
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 한 지점에서 원자적으로 판단하게 하는 것입니다.
PUT으로 값을 덮어쓰는 요청은 대개 멱등이고, "포인트 1,000점 적립" 요청은 멱등이 아닙니다.
쿠폰 사용 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을 붙였으니 안전하다고 생각하기 쉽지만, 트랜잭션은 "모두 성공하거나 모두 취소된다"를 보장할 뿐입니다. 기본 격리 수준에서 두 트랜잭션이 같은 값을 읽고 각자 판단하는 것은 막지 않습니다.
같은 구조는 쿠폰 밖에도 많습니다.
예전에는 이런 틈이 수 밀리초 정도라 원격에서 맞추기 어렵다고 여겼습니다. 네트워크를 지나는 동안 요청마다 도착 시각이 조금씩 흩어지기(지터) 때문입니다.
2023년 8월 PortSwigger Research는 Black Hat USA와 DEF CON 31에서 "Smashing the state machine" 연구를 발표했습니다. 이 연구는 여러 요청을 거의 같은 순간에 서버에 도착시키는 single-packet attack을 공개했습니다. 네트워크 지터의 영향을 줄여 원격에서도 이런 틈을 안정적으로 노릴 수 있음을 보였고, 실제 대상에서 기존 기법으로 2시간 넘게 걸리던 재현이 약 30초로 줄었다고 밝혔습니다. 같은 연구는 경쟁 조건을 "숨은 중간 상태를 가진 다단계 흐름"으로 봐야 한다고 정리했습니다. 한 번의 요청 처리 안에도 "아직 저장하지 않은 판단", "보냈지만 확정하지 않은 상태" 같은 중간 상태가 있고, 동시 요청은 그 중간 상태를 봅니다.
따라서 "동시에 올 리 없다"는 방어가 되지 못합니다. 동시에 온다고 가정하고 설계해야 합니다.
가장 가까운 항목은 A06 안전하지 않은 설계입니다. 동시성을 고려하지 않은 설계라는 뜻입니다. CWE로는 362·367로 분류되지만 Top 10에 카테고리 이름으로 나오지 않습니다. API Security Top 10의 API6(민감한 비즈니스 흐름에 대한 무제한 접근)과도 겹칩니다.
독립 항목이 없는 이유는 결함의 모양에 있습니다. 코드 한 줄의 결함이 아니라 "검사와 사용이 원자적이지 않은 설계"입니다. 위 예시 코드의 어느 줄을 봐도 틀린 곳이 없고, 정적 분석 도구가 짚을 만한 위험한 함수도 없습니다. 요청을 하나씩 보내는 일반 테스트도 모두 통과합니다. 정상 기능을 정상 순서로 쓰는데 결과만 틀리는 문제라, 위험 범주 목록에서는 넓은 "설계" 항목 안에 들어갑니다.

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"하는 모양은 흔하고, 이 모양이 그대로 공격면입니다.

