전체 글 236

WIL - 9주차 (같은 best-effort라도, 어떤 방향으로 깨지는지가 설계다)

이번 주에 새로 배운 것"best-effort니까 괜찮다"는 설계가 아니다Kafka Consumer에서 DB에 메트릭을 적재하는 기존 파이프라인에 Redis ZSET 랭킹 점수를 추가해야 했다. try-catch로 감싸면 Redis가 죽어도 DB 트랜잭션은 안 깨진다. "best-effort니까 이 정도면 충분하지 않을까?"이 판단이 틀렸다. 같은 best-effort인데 ZINCRBY를 TX 안에 넣느냐, TX 커밋 후에 넣느냐에 따라 결함의 방향이 달랐다.TX 안에서 ZINCRBY → TX COMMIT 실패 시 → Redis에는 반영됨, DB에는 안 됨 → 재처리 시 → double increment (over-count)TX 커밋 후 ZINCRBY → COMMIT 성공 ..

스터디/루퍼스 2026.04.11

best-effort니까 괜찮지 않나? Kafka 랭킹 파이프라인에 afterCommit 대신 배치 구조를 선택한 이유

best-effort니까 괜찮지 않나? Kafka 랭킹 파이프라인에 afterCommit 대신 배치 구조를 선택한 이유TL;DR:MySQL과 Redis에 동시에 쓰는 dual-write 문제에서, 이 랭킹 시나리오에서는 "가짜 인기를 만드는 것"보다 "인기를 살짝 놓치는 것"이 낫다고 판단해 at-most-once를 선택했다. afterCommit 패턴을 검토했지만 배치 최적화를 막는 구조적 한계를 발견하고, Consumer를 분리해 배치 수집 + Pipeline flush 구조를 설계했다. 정합성 모델은 유지하면서 Redis 네트워크 왕복을 건별 호출에서 배치 2회로 줄인 과정.시작: 기존 파이프라인에 ZINCRBY 적용이전에 Kafka 기반 이벤트 파이프라인을 구축해둔 상태였다. 상품 조회·좋아요·주..

운영/Kafka & MQ 2026.04.10

Redis 키 설계 전략이 중요한 이유 시간의 양자화와 롱테일

TL;DR: 랭킹 ZSET의 키 하나가 "무엇을 측정하는가"를 결정한다. 누적 키는 롱테일을 만들고, 일간 키는 콜드 스타트를 만든다. 키를 자르는 순간 정보가 손실되고, 그 손실을 carry-over와 fallback으로 메운다. 키 설계는 네이밍이 아니라 데이터 모델링이다.랭킹 키 하나의 무게이커머스 인기 상품 랭킹을 만들면서 가장 먼저 마주친 질문은 이거였다.ranking:{productId} → score 이 ZSET의 키 이름을 어떻게 지을 것인가.처음에는 네이밍 문제라고 생각했다. ranking:all이든 ranking:daily든, 어차피 ZINCRBY로 점수를 올리는 건 같으니까. 키 이름은 규칙만 맞추면 되는 거 아닌가?이 생각이 틀렸다. 키 이름이 결정하는 건 "어디에 저장하는가"가 아..

운영 2026.04.10

@Transactional이 삼킨 커넥션, BCrypt가 놓아주지 않은 150ms

@Transactional(readOnly = true)를 습관적으로 붙이면 안전한 줄 알았다. 그런데 인증 메서드 안에 BCrypt가 있으면, DB가 아무 일도 안 하는 150ms 동안 커넥션을 잡고 놓아주지 않는다. 커넥션 40개짜리 풀에서 동시 30명이 인증하면, 다른 API는 커넥션을 기다리다 죽는다. 어노테이션 한 줄 지우는 것이 성능 최적화의 전부였다.발견: "readOnly면 가볍다"는 착각이커머스 프로젝트에서 인증 메서드를 작성하면서, 읽기 전용 메서드에는 습관적으로 @Transactional(readOnly = true)를 붙였다. 읽기 전용이면 flush도 안 하고, 스냅샷도 안 만들고, 최적화만 해주는 거라고 알고 있었으니까.그런데 부하 테스트 시나리오를 설계하면서 커넥션 풀 사용률을..

SSE를 실무에 도입하면서 마주친 것들 Polling에서 SSE로, 다시 Polling으로

