| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 디자인 패턴
- GoF 23
- 이펙티브 자바
- redis
- Effective Java
- 로드밸런서
- 코드카타
- DB
- 트러블슈팅
- 프로그래머스
- 성능 개선
- Til
- 프록시 패턴
- 추상클래스
- 스케줄러
- lv1
- 빌더 패턴
- java
- 스프링
- 백엔드
- Spring Batch
- 자바
- 김영한
- 템플릿 메서드 패턴
- 스프링 배치
- 배치
- 토스
- 계산기
- spring boot
- Spring
- Today
- Total
김코딩
[ 내돈 네돈 챌린지 ] 결제 시스템 데이터 정합성 보장: Saga 패턴 구현기 본문
들어가며
토스 페이먼츠 API를 연동한 결제 시스템을 구현하면서 마주한 문제가 있습니다.
시나리오:
1. 토스 결제 승인 성공
2. DB에 결제 내역 저장중
3. 포인트 충전 중
3. 결제 내역 저장 or 포인트 충전 중 장애 발생
4. 결과: 토스는 결제 성공, 포인트는 충전이 안되어있음
사용자는 결제를 했는데(돈이 빠져나갔는데), 포인트가 충전이 안되는 상황이 발생하고있습니다.
이번 글에서는 외부 API를 사용하는 시스템에서 데이터 정합성을 어떻게 보장해나가는지 작성하겠습니다.
1. 문제 상황: 외부 API 연동의 본질적인 문제
테스트: 외부 API(토스 결제)는 성공 후, 포인트 충전 중 예외 발생
1. 포인트 3만원 결제

2. 토스 개발자 센터에서는 결제 성공으로 확인

3. 사용자는 결제 실패화면과 함께 포인트 충전이 되지 않음

테스트 결과:
1. 사용자는 토스 결제를 성공함
2. 포인트 충전 중 실패함
3. 돈은 빠져나갔는데 충전이 안되는 문제 발생
2. 문제 분석
현재 코드
/**
* 결제 승인 및 포인트 충전
*/
// 메인 메서드: 트랜잭션 외부
@Override
public PaymentConfirmResponse confirmAndChargePoint(Long userId, PaymentConfirmRequest request) {
userService.getProfile(userId);
// 토스 API 호출
TossConfirmResult tossConfirmResult = tossClient.confirmPayment(
request.getPaymentKey(), request.getOrderId(), request.getAmount());
// 여기 이후에서 예외가 발생 시 토스는 결제 성공 상태
// DB 작업
return processPaymentSuccess(userId, request, tossConfirmResult);
}
// DB 작업만 짧은 트랜잭션
@Transactional
protected PaymentConfirmResponse processPaymentSuccess(Long userId, PaymentConfirmRequest request,
TossConfirmResult tossConfirmResult) {
// 결제 내역 저장 <- 만약에 여기서 예외가 발생한다면?
Payment payment = Payment.createSuccessPayment(
userId,
Money.of(request.getAmount()),
tossConfirmResult.getPaymentKey(),
tossConfirmResult.getStatus(),
tossConfirmResult.getMethod(),
tossConfirmResult.getApprovedAt().toLocalDateTime()
);
Payment savedPayment = paymentRepository.save(payment);
// 포인트 충전 <- 만약에 여기서 예외가 발생한다면?
pointService.chargePoint(
savedPayment.getUserId(),
savedPayment.getAmount().getValue(),
"CHARGE",
savedPayment.getOrderId().getValue()
);
return PaymentConfirmResponse.toDto(payment);
}
문제:
- processPaymentSuccess에서 예외 발생
- @Transactional 롤백으로 DB 작업은 취소됨
- 하지만 토스 API는 이미 성공 상태
왜 외부 API는 롤백이 안되는가?
외부 API(토스 페이먼츠)는 우리와 완전히 독립된 시스템입니다.
| 우리 시스템 | 토스 시스템 |
| 우리 DB | 토스 DB |
| 우리 서버 | 토스 서버 |
| 우리가 제어 가능 | 우리가 제어 불가능 |
이 문제를 해결하기 위해서 토스 결제를 @Transactional 안에 넣는다고 해도 토스 서버까지는 롤백할 수 없습니다.
3. 해결 방안: Saga 패턴과 보상 트랜잭션
3.1 Saga 패턴이란?
분산 시스템에서 데이터 정합성을 보장하는 패턴입니다.
핵심 아이디어:
- 롤백 대신 → 보상 트랜잭션(Compensating Transaction) 실행
- 실패한 작업을 "되돌리는" 반대 작업을 실행
우리 경우:
- 정상: 토스 결제 승인 → DB 저장 ✅
- 실패: 토스 결제 승인 → DB 저장 실패 → 토스 결제 취소 ✅
3.2 Saga 패턴의 두 가지 유형
1. Choreography 방식:
- 각 서비스가 이벤트를 발행하며 자율적으로 반응
- 중앙 조율자가 없음
- 장점: 느슨한 결합
- 단점: 전체 흐름 파악 어려움
2. Orchestration 방식(우리가 선택한 방식):
- 중앙 조율자(Orchestrator)가 전체 흐름 제어
- 명시적인 순서와 보상 로직
- 장점: 흐름 파악 쉬움, 디버깅 용이
- 단점: 조율자에 의존성 생김
3.3 우리 코드에서 Orchestrator 역할
/**
* 결제 승인 및 포인트 충전
*/
// 이 메서드가 Orchestrator 역할
@Override
public PaymentConfirmResponse confirmAndChargePoint(Long userId, PaymentConfirmRequest request) {
userService.getProfile(userId);
// 1. 토스 결제 API
TossConfirmResult tossConfirmResult = tossClient.confirmPayment(
request.getPaymentKey(), request.getOrderId(), request.getAmount());
try {
// 2. DB 저장(결제 내역 저장, 포인트 충전)
return processPaymentSuccess(userId, request, tossConfirmResult);
} catch (Exception e) {
// 3. 보상 트랜잭션
paymentCompensateService.executePaymentCompensation(
tossConfirmResult.getPaymentKey(),
"시스템 오류로 인한 자동 취소"
);
throw e;
}
}
주의사항
보상 트랜잭션은 API를 제공(ex. 결제 취소 API)하는 외부 API에서만 가능합니다.
| 외부 시스템 | 정방향 API | 보상 API | Saga 적용 가능 여부 |
| 토스 페이먼츠 | 결제 승인 | ✅ 결제 취소 | ✅ 가능 |
| CJ 대한통운 | 배송 접수 | ✅ 배송 취소 | ✅ 가능 |
| 이메일 발송 API | 이메일 전송 | ❌ 전송 취소 불가 | ❌ 불가능* |
| 문자 발송 API | 문자 전송 | ❌ 전송 취소 불가 | ❌ 불가능* |
우리가 결제 시스템에서 보상 트랜잭션을 적용할 수 있는 이유:
- 토스 페이먼츠에서는 취소 API를 제공하기 때문
만약에 취소 API가 없다면?
- 토스 결제와 DB 저장(결제 내역 저장, 포인트 충전)의 순서를 바꾸고 하나의 트랜잭션으로 묶어서 토스 결제 실패 시 롤백 시키는 방법(외부 API가 하나의 트랜잭션에 묶이는 문제가 발생하지만, 결제시스템에서는 데이터 정합성이 더 중요한 부분이라고 판단)
3.4 보상 트랜잭션 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentCompensateService {
private final TossClient tossClient;
/**
* 결제 보상 트랜잭션 실행
*/
public void executePaymentCompensation(String paymentKey, String reason) {
try {
tossClient.cancelPayment(paymentKey, reason);
log.info("보상 트랜잭션 성공 - 결제 취소: paymentKey={}", paymentKey);
} catch (Exception e) {
// 취소 실패 시 로그만 남기고 수동 처리
log.error("보상 트랜잭션 실패 - 수동 처리 필요: paymentKey={}, reason={}",
paymentKey, reason, e);
}
}
}
구현 내용:
- DB 작업(결제 내역 저장, 포인트 충전) 실패 시 실행
- 자동으로 토스 결제 취소 API 호출
고민 사항:
- 현재는 보상 트랜잭션 실패 시 로그만 저장 중
- 슬랙 API 연동으로 개발자에게 빠른 알림, DLQ(Dead Letter Queue)를 구현해서 실패한 결제를 큐에 넣고 순차적으로 처리하는 방법
4. 보상 트랜잭션 테스트
1. 포인트 5만원 결제

