TL;DR
- @Transactional 하나에 모든 걸 묶던 구조에서 벗어나, 핵심 비즈니스와 부가 로직(외부 API 등)을 이벤트로 분리했다.
- 트랜잭션 분리 후 발생한 '이벤트 유실' 위험은 Transactional Outbox Pattern 을 도입해 At-Least-Once를 목표로 설계했다.
- Kafka 파티션 키를 직관적으로 잡았다가 동시성 경합 가능성을 발견하고, Consumer 입장에서 재설계했다.
- 선착순 쿠폰 비동기 처리 중 @Transactional 프록시 우회(Self-Invocation) 문제를 만나 원인을 추적했다.
- 결론: 분산 환경에서 완벽한 정합성은 없으며, 상황에 맞는 트레이드오프(Trade-off)가 아키텍처의 핵심이다.
PG 환불이 10초 걸리면, DB 커넥션도 10초 멈춘다
지금까지 과제로 이커머스 서비스를 설계하면서 재고차감, 쿠폰 사용, 결제처리 등 모든 흐름을 하나의 트랜잭션 안에서 처리해왔다.
각 단계가 하나라도 실패하면 전체가 롤백되어야 했기 때문에, 트랜잭션은 점점 커지고 시스템의 결합도도 높아졌다.
우선 나의 주문 취소 로직을 떠올려보면
'주문 상태 변경, 재고 복원, 쿠폰 복원, PG 환불' 이 모든 것이 하나의 @Transactional 안에 들어있다.
PG사 환불 API 가 3~10초 걸린다면 어떻게 될까? 그 시간 동안 DB 커넥션은 계속 점유된다.
트래픽이 몰리는 시간대에 이런 요청이 수십 건 쌓이면 커넥션 풀이 고갈되고, 환불과 무관한 단순 조회 API 까지 전부 느려질 것이다.
이번 과제에서 이 문제를 출발점으로 트랜잭션 경계를 나누고 Kafka 파이프라인을 구축하며 선착순 쿠폰까지 구현했다.
그 과정에서 마주한 세 가지 문제점을 차례로 정리해보려 한다.
문제점을 마주하기 전 - 트랜잭션 경계 나누기
가장 먼저 한 일은 Spring ApplicationEvent 를 이용해 트랜잭션의 경계를 나누는 것이었다.
기준은 "이 부가 로직이 실패했다고 해서 핵심 비즈니스를 롤백시킬 것인가?" 였다.
| 로직 | 판단 | 이유 |
| 재고 차감, 쿠폰 복원 | 핵심(TX 내부) | 실패 시 주문 취소 자체가 무의미함 |
| PG 환불 | 후속(AFTER_COMMIT) | 환불은 재시도 가능. 외부 API 지연이 DB 커넥션을 점유하면 안 됨 |
| 통계 집계 로깅 | 후속(이벤트 분리) | 로깅 실패로 주문이 롤백되면 안 됨 |
이렇게 @TransactionalEventListener(AFTER_COMMIT) 을 활용해 잘 분리했다고 생각했지만 또 다시 딜레마가 찾아왔다.
DB 커밋은 성공했는데, Kafka로 이벤트를 발행하기 직전 찰나에 서버가 죽는다면?
Spring의 ApplicationEvent 는 프로세스 내부 이벤트다. 디스크에 기록되지 않고, 재시도 매커니즘도 없다.
즉 내구성이 없다. 서버가 죽으면 발행 대기 중이던 이벤트는 그대로 사라질 것이다.
주문은 취소됐는데 통계 서버에는 반영되지 않는 데이터 유실 문제가 발생할 수도 있다는 점을 깨달았다.
이부분이 AFTER_COMMIT 만으로는 부족하고 Outbox 패턴이 필요한 이유라고 생각했다.
- 트랜잭션 분리 전후 비교


문제점 1 - 이벤트를 발행하기 직전에 서버가 죽으면?
이벤트 유실을 막기 위해 Transactional Outbox Pattern 을 도입했다.
주문 데이터를 DB에 저장할 때, 동일한 로컬 트랜잭션으로 outbox_events 테이블에 Kafka로 보낼 메시지도 함께 INSERT 하는 방식이다.
이후 스케줄러가 미발행 건을 주기적으로 읽어 Kafka로 발행하기 때문에 이벤트 유실가능성을 DB 트랜잭션 수준으로 낮출 수 있었다.
Outbox 단독으로 At-Least-Once 가 보장되는 것은 아니고, Kafka Producer 의 acks=all + 재시도 설정 + relay의 재시도 정책이 함께 필요하다.
다만 Outbox도 완벽하지는 않다. relay 가 Kafka 전송 후 published 플래그를 업데이트하기 전에 프로세스가 죽으면 같은 메시지가 중복 발행될 수 있다. 그래서 Consumer 측의 멱등 처리가 함께 설계되어야 한다.
- OutBox 패턴 전체 흐름