가장 먼저 쓸 방법입니다. 판단 조건을 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하는 방식은 위험한 모양으로 돌아갑니다.
판단에 여러 행이 필요하면 조건부 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("다시 시도해 주세요");
}
금전이 움직이는 흐름에서는 충돌 시 자동 재시도 대신 실패를 반환하는 쪽이 안전합니다. 자동 재시도는 사용자가 의도하지 않은 두 번째 처리를 만들 수 있고, 사용자가 결과를 보고 다시 시도하게 하는 편이 추적하기 쉽습니다.
인증 코드, 비밀번호 재설정 토큰, 초대 링크처럼 한 번만 쓸 수 있는 값은 "조회해서 유효한지 보고 → 사용 처리"로 나누지 않습니다. 소비하는 연산 하나로 끝냅니다.
-- 인증 코드 소비: 삭제된 행이 있을 때만 성공 (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;

결제대행사(PG)는 결제가 끝나면 우리 서버의 웹훅 주소로 "결제 성공" 이벤트를 보냅니다. 이 전달은 대부분 최소 한 번(at-least-once) 방식입니다. 우리 서버의 응답이 늦거나 5xx 오류가 나면, 대행사는 같은 이벤트를 다시 보냅니다. 대행사 입장에서는 이벤트를 잃는 것보다 두 번 보내는 쪽이 낫기 때문입니다.
우리 서버가 실제로는 첫 이벤트를 처리했는데 응답만 늦었다면 어떻게 될까요. 대행사는 실패로 보고 다시 보내고, 서버는 그 이벤트를 새 결제로 처리합니다. 포인트가 두 번 쌓이고, 배송이 두 번 나가고, 환불 이벤트였다면 환불이 두 번 됩니다.
같은 일이 우리 API 쪽에서도 생깁니다. 모바일 앱이 결제 요청을 보낸 뒤 타임아웃이 나면 다시 보냅니다. 첫 요청이 서버에서 이미 처리됐다면 결제가 두 번 됩니다.
여기에 재전송(replay)이 더해집니다. 웹훅 서명을 검증하지 않거나, 서명은 검증해도 타임스탬프를 보지 않으면, 한 번 캡처된 정상 웹훅 요청을 제3자가 나중에 다시 보내 같은 효과를 낼 수 있습니다. 서명이 맞는 요청이라도 두 번째로 온 것이라면 처리하면 안 됩니다.
서명 없는 웹훅을 믿는 문제는 A08 소프트웨어·데이터 무결성 실패에, 멱등성을 설계하지 않은 문제는 A06 안전하지 않은 설계에 걸칩니다. 하지만 멱등성은 보통 분산 시스템의 신뢰성 항목으로 분류되어 보안 점검에서 빠지기 쉽습니다. 웹훅 서명을 검증했다면 보안 점검표의 해당 줄은 통과합니다. 그다음 줄, "서명이 맞는 같은 이벤트가 두 번 오면?"은 점검표에 없는 경우가 많습니다. 결과는 금전 사고입니다.
원인까지 공개된 웹훅 중복 사고 포스트모템은 찾지 못했습니다. 아래는 "중복 처리"가 어떤 규모의 영향을 내는지 보여 주는 공개 사례이고, 기술적 원인은 [추정]입니다.
NatWest (영국, 2022). 직불카드 거래 약 11만 2천 계좌(직불카드 고객의 약 1%)에서 결제가 두 번 표시되고 차감됐습니다. 은행은 가맹점에는 한 번만 지급됐다고 밝혔습니다. 원인은 상세히 공개되지 않았습니다([추정]: 배치 재처리 중 중복).
JPMorgan Chase (미국, 2023-06). 온라인 뱅킹의 버그로 일부 고객의 거래와 수수료가 중복 처리됐고, 은행은 역처리와 환불을 진행했습니다. 원인 상세는 공개되지 않았습니다.
결제대행사의 공식 안내. Stripe는 웹훅 엔드포인트가 같은 이벤트를 두 번 이상 받을 수 있으니 처리한 이벤트 ID를 기록하라고 문서에 적고 있습니다. 서명 헤더의 타임스탬프 허용 오차(SDK 기본 300초)로 재전송을 제한합니다. 중복 전달은 규약에 들어 있는 정상 동작이라는 뜻입니다.
국내 참고: 현대카드 결제대금 이중 출금 (2014). 내부 전산 오류로 약 1,300명에게서 약 15억 원이 두 번 출금됐습니다. 2019년 이전 사례이고, 최근 국내 사례는 검증할 수 있는 원문을 찾지 못했습니다.
토스페이먼츠, KG이니시스, 포트원 등 어떤 PG를 쓰든 웹훅은 중복으로 온다고 가정하고 설계합니다.
수신 순서는 이렇습니다. 서명 검증 → 타임스탬프 허용 오차 확인 → 이벤트 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)
UPDATE가 영향받은 행 0으로 끝나는 경우(이미 PAID인 주문)는 정상입니다. 단방향 상태 전이가 한 번 더 막아 준 것입니다.우리 결제·환불 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);
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;
SELECT … FOR UPDATE로 잠그고, 잠금 순서가 정해져 있다DELETE … RETURNING이나 조건부 UPDATE로 소비 자체를 원자화했다Idempotency-Key를 받아 같은 키에는 저장된 응답을, 다른 본문에는 거절을 돌려준다SELECT … FOR UPDATE, 유니크 제약, 멱등성 키, 단방향 상태 전이로 "한 번만"을 DB가 판단하게 합니다.오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. NatWest·Chase 사례는 영향만 공개됐고 원인은 [추정]입니다. 이 글은 법률 자문이 아닙니다.
이전 글 경계 밖의 이름이 문을 연다: DNS 리바인딩과 서브도메인 탈취
다음 글 봇과 청구서가 공격이 될 때: 자동화 위협과 비용 고갈 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록
안녕하세요! 확인한 시점과 쓴 시점 사이 틈을 조건부 UPDATE와 FOR UPDATE 그리고 유니크 제약으로 나눠 주셔서 정리가 잘 됐습니다. 쿠폰처럼 한 번만 허용할 땐 어느 쪽을 먼저 권하시나요? 저도 비슷한 주제로 공부 기록을 올리고 있습니다. 팔로우 눌러 두고 갑니다