결제 API에 멱등성 키를 적용해 중복 결제 방지하기

현서황·2026년 7월 30일

결제 API를 개발하다 보면 “결제 요청 한 번에 결제도 정확히 한 번만 실행된다”고 생각하기 쉽다.

하지만 실제 서비스에서는 동일한 결제 요청이 여러 번 전달될 수 있다.

  • 사용자가 결제 버튼을 연속으로 클릭한 경우
  • 서버 응답이 늦어 프론트엔드가 요청을 다시 보낸 경우
  • 모바일 네트워크가 끊겼다가 복구되면서 요청이 재전송된 경우
  • HTTP 클라이언트가 타임아웃된 요청을 자동으로 재시도한 경우
  • 동일한 요청이 서로 다른 서버 인스턴스로 전달된 경우

이런 상황에서 서버가 모든 요청을 새로운 결제로 인식하면 실제 결제가 두 번 실행될 수 있다.

이번 글에서는 TinyPay 결제 API에 멱등성 키를 적용해 중복 결제를 방지한 과정을 정리한다.

멱등성이란?

멱등성(Idempotency)이란 동일한 요청을 여러 번 실행하더라도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질이다.

결제 API에서 멱등성을 보장한다는 것은 다음을 의미한다.

동일 결제 요청 1회 → 결제 1회 실행
동일 결제 요청 5회 → 결제 1회 실행

클라이언트는 결제 요청마다 고유한 idempotencyKey를 생성한다. 같은 결제를 재시도할 때는 새로운 키를 만들지 않고 기존 키를 다시 사용한다.

새로운 결제 → 새로운 idempotencyKey
같은 결제 재시도 → 기존 idempotencyKey 재사용

기존 구현의 문제

기존 코드도 성공한 결제 기록을 조회해 기본적인 중복 요청을 방어하고 있었다.

Optional<PaymentLog> existingLog =
        paymentLogRepository.findByRequestAndPaymentStatus(
                aiRequest,
                PaymentStatus.SUCCESS
        );

if (existingLog.isPresent()) {
    return 기존_결제_응답;
}

결제가 이미 완료된 뒤 동일한 요청이 다시 들어오면 기존 결과를 반환할 수 있다.

문제는 두 요청이 거의 동시에 들어오는 경우다.

요청 A: 성공한 PaymentLog 조회 → 없음
요청 B: 성공한 PaymentLog 조회 → 없음
요청 A: 블록체인 결제 실행
요청 B: 블록체인 결제 실행

두 요청 모두 결제 기록이 저장되기 전에 조회하면 둘 다 “아직 처리되지 않은 결제”라고 판단할 수 있다.

이처럼 여러 작업이 같은 데이터를 동시에 확인하고 수정하면서 실행 순서에 따라 결과가 달라지는 문제를 경쟁 상태(Race Condition)라고 한다.

기존 구조는 결제 완료 후 중복 여부를 확인했기 때문에 경쟁 상태를 충분히 막을 수 없었다. 이를 해결하기 위해 실제 결제를 실행하기 전에 한 요청만 처리 권한을 확보하도록 구조를 변경했다.

전체 처리 흐름

개선한 결제 처리 흐름은 다음과 같다.

결제 요청 수신
→ 사용자·금액·비밀번호·예산·잔액 검증
→ idempotencyKey 선점 시도
→ 선점 성공 시 블록체인 결제 실행
→ 결제 성공 시 PaymentLog 저장
→ 멱등성 상태를 COMPLETED로 변경

이미 같은 키가 존재한다면 상태에 따라 다르게 처리한다.

COMPLETED  → 기존 결제 결과 반환
PROCESSING → 처리 중이라는 409 응답
FAILED     → 이전 처리 실패라는 409 응답
요청 불일치 → 키 재사용 오류

여기서 선점이란 여러 요청 중 하나가 먼저 처리 권한을 확보하는 것을 뜻한다.

결제 요청에 idempotencyKey 추가

먼저 결제 요청 DTO에 idempotencyKey를 추가했다.

@NotBlank(message = "멱등성 키가 존재하지 않습니다.")
@Size(max = 100, message = "멱등성 키는 100자 이하여야 합니다.")
private String idempotencyKey;

