안녕하세요. 이번 주에는 주문 시스템의 결제 방식을 바꾸는 과제를 진행했습니다. 기존의 내부 포인트 결제 방식에서 외부 PG사를 호출하는 방식으로 전환하는 작업이었습니다. 생각보다 많은 고민을 하게 되었고 이 글에서는 그 고민의 과정을 순서대로 따라가 보려 합니다.
주문 상태와 트랜잭션 분리
기존 구조는 단순했습니다. 포인트 차감, 주문 상태 변경, 재고 차감을 하나의 트랜잭션 안에서 처리했습니다.
하지만 이제는 그 사이에 외부 호출이 끼어듭니다. PG사 호출은 우리 DB의 트랜잭션이 통제할 수 없는 영역이고, 외부 호출을 트랜잭션 안에 넣으면 응답을 기다리는 동안 커넥션과 락을 붙잡고 있게 됩니다. 하나의 트랜잭션으로는 처리할 수 없습니다.
먼저 주문 상태를 하나 추가했습니다.
기존: DRAFT → CONFIRMED / FAILED
변경: DRAFT → AWAITING_PAYMENT → CONFIRMED / FAILED
그리고 처리 흐름을 두 개의 트랜잭션으로 나누었습니다.
[트랜잭션 1] 주문 상태 AWAITING_PAYMENT로 변경, 재고 차감
↓
[외부 호출] PG사 결제 요청 (POST)
↓
[트랜잭션 2] 주문 상태 CONFIRMED 또는 FAILED로 변경 (실패 시 재고 복구)
1. 트랜잭션 2의 멱등성과 동시성
PG사에는 동기 결제 호출에 대한 응답 말고도 callback 메커니즘이 있고, 우리가 직접 GET으로 현재 결제 상태를 질의할 수도 있습니다. 즉 트랜잭션 2가 실행될 수 있는 창구가 세 개입니다. 그리고 이 세 개는 심지어 동시에 일어날 수도 있습니다. 그러므로 트랜잭션 2는 멱등성과 동시성 문제를 모두 해결해야 합니다.
멱등성은 어떻게 해결할까요?
주문 상태가 AWAITING_PAYMENT인 경우에만 후속 작업을 수행하면 됩니다.
동시성은 어떻게 해결할까요?
그 상태를 확인할 때 X-lock을 걸면 된다. 그러면 동시에 들어오는 요청들은 직렬화되고, 먼저 처리된 요청이 상태를 바꿔 놓았기 때문에 뒤따르는 요청은 아무것도 하지 않고 끝납니다.
SELECT * FROM orders WHERE id = ? FOR UPDATE;
-- status = 'AWAITING_PAYMENT' 일 때만 상태 변경 및 후속 처리

