[CS] 결제 시스템 - 주문하기

Bronze_Yun·2026년 8월 25일

PocketPay

목록 보기
1/4
post-thumbnail

PG사를 이용한 결제 시스템을 직접 구축해보고 싶어서 프로젝트를 진행하게 되었습니다.

프로젝트를 간략하게 설명하자면, 여러 기업들이 포켓몬 카드를 저희 사이트에 판매 등록하면, 일반 사용자가 그 카드를 구매하고, PG사 수수료와 PocketPay(플랫폼) 수수료를 제외한 나머지 금액이 해당 기업에게 정산됩니다. 구매자에게는 구매 금액의 5%를 포인트로 지급합니다.

즉 스토어가 직접 재고를 파는 일반적인 이커머스 구조가 아니라, 여러 기업이 자사 카드를 등록하면 일반 사용자가 그걸 사가는 위탁판매 구조입니다.

이번 결제 시스템 블로그 시리즈는 크게 세 가지 주제로 나눠서 작성할 예정입니다.

  1. 주문 — 오늘 다룰 내용
  2. 결제 승인
  3. 환불

오늘은 그 첫 번째, "주문 하나가 재고 하나를 정확히 줄이게 만들기"입니다.


고려한 부분

주문 하나하나가 "누군가의 재고를 정확히 하나 줄이는 일"이라는 단순한 문장을 지키기 위해, 아래 두 가지를 반드시 풀어야 했습니다.

  • 같은 요청이 두 번 처리되면 안 된다 — 네트워크 재시도, 사용자의 연타 클릭(일명 따닥), 프론트엔드 버그 등으로 같은 요청이 중복 도착할 수 있습니다.
  • 동시에 여러 명이 같은 상품을 주문해도 재고가 꼬이면 안 된다 — 인기 있는 한정판 카드는 여러 사용자가 동시에 몰리는 상황이 흔합니다.

각각을 왜 중요하게 봤는지, 그리고 어떻게 풀었는지 순서대로 정리해보겠습니다.


요청 하나가 정확히 한 번만 처리되게 🤔

왜 이게 문제가 되나

카드 재고는 입점 기업이 등록한 카드라서 애초에 수량 자체가 한정적인 경우가 많습니다. 만약 같은 주문 요청이 실수로 두 번 처리되면, 존재하지도 않는 재고의 두 번째 차감이 일어나거나, 최악의 경우 이미 판매된 카드가 다시 판매 가능한 것처럼 보이는 등 데이터 정합성이 완전히 무너질 수 있습니다.

중복 요청이 생기는 경로는 생각보다 다양합니다.

  • 사용자가 "주문하기" 버튼을 빠르게 두 번 클릭
  • 프론트엔드가 응답 지연을 타임아웃으로 오인하고 자동 재시도
  • 네트워크가 불안정해 요청은 서버에 도착했는데 응답만 유실
  • 클라이언트 재시도 로직이나 로드밸런서 레벨의 재시도

이런 경로들의 공통점은 "같은 의도를 가진 요청이 물리적으로 두 번 이상 서버에 도달할 수 있다"는 거예요. 그래서 서버 쪽에서 "이 요청, 전에 본 적 있나?"를 판단할 수 있는 장치가 필요했습니다.

어떻게 풀었나 — 멱등키 이중 방어

주문 생성 API(POST /api/orders)는 요청마다 고유한 식별 키(멱등키, Idempotency-Key 헤더)를 반드시 받도록 설계했습니다.

(멱등성에 대한 설명은 토스페이먼츠에 잘 나와있으니 한번 읽어보면 좋을것 같습니다.)

클라이언트: 요청을 보낼 때마다 새 키를 발급해서 헤더에 실어 보냄
서버: 이 키를 Redis에 SETNX로 먼저 선점 시도
   ├── 아무도 선점하지 않은 키 → 선점 성공, 그대로 처리 진행
   └── 이미 선점된 키(=같은 요청이 이미 들어온 적 있음) → 즉시 "중복 요청"으로 반려

