[Network] RESTful API와 멱등

개요

결제·주문처럼 금전이 오가는 영역에서는 따닥 클릭으로 같은 요청이 두 번 들어오는 일이 흔하고, 네트워크 타임아웃 뒤 클라이언트가 자동으로 재시도하면 또 같은 일이 벌어진다. 이때 결과가 어떻게 수렴하느냐는 거의 전적으로 서버가 정의한 HTTP(HyperText Transfer Protocol) 메서드의 의미와 멱등성(Idempotency) 정책에 달려 있다.

RESTful API는 자원(Resource)을 URI(Uniform Resource Identifier)로 식별하고, 그 자원에 대한 동작을 HTTP 메서드로 표현하는 설계 양식이다. 단순한 명명 규칙이 아니라 자원 식별, 상태 전이, 캐싱, 계층화, 자기 서술 등 여러 제약을 동시에 만족시키는 아키텍처 스타일이다.

개념정의예시
안전성(Safety)서버 상태를 변경하지 않음GET, HEAD, OPTIONS
멱등성(Idempotency)같은 요청을 N번 보내도 최종 상태가 같음GET, PUT, DELETE, HEAD, OPTIONS
캐시 가능성(Cacheable)응답을 캐시해 재사용할 수 있음GET, HEAD(기본), POST(조건부)

HTTP 프로토콜

HTTP는 클라이언트와 서버가 웹 자원을 주고받기 위한 응용 계층 프로토콜이다. 가장 큰 특징은 무상태(stateless)이다. 서버는 클라이언트의 과거 요청을 기억하지 않는다. 같은 사용자라도 매 요청마다 인증 정보·세션 식별자를 함께 보내야 식별 가능하다. 무상태 덕분에 인스턴스마다 상태를 동기화할 필요가 없고, 어떤 서버에 요청이 라우팅되어도 같은 응답이 나오기에 수평 확장이 가능해진다.

HTTP 요청은 요청 라인, 헤더, 빈 줄, 본문으로 구성된다. 응답도 상태 라인, 헤더, 빈 줄, 본문 구조를 따른다.

POST /api/orders HTTP/1.1
Host: api.example.com
User-Agent: example-app/1.0.0
Accept: application/json
Content-Type: application/json
Content-Length: 64
Idempotency-Key: 4f1e0c1e-2e6a-4d1d-9a7d-c9f5a82a5bd5
Authorization: Bearer eyJhbGciOi...
 
