티스토리 뷰

서킷이 열리면 결제도 실패한 걸까요? PG에는 이미 성공이 기록됐지만, 우리 서비스는 그 결과를 조회하지 못하는 상황을 만들어봤습니다. 조회 실패와 결제 실패를 제 코드가 어떻게 구분하는지 확인해보겠습니다.
가설
서킷이 열려 조회하지 못하더라도 기존 결제 상태는 유지되고, 이후 조회에서 확인한 결과에 따라 주문이 처리될 것으로 예상했습니다.
서킷브레이커는 외부 호출이 반복해서 실패할 때 호출을 잠시 막습니다. OPEN은 그 차단 상태를 뜻합니다. 지금 보낼 조회가 막혔다는 뜻이지, 앞서 보낸 결제가 실패했다는 뜻은 아닙니다.
이번 상황은 PG 한 곳만 사용하는 서비스입니다.
결제 요청은 접수됐고 거래키도 받았지만, 최종 성공 여부는 아직 우리 DB에 반영하지 못했습니다.
여기서 실패를 단정하면 PG에서는 결제됐는데 주문은 취소될 수 있습니다. 반대로 성공을 단정할 근거도 없습니다.
그래서 기존 결제 상태와 거래키, 예약 재고를 유지하고 다시 확인할 수 있어야 한다고 봤습니다.
새 결제를 보내는 대신, 이미 보낸 거래를 조회하는 방향입니다.
테스트
어떤 환경에서 확인했나
Java 21, Spring Boot 3.4.4, Resilience4j 2.3.0 환경입니다.
실제 결제 처리 코드와 DB 저장까지 확인하려고 통합 테스트로 실행했습니다.
MySQL·Redis는 테스트용 컨테이너, PG는 제 컴퓨터에서 실행한 HTTP 서버를 사용했습니다.
테스트용 PG에는 거래 상태와 요청 횟수가 기록되므로, PG가 아는 결과와 우리 DB에 저장된 결과를 따로 확인할 수 있습니다.
상품 재고는 5개, 주문 수량은 2개로 준비했습니다. 결제 요청을 보내기 전에 2개를 확보하므로, 가용 재고는 3개에서 시작합니다.
PG는 성공했지만, 조회는 막힌 상황
- 결제 요청을 보내 접수 응답과 거래키를 받습니다. 우리 결제와 주문은 대기 상태입니다.
- 테스트용 PG의 거래를 성공으로 바꿉니다. 콜백은 보내지 않아 우리 DB는 아직 대기 상태로 둡니다.
- 다른 거래의 조회에 HTTP 503을 10번 응답하게 해, 실제 서킷을 OPEN으로 만듭니다.
- PG 조회 응답을 정상으로 돌린 뒤 같은 주문을 조회합니다. PG는 응답할 수 있지만, 서킷은 아직 OPEN인 상황입니다.
- OPEN 대기 시간 10초를 넘기려고 실제로 11초를 기다린 뒤 다시 조회합니다. 이어서 같은 성공 결과를 한 번 더 조회합니다.

