쿠폰 한 장으로 열 번 할인: 경쟁 조건과 웹훅 멱등성을 DB로 막기

아프로·2026년 11월 1일

쿠폰 한 장으로 열 번 할인 표지

2025년 10월 19일부터 20일까지 AWS us-east-1 리전에서 큰 장애가 났습니다. DynamoDB의 DNS를 관리하는 시스템에서 실행기(Enactor) 두 개가 동시에 DNS 계획을 적용했습니다. 한쪽이 "더 새로운 계획이 있는지" 확인한 결과는 처리가 늦어지는 사이 낡은 정보가 됐고, 오래된 계획이 새 계획을 덮어썼습니다. 이어서 정리 작업이 활성 계획을 지우면서 리전 엔드포인트의 DNS 레코드가 비었습니다. AWS는 근본 원인을 "latent race condition(잠복해 있던 경쟁 조건)"이라고 적었습니다. 공격자는 없었습니다. 확인한 시점과 사용한 시점 사이의 틈 하나로 충분했습니다.

이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 10-1, 10-2, 10-3, 10-4

이 글의 범위. 경쟁 조건과 웹훅 중복 처리의 원리를 그림과 말로 설명하고, DB와 서버 코드로 막는 방법을 정리합니다. 남의 서비스에 동시 요청을 보내는 도구 사용법이나 재현 절차는 싣지 않습니다. 코드는 모두 방어 쪽 코드이고, 테이블·ID·키 값은 가짜입니다. 사실관계는 각 기관과 회사의 공개 자료(2026-11-01 기준)를 따르고, 원인이 공개되지 않은 사례는 [추정]으로 표시합니다.

한눈에 보기

기법한 줄 정의대표 사례막는 핵심가장 가까운 OWASP 2025
경쟁 조건·한도 초과(TOCTOU)"확인"과 "사용" 사이의 틈에 같은 요청이 여러 개 끼어들어, 한 번만 허용돼야 할 동작이 여러 번 성공하는 문제PortSwigger single-packet attack(2023-08), GitLab CVE-2022-4037(2023-01 패치), AWS us-east-1 장애(2025-10)조건부 UPDATE, SELECT … FOR UPDATE, 유니크 제약A06 안전하지 않은 설계(API Security API6)
웹훅 재전송·멱등성 누락같은 요청이나 이벤트가 두 번 이상 도착했을 때 서버가 별개 거래로 처리해 돈이 두 번 움직이는 문제NatWest 이중 차감(2022), JPMorgan Chase 중복 처리(2023-06), 결제대행사 공식 문서의 중복 전달 안내이벤트 ID 유니크, 멱등성 키, 단방향 상태 전이A08 소프트웨어·데이터 무결성 실패, A06

두 기법은 원인이 다릅니다. 경쟁 조건은 동시에 온 요청이, 멱등성 누락은 다시 온 요청이 문제를 만듭니다. 하지만 결과는 같습니다. 한 번만 일어나야 할 일이 두 번 일어납니다. 막는 방법도 같은 곳으로 모입니다. "한 번만"이라는 규칙을 애플리케이션의 if문에 두지 않고, DB 한 지점에서 원자적으로 판단하게 하는 것입니다.

먼저 짚을 개념: 원자성과 "확인 후 사용"

  • 원자적(atomic) 연산. 중간 상태를 다른 요청이 볼 수 없는, 쪼갤 수 없는 한 단위의 연산입니다. "잔액이 충분하면 차감한다"가 원자적이라면, 두 요청이 동시에 와도 하나가 끝난 다음에 다른 하나가 판단합니다.
  • TOCTOU(Time Of Check to Time Of Use). 확인한 시점과 사용하는 시점이 다르다는 뜻입니다. 확인한 뒤 사용하기 전에 상태가 바뀌면, 확인 결과는 이미 낡은 정보입니다. CWE-367이 이 패턴이고, 더 넓은 경쟁 조건은 CWE-362입니다.
  • 트랜잭션 격리 수준. DB가 동시에 실행되는 트랜잭션끼리 서로의 변경을 얼마나 보게 할지 정한 값입니다. PostgreSQL과 Oracle의 기본값인 READ COMMITTED에서는 커밋된 변경만 보이지만, "읽고 → 판단하고 → 쓰는" 두 트랜잭션이 같은 옛 값을 읽는 것은 막지 않습니다.
  • 멱등성(idempotency). 같은 요청을 한 번 보내든 열 번 보내든 결과가 같은 성질입니다. PUT으로 값을 덮어쓰는 요청은 대개 멱등이고, "포인트 1,000점 적립" 요청은 멱등이 아닙니다.

1. 경쟁 조건: 동시에 두 번

원리: 확인과 사용 사이의 틈

경쟁 조건 원리. 왼쪽 위험한 모양은 요청 A와 B가 모두 used=false를 읽고 둘 다 할인을 적용해 할인 2번. 오른쪽 고친 모양은 조건부 UPDATE로 요청 A만 영향받은 행 1, 요청 B는 0으로 거절되어 할인 1번

쿠폰 사용 API를 이렇게 만들었다고 해 봅시다. 많은 서비스에서 볼 수 있는 모양입니다.

// 위험한 모양: 조회 → if → 저장 (같은 실수를 보여 주는 일반 예시)
@Transactional
public void redeem(long couponId, long userId) {
    Coupon c = couponRepo.findById(couponId).orElseThrow();   // ① 읽기
    if (c.isUsed()) throw new AlreadyUsedException();          // ② 판단
    c.markUsed(userId);                                        // ③ 쓰기 (커밋 때 UPDATE)
    orderService.applyDiscount(userId, c.getAmount());
}