실제로는 idempotency:lock:order:{idempotencyKey}라는 키에 SETNX를 걸고, 이 락에는 60초짜리 TTL을 같이 붙여둡니다.

Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey(namespace, idempotencyKey), "LOCKED", Duration.ofSeconds(lockTtlSeconds));

여기에 한 겹을 더 뒀습니다. DB에도 이 키에 유일성 제약(Unique Constraint)을 걸어둔 겁니다.

ALTER TABLE orders
  ADD CONSTRAINT uk_orders_idempotency_key UNIQUE (idempotency_key);

처음엔 "Redis가 장애나 재시작으로 데이터를 잃어버릴 수도 있으니까 DB로 한 번 더 막아두자"는 정도로만 생각했는데, 실제로 이 두 겹을 어떻게 조합할지 코드로 옮기면서 훨씬 더 구체적인 이유가 드러났습니다. Redis 쪽 락에 굳이 TTL을 붙여둔 이유부터가, "이 락이 영원히 안 풀리는 상황을 막기 위해서"였습니다. 만약 락을 잡은 채로 처리 중이던 서버가 죽어버리면, TTL이 없는 락은 아무도 못 푸는 좀비 락이 되어버립니다. 그래서 "처리 중 영구히 안 풀리는 것 방지"용 세이프티넷으로 60초 TTL을 뒀는데, 이 TTL은 곧 "Redis 혼자서는 60초를 넘어가는 순간부터 같은 키를 완전히 새로운 요청과 구분하지 못한다"는 뜻이기도 합니다.

그리고 주문 생성 흐름에서는 이 락을 처리가 끝난 뒤 명시적으로 풀어주지(release) 않습니다. 성공하든 실패하든 그대로 두고 TTL이 흐르길 기다립니다. 그래서 실제로 막아야 하는 상황은 두 가지입니다.

  1. 같은 키로 60초 안에 다시 요청이 온 경우 → Redis의 SETNX가 그 자리에서 바로 막아줍니다. DB까지 갈 필요도 없습니다.
  2. 같은 키로 60초가 지난 뒤에 다시 요청이 온 경우(뒤늦은 재시도, 클라이언트 버그로 같은 키를 재사용, Redis 재시작으로 락 정보 유실 등) → Redis 입장에서는 이미 TTL이 지나 아무도 선점하지 않은 키라서 선점에 성공해버립니다. 이때 실제로 막아주는 건 Redis가 아니라, orders 테이블에 새 행을 넣으려는 순간 걸리는 DB 유일성 제약입니다.
try {
    Order newOrder = Order.create(generateOrderNumber(), memberId, totalAmount, idempotencyKey);
    order = orderRepository.save(newOrder);
} catch (DataIntegrityViolationException e) {
    throw new CustomException(CommonErrorCode.DUPLICATE_REQUEST); // COMMON_001
}

즉 Redis는 "빠르게, 대부분의 경우를 걸러주는 1차 방어"이고, DB 유일성 제약은 "Redis의 TTL이 만료된 뒤에도 여전히 살아있는, 절대 뚫리면 안 되는 최종 방어선"입니다. Redis 장애 상황도 결국 이 두 번째 케이스로 흡수됩니다 — Redis가 어떤 이유로 락 정보를 잃어버리든, 최종적으로는 DB가 잡아줍니다.

💡 왜 이전 응답을 캐싱해서 재사용하지 않았나?

주문 생성은 처리 자체가 워낙 빠르고 가벼운 작업이라, 재요청이 왔을 때 "이전과 완전히 동일한 응답"을 반드시 재구성해서 돌려줘야 할 만큼 절박한 케이스가 아니라고 판단했고, 응답을 캐싱하려면 별도의 저장소와 TTL 관리까지 함께 고려해야 해서 복잡도가 늘어납니다. 그래서 먼저 온 요청만 통과시키고 나머지는 바로 거절하는 훨씬 단순한 방식을 선택했습니다. 그 대신, 처리가 끝나도 락을 풀어주지 않고 TTL(60초)이 다 지날 때까지는 같은 키로 재요청이 오면 무조건 거절하도록 뒀습니다.