비교를 위해 PG가 결제 실패를 응답하는 경우도 별도로 실행했습니다.
조회를 못 한 경우와, 조회해서 실패를 확인한 경우가 다르게 처리되는지 확인하려는 목적입니다.
결과
조회가 막혀도 결제 실패로 바뀌지 않았습니다
PG에는 성공이 기록돼 있었지만, 차단된 조회는 PG까지 가지 않았습니다.
우리 결제는 PENDING(대기), 주문도 결제 대기를 유지했습니다. 거래키와 예약 재고도 그대로였습니다.
코드에서는 이 상황을 결제 상태와 분리해서 반환합니다.
lookupAvailable=false는 이번 조회로 결과를 확인하지 못했다는 표시입니다.
return new Result(
transactions.findForReconciliation(
userId, orderId),
false
);
실패 상태를 새로 저장하지 않고, 현재 저장된 결제를 다시 읽습니다.
이미 성공이 저장돼 있었다면 그 성공도 유지됩니다.
다시 조회했을 때 성공을 반영했습니다
11초 뒤 다시 조회하자, 일부 호출을 시험적으로 허용하는 HALF_OPEN 상태에서 PG로 요청이 전달됐습니다. 성공 결과를 확인한 뒤 서킷은 일반 호출을 허용하는 CLOSED로 돌아왔고, 우리 결제와 주문에도 성공을 반영했습니다.
| 확인 항목 | 조회가 차단됐을 때 | 성공을 다시 확인한 뒤 |
|---|---|---|
| PG의 결제 | 성공 | 성공 |
| 우리 결제 | 대기 | 성공 |
| 우리 주문 | 결제 대기 | 주문 확정 |
| 예약 재고 | 확보 유지 | 확정 주문에 사용 |
| 가용 재고 | 3개 | 3개 |
| 누적 결제 생성 요청 | 1회 | 1회 |
가용 재고가 계속 3개인 이유는 주문할 때 이미 2개를 확보했기 때문입니다.
성공을 확인할 때는 예약을 사용 완료로 바꾸고, 재고를 또 차감하지 않습니다.
같은 성공을 한 번 더 조회해도 재고는 3개, 결제 생성 요청도 1회였습니다.
이 테스트에서는 같은 주문을 직접 다시 조회했습니다. 백그라운드 작업이 자동으로 복구해 준 결과는 아닙니다.
조회 차단과 달리, 결제 실패를 확인하면 재고를 반환합니다
앞에서는 서킷에 막혀 PG의 결제 결과를 조회하지 못했습니다. 그래서 기존 결제 상태와 예약 재고를 유지했습니다.
이번에는 비교를 위해 조회가 정상적으로 완료되고, PG의 결제 결과가 FAILED인 상황을 별도로 테스트했습니다.
결제 실패가 확인되자 우리 결제와 주문도 실패로 바뀌었습니다. 예약은 해제됐고, 가용 재고는 3개에서 5개로 돌아왔습니다.
결과를 확인하지 못한 상황과, 실패라는 결과를 확인한 상황을 다르게 처리한 셈입니다.
다만 조회가 계속 막히면 예약 재고도 계속 점유하게 됩니다.
결제 결과를 모르는 상태에서 재고를 언제까지 잡아둬야 할까요?
이번 테스트에서는 조회가 다시 가능해진 뒤 결과를 반영하는 흐름까지 확인했고,
장기 장애에서 재고를 유지하거나 해제할 기준은 정하지 않았습니다.
느낀 점
- 서킷은 호출을 막을 뿐, 결제 결과를 결정하지 않습니다. 조회 불가와 결제 실패를 따로 표현해야 했습니다.
- 결과를 모르는 동안에는 거래키와 기존 상태를 남겨야 다시 확인할 수 있습니다.
- 기다리는 데도 비용이 있습니다. 예약 재고가 계속 남으므로, 장애가 길어질 때 언제 재조회하고 언제 취소·환불할지는 별도 정책이 필요합니다.
- 이번에 확인한 복구는 기존 거래를 다시 조회해 결과를 반영하는 과정입니다. 재조회 자체가 계속 실패하거나 PG 기록이 모호한 상황은 여전히 남아 있습니다.
참고 자료
- Resilience4j CircuitBreaker 공식 문서: OPEN·HALF_OPEN·CLOSED 상태 전이
'개발 > Spring & Spring Boot' 카테고리의 다른 글
| Gradle 의존성 이해하기 (0) | 2026.07.21 |
|---|---|
| 초기 세팅시 스프링부트 메인을 실행했는데 꺼지는 이유 (1) | 2025.04.10 |
| 메소드 파라미터마다 Final 하는 이유 (0) | 2023.10.21 |
| [Spring] 프록시 객체를 이용하는 이유 (0) | 2023.05.29 |
| API 응답값으로 MAP을 지양하는이유 (3) | 2022.08.21 |
- Total
- Today
- Yesterday
- 네트워크
- Docker
- rds
- tcp
- osi7계층
- 라우터
- 자바
- ec2
- 개발
- SpringBoot
- spring
- aws
- s3
- 회고록
- 삽질
- 3Way Handshake
- 라우팅
- 프로토콜
- Spring Boot
- 스프링
- 회고
- 계층
- dto
- 개발자
- 프로그래머스
- lambda
- 초보
- java
- 요금
- 스위치
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |