어떤 문제가 있었냐면...
크라우드펀딩 플랫폼을 만들고 있는데요, Funding 서비스가 다른 서비스들(Reward, Payment)이랑 통신해야 하는 상황이었습니다.

비즈니스 플로우는 이렇습니다
1. 사용자가 후원 버튼 클릭
2. Reward 서비스한테 티켓 재고 감소 요청
3. Payment 서비스한테 결제 요청
4. Funding DB에 저장
5. 통계 업데이트
간단해 보이죠? 근데 여기서 문제가 생겼는데요,
실제로 겪은 문제들
케이스 1: 티켓은 예약됐는데 결제가 실패함
✅ Reward 서비스: 재고 감소됨 (이미 커밋됨)
❌ Payment 서비스: 결제 실패
❌ Funding DB: 저장 안 됨 (롤백됨)
결과: 티켓 재고만 줄어들고 실제 후원은 안 된 상태 💥
사용자 입장에선 결제 실패했다고 나오는데, 티켓은 이미 소진돼버린 거죠.
케이스 2: 저장은 됐는데 이벤트 발행이 실패함
✅ Funding DB: 저장됨
❌ Kafka: 이벤트 발행 실패
결과: Reward 서비스가 QR 코드 생성을 못 함 💥
후원은 완료됐는데 QR 코드가 안 나오는 상황...
❌ 기존 코드
1. 이벤트 발행 코드가 똑같은 걸 계속 반복
@Service
public class FundingServiceImpl {
// 펀딩 완료할 때
public void createFunding(...) {
// ... 비즈니스 로직
// 이벤트 발행 (똑같은 코드 1)
Outbox event = Outbox.createOutbox(
"FUNDING",
funding.getId(),
"FUNDING_COMPLETED",
Map.of(
"fundingId", funding.getId(),
"reservationId", funding.getReservationId(),
// ...
)
);
outboxRepository.save(event);
eventPublisher.publishEvent(new OutboxEventCreated(event.getId()));
}
// 펀딩 취소할 때
public void cancelFunding(...) {
// ... 비즈니스 로직
// 이벤트 발행 (똑같은 코드 2)
Outbox event = Outbox.createOutbox(
"FUNDING",
funding.getId(),
"FUNDING_CANCELLED",
Map.of(
"reservationId", funding.getReservationId(),
"fundingId", funding.getId(),
// ...
)
);
outboxRepository.save(event);
eventPublisher.publishEvent(new OutboxEventCreated(event.getId()));
}
}
보시다시피 Outbox 생성하고 저장하고 이벤트 발행하는 코드가 계속 반복되더라고요. 이거 나중에 새로운 이벤트 타입 추가할 때마다 또 복붙해야 하는 상황이었습니다.
2. 트랜잭션 관리가 헷갈림
@Transactional
public CreateFundingResult createFunding(...) {
try {
// 결제 성공
Funding funding = fundingRepository.save(funding);
// 이벤트 발행 (이게 같은 트랜잭션인가?)
outboxRepository.save(event);
// 여기서 예외 터지면?
fundingProjectStatisticsService.increase(...);
} catch (Exception e) {
// 보상 트랜잭션 (이건 별도 트랜잭션이어야 하나?)
outboxRepository.save(compensationEvent);
throw e;
}
}
이 코드 짤 때 계속 헷갈렸던 게:
- 성공했을 때 이벤트는 Funding이랑 같이 커밋되어야 하나?
- 실패했을 때 보상 이벤트는 Funding 롤백이랑 상관없이 발행되어야 하나?
- 그럼 트랜잭션을 어떻게 나눠야 하지?
✅ 그래서 이렇게 바꿨어요
고민 끝에 두 가지를 개선하기로 했어요:
- 이벤트 발행 로직을 OutboxService로 따로 빼기
- 트랜잭션 전파 레벨을 명확하게 구분하기
- 성공 이벤트: REQUIRED (메인 트랜잭션이랑 같이 커밋)
- 보상 이벤트: REQUIRES_NEW (별도 트랜잭션으로 즉시 커밋)
Step 1: OutboxService 인터페이스 만들기

메서드를 딱 2개만 만들었어요. 이름만 봐도 뭐 하는 건지 바로 알 수 있죠?
왜 이렇게 설계했냐면:
- 메서드명으로 의도가 명확함 (성공 vs 보상)
- aggregateType 파라미터로 여러 타입 지원 (FUNDING, FUNDING_PROJECT 등)
- aggregateId는 필수 (성공일 땐 fundingId, 보상일 땐 reservationId)
Step 2: OutboxService 구현하기