요청 하나만 보면 틀린 곳이 없습니다. 문제는 같은 쿠폰으로 요청 두 개가 거의 동시에 들어올 때입니다. 두 요청은 각자 트랜잭션을 열고, 둘 다 ①에서 used = false를 읽습니다. 아직 어느 쪽도 ③을 커밋하지 않았기 때문입니다. 둘 다 ②를 통과하고, 둘 다 ③에서 사용 처리와 할인을 합니다. 요청이 열 개면 열 번 통과할 수 있습니다. 각 요청은 혼자 보면 정상인데, 합치면 한도를 넘습니다.

@Transactional을 붙였으니 안전하다고 생각하기 쉽지만, 트랜잭션은 "모두 성공하거나 모두 취소된다"를 보장할 뿐입니다. 기본 격리 수준에서 두 트랜잭션이 같은 값을 읽고 각자 판단하는 것은 막지 않습니다.

같은 구조는 쿠폰 밖에도 많습니다.

  • 잔액·포인트 차감: 잔액 조회 → 충분한지 판단 → 차감
  • 재고·선착순: 남은 수량 조회 → 0보다 큰지 판단 → 감소
  • 1인 1회 이벤트: 참여 기록 조회 → 없으면 → 참여 기록 저장
  • 인증 코드·재설정 토큰: 코드 조회 → 유효하면 → 사용 처리

"타이밍이 맞을 리 없다"는 가정이 무너진 이유

예전에는 이런 틈이 수 밀리초 정도라 원격에서 맞추기 어렵다고 여겼습니다. 네트워크를 지나는 동안 요청마다 도착 시각이 조금씩 흩어지기(지터) 때문입니다.

2023년 8월 PortSwigger Research는 Black Hat USA와 DEF CON 31에서 "Smashing the state machine" 연구를 발표했습니다. 이 연구는 여러 요청을 거의 같은 순간에 서버에 도착시키는 single-packet attack을 공개했습니다. 네트워크 지터의 영향을 줄여 원격에서도 이런 틈을 안정적으로 노릴 수 있음을 보였고, 실제 대상에서 기존 기법으로 2시간 넘게 걸리던 재현이 약 30초로 줄었다고 밝혔습니다. 같은 연구는 경쟁 조건을 "숨은 중간 상태를 가진 다단계 흐름"으로 봐야 한다고 정리했습니다. 한 번의 요청 처리 안에도 "아직 저장하지 않은 판단", "보냈지만 확정하지 않은 상태" 같은 중간 상태가 있고, 동시 요청은 그 중간 상태를 봅니다.

따라서 "동시에 올 리 없다"는 방어가 되지 못합니다. 동시에 온다고 가정하고 설계해야 합니다.

왜 OWASP Top 10에 이름이 없나

가장 가까운 항목은 A06 안전하지 않은 설계입니다. 동시성을 고려하지 않은 설계라는 뜻입니다. CWE로는 362·367로 분류되지만 Top 10에 카테고리 이름으로 나오지 않습니다. API Security Top 10의 API6(민감한 비즈니스 흐름에 대한 무제한 접근)과도 겹칩니다.

독립 항목이 없는 이유는 결함의 모양에 있습니다. 코드 한 줄의 결함이 아니라 "검사와 사용이 원자적이지 않은 설계"입니다. 위 예시 코드의 어느 줄을 봐도 틀린 곳이 없고, 정적 분석 도구가 짚을 만한 위험한 함수도 없습니다. 요청을 하나씩 보내는 일반 테스트도 모두 통과합니다. 정상 기능을 정상 순서로 쓰는데 결과만 틀리는 문제라, 위험 범주 목록에서는 넓은 "설계" 항목 안에 들어갑니다.

실제 사례

동시에 두 번, 같은 것을 두 번 사례 표. 2015-05 Starbucks 기프트카드, 2022 NatWest, 2023-01 GitLab CVE-2022-4037, 2023-06 JPMorgan Chase, 2023-08 PortSwigger single-packet attack, 2025-10-19~20 AWS us-east-1 장애

GitLab CVE-2022-4037 (2023-01 패치, 15.5.7·15.6.4·15.7.2). 경쟁 조건으로 "검증된 이메일"을 위조할 수 있었습니다. 이 취약점이 위험했던 이유는 GitLab을 OAuth 제공자로 쓰는 제3자 서비스에 있습니다. 그 서비스들은 GitLab이 "검증됐다"고 알려 준 이메일로 계정을 연결하므로, 위조된 검증 이메일이 다른 서비스의 계정 탈취로 이어질 수 있었습니다. PortSwigger 연구에서 찾은 사례이고, CWE-362로 분류됐습니다.

AWS us-east-1 장애 (2025-10-19~20). 공격이 아닌 운영 사고지만 TOCTOU의 구조를 가장 잘 보여 줍니다. 장면에서 본 것처럼 "더 새로운 계획이 있는가"를 확인한 결과가 처리 지연으로 낡은 상태가 됐고, 오래된 계획이 덮어쓴 뒤 정리 작업이 활성 계획을 지웠습니다. 쿠폰 API의 "아직 안 썼는가"와 같은 질문이, 인프라 자동화에서는 리전 엔드포인트를 지웠습니다.