여기서 아직 남아있는 트레이드오프도 하나 짚어두려고 합니다. 락을 성공/실패와 상관없이 풀어주지 않다 보니, 상품이 존재하지 않거나 재고가 부족해 주문 생성이 실패한 경우에도 같은 멱등키는 TTL 60초 동안 그대로 막혀 있습니다. 클라이언트가 실패 원인을 고치고 같은 키로 바로 재시도하면, 실제로는 처음 보는 시도인데도 "중복 요청"으로 거절당하는 겁니다. 지금은 "멱등키는 시도 1회당 새로 발급한다"는 클라이언트 쪽 규약으로 감수하고 있는 부분이라고 생각합니다.


동시에 몰려도 재고가 안 꼬이게 🤔

왜 이게 문제가 되나

이 서비스에서 거래되는 상품은 현재 인기있는 포켓몬 카드입니다. 인기 있는 카드일수록 여러 사용자가 거의 같은 순간에 주문을 시도하는 상황이 자주 벌어집니다. 이때 반드시 지켜야 하는 원칙은 하나입니다.

실제로 존재하는 재고보다 더 많이 팔리는 일은 절대 일어나면 안 된다.

사용자가 "주문하기" 버튼을 누르는 순간, 코레일톡에서 좌석을 고르면 그 즉시 다른 사람이 못 잡게 선점되는 것처럼, 해당 카드를 곧바로 예약 상태로 선점합니다. 그래야 주문서까지 다 넘어가서 결제 정보를 입력했는데 그제서야 "이미 팔린 카드입니다"라는 안내를 받는 최악의 경험을 막을 수 있기 때문입니다.

동시성 제어 방식을 정하기까지 총 세 단계를 거쳤습니다. 각 단계에서 무엇을 확인했고 왜 다음 단계로 넘어갔는지 순서대로 정리해보겠습니다.

첫 번째 시도 — 낙관적 락 🔒

가장 먼저 검토한 건 낙관적 락(Optimistic Lock)이었습니다. 재고 row에 @Version 필드를 두고, 저장하는 순간에만 버전이 바뀌었는지 확인하는 방식입니다.

UPDATE stock
SET reserved_quantity = reserved_quantity + 1,
    version = version + 1
WHERE product_id = :productId
  AND version = :versionReadEarlier;
-- 영향받은 row가 0건이면 다른 요청이 먼저 갱신한 것 → 재시도

락을 미리 걸지 않으니 평소엔 가볍고, 실패하면 Backoff 및 Jitter를 두고 최대 3회까지 재시도하도록 구현했습니다. "충돌은 자주 안 일어난다"는 전제가 맞다면 이게 가장 효율적인 선택이라고 생각했습니다.

부하테스트

  • 재고 30개짜리 상품 하나에 여러 사용자가 동시에 몰리는 시나리오

총 60건의 요청 중 29건이 실패했습니다. 거의 절반에 가까운 실패율입니다.

더 눈여겨봐야 할 건 재고 테이블 상태였습니다. total_quantity는 30인데 reserved_quantity는 21, sold_quantity는 0, 그리고 version은 51이었습니다.

이 숫자들이 말해주는 건 이렇습니다.

  • 재고는 아직 9개나 남아있었습니다 (30 - 21 = 9). 즉 팔 수 있는 재고가 분명히 있었는데도 안 팔린 겁니다.
  • version이 51까지 올라갔다는 건, 재고를 21개 반영하는 데 무려 51번의 시도가 있었다는 뜻입니다. 절반 가까운 시도가 버전 충돌로 헛수고가 됐다는 의미입니다.
  • 그리고 재고 9개는 왜 안 팔렸을까요? 재시도를 최대 3회로 제한해뒀기 때문입니다. 경쟁이 치열했던 그 순간, 3번 다 남한테 밀린 요청들은 "아직 재고가 남아있었음에도" 재시도 횟수를 다 써버리고 실패로 끝나버린 겁니다.

