콘텐츠로 건너뛰기

결제 버튼을 한 번 눌렀는데 돈이 두 번 빠져나갔다?

온라인 쇼핑몰에서 물건을 구매한다고 가정하자.

상품을 장바구니에 담고 결제 버튼을 눌렀다.

그런데 한참 동안 로딩 화면만 나타나더니 결국 오류 메시지가 표시된다.

“결제 요청을 처리하지 못했습니다. 다시 시도해 주세요.”

사용자는 결제가 실패했다고 생각하고 다시 결제 버튼을 누른다.

잠시 뒤 카드 결제 알림이 두 번 도착한다.

결제 버튼을 한 번 더 눌렀을 뿐인데 돈이 두 번 빠져나간 것이다.

어떻게 이런 일이 가능할까?

우리는 흔히 네트워크 요청에 실패하면 작업도 실패했다고 생각한다.

하지만 분산 시스템에서는 응답을 받지 못했다는 사실과 작업이 실행되지 않았다는 사실은 전혀 다르다.

1. 서버가 응답하지 않았다면 결제는 실패한 걸까?

다음과 같은 결제 과정을 생각해 보자.

사용자                결제 서버
  |                       |
  | ---- 결제 요청 -----> |
  |                       |
  |                  결제 승인 성공
  |                       |
  | <--- 응답 전송 ----X   |
  |      네트워크 장애     |
  |                       |
  |    응답 시간 초과      |

결제 서버는 실제로 결제 승인을 완료했다.

하지만 응답을 전송하는 과정에서 네트워크 장애가 발생했다.

사용자에게는 실패한 것처럼 보인다.

이때 사용자가 다시 요청하면 어떤 일이 벌어질까?

별도의 방어 로직이 없다면 서버는 새로운 결제 요청으로 판단하고 다시 결제를 진행할 수 있다.

결국 결제는 두 번 처리된다.

여기서 중요한 것은 네트워크의 불확실성이다.

분산 시스템에서는 요청을 보낸 쪽이 응답을 받지 못했다고 해서, 상대방이 요청을 받지 못했거나 처리하지 않았다고 결론 내릴 수 없다.

타임아웃은 결제 실패의 증거가 아니라, 결제 결과를 알 수 없다는 신호일 수 있다.

2. 재시도를 하지 않으면 해결되지 않을까?

가장 단순한 해결책은 재시도를 하지 않는 것이다.

하지만 네트워크 장애가 발생했다고 모든 요청을 포기할 수는 없다.

사용자가 결제 버튼을 누를 때마다 네트워크가 완벽하게 동작한다고 보장할 수도 없다.

따라서 현실적인 시스템에서는 재시도(Retry)를 고려해야 한다.

문제는 재시도가 중복 실행으로 이어질 수 있다는 점이다.

이를 해결하기 위해 등장하는 개념이 **멱등성(Idempotency)**이다.

멱등성이란 동일한 연산을 여러 번 적용하더라도, 한 번 적용한 이후의 결과 상태가 더 이상 달라지지 않는 성질을 의미한다.

예를 들어 다음 연산을 생각해 보자.

SET balance = 10000

이미 잔액이 10,000원인 상태에서 동일한 값을 다시 설정하더라도 결과는 달라지지 않는다.

반면 다음 연산은 다르다.

balance = balance + 10000

같은 작업을 두 번 실행하면 잔액이 20,000원 증가한다.

결제 API에서도 같은 원칙을 적용할 수 있다.

동일한 결제 요청이 여러 번 전달되더라도 실제 결제 효과는 한 번만 발생하도록 만드는 것이다.

3. Idempotency Key로 동일한 요청을 식별하자

이를 구현하는 대표적인 방법이 Idempotency Key다.

클라이언트가 하나의 논리적인 결제 작업에 고유한 식별자를 부여한다.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8a1b-2c3d-4e5f

{
  "orderId": 101,
  "amount": 50000
}

여기서 Idempotency-Key는 한 결제 작업을 식별하는 값이다.

서버는 이 키를 기준으로 이미 처리한 요청인지 확인할 수 있다.

첫 번째 요청
    |
    ▼
Idempotency Key 확인
    |
    ▼
처리 이력 없음
    |
    ▼
결제 실행
    |
    ▼
결과 저장


두 번째 요청 (동일한 Key)
    |
    ▼
Idempotency Key 확인
    |
    ▼
이미 처리한 요청
    |
    ▼
기존 결과 반환

결제 요청을 재시도하더라도 같은 키를 사용한다면 서버가 동일한 작업임을 식별할 수 있다.

다만 여기서 중요한 조건이 있다.

같은 키를 가지고 서로 다른 결제 금액이나 주문 정보를 전달해서는 안 된다.

서버는 동일한 키가 같은 요청 내용을 의미하는지 검증할 필요가 있다.

또한 키는 충분히 고유해야 하고, 유효기간과 재사용 정책도 명확해야 한다.

4. 단순히 중복 여부를 확인하면 충분할까?

다음과 같은 코드를 생각해 보자.

if (!paymentExists(idempotencyKey)) {
    processPayment();
    savePaymentResult(idempotencyKey);
}

언뜻 보면 문제가 없어 보인다.