고전 참고: Starbucks 기프트카드 (Sakurity, 2015-05). 기프트카드 사이의 잔액 이전 기능에 경쟁 조건이 있었던 사례입니다. 2019년 이전 사례지만, 잔액 이전처럼 금액이 움직이는 기능이 가장 먼저 노려진다는 것을 보여 줍니다.

국내 쿠폰·포인트 동시 요청 사고 중 1차 출처로 확인할 수 있는 2019년 이후 사례는 찾지 못했습니다. 다만 포인트·쿠폰·선착순 이벤트가 많은 국내 서비스에서 "JPA로 조회하고 if문으로 검사한 뒤 save"하는 모양은 흔하고, 이 모양이 그대로 공격면입니다.

서버에서 막기

한 번만을 DB에 맡기는 다섯 가지 방법 표. 조건부 UPDATE, SELECT FOR UPDATE, 유니크 제약, 낙관적 잠금, 단방향 상태 전이의 쓰임과 주의점, 그리고 분산 락·큐·격리 수준에 대한 메모

코드 수준 ①: 조건부 UPDATE, 판단과 갱신을 한 문장으로

가장 먼저 쓸 방법입니다. 판단 조건을 WHERE에 넣어 판단과 갱신을 SQL 한 문장으로 합칩니다. DB는 같은 행에 대한 갱신을 한 번에 하나씩 처리하므로, 두 번째 요청은 첫 번째 요청이 바꾼 값을 기준으로 조건을 다시 평가합니다.

-- 잔액 차감: 검사와 사용을 한 문장으로 (값은 가짜)
UPDATE wallet
   SET balance = balance - :amount
 WHERE user_id = :uid
   AND balance >= :amount;
-- 영향받은 행 수가 0이면 잔액 부족으로 거절한다

-- 쿠폰 사용: 아직 쓰지 않았고 만료되지 않은 경우에만
UPDATE coupon
   SET used_by = :uid, used_order_id = :order_id, used_at = now()
 WHERE id = :coupon_id
   AND used_by IS NULL
   AND expires_at > now();

-- 재고·선착순: 남아 있을 때만 하나 줄인다
UPDATE product
   SET stock = stock - 1
 WHERE id = :product_id
   AND stock > 0;

애플리케이션에서는 영향받은 행 수를 반드시 확인합니다. 0을 무시하면 조건부 UPDATE를 쓴 의미가 없습니다.

// Spring JdbcTemplate: 영향받은 행 수로 성공을 판단 (방어 예시)
@Transactional
public void redeem(long couponId, long userId, long orderId) {
    int updated = jdbc.update("""
        UPDATE coupon SET used_by = ?, used_order_id = ?, used_at = now()
         WHERE id = ? AND used_by IS NULL AND expires_at > now()
        """, userId, orderId, couponId);
    if (updated == 0) {
        throw new CouponNotAvailableException(couponId);   // 이미 사용, 만료, 없음
    }
    orderService.applyDiscount(orderId, couponId);          // 같은 트랜잭션
}

JPA를 쓴다면 @Modifying 쿼리로 같은 문장을 실행하고 반환값(int)을 확인합니다. 엔티티를 읽어 필드를 바꾸고 save하는 방식은 위험한 모양으로 돌아갑니다.

코드 수준 ②: SELECT … FOR UPDATE, 여러 행을 보고 판단할 때

판단에 여러 행이 필요하면 조건부 UPDATE 한 문장으로 끝나지 않습니다. 이때는 같은 트랜잭션 안에서 SELECT … FOR UPDATE로 행을 잠근 뒤 판단하고 갱신합니다. 잠근 행을 다른 트랜잭션이 갱신하거나 잠그려 하면, 앞의 트랜잭션이 끝날 때까지 기다립니다.

// JPA: 비관적 잠금으로 읽기 (방어 예시)
public interface CouponRepository extends JpaRepository<Coupon, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)                 // SELECT ... FOR UPDATE
    @Query("select c from Coupon c where c.id = :id")
    Optional<Coupon> findByIdForUpdate(@Param("id") long id);
}

@Transactional
public void redeem(long couponId, long userId) {
    Coupon c = couponRepo.findByIdForUpdate(couponId).orElseThrow();
    if (c.isUsed()) throw new AlreadyUsedException();
    c.markUsed(userId);
}

까다로운 경우는 아직 없는 행으로 판단할 때입니다. "이 사용자는 이번 달 쿠폰을 3번까지만 쓸 수 있다"라는 규칙은 사용 기록을 세어 판단하는데, 아직 생기지 않은 네 번째 기록은 잠글 수 없습니다. 이럴 때는 판단의 기준이 되는 부모 행(사용자 행)을 잠가 같은 사용자의 요청을 줄 세웁니다.

-- 1인당 월 3회 한도: 사용자 행을 잠가 같은 사용자의 요청을 차례로 처리 (값은 가짜)
BEGIN;
SELECT id FROM member WHERE id = :uid FOR UPDATE;

SELECT count(*) FROM coupon_redemption
 WHERE user_id = :uid
   AND redeemed_at >= date_trunc('month', now());
-- 3 이상이면 ROLLBACK

INSERT INTO coupon_redemption (coupon_id, user_id, order_id)
VALUES (:coupon_id, :uid, :order_id);
COMMIT;

잠금을 쓸 때는 두 가지를 지킵니다. 트랜잭션을 짧게 유지하고(잠금을 잡은 채 외부 API를 부르지 않습니다), 여러 행을 잠글 때는 항상 같은 순서(예: ID 오름차순)로 잠가 교착 상태를 피합니다.