정리하면, 낙관적 락에서 겪은 문제는 두 가지였습니다.

  1. 판매 기회 손실 — 우연히 재시도 타이밍이 계속 겹친 요청은, 재고가 남아있었는데도 재시도 횟수 소진으로 실패 처리됩니다. 사용자 입장에서는 "분명 남아있다고 했는데 왜 안 사졌지?"라는 경험을 하게 됩니다.
  2. 오히려 느려짐 — 재시도 한 번은 "조회 → 로직 실행 → 저장 시도"라는 사이클을 처음부터 다시 도는 것과 같습니다. 충돌이 잦으면 이 무거운 사이클이 여러 번 반복되면서, 차라리 순서대로 기다리는 것보다 전체적으로 더 오래 걸리는 경우가 생겼습니다.

결론: 충돌이 자주 발생하는 상황에서는 낙관적 락을 쓰지 않는 게 좋습니다. 낙관적 락은 "대부분 충돌이 안 난다"는 전제가 성립할 때만 이득을 보는 방식인데, 한정판 카드처럼 특정 상품에 순간적으로 트래픽이 몰리는 경우는 정확히 그 전제가 깨지는 상황이었습니다. 충돌이 잦아지면 재시도가 재시도를 낳으면서, 판매 기회는 놓치고 응답 시간은 오히려 늘어나는 역효과가 났습니다.

두 번째 시도 — 비관적 락 🔒

낙관적 락의 문제는 "충돌이 잦은 상황에서 재시도가 손해를 본다"는 것이었으니, 아예 처음부터 순서를 확정 짓는 비관적 락(Pessimistic Lock)으로 방향을 바꿨습니다.

SELECT * FROM stock WHERE product_id = :productId FOR UPDATE;

이 방식은 구현이 단순하고, "먼저 락을 잡은 트랜잭션이 끝날 때까지 다른 트랜잭션은 절대 끼어들 수 없다"는 게 DB 엔진 레벨에서 보장되기 때문에 정합성 측면에서는 의심할 여지가 없습니다. 낙관적 락에서 겪었던 "재고는 남아있는데 재시도 소진으로 놓치는" 문제도 이 방식에서는 원천적으로 발생하지 않습니다. 재시도가 아니라 순서대로 처리되기때문입니다.

부하테스트

  • 재고 10,000개짜리 상품 하나에 최대 3,000명(VU)이 3분 동안 지속적으로 몰리는 시나리오

테스트가 시작되고 얼마 지나지 않아 대기 스레드(Pending)가 190까지 치솟았습니다. 총 커넥션은 10개뿐인데, 그 10개를 얻으려는 요청이 최대 190개까지 줄을 선 겁니다. 그 뒤로는 계속 130~190 사이를 오가며 커넥션 풀이 거의 항상 포화 상태였습니다.

k6 결과를 보면 checks 통과율이 96.50%로, 정상 응답(201, 409)이 아닌 실패가 섞여 있었습니다. 응답 시간도 평균 1분 5초, 최대 1분 59초로 상당히 느렸습니다.

VU가 3,000으로 유지되는 동안 처리량(req/s)이 계속 낮은 수준에 머물러 있는 걸 볼 수 있습니다. 요청은 계속 쌓이는데 처리 속도가 못 따라가고 있다는 뜻입니다.

가장 눈에 띄는 지표는 Connection Timeout이 151건 발생했다는 겁니다. HikariCP가 커넥션을 못 받고 기다리다가, 결국 포기하고 예외를 던진 횟수입니다.

실제 요청 단위로 쪼개 보면 더 명확합니다. 성공(201) 응답조차 p95가 1.54분이었고, 여기에 500 에러까지 섞여 있었습니다(min 10.2초, p95 45.7초). 즉 비관적 락 하나로 인해 DB 커넥션 풀이 고갈되면서, 재고 차감과 전혀 무관해 보이는 "서버 에러"라는 형태로 사용자에게 장애가 전파된 겁니다.