처음에는 Polling이 싫었다법률 번역 플랫폼을 만들 때의 일이다. 법률 문서를 업로드하면 AI가 문단별로 번역하는 시스템이었다. 원래 구조는 단순했다. 클라이언트가 5초마다 번역 상태를 폴링하고, 서버는 DB에서 현재 진행률을 읽어서 응답한다.// 원래 구조: 5초 폴링@GetMapping("/translation/{taskId}/status")public TranslationStatus getStatus(@PathVariable String taskId) { return translationRepository.findStatus(taskId);} 문제가 세 가지 있었다.첫째, 5초 간격이 너무 느렸다. 번역이 문단 단위로 진행되는데, 문단 하나가 1~2초면 끝난다. 그런데 폴링이 5초라 진행률..

운영 2026.04.03

ZADD vs ZADD NX 대기열에서 멱등성이 UX를 결정한다

플래그 하나가 사용자의 대기 순서를 바꾼다대기열 진입 API를 처음 구현했을 때, 나는 별 생각 없이 ZADD를 썼다. Redis Sorted Set에 사용자 ID를 멤버로, 현재 타임스탬프를 스코어로 넣는 단순한 구조였다.redisTemplate.opsForZSet().add("queue:waiting", userId, System.currentTimeMillis()); 동작은 했다. 대기열에 들어가고, 스코어(타임스탬프) 기준으로 정렬되고, ZPOPMIN으로 먼저 들어온 사용자부터 빠지는 구조가 완성됐다.그런데 테스트 중에 이상한 일이 발생했다. 대기열 순번이 50번이던 사용자가 브라우저를 새로고침한 뒤 150번으로 밀려나 있었다. "분명 50번이었는데" 하는 사용자의 당혹감은 서비스 신뢰도를 순간적..

운영 2026.04.03

토큰 TTL 하나로는 부족하다. Access, Activity, Hard Limit 3계층 설계

TTL 180초의 딜레마대기열을 통과한 사용자에게 토큰을 발급할 때, TTL을 몇 초로 설정할지가 생각보다 어려운 문제였다.처음에는 단순하게 180초(3분)로 잡았다. "결제까지 3분이면 되지 않나?" 그런데 실제 이커머스 사용자의 행동 패턴을 떠올려보니, 이게 단순하지 않았다.TTL = 60초? → 쿠폰을 고르다가 90초째에 토큰이 만료. 처음부터 다시 대기열.TTL = 300초? → 결제를 포기하고 탭을 닫은 사용자가 5분 동안 자리를 차지.TTL = 180초? → 둘 다 완벽히 해결하지 못하는 절충안. 핵심 테제: 단일 TTL은 "활발한 사용자"와 "이탈한 사용자"를 구분할 수 없다. 이 두 가지를 동시에 다루려면 TTL을 계층화해야 한다.이커머스 사용자 행동 스펙트럼토큰 TTL을 설계하려면, 먼..

대기열 폴링 최적화 동적 간격으로 QPS 68%를 줄이다

10,000명이 1초마다 물어본다대기열을 구현하고 나서, 처음 마주한 질문은 "사용자가 자기 순번을 어떻게 알지?"였다. 가장 직관적인 답은 폴링이다. 클라이언트가 주기적으로 서버에 "내 순번 몇 번이야?"를 묻는 것.문제는 숫자에 있다.10,000명 대기 중 x 1초마다 요청 = 10,000 QPSRedis가 처리 못 하는 수준은 아니지만, 99%는 낭비다.9,500번째 사용자가 매초 순번을 확인해봐야 "9,498번째요"가 "9,497번째요"로 바뀌는 것뿐이다. 솔직히 처음에는 "Redis가 초당 10만 연산 처리하니까 10,000 QPS쯤이야"라고 생각했다. 그런데 이건 Redis만의 문제가 아니다. 10,000 QPS면 Spring 서버도 초당 10,000건의 HTTP 요청을 받아야 하고, Hika..

운영 2026.04.03

@Scheduled가 3대에서 돌면 생기는 일: 분산 환경 스케줄러의 함정