코드 수준 ③: 유니크 제약, "한 번만"을 스키마에 못 박기

"하나에 한 번"이 규칙이라면 그 규칙을 DB 제약으로 만듭니다. 애플리케이션 코드에 어떤 실수가 있어도, 제약은 두 번째 INSERT를 거부합니다. 조사 자료의 표현대로 애플리케이션의 if문보다 제약이 마지막 방어선입니다.

-- 쿠폰 한 장은 한 번만 사용된다
CREATE TABLE coupon_redemption (
  id          bigserial   PRIMARY KEY,
  coupon_id   bigint      NOT NULL,
  user_id     bigint      NOT NULL,
  order_id    bigint      NOT NULL,
  redeemed_at timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_coupon_redeemed_once UNIQUE (coupon_id)
);

-- 1인 1회 프로모션 참여
CREATE TABLE promo_entry (
  promotion_id bigint      NOT NULL,
  user_id      bigint      NOT NULL,
  entered_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (promotion_id, user_id)
);

-- 잔액은 음수가 될 수 없다: 조건부 UPDATE를 빠뜨린 코드가 있어도 여기서 멈춘다
ALTER TABLE wallet ADD CONSTRAINT ck_wallet_balance_nonneg CHECK (balance >= 0);

제약 위반 예외는 서버 오류로 보내지 않고, 정상적인 거절 응답으로 바꿉니다.

// 유니크 제약 위반을 "이미 사용됨"으로 처리 (방어 예시)
try {
    redemptionRepo.saveAndFlush(new CouponRedemption(couponId, userId, orderId));
} catch (DataIntegrityViolationException e) {
    throw new AlreadyUsedException(couponId);   // 409로 응답(500 대신)
}

코드 수준 ④: 낙관적 잠금, 그리고 금전 흐름에서는 재시도하지 않기

충돌이 드물고 잠금을 오래 잡기 싫다면 version 컬럼을 쓰는 낙관적 잠금도 가능합니다. 읽을 때의 버전과 쓸 때의 버전이 다르면 갱신이 실패합니다.

@Entity
public class Wallet {
    @Id private Long userId;
    private long balance;
    @Version private long version;    // UPDATE ... WHERE user_id = ? AND version = ?
}

// 충돌하면 자동 재시도하지 않고 실패를 돌려준다 (방어 예시)
try {
    walletService.withdraw(userId, amount);
} catch (ObjectOptimisticLockingFailureException e) {
    return ResponseEntity.status(HttpStatus.CONFLICT).body("다시 시도해 주세요");
}

금전이 움직이는 흐름에서는 충돌 시 자동 재시도 대신 실패를 반환하는 쪽이 안전합니다. 자동 재시도는 사용자가 의도하지 않은 두 번째 처리를 만들 수 있고, 사용자가 결과를 보고 다시 시도하게 하는 편이 추적하기 쉽습니다.

코드 수준 ⑤: "1회 소비" 값은 소비 자체를 원자적으로

인증 코드, 비밀번호 재설정 토큰, 초대 링크처럼 한 번만 쓸 수 있는 값은 "조회해서 유효한지 보고 → 사용 처리"로 나누지 않습니다. 소비하는 연산 하나로 끝냅니다.

-- 인증 코드 소비: 삭제된 행이 있을 때만 성공 (PostgreSQL)
DELETE FROM otp_code
 WHERE user_id   = :uid
   AND code_hash = :code_hash
   AND expires_at > now()
RETURNING id;
-- 돌아온 행이 없으면 실패. 같은 코드로 두 요청이 와도 행은 한 번만 지워진다

-- 또는 사용 표시로 소비
UPDATE password_reset_token
   SET used = true, used_at = now()
 WHERE token_hash = :token_hash
   AND used = false
   AND expires_at > now();

설정 수준: 격리 수준만 올리는 것으로는 부족하다

격리 수준을 올리면 해결될 것 같지만, 조사 자료는 이것만으로는 부족할 수 있다고 정리합니다. READ COMMITTED에서 검사와 갱신을 나눈 패턴은 그대로 경쟁 조건을 만듭니다. 더 높은 격리 수준은 DB마다 동작이 다르고, 충돌을 직렬화 실패로 돌려주는 방식이라면 그 실패를 처리하는 코드가 다시 필요합니다. 패턴 자체를 바꾸는 것이 먼저이고, 격리 수준은 그 위에 더하는 선택입니다.

아키텍처 수준: 여러 인스턴스와 경합이 심한 자원

분산 락은 펜싱 토큰과 함께. 서버 인스턴스가 여러 대이고 DB 밖의 자원(외부 API, 파일, 캐시)을 함께 다룬다면 Redis 같은 저장소로 자원 키 단위 분산 락을 걸기도 합니다. 이때 함정은 락 만료입니다. 락을 잡은 프로세스가 GC나 네트워크 지연으로 멈춘 사이 락이 만료되고, 다른 프로세스가 같은 락을 잡습니다. 멈췄던 프로세스는 자기가 아직 락을 가졌다고 생각하고 이어서 씁니다. 이를 막는 장치가 펜싱 토큰입니다. 락을 줄 때마다 1씩 커지는 번호를 함께 주고, 저장하는 쪽이 더 작은 번호의 쓰기를 거부합니다.

-- 펜싱 토큰: 더 최근 락을 받은 쓰기만 통과 (값은 가짜)
UPDATE inventory
   SET qty = qty - 1, last_fence = :fence_token
 WHERE sku = :sku
   AND qty > 0
   AND last_fence < :fence_token;