여기서 핵심은 @Transactional의 propagation 속성입니다. 이게 트랜잭션을 어떻게 관리할지 결정하거든요.
Step 3: 트랜잭션 전파 레벨 이해하기
REQUIRED (성공 이벤트용)
@Transactional // 메인 트랜잭션
public CreateFundingResult createFunding(...) {
try {
// 1. Funding 저장 (아직 커밋 안 됨)
fundingRepository.save(funding);
// 2. 성공 이벤트 발행 (REQUIRED - 같은 트랜잭션 사용)
outboxService.publishSuccessEvent(
"FUNDING",
funding.getId(),
"FUNDING_COMPLETED",
Map.of(...)
);
// 3. 메서드 정상 종료
return success();
} catch (Exception e) {
throw e;
}
}
// 4. 여기서 메인 트랜잭션 커밋
// → Funding + Outbox 둘 다 같이 커밋됨 ✅
실행 흐름을 자세히 보면:
메인 트랜잭션 시작
↓
Funding 저장 (아직 커밋 X)
↓
publishSuccessEvent() 호출
→ REQUIRED라서 같은 트랜잭션 재사용
→ Outbox 저장 (아직 커밋 X)
↓
메서드 정상 종료
↓
메인 트랜잭션 커밋
↓
✅ Funding + Outbox 둘 다 커밋됨
↓
@TransactionalEventListener(AFTER_COMMIT) 실행
↓
Kafka 발행
이렇게 하면 좋은 점:
- 둘 다 성공하거나 둘 다 실패: Funding만 저장되고 Outbox는 안 저장되는 일이 없어요
- Kafka 발행 전에 DB 커밋 완료: 안전하게 이벤트 발행 가능
- 실패하면 재시도 가능: At-Least-Once 보장
REQUIRES_NEW (보상 트랜잭션용)
여기가 제일 중요합니다.
@Transactional // 메인 트랜잭션
public CreateFundingResult createFunding(...) {
// 재고 감소 (try-catch 밖에서 먼저 실행)
UUID reservationId = rewardClient.decreaseReward(...); // ✅ 성공
try {
// 결제 시도
paymentClient.processPayment(...); // ❌ 여기서 실패!
} catch (Exception e) {
// 보상 트랜잭션 이벤트 발행 (REQUIRES_NEW - 새 트랜잭션)
outboxService.publishCompensationEvent(
"FUNDING",
reservationId,
"FUNDING_FAILED",
Map.of(...)
);
throw e; // 메인 트랜잭션 롤백
}
}
실행 흐름:
메인 트랜잭션 시작
↓
재고 감소 성공 ✅ (Reward 서비스에서 이미 커밋됨)
↓
결제 실패 ❌ (예외 발생)
↓
catch 블록 실행
↓
publishCompensationEvent() 호출
→ REQUIRES_NEW라서 새 트랜잭션 생성! ⭐
→ Outbox 저장
→ 새 트랜잭션 바로 커밋 ✅
↓
throw e (예외 다시 던짐)
↓
메인 트랜잭션 롤백
↓
결과 정리:
❌ Funding: 없음 (롤백됨)
✅ Outbox: 있음 (별도 트랜잭션으로 커밋됨)
✅ 재고: 감소됨 (Reward 서비스)
↓
@TransactionalEventListener(AFTER_COMMIT) 실행
↓
Kafka 발행 → Reward 서비스가 재고 복구 ✅
처음엔 이게 이상하게 느껴질 수 있습니다:
Q: Funding이 저장도 안 되는데 Outbox는 왜 저장돼요?
A: REQUIRES_NEW라서 별도 트랜잭션이기 때문이에요!
Q: 그럼 Outbox만 남는 거 아니에요?
A: 맞아요! 근데 그게 의도한 거예요!
→ 재고는 이미 줄어들었잖아요 (Reward 서비스에서)
→ 복구 이벤트를 발행해야 해요
→ Funding은 롤백되어야 하지만, 이벤트는 발행되어야 해요
핵심은, 외부 서비스(Reward)는 이미 작업을 완료했기 때문에, 우리 트랜잭션이 롤백되어도 그 작업은 되돌릴 수 없습니다. 그래서 보상 이벤트를 발행해서 수동으로 되돌려야 하는 거죠.
Step 4: 수정된 코드

코드가 훨씬 깔끔해졌죠? 이제 이벤트 발행 로직을 outboxService한테 위임하니까 비즈니스 로직에만 집중할 수 있습니다.

Step 5: 리스너는 하나만!

리스너는 하나만 있으면 모든 Outbox 이벤트를 처리할 수 있습니다.
포인트:
- @TransactionalEventListener(AFTER_COMMIT): DB 커밋된 후에 실행
- 실패해도 괜찮아서 예외를 던지지 않습니다: OutboxScheduler가 5분마다 재시도해줌
REQUIRED vs REQUIRES_NEW 비교
| 구분 | REQUIRED | REQUIRES_NEW |
| 트랜잭션 | 기존 거 그대로 사용 | 새로 만듦 |
| 커밋 시점 | 메인이랑 같이 | 바로 커밋 |
| 롤백 영향 | 메인 롤백되면 같이 롤백 | 메인 롤백이랑 상관없음 |
| 언제 쓰나 | 성공 이벤트 (같이 저장되어야 함) | 보상 트랜잭션 (따로 저장되어야 함) |
정리
설계 원칙
- 이벤트 발행 로직은 따로 빼자
- OutboxService로 중복 제거
- 메서드명으로 의도 명확하게
- 트랜잭션 전파 레벨을 확실히 하자
- 성공할 때: REQUIRED (같이 커밋)
- 보상할 때: REQUIRES_NEW (따로 커밋)
- 리스너는 하나로 통일
- @TransactionalEventListener(AFTER_COMMIT)
- DB 커밋 후 Kafka 발행
주의할 점 및 마무리
try-catch 위치 중요!
// ❌
try {
reservationId = rewardClient.decreaseReward(...); // 실패해도 보상 불필요
paymentClient.processPayment(...); // 실패하면 보상 필요
}
// ✅
reservationId = rewardClient.decreaseReward(...); // try 밖에서
try {
paymentClient.processPayment(...); // try 안에서
}
MSA 환경에서 분산 트랜잭션 다룰 때, 트랜잭션 전파 레벨을 제대로 이해하고 쓰는 게 진짜 중요하다고 생각했습니다.
- 성공할 때: REQUIRED로 같이 커밋
- 보상할 때: REQUIRES_NEW로 따로 커밋
OutboxService로 추상화하고 리스너 하나로 통일하니까, 코드도 깔끔해지고 유지보수도 훨씬 쉬워졌습니다.