테스트에서 사라진 30명동시성 테스트를 돌리고 있었다. 100명이 대기열에 등록하고, 전원이 활성화된 뒤 주문하는 시나리오였다. 그런데 결과가 계속 70명만 성공했다.100 - 70 = 30. 배치 사이즈가 30이었다.처음에는 동시성 문제를 의심했다. 락 충돌인가? 커넥션 풀 부족인가? 한참을 뒤지다가 원인을 찾았다. @SpringBootTest가 @Scheduled 빈을 자동으로 시작하고, 테스트 실행 중에 스케줄러가 대기열에서 30명을 꺼내가고 있었다. 테스트가 100명을 등록하고 읽으려는 사이에 스케줄러가 30명을 소비해버린 것이다.이건 로컬에서의 문제였지만, 프로덕션에서는 더 심각한 버전이 존재한다. 서버가 3대면 스케줄러도 3개다.@Scheduled의 본질: JVM 로컬 타이머@Scheduled..

Fail-Open vs Fail-Closed: Redis가 죽으면 매출을 포기할 것인가

Redis가 죽는 순간을 상상해본 적 있는가대기열 시스템을 만들고 나서, 나는 한 가지 질문에 부딪혔다. "Redis가 죽으면 어떻게 하지?"이커머스 대기열의 모든 흐름이 Redis를 경유한다. 대기열 등록은 Sorted Set에 ZADD, 활성화는 ZPOPMIN, 토큰 검증은 GET. Redis가 응답하지 않으면 아무도 주문할 수 없다.처음에 나는 당연히 "Redis가 죽으면 주문을 막아야지"라고 생각했다. 무결성이 깨질 수 있으니까. 그런데 이 "당연한" 선택이 비즈니스 관점에서 정말 당연한지 곰곰이 따져보니, 그렇지 않았다.결론부터 말하면: 이커머스에서는 Fail-Open을 선택했고, RateLimiter의 허용 수치를 배치 사이즈 M=30과 동일하게 설정한 것이 설계의 핵심이다.Fail-Close..

아키텍처 2026.04.03

Fixed-Rate vs Concurrency-Based 대기열 활성화 전략을 도메인이 결정한다

처음에 당연하다고 생각했던 것대기열 시스템을 구현하면서, 나는 처음에 "활성 사용자 수를 모니터링하다가 자리가 비면 새 사용자를 넣으면 되지 않나?"라고 생각했다. 은행 번호표처럼. 누군가 창구를 떠나면 다음 사람을 부르는 방식. 직관적이고 자연스럽다.그런데 실제로 이커머스 주문 대기열에 이 방식을 적용하려고 하니, 생각보다 근본적인 문제가 드러났다. 결론부터 말하면: 대기열 활성화 전략은 기술적 우열이 아니라 도메인의 체류 시간 특성이 결정한다.두 가지 전략의 본질대기열에서 대기 중인 사용자를 활성 상태로 전환하는 방식은 크게 두 가지다.Fixed-Rate (고정 속도 방식)매 스케줄러 주기마다 정확히 M명을 활성화한다. 현재 활성 사용자가 몇 명인지는 보지 않는다. 놀이공원 입장 게이트처럼 — 안에 ..

아키텍처 2026.04.03

Rate Limiting 알고리즘 전체 지도 - Token Bucket부터 Sliding Window까지

처음에 30이라는 숫자만 있었다대기열 시스템의 Fail-Open 폴백을 설계하면서 Resilience4j의 RateLimiter를 도입했다. Redis가 죽었을 때 DB를 보호하기 위한 마지막 방어선이었고, 설정은 이랬다.RateLimiterConfig.custom() .limitForPeriod(30) // 1초에 30개 .limitRefreshPeriod(Duration.ofSeconds(1)) .timeoutDuration(Duration.ZERO) // 대기 없이 즉시 거부 .build(); 30이라는 숫자는 DB TPS에서 역산한 값이다. HikariCP 풀 10개, 주문 처리 약 200ms, 그래서 초당 처리 가능한 요청이 대략 50개. 여기서 안전 마진을..

아키텍처 2026.04.03

WIL - 8주차 (개선은 문제를 정확히 말하는 데서 시작된다)