조사 자료의 권고도 같습니다. 락 만료와 펜싱 토큰을 함께 설계하고, 락만 믿지 말고 DB 제약을 같이 둡니다.

경합이 심하면 큐로 직렬화. 인기 상품의 재고나 한 계정의 잔액처럼 같은 자원에 요청이 몰리면, 잠금 대기가 길어지고 실패가 늘어납니다. 이때는 자원 키(상품 ID, 계정 ID)로 파티션한 큐에 요청을 넣고, 파티션마다 소비자 하나가 차례로 처리합니다. 같은 자원의 요청은 애초에 동시에 실행되지 않습니다.

방어를 테스트로 확인하기

경쟁 조건은 요청을 하나씩 보내는 테스트로는 드러나지 않습니다. 내 서비스 코드를 대상으로 같은 요청을 동시에 여러 번 실행하는 테스트를 만들어 둡니다. 테스트 DB는 운영과 같은 종류(예: PostgreSQL 컨테이너)를 씁니다. 메모리 DB는 잠금 동작이 달라 결과를 믿기 어렵습니다.

// 내 서비스의 동시성 테스트: 같은 쿠폰을 100번 동시에 사용해도 성공은 1번 (방어 예시)
@Test
void sameCouponRedeemedConcurrently_onlyOneSucceeds() throws Exception {
    int n = 100;
    ExecutorService pool = Executors.newFixedThreadPool(32);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger succeeded = new AtomicInteger();
    List<Future<?>> futures = new ArrayList<>();

    for (int i = 0; i < n; i++) {
        long orderId = 9_000 + i;                                  // 가짜 주문 ID
        futures.add(pool.submit(() -> {
            start.await();                                         // 동시에 출발
            try {
                couponService.redeem(FAKE_COUPON_ID, FAKE_USER_ID, orderId);
                succeeded.incrementAndGet();
            } catch (CouponNotAvailableException ignored) { }
            return null;
        }));
    }
    start.countDown();
    for (Future<?> f : futures) f.get();

    assertThat(succeeded.get()).isEqualTo(1);
    assertThat(redemptionRepo.countByCouponId(FAKE_COUPON_ID)).isEqualTo(1);
}

위험한 모양의 코드에서는 이 테스트가 1보다 큰 값으로 실패하고, 조건부 UPDATE나 유니크 제약을 넣으면 통과합니다. 같은 테스트를 CI에 두면 나중에 누군가 "조회 후 저장"으로 되돌렸을 때 바로 드러납니다.

탐지 신호

  • 같은 사용자·같은 자원에 수 밀리초 간격으로 몰린 요청. 사람이 버튼을 누르는 간격으로는 생기지 않는 분포입니다.
  • 유니크 제약 위반 예외 급증. 제약이 막고 있다는 뜻이지만, 누군가 같은 동작을 반복해서 시도한다는 뜻이기도 합니다.
  • 낙관적 잠금 충돌 로그 급증.
  • 정산 이상. "사용 횟수 > 발급 횟수", 음수 잔액 같은 값이 나오면 이미 어딘가에서 통과한 것입니다.
-- 정산 점검: 한 장이 여러 번 쓰인 쿠폰, 음수 잔액 (테이블은 가짜)
SELECT coupon_id, count(*) AS uses
  FROM coupon_redemption_log
 GROUP BY coupon_id
HAVING count(*) > 1;

SELECT user_id, balance FROM wallet WHERE balance < 0;

2. 웹훅 재전송과 멱등성 누락: 같은 것을 두 번

원리: 웹훅은 최소 한 번 온다

웹훅 수신 흐름. 서명 검증, 시각 확인, 이벤트 ID 기록(중복이면 바로 200), 상태 전이(PENDING일 때만 PAID). 기록이 없으면 재시도가 새 거래로 처리되고, 기록이 있으면 돈이 한 번만 움직인다. 상태는 PENDING, PAID, REFUNDED 순으로 앞으로만 간다

결제대행사(PG)는 결제가 끝나면 우리 서버의 웹훅 주소로 "결제 성공" 이벤트를 보냅니다. 이 전달은 대부분 최소 한 번(at-least-once) 방식입니다. 우리 서버의 응답이 늦거나 5xx 오류가 나면, 대행사는 같은 이벤트를 다시 보냅니다. 대행사 입장에서는 이벤트를 잃는 것보다 두 번 보내는 쪽이 낫기 때문입니다.

우리 서버가 실제로는 첫 이벤트를 처리했는데 응답만 늦었다면 어떻게 될까요. 대행사는 실패로 보고 다시 보내고, 서버는 그 이벤트를 새 결제로 처리합니다. 포인트가 두 번 쌓이고, 배송이 두 번 나가고, 환불 이벤트였다면 환불이 두 번 됩니다.

같은 일이 우리 API 쪽에서도 생깁니다. 모바일 앱이 결제 요청을 보낸 뒤 타임아웃이 나면 다시 보냅니다. 첫 요청이 서버에서 이미 처리됐다면 결제가 두 번 됩니다.

여기에 재전송(replay)이 더해집니다. 웹훅 서명을 검증하지 않거나, 서명은 검증해도 타임스탬프를 보지 않으면, 한 번 캡처된 정상 웹훅 요청을 제3자가 나중에 다시 보내 같은 효과를 낼 수 있습니다. 서명이 맞는 요청이라도 두 번째로 온 것이라면 처리하면 안 됩니다.

왜 OWASP Top 10에 이름이 없나