{
  "userId": 100,
  "items": [{"productId": 7, "qty": 2}]
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/4815
Cache-Control: no-store
Date: Sat, 14 Jun 2026 10:30:00 GMT
 
{
  "id": 4815,
  "status": "PENDING"
}

요청 본문의 형식은 헤더 Content-Type 으로 선언하고, 클라이언트가 받고 싶은 형식은 Accept 로 선언한다. 둘이 어긋나면 415(Unsupported Media Type) 또는 406(Not Acceptable)이 돌아온다. 응답 헤더의 Location 은 201 Created 와 함께 새로 생성된 자원의 URI 를 알려주는 표준 방법이다.

상태 코드는 응답이 어떤 종류의 결과인지 한눈에 알 수 있도록 5개 범위로 나뉜다.

범위분류주요 코드
1xx정보100 Continue, 101 Switching Protocols
2xx성공200 OK, 201 Created, 202 Accepted, 204 No Content
3xx리다이렉션301 Moved Permanently, 302 Found, 304 Not Modified
4xx클라이언트 오류400, 401, 403, 404, 409, 422, 429
5xx서버 오류500, 502, 503, 504

REST 아키텍처

REST는 자원을 표현(Representation)으로 만들어 그 표현을 주고받음으로써 자원의 상태를 전이시키는 아키텍처 스타일이다.

클라이언트-서버 분리

사용자 인터페이스와 데이터의 관심사를 분리한다. 클라이언트는 사용자 인터페이스와 인증 정보를 다루고, 서버는 비즈니스 로직과 영속 저장소를 다룬다. 이 분리 덕분에 서버는 클라이언트 종류(웹·모바일·명령행)와 무관하게 진화할 수 있다.

무상태(Stateless)

서버는 클라이언트의 세션 상태를 저장하지 않는다. 모든 요청은 그 자체로 처리에 필요한 정보를 모두 포함해야 한다. 무상태 제약 덕분에 어느 인스턴스에 트래픽이 가도 동일한 응답이 가능하고, 수평 확장과 장애 복구가 단순해진다.

캐시(Cacheable)

응답은 캐시 가능 여부를 명시해야 한다. 클라이언트나 중간 프록시는 이 표시를 보고 응답을 재사용할 수 있다. HTTP 에서는 Cache-Control, ETag, Last-Modified 헤더가 그 역할을 한다.

HTTP/1.1 200 OK
Cache-Control: max-age=3600, public
ETag: "abc123def456"
Last-Modified: Mon, 01 Jan 2026 10:00:00 GMT
 
{"id": 123, "name": "kim"}

캐시는 단순한 성능 최적화가 아니라 자원의 정체성과 직결된다. 같은 URI에 대해 같은 응답이 나오기 때문에 캐시가 의미를 가지는 것이고, 그래서 GET처럼 멱등한 메서드만 기본적으로 캐시 가능하다.

균일한 인터페이스(Uniform Interface)

자원 식별, 표현을 통한 자원 조작, 자기 서술 메시지, 하이퍼미디어 4가지 하위 제약으로 구성된다. URI 하나로 자원을 식별하고, HTTP 메서드와 표현(JSON, XML)으로 조작하며, 응답에 다음 가능한 상태 전이(하이퍼링크)를 포함시킨다(HATEOAS).

{
  "id": 100,
  "name": "kim",
  "_links": {
    "self": "https://api.example.com/applicants/100",
    "next": "https://api.example.com/applicants/101",
    "resume": "https://api.example.com/applicants/100/resume"
  }
}

HAL(Hypertext Application Language) 형식은 자원 필드와 하이퍼링크를 _links 로 분리해 가독성을 살린다.

계층화(Layered System)

클라이언트는 직접 통신하는 다음 홉이 진짜 서버인지 프록시인지 알 필요가 없다. 사이에 로드 밸런서, 게이트웨이, 캐시 서버, 보안 계층이 들어가도 동일한 인터페이스로 동작한다.

주문형 코드(Code-on-Demand, 선택)

서버가 클라이언트에 실행 가능한 코드(자바스크립트)를 내려보내 동작을 확장할 수 있다.

URI, URL, URN

REST의 첫 번째 제약은 자원 식별이고, 그 식별 수단이 URI(Uniform Resource Identifier)이다. URI는 자원을 식별하는 상위 개념이고, URL(Uniform Resource Locator)과 URN(Uniform Resource Name)은 그 하위 종류이다.

구분식별 방식예시
URI자원 식별 일반URL과 URN의 상위 개념
URL위치로 식별https://api.example.com/payments/p_42
URN이름으로 식별urn:isbn:0451450523, urn:uuid:...
https://api.example.com:8080/payments/p_42?expand=method#charge
scheme  host             port path          query        fragment

scheme는 통신 방식(https), host는 서버 도메인, port는 포트 번호, path는 자원 경로, query는 옵션 파라미터, fragment는 페이지 내 앵커이다. 모든 URL은 URI이지만, 모든 URI가 URL은 아니다. URN은 자원이 어디에 있는지가 아니라 어떤 이름인지만 알려주므로 위치가 바뀌어도 식별자가 변하지 않는다.

URI는 자원을 유니크하게 식별하는 것이고, URL은 자원(파일)이 어디에 위치해있는지, URN은 자원을 표기하기 위한 이름이다.

  • https://www.example.com/users/1
  • https://www.example.com/users/2

이 경우는 users/{id}로 식별자를 나눌 수 있으므로 다른 URI라고 할 수 있다. 하지만 자원의 위치를 나타내주지 않으므로 URL이라곤 볼 수 없다.

와 같이 자원의 위치가 표기된 것을 URL이라고 할 수 있다.

경로 변수(Path Variable)와 쿼리 스트링(Query String)

경로 변수는 자원을 식별할 때 쓰고, 쿼리 스트링은 옵션을 표현할 때 쓴다. 자원의 계층 관계도 경로로 표현한다.

GET /workspaces/1/openings/2
GET /workspaces/1/openings?status=OPEN&page=0&size=20

필터·정렬·페이지네이션처럼 자원을 식별하는 결정적 요소가 아닌 부가 정보는 쿼리 스트링으로 분리한다.

헤더는 메타데이터에

자원 자체의 일부가 아니라 요청 자체에 대한 메타데이터는 헤더로 보낸다. 인증 토큰(Authorization), API 버전(X-Api-Version), 멱등성 키(Idempotency-Key)가 대표 예이다.

명사 복수형과 하이픈

자원은 컬렉션의 일부로 표현하므로 복수형 명사를 쓴다(/orders, /products, /payments). 컬렉션 안의 특정 자원은 식별자로 좁힌다(/orders/1). 동사는 HTTP 메서드에서 표현되므로 URI에 동사를 넣지 않는 것이 원칙이지만, /login, /logout 같이 메서드만으로 표현이 어색한 경우는 예외로 허용된다. 여러 단어로 이뤄진 자원 이름은 하이픈으로 잇는다. completedOrder 가 아니라 /completed-order 가 가독성에 유리하다.

HTTP 메서드 7가지

HTTP 메서드는 자원에 대해 수행할 동작을 정의한다. RESTful에서 메서드는 단순한 동사가 아니라 안전성·멱등성·캐시 가능성을 함께 약속하는 의미 단위이다.

메서드의미안전멱등본문 사용캐시
GET자원 조회OO없음가능
HEAD헤더만 조회OO없음가능
OPTIONS지원 메서드 조회OO없음불가
POST새 자원 생성·임의 처리XX사용조건부
PUT자원 전체 교체·없으면 생성XO사용불가
PATCH자원 부분 수정X구현 의존사용불가
DELETE자원 삭제XO미권장불가

GET

자원을 조회한다. 본문 없이 URI와 쿼리 스트링으로 모든 정보를 전달한다. 서버 상태를 바꾸지 않으므로 안전하고, 같은 요청을 여러 번 보내도 결과가 같으므로 멱등하다. 조회 카운트 증가, 감사 로그, 캐시 갱신 같은 부수 효과는 자원 자체의 상태 변경이 아니므로 안전성을 깨지 않는 것으로 간주한다. 쓰기 작업으로 매번 조회 결과가 달라지면 규약을 위반한다. GET /api/users/1 이 호출될 때마다 사용자 정보를 수정한다면, 클라이언트는 안전한 호출이라고 믿었던 요청에서 의도하지 않은 변경을 일으키게 된다.

POST

새 자원을 생성하거나 표준 메서드로 표현하기 어려운 임의 처리(로그인, 결제)를 수행한다. 본문에 자원 표현을 담고, 서버는 새 자원을 만든 뒤 응답한다. 두 번 보내면 두 개의 자원이 생성되므로 멱등하지 않다. 가령 결제 API에 따닥으로 두 번 요청이 들어가면 결제도 두 번 진행된다.

PUT

자원을 통째로 교체한다. 자원이 없으면 새로 만들고, 있으면 body의 값으로 덮어쓴다. body는 자원 전체를 담아야 하며, 일부 필드만 보내면 누락된 필드가 어떻게 처리될지 모호해진다. PUT은 몇 번을 보내도 body의 값으로 덮어지므로 최종 상태가 같으므로 멱등하다. 같은 요청에 대해 응답 코드가 첫 번째는 201, 두 번째부터 200으로 달라지더라도 자원 상태 자체는 동일하므로 여전히 멱등하다.

PATCH

자원을 부분 수정한다. 본문에 변경할 필드만 담는다. 멱등성은 구현에 따라 달라진다. 단순 필드 교체라면 멱등하지만, {"counter": "+1"} 처럼 연산형 패치를 정의하면 보낼 때마다 값이 달라져 멱등하지 않다.

DELETE

자원을 삭제한다. 첫 요청은 200 또는 204로 성공하고, 같은 자원에 대한 두 번째 요청은 404가 돌아올 수 있다. 응답 코드가 달라져도 자원의 최종 상태가 없음으로 동일하므로 DELETE는 멱등하다. 다만 클라이언트가 멱등하다는 이유로 응답 코드와 무관하게 같은 로직을 적용하면 위험하다. 어떤 사용자는 첫 요청에서 삭제했고 어떤 사용자는 이미 삭제된 자원에 다시 요청한 것인데, 두 케이스를 구분하지 않으면 감사 로그·메트릭이 왜곡된다.

HEAD 와 OPTIONS

HEAD는 GET과 같지만 본문 없이 헤더만 응답한다. 자원 존재 여부 확인, 파일 크기 확인, 캐시 검증에 쓴다. OPTIONS는 서버가 해당 URI에 지원하는 메서드 목록을 응답한다. CORS(Cross-Origin Resource Sharing) preflight 요청이 대표 사용처이고, 브라우저가 본 요청 전에 OPTIONS로 허용 여부를 먼저 확인한다.

멱등성과 안전성

메서드멱등성안전성운영 시 의미
GETOO마음껏 재시도 가능, 캐시 가능
HEADOO가벼운 존재 확인용
OPTIONSOOpreflight, 메타 정보 조회
PUTOX재시도 안전, 같은 본문이면 같은 결과
DELETEOX재시도 안전, 응답 코드는 달라질 수 있음
POSTXX재시도 시 중복 자원 생성 위험
PATCH구현 의존X연산형 패치는 비멱등, 필드 교체는 멱등

멱등성이 중요한 이유

요청은 어떤 경우든 실패할 수 있다. TCP 연결이 끊어질 수도 있고, 응답이 돌아오는 도중에 게이트웨이가 타임아웃을 던질 수도 있고, 서버는 처리에 성공했지만 응답이 클라이언트에 도달하지 못할 수도 있다. 클라이언트 입장에서는 결과를 알 수 없으므로 가장 안전한 동작은 재시도이다.

클라이언트(브라우저, 모바일 앱, HTTP 라이브러리)는 메서드가 멱등하다고 표준에 명시된 경우 자체적으로 재시도 로직을 돌릴 수 있다. 따라서 GET을 구현하면서 내부에서 자원 상태를 바꾸거나 PUT 을 비멱등하게 구현하면 클라이언트의 자동 재시도 로직과 충돌하여 문제가 생길 수 있으므로 서버에서의 로직은 HTTP 메서드의 멱등성에 맞게 구현될 필요가 있다.

Idempotency-Key 헤더(비멱등 메서드를 멱등하게)

POST를 멱등하게 만드는 표준 도구는 클라이언트가 요청마다 고유한 키를 헤더로 보내는 패턴이다. 헤더 이름으로 가장 널리 쓰이는 표기가 Idempotency-Key 이다.

키 라이프사이클

키는 URI, HTTP 메서드, Body를 조합하여 해시를 만들수도, UUID로 유니크한 값으로 생성할 수 있다.

키 한 개는 클라이언트가 한 번의 비즈니스 시도(예: 결제 1건)를 의도할 때 생성한다. 따닥·재시도·재접속과 무관하게 그 시도가 끝날 때까지 같은 키를 유지한다. 서버 입장에서는 키의 상태가 다음 세 단계로 전이된다.

단계의미일반적 응답
PROCESSING첫 요청이 들어와 처리 중같은 키의 두 번째 동시 요청에는 409 Conflict
COMPLETED처리 끝, 응답 캐시됨같은 키의 후속 요청에 캐시 응답 그대로 반환
FAILED처리 실패(검증 실패 등)같은 키 후속 요청에 동일 에러 응답 그대로 반환

PROCESSING 단계는 짧은 락(예: 30초), COMPLETED·FAILED 단계는 긴 캐시(예: 24시간)로 TTL을 분리한다. 짧은 캐시만 두면 첫 요청의 응답이 끝난 직후에 두 번째 요청이 들어왔을 때 캐시가 없어 다시 처리되는 문제가 발생할 수 있다.

서버가 키를 처리하는 단계는 저장소나 레이어와 무관하게 동일하다.

단계서버 동작응답
1. 헤더 확인Idempotency-Key 존재 검증누락 시 400(정책 따라 통과 가능)
2. 본문 해시SHA-256으로 본문 해시 계산
3. 키 조회저장소에서 키 상태 조회
3-a. PROCESSING다른 요청 처리 중409 Conflict
3-b. COMPLETED/FAILED + 해시 일치캐시 응답 반환첫 요청 응답 그대로
3-c. COMPLETED/FAILED + 해시 불일치키 재사용 충돌422 Unprocessable Entity
3-d. 없음새 키 등록(PROCESSING)
4. 비즈니스 수행도메인 로직 실행
5. 응답 캐시응답 코드·본문·헤더 저장응답 반환
sequenceDiagram
    autonumber
    participant C as Client
    participant S as Server
    participant K as Idempotency Store
    participant B as Business Logic

    C->>S: POST /payments (Idempotency-Key=K1)
    S->>K: 키 조회 (K1)
    K-->>S: 없음
    S->>K: SETNX K1 -> PROCESSING (TTL 30s)
    S->>B: 결제 수행
    B-->>S: 결제 성공 (paymentId=p1)
    S->>K: SET K1 -> COMPLETED, body=p1 (TTL 24h)
    S-->>C: 201 Created, body=p1

    Note over C,S: 네트워크 일시 단절, 클라이언트 재시도

    C->>S: POST /payments (Idempotency-Key=K1)
    S->>K: 키 조회 (K1)
    K-->>S: COMPLETED, body=p1
    S-->>C: 201 Created, body=p1 (재실행 없음)

curl 로 흐름을 확인하면 다음과 같다.

KEY=$(uuidgen)
 
# 1차 요청
curl -X POST https://api.example.com/api/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"orderId":"o-1","amount":10000}'
# -> 201 Created, body=p_42
 
# 네트워크 단절 시뮬레이션 후 같은 키로 재시도
curl -X POST https://api.example.com/api/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"orderId":"o-1","amount":10000}'
# -> 201 Created, body=p_42 (재실행 없음)
 
# 같은 키 + 다른 본문 -> 충돌
curl -X POST https://api.example.com/api/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"orderId":"o-1","amount":99999}'
# -> 422 Unprocessable Entity

멱등키 생성 책임(브라우저 · Gateway · Service)

멱등키는 같은 비즈니스 시도에는 같은 키가 실리도록 해야 효과가 있다. 이 전제가 깨지면 뒤에서 어떤 저장소로 락을 잡든 멱등성은 성립하지 않는다. 그래서 키를 어느 레이어에 저장하고 어디서 가로챌지보다, 키를 누가 언제 만드느냐가 먼저 결정돼야 한다.

매 요청마다 새 키를 생성하면 따닥이 안 잡힌다

클라이언트가 버튼을 누를 때마다, 또는 재시도할 때마다 UUID를 새로 만들어 헤더에 실으면 서버는 매번 다른 키를 본다. 사용자가 결제 버튼을 따닥으로 세 번 누르면 K1, K2, K3 세 개의 키가 도착하고, 서버는 셋 다 처음 보는 키이므로 각각 PROCESSING으로 등록해 결제를 세 번 수행한다. 키가 요청마다 달라지는 순간 멱등키는 서버 입장에서 서로 다른 세 건의 정상 요청과 구분되지 않는다. 즉 매 요청 새 키 방식은 멱등키를 달았을 뿐 멱등성은 전혀 얻지 못한다.

핵심은 키의 생성 단위다. 키는 HTTP 요청 단위가 아니라 비즈니스 시도 단위로 만든다. 사용자가 결제를 확정하려는 순간 키 한 개를 만들고, 그 시도가 최종 완료되거나 실패할 때까지 따닥·네트워크 재시도·앱 재시작·재접속과 무관하게 같은 키를 재사용한다. 웹이라면 결제 화면에 진입할 때 UUID를 한 번 고정해 화면 상태에 보관하고, 재시도 시 그 값을 그대로 싣는다. 새 결제를 시작할 때만 새 키를 만든다.

// 안티패턴: 요청을 보낼 때마다 새 키 생성 (따닥이 세 건으로 통과)
fun pay() {
    val key = UUID.randomUUID().toString()   // 클릭마다 새 UUID
    api.createPayment(idempotencyKey = key, body = paymentBody)
}
 
// 올바른 패턴: 시도 시작 시 한 번 고정, 재시도는 같은 키 재사용
val attemptKey = rememberOncePerScreen { UUID.randomUUID().toString() } // 결제 화면당 1개
fun pay() {
    api.createPayment(idempotencyKey = attemptKey, body = paymentBody)
}

세 가지 생성 위치

Client 생성이 유일하게 재시도를 같은 키로 묶을 수 있는 위치다. 어떤 요청이 새 시도이고 어떤 요청이 이전 시도의 재시도인지에 대한 비즈니스를 아는 주체는 클라이언트뿐이기 때문이다. 클라이언트는 시도 시작 시 키를 만들고 재시도에 그대로 싣는다.

Gateway 생성은 얼핏 중앙에서 키를 붙여 편해 보이지만, 게이트웨이는 매 HTTP 요청을 독립 이벤트로 본다. 재시도로 다시 들어온 요청에도 새 키를 붙이므로 결국 요청마다 새 키가 되어 앞의 문제와 똑같이 따닥을 못 잡는다. 게이트웨이의 올바른 역할은 키 생성이 아니라 클라이언트가 보낸 키의 존재를 강제(누락 시 400)하고 형식(UUID 등)을 검증하는 것이다.

Service 서버 생성은 본문 내용(주문 정보·금액)을 해시해 키로 삼는 방식이다. 같은 내용이면 같은 키가 되어 완전 동일한 중복은 잡지만, 정당한 재주문(같은 상품을 다시 구매)까지 같은 키로 막아버리는 오탐이 생긴다. 클라이언트의 재시도 의도와 무관하게 서버가 추측하는 것이라 경계가 모호하다. 본문 해시 키는 데이터베이스 UNIQUE 제약과 함께 2차 방어로는 유용하지만, 1차 멱등키로는 부적합하다.

생성 위치재시도 시 같은 키 유지장점한계
ClientO따닥·재시도를 한 키로 묶음클라이언트 구현을 신뢰해야 함
GatewayX(요청마다 새 키)키 누락 강제·형식 검증에 적합재시도를 새 시도로 오인
Service(본문 해시)내용 같으면 O서버 단독 2차 방어 가능정당한 재주문 오탐, 경계 모호

1차 키는 Client가 생성해 헤더로 싣고, Gateway는 키 누락을 400으로 강제하며, Service는 본문 해시를 UNIQUE 제약과 함께 2차 방어로 둔다. 생성은 Client, 강제는 Gateway, 최종 방어는 Service 가 맡는 구조가 실무의 기본형이다. 그리고 이렇게 클라이언트가 의도적으로 생성하는 멱등키는, 서버가 자원 식별자로 자동 생성하는 분산 락 키와는 목적이 다르다. 멱등키는 응답까지 보존해 재시도에 그대로 돌려주고, 분산 락은 짧은 시간의 동시 접근을 직렬화한다. 둘은 한 시스템에 공존할 수 있다.

시나리오

1. Client: 결제 화면에서 키 생성

결제 화면에 진입할 때 키 한 개를 만들어 고정하고, 따닥·재시도·네트워크 복구에 그대로 재사용한다. 새 결제를 시작할 때만 새 키를 만든다.

// 결제 화면 진입 시 시도당 키 1개 고정 (따닥·재시도에 그대로 재사용)
val idempotencyKey = rememberOncePerScreen { UUID.randomUUID().toString() }
 
fun pay() {
    paymentApi.create(
        idempotencyKey = idempotencyKey,
        body = PaymentBody(orderId = "o-1", amount = 10000),
    )
}

2. Gateway: 키 검증

결제·주문 경로에 키가 없으면 400으로 즉시 거절하고, 있으면 통과시킨다. 어떤 경로가 키를 요구하는지는 설정으로 뺀다.

idempotency:
  required-paths:
    - /api/payments/**
    - /api/orders/**
// 게이트웨이/필터는 키를 만들지 않고 존재와 형식만 강제한다
@Component
class IdempotencyKeyGuard(
    private val props: IdempotencyProperties,
) : OncePerRequestFilter() {
 
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
    ) {
        if (props.requires(request.requestURI) && request.getHeader("Idempotency-Key").isNullOrBlank()) {
            response.status = HttpStatus.BAD_REQUEST.value()  // 키 누락 거절
            return
        }
        chain.doFilter(request, response)
    }
}

3. Service: UNIQUE 제약

서비스는 클라이언트가 보낸 키를 그대로 쓴다. 외부 결제 게이트웨이 호출에도 같은 키를 실어 외부 중복 결제를 막고, 자체 데이터베이스에는 멱등키 UNIQUE 제약을 둔다.

@Service
class PaymentService(
    private val pgClient: PgClient,
    private val paymentRepository: PaymentRepository,
) {
 
    fun create(command: CreatePaymentCommand): Payment {
        // 외부 게이트웨이 호출에도 같은 키 전달: 외부 중복 결제 차단
        val pgResult = pgClient.charge(
            ChargeRequest(
                amount = command.amount,
                currency = command.currency,
                idempotencyKey = command.idempotencyKey,
            )
        )
        // 자체 DB UNIQUE(idempotency_key): 마지막 방어선
        return paymentRepository.saveIfAbsent(
            Payment(
                id = pgResult.id,
                amount = command.amount,
                status = pgResult.status,
                idempotencyKey = command.idempotencyKey,
            )
        )
    }
}

saveIfAbsentINSERT ... ON DUPLICATE KEY UPDATE id = id 로 구현해, 같은 멱등키가 이미 있으면 새 행을 만들지 않고 기존 행을 반환한다.

Client가 시도당 키를 만들어 따닥·재시도를 한 키로 묶고, Gateway가 키 누락을 400 으로 강제해 무방비 요청을 걸러내며, Service가 UNIQUE 제약으로 중복 결제 생성을 막는다.