4 분 소요

개요

온라인 쇼핑몰에서 결제 버튼을 눌렀는데 화면이 멈춰버린 경험, 누구나 한 번쯤 있을 것이다.

답답한 마음에 새로고침을 하거나 결제 버튼을 연타했는데, 잠시 후 ‘결제가 완료되었습니다’라는 문자와 함께 내 계좌에서 돈이 두 번 빠져나갔다면 어떨까?

사용자 입장에서는 고객센터에 전화해 환불을 요구하면 그만일지 모르지만, 결제 시스템을 만드는 개발자 입장에서는 식은땀이 나는 장애 시나리오다. 단순히 시스템이 다운되는 것보다, 데이터의 정합성(특히 ‘돈’과 관련된)이 깨지는 것이 훨씬 더 치명적인 문제이기 때문이다.

이러한 ‘중복 결제’ 문제는 왜 발생하는 것일까?
사용자의 성급함 때문이라고 탓할 수도 있겠지만, 근본적인 원인은 네트워크의 불안정성에 있다. 그리고 이 불안정한 네트워크 환경에서 결제 시스템을 안전하게 지켜내기 위한 핵심 개념이 바로 이번 글의 주제인 멱등성(Idempotency)이다.

결제 시스템에서의 네트워크 타임아웃

멱등성을 이야기하기 전에, 먼저 중복 결제가 발생하는 구체적인 상황을 짚고 넘어갈 필요가 있다.

클라이언트(웹 브라우저 혹은 앱)가 결제 서버로 결제 요청을 보낼 때, 이 요청이 성공하는지 실패하는지는 꽤 명확해 보이지만 사실 그 사이에 타임아웃 이라는 상태가 존재한다.

만약 서버가 죽어서 연결 자체가 안 되거나, 잔액 부족으로 결제가 거절되었다면 클라이언트는 명확한 실패 응답을 받는다.
하지만 다음의 흐름을 생각해보자.

  1. 클라이언트가 결제 서버에 5만 원 결제 요청을 보낸다.
  2. 서버는 요청을 받아 PG사에 5만원 결제 요청을 보내고 결제가 이루어진다.
  3. 서버가 클라이언트에게 “결제 성공!” 이라는 응답을 돌려보내려는데, 이때 네트워크가 끊기거나 지연이 발생한다.
  4. 클라이언트 입장에서는 일정 시간 동안 응답이 오지 않으니 ‘시간 초과’ 에러를 띄운다.

여기서 문제가 발생한다.

클라이언트는 결제가 실패했다고 생각해서 (혹은 사용자가 버튼을 다시 눌러서) 동일한 결제 요청을 서버로 다시 보낸다.

하지만 서버 입장에서는 첫 번째 요청 처리를 이미 완료했음에도 불구하고, 새로 들어온 요청을 새로운 결제 건으로 인식해 또다시 5만 원을 차감해 버린다. 이것이 바로 우리가 막아야 할 중복 결제 시나리오다.

멱등성이란?

수학이나 컴퓨터 과학에서 멱등성이란, 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 의미한다.

수학 기호로 표현하자면 $f(f(x)) = f(x)$ 가 성립하는 함수 $f$를 멱등하다고 한다.

가장 쉬운 예로 엘리베이터의 버튼을 생각해보자.
엘리베이터의 ‘닫힘’ 버튼을 한 번 누르나, 성격이 급해서 열 번을 연속으로 연타하나 엘리베이터 문이 닫힌다는 결과 자체는 변하지 않는다. 이것이 멱등성이 보장된 상태다.

이를 우리가 개발하는 REST API 관점으로 가져와보자.

  • GET: 서버의 데이터를 조회만 하므로 여러 번 호출해도 상태가 변하지 않는다. (멱등함)
  • PUT / DELETE: 특정 리소스를 완전히 교체하거나 삭제하므로, 여러 번 지우거나 덮어씌워도 결국 최종 상태는 같다. (멱등함)
  • POST: 호출할 때마다 새로운 리소스를 생성하거나 상태를 변경한다. (멱등하지 않음)

일반적으로 결제 요청은 POST /payments 와 같은 형태로 이루어진다.
호출할 때마다 새로운 결제 트랜잭션이 생성되기 때문에 멱등하지 않다. 그렇다면 이 POST 요청을 어떻게 멱등하게 만들 수 있을까?

멱등성 키의 도입

이 문제를 해결하기 위해 Stripe, Toss 등 수많은 글로벌 결제 회사들이 사용하는 표준적인 방법이 바로 멱등성 키를 도입하는 것이다.

원리는 간단하다.
클라이언트가 결제 요청을 할 때, 이 요청이 유일하다는 것을 증명하는 고유한 ID를 생성해서 HTTP 헤더에 담아 함께 보내는 것이다.

  1. 클라이언트가 헤더에 Idempotency-Key: a1b2c3d4... 를 담아 결제를 요청한다.
  2. 서버는 이 멱등성 키를 받아 데이터베이스(혹은 Redis와 같은 캐시 저장소)에 이미 처리된 적이 있는 키인지 조회한다.
  3. 만약 처음 보는 키라면? 정상적으로 결제 로직을 수행하고, 결제가 완료되면 이 멱등성 키와 결제 결과(응답 값)를 쌍으로 묶어 저장해둔다.
  4. 만약 이미 저장된 키라면? (즉, 타임아웃 등으로 인해 클라이언트가 재시도한 경우라면) 결제 로직을 다시 타지 않는다. 대신, 이전에 저장해두었던 결제 결과(응답 값)를 그대로 꺼내서 클라이언트에게 돌려준다.
  5. 이렇게 하면 클라이언트가 네트워크 문제로 같은 결제 요청을 10번, 100번 재시도하더라도 서버는 최초 1회만 결제를 처리하고 나머지는 캐싱된 성공 응답만 내려주게 된다. 비로소 결제 API가 멱등성을 갖추게 된 것이다.