서명 없는 웹훅을 믿는 문제는 A08 소프트웨어·데이터 무결성 실패에, 멱등성을 설계하지 않은 문제는 A06 안전하지 않은 설계에 걸칩니다. 하지만 멱등성은 보통 분산 시스템의 신뢰성 항목으로 분류되어 보안 점검에서 빠지기 쉽습니다. 웹훅 서명을 검증했다면 보안 점검표의 해당 줄은 통과합니다. 그다음 줄, "서명이 맞는 같은 이벤트가 두 번 오면?"은 점검표에 없는 경우가 많습니다. 결과는 금전 사고입니다.

실제 사례

원인까지 공개된 웹훅 중복 사고 포스트모템은 찾지 못했습니다. 아래는 "중복 처리"가 어떤 규모의 영향을 내는지 보여 주는 공개 사례이고, 기술적 원인은 [추정]입니다.

NatWest (영국, 2022). 직불카드 거래 약 11만 2천 계좌(직불카드 고객의 약 1%)에서 결제가 두 번 표시되고 차감됐습니다. 은행은 가맹점에는 한 번만 지급됐다고 밝혔습니다. 원인은 상세히 공개되지 않았습니다([추정]: 배치 재처리 중 중복).

JPMorgan Chase (미국, 2023-06). 온라인 뱅킹의 버그로 일부 고객의 거래와 수수료가 중복 처리됐고, 은행은 역처리와 환불을 진행했습니다. 원인 상세는 공개되지 않았습니다.

결제대행사의 공식 안내. Stripe는 웹훅 엔드포인트가 같은 이벤트를 두 번 이상 받을 수 있으니 처리한 이벤트 ID를 기록하라고 문서에 적고 있습니다. 서명 헤더의 타임스탬프 허용 오차(SDK 기본 300초)로 재전송을 제한합니다. 중복 전달은 규약에 들어 있는 정상 동작이라는 뜻입니다.

국내 참고: 현대카드 결제대금 이중 출금 (2014). 내부 전산 오류로 약 1,300명에게서 약 15억 원이 두 번 출금됐습니다. 2019년 이전 사례이고, 최근 국내 사례는 검증할 수 있는 원문을 찾지 못했습니다.

토스페이먼츠, KG이니시스, 포트원 등 어떤 PG를 쓰든 웹훅은 중복으로 온다고 가정하고 설계합니다.

서버에서 막기

코드 수준 ①: 웹훅 수신, 이벤트 ID를 유니크 키로

수신 순서는 이렇습니다. 서명 검증 → 타임스탬프 허용 오차 확인 → 이벤트 ID를 유니크 키로 INSERT → INSERT가 성공했을 때만 비즈니스 처리. INSERT가 중복으로 끝나면 이미 처리한 이벤트이므로 200을 바로 돌려줍니다. 대행사는 200을 받으면 재시도를 멈춥니다.

CREATE TABLE processed_event (
  provider    text        NOT NULL,
  event_id    text        NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);
-- 같은 트랜잭션에서: INSERT processed_event → 주문 상태 변경
# 웹훅 수신 처리 (방어 예시, 헤더 이름·시크릿은 가짜 값)
import hashlib, hmac, json, time
from fastapi import Request, Response

WEBHOOK_SECRETS = [b"FAKE-whsec-current", b"FAKE-whsec-previous"]  # 회전 중에는 둘 다
TOLERANCE_SEC = 300

def signature_ok(raw: bytes, ts: str, sig: str) -> bool:
    for secret in WEBHOOK_SECRETS:
        expected = hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, sig):          # 상수 시간 비교
            return True
    return False

@app.post("/webhooks/fakepg")
async def fakepg_webhook(request: Request):
    raw = await request.body()                          # 파싱 전 원본 바이트로 검증
    ts  = request.headers.get("x-fakepg-timestamp", "")
    sig = request.headers.get("x-fakepg-signature", "")
    if not signature_ok(raw, ts, sig):
        return Response(status_code=400)
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE_SEC:
        return Response(status_code=400)                # 오래된 요청의 재전송 거부

    event = json.loads(raw)
    with pool.connection() as conn, conn.transaction():
        cur = conn.execute(
            "INSERT INTO processed_event (provider, event_id) VALUES ('fakepg', %s) "
            "ON CONFLICT DO NOTHING", (event["id"],))
        if cur.rowcount == 0:
            return Response(status_code=200)            # 이미 처리한 이벤트
        if event["type"] == "payment.succeeded":
            conn.execute(
                "UPDATE orders SET status = 'PAID', paid_at = now() "
                "WHERE id = %s AND status = 'PENDING'", (event["data"]["order_id"],))
    return Response(status_code=200)
  • 서명은 원본 바이트 그대로 검증합니다. JSON을 파싱했다가 다시 직렬화하면 공백이나 키 순서가 바뀌어 서명이 맞지 않거나, 검증한 내용과 처리한 내용이 달라질 수 있습니다.
  • 이벤트 ID 기록과 상태 변경은 같은 트랜잭션에 둡니다. 따로 두면 기록만 되고 처리가 실패하거나, 처리만 되고 기록이 실패하는 틈이 생깁니다.
  • 위 코드의 UPDATE가 영향받은 행 0으로 끝나는 경우(이미 PAID인 주문)는 정상입니다. 단방향 상태 전이가 한 번 더 막아 준 것입니다.

코드 수준 ②: 내부 API의 멱등성 키