2. 사용자 결제 실패

3. 토스 결제내역확인

테스트 결과:
- 사용자가 포인트 5만원 결제
- 토스 결제 성공 후, DB 저장(결제 내역 저장, 포인트 충전) 중 예외 발생
- 자동으로 토스 결제 API 호출
- 돈은 빠져나갔지만 결제 실패 문제 해결
마치며
1. 핵심 배운 점 정리
- 외부 API는 우리가 제어할 수 없는 독립된 시스템
- DB 롤백으로는 외부 API 상태를 되돌릴 수 없음
- 보상 트랜잭션으로 "되돌리는 작업"을 명시적으로 구현해야 함
2. 기술적 성과
- Saga 패턴(Orchestration 방식) 적용
- 데이터 정합성 문제 해결
- 사용자에게 돈만 빠져나가는 최악의 상황 방지
3. 앞으로의 개선 방향
- 보상 트랜잭션 실패 시 자동 재시도 로직
- 슬랙 알림 연동으로 빠른 장애 대응
- DLQ 구현으로 실패한 보상 처리
- 멱등성 보장 (같은 요청 중복 처리 방지)
'개발팁' 카테고리의 다른 글
| [ FINSight ] 단일 서버 환경에서 JWT 대신 Session을 선택한 이유 (0) | 2025.12.01 |
|---|---|
| [ 내돈 네돈 챌린지 ] 외부 API 장애 전파 방지: Circuit Breaker & Retry 패턴 (0) | 2025.11.28 |
| [ 내돈 네돈 챌린지 ] 결제 API 성능 개선기: 트랜잭션 경계 재설계로 DB 커넥션 점유 시간 96% 단축 (0) | 2025.11.27 |
| 인덱스(Index)란 무엇인가? (0) | 2025.11.25 |
| [ 면접의 神 ] 동시 요청 환경에서 DB 병목 해결을 위한 캐싱 전략 수립 (0) | 2025.11.23 |