@NotBlank와 @Size는 Bean Validation 기능이다. Bean Validation은 요청값이 서비스 로직에 들어가기 전에 필수값, 길이, 형식 등을 검사하는 Java 표준 검증 방식이다.

클라이언트는 다음과 같이 키를 포함해 결제를 요청한다.

{
  "idempotencyKey": "4dddbed4-3537-4ec4-82f8-643b59243e87",
  "estimatedCost": 10.500000,
  "walletPassword": "123456"
}

키는 UUID처럼 충돌 가능성이 매우 낮은 값을 사용하는 것이 적합하다.

키가 없거나 100자를 초과하면 결제 로직을 실행하지 않고 400 Bad Request를 반환한다.

멱등성 정보를 저장할 엔티티 설계

멱등성 처리 상태를 저장하기 위해 PaymentIdempotency 엔티티를 추가했다.

private Long userId;
private Long requestId;
private String idempotencyKey;
private BigDecimal requestAmount;
private PaymentIdempotencyStatus status;
private PaymentLog payment;

각 필드의 역할은 다음과 같다.

필드역할
userId키를 사용한 사용자
requestId결제 대상 AI 요청
idempotencyKey클라이언트가 생성한 중복 방지 키
requestAmount동일한 키로 금액이 변경되는 것을 방지
status현재 멱등성 처리 상태
payment완료된 실제 결제 내역

별도의 엔티티를 사용한 이유는 결제가 완료되기 전부터 요청의 처리 상태를 저장해야 하기 때문이다.

PaymentLog만 사용하면 실제 결제 내역이 생성되기 전에는 중복 요청을 식별하기 어렵다.

DB Unique Constraint 적용

동일한 사용자가 같은 키를 두 번 선점하지 못하도록 복합 유일 제약을 추가했다.

유일 제약(Unique Constraint)이란 특정 컬럼 값 또는 컬럼 조합이 테이블 안에서 중복되지 않도록 데이터베이스가 보장하는 규칙이다.

@Table(
    name = "payment_idempotency",
    uniqueConstraints = @UniqueConstraint(
        name = "uk_payment_idempotency_user_key",
        columnNames = {"user_id", "idempotency_key"}
    )
)

동작은 다음과 같다.

사용자 1 + key-123 → 저장 성공
사용자 1 + key-123 → 중복이므로 저장 실패
사용자 2 + key-123 → 저장 성공

키만 유일하게 설정하지 않고 userId와 묶은 이유는 사용자별로 키 공간을 분리하기 위해서다. 서로 다른 사용자가 우연히 같은 UUID를 생성해도 영향을 받지 않는다.

애플리케이션 코드만으로 막지 않은 이유

Java 코드에서 먼저 기존 키를 조회한 뒤 저장하는 방법도 생각할 수 있다.

if (!repository.existsByIdempotencyKey(key)) {
    repository.save(entity);
}

하지만 동시에 요청이 들어오면 다음과 같은 문제가 발생할 수 있다.

요청 A: 키 조회 → 없음
요청 B: 키 조회 → 없음
요청 A: 저장
요청 B: 저장

조회와 저장 사이에 빈틈이 있기 때문이다.

또한 서버가 여러 대라면 synchronized처럼 한 서버의 메모리에서만 동작하는 잠금으로는 다른 서버에 전달된 요청을 막을 수 없다.

모든 서버가 공유하는 DB에 유일 제약을 적용하면 여러 서버에서 동시에 요청하더라도 하나의 INSERT만 성공한다.

멱등성 상태 관리

멱등성 처리 상태는 세 가지로 구분했다.

public enum PaymentIdempotencyStatus {
    PROCESSING,
    COMPLETED,
    FAILED
}

각 상태의 의미는 다음과 같다.

  • PROCESSING: 처리 권한을 확보하고 결제를 진행 중
  • COMPLETED: 결제가 성공해 기존 결과를 재사용할 수 있음
  • FAILED: 이전 결제 시도가 실패함

새로운 레코드는 PROCESSING 상태로 생성된다.