우리 결제·환불 API도 클라이언트 재시도에 대비합니다. 클라이언트가 요청마다 고유한 Idempotency-Key 헤더를 붙이고, 서버는 (키, 사용자, 요청 해시)를 저장합니다. 같은 키로 다시 오면 처리하지 않고 저장해 둔 응답을 그대로 돌려줍니다. 같은 키인데 본문이 다르면 거절합니다.

CREATE TABLE idempotency_key (
  user_id       bigint      NOT NULL,
  idem_key      text        NOT NULL,
  request_hash  text        NOT NULL,                       -- 메서드+경로+본문의 해시
  status        text        NOT NULL DEFAULT 'IN_PROGRESS', -- IN_PROGRESS → DONE
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, idem_key)
);
# 멱등성 키 처리 흐름 (방어 예시)
def handle_payment(conn, user_id, idem_key, body: bytes):
    req_hash = hashlib.sha256(body).hexdigest()
    with conn.transaction():
        cur = conn.execute(
            "INSERT INTO idempotency_key (user_id, idem_key, request_hash) "
            "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", (user_id, idem_key, req_hash))
        if cur.rowcount == 0:                            # 이미 본 키
            row = conn.execute(
                "SELECT request_hash, status, response_code, response_body "
                "FROM idempotency_key WHERE user_id = %s AND idem_key = %s",
                (user_id, idem_key)).fetchone()
            if row[0] != req_hash:
                return 422, {"error": "same key, different request"}
            if row[1] != "DONE":
                return 409, {"error": "request in progress"}
            return row[2], row[3]                        # 저장된 응답을 그대로

        code, resp = process_payment(conn, user_id, body)  # 같은 트랜잭션에서 처리
        conn.execute(
            "UPDATE idempotency_key SET status = 'DONE', response_code = %s, "
            "response_body = %s WHERE user_id = %s AND idem_key = %s",
            (code, json.dumps(resp), user_id, idem_key))
        return code, resp

키를 사용자 ID와 묶어 저장하면, 다른 사용자가 같은 키 문자열을 보내도 서로 영향을 주지 않습니다. 오래된 키는 보관 기간을 정해 정리합니다.

코드 수준 ③: 상태 전이를 앞으로만

주문·결제·환불의 상태는 정해진 방향으로만 움직이게 합니다. 모든 상태 변경 SQL에 이전 상태 조건을 붙이면, 중복 이벤트와 순서가 뒤바뀐 이벤트가 모두 영향받은 행 0으로 끝납니다.

-- PENDING일 때만 PAID로. 이미 PAID면 아무 일도 하지 않는다
UPDATE orders SET status = 'PAID', paid_at = now()
 WHERE id = :order_id AND status = 'PENDING';

-- PAID일 때만 REFUNDED로
UPDATE orders SET status = 'REFUNDED', refunded_at = now()
 WHERE id = :order_id AND status = 'PAID';

부분 환불이 있다면 "원거래당 누적 환불액이 승인액을 넘지 않는다"를 조건부 UPDATE와 제약으로 강제합니다.

-- 누적 환불액이 승인액을 넘지 않을 때만 환불 (값은 가짜)
UPDATE payment
   SET refunded_amount = refunded_amount + :refund_amount
 WHERE id = :payment_id
   AND refunded_amount + :refund_amount <= approved_amount;

ALTER TABLE payment
  ADD CONSTRAINT ck_refund_not_over_approved CHECK (refunded_amount <= approved_amount);

설정 수준: 수신 경로와 시크릿 회전

  • 웹훅 수신 경로는 결제대행사 IP 대역 허용 목록과 서명 검증을 둘 다 적용합니다. 한쪽만으로는 부족합니다.
  • 웹훅 서명용 시크릿(코드와 로그 밖에 숨겨야 하는 값)을 바꿀 때는 이전 키와 새 키를 함께 받아 주는 기간을 둡니다. 위 예시의 WEBHOOK_SECRETS 목록이 그 역할을 합니다. 이 기간이 없으면 회전하는 순간 정상 웹훅이 실패하고, 대행사의 재시도가 몰립니다.

아키텍처 수준: 받자마자 저장하고, 처리는 따로

웹훅 처리 시간이 길면 대행사가 응답을 기다리다 재시도하고, 재시도가 다시 처리 시간을 늘립니다. 이를 끊으려면 받자마자 수신함(inbox) 테이블에 저장하고 200을 돌려준 뒤, 비동기 소비자가 이벤트 ID 기준으로 한 번만 처리합니다.

-- inbox: 이벤트 ID가 기본 키라 같은 이벤트는 한 번만 저장된다
CREATE TABLE webhook_inbox (
  provider     text        NOT NULL,
  event_id     text        NOT NULL,
  payload      jsonb       NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  PRIMARY KEY (provider, event_id)
);

-- 소비자: 아직 처리하지 않은 이벤트를 여러 워커가 겹치지 않게 가져간다
SELECT provider, event_id, payload
  FROM webhook_inbox
 WHERE processed_at IS NULL
 ORDER BY received_at
 LIMIT 50
   FOR UPDATE SKIP LOCKED;
-- 처리와 processed_at = now() 갱신을 같은 트랜잭션에서

마지막 겹은 대사(reconciliation)입니다. 내부 원장과 결제대행사 정산 데이터를 매일 자동으로 맞춰 보고, 불일치를 경보로 올립니다. 앞의 모든 장치를 통과한 오류도 하루 안에 드러납니다.

