STAYONE KOREA의 예약 시스템을 설계하면서 가장 깊이 고민했던 부분 중 하나는 "결제 시각을 어디 기준으로 저장해야 하는가"였습니다. 결제 데이터는 기본적으로 payments 테이블에서 관리되고 있습니다. 결제 요청, 승인, 실패, 환불 등 모든 이벤트가 기록되므로, 처음엔 ‘PG(결제대행사)가 넘겨주는 시각 하나만 저장하면 충분하다고 생각’했습니다.
하지만 실제 운영에서는 그렇게 단순하지 않았습니다. 서비스와 PG사 간 통신은 완전히 비동기적이기 때문에, “결제를 언제 요청했고, 언제 완료되었는가”를 단일 시각으로는 명확히 표현할 수 없었습니다.
문제 상황
예약은 생성 시점으로부터 24시간 이내에 결제 요청이 완료되어야 합니다.
이 시간 안에 결제가 승인되지 않으면 예약은 자동으로 expired 상태로 전환됩니다.
문제는 이 과정이 PG사와 비동기로 동작한다는 점입니다.
아래는 실제로 발생했던 타임라인입니다.
| 12:00 | 예약 생성 | bookings.status = pending |
| 11:59 (다음날) | 결제 요청 | payments.status = requested |
| 12:00 (만료 시점) | 예약 만료 처리 | bookings.status = expired |
| 12:02 | PG 승인 완료 (pg_approved_at) | PG 기준 “결제 성공” |
| 12:03 | 서버가 Webhook 수신 | payments.status = success |
이 경우,
PG 입장에서는 결제가 정상적으로 승인된 거래입니다.
하지만 서비스에서는 이미 24시간이 지나 예약이 만료된 상태입니다.
그 결과, payments는 성공(success), bookings는 만료(expired) 로 기록되어 데이터가 충돌하는 상황이 발생합니다.
문제의 원인
이 문제는 시스템간의 요청/응답에 지연이 발생하기 때문입니다.
요청과 완료 각각에 대해 두 개의 시스템(서비스 ↔ PG) 이 존재합니다.
즉, 실제로는 4개의 시각이 있습니다.
| 결제 요청 | requested_at | pg_requested_at | 서비스가 결제를 요청한 시각 / PG가 요청을 수신한 시각 |
| 결제 완료 | service_confirmed_at | pg_approved_at | 서비스가 Webhook으로 결제 성공을 인지한 시각 / PG가 결제를 승인한 시각 |
이 네 시각이 서로 달라지는 이유는 네트워크 지연, PG 내부 큐잉 및 승인 처리 지연, Webhook 전송/수신 지연등으로 인해 발생합니다. 이 차이로 인해 “PG 기준 결제 성공, 서비스 기준 결제 실패”인 상태가 만들어집니다.
즉, 서비스는 이미 실패로 처리했지만, 결제는 나중에 성공하는 상황이 일어나는 것입니다.
더욱 큰 문제는 이러한 상황에서 PG사가 제공하는 결제 완료 시간만을 기록한다면 해당 문제를 발견할 수도, 해결할 수도 없습니다.
조치: 네 개의 결제 시각을 모두 기록하기로 함
이를 해결하기 위해, payments 테이블에 다음 네 개의 컬럼을 추가했습니다.
requested_at TIMESTAMP NOT NULL, -- 서비스가 요청한 시각
pg_requested_at TIMESTAMP NULL, -- PG가 요청을 수신한 시각
pg_approved_at TIMESTAMP NULL, -- PG가 결제 승인 완료한 시각
service_confirmed_at TIMESTAMP NULL -- 서비스가 Webhook을 통해 승인 인지한 시각
1. 요청과 수신은 다르다
결제 요청이 24시간 내에 이루어져야 한다는 조건을 검증할 때, 서비스는 requested_at을 기준으로 판단합니다.
하지만 실제로 PG가 요청을 늦게 받았다면(pg_requested_at이 만료 이후라면) PG 입장에서는 “만료 이후 결제 요청”으로 처리될 수 있습니다. 이 차이를 저장해두면 "우리는 제때 요청했지만 PG가 늦게 받았다"는 사실을 알 수 있습니다.
2. 만료 상태 확정의 기준이 서비스 시간이기 때문
PG 승인 시간(pg_approved_at)은 외부 시스템 기준입니다. 서비스에서는 "우리 시스템이 결제 성공을 인지한 시점"을 기준으로 상태를 바꿔야 합니다. 즉, 결제 성공을 알리는 Webhook이 도착했을 때 그 응답 시간을 저장해야 합니다. 이 시각은 서비스 로직의 기준 시각이며, 24시간 유효시간 검증과 예약 확정 여부 판단에 직접 사용됩니다.
3. 상태 불일치(Expired / Success)를 구분하기 위해
Webhook이 늦게 도착한 경우, PG 승인 시각은 유효시간 내이더라도, 서비스에서는 이미 예약을 expired로 처리했을 수 있습니다.
이때 결제 완료 시각이 bookings에 저장되어 있으면, "결제 승인 시각은 유효했지만 예약은 만료된 상태"임을 정확히 식별할 수 있습니다. 이는 PG사와 서비스의 문제이므로 만료된 상태가 아닌 유요한 예약으로 상태를 전환할 수 있습니다.
4. 문제 원인을 명확히 구분할 수 있다
-- 서비스 → PG 요청 지연
SELECT AVG(pg_requested_at - requested_at) FROM payments;
-- PG 승인 → 서비스 수신 지연
SELECT AVG(service_confirmed_at - pg_approved_at) FROM payments;
이렇게 하면 어느 구간에서 병목이 생겼는지 한눈에 파악할 수 있습니다. PG 문제인지, 네트워크 문제인지, 아니면 우리 서버의 문제인지 명확히 구분됩니다.
또한 요청 -> 승인까지 걸린 전체 시간을 기록하고 실제 결제 흐름의 성능을 수치화 할 수 있습니다.
SELECT
AVG(service_confirmed_at - requested_at) AS total_latency
FROM payments
WHERE status = 'success';
이를 통해 평균 결제 지연 시간을 측정하고, 특정 PG사나 결제 수단의 응답 속도 문제를 실시간으로 감시할 수 있습니다.
결과
이 구조로 변경한 이후 다음과 같은 효과를 얻었습니다.
- 결제 상태 판단의 정확도 향상
요청과 완료 시각을 서비스/PG 기준으로 분리 저장함으로써,
상태 불일치를 체계적으로 추적할 수 있게 되었습니다. - 지연 원인 분석의 명확화
결제 실패나 만료 충돌 시, 지연의 원인이 PG인지 서비스인지 한눈에 파악 가능해졌습니다. - 정산 및 예약 일관성 확보
PG 기준과 서비스 기준의 시각이 모두 기록되어,
결제·예약·정산 데이터의 불일치 문제를 근본적으로 차단했습니다.
이렇게 상태 충돌로 인한 해결 로직을 구현하였더라도 결제 지연으로 인한 상태 충돌이 발생하지 않도록 하는것이 최대한 좋은 상황입니다. 따라서 실제 로직에서는 24시간 5분 정도의 유예 시간을 두는 것이 가장 합리적입니다.
이 정도의 여유는 시스템 안정성과 사용자 경험 사이의 균형을 잡아주어, “늦게 승인된 정상 결제”를 불필요하게 실패로 처리하는 일을 방지할 수 있습니다.
결론
요청과 완료 각각에 대해 서비스 기준 시각과 PG 기준 시각을 모두 저장함으로써, 결제 흐름을 정확히 추적할 수 있고, 문제 발생 시 원인을 명확히 구분할 수 있으며, 사용자와 PG, 두 시스템의 신뢰를 모두 지킬 수 있습니다.
'개발 > 서버' 카테고리의 다른 글
| 개발 회고 : 예외가 발생하면 로그 레벨은 error로 설정해야할까? (0) | 2025.10.18 |
|---|---|
| 왜 PostgreSQL을 선택했는가: 다국어 서비스를 위한 JSONB 활용 (0) | 2025.10.16 |
| Can't create table `visit_plans` (errno: 150 "Foreign key constraint is incorrectly formed") - JPA에서 테이블 복사 후 외래키 에러 해결 (1) | 2025.09.23 |
| Unknown table 'SEQUENCES' in information_schema (1) | 2025.09.22 |
| 프랜차이즈 데이터 관리 구조 개선: 33개 테이블에서 12개의 테이블로 (0) | 2025.08.20 |
댓글