여기서 든 생각 — Redis를 쓰면 훨씬 빠르지 않을까?

비관적 락의 문제는 결국 "DB 커넥션을 오래 붙잡고 있다"는 것이었습니다. 그래서 자연스럽게 이런 생각이 들었습니다.

"Redis는 인메모리 저장소라 디스크 기반 DB보다 훨씬 빠르니까, 락 관리를 Redis(Redisson 분산 락)로 옮기면 비관적 락보다 응답 속도 자체가 훨씬 빨라지지 않을까?"

디스크 I/O가 필요한 DB 락과 달리, Redis는 메모리에서 바로 명령을 처리하니 "락을 잡고 푸는 속도" 자체는 분명 Redis 쪽이 훨씬 빠릅니다. 이 가정을 가지고 실제로 Redisson 분산 락을 도입해서 같은 조건으로 다시 측정해봤습니다.

세 번째 시도 — Redisson 분산 락 🔒

boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
if (!acquired) {
    throw new CustomException(REQUEST_TIMEOUT);
}

핵심은 재고 차감 로직에 진입하기 전에 상품 단위로 분산 락을 먼저 획득하고, 락을 얻은 요청만 DB에 접근하도록 구조를 바꾼 것입니다. 락을 정해진 시간(waitTime) 안에 못 얻으면, DB 근처에도 가지 않고 그 자리에서 바로 실패 응답(409)을 돌려주도록 했습니다.

부하테스트

  • 같은 조건(재고 10,000개, VU 3,000, 3분 지속)으로 재측정했습니다.

커넥션 그래프를 보면 대기(Pending) 선이 테스트 내내 바닥에 붙어 있고, 오히려 유휴(Idle) 커넥션이 대부분의 시간 동안 8~10개를 유지하고 있습니다. DB 커넥션 풀은 거의 놀고 있었다는 뜻입니다. Redis 락이 앞단에서 트래픽을 먼저 걸러준 덕분에, DB까지 도달하는 요청 자체가 크게 줄어든 겁니다.

checks 통과율이 100.00%로 올라갔습니다. 실패(fail_count 6,232건)는 여전히 존재하지만, 전부 정상적인 409(재고부족 또는 락 대기 초과) 응답이었고 500 에러는 단 한 건도 발생하지 않았습니다(http_req_failed 0.11%, 10건 — 이마저도 서버 에러가 아니라 다른 예외 처리 케이스).

가장 극적으로 달라진 건 이 지표입니다. Connection Timeout이 0건, Pending 최댓값도 0이었습니다. Before에서 151건, 190까지 치솟았던 지표가 완전히 사라졌습니다.

요청 상세 테이블에도 이제 201과 409만 존재합니다. 500 상태 자체가 사라졌습니다. 성공(201) 응답의 p95는 1.10분으로, Before(1.54분)보다는 개선됐지만 여전히 1분대였습니다.

왜 예상만큼 극적으로 빨라지지 않았을까

결과만 놓고 보면 물음표가 남습니다. DB 커넥션 문제는 완전히 해결됐는데, 왜 응답 속도는 1.54분 → 1.10분 정도로만 개선되고, 여전히 1분대에 머물러 있을까요? Redis가 메모리 기반이라 더 빠를 거라 예상했던 것과는 다른 결과였습니다.

원인을 찾아보니, "락 자체를 처리하는 속도"와 "동시에 몰린 사람들이 순서를 기다리는 시간"은 서로 다른 문제였습니다.

  • Redis가 락 하나를 걸고 푸는 데 걸리는 시간은 분명 밀리초 단위로 매우 빠릅니다. 여기까지는 예상대로였습니다.
  • 하지만 3,000명이 동시에 같은 상품에 몰리면, 그 3,000명은 결국 어떤 방식으로든 한 명씩 순서대로 처리될 수밖에 없습니다. 락 자체가 아무리 빨라도, "내 앞에 2,999명이 있다"는 사실 자체는 바뀌지 않습니다.
  • 즉 병목이 사라진 게 아니라, DB 커넥션 풀이라는 위험한 곳(고갈되면 서버 전체가 죽는 자원)에서, Redis 락 대기열이라는 안전한 곳(고갈돼도 그냥 409로 순서를 알려줄 뿐인 자원)으로 옮겨간 것입니다.