하지만, 모든 이벤트에 Outbox 를 적용했을까?
그렇지 않다. 상품조회(ProductViewedEvent) 로깅은 Outbox 를 거치지 않고 즉시 Kafka 에 직접 발행했다.
이유는 조회 수가 100만 건 중 1~2건 누락되는 것보다, 무거운 Outbox 테이블에 매번 INSERT 해서 발생하는 I/O 오버헤드를 막는것이 시스템 전체에 훨씬 합리적인 트레이드오프라고 생각했기 때문이다.
| 이벤트 | 전파 방식 | 이유 |
| 주문 생성/취소 | Outbox | 정합성 필수 (통계에 누락되면 안 됨) |
| 좋아요/취소 | Outbox | likeCount 집계에 반영되어야 함 |
| 상품 조회 | 직접 Kafka 발행 | 유실 허용 |
문제점 2 - 파티션 키를 잘못 잡으면 Consumer 가 서로 싸운다
Outbox 를 적용하고 Kafka 로 이벤트를 발행하려다 발생한 문제점이였다.
주문 취소 이벤트를 발행할 때 직관적으로 orderId 를 파티션 키로 설정했다. 그런데 통계를 집계하는 Consumer 쪽을 설계하다 보니 불필요한 DB 경합을 유발하는 구조라는 것을 알게 되었다.
- 문제 상황

만약 같은 상품에 대한 이벤트가 여러 파티션으로 흩어지면, 여러 Consumer 스레드가 동시에 DB의 같은 row를 UPDATE 하려고 한다.
- 해결

모든 통계 이벤트의 파티션 키를 productId 로 통일했다. 물론 파티션 키 통일이 DB row lock 자체를 제거하는 것은 아니다.
다른 Consumer Group 이나 retry worker 가 동시에 같은 row 에 접근할 가능성은 여전히 존재한다.
하지만 같은 Consumer Group 내에서 동일 상품 이벤트가 단일 Consumer 에서 순차 처리 되므로, 가장 빈번한 경합 시나리오를 구조적으로 차단할 수 있었다.
이 과정에서 파티션 키는 데이터를 생성하는 주체가 아니라, 상태를 수정하는 대상을 기준으로 잡아야 한다는 인사이트를 얻었다.
주문 취소 이벤트를 발행할 때도 주문 통째로가 아니라 포함된 상품 단위로 이벤트를 잘게 쪼개서 Outbox 에 기록했다.
그 덕에 같은 상품 이벤트는 무조건 1개의 파티션으로 들어가 순차적으로 안전하게 처리될 수 있었다.
// OrderCancelledEvent에 items 정보 추가
public record OrderCancelledEvent(Long orderId, Long userId, List<CancelledItem> items) {
public record CancelledItem(Long productId, int quantity) {}
}
// Outbox 리스너에서 item별로 분리 저장
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOrderCancelled(OrderCancelledEvent event) {
for (var item : event.items()) {
outboxEventService.save(
"PRODUCT", // aggregateType
item.productId(), // aggregateId (= 파티션 키)
"ORDER_CANCELLED",
Map.of("productId", item.productId(), "quantity", item.quantity())
);
}
}
문제점 3 - @Transactional 이 붙어있는데 트랜잭션이 안 열린다
마지막으로 구현한 것은 선착순 100장 쿠폰 시스템이다.
DB 경합을 막기 위해 API 서버는 발급 요청을 Kafka 큐에 밀어 넣고 즉시 응답하는 비동기 대기열 모델을 구성했다.
Consumer 가 자신이 감당할 수 있는 속도로 큐에서 빼서 DB 에 반영하는 구조다.
- 쿠폰 발급 비동기 처리 흐름

