2026/03/29 12

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

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

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..

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 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..

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/..

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 파티션 순차 처리의 실체

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

멱등성에 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..

비동기 콜백에서 @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..

@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..

멱등성에 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..

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

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