운영 37

한 번에 끝낼 작업에 청크를 선택했다: 배치 프레임워크를 잘못 고른 비용

TL;DR한 번에 집계해서 교체해야 하는 작업에 청크 기반 처리를 선택하면서, 프레임워크의 장점은 살리지 못하고 복잡성만 늘어났다. 그 과정에서 도구의 형식보다 문제의 본질에 맞는 선택이 더 중요하다는 것을 배웠다.이번 글에서 다루는 문제이커머스 서비스에서 “이번 주 인기 상품 TOP 100”을 만들어야 했다.사용자가 상품을 조회하거나, 좋아요를 누르거나, 주문할 때마다 이벤트가 쌓인다. 이 이벤트를 주 단위로 모아 상품별 점수를 계산하고, 점수가 높은 순서대로 상위 100개 상품을 저장하면 된다.이미 데이터베이스에는 ranking_event 테이블이 있었다.이 테이블에는 “어떤 상품에 어떤 행동이 언제 발생했는지”라는 원본 이벤트 데이터가 저장돼 있었다. 이 데이터를 다시 읽어서 원하는 방식으로 점수를..

운영 2026.04.17

테스트는 전부 통과했는데 배치가 빈 테이블을 만든 이유

Spring Batch 실전에서 만난 3가지 함정한 줄 요약:Spring Batch로 주간 랭킹 집계를 만들고, 테스트 4개를 짰고, 전부 통과했다. 그런데 실제로 돌리면 결과 테이블이 매번 비어있었다. 원인을 추적하니, 테스트 자체가 3개의 치명적 결함을 구조적으로 숨기고 있었다. 이 글은 그 3가지 함정을 발견하고 수정한 기록이다.배경: 뭘 만들었나이커머스 서비스에서 "이번 주 인기 상품 TOP 100" 같은 랭킹을 보여주려면, 사용자의 행동(상품 조회, 좋아요, 주문)을 모아서 점수를 매기고, 높은 순서대로 정렬해야 한다.기존에는 실시간 일간 랭킹이 있었다. 사용자가 상품을 조회하면 Kafka(메시지 큐)를 통해 이벤트가 전달되고, Consumer(이벤트 수신기)가 이를 받아 Redis라는 인메모리..

운영 2026.04.17

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

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

대기열 폴링 최적화 동적 간격으로 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

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

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

운영 2026.04.03

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