public static PaymentIdempotency processing(
        Long userId,
        Long requestId,
        String idempotencyKey,
        BigDecimal requestAmount
) {
    return new PaymentIdempotency(
            userId,
            requestId,
            idempotencyKey,
            requestAmount
    );
}

결제가 성공하면 실제 결제 기록을 연결하고 상태를 변경한다.

public void complete(PaymentLog payment) {
    this.payment = payment;
    this.status = PaymentIdempotencyStatus.COMPLETED;
}

실패하면 FAILED 상태로 변경한다.

public void fail() {
    this.status = PaymentIdempotencyStatus.FAILED;
}

같은 키로 요청 내용을 바꾸는 문제

동일한 키인지 확인하는 것만으로는 부족하다.

예를 들어 다음 두 요청은 키는 같지만 결제 금액이 다르다.

첫 번째 요청: key-123, requestId=10, amount=10
두 번째 요청: key-123, requestId=10, amount=100

두 번째 요청에 첫 번째 결과를 반환하거나 새로운 결제를 실행하면 안 된다.

이를 막기 위해 기존 요청의 requestId와 requestAmount도 함께 비교했다.

public boolean matches(
        Long requestId,
        BigDecimal requestAmount
) {
    return this.requestId.equals(requestId)
            && this.requestAmount.compareTo(requestAmount) == 0;
}

금액 비교에는 BigDecimal.equals() 대신 compareTo()를 사용했다.

10.0
10.00
10.000000

위 값들은 금액으로는 모두 같지만 equals()는 소수점 자릿수까지 비교하기 때문에 서로 다른 값으로 판단할 수 있다.

compareTo()는 실제 숫자의 크기를 비교하므로 결제 금액 비교에 더 적합하다.

Repository 구현

기존 멱등성 기록을 사용자 ID와 키로 조회할 수 있도록 Repository를 추가했다.

public interface PaymentIdempotencyRepository
        extends JpaRepository<PaymentIdempotency, Long> {

    @EntityGraph(attributePaths = {
        "payment",
        "payment.wallet",
        "payment.request"
    })
    Optional<PaymentIdempotency>
            findByUserIdAndIdempotencyKey(
                    Long userId,
                    String idempotencyKey
            );
}

@EntityGraph는 연관된 데이터를 어떤 범위까지 함께 조회할지 지정하는 JPA 기능이다.

완료된 요청이 다시 들어오면 기존 PaymentLog, 지갑 및 요청 정보가 필요하다. 이를 한 번에 조회해 트랜잭션 종료 후 연관 데이터 접근 시 발생할 수 있는 지연 로딩 문제를 방지했다.

지연 로딩(Lazy Loading)이란 연관 데이터를 처음부터 모두 조회하지 않고, 실제로 사용할 때 추가로 조회하는 방식이다.

멱등성 선점 서비스 구현

멱등성 선점과 상태 변경은 PaymentIdempotencyService로 분리했다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public PaymentIdempotency createClaim(
        Long userId,
        Long requestId,
        String idempotencyKey,
        BigDecimal requestAmount
) {
    return paymentIdempotencyRepository.saveAndFlush(
            PaymentIdempotency.processing(
                    userId,
                    requestId,
                    idempotencyKey,
                    requestAmount
            )
    );
}

여기서 핵심은 REQUIRES_NEW와 saveAndFlush()다.

REQUIRES_NEW란?

트랜잭션(Transaction)이란 여러 DB 작업을 하나의 작업 단위로 묶는 기능이다. 중간에 오류가 발생하면 해당 단위의 변경을 모두 취소할 수 있다.

REQUIRES_NEW는 현재 진행 중인 트랜잭션과 별개로 새로운 트랜잭션을 시작한다.

멱등성 선점 기록을 결제 처리보다 먼저 확정하기 위해 사용했다.

멱등성 PROCESSING 저장 및 커밋
→ 블록체인 결제 실행
→ PaymentLog 저장
→ 멱등성 상태 COMPLETED

블록체인 결제 이후 서버가 갑자기 종료되더라도 선점 기록은 PROCESSING으로 남는다. 재요청이 들어와도 새로운 결제를 바로 실행하지 않으므로 중복 결제보다 안전한 방향으로 실패할 수 있다.