정리하면, Redis를 쓴다고 해서 "동시에 3,000명이 몰렸을 때 모두를 순식간에 처리해주는" 마법은 일어나지 않습니다. 분산 락이 실제로 해결해준 문제는 속도가 아니라 안정성이었습니다. DB 커넥션이라는 비싸고 위험한 자원을 오래 붙잡지 않게 되면서, 시스템이 죽지 않고 "느리지만 안전하게" 응답할 수 있게 된 것이 이번 개선의 진짜 핵심이었습니다.

세 방식을 실측으로 정리하면

지표낙관적 락비관적 락 단독분산 락
재고 정합성 문제없음없음없음
재시도로 인한 지연있음(버전 충돌마다 조회~저장 사이클 재실행)없음없음
checks 통과율-96.50%100.00%
500 에러-발생없음
Connection Timeout-151건0건
HikariCP Pending 최대-1900
성공 응답 p95-1.54분1.10분

낙관적 락은 애초에 이 규모의 부하테스트(VU 3,000)까지 갈 필요도 없이, 훨씬 작은 트래픽(60건)에서도 문제가 드러났습니다. 충돌이 잦은 자원에는 재시도 기반의 낙관적 락이 오히려 독이라는 걸 실측으로 먼저 확인했고, 그래서 비관적 락으로 방향을 바꿨습니다. 이후 비관적 락의 DB 커넥션 문제를 분산 락으로 보완하면서, 응답 속도 자체의 개선폭은 기대보다 크지 않았지만 시스템이 절대 죽지 않는다는 것을 확보했습니다. 커넥션 풀 고갈은 재고 차감 API 하나만의 문제로 끝나지 않고, 같은 DB 커넥션 풀을 공유하는 다른 모든 API(로그인, 상품 조회, 다른 주문 등)까지 연쇄적으로 마비시킬 수 있는 문제이기 때문입니다.


전체 흐름 정리

주문 생성 요청 하나가 들어왔을 때, 시스템은 이렇게 움직입니다.

요청 수신
  ↓
멱등키 검증 (Redis SETNX, 60초 세이프티넷 TTL)
  ↓ (60초 이내 동일 키 재요청이면 즉시 반려)
상품 존재 확인
  ↓
주문 레코드 생성 (idempotency_key에 DB 유일성 제약 — Redis TTL 만료 후 재요청까지 여기서 최종 반려)
  ↓
Redis 분산 락 획득 시도 (상품 단위)
  ├── 획득 실패(waitTime 초과) → 즉시 실패 응답 + 주문 롤백  ← DB 근처도 안 감
  └── 획득 성공
        ↓
      DB에서 재고 조회 + 비관적 락(FOR UPDATE)으로 최종 검증
        ├── 재고 부족 → 실패 응답 + 주문 롤백
        └── 재고 충분 → 차감 및 예약 완료
  ↓
분산 락 해제
  ↓
주문 상태 = "재고 예약 완료"
  ↓
주문번호 응답

이 흐름 덕분에, 한 상품에 여러 사용자가 몰리는 상황에서도 실제로 존재하는 재고보다 더 많이 팔리는 일은 절대 일어나지 않으면서도, 낙관적 락에서 겪었던 "재고는 남았는데도 못 판" 손실 없이, DB 커넥션이라는 위험한 자원까지 안전하게 지킬 수 있었습니다. 그리고 같은 멱등키로 오는 재요청은 60초 안에는 Redis가, 그 이후에는 DB가 이어받아 끝까지 막아줍니다.

profile
정답이 꼭 정답이 아니다.

0개의 댓글