2. PG 결제 요청, 정확히 한 번은 가능할까
외부 서버에 요청을 보낼 때 우리가 바라는 최선은 exactly once입니다. 하지만 네트워크에는 근본적인 모호성이 있습니다. 그래서 순수한 의미의 exactly once는 불가능합니다. 우리가 흔히 exactly once라고 부르는 것은 사실 호출받는 서버가 멱등성 메커니즘(예: 멱등 키)을 제공하는 상황에서의 at least once입니다.
이번 과제의 PG사는 멱등성 메커니즘을 지원하지 않았습니다. 결국 at least once와 at most once 중 하나를 골라야 했습니다.
저는 고객 경험 관점에서 at most once를 선택했습니다. 결제가 중복으로 일어나는 것이 결제가 실패하는 것보다 훨씬 나쁜 경험을 준다고 판단했습니다. 그래서 PG 결제 요청은 단 한 번만 보내고, 재시도는 하지 않기로 결정했습니다.
3. 고객의 인내심은 어디까지 일까?
과제 요구사항 중에는 이런 내용이 있었습니다. PG사 응답이 PENDING이거나 타임아웃이 발생하더라도 결제 실패로 단정하지 말 것.
그렇다면 계속 확인해도 PENDING이나 타임아웃만 돌아오면 어떻게 해야 할까요? 백엔드 입장에서만 보면 주문을 AWAITING_PAYMENT로 그냥 두는 것이 가장 단순한 구현입니다. 하지만 현실에서는 그럴 수 없습니다. 결제창 앞의 고객에게는 결국 성공이든 실패든 어떤 화면이 보여져야 합니다. 그래서 이런 상황을 어떻게 처리할지, 타임아웃을 얼마로 잡을지 고민했습니다.
먼저 고객 행동을 가정했습니다. 고객은 결제창에서 10초를 기다려 준다. 그러면 설계는 아래 두 가지를 만족해야 합니다.
- 결제 요청을 보내고 10초 안에 주문 상태 변경을 보장할 수 있는가?
- 그 10초 안에 결제 결과를 충분히 확인하는가?
여기서 결제 결과를 능동적으로 확인하는 수단은 두 가지입니다. 하나는 결제 요청(POST)에 대한 응답 그 자체이고, 다른 하나는 결제 상태 조회 API(GET)입니다.
처음 떠오른 방안은 두 가지였습니다. 방안 A는 POST 요청 하나로 모든 것을 처리하는 방식으로, 결제 요청의 read timeout을 9초로 두고 그 안에 결과가 오면 상태를 바꿉니다. 방안 B는 결제 요청의 타임아웃을 5초 정도로 짧게 두고, 타임아웃이 나면 상태 조회 GET 요청을 두 번 더 보내 결제 결과를 확인하는 방식입니다.

