들어가며
개인 프로젝트 bowchat은 처음부터 MSA로 시작한 프로젝트가 아니었다. 처음에는 모놀리식으로 시작했고, 이후에 MSA로 전환했다.
단순히 서비스를 잘게 쪼개는 것 자체가 목적은 아니었다. Kafka를 제대로 써보고 싶었고, 서비스 간 데이터 정합성 문제를 직접 겪어보면서 풀어보고 싶었다. 그 과정에서 가장 먼저 부딪힌 문제 중 하나가 경매 서비스의 입찰 흐름이었다.
실시간 경매에서 입찰은 단순히 숫자 하나를 바꾸는 요청이 아니다.
- 현재 최고 입찰가가 바뀌고
- 입찰 이력이 남아야 하고
- 다른 참여자 화면에도 거의 실시간으로 반영되어야 한다
처음 구현은 단순했다. 입찰이 성공하면 같은 흐름 안에서 바로 Kafka로 이벤트를 발행했다.
입찰 처리 흐름은 AuctionBidService와 AuctionController에서 볼 수 있다.
처음에는 이 구조가 가장 자연스럽게 보였다. 그런데 조금만 생각해 보면 바로 불안한 지점이 보인다.
입찰 저장은 끝났는데, 그 다음 Kafka 발행이 실패하면 어떻게 될까?
이번 글은 그 질문에서 시작해서, 왜 @TransactionalEventListener(AFTER_COMMIT) 대신 Outbox 패턴을 선택했는지 정리한 회고다.
문제를 풀어간 과정
처음에는 "저장하고 바로 보내면 되지 않나"라고 생각했다
기존 흐름은 대략 이랬다.
입찰 요청
-> 경매 검증
-> 현재가 갱신
-> 입찰 이력 저장
-> Kafka 이벤트 발행
이 방식의 장점은 분명했다.
- 이해하기 쉽고
- 코드 흐름도 짧고
- 입찰 성공 직후 바로 브로드캐스트할 수 있다
하지만 이 구조는 중요한 사실 하나를 가리고 있었다. DB 저장은 로컬 트랜잭션 안에서 일어나지만, Kafka 발행은 결국 외부 시스템 호출이라는 점이다.
그래서 입찰 저장은 성공했는데 Kafka 발행 직전에 장애가 나서, DB에는 입찰이 반영됐지만 이벤트는 전달되지 않는 상황이 충분히 생길 수 있었다.
입찰 저장 성공
-> Kafka 발행 직전 장애
-> DB에는 입찰이 반영됨
-> 하지만 이벤트는 전달되지 않음
이 순간부터 문제는 단순한 예외 처리가 아니라 정합성 문제가 된다.
사용자 기준으로 보면 입찰은 성공했다. 하지만 다른 참여자 화면에는 그 사실이 전파되지 않을 수 있다.
처음에는 이런 문제를 막연하게 "비동기 처리에서 생길 수 있는 이슈" 정도로만 봤다.
그런데 구조를 다시 보니, 이건 비동기 자체의 문제가 아니라 저장과 발행 사이에 복구 기준이 없다는 문제였다.
이번에 먼저 풀어야 했던 건 멱등성보다 원자성이었다
분산 시스템 이야기를 하다 보면 멱등성과 원자성이 같이 자주 등장한다. 나도 처음에는 "이벤트가 중복 발행되면 어떻게 하지?" 같은 쪽을 먼저 떠올렸다.
그런데 이번 입찰 흐름에서 먼저 문제였던 건 그게 아니었다.
- 입찰 저장은 성공했다
- 입찰 이력도 남았다
- 그런데 후속 이벤트는 발행되지 않았다
이건 같은 이벤트가 두 번 처리된 문제가 아니라, 하나의 비즈니스 흐름이 중간에서 끊어진 문제였다.
즉 이번에 먼저 풀어야 했던 건 멱등성보다 원자성이었다.
여기서 말하는 원자성은 "입찰 저장과 Kafka 발행을 하나의 트랜잭션으로 묶는다"는 뜻은 아니다. 현실적으로 DB와 Kafka를 그렇게 단순하게 묶을 수는 없기 때문이다.
대신 적어도 이런 기준은 필요했다. 입찰이 저장됐다면, 나중에 보내야 할 이벤트 기록도 반드시 함께 남아 있어야 했다.
이 기준이 생기고 나서야 "실패하면 나중에 다시 보낸다"는 말이 의미를 갖게 된다.
AFTER_COMMIT도 떠올렸지만, 결국 충분하지 않았다
Spring에는 @TransactionalEventListener(AFTER_COMMIT)가 있다. 입찰 트랜잭션이 커밋된 뒤에 이벤트 발행을 실행하면 꽤 깔끔하게 보인다.
처음엔 나도 이 방향을 생각했다.
- 입찰 트랜잭션 안에서는 DB만 처리하고
- 커밋이 끝난 뒤 이벤트를 발행하면
- 책임도 분리되고 코드도 단순해 보이기 때문이다
그런데 이 방식은 한 가지를 보장하지 못한다.
AFTER_COMMIT은 말 그대로 커밋 후에 실행될 뿐이다.
이벤트가 유실되지 않는다는 보장은 아니다.
예를 들면 이런 상황이 가능하다.
입찰 트랜잭션 커밋 성공
-> AFTER_COMMIT 리스너 실행 직전 프로세스 종료
-> 입찰은 DB에 남음
-> 이벤트는 어디에도 남지 않음
이 지점에서 생각이 정리됐다.
내가 원했던 건 "커밋 후에 실행된다"는 사실이 아니었다. 내가 원했던 건 "보내야 할 사실이 사라지지 않는다"는 보장이었다.
그래서 이번 입찰 이벤트에는 AFTER_COMMIT보다 Outbox가 더 맞다고 판단했다.
그래서 입찰 저장과 이벤트 기록을 같은 트랜잭션으로 묶었다
Outbox를 적용한 뒤 흐름은 이렇게 바뀌었다.
입찰 요청
-> Auction 저장
-> AuctionBid 저장
-> OutboxEvent 저장
-> 트랜잭션 커밋
그 후 별도 스케줄러
-> Outbox 조회
-> Kafka 발행
-> 성공 시 완료 처리
-> 실패 시 재시도
실제 구현은 AuctionBidService, AuctionOutboxService, OutboxEvent, OutboxEventRepository, OutboxRelayScheduler에 나뉘어 있다.
여기서 중요했던 건 입찰 처리와 Outbox 저장을 같은 로컬 트랜잭션 안에 넣는 것이었다.
정리하면 바뀐 구조는 이렇다.
- 예전: 입찰 저장 -> 바로 Kafka 발행
- 지금: 입찰 저장 + Outbox 기록 -> 나중에 scheduler가 Kafka 발행
이 구조에서 보장하려는 건 "입찰 저장과 Kafka 발행의 원자성"이 아니다. 더 정확히 말하면 "입찰 저장과 이벤트 기록의 원자성"이다.
Kafka 발행은 여전히 비동기다. 대신 이제는 발행이 실패해도 Outbox에 근거가 남아 있기 때문에 재시도를 할 수 있다.
Propagation.MANDATORY를 둔 것도 같은 이유였다
이 부분은 AuctionOutboxService에 들어 있고, 여기에는 Propagation.MANDATORY를 뒀다.
이 설정은 Outbox 저장이 반드시 기존 입찰 트랜잭션 안에서만 실행되게 강제한다.
왜냐하면 내가 원했던 건 Outbox가 독립적으로 동작하는 서비스가 아니라, 입찰 처리의 일부로서 기록되는 것이었기 때문이다.
만약 누군가 실수로 다른 곳에서 Outbox만 단독 호출할 수 있게 두면, 실제 입찰은 없는데 이벤트만 남는 이상한 상태가 생길 수 있다.
그래서 이 부분은 단순히 트랜잭션 옵션을 건드린 게 아니라, "Outbox는 비즈니스 트랜잭션의 일부다"라는 의도를 코드로 고정한 셈에 가깝다.
Relay는 왜 상태를 나눠 관리했는가
Outbox를 polling 방식으로 돌리면 생각보다 단순하지 않다. 처음에는 미발행 / 발행완료 정도만 있어도 되지 않을까 생각했지만, 실제로는 그 정도로는 부족했다.
이번 구현에서는 상태를 세 개로 뒀다.
- PENDING
- PROCESSING
- PUBLISHED
상태 정의는 OutboxEventStatus에 있고, 상태 전이 로직은 OutboxRelayScheduler에서 처리한다.
흐름은 단순하다.
- 정상 경로: PENDING -> PROCESSING -> PUBLISHED
- 실패 경로: PENDING -> PROCESSING -> PENDING
PROCESSING 상태를 둔 이유는 두 가지였다. 첫째, 같은 이벤트를 여러 worker가 동시에 집는 가능성을 줄이기 위해서였고, 둘째, 처리 중 죽어버린 이벤트를 다시 복구하기 위해서였다.
예를 들어 스케줄러가 이벤트를 집어서 PROCESSING으로 바꾼 직후 죽으면, 그 이벤트는 영원히 처리 중 상태에 머물 수 있다. 그래서 processingStartedAt을 두고, 너무 오래된 PROCESSING 이벤트는 다시 PENDING으로 돌리도록 했다.
처음엔 이 필드도 굳이 필요한가 싶었는데, 실제로는 "처리 중이었다가 멈춘 이벤트"를 복구하는 기준점이 됐다.
Outbox를 넣었다고 멱등성이 끝나는 건 아니었다
이번 작업을 하면서 개인적으로 가장 중요하게 느낀 건 이 부분이었다.
Outbox를 넣으면 이벤트 유실 가능성은 줄어든다. 하지만 그렇다고 멱등성 문제가 사라지는 건 아니다.
예를 들면 이런 상황은 여전히 가능하다.
Kafka 발행 성공
-> PUBLISHED 마킹 전에 장애
-> 재시도 과정에서 같은 이벤트가 다시 발행
즉 Outbox는 "유실 방지와 복구 가능성" 쪽에 가깝고, end-to-end 기준으로 보면 여전히 at-least-once에 가까운 구조다.
그래서 다음 단계에서는 이런 질문이 다시 남는다.
- consumer가 같은 이벤트를 두 번 받아도 안전한가
- eventId 기준 deduplication이 필요한가
- 후속 저장이나 브로드캐스트도 멱등하게 처리해야 하는가
이번 작업을 하면서 느낀 건 원자성과 멱등성은 같이 자주 언급되지만, 먼저 풀어야 할 문제는 다를 수 있다는 점이었다.
내 경우에는 순서가 이랬다.
- 입찰 저장과 이벤트 기록의 원자성을 먼저 확보한다
- 그다음 relay/consumer 경계에서 멱등성을 설계한다
이 순서로 보니 구조가 훨씬 명확해졌다.
실시간성은 프론트 응답으로 보완했다
Outbox를 쓰면 자주 받는 질문이 있다. 실시간 입찰인데 채팅 반영이 느려지는 것 아니냐는 질문이다.
맞다. polling 기반 relay라면 즉시 발행보다 지연이 생길 수 있다.
그래서 이번에는 정합성을 먼저 택하고, UX는 다른 방식으로 보완했다.
입찰 API가 성공하면 200 OK만 내려주는 대신 최신 경매 상태를 같이 반환하도록 바꿨고, 이 부분은 AuctionController와 AuctionResponse에서 볼 수 있다.
이렇게 하면 입찰한 본인의 화면은 WebSocket 브로드캐스트를 기다리지 않고도 바로 갱신할 수 있다. 결국 흐름을 둘로 나눠 가져간 셈이다.
- 입찰한 본인 화면: HTTP 응답으로 즉시 갱신
- 다른 참여자 화면: Outbox를 거친 비동기 이벤트로 반영
처음에는 "실시간이면 전부 즉시 반영되어야 하지 않나?"라고 생각했는데, 실제로는 즉시성이 필요한 경계와 복구 가능성이 필요한 경계를 분리하는 쪽이 더 현실적이었다.
이번 변경으로 얻은 것
Outbox를 적용하고 나서 좋았던 점은 명확했다.
첫째, 부분 실패에 더 강해졌다. 예전에는 입찰 저장 이후 발행 실패가 나면 복구 기준이 없었지만, 지금은 Outbox에 이벤트가 남아 있기 때문에 재시도가 가능하다.
둘째, 책임 경계가 선명해졌다. 입찰 서비스는 비즈니스 트랜잭션을 책임지고, relay는 비동기 전달을 책임진다. 저장과 전달이 섞여 있을 때보다 코드의 의도가 훨씬 분명해졌다.
셋째, 프론트 UX도 함께 좋아졌다. 입찰한 본인은 HTTP 응답으로 바로 화면을 갱신하고, 전체 동기화는 이후 이벤트로 맞추는 방식이 가능해졌다.
마무리하며
bowchat을 모놀리식에서 MSA로 전환하면서 가장 해보고 싶었던 건, 서비스를 나누는 행위 자체가 아니라 그 이후에 생기는 문제를 직접 풀어보는 것이었다.
Kafka를 붙이면 이벤트는 오가지만, 이벤트가 오간다는 것만으로 정합성이 생기지는 않는다.
이번 입찰 흐름에서 내가 먼저 해결해야 했던 건 "중복을 막는 것"보다 "보내야 할 사실이 사라지지 않게 만드는 것"이었다.
그래서 @TransactionalEventListener(AFTER_COMMIT)보다 Outbox를 선택했다. 커밋 후 실행보다, 실패해도 다시 보낼 수 있는 기록을 남기는 쪽이 이번 문제에는 더 중요했기 때문이다.
결국 이번 변경은 이렇게 정리할 수 있다.
입찰 저장과 Kafka 발행을 한 번에 묶으려 한 것이 아니라,
입찰 저장과 이벤트 기록을 같은 트랜잭션으로 묶어서
이벤트 유실 없이 후속 발행을 재시도할 수 있게 만든 작업이었다.
그리고 그 다음부터 비로소 멱등성이라는 다음 문제가 더 또렷하게 보이기 시작했다.
기존 구조와 변경 구조 비교
캡션 예시: 입찰 저장 직후 바로 Kafka를 보내던 구조에서, 입찰 저장과 Outbox 기록을 묶고 relay가 나중에 발행하는 구조로 바꿨다.
flowchart LR
subgraph Before
A1[입찰 요청] --> B1[Auction 저장]
B1 --> C1[AuctionBid 저장]
C1 --> D1[Kafka 즉시 발행]
end
subgraph After
A2[입찰 요청] --> B2[Auction 저장]
B2 --> C2[AuctionBid 저장]
C2 --> D2[OutboxEvent 저장]
D2 --> E2[OutboxRelayScheduler]
E2 --> F2[Kafka 발행]
end
부분 실패 시나리오 비교
캡션 예시: 같은 장애가 발생해도, Outbox가 있으면 "나중에 다시 보내야 할 근거"가 남는다.
- 왼쪽: DB 저장 성공 후 Kafka 발행 실패, 이벤트 유실
- 오른쪽: DB 저장 성공 후 Outbox 기록 존재, relay 재시도 가능
Outbox 상태 전이
캡션 예시: Outbox 이벤트는 발행 대기, 처리 중, 발행 완료 상태를 오가며 장애가 나면 다시 재시도된다.
stateDiagram-v2
[*] --> PENDING
PENDING --> PROCESSING: 스케줄러 claim
PROCESSING --> PUBLISHED: Kafka 발행 성공
PROCESSING --> PENDING: 발행 실패
PROCESSING --> PENDING: stale event 복구
프론트 화면 반영 흐름
캡션 예시: 입찰한 본인 화면은 HTTP 응답으로 먼저 갱신하고, 전체 반영은 이후 이벤트로 맞췄다.
sequenceDiagram
participant U as User
participant F as Frontend
participant A as auction-service
participant O as OutboxRelayScheduler
participant K as Kafka / chat-service
U->>F: 입찰 버튼 클릭
F->>A: POST /bid
A-->>F: 200 + AuctionResponse
F->>F: 본인 화면 즉시 갱신
A->>O: Outbox 저장 완료
O->>K: auction-bid 발행
K-->>F: 브로드캐스트 수신
F->>F: 서버 기준 상태로 동기화
코드
GitHub - mangtaeeee/bowchat-auction
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
'Java > 기술회고' 카테고리의 다른 글
| 내부 서비스 인증을 X-Service-Token에서 OAuth 2.0으로 바꾼 이유 (2) | 2026.04.09 |
|---|---|
| Kafka를 제대로 쓰고 싶어서 MSA로 전환한 이야기 (0) | 2026.04.06 |
| Kafka Consumer Lag 600을 0으로 만든 방법 (k6 + Prometheus +Grafana) (0) | 2026.01.28 |
| Thread.sleep()은 왜 위험할까? Kafka DLQ로 안전하게 재시도 처리하기 (0) | 2025.12.15 |
| 로컬에서 Spring Boot 서버 2개 띄워 HAProxy로 트래픽 분산해보기 (0) | 2025.10.26 |