장애전파를 막는 방법: 내 잘못 아닌데요.
PG 연동에서 “돈이 맞는 코드”를 만들기까지 — 결제 장애복구 회고
결제 시스템에서 가장 중요한 건 “정상 동작하는 코드”가 아니라 “장애 상황에서도 돈이 맞는 코드”입니다. 이 글은 외부 PG사 연동 과정에서 겪은 동시성 버그, Circuit Breaker 설정 시행착오, 그리고 “멱등성이 만능이 아니었다”는 깨달음을 기록합니다.
들어가며: 장애는 전파된다
커머스 서비스에서 주문과 결제는 분리된 시스템입니다. 주문 서비스(Commerce API)가 외부 PG사에 결제를 요청하고, PG가 카드사와 통신하여 승인/거절 결과를 돌려줍니다.
[사용자] → [Commerce API] → [PG사] → [카드사]
출처: Martin Fowler - Circuit Breaker Pattern
이 구조에서 PG가 장애가 나면 어떻게 될까요?
PG 장애 발생
→ Commerce API가 PG 응답을 5초간 대기 (타임아웃)
→ 스레드가 대기 상태로 점유됨
→ 다른 사용자의 요청도 처리 못함
→ Commerce API도 장애 💥 (장애 전파)
PG 하나가 죽었을 뿐인데, 주문 조회, 상품 목록 등 결제와 무관한 기능까지 전부 멈춥니다. 이것이 장애 전파(Cascading Failure)입니다.
처음에는 단순하게 생각했습니다. “PG 호출 실패하면 에러 반환하면 되지 않나?” 하지만 실제로 구현하면서 마주치는 질문들은 훨씬 복잡했습니다.
- PG가 응답을 안 주면? → 타임아웃까지 스레드가 묶임
- 응답은 왔는데 타임아웃으로 처리되면? → 결제는 됐는데 우리는 실패로 인식
- 콜백이 유실되면? → 주문이 영원히 “결제 대기중”
- 콜백과 스케줄러가 같은 주문을 동시에 처리하면? → 재고 이중 복구
- PG가 장애인데 계속 요청을 보내야 하나? → 장애를 악화시킴
이 글에서는 Spring Boot + OpenFeign + Resilience4j 환경에서 이 질문들에 하나씩 답을 찾아간 과정을 공유합니다. 화해 기술 블로그의 “내부통신에 서킷브레이커 적용하기” 글에서도 비슷한 고민과 해결 과정을 확인할 수 있습니다.
PG Simulator — 왜 가상 서비스를 만들었는가
실제 PG사(토스페이먼츠, NHN KCP 등)를 연동하면 장애 상황을 재현할 수 없습니다. “PG가 40% 확률로 실패하는 상황”을 실제 PG로 테스트할 수는 없으니까요.
그래서 PG의 핵심 동작을 시뮬레이션하는 PG Simulator를 Kotlin으로 직접 만들었습니다.
Commerce API (Java, port 8080) ←→ PG Simulator (Kotlin, port 8082)
PG Simulator는 실제 PG사의 동작을 모사합니다:
| 실제 PG | PG Simulator |
|---|---|
| 카드사 통신 후 승인/거절 | 확률 기반 승인(70%)/거절(30%) |
| 결제 처리 후 콜백 전송 | 비동기 이벤트로 콜백 전송 |
| 네트워크 지연 | 100~500ms 랜덤 지연 |
| 서버 장애 | 40% 확률로 500 에러 반환 |
특히 40% 실패율은 의도적으로 높게 설정한 것입니다. 이 환경에서 Circuit Breaker, Retry, Fallback이 정상 동작하면, 실제 PG(실패율 1% 미만)에서는 더 안정적으로 동작할 것이라는 판단입니다.
이후 글에서 “PG”라고 표현하는 것은 모두 이 PG Simulator를 의미합니다. 실제 PG사 연동 시에는 인증(API Key), 서명 검증, 멱등키 등 추가 고려 사항이 있습니다.
0장. Resilience4j — 외부 서비스 장애에 대비하는 도구 상자
왜 Resilience4j인가?
장애 전파를 막기 위한 패턴으로 가장 유명한 것이 Netflix의 Hystrix입니다. 하지만 Hystrix는 2018년에 유지보수 모드에 들어갔고, Netflix 스스로 Resilience4j를 대안으로 권장합니다.
| 비교 | Hystrix | Resilience4j |
|---|---|---|
| 상태 | 유지보수 모드 (2018~) | 활발한 개발 중 |
| 의존성 | RxJava 필수 | 순수 Java, 외부 의존성 없음 |
| Spring Cloud 통합 | spring-cloud-netflix (deprecated) | spring-cloud-circuitbreaker (공식) |
| 설정 방식 | 코드 기반 | yaml + 어노테이션 (선언적) |
Resilience4j는 경량이고 Spring Boot와의 통합이 깔끔합니다. 어노테이션 하나(@CircuitBreaker, @Retry)로 적용할 수 있고, yaml로 설정을 외부화할 수 있어 운영 중 튜닝이 용이합니다.
Sliding Window — 장애를 어떻게 감지하는가?
Circuit Breaker가 “장애 상태”를 판단하려면 최근 요청의 성공/실패를 추적해야 합니다. 이를 위해 슬라이딩 윈도우(Sliding Window) 알고리즘을 사용합니다.
Resilience4j는 두 가지 방식을 제공합니다:
| 방식 | 기준 | 장점 | 단점 |
|---|---|---|---|
| Count-Based | 최근 N건 | 구현 단순, 트래픽 무관하게 동작 | 트래픽이 적으면 오래된 데이터로 판단 |
| Time-Based | 최근 N초 | 실시간 트래픽 반영 | 트래픽이 적으면 샘플 부족 |
화해 기술 블로그에서는 실시간 트래픽 관찰이 적합하다고 판단하여 Time-Based를 선택했습니다. 저는 PG Simulator 환경에서 트래픽이 일정하지 않으므로 Count-Based(최근 10건 기준)를 선택했습니다. 10건이면 정상 거절률(~30%)과 시스템 장애를 구분하기에 충분한 샘플입니다.
Sliding Window에 대한 자세한 설명은 Resilience4j 공식 문서 - CircuitBreaker에서 확인할 수 있습니다.
이제 Resilience4j의 핵심 패턴 3가지를 설명하고, 이후 장에서 이것들을 실전에 적용하면서 어떤 문제를 만났고 어떻게 해결했는지 이야기합니다.
Retry — 실패하면 다시 시도한다
네트워크는 완벽하지 않습니다. 일시적인 패킷 유실, 서버의 순간적인 과부하 등으로 요청이 실패할 수 있습니다. 이런 일시적 장애는 다시 시도하면 성공하는 경우가 많습니다.
1회차: PG 호출 → 타임아웃 ❌
500ms 대기
2회차: PG 호출 → 타임아웃 ❌
1000ms 대기 (Exponential Backoff: 대기 시간이 2배씩 증가)
3회차: PG 호출 → 성공 ✅
Spring에서는 어노테이션 하나로 적용할 수 있습니다.
@Retry(name = "pg-simulator")
public PaymentResult requestPayment(...) {
return pgClient.createPayment(request);
}
# application.yml
resilience4j:
retry:
instances:
pg-simulator:
max-attempts: 3 # 최대 3회 시도
wait-duration: 500ms # 초기 대기 500ms
enable-exponential-backoff: true # 점진적 증가
exponential-backoff-multiplier: 2 # 500ms → 1s → 2s
하지만 재시도는 만능이 아닙니다. PG가 완전히 죽은 상태라면 아무리 재시도해도 실패합니다. 오히려 이미 과부하인 PG에 요청을 더 보내서 상황을 악화시킵니다. 그래서 Circuit Breaker가 필요합니다.
Circuit Breaker — 장애가 퍼지지 않도록 차단한다
전기의 차단기(두꺼비집) 를 생각하면 됩니다. 과전류가 흐르면 차단기가 내려가서 전기를 끊습니다. 불편하지만 화재를 막습니다.
소프트웨어에서도 같은 상황이 발생합니다:
Circuit Breaker가 없으면:
PG 장애 → 모든 요청이 5초씩 타임아웃 대기 → 스레드 풀 고갈 → 우리 서버도 장애 💥
Circuit Breaker가 있으면:
PG 장애 → 실패율 50% 초과 감지 → PG 호출 차단 → 즉시 Fallback 응답 → 우리 서버는 정상 ✅
3가지 상태를 순환합니다:
- Closed: 정상. 모든 요청이 PG로 전달됩니다.
- Open: 장애 감지. PG를 호출하지 않고 즉시 Fallback을 실행합니다. PG에 복구 시간을 줍니다.
- Half-Open: 대기 시간 후, 소수의 요청만 PG에 보내서 복구 여부를 확인합니다.
@CircuitBreaker(name = "pg-simulator", fallbackMethod = "requestPaymentFallback")
@Retry(name = "pg-simulator")
public PaymentResult requestPayment(...) {
return pgClient.createPayment(request);
}
resilience4j:
circuitbreaker:
instances:
pg-simulator:
sliding-window-size: 10 # 최근 10건 기준
failure-rate-threshold: 50 # 실패율 50% 초과 시 Open
wait-duration-in-open-state: 5s # Open 후 5초 대기
permitted-number-of-calls-in-half-open-state: 3 # 3건만 시험
Retry와 Circuit Breaker의 실행 순서:
요청 → [Circuit Breaker] → [Retry] → 실제 PG 호출
CircuitBreaker [
Retry [
PG 호출 → 실패 → 재시도 → 실패 → 재시도 → 실패
] → Retry 소진 → 최종 실패
] → CircuitBreaker가 실패 1건으로 기록 → 누적 실패율 계산
중요한 점은 Retry가 모두 소진된 후의 최종 결과만 Circuit Breaker에 기록된다는 것입니다. 1회차 실패 후 2회차에 성공했다면, Circuit Breaker에는 “성공”으로 기록됩니다. 일시적 장애는 Retry가 처리하고, 지속적 장애만 Circuit Breaker가 감지하는 구조입니다.
Fallback — 실패해도 사용자에게 의미 있는 응답을 준다
Circuit Breaker가 Open이거나 Retry가 모두 실패하면, 원래 로직 대신 실행되는 대체 로직입니다.
// 원래 로직: PG에 결제 요청
@CircuitBreaker(name = "pg-simulator", fallbackMethod = "requestPaymentFallback")
public PaymentResult requestPayment(...) {
return pgClient.createPayment(request); // PG 장애 시 실패
}
// Fallback: PG 장애 시 실행
private PaymentResult requestPaymentFallback(..., Exception e) {
// 500 에러 대신 "결제 처리 중"을 반환
return new PaymentResult(null, PENDING, "결제 처리 중");
}
Fallback의 핵심은 “실패를 사용자에게 그대로 보여주지 않는 것”입니다. “서버 오류입니다”(500) 대신 “결제 처리 중입니다”라고 응답하면, 나중에 콜백이나 스케줄러가 실제 결과를 보정할 수 있습니다.
Fallback 전략은 도메인마다 다릅니다:
| 도메인 | Fallback 전략 | 이유 |
|---|---|---|
| 상품 조회 | 캐시된 데이터 반환 | 약간 오래된 데이터라도 보여주는 게 나음 |
| 추천 시스템 | 인기 상품 목록 반환 | 개인화 실패해도 기본 추천이라도 |
| 결제 | PENDING 반환 + 보조 수단으로 보정 | 돈이 관련되므로 “처리 중” 상태로 두고 나중에 확인 |
세 가지 패턴을 조합하면
사용자 결제 요청
│
▼
[Retry] 일시적 장애 → 재시도로 복구 시도 (500ms → 1s → 2s)
│
▼ (Retry 소진)
[Circuit Breaker] 지속적 장애 → PG 호출 차단, 장애 전파 방지
│
▼ (Open 상태)
[Fallback] 사용자에게 "결제 처리 중" 응답 → 콜백/스케줄러가 나중에 보정
이론은 깔끔합니다. 하지만 실제로 적용하면서 “이론대로 안 되는 지점”들을 만났습니다. 다음 장부터 그 이야기를 합니다.
1장. 전체 구조 — 왜 3중 방어인가
결제 흐름 설계
PG 연동의 핵심은 “요청은 동기, 처리는 비동기”라는 점입니다.
[사용자] → POST /payments → [Commerce API] → POST /payments → [PG]
↓
비동기 결제 처리
(승인/거절 판정)
↓
[사용자] ← 200 OK ← [Commerce API] ← POST /callback ← [PG]
사용자가 결제 요청을 보내면 PG는 “접수했다”는 응답만 줍니다. 실제 승인/거절은 비동기로 처리되고, 결과는 콜백으로 전달됩니다.
여기서 문제가 생깁니다. 콜백이 유실되면? PG가 장애면? 이를 대비해 3중 방어 구조를 설계했습니다.