saveAndFlush()를 사용한 이유

JPA의 save()는 SQL 실행을 트랜잭션 종료 시점까지 미룰 수 있다.

플러시(Flush)란 JPA가 메모리에 모아둔 변경사항을 실제 DB SQL로 반영하는 과정이다.

saveAndFlush()를 사용하면 INSERT를 즉시 실행하므로 DB 유일 제약 위반 여부를 블록체인 결제 전에 확인할 수 있다.

결제 승인 로직 변경

기존 사용자, 비밀번호, 예산 및 잔액 검증을 통과한 뒤 블록체인 결제 직전에 키를 선점하도록 변경했다.

PaymentIdempotency idempotency;

try {
    idempotency =
            paymentIdempotencyService.createClaim(
                    userId,
                    requestId,
                    request.getIdempotencyKey(),
                    estimatedCost
            );
} catch (DataIntegrityViolationException e) {
    // 이미 동일한 키가 존재하는 경우 처리
}

동시에 같은 키로 INSERT를 시도하면 하나만 성공한다.

요청 A → 선점 성공 → 블록체인 결제 진행
요청 B → 유일 제약 충돌 → 기존 상태 조회

이미 완료된 요청

기존 상태가 COMPLETED라면 블록체인을 다시 호출하지 않고 기존 결과를 반환한다.

if (existing.getStatus()
        == PaymentIdempotencyStatus.COMPLETED
        && existing.getPayment() != null) {
    return toResponse(
            aiRequest,
            existing.getPayment()
    );
}

첫 번째 결제는 성공했지만 네트워크 문제로 클라이언트가 응답을 받지 못한 경우에도 같은 키로 재요청하면 기존 결과를 받을 수 있다.

처리 중인 요청

기존 상태가 PROCESSING이면 두 번째 요청에 409 Conflict를 반환한다.

409 Conflict는 요청 형식은 올바르지만 현재 서버에 저장된 상태와 충돌한다는 의미의 HTTP 상태 코드다.

throw new CustomException(
        ErrorType.IDEMPOTENCY_REQUEST_IN_PROGRESS
);

이전에 실패한 요청

기존 상태가 FAILED라면 해당 키로 결제를 자동 재실행하지 않는다.

if (existing.getStatus()
        == PaymentIdempotencyStatus.FAILED) {
    throw new CustomException(
            ErrorType.IDEMPOTENCY_REQUEST_FAILED
    );
}

실패한 요청을 다시 실행하려면 새로운 키를 사용하도록 했다. 이는 실패 원인이 명확하지 않은 상태에서 동일 결제가 자동으로 다시 실행되는 것을 막기 위한 정책이다.

결제 성공과 실패 처리

결제가 성공하면 PaymentLog를 저장하고 멱등성 상태를 COMPLETED로 변경한다.

paymentLogRepository.save(paymentLog);

paymentIdempotencyService.complete(
        idempotency,
        paymentLog
);

실패하면 실패 결제 기록과 함께 멱등성 상태를 FAILED로 변경한다.

paymentLogService.saveFailedPaymentLog(...);
paymentIdempotencyService.fail(
        idempotency.getId()
);

오류 상태 구분

멱등성 처리 과정에서 발생할 수 있는 충돌을 세 가지로 구분했다.

IDEMPOTENCY_KEY_REUSED(
    HttpStatus.CONFLICT,
    "동일한 멱등성 키가 다른 결제 요청에 사용되었습니다."
),

IDEMPOTENCY_REQUEST_IN_PROGRESS(
    HttpStatus.CONFLICT,
    "동일한 결제 요청이 처리 중입니다."
),

IDEMPOTENCY_REQUEST_FAILED(
    HttpStatus.CONFLICT,
    "동일한 결제 요청이 이전 처리에서 실패했습니다."
)

모든 중복 요청을 하나의 오류로 처리하지 않고 원인을 구분하면 클라이언트도 상황에 맞게 대응할 수 있다.

테스트

엔티티 단위 테스트

단위 테스트(Unit Test)는 클래스나 메서드처럼 작은 코드 단위를 독립적으로 검증하는 테스트다.

