멱등성 배우고 결제 코드 다시 읽기

우리 서비스의 pgOrderId가 멱등키가 되기까지
2026년 3월 16일

🔑 멱등성 배우고 결제 코드 다시 읽기

서버 스터디를 시작하면서 HTTP 개념을 다시 훑게 됐다. 멱등성을 정리하다 보니 자연스럽게 궁금해졌다. "우리 서비스의 결제 시스템은 멱등성을 어떻게 다루고 있을까?" 백엔드 코드를 직접 열어보고 나서야 프론트와 서버, 토스페이먼츠 사이의 전체 흐름이 비로소 눈에 들어왔다.


멱등성이란?

멱등성(Idempotency)은 같은 요청을 여러 번 보내도 결과가 동일한 성질이다. 멱등한 API는 같은 요청을 1번 보내든 10번 보내든 서버 상태가 동일하다.


HTTP 메서드별 멱등성

모든 HTTP 메서드가 멱등하지는 않다.

메서드멱등성안전성설명
GETOO리소스를 조회만 한다. 서버 상태를 변경하지 않는다.
HEADOOGET과 동일하지만 응답 본문 없이 헤더만 반환한다. 리소스 존재 여부나 크기 확인에 쓴다.
OPTIONSOO서버가 지원하는 HTTP 메서드 목록을 반환한다. CORS preflight 요청에서 브라우저가 자동으로 보내는 메서드다.
PUTOX리소스를 전체 교체한다. 같은 데이터로 여러 번 PUT해도 결과는 같다.
DELETEOX리소스를 삭제한다. 이미 삭제된 리소스를 다시 삭제해도 "없는 상태"는 동일하다.
POSTXX리소스를 생성한다. 같은 요청을 두 번 보내면 두 개가 생길 수 있다.
PATCHXX리소스를 부분 수정한다. 구현에 따라 멱등하지 않을 수 있다.

멱등성 vs 안전성: 안전한(Safe) 메서드는 서버 상태를 아예 변경하지 않는다. GET, HEAD, OPTIONS가 해당한다. PUT과 DELETE는 서버 상태를 변경하지만 여러 번 호출해도 결과가 같으므로 멱등하지만 안전하지는 않다.

결제에서 POST가 문제인 이유

결제 승인은 POST /v1/payments/confirm으로 호출한다. POST는 본질적으로 멱등하지 않다. 네트워크 타임아웃으로 응답을 못 받았을 때 재시도하면, 같은 결제가 두 번 처리될 위험이 있다.

이 문제를 해결하는 것이 멱등키(Idempotency Key) 다.


토스페이먼츠의 멱등키 방식

토스페이먼츠 공식 문서에 따르면, 요청 헤더에 멱등키를 포함하는 방식을 사용한다.

동작 원리

멱등키 식별 기준

토스는 단순히 키 값만으로 판별하지 않는다. 아래 세 가지 조합으로 "같은 요청인지"를 판단한다.

  • API 키 (어떤 가맹점인지)
  • API 주소 (어떤 엔드포인트인지)
  • HTTP 메서드 (GET인지 POST인지)

에러 시나리오

상황HTTP 상태설명
멱등키 누락/형식 오류400키가 없거나 유효하지 않음
같은 키로 동시 요청409이전 요청이 아직 처리 중
같은 키 + 다른 요청 본문422키는 같은데 내용이 다름 (악용 방지)

우리 서비스의 결제 흐름

일단 우리 서비스의 경우는, 프론트에서 토스 API를 직접 호출하지 않는다. 프론트엔드 → 우리 서버 → 토스 서버 순서로 호출이 이루어진다. 멱등키를 프론트에서 헤더로 보내는 게 아니라, 우리 서버가 토스에 요청할 때 직접 멱등키를 붙인다.

이 멱등키로 사용하는 값이 pgOrderId 다.

전체 결제 플로우

결제 플로우 다이어그램
프론트엔드 → 우리 서버 → 토스 서버 순서의 전체 결제 흐름

pgOrderId — 우리의 멱등키

멱등키로 UUID 대신 pgOrderId를 사용한다. 이 값은 다음 규칙으로 생성된다.

  • orderNo: COMPANYNAME-{yyMMddHHmmss}-{4자리 HEX} 형식, 주문 생성 시 한 번만 발급
  • attemptNumber: 결제 시도 횟수 (1부터 시작, 재시도마다 증가)

왜 시도마다 새로운 멱등키를 쓸까?

처음에는 의아했다. "멱등키가 같아야 중복 결제를 방지하는 거 아닌가?"

하지만 같은 결제 시도 내에서의 네트워크 재시도사용자가 의도적으로 다시 시도하는 재결제는 다르다.

상황pgOrderId동작
네트워크 타임아웃으로 같은 요청 재전송COMPANYNAME-..._1 (동일)토스가 멱등키를 인식하고 기존 결과 반환
결제 실패 후 사용자가 다시 결제 시도COMPANYNAME-..._2 (새로 발급)새로운 결제로 처리