이번 주에 새로 배운 것"왜 30인지" 설명할 수 있어야 설계다대기열 시스템을 구현하면서 스케줄러의 배치 크기 M을 정해야 했다. 처음에는 "30이면 적당하지 않을까?"라고 감으로 잡으려 했다. 하지만 왜 30인지, 25가 아니고 50이 아닌 이유를 설명할 수 있어야 비로소 설계라고 부를 수 있다.DB 커넥션 풀(10개)에서 출발해서 커넥션 점유 시간(200ms)으로 이론 TPS(50)를 구하고, 안전 마진 60%를 적용해서 실효 TPS 30을 얻었다. 이 값이 스케줄러 주기 1초와 만나서 M=30이 됐다.숫자에 근거가 생기니까 달라지는 게 있었다. "놀이공원식(Fixed-Rate)이 왜 맞는가"에 대해 M이 TPS 기반이므로 누적 위험이 보수적으로 통제된다고 설명할 수 있었고, Redis 장애 시 Ra..

스터디/루퍼스 2026.04.03

대기열 배치 크기를 방정식으로 산정하기까지

대기열 스케줄러의 배치 크기를 "감"이 아닌 "방정식"으로 산정하기까지TL;DR대기열 스케줄러가 1초마다 몇 명을 활성화할지를 정하는 건 감이 아니라 역산이다. DB 커넥션 풀에서 출발해서 TPS를 구하고, 안전 마진을 적용하면 "왜 이 값인지"를 설명할 수 있는 숫자가 나온다. 그리고 이 숫자가 방정식에서 나왔다는 사실이, 설계 판단을 설명 가능하게 만드는 핵심 근거가 된다.대기열이 보호하는 것쇼핑몰에서 블랙프라이데이를 한다고 가정해보자. 평소에 초당 100명 정도가 접속하던 사이트에 갑자기 10만 명이 동시에 몰린다. 전원이 "주문하기" 버튼을 누른다.서버가 요청을 받는 것 자체는 가능하다. Tomcat은 수천 개의 동시 연결을 처리할 수 있다. 문제는 주문을 처리하는 과정에서 생긴다. 주문을 처리하..

운영 2026.04.03

WIL - 7주차 (이론값과 실측값 사이에서 자기 생각을 만드는 법)

이번 주에 새로 배운 것"교과서대로 했는데 안 됐다"가 가장 많이 가르쳐줬다이번 주 루퍼스 과제는 EDA + Kafka Outbox Pipeline이었다. 핵심 트랜잭션과 부가 로직을 분리하고, Outbox Pattern으로 시스템 간 이벤트를 신뢰성 있게 전달하는 것.교과서적 답은 알고 있었다. @TransactionalEventListener(AFTER_COMMIT)으로 TX 커밋 후 이벤트를 발행하면 된다. 그래서 AFTER_COMMIT에서 Outbox에 저장했다. 실행되지 않았다. TransactionTemplate.execute() 반환 시점에 TX가 이미 끝나서, 리스너가 바인딩할 TX 컨텍스트가 없었다.그래서 BEFORE_COMMIT으로 바꿨다. 동작했다. 하지만 "주문 확정 이벤트"가 확정..

스터디/루퍼스 2026.03.29

server.shutdown graceful을 설정했는데 왜 @Scheduled는 안 기다려줄까

server.shutdown: graceful을 설정했는데, 왜 @Scheduled는 안 기다려줄까Spring Boot에서 Graceful Shutdown을 설정하면 "안전하게 종료된다"고 생각하기 쉽다. 하지만 이 설정이 실제로 보호하는 영역과 보호하지 않는 영역은 다르다. Outbox Relay를 운영하면서 발견한 5가지 빈틈과, "왜 그런 구조인가"를 Spring 내부 코드 레벨에서 추적한다.발단: "왜 Shutdown 시 Phase 1이 새로 트리거되지?"Outbox Relay를 구현하면서 Graceful Shutdown을 직접 만들었다. @PreDestroy에서 shuttingDown 플래그를 세우고, Phase 2 완료를 기다리고, 미완료 이벤트를 복원한다.@PreDestroypublic vo..

운영/Kafka & MQ 2026.03.29

Outbox Pipeline 성능 테스트 이론값 500건초의 실체를 파헤치다

Outbox Pipeline 성능 테스트 — 이론값 "500건/초"의 실체를 파헤치다PR에 적은 이론값과 실측값의 격차는 부끄러운 게 아니라, 시스템을 이해한 증거다.도입: 왜 성능 테스트를 해야 하는가PR에 이런 수치를 적었다:PR 주장 값발행 지연평균 0.5초 (최대 1초)최대 처리량500건/초Phase 1 + Phase 21초 안에 끝남이 수치를 측정 없이 "이론값"으로만 두면, 멘토가 "진짜야?"라고 물었을 때 답할 수 없다."이론상 이만큼 나와야 합니다"와 "실측해봤더니 이만큼 나왔습니다. 격차의 원인은 이것입니다"는 신뢰도가 완전히 다르다.Outbox Pattern이란 — 왜 필요한가Dual Write Problem주문이 확정되면 Kafka에 이벤트를 발행해야 한다. 가장 자연스러운 구현:@T..

