2026/03 28

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

타임아웃은 실패가 아니다 외부 API 연동 다루는 법

들어가며PG사 결제 API를 연동하면서 가장 먼저 마주친 질문은 이거였다."결제 요청을 보냈는데 응답이 안 오면, 실패로 처리하면 되지 않나?"직관적으로 맞는 것 같았다. 타임아웃이 나면 실패인 거고, 실패면 롤백하면 된다고 생각했다.틀렸다.타임아웃은 실패가 아니다. "모른다"는 뜻이다. 이 한 줄을 이해하는 데 설계 전체가 바뀌었고, 그 과정에서 내가 세운 원칙이 하나 있다.응답을 받았으면 결과를 안다. 못 받았으면 모른다. 모르면 행동하지 말고, 알아낸 다음에 행동한다.이 글은 이 원칙을 발견하기까지의 과정이다.1. 왜 타임아웃이 실패가 아닌가DB와 PG는 근본적으로 다르다. DB 트랜잭션은 하나의 프로세스 안에서 ACID를 보장하지만, PG 연동은 네트워크를 사이에 둔 분산 트랜잭션(distribu..

운영 2026.03.20

느려진 서비스의 본질은 코드가 아니라 구조에 있다

"주니어 백엔드 개발자가 반드시 알아야 할 실무 지식" 2장·3장을 읽고, CS 개념과 실무 경험을 엮어 정리한 글입니다.들어가며서비스가 느려지면 가장 먼저 떠오르는 생각은 "코드를 최적화해야 하나?"이다. for문을 줄이고, 알고리즘을 개선하고, 불필요한 연산을 없애는 것. 물론 중요하다. 하지만 실무에서 마주치는 대부분의 성능 문제는 코드의 계산 복잡도가 아니라 I/O 경계에서 발생한다.이 책의 2장과 3장은 그 경계를 정확히 짚는다. DB 연동, 외부 API 호출, 커넥션 풀 설정, 캐시 전략, 인덱스 설계. 이것들이 실제 서비스 응답 시간의 70~90%를 지배한다는 사실을 데이터로 보여준다.이 글에서는 책에서 다루는 핵심 키워드들을 CS 관점에서 깊이 있게 풀어보고, 내가 실무에서 겪었던 경험을 ..

도서 2026.03.15

인덱스를 만들었는데 왜 안 타죠 ? 옵티마이저의 비용 계산을 파헤치다

인덱스를 만들었는데 왜 안 타죠? — 옵티마이저의 비용 계산을 파헤치다인덱스를 걸었는데 풀스캔이 나왔다프로젝트에서 Soft Delete를 쓰고 있었다. 상품 조회 쿼리에 WHERE deleted_at IS NULL 조건이 붙는데, deleted_at에 인덱스를 걸어봤자 EXPLAIN을 찍으면 type: ALL — 풀 테이블 스캔이 나온다.인덱스가 분명히 있는데 왜 안 쓸까? "선택도가 낮아서"라는 답은 알고 있었지만, 왜 선택도가 낮으면 풀스캔이 더 나은 건지 — 그 물리적인 이유를 몰랐다.이번에 옵티마이저의 비용 계산 구조를 파면서, 그 "왜"를 찾았다.옵티마이저 — SQL의 "어떻게"를 결정하는 두뇌SQL을 던지면 MySQL이 바로 실행하는 게 아니다. 3단계를 거친다.SQL 입력 → [1] Par..

데이터베이스 2026.03.14

WIL - 5주차 ("왜?"를 멈추지 않았더니 보이기 시작한 것들)

이번 주에 새로 배운 것"왜?"라는 질문이 깊이를 만든다이번 주는 유독 "왜?"를 많이 물었다. 복합 인덱스를 공부하다가 "카디널리티가 높은 컬럼을 앞에 놓으라"는 규칙을 만났다. 예전 같았으면 그대로 외웠을 것이다. 그런데 이번엔 "왜?"를 던져봤다. 왜 카디널리티가 높으면 앞이어야 하지? B+Tree에서 실제로 어떤 차이가 생기지? 등호 조건과 범위 조건이 섞이면 어떻게 되지? 파고 들어가니까 답이 달라졌다. 카디널리티보다 등호 조건이 먼저라는 게 진짜 규칙이었다. 카디널리티가 아무리 높아도 범위 조건이면 그 뒤 컬럼은 인덱스를 못 탄다. B+Tree의 리프 노드가 정렬되는 방식을 이해하고 나니, "왜 그런지"가 보였다. 외운 규칙이 아니라 원리에서 나온 판단이 되니까, 새로운 상황을 만나도 스스로 ..

스터디/루퍼스 2026.03.13