이미 처리한 결제라면 다시 실행하지 않기 때문이다.

그런데 지난번 좌석 예약 문제를 떠올려보자.

동일한 요청이 동시에 두 번 도착하면 어떻게 될까?

두 요청 모두 paymentExists()에서 false를 확인할 수 있다.

Request A                 Request B
    |                         |
중복 확인: 없음          중복 확인: 없음
    |                         |
결제 실행                결제 실행
    |                         |
결과 저장                결과 저장

중복 처리를 막기 위한 코드가 또다시 경쟁 상태에 빠진 것이다.

결국 멱등성 구현에도 동시성 제어가 필요하다.

DB의 UNIQUE 제약조건을 활용한다면?

다음과 같은 요청 관리 테이블을 생각해 보자.

CREATE TABLE payment_requests (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(100) NOT NULL UNIQUE,
    request_hash VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL,
    result_json JSON
);

idempotency_key에 UNIQUE 제약조건을 설정했다.

이제 동일한 키로 두 행을 삽입할 수 없다.

그러나 UNIQUE 제약조건만 추가했다고 외부 결제 API가 자동으로 한 번만 호출되는 것은 아니다.

서버는 다음과 같은 흐름을 설계해야 한다.

  1. 결제 작업의 고유 키를 저장하고 처리 권한을 확보한다.
  2. 이미 존재하는 키라면 요청 내용이 같은지 확인한다.
  3. 처리 중인 요청이라면 완료를 기다리거나 처리 중이라는 응답을 반환한다.
  4. 처음 처리하는 요청만 결제 작업을 시작한다.
  5. 결제 결과를 저장해 동일 요청의 재시도에 활용한다.

이 과정에서 요청 상태를 PROCESSING, SUCCEEDED, FAILED 등으로 관리할 수 있다.

단, 상태 관리와 동시 요청 제어를 정확하게 구현해야 한다.

그리고 더 어려운 문제가 남아 있다.

5. 우리 DB에는 실패라고 되어 있는데 카드사에서는 승인됐다면?

외부 결제 시스템과 연동하는 상황을 생각해 보자.

우리 서버
   |
   ▼
외부 결제 API 호출
   |
   ▼
외부 결제 승인 완료
   |
   X 네트워크 장애
   |
   ▼
우리 서버는 결과를 알 수 없음

이 경우 우리 DB에서 결제 실패로 단정할 수 없다.

외부 결제 시스템에서는 이미 결제가 처리되었을 수 있기 때문이다.

여기서 로컬 DB 트랜잭션만으로 모든 문제를 해결할 수는 없다.

@Transactional이 우리 데이터베이스의 작업을 롤백하더라도 외부 카드사의 결제 승인까지 자동으로 취소해 주지는 않기 때문이다.

따라서 외부 결제 서비스가 멱등성 키를 지원한다면 동일한 논리적 결제에 동일한 키를 전달하는 것이 중요하다.

또한 결과가 불확실한 요청에 대해서는 결제 조회 API나 웹훅 등을 통해 실제 처리 결과를 확인하는 대사(Reconciliation) 절차가 필요하다.

실제 결제 서비스인 Stripe도 이러한 문제를 해결하기 위해 Idempotency Key 기반의 재시도 기능을 제공한다.

이는 단순한 편의 기능이 아니라 금융 거래의 신뢰성과 연결되는 중요한 설계 원칙이다.

6. 재시도할 수 있는 시스템이 더 안전하다

처음 질문으로 돌아가 보자.

결제 버튼을 한 번 눌렀는데 왜 돈이 두 번 빠져나갔을까?

서버가 결제를 두 번 처리했기 때문일 수 있다.

하지만 더 근본적인 원인은 같은 결제 작업과 새로운 결제 작업을 구분하지 못했다는 점에 있다.

네트워크 장애는 언제든 발생할 수 있다.

사용자가 버튼을 여러 번 누르는 상황도 완전히 막을 수는 없다.

프론트엔드에서 결제 버튼을 비활성화하는 것만으로는 충분하지 않다. 네트워크 재시도나 다른 클라이언트의 요청까지 방지할 수 없기 때문이다.

따라서 중요한 금융 작업은 동일한 요청이 여러 번 전달되는 상황을 전제로 설계해야 한다.

다만 멱등성이 있다고 해서 분산 시스템의 모든 문제가 해결되는 것은 아니다. 처리 결과가 불확실한 외부 작업에는 별도의 상태 확인과 복구 전략이 필요하다.

신뢰할 수 있는 시스템이란 장애가 절대로 발생하지 않는 시스템이 아니라, 장애와 재시도가 발생하더라도 같은 비즈니스 작업이 중복으로 실행되지 않도록 설계된 시스템이다.

생각해 볼 문제

Q1. 결제 API가 타임아웃을 반환했다. 사용자는 결제가 실패했다고 판단해도 될까?

Q2. 같은 Idempotency Key를 가진 요청 두 개가 동시에 도착한다면 어떻게 처리해야 할까?

Q3. 외부 결제는 성공했지만 우리 DB에 결과를 저장하기 전에 서버가 종료되었다. 어떤 복구 전략이 필요할까?

참고 자료

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다