-- 어제자 내부 원장과 PG 정산 데이터의 불일치 (테이블은 가짜)
SELECT payment_id, l.amount AS ledger_amount, s.amount AS settled_amount
  FROM ledger_daily l
  FULL OUTER JOIN pg_settlement_daily s USING (payment_id)
 WHERE coalesce(l.biz_date, s.biz_date) = current_date - 1
   AND l.amount IS DISTINCT FROM s.amount;

탐지 신호

  • 같은 이벤트 ID·결제 ID의 중복 수신 로그. 수신 자체는 정상이지만, 비율이 갑자기 늘면 우리 처리 시간이나 응답에 문제가 있다는 뜻입니다.
  • 처리 시간이 대행사 재시도 간격을 넘는 웹훅. 중복 수신의 원인이 됩니다.
  • 원거래 대비 환불 비율 이상.
  • 대사 불일치 건수 증가.
  • 서명 검증 실패 급증. 시크릿 회전 실수이거나, 누군가 위조·재전송을 시도하고 있다는 신호입니다.

설계 원칙으로 정리

  1. 동시에 온다고 가정합니다. single-packet attack 이후 "타이밍이 맞을 리 없다"는 방어가 되지 못합니다. 정상 재시도와 at-least-once 전달도 같은 요청을 다시 보냅니다.
  2. "한 번만"은 DB 한 지점에서 원자적으로 판단합니다. 판단과 갱신을 조건부 UPDATE 한 문장으로 합치고, 영향받은 행 수로 성공을 판단합니다. 애플리케이션의 if문은 동시 요청 앞에서 같은 상태를 여러 번 봅니다.
  3. 규칙은 제약으로 못 박습니다. 유니크 제약과 CHECK 제약이 코드 실수의 마지막 방어선입니다.
  4. 상태는 앞으로만 움직입니다. 모든 상태 변경에 이전 상태 조건을 붙이면 중복과 순서 뒤바뀜이 모두 영향받은 행 0으로 끝납니다.
  5. 매일 숫자를 맞춰 봅니다. 원장 대사와 정산 점검이 앞의 장치를 통과한 오류를 하루 안에 드러냅니다.

점검 체크리스트

  • 잔액·재고·쿠폰처럼 금액이나 수량이 움직이는 곳에 "조회 → if → save" 패턴이 없다
  • 조건부 UPDATE의 영향받은 행 수 0을 실패로 처리한다
  • 여러 행을 보고 판단하는 흐름은 같은 트랜잭션 안에서 SELECT … FOR UPDATE로 잠그고, 잠금 순서가 정해져 있다
  • "한 번만" 규칙(쿠폰 사용, 1인 1회, 이벤트 ID)이 유니크 제약으로 스키마에 있다
  • 잔액·환불액에 CHECK 제약(음수 금지, 승인액 초과 금지)이 있다
  • 인증 코드·재설정 토큰은 DELETE … RETURNING이나 조건부 UPDATE로 소비 자체를 원자화했다
  • 분산 락을 쓰는 곳은 펜싱 토큰과 DB 제약을 함께 둔다
  • 웹훅은 원본 바이트로 서명을 검증하고, 타임스탬프 허용 오차를 확인하고, 이벤트 ID를 유니크 키로 기록한 뒤 처리한다
  • 결제·환불 API가 Idempotency-Key를 받아 같은 키에는 저장된 응답을, 다른 본문에는 거절을 돌려준다
  • 주문·결제 상태 변경 SQL에 모두 이전 상태 조건이 붙어 있다
  • 같은 요청을 동시에 N번 실행하는 동시성 테스트가 CI에 있다
  • 내부 원장과 PG 정산 데이터를 매일 자동으로 대사하고, 불일치를 경보로 올린다

정리

  • 경쟁 조건은 확인과 사용 사이의 틈에서 생깁니다. 각 요청은 혼자 보면 정상이지만 합치면 한도를 넘고, PortSwigger 연구 이후 원격에서도 그 틈을 안정적으로 노릴 수 있게 됐습니다.
  • 웹훅은 최소 한 번 오고, 클라이언트는 타임아웃 뒤 다시 보냅니다. 서명이 맞는 요청이라도 두 번째로 왔다면 처리하지 않아야 합니다.
  • 막는 방법은 한곳으로 모입니다. 조건부 UPDATE, SELECT … FOR UPDATE, 유니크 제약, 멱등성 키, 단방향 상태 전이로 "한 번만"을 DB가 판단하게 합니다.
  • 그 위에 동시성 테스트와 매일의 대사를 더해, 규칙이 지켜지고 있는지 계속 확인합니다.

더 깊게

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 10-1, 10-2, 10-3, 10-4

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 2-8, 4-3, 4-4, 6-2

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. NatWest·Chase 사례는 영향만 공개됐고 원인은 [추정]입니다. 이 글은 법률 자문이 아닙니다.


이전 글 경계 밖의 이름이 문을 연다: DNS 리바인딩과 서브도메인 탈취
다음 글 봇과 청구서가 공격이 될 때: 자동화 위협과 비용 고갈 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

1개의 댓글

comment-user-thumbnail
2026년 11월 3일

안녕하세요! 확인한 시점과 쓴 시점 사이 틈을 조건부 UPDATE와 FOR UPDATE 그리고 유니크 제약으로 나눠 주셔서 정리가 잘 됐습니다. 쿠폰처럼 한 번만 허용할 땐 어느 쪽을 먼저 권하시나요? 저도 비슷한 주제로 공부 기록을 올리고 있습니다. 팔로우 눌러 두고 갑니다

답글 달기