단일 방어 수단은 각각 실패할 수 있습니다:
| 방어 수단 | 실패 시나리오 |
|---|---|
| Retry만 | PG 전면 장애 → 재시도가 오히려 부하를 가중 |
| Callback만 | 네트워크 장애로 콜백 유실, 서버 재시작 중 콜백 수신 불가 |
| Scheduler만 | 5분 지연 → 사용자가 결제 결과를 모르는 시간이 너무 김 |
3중 방어를 조합하면 각 계층의 약점을 다른 계층이 보완합니다.
2장. 첫 번째 시행착오 — “멱등성 가드면 충분하지 않나?”
처음 작성한 코드
주문의 결제 상태를 업데이트하는 코드를 처음 이렇게 작성했습니다.
public void failPayment(Long orderId) {
Order order = orderRepository.findById(orderId) // 일반 SELECT
.orElseThrow(() -> new CoreException(ErrorType.ORDER_NOT_FOUND));
if (order.getStatus() != OrderStatus.PAYMENT_PENDING) {
return; // 멱등성 가드: 이미 처리된 주문은 무시
}
Order failed = order.failPayment();
orderRepository.save(failed);
eventPublisher.publishEvents(failed); // OrderCancelledEvent → 재고 복구
}
논리적으로는 완벽해 보입니다. PAYMENT_PENDING이 아니면 return하니까, 두 번 호출해도 안전하겠죠?
동시성 테스트에서 터진 버그
“5건의 동시 실패 콜백” 테스트를 작성했습니다. 재고가 15개인 상품에 대해 5개를 주문하고, 결제 실패 시 5개가 복구되어 20개가 되어야 합니다.
// 기대: 재고 15 + 복구 5 = 20
// 실제: 재고 15 + 복구 10 = 25 💥
재고가 25개가 됐습니다. 재고 복구가 2번 실행된 것입니다.
원인 분석
Thread A: findById(1) → PAYMENT_PENDING 읽음
Thread B: findById(1) → PAYMENT_PENDING 읽음 ← 같은 시점에 같은 상태!
Thread A: status != PENDING? → false → failPayment() → 재고 복구 ①
Thread B: status != PENDING? → false → failPayment() → 재고 복구 ② 💥
findById()는 일반 SELECT입니다. 두 스레드가 동시에 실행하면 둘 다 PAYMENT_PENDING을 봅니다. 멱등성 가드는 메모리에서만 동작하기 때문에 DB 레벨의 동시성을 제어하지 못합니다.
해결: 비관적 락
public void failPayment(Long orderId) {
// SELECT FOR UPDATE → 행 수준 락 획득
Order order = orderRepository.findByIdWithLock(orderId)
.orElseThrow(() -> new CoreException(ErrorType.ORDER_NOT_FOUND));
if (order.getStatus() != OrderStatus.PAYMENT_PENDING) {
return; // 이제 진짜 안전한 멱등성 가드
}
Order failed = order.failPayment();
orderRepository.save(failed);
eventPublisher.publishEvents(failed);
}
Thread A: findByIdWithLock(1) → PENDING 읽음 + 행 락 🔒
Thread B: findByIdWithLock(1) → 대기...
Thread A: failPayment() → FAILED 저장 + 재고 복구 → 커밋 → 락 해제
Thread B: findByIdWithLock(1) → FAILED 읽음 (커밋된 최신 데이터)
Thread B: status != PENDING → return ✅
배운 것
멱등성 가드는 “논리적 방어”일 뿐, DB 레벨 동시성 제어(SELECT FOR UPDATE)와 함께 써야 실제로 동작합니다.
단일 스레드에서 테스트하면 절대 발견할 수 없는 버그입니다. 결제처럼 “실패하면 돈이 안 맞는” 도메인에서는 반드시 동시성 테스트를 작성해야 합니다.
3장. 두 번째 시행착오 — Circuit Breaker 대기 시간
30초의 함정
Circuit Breaker의 wait-duration-in-open-state를 처음에 30초로 설정했습니다. Resilience4j 공식 문서의 예제가 60초였으니 절반인 30초면 적당하다고 생각했습니다.
wait-duration-in-open-state: 30s
하지만 결제 도메인의 특성을 간과했습니다.
사용자 관점에서 생각해보기
PG 장애가 발생하면 이런 일이 벌어집니다.
10:00:00 - 결제 실패율 50% 초과 → Circuit Open
10:00:00 ~ 10:00:30 - 모든 결제 요청이 Fallback으로 빠짐
사용자에게 "결제 처리 중" 메시지만 30초간 노출
10:00:30 - Half-Open → 3건 시험 → 성공 → Closed
30초 동안 모든 사용자가 결제를 할 수 없습니다. 쇼핑몰에서 30초는 사용자가 결제를 포기하고 다른 플랫폼으로 이동하기 충분한 시간입니다.
5초로 변경한 이유
| 대기 시간 | 사용자 경험 | PG 부하 | 판단 |
|---|---|---|---|
| 1초 | 즉시 재시도 | Open의 의미 없음 | ❌ |
| 5초 | 짧은 대기 | 적절한 쿨다운 | ✅ |
| 30초 | 결제 불가 상태 지속 | PG 충분히 쉼 | ❌ (결제 도메인) |
결정적인 이유는 콜백 + 스케줄러라는 보조 수단이 있기 때문입니다. Circuit Breaker가 5초 후에 빠르게 닫혀서 PG에 요청을 보내도, 설령 다시 실패하더라도 콜백과 스케줄러가 최종적으로 상태를 보정합니다.
Circuit Breaker는 “PG를 보호하기 위한 장치”이지만, 결제 도메인에서는 “사용자를 보호하는 것”이 더 중요합니다.
4장. 세 번째 시행착오 — Retry 전략
고정 간격의 문제
# 처음 설정
max-attempts: 2
wait-duration: 1s # 고정 간격
코드 자체는 문제없이 동작합니다. 하지만 서버가 여러 대일 때를 생각해보면:
10:00:00.000 - 서버 A: PG 호출 실패
10:00:00.005 - 서버 B: PG 호출 실패
10:00:00.010 - 서버 C: PG 호출 실패
10:00:01.000 - 서버 A: 재시도 ← 동시!
10:00:01.005 - 서버 B: 재시도 ← 동시!
10:00:01.010 - 서버 C: 재시도 ← 동시!
모든 서버가 정확히 1초 후에 동시에 재시도합니다. PG가 과부하 상태인데 여러 서버가 같은 타이밍에 몰려오면 상황이 악화됩니다. 이를 Thundering Herd 문제라고 합니다.
Exponential Backoff로 변경
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
# 1회차: 500ms 대기 → 2회차: 1s 대기 → 3회차: 바로 실행
점진적으로 대기 시간을 늘려서 PG에 복구 시간을 줍니다. 완벽한 해결은 아닙니다. Jitter(랜덤 지연)를 추가하면 서버 간 재시도 타이밍을 분산할 수 있지만, 현재 Resilience4j 설정으로는 기본 Exponential Backoff까지만 적용 가능합니다.
Exponential Backoff와 Jitter 전략의 비교 분석은 AWS Architecture Blog - Exponential Backoff And Jitter에서 그래프와 함께 자세히 확인할 수 있습니다.
왜 3회인가?
결제 도메인에서 재시도 횟수를 보수적으로 잡는 이유가 있습니다.
PG가 실제로는 결제를 승인했는데 응답만 타임아웃으로 실패한 경우를 생각해보세요. 클라이언트(우리)는 실패로 판단하고 재시도합니다. PG에 멱등키(idempotency key)가 없으면 같은 카드로 같은 금액이 두 번 결제됩니다.
[Commerce API] → POST /payments (orderId=1, amount=50000) → [PG: 승인 ✅]
← 타임아웃 (응답 유실)
[Commerce API] → POST /payments (orderId=1, amount=50000) → [PG: 또 승인 ✅] 💥
이중 결제는 사용자 신뢰를 완전히 무너뜨리는 사고입니다. 재시도는 최소한으로 하되, 콜백과 스케줄러로 보정하는 전략이 더 안전합니다.
5장. Fallback의 구조적 모순
모순을 알면서도 남겨둔 이유
private PaymentResult requestPaymentFallback(UserId userId, PaymentCommand command, Exception e) {
try {
// Circuit Breaker가 열린 이유가 PG 장애인데... 또 PG를 호출?
return toPaymentResult(getPaymentStatus(userId, command.orderId()));
} catch (Exception ex) {
return new PaymentResult(null, PaymentStatus.PENDING, "PG 일시적 장애로 결제 대기 중");
}
}
PG 장애로 Circuit이 열렸는데, Fallback에서 같은 PG의 조회 API를 호출합니다. 모순입니다.
그래도 유지한 이유:
PG 내부적으로 결제 생성(POST /payments)과 조회(GET /payments)는 다른 시스템일 수 있습니다.
| 시나리오 | 생성 API | 조회 API | Fallback 효과 |
|---|---|---|---|
| PG 쓰기 서버만 장애 | ❌ | ✅ | 실제 결제 상태를 반환할 수 있음 |
| PG 전면 장애 | ❌ | ❌ | 두 번째 catch → PENDING 반환 (안전) |
| 네트워크 단절 | ❌ | ❌ | 두 번째 catch → PENDING 반환 (안전) |
최악의 추가 비용은 read-timeout(5초) 1회입니다. 전면 장애 시에도 두 번째 catch에서 안전하게 PENDING을 반환하므로, 시도할 가치가 있다고 판단했습니다.
개선 방향:
조회 API에 별도 Circuit Breaker(pg-simulator-query)를 적용하면 전면 장애 시 즉시 Fallback으로 빠질 수 있습니다. 또는 Fallback에서 PG 재호출을 아예 제거하고 즉시 PENDING을 반환하는 방식도 있습니다. 어느 쪽이 맞는지는 실제 PG의 장애 패턴(부분 장애 빈도)에 따라 달라집니다.
6장. 설정값에는 근거가 있어야 한다
“왜 이 숫자인가?”라는 질문에 “다른 데서 이렇게 하길래”는 좋은 답이 아닙니다.
Circuit Breaker
sliding-window-size: 10
failure-rate-threshold: 50
왜 10건, 50%인가?
PG Simulator의 정상 거절률을 먼저 계산합니다.
- 한도 초과: 20%
- 카드 오류: 10%
- 정상 거절률: ~30%
10건 중 3건은 정상적인 거절입니다. 임계치를 30%로 설정하면 정상 상태에서도 Circuit이 열립니다. 50%로 설정하면 10건 중 5건 초과(6건 이상)가 실패해야 열리므로, 정상 거절과 시스템 장애를 구분할 수 있습니다.
정상 상태: 10건 중 3건 실패 (30%) → Closed ✅
시스템 장애: 10건 중 7건 실패 (70%) → Open ✅
경계 구간: 10건 중 5건 실패 (50%) → Closed (여유분)
운영 환경에서 바꿔야 할 것:
- TPS가 높으면 sliding-window-size를 키워야 합니다. 10건은 TPS 1~10 수준에서 적합합니다.
- 실제 PG의 거절률을 모니터링한 후 failure-rate-threshold를 (거절률 + 15%) 정도로 조정해야 합니다.
스케줄러
fixedDelay: 300_000 # 5분
BATCH_SIZE: 100
PENDING_THRESHOLD: 5분
왜 5분 주기인가?
콜백이 주된 복구 수단입니다. PG가 정상이면 콜백은 1분 내에 도착합니다. 5분은 “콜백이 정말 유실되었다”를 확인하기 충분한 시간입니다. 1분 주기는 콜백이 아직 도착 중인 건을 불필요하게 PG에 재조회하게 됩니다.
왜 100건 배치인가?
스케줄러가 PG 조회 API를 호출합니다. 100건 × read-timeout(5초) = 최악 8분 20초. 스케줄러 주기(5분)와 겹칠 수 있지만, fixedDelay(이전 실행 완료 후 5분)이므로 겹치지 않습니다.
fixedRate: |--실행--|--5분--|--실행--| ← 이전 실행이 끝나지 않으면 겹침
fixedDelay: |--실행--|--5분--|--실행--| ← 이전 실행 완료 후 5분 대기
무제한 조회는 PENDING 주문이 수만 건일 때 메모리와 PG 부하를 유발하므로 100건으로 제한했습니다.
7장. 코드 리뷰에서 배운 것들
PR 제출 후 코드 리뷰에서 12건의 문제가 발견되었습니다. 처음에 “이 정도면 충분하다”고 생각했지만, 리뷰를 통해 운영 관점에서의 빈틈을 많이 발견했습니다.
“이건 진짜 위험했다” — Critical
동시성 경합으로 재고 이중 복구 (2장에서 설명)
이건 코드 리뷰가 아니라 동시성 테스트에서 발견했습니다. 만약 테스트 없이 배포했다면 사용자의 돈이 안 맞는 사고가 발생했을 것입니다.
“모르고 있었다” — Major
@RequestAttribute 속성 이름 미지정
// AS-IS: 기본값 "userId"를 찾음 → 인터셉터가 설정한 "authenticatedUserId"와 불일치
public ResponseEntity<Void> createOrder(@RequestAttribute UserId userId, ...)
// TO-BE
public ResponseEntity<Void> createOrder(@RequestAttribute("authenticatedUserId") UserId userId, ...)
인터셉터에서 request.setAttribute("authenticatedUserId", ...)로 설정하는데, @RequestAttribute에 이름을 생략하면 파라미터명(userId)으로 찾습니다. createOrder만 이 방식이고, 다른 메서드들은 request.getAttribute("authenticatedUserId")로 직접 꺼내서 문제가 없었습니다. 같은 컨트롤러 안에서 두 가지 방식이 섞여있는 것이 근본 원인입니다.
결제 상태를 String으로 관리
// AS-IS: 오타 한 글자로 결제가 틀어짐
if ("SUCESS".equals(command.status())) { // SUCCESS 오타 → 절대 true가 안 됨
updateOrderPaymentUseCase.completePayment(orderId);
}
// TO-BE: 컴파일 타임에 잡힘
switch (command.status()) {
case SUCCESS -> updateOrderPaymentUseCase.completePayment(orderId);
case FAILED -> updateOrderPaymentUseCase.failPayment(orderId);
case PENDING -> { }
}
PaymentStatus enum을 만들고, 컨트롤러 경계에서 PaymentStatus.from(String)으로 파싱하도록 변경했습니다. 잘못된 상태값이 들어오면 서비스 내부가 아니라 컨트롤러에서 즉시 VALIDATION_ERROR를 반환합니다.
“운영에서 터질 뻔했다” — 보안/운영
카드번호 로그 노출
Java의 record는 기본 toString()이 모든 필드를 출력합니다. 장애 분석 중 로그를 그대로 남기면 카드번호가 평문으로 노출됩니다.
// record 기본 toString()
// "CreatePayment[orderId=1, cardType=VISA, cardNo=1234-5678-9012-3456, ...]"
// 오버라이드 후
// "CreatePayment[orderId=1, cardType=VISA, cardNo=****-****-****-3456, ...]"
PG 장애를 500으로 분류
// AS-IS: PG 장애 = 내부 서버 오류? → 알림 폭주, 원인 분리 불가
PAYMENT_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, ...)
// TO-BE: 외부 서비스 장애는 502
PAYMENT_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, ...)
운영 환경에서 500과 502를 분리하면 “우리 코드 문제 vs 외부 PG 문제”를 대시보드에서 즉시 구분할 수 있습니다.
8장. 아직 해결하지 못한 문제
완벽한 코드는 없습니다. 의도적으로 미적용한 부분과 그 이유를 솔직하게 남깁니다.
콜백 엔드포인트에 인증이 없다
@PostMapping("/callback")
public ResponseEntity<Void> handleCallback(@RequestBody CallbackRequest request) {
// 누구나 호출 가능 — 결제 없이 주문 완료 가능 💥
}
공격자가 {"orderId": "1", "status": "SUCCESS"}를 보내면 결제 없이 주문이 완료됩니다. 운영 환경에서는 HMAC 서명 검증 또는 IP 화이트리스트가 필수입니다.
결제 이력이 Commerce API에 없다
현재 결제 정보(transactionKey, 카드번호, 금액)는 PG에만 저장됩니다. PG가 장애나면 결제 이력 조회가 불가능합니다. 운영 환경에서는 Commerce API에 Payment 엔티티를 추가하여 로컬에 결제 이력을 저장해야 합니다.
스케줄러 다중 인스턴스 중복 실행
@Scheduled는 서버가 여러 대일 때 모든 인스턴스에서 동시에 실행됩니다. 비관적 락 덕분에 정합성은 보장되지만, 불필요한 PG 호출과 DB 락 경합이 발생합니다. ShedLock(Redis 분산 락)으로 단일 인스턴스만 실행되도록 해야 합니다.
9장. 동시성 테스트 — 무엇을 증명했는가
“테스트 5종 통과”보다 중요한 것은 각 테스트가 무엇을 증명하는지입니다.
| 시나리오 | 증명하는 것 | 이 테스트가 없으면? |
|---|---|---|
| 10건 동시 주문 | 비관적 락 순서 보장 | 재고가 음수로 갈 수 있음 |
| 5건 중복 콜백 | 멱등성 + 비관적 락 조합 | 주문 상태가 여러 번 전이 |
| 콜백 + 상태 조회 동시 | 콜백 경로와 스케줄러 경로의 경합 안전성 | 같은 주문이 SUCCESS와 FAILED로 동시 처리 |
| 5건 동시 실패 콜백 | 재고 이중 복구 방지 | 돈이 안 맞음 (이 글의 2장) |
| Circuit Breaker 장애 | Fallback → PENDING 반환 | 사용자에게 500 에러 노출 |
4번 테스트가 가장 중요합니다. 이 테스트가 없었다면 2장의 버그를 발견하지 못했을 것입니다.
마치며
결제 코드의 체크리스트
이번 경험을 통해 결제 도메인 코드를 작성할 때 반드시 확인해야 할 체크리스트를 정리했습니다.
동시성
- 상태 변경에 DB 레벨 락이 적용되어 있는가? (멱등성 가드만으로는 부족)
- 동시성 테스트를 작성했는가? (단일 스레드 테스트로는 발견 불가)
장애 복원력
- 외부 서비스 호출에 타임아웃이 설정되어 있는가?
- Circuit Breaker 설정값에 근거가 있는가? (정상 거절률 기반)
- Fallback이 또 다른 장애 지점이 되지 않는가?
- 모든 경로가 실패해도 사용자에게 의미 있는 응답을 반환하는가?
보안
- 카드번호가 로그에 평문으로 노출되지 않는가?
- 콜백 엔드포인트에 인증/검증이 있는가?
- 외부 입력값이 서비스 내부까지 검증 없이 전파되지 않는가?
운영
- 내부 오류(500)와 외부 장애(502)가 분리되어 있는가?
- 스케줄러가 다중 인스턴스 환경에서 안전한가?
- 설정값(Circuit Breaker, Retry, 스케줄러 주기)을 모니터링 기반으로 튜닝할 준비가 되어 있는가?
두려움의 원인은 검증 부족이었다
처음 결제 연동을 시작할 때 막연한 두려움이 있었습니다. “외부 서비스와 통신하는 코드를 내가 잘 짤 수 있을까?”, “장애 나면 어떡하지?”
화해 기술 블로그에서 비슷한 이야기를 읽었습니다.
“막연한 두려움을 가졌던 이유는 근거와 더불어 검증이 충분하지 않기 때문이라 느꼈습니다.”
돌이켜보면 맞는 말입니다. 두려움이 줄어든 시점은 코드를 완성했을 때가 아니라, 동시성 테스트에서 재고 이중 복구 버그를 발견하고 고쳤을 때였습니다. “이 테스트가 통과하면 최소한 돈은 맞는다”는 확신이 생기니 배포가 무섭지 않았습니다.
설정값도 마찬가지입니다. Circuit Breaker의 wait-duration을 30초에서 5초로 바꿀 때, “5초면 너무 짧지 않나?”라는 불안감이 있었습니다. 하지만 “콜백 + 스케줄러가 보조 수단이므로 Circuit Breaker가 빨리 닫혀도 최종 정합성은 보장된다”는 근거를 세우니 확신이 생겼습니다.
근거 없는 설정은 불안하고, 검증 없는 코드는 두렵습니다. 결제처럼 “실패하면 돈이 안 맞는” 도메인에서는 코드를 작성하는 시간보다 검증하는 시간이 더 중요하다는 것을 배웠습니다.
물론 이 프로젝트는 PG Simulator라는 가상 서비스를 만들어 연동한 것이므로, 실제 PG사 연동과는 차이가 있습니다. 실제 운영에서는 PG사의 API 인증, 서명 검증, 멱등키, 정산 연동 등 훨씬 더 많은 고려 사항이 존재합니다. 하지만 장애 복원력의 핵심 패턴(Circuit Breaker, Retry, Fallback)과 동시성 안전의 원칙은 가상 서비스든 실제 서비스든 동일합니다. 이 경험이 실제 PG 연동을 앞둔 분들에게 기초 체력이 되길 바랍니다.
긴 글 읽어주셔서 감사합니다.
참고 자료