운영/Kafka & MQ 2026.03.29

Kafka Streams로 실시간 집계하면 Consumer보다 뭐가 좋은가

Kafka Streams로 실시간 집계하면 Consumer보다 뭐가 좋은가현재 상품 메트릭을 Consumer에서 건건이 DB UPDATE하고 있다. Kafka Streams의 KTable aggregation으로 바꾸면 뭐가 달라지는가? 실제 코드를 비교하고 트레이드오프를 분석한다.현재 구현: Consumer에서 건건이 DB UPDATE// CatalogMetricsProcessor.java (현재)@Transactionalpublic boolean process(String eventType, String outboxId, String payload) { if (eventHandledRepository.existsByEventId(outboxId)) return false; switch (e..

운영/Kafka & MQ 2026.03.29

Polling에서 CDC로 전환해야 하는 시점 - 135건초의 의미

"CDC가 좋다"는 말은 많이 들었다. 하지만 언제 전환해야 하는가? "처리량이 부족할 때"라는 답은 너무 모호하다. 성능 테스트에서 측정한 135건/초가 그 기준선이 되었다.Polling과 CDC — 같은 문제, 다른 접근둘 다 Outbox 테이블의 이벤트를 Kafka로 전달하는 방법이다.Polling (현재 구현)애플리케이션 → SELECT ... FOR UPDATE SKIP LOCKED → Kafka send → markPublished애플리케이션이 주기적으로 DB를 조회하여 이벤트를 가져간다. 간단하고 인프라 추가 없이 구현 가능.CDC (Change Data Capture)DB binlog → Debezium Connector → Kafka → (Outbox 변환)DB의 변경 로그(binlog/..

운영/Kafka & MQ 2026.03.29

FOR UPDATE SKIP LOCKED 큐잉 시스템의 숨은 주역

SELECT ... FOR UPDATE는 대부분 알고 있다. 하지만 SKIP LOCKED를 아는 개발자는 드물다. Outbox Relay에서 멀티 인스턴스 중복 발행을 방지하는 핵심이 바로 이 2단어다.문제: 같은 이벤트를 두 인스턴스가 동시에 발행한다Outbox Relay는 outbox_event 테이블을 폴링하여 Kafka에 발행한다. 인스턴스가 1개면 문제 없다. 하지만 2개 이상이면:인스턴스 A: SELECT * FROM outbox_event WHERE status = 'PENDING' LIMIT 500인스턴스 B: SELECT * FROM outbox_event WHERE status = 'PENDING' LIMIT 500→ 같은 500건을 조회! → 같은 이벤트를 Kafka에 두 번 발행!F..

운영/Kafka & MQ 2026.03.29

선착순 쿠폰에 락을 안 건다고? Kafka 파티션 순차 처리의 실체

비관적 락으로 선착순 쿠폰을 구현했다가, Kafka 파티션 순차 처리로 전환했다. 락이 없는데 어떻게 동시성을 보장하는가? 파티션 내부에서 실제로 벌어지는 일을 추적한다.원래 구현: 비관적 락@Transactionalpublic void issue(Long templateId, Long userId) { CouponTemplate template = couponTemplateRepository .findByIdForUpdate(templateId); // SELECT ... FOR UPDATE if (template.getRemainingQuantity() 100명이 동시에 요청하면:99명은 FOR UPDATE에서 대기 (행 잠금)1명씩 순차적으로 잔여 수량 확인 → 발급 → 락..

운영/Kafka & MQ 2026.03.29

멱등성에 DB를 쓰면 느리지 않나 event_handled의 10.9ms가 의미하는 것v

"멱등성 체크에 왜 Redis가 아니라 DB를 쓰나요?" 이 질문에 답하려면 10.9ms의 의미와, Redis SET NX가 숨기고 있는 함정을 알아야 한다.왜 멱등성이 필요한가Kafka Consumer는 at-least-once 전달을 기본으로 한다. 네트워크 장애, 리밸런싱, Consumer 재시작 시 같은 메시지를 두 번 이상 받을 수 있다.Broker → Consumer: 메시지 전달Consumer: 처리 완료, ACK 전송 시도네트워크 끊김: ACK 미도달Broker: "ACK 안 왔네, 다시 보내자"Consumer: 같은 메시지를 또 받음멱등성이 없으면:좋아요 수가 이중으로 증가 (비멱등 연산)쿠폰이 이중으로 발급포인트가 이중으로 적립현재 구현: DB event_handled 테이블@Trans..

운영/Kafka & MQ 2026.03.29

비동기 콜백에서 @Transactional이 안 먹는 이유 스레드가 바뀌면 TX도 끊긴다

Outbox Relay에서 Kafka 발행을 비동기로 바꿨더니 markPublished()가 DB에 반영되지 않았다. 별도 랩 프로젝트에서 발견한 이 버그가 동기 .get() 전환의 결정적 근거가 됐다.발단: 비동기가 당연히 더 빠르니까Outbox Relay의 Phase 2는 Kafka에 이벤트를 발행한다. 처음에는 비동기 방식으로 구현했다:kafkaTemplate.send(producerRecord).whenComplete((result, ex) -> { if (ex == null) { event.markPublished(); // 상태 변경 metrics.recordPublishSuccess(); } else { event.markFailed..

운영/Kafka & MQ 2026.03.29

@TransactionalEventListener(AFTER_COMMIT)에서 Outbox를 저장하면 왜 안 되는가

교과서대로 했다. "TX 커밋 후 이벤트를 발행하라." 그래서 AFTER_COMMIT에서 Outbox를 저장했다. 그런데 실행되지 않았다. 왜?배경: Outbox Pattern의 핵심 요구사항Outbox Pattern의 전제는 단순하다:비즈니스 로직 + Outbox INSERT → 같은 TX → 원자적DB 커밋이 성공하면 Outbox에도 이벤트가 있고, 실패하면 둘 다 없다. 이 원자성이 Dual Write Problem을 해결하는 핵심이다.그런데 이벤트를 "언제" 저장하느냐에 따라 이 원자성이 깨진다.첫 번째 시도: AFTER_COMMIT에서 Outbox 저장Spring의 @TransactionalEventListener는 TX 생명주기에 바인딩되는 이벤트 리스너다.@TransactionalEventL..

운영/Kafka & MQ 2026.03.29

멱등성에 DB를 쓰면 느리지 않나? event_handled의 10.9ms가 의미하는 것

"멱등성 체크에 왜 Redis가 아니라 DB를 쓰나요?" 이 질문에 답하려면 10.9ms의 의미와, Redis SET NX가 숨기고 있는 함정을 알아야 한다.왜 멱등성이 필요한가Kafka Consumer는 at-least-once 전달을 기본으로 한다. 네트워크 장애, 리밸런싱, Consumer 재시작 시 같은 메시지를 두 번 이상 받을 수 있다.Broker → Consumer: 메시지 전달Consumer: 처리 완료, ACK 전송 시도네트워크 끊김: ACK 미도달Broker: "ACK 안 왔네, 다시 보내자"Consumer: 같은 메시지를 또 받음멱등성이 없으면:좋아요 수가 이중으로 증가 (비멱등 연산)쿠폰이 이중으로 발급포인트가 이중으로 적립현재 구현: DB event_handled 테이블@Trans..

운영/Kafka & MQ 2026.03.29

Consumer에서 self-invocation을 발견하기까지 @Transactional이 무시되는 구조

Kafka Consumer 안에서 @Transactional 메서드를 호출했는데, 트랜잭션이 안 걸렸다. 원인은 Spring AOP의 가장 유명한 함정 — self-invocation이었다.발단: 쿠폰 발급은 됐는데 event_handled가 안 남았다선착순 쿠폰 Consumer를 테스트하던 중 이상한 현상을 발견했다:쿠폰은 정상 발급됨하지만 event_handled 테이블에 기록이 남지 않음같은 이벤트가 다시 오면 중복 발급@Transactional로 감싸서 쿠폰 발급 + event_handled 저장을 원자적으로 처리했는데, 왜 event_handled만 빠졌는가?원인: self-invocation — 같은 클래스 내부 호출은 프록시를 우회한다문제가 된 코드 (리팩토링 전)@Componentpubli..

운영/Kafka & MQ 2026.03.29

Outbox Relay 최적화, 10건에서 155건으로

Outbox Relay 최적화: 10건/초에서 155건/초까지TL;DROutbox Relay의 초기 구현은 10건/초였다. PROCESSING 상태 추가 + FOR UPDATE SKIP LOCKED + parallelStream으로 155건/초까지 개선했다. 부하 테스트로 병목이 Phase 2(Kafka 동기 발행)임을 확인했고, 5분 PROCESSING 복구 threshold가 충분히 안전한 것을 수치로 증명했다.1. 초기 구현의 한계 — 왜 10건/초인가[이전 글](커밋 후 발행 vs 발행 후 커밋)에서 Outbox Pattern을 "왜" 선택했는지 다뤘다.이 글은 그 다음 질문에 답한다. Outbox를 선택했으면, 처리량은 어떻게 끌어올리는가?초기 구현은 단순했다.5초 간격 폴링 × 50건 배치 =..

운영/Kafka & MQ 2026.03.28

트래픽이 몰려도 데이터를 잃지 않는 Kafka 파이프라인 설계

트래픽이 몰려도 데이터를 잃지 않는 Kafka 파이프라인 설계TL;DR100명이 동시에 선착순 쿠폰을 요청하면, 10장만 정확히 발급되어야 한다. 11장도 안 되고, 9장도 안 된다. 이 글에서는 이커머스 프로젝트에서 Kafka 파이프라인을 설계하고 구현하면서 마주한 실제 문제들 — Topic 설계, Producer/Consumer 전략, 장애 시나리오와 방어 메커니즘 — 을 다룬다. 이론이 아니라 직접 깨지고 고친 경험이다.1. 5개 토픽, 각각 다른 이유로 존재한다토픽은 "메시지를 담는 곳"이 아니라 "도메인 경계"다토픽을 하나로 합치고 eventType 헤더로 구분하면 안 되나? 기술적으로는 가능하다. 하지만 Consumer Group이 토픽 단위로 묶이기 때문에, 하나의 토픽에 서로 다른 도메인의..

운영/Kafka & MQ 2026.03.28

커밋 후 발행 vs 발행 후 커밋: 둘 다 틀렸다

커밋 후 발행 vs 발행 후 커밋: 왜 둘 다 충분하지 않은가TL;DRDB에 쓰고, Kafka에 쓴다. 이 두 쓰기를 원자적으로 묶을 수 없다면? 하나만 쓰고 나머지는 비동기로 전파한다. Transactional Outbox Pattern은 이벤트를 DB에 함께 저장하여 단일 트랜잭션으로 원자성을 확보하고, Relay가 비동기로 Kafka에 발행하는 구조다. 이 글에서는 "커밋 후 발행"과 "발행 후 커밋"이 왜 근본적으로 불완전한지 분석하고, Outbox Pattern으로 해결한 실제 구현과 부하 테스트 결과를 공유한다.1. 이벤트 발행, 언제 해야 하는가?주문이 생성되면 재고를 감소시켜야 한다. Event-Driven Architecture(EDA)에서는 주문 서비스가 재고 서비스를 직접 호출하는 대..

운영/Kafka & MQ 2026.03.27

WIL - 6주차 (모르는 것을 모른다고 말할 수 있게 되기까지)

이번 주에 새로 배운 것"모른다"를 설계에 담는 법이번 주 루퍼스 과제는 PG 연동이었다. 결제 요청을 보내고 응답이 안 오면 어떻게 할 것인가. 처음엔 단순하게 생각했다. 타임아웃이 나면 실패로 처리하고 롤백하면 되지 않나.틀렸다. 타임아웃은 실패가 아니다. "모른다"는 뜻이다. PG가 요청을 아예 못 받았을 수도 있고, 받아서 승인까지 했는데 응답만 유실됐을 수도 있다. 이 세 가지 가능성을 구분할 수 없는 상태에서 "실패"로 단정하면, 사용자 카드에서는 돈이 빠졌는데 주문은 취소되는 사고가 난다. 반대로 "성공"으로 단정하면, 돈을 안 받았는데 상품이 나간다.결국 UNKNOWN이라는 상태를 만들었다. "아직 모른다"를 명시적으로 표현하는 상태다. 모르면 행동하지 않고, 알아낸 다음에 행동한다. 이 ..

스터디/루퍼스 2026.03.22