다음 내용을 검증했다.

  • 요청 ID와 금액이 같으면 동일 요청으로 판단
  • 요청 ID가 다르면 불일치
  • 금액이 다르면 불일치
  • 실패 처리 후 상태가 FAILED로 변경
  • 금액의 소수점 자릿수가 달라도 같은 값으로 판단
assertThat(
    idempotency.matches(
        10L,
        new BigDecimal("12.34")
    )
).isTrue();

저장된 금액이 12.340000이어도 동일한 금액으로 판단한다.

MySQL 통합 테스트

통합 테스트(Integration Test)는 여러 구성요소가 함께 동작할 때 결과가 올바른지 검증하는 테스트다.

Testcontainers를 이용해 실제 MySQL 환경에서 유일 제약이 동작하는지 확인했다.

Testcontainers는 테스트를 실행할 때 Docker 컨테이너로 MySQL 같은 외부 시스템을 자동 실행해주는 테스트 도구다.

paymentIdempotencyRepository.saveAndFlush(first);

assertThatThrownBy(() ->
        paymentIdempotencyRepository
                .saveAndFlush(second)
).isInstanceOf(
        DataIntegrityViolationException.class
);

동일한 사용자의 같은 키를 두 번 저장했을 때 두 번째 저장이 DB에서 거부되는 것을 검증했다.

단위 테스트: BUILD SUCCESSFUL
MySQL 통합 테스트: 1 test, 0 failures

이번 구현으로 방어할 수 있는 상황

이번 개선으로 다음 상황에서 동일한 결제가 다시 실행되는 것을 방지할 수 있다.

  • 결제 버튼 중복 클릭
  • 동일 요청 재전송
  • 클라이언트의 자동 재시도
  • 여러 서버 인스턴스로 들어온 동일 요청
  • 결제 처리 중 발생한 중복 요청
  • 같은 키로 결제 금액을 변경한 요청
  • 결제 도중 서버가 중단된 후 들어오는 재요청

남아 있는 과제

이번 구현은 중복 결제를 막는 데 초점을 맞췄다.

다만 블록체인 결제가 성공한 직후, 멱등성 상태를 COMPLETED로 변경하기 전에 서버가 종료되면 해당 키가 계속 PROCESSING으로 남을 수 있다.

현재 구현은 이런 상황에서 결제를 자동으로 다시 실행하지 않는다. 중복 결제를 발생시키는 것보다 운영자가 확인할 수 있도록 멈추는 방향을 선택한 것이다.

향후에는 대사(Reconciliation) 작업이 필요하다.

대사란 내부 DB의 결제 내역과 외부 결제 시스템 또는 블록체인의 실제 거래 내역을 비교해 불일치를 찾고 상태를 바로잡는 작업이다.

오래된 PROCESSING 요청 조회
→ 블록체인 거래 내역 확인
→ 실제 성공이면 COMPLETED
→ 실제 실패면 FAILED
→ 판단할 수 없으면 운영 확인 대상으로 등록

이 대사 작업까지 추가하면 장애 발생 후 자동 복구가 가능한 결제 구조로 확장할 수 있다.

마무리

처음에는 성공한 PaymentLog가 있는지 조회하는 것만으로 중복 결제를 막으려고 했다.

하지만 동시에 요청이 들어오는 상황에서는 두 요청이 모두 결제 기록이 없다고 판단할 수 있었다. 이를 해결하기 위해 다음과 같이 개선했다.

  • 클라이언트가 결제별 idempotencyKey 전달
  • 별도의 멱등성 엔티티와 처리 상태 관리
  • 사용자와 키에 대한 DB 복합 유일 제약 적용
  • 결제 전에 멱등성 처리 권한 선점
  • 완료 요청에는 기존 결과 반환
  • 처리 중·실패·키 재사용 상황을 구분
  • 실제 MySQL 통합 테스트로 유일 제약 검증

이번 작업을 통해 멱등성은 단순히 “기존 결제가 있는지 조회하는 기능”이 아니라, 요청의 처리 권한과 상태를 결제 실행 전에 안전하게 기록하는 구조라는 점을 배울 수 있었다.

profile
노는 게 제일 좋은 뽀로로

0개의 댓글