방안 A의 장점은 구현이 단순하다는 점입니다. 또 POST 요청의 타임아웃이 길수록 결제 결과를 받아낼 가능성이 높아진다고 봤습니다. 반면 9초 동안 스레드를 붙잡고 있어야 하므로 자원 점유가 상당하고, 단일 요청에 모든 것을 거는 구조라 불안한 구석이 있었습니다.
방안 B는 타임아웃이 짧아 스레드 자원을 덜 쓰고, 상태 조회를 통해 결제 결과를 한 번 더 교차 확인할 수 있다는 장점이 있었습니다.
고민 끝에 내린 결론은 두 방식의 장점을 섞는 것이었습니다. POST 요청의 read timeout은 9초로 두되, 그 결과와 관계없이 POST를 보낸 지 7초가 지난 시점에 상태 조회 GET 요청을 한 번 보냅니다. POST가 정상적으로 응답하면 그것으로 상태가 확정되고, POST 쪽 경로가 어딘가에서 막혀 있더라도 별도의 GET이 결과를 확인할 기회를 한 번 더 만들어 줍니다. 두 경로가 동시에 트랜잭션 2에 도달하더라도 앞에서 만든 락과 상태 검사가 이를 정리해 줍니다.
스레드 자원을 많이 쓰는 것은 사실입니다. 하지만 PG사에 장애가 나는 상황이라면 5초든 9초든 결과적으로 큰 차이는 없을 것이라고 판단했습니다. 대신 워커 스레드는 서킷 브레이커와 세마포어로 보호해, PG 장애가 서버 전체로 번지지 않도록 할 계획입니다.
4. 이걸로 정말 해결된 걸까
얼핏 보면 POST 요청이 결국 주문 상태를 확정 짓기 때문에 어떤 상황에서도 문제가 없어 보입니다. 하지만 두 가지 구멍이 남아 있었습니다.
첫 번째는 read timeout의 정의입니다. read timeout은 전체 응답 시간이 아니라 패킷과 패킷 사이의 간격을 잽니다. PG 서버가 패킷을 아주 천천히, 그러나 타임아웃에 걸리지 않을 만큼씩 흘려보낸다면 전체 대기 시간은 얼마든지 10초를 넘길 수 있습니다.
두 번째는 우리 서버 자체가 다운되는 경우입니다. 트랜잭션 1만 실행된 상태, 혹은 트랜잭션 1을 마치고 PG 요청까지 보낸 상태에서 서버가 내려가면, 그 주문은 누구도 정리해 주지 않은 채 AWAITING_PAYMENT로 남게 됩니다.
해결 방안으로는 두 가지가 떠올랐습니다. 하나는 결제 요청을 보내는 순간 이벤트를 발행하고, 그 이벤트가 9초 뒤 주문을 FAILED로 정리하게 하는 지연 이벤트 방식입니다. 다른 하나는 별도의 스케줄러가 주기적으로 AWAITING_PAYMENT 상태로 9초 이상된 주문을 FAILED로 만료시키는 방식입니다.
지연 이벤트 방식은 견고하게 만들려면 추가 인프라가 필요합니다. 게다가 서버가 죽는 상황에서도 이벤트 발행을 엄격히 보장하려면 outbox 패턴을 써야 하는데, outbox를 읽어가는 주체가 결국 주기적으로 도는 스케줄러입니다. 돌고 돌아 스케줄러 방식을 써야합니다.
그래서 스케줄러 방식으로 가기로 했습니다. 1초 주기로 도는 스케줄러입니다. 1초라는 주기가 조금 부담스럽긴 하지만, 하는 일 자체는 가볍습니다. 락을 잡기는 하지만 SKIP LOCKED 옵션을 쓰면 트랜잭션 2가 처리 중인 행은 건너뛰기 때문에 서로를 기다리지 않습니다.
앞서 말한 두 문제를 모두 해결했습니다. 느리게 흘러오는 응답이든 서버 다운이든, 9초가 지나면 주문은 반드시 확정됩니다.
5. 진짜로 다 해결된 걸까?
물론 스케쥴 서버가 다운되면 여전히 문제가 생깁니다. 이 부분은 이중화하는 등의 방법으로 대응할 수 있을 것 같습니다. 이를 일단 논외로 한다면, 이제 10초가 지나면 모든 주문은 CONFIRMED 또는 FAILED 중 하나로 확정됩니다.
남은 문제는 실제로는 결제가 되었는데 우리 쪽에서는 FAILED로 확정된 주문입니다. 이것은 이후 PG사의 결제 내역과 우리 주문 데이터를 대조하는 reconciliation을 통해 해결해야 합니다. PG사의 응답 자체가 시간에 따라 바뀔 수 있다면 머리가 한층 더 아파지겠지만, 이번에는 그런 경우는 다루지 않았습니다.
그리고 정말 마지막으로, 끝내 해결할 수 없는 케이스도 하나 있습니다. PG사 내부적으로는 결제가 성공했지만, PG사가 PENDING이나 TIMEOUT처럼 판단할 수 없는 응답만 영원히 돌려준다면 우리 쪽에서는 이를 알아낼 방법이 없습니다.
마치며
좋은 설계에선 장애가 났을 때 사람이 손대지 않아도 자연스럽게 복구되고, 결국에는 데이터가 일관된 상태로 수렴하게 됩니다. 이번 설계가 완벽하다고는 말할 수 없지만, 적어도 제가 고려한 케이스 안에서는 자연스러운 복구가 일어나도록 만들어 보았습니다.
그럼에도 여전히 1초 주기 스케쥴러는 사용해 본 경험이 없어서 우려가 남습니다.
'기타 주제' 카테고리의 다른 글
| 상품 목록 조회 쿼리를 개선해보자 (0) | 2026.10.14 |
|---|---|
| 낙관적 락과 비관적 락, 상황에 따라 무엇을 고를까 (0) | 2026.10.09 |
| 모호한 요청을 받았을 때 먼저 던지는 질문 (0) | 2026.09.10 |
| 안정 해시(Consistent Hashing) (0) | 2025.08.17 |