같은 시도 내에서는 중복을 방지하고, 새로운 시도에서는 정상 처리한다. 두 목표를 하나의 키 설계로 해결하는 셈이다.

서버에서 토스로 멱등키를 전달하는 방식

Idempotency-Key 헤더에 pgOrderId를 그대로 넣는다. 요청 본문의 orderId와 헤더의 멱등키가 같은 값이므로, orderId 자체가 멱등키 역할을 겸한다.


2-Phase 트랜잭션 — 외부 API 호출의 안전장치

멱등키만으로는 부족하다. 멱등키는 토스 쪽의 중복 처리를 막아주지만, 우리 서버 내부의 데이터 정합성은 별도로 관리해야 한다.

왜 트랜잭션 하나로는 안 될까?

"결제 기록 저장 → 토스 API 호출 → 결과 업데이트"를 하나의 트랜잭션으로 처리하면 깔끔해 보인다. 하지만 여기에 함정이 있다.

토스 응답이 느리면 트랜잭션이 수 초간 열려 있고, 그 동안 관련 DB 행에 락이 걸린다. 최악의 경우, 토스 API가 타임아웃 나면 트랜잭션도 함께 롤백된다. 토스에서는 결제가 승인됐는데 우리 DB에는 기록이 없는 상태가 발생한다.

해결: 트랜잭션을 둘로 나눈다

Phase 1은 "보험을 드는 단계"다. 토스 API를 호출하기 전에 먼저 "지금 결제를 시도하고 있다"는 사실을 DB에 남긴다. 이렇게 하면 어떤 상황에서도 "결제 시도가 있었다"는 기록이 남아있게 된다.

토스 API 호출은 트랜잭션 밖에서 이루어진다. DB 락을 잡고 있지 않으므로, 토스 응답이 느려도 다른 DB 작업에 영향을 주지 않는다.

Phase 2는 토스 응답을 받아 최종 결과를 기록한다. 성공이면 COMPLETED, 실패면 FAILED로 업데이트한다.

장애 상황에서도 안전한 이유

장애 시나리오상태대응
토스 호출 중 우리 서버가 죽음DB에 IN_PROGRESS 기록이 남아 있음서버 복구 후 IN_PROGRESS 건을 찾아 토스에 결제 상태 조회
토스 응답을 못 받음 (타임아웃)DB에 IN_PROGRESS 기록이 남아 있음같은 멱등키로 다시 호출하면 토스가 기존 결과 반환

Phase 1이 커밋된 이후에는 어떤 장애가 발생하더라도 "결제 시도가 있었다"는 사실을 잃어버리지 않는다. 하나의 트랜잭션이었다면 중간 실패 시 롤백되어 기록 자체가 사라졌을 것이다.

중복 결제 에러 처리

토스에서 "이미 처리된 결제"나 "중복 주문번호" 에러가 오면 HTTP 409 Conflict가 반환된다.

에러HTTP 상태의미
이미 처리된 결제409같은 멱등키로 이미 승인 완료된 결제가 있음
중복된 주문번호409같은 orderId로 이미 처리된 주문이 있음

이 에러는 "실패"가 아니라 "이미 성공한 것"이다. 서버는 기존 결제 결과를 찾아 반환하면 된다.


프론트 입장에서 정리하면

  1. "결제 준비" API를 호출한다
  2. 서버가 pgOrderId를 발급해서 돌려준다
  3. pgOrderId를 토스 결제창에 넘긴다
  4. 결제 완료 후, 토스가 paymentKey + orderId + amount를 콜백으로 준다
  5. 이 값들을 서버의 "결제 승인" API로 보낸다
  6. 서버가 pgOrderId를 Idempotency-Key 헤더에 담아 토스 승인 API를 호출한다
  7. 네트워크 문제로 재시도되더라도, 같은 멱등키 덕분에 중복 결제가 방지된다

프론트에서 멱등키를 직접 만들거나 헤더에 담을 필요가 없다. 서버가 pgOrderId를 멱등키로 재활용해서 토스에 전달하기 때문이다. 프론트는 서버가 발급한 pgOrderId를 토스 결제창에 전달하고, 결제 완료 후 받은 값을 다시 서버로 보내면 된다.


마치며

멱등성은 "안전한 재시도"를 가능하게 하는 개념이다. 결제처럼 돈이 오가는 시스템에서는 특히 중요하다. POST는 본질적으로 멱등하지 않지만, 멱등키를 사용하면 멱등하게 만들 수 있다. 멱등키 + 2-Phase 트랜잭션을 조합하면, 외부 API 호출이 실패하더라도 데이터 정합성을 유지할 수 있다.

이전처럼 "서버에서 알아서 처리하겠지"라고 넘겼다면 이 흐름을 몰랐을 것이다. 서버 스터디에 들어가서 HTTP 개념을 다시 공부하고 궁금증이 생겨 직접 코드를 열어보고 나서야 pgOrderId로 서버에서 어떤 처리를 해왔는지 알게 되었다. 프론트 개발자라도 결제 플로우 전체를 한 번쯤 따라가 볼 가치가 있는 것 같다.