이렇게 하면 클라이언트가 네트워크 문제로 같은 결제 요청을 10번, 100번 재시도하더라도 서버는 최초 1회만 결제를 처리하고 나머지는 캐싱된 성공 응답만 내려주게 된다. 비로소 결제 API가 멱등성을 갖추게 된 것이다.

멱등성 키 설계 시 고려할 것들

멱등성 키를 도입하는 것 자체는 어렵지 않지만, 실제 운영 환경에서는 키를 어떻게 생성하고, 얼마나 오래 보관하며, 실패한 요청은 어떻게 다룰지까지 설계해야 한다.

키의 생성: 누가, 어떤 형식으로?

멱등성 키는 클라이언트가 생성하여 서버에 전달하는 것이 원칙이다.
서버가 키를 발급하는 구조로 만들면, 키를 발급받는 요청 자체가 실패했을 때 다시 원점으로 돌아오기 때문이다. 키의 형식으로는 UUID v4가 가장 널리 쓰인다. 충돌 확률이 사실상 무시할 수 있는 수준이고, 클라이언트 단독으로 생성할 수 있기 때문이다.

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

다만 주의할 점이 있다. 멱등성 키는 하나의 의도에 대응해야 한다.
같은 사용자가 같은 상품을 두 번 구매하는 것은 두 건의 서로 다른 결제이므로, 각각 다른 멱등성 키를 가져야 한다.

반대로 네트워크 타임아웃으로 인한 재시도는 같은 의도의 반복이므로 동일한 키를 재사용해야 한다.
이 구분이 제대로 이루어지지 않으면 멱등성 키가 정상적인 결제까지 차단하거나, 반대로 중복 결제를 허용하게 된다.

첫 번째 요청이 실패했다면?

최초 요청이 성공하지 않고 실패 응답을 멱등키와 저장하게 되면 문제가 될 수 있다. 클라이언트가 같은 키로 재시도할 때마다 실패 응답만 돌려받게 되어 결제를 완료할 수 없는 상황에 빠진다.
이를 해결하기 위한 기본 원칙은 다음과 같다.

  • 성공 응답(2xx): 멱등성 키와 함께 저장한다. 이후 같은 키로 요청이 들어오면 저장된 성공 응답을 그대로 반환한다.
  • 클라이언트 에러(4xx): 잔액 부족, 잘못된 카드 번호 등 클라이언트 측 원인이므로, 재시도해도 결과가 달라지지 않는다. 이 역시 멱등성 키와 함께 저장하여 동일한 에러 응답을 반환한다.
  • 서버 에러(5xx): 서버 측 일시적 장애이므로, 재시도하면 성공할 가능성이 있다. 따라서 응답을 저장하지 않고 같은 키로의 재시도를 허용해야 한다.

이 세 가지 분기를 구분하지 않으면 멱등성 키가 오히려 시스템의 복구를 가로막는 장애물이 될 수 있다.

조금 더 깊게 들어가보면 (동시성 제어)

이론은 완벽해 보이지만, 실제 시스템에 이를 구현할 때는 한 가지 더 까다로운 문제를 만나게 된다. 바로 동시성 문제다.

만약 사용자가 정말 빠르게 결제 버튼을 두 번 눌러서, 완전히 동일한 멱등성 키를 가진 두 개의 요청이 0.001초 차이로 서버에 동시에 도달했다면 어떻게 될까?
서버의 스레드 A와 스레드 B가 동시에 DB를 조회하고, 둘 다 “어? 아직 처리 안 된 키네?” 라고 판단하여 결제 로직을 두 번 실행해버릴 위험이 있다. 바로 Race Condition이다.

이를 방지하기 위해 서버는 멱등성 키를 저장할 때 분산 락을 사용하거나, 데이터베이스의 유니크 제약 조건을 활용하여 “단 하나의 스레드만” 결제 처리를 시작할 수 있도록 통제해야 한다.

결제 도메인에서 멱등성을 보장한다는 것은 단순히 캐시를 확인하는 것을 넘어, 이런 동시성 제어까지 이루어졌을 때 안전한 결제 시스템이 완성된다고 볼 수 있다.

마치며

눈에 보이지 않는 네트워크 영역의 문제는 시스템 전반에 치명적인 영향을 미친다. 결제 시스템에서의 네트워크 타임아웃 또한 피할 수 없는 자연재해와 같다.

우리는 네트워크가 항상 안정적일 것이라고 믿어선 안 된다. 클라이언트는 언제든 같은 요청을 다시 보낼 수 있다. 멱등성은 이러한 예측 불가능한 환경 속에서 시스템의 데이터 정합성을 지키고 시스템을 예측가능하게 만들어준다.

댓글남기기