구현 후 동시성 테스트를 돌렸다. 그런데 발급 수량이 0 에서 변경되지 않았다.
원인을 찾아보니 스프링 AOP 프록시의 Self-Invocation(내부 호출)의 문제점이였다.
Before - 프록시가 우회되는 코드
@Service
public class CouponIssueProcessor {
public void process(Long requestId) {
tryIssue(requestId); // ← this.tryIssue() = 프록시 우회
}
@Transactional // ← 이 어노테이션이 무시됨
public void tryIssue(Long requestId) {
// 수량 체크 + 발급 로직
couponRepository.incrementIssuedQuantity(...); // @Modifying 쿼리 → TX 필수
}
}
Spring AOP 의 @Transactional 은 프록시 기반으로 동작한다. 외부에서 빈을 호출할 때만 프록시 객체가 가로채서 트랜잭션을 열어주는데, 같은 클래스 내부에서 this.tryIssue() 를 호출하면 프록시를 거치지 않고 원본 객체의 메서드가 직접 실행된다.
즉 @Transactional 어노테이션이 완전히 무시된다.
Spring Data JPA 의 @Modifying 쿼리는 트랜잭션 컨텍스트가 없으면 예외를 던지거나 정상 동작하지 않는다.
incrementIssuedQuantity 가 이 경우에 해당해서, 수량 증가가 아예 반영되지 않았던 것이다.
After - 별도 빈으로 분리
// 트랜잭션 로직을 별도 빈으로 분리
@Service
public class CouponIssueTxService {
@Transactional // ← 외부에서 호출하므로 프록시 정상 동작
public void tryIssue(Long requestId) {
// 수량 체크 + 발급 로직
}
@Transactional
public void markFailed(Long requestId, String reason) { ... }
}
// Processor는 위임만 담당
@Service
public class CouponIssueProcessor {
private final CouponIssueTxService couponIssueTxService;
public void process(Long requestId) {
try {
couponIssueTxService.tryIssue(requestId); // 프록시 경유
} catch (DataIntegrityViolationException e) {
couponIssueTxService.markFailed(requestId, "중복 발급 요청");
} catch (Exception e) {
couponIssueTxService.markFailed(requestId, "발급 처리 오류");
}
}
}
별도 빈으로 분리 후에 DB의 원자적 연산이 정상 작동하며, 초과 발급 없이 100명에게만 성공하는것을 확인할 수 있었다.
마무리
- "이 데이터는 유실을 허용해도 되는가?"
- "누가 이 상태를 변경하는가?"
- "이 메서드는 프록시를 타고 있는가?"
이 세가지 질문이 이번 과제를 통해 가장 기억에 남는 질문들이다.
강한 결합을 끊어내는 일은 의외로 단순했다.
모듈을 분리하고 메시지 브로커를 사이에 두는 것만으로도 시스템은 빠르게 분산된 형태를 갖추었다.
하지만 문제는 그 다음부터였다. 하나의 흐름이 여러 경계로 나뉘는 순간, 데이터는 더 이상 동시에 도착하지 않았다.
같은 작업이 여러 번 실행되기도 했고, 어떤 요청은 조용히 사라지기도 했다.
유실을 막기 위해 Outbox와 재시도를 도입하면 멱등성 문제가 따라왔고, 멱등성을 보장하려다 보면 또 다른 DB 경합이 발생했다. 단일 프로세스에서는 데이터베이스가 알아서 보장해 주던 것들을, 이제는 애플리케이션 레벨에서 하나하나 통제해야 했다.
이번 경험을 통해 실패는 예외가 아니라 '기본 상태(Default)'로 다뤄야 하며, 시스템의 일관성은 주어지는 것이 아니라 의도적으로 설계해야 하는 목표라는 점을 배웠다. 서비스를 나누는 것보다, 나뉜 서비스들을 다시 안전하게 연결하는 것이 분산 아키텍처의 진짜 과제였다.
'루퍼스 > 라이팅' 카테고리의 다른 글
| [Spring/Redis] 이커머스 실시간 인기순 랭킹 시스템 구현기 (feat. ZSET) (0) | 2026.04.09 |
|---|---|
| [성능 최적화] 인덱스와 캐시, 각각 얼마나 빠를까 (0) | 2026.03.13 |
| [동시성 제어] 왜 나는 항상 선착순에 실패하는 걸까? (0) | 2026.03.06 |
| [성장기] 요구사항만 구현하던 SI 주니어가, 처음 설계를 해보며 바뀐 생각들 (0) | 2026.02.13 |
| [삽질기] Colima 까지 갔다가 설정 한 줄로 돌아온 건에 대하여 (feat. Testcontainers) (1) | 2026.02.05 |