모든 글
약 18분 분량 학습 프로젝트/pay

보안·대기열·유입제어

목차

결제 시스템 시리즈: 보안·대기열·유입제어. 원 연재 여러 편을 한 챕터로 묶었다. 각 절이 원래 한 편이다.

무상태 토큰을 어떻게 폐기하나: JWT 갱신·폐기와 Redis denylist

0. 발급은 했는데, 그 다음은?

발급만 있던 토큰에 갱신과 폐기를 채웠다. 짧은 access는 회전(rotation)하는 refresh로 연장하고, 폐기는 Redis denylist로 요청마다 걸러낸다. JWT로 BCrypt 병목을 없앨 때는 사실 절반만 만든 상태였다. 로그인하면 토큰을 발급하는 것까지. 그때 글 말미에 이렇게 적어뒀다.

JWT 검증도 공짜는 아니고, 실서비스라면 토큰 만료·갱신(refresh)·폐기(블랙리스트)까지 설계해야 한다.

이번 절이 그 “그 다음”이다. 실서비스 토큰엔 두 가지가 더 필요하다.

  • 갱신(refresh): access 토큰이 30분 뒤 만료되는데, 사용자를 매번 재로그인(BCrypt)시킬 수는 없다. 만료 전에 조용히 연장해야 한다.
  • 폐기(revocation): 로그아웃하거나 토큰이 탈취되면, 그 토큰을 즉시 무효화해야 한다.

문제는 폐기였다.

1. 무상태의 역설: 폐기가 어렵다

JWT의 장점은 무상태다. 서버가 세션을 안 들고 있어도 토큰의 서명만 검증하면 된다(그래서 BCrypt 병목이 사라졌다). 그런데 폐기 앞에서는 이 장점이 그대로 발목을 잡는다.

서버가 아무 상태도 안 들고 있으니, 한 번 발급한 토큰을 “이건 이제 무효야”라고 표시할 곳이 없다. 토큰은 만료 시각까지 유효한 서명을 갖고 있고, 서버는 그걸 막을 방법이 없다. 로그아웃을 눌러도 토큰 자체는 여전히 살아있다.

상태를 안 들어서 빠른데, 바로 그것 때문에 폐기를 못 한다.

해법은 결국 “폐기된 토큰만” 상태로 들고 있는 것이다. 전체를 상태로 관리하면 무상태의 이점이 사라진다. 반면 폐기 목록(denylist)만 관리하면 대부분의 토큰은 여전히 무상태로 빠르게 검증되고, 폐기된 소수만 별도로 걸러진다.

denylist를 둘 곳은 Redis였다. 마침 이 프로젝트는 Redis를 의존성에 넣어두고 정작 런타임엔 안 쓰고 있었다(멱등은 DB로 했으니까). 이번에 Redis가 제 일을 맡게 됐다.

2. 짧은 access + 회전하는 refresh

먼저 토큰을 둘로 나눴다.

  • access 토큰: 짧게(30분). JWT라 서명만 검증해서 빠름. jti(토큰 고유 id)를 심는다.
  • refresh 토큰: 길게(14일). 불투명(opaque) 문자열(UUID)로, Redis에만 존재한다. JWT가 아니라서 Redis 조회로만 검증된다.

로그인하면 이 둘을 함께 준다. access가 만료되면 refresh로 새 access를 받는다.

POST /auth/refresh {refreshToken}
→ Redis에서 refresh 조회 → 있으면 새 access + 새 refresh 발급

여기서 중요한 게 회전(rotation)이다.

refresh로 갱신할 때마다 옛 refresh를 즉시 폐기하고 새 refresh를 발급한다. 한 번 쓴 refresh는 다시 못 쓴다. 만약 refresh가 탈취돼서 공격자가 먼저 갱신하면 정상 사용자의 다음 갱신이 실패하고, 그때 “누군가 내 토큰을 썼다”를 감지할 수 있다. 회전이 없으면 탈취된 refresh를 14일 내내 재사용당한다.

실기동으로 확인했다.

로그인 → access + refresh
refresh로 갱신 → 200, 새 access + 새 refresh
옛 refresh 재사용 → 401 ← 회전으로 무효화됨

3. 폐기: Redis denylist를 요청마다 검사

로그아웃(또는 탈취 대응)은 access 토큰을 즉시 무효화해야 한다.

POST /auth/logout
→ access의 jti를 Redis에 revoked:{jti} 로 등록 (TTL = 토큰 만료까지 남은 시간)
→ 해당 refresh 토큰도 Redis에서 삭제

그리고 요청마다 이 denylist를 검사한다. JWT 디코더에 검증기를 하나 더 붙였다.

// 기본 검증(서명·만료) + 폐기 검증
new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), revocationValidator);

revocationValidator는 토큰의 jtirevoked:{jti}에 있으면 거부한다. TTL을 “만료까지 남은 시간”으로 잡은 게 포인트다. 어차피 그 시각이 지나면 토큰은 자연 만료되니, denylist에 영원히 둘 필요 없이 자동으로 청소된다.

로그아웃 → 200
폐기된 access로 요청 → 401 ← denylist에 걸림

4. Redis가 죽으면? fail-open

여기엔 가용성 트레이드오프가 있다. 이제 모든 인증 요청이 Redis를 한 번 조회한다(denylist 검사). 그럼 Redis가 죽으면 인증 전체가 마비될까?

그렇게 두면 안 된다. 그래서 fail-open으로 했다.

denylist 검사에서 Redis 예외가 나면 통과시키고 경고 로그만 남긴다. Redis 장애 시엔 “폐기 검사를 못 하니 일단 유효한 것으로 본다”는 뜻이다. 폐기의 즉시성을 잃는 대신 인증 가용성을 지키는 선택이다.

정답이 있는 문제는 아니다. 보안이 극도로 중요하면 fail-closed(Redis 죽으면 전부 거부)가 맞을 수도 있다. 하지만 결제 서비스에서 “Redis 잠깐 죽었다고 모든 사용자가 로그아웃되는” 건 더 큰 사고라 가용성을 택했고, 무엇을 우선했는지를 주석으로 남겼다.

마치며

JWT 편의 미완성 절반이 이걸로 채워졌다. 대부분의 토큰은 무상태로 빠르게 검증하고, 폐기된 소수만 Redis denylist로 걸러낸다. 의존성에만 있고 안 쓰이던 Redis도 토큰 저장소 겸 denylist로 실제 역할을 찾았다.

그런데 “만들어두고 안 쓰던 것”이 토큰뿐이 아니었다. 돈의 정합성 쪽에도 하나 더 있었다. 대사 엔진이다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 갱신 회전·로그아웃 폐기를 실 Redis로 검증했다.


대사 엔진은 있는데 넣을 파일이 없었다: PG 정산 파일 업로드로 루프 닫기

0. 엔진은 도는데, 연료가 없었다

PG 정산 파일을 업로드해 대사 엔진에 물리는 입구를 만들었다. 이걸로 업로드 → 대사 → 불일치 큐 → 수기 확정의 루프가 닫힌다.

대사(reconciliation)는 우리 내부 기록과 PG가 보내온 기록을 맞춰보고, 안 맞으면 4분류로 남기는 정합성의 최종 방어선이다.

MATCHED 내부·외부 둘 다 있고 금액 일치 → 자동 종결
INTERNAL_ONLY 우리에겐 있는데 PG엔 없음 → 사람 확인
EXTERNAL_ONLY PG엔 있는데 우리에겐 없음 → 사람 확인
AMOUNT_MISMATCH 둘 다 있는데 금액이 다름 → 사람 확인

엔진(reconcile(List<ExternalRecord>))은 잘 만들어져 있었다. 그런데 정작 그 ExternalRecord(외부 기록)를 넣을 방법이 없었다. PG는 정산을 파일(CSV/엑셀)로 주는데, 그걸 파싱해서 엔진에 물리는 경로가 빠져 있었던 것. 내부 기록은 결제 완료 이벤트로 자동 적재되는데 외부 쪽 연료가 없으니 엔진이 사실상 돌지 않았다.

1. 진짜 정산 파일은 지저분하다

“CSV 파싱쯤이야” 싶었는데, 실무 정산 파일을 떠올리니 만만치 않았다.

PG마다 헤더 이름이 다르다. 어디는 orderNo, 어디는 order_no, 국내 PG는 주문번호. 금액도 amount/결제금액/settle_amount. 게다가 파일엔 우리가 안 쓰는 부가 컬럼(거래일시·승인번호·수수료…)이 잔뜩 있고, 맨 아래엔 요약행(합계)이나 빈 줄이 섞인다. 엑셀로 저장하면 파일 앞에 BOM이 붙고, 금액엔 천단위 콤마가 들어간다.

그래서 파서를 “관대하되 견고하게” 만들었다.

  • 헤더 기반 매핑: 첫 줄에서 컬럼명을 정규화(소문자·공백 제거)해 orderNo류와 amount류의 위치를 찾는다. 컬럼 순서나 부가 컬럼에 무관하다. 두 컬럼이 없으면 “이건 정산 파일이 아니다”로 400 에러.
  • 불량행 skip + 카운트: 컬럼이 모자란 요약행, orderNo가 빈 행, 금액 파싱 실패 행은 건너뛰고 스킵 수를 집계한다. 한 줄 이상하다고 전체 대사를 못 돌리면 안 되니까.
  • 자잘한 방어: BOM 제거, 천단위 콤마·공백 제거, 앞머리 공백줄 건너뛰기.

실무에서 필요한 동작은 “3만 줄 중 2줄은 요약행이라 건너뛰고 29,998건 대사 완료”다. “업로드했는데 3만 줄 중 1줄이 이상해서 실패”로 끝나면 곤란하다.

2. 업로드 → 대사 → 큐 → 확정, 루프가 닫힌다

이제 어드민에서 정산 파일을 올리면 된다.

POST /api/v1/admin/reconciliations/run (multipart, 정산 CSV)
→ 파싱 → reconcile() → 결과를 타입별 집계해 즉시 응답

실제로 한글 헤더 파일로 돌려봤다. 하나는 금액 일치, 하나는 금액 불일치(우리 10,000 vs PG 9,000), 하나는 우리에게 없는 유령 주문이다.

업로드 파일:
주문번호,결제금액,승인번호
01KWW05F...,10000,A001 ← 일치
01KWW05G...,9000,A002 ← 금액 불일치(내부 10000)
ORDER-GHOST-999,5000,A003 ← 외부 단독(우리에겐 없음)
응답:
{ external:3, matched:1, amountMismatch:1, externalOnly:1, pending:2 }

불일치 2건은 예외 큐(PENDING)에 쌓이고, 어드민이 조사한 뒤 수기 확정하면 큐에서 빠진다.

POST /admin/reconciliations/2/resolve → 200
확정 후 PENDING 큐 → [3] ← 확정한 2번은 빠지고 3번만 남음

그동안 조각조각 있던 것들(대사 엔진, 불일치 조회·수기 확정)이 파일 인입이라는 마지막 조각으로 이어졌다.

마치며

좋은 엔진도 돌릴 입력 경로가 없으면 쓰이지 못한다. FDS 판정 엔진도, 이 대사 엔진도 만들어두기만 하고 실제 트리거가 빠져 있었다는 공통점이 있었다.

또 하나. 실무 파일은 깔끔한 CSV 가정을 바로 배신한다. 헤더가 제각각이고, 요약행이 섞이고, BOM이 붙는 걸 전제로 파서를 짜야 한다. 관대하게 받아주되 무엇을 건너뛰었는지는 숫자로 정직하게 보고하는 것까지가, 정산처럼 돈이 걸린 데이터를 다루는 파서의 일이다.

여기까지가 만들어둔 것을 마저 잇는 작업이었다면, 다음은 성격이 다른 문제다. 한정판 오픈에 평소의 수백 배 트래픽이 한꺼번에 몰리면 어떻게 하나.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 한글 헤더 정산 CSV 업로드→대사→수기 확정을 실 MySQL로 검증했다.


한정판 오픈에 10만 명이 몰리면: Redis Sorted Set 선착순 대기열

0. 문을 넓히면 무너진다

Redis Sorted Set으로 선착순 대기열을 만들었다. 앞에서부터 정해진 인원만 들여보내고 나머지는 순번을 기다리게 해서, DB엔 항상 감당 가능한 만큼만 흘러 들어간다.

성능 편에서 부하를 다뤘지만, 무서운 건 선착순이다. 한정판 100개를 오픈하는 순간 10만 명이 몰리면, 평소 트래픽의 수백 배가 한꺼번에 들어온다. 이걸 “서버를 키워서” 감당하려는 건 대개 실패한다.

10만 명을 다 받아주면, 그 10만 개의 요청이 동시에 재고 차감을 때린다. DB 커넥션 풀이 순식간에 마르고, 락 경합이 폭발하고, 정상 사용자까지 다 느려진다. 어차피 살 수 있는 건 100명뿐인데 10만 명이 다 DB를 두들기는 것이다. 문을 아무리 넓혀도 그 뒤의 DB는 그대로다.

그래서 발상을 바꿨다. 문을 넓히는 대신 줄을 세운다.

1. Redis Sorted Set이 딱 맞는 이유

“순번이 있는 줄”을 뭘로 만들까. Redis Sorted Set(ZSET)이 이 용도에 들어맞는 자료구조다.

ZSET은 각 멤버에 score를 매겨 정렬해둔다. 그래서 이렇게 쓸 수 있다.

입장 요청 → INCR queue:{event}:seq (도착 순번을 원자적으로 발급)
→ ZADD queue:{event} {순번} {userId}

score도착 순번으로 쓰는 게 핵심이다. 순번이 작을수록 먼저 도착이니 ZSET이 자동으로 도착 순서(FIFO)로 정렬한다. 그럼 내 순위는?

rank = ZRANK queue:{event} {userId} // 0-based, 내 앞에 몇 명인지
admitted = (rank < admitLimit) // 앞에서 admitLimit명만 입장

ZRANK 하나로 “내 앞에 몇 명 있는지”가 바로 나온다. 앞에서부터 admitLimit(예: 100)명만 admitted=true로 입장시키고, 나머지는 대기하며 자기 순번을 폴링한다.

순번을 INCR로 발급하는 게 중요하다. 도착 순번이 원자적이라, 동시에 1만 명이 들어와도 순번이 겹치지 않는다. 시각(timestamp)을 score로 쓰면 같은 밀리초에 들어온 요청들의 순서가 애매해지는데, INCR은 그 문제가 없다.

2. 폴링해도 뒤로 밀리지 않게: 재진입 멱등

대기 중인 사용자는 “내 순번 언제 와?”를 계속 폴링한다. 여기에 함정이 있다.

만약 폴링(또는 새로고침)할 때마다 enter가 다시 ZADD를 하면? 매번 새 도착 순번을 받아 줄 맨 뒤로 밀려난다. 기다릴수록 순번이 뒤로 가는 최악의 대기열이 된다.

그래서 enter멱등하게 만들었다.

// 이미 줄에 있으면 순번을 새로 뽑지 않고 기존 rank를 반환
if (redis.opsForZSet().score(key, userId) != null) {
return currentPosition(key, userId); // ZADD 안 함
}
// 처음 온 사람만 순번 발급 + ZADD

ZSCORE로 이미 줄에 있는지 먼저 확인하고, 있으면 순번을 그대로 둔다. 100번 폴링해도 내 순번은 안 변한다. “입장/상태 조회”가 몇 번 호출되든 도착 순서를 보존하는 것이 대기열의 신뢰성이다.

3. 앞사람이 빠지면 뒷사람이 당겨진다

입장한 사람이 결제를 끝내면 줄에서 나간다.

POST /queue/{event}/leave → ZREM queue:{event} {userId}

여기서 ZSET의 좋은 점이 또 나온다. 앞사람을 ZREM으로 빼면 뒷사람들의 rank가 자동으로 한 칸씩 당겨진다. rank는 “현재 ZSET에서의 순위”라 실시간으로 재계산되기 때문이다. 별도 로직 없이, 앞사람이 나가면 대기 1번이 입장 대상이 된다.

실기동으로 확인했다(admit-limit=1로).

user1 입장 → position 1, admitted:true (입장!)
user2 입장 → position 2, admitted:false (대기)
user1 이탈 → leave
user2 상태 → position 1, admitted:true (순번 당겨져 입장!)

user1이 나가자마자 user2가 자동으로 입장 대상이 됐다. 줄이 실제로 움직인다.

4. 결제 경로는 건드리지 않았다

의식적으로 지킨 원칙이 하나 있다. 대기열을 크리티컬한 결제 경로에 끼워 넣지 않았다. 대기열은 “입장/상태/이탈”만 하는 독립 프리미티브로 뒀고, 다른 모듈을 전혀 참조하지 않는다(모듈 의존 0).

“입장 확인 후 결제로 진행”은 클라이언트 흐름이다. 대기열에서 admitted를 받은 사용자가 결제 API를 호출하는 식이다. 이렇게 분리하면 대기열이 죽어도 결제 자체는 멀쩡하고(대기열 조회는 Redis 예외 시 fail-soft), 결제 경로에 Redis 의존이 안 생긴다. 트래픽 제어와 결제 처리를 섞지 않은 것이다.

denylist 토큰 저장소에 이어, 이 챕터에서 Redis 자료구조가 두 번째로 제 일을 맡은 사례이기도 하다.

마치며

“감당 못 할 트래픽을 어떻게 감당하나”라는 질문에 입장 순서로 답했다. 서버 증설은 답이 못 됐다. score를 도착 순번으로 쓰니 FIFO가 공짜로 따라오고, 순위는 ZRANK가, 순번 당김은 ZREM이 해결했다. 자료구조를 문제에 맞게 고르면 로직이 이만큼 간결해진다.

트래픽 방어는 일단 여기까지. 다음은 데이터 쪽에 미뤄둔 숙제다. 필드 암호화 코드에 적어둔 “운영에서는 envelope로”라는 주석이 아직 그대로였다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 입장·대기·이탈 순번 당김을 실 Redis로 검증했다.


키를 바꾸려고 수백만 건을 다시 암호화할 순 없다: Envelope 암호화와 키 로테이션

0. 단일 키의 함정: 키를 못 바꾼다

암호화 키를 두 겹(DEK/KEK)으로 나누는 envelope 방식으로 바꿨다. 키를 로테이션해도 데이터 재암호화가 필요 없어진다.

필드 암호화 편에서 계좌번호 같은 민감 필드를 AES-256-GCM으로 암호화했다. 프로퍼티로 주입한 하나의 키로. 그때도 코드 주석에 이렇게 적어뒀다.

운영에서는 envelope encryption으로 교체한다. 데이터는 DEK로, DEK는 KMS 마스터키(KEK)로 암호화해 함께 저장한다.

왜 단일 키로는 부족할까? 문제는 키 로테이션이다.

보안 표준은 암호화 키를 주기적으로 교체하라고 한다(유출 대비, 컴플라이언스). 그런데 단일 키로 모든 데이터를 암호화했다면, 키를 바꾸는 순간 그 키로 암호화한 모든 데이터를 복호화해서 새 키로 다시 암호화해야 한다. 계좌번호가 수백만 건이면? 전체를 읽고, 풀고, 다시 잠그는 대공사다. 그게 무서워서 키를 안 바꾸게 되고, 안 바꾸니 위험이 쌓인다.

단일 키는 “바꿀 수 없는 키”가 되기 쉽다. 그래서 실서비스는 envelope를 쓴다.

1. Envelope: 키를 두 겹으로

핵심 아이디어는 키를 두 겹으로 나누는 것이다.

  • DEK (Data Encryption Key): 실제 데이터를 암호화하는 키. 암호화할 때마다 새로 생성한다(무작위).
  • KEK (Key Encryption Key, 마스터키): DEK를 암호화(wrap)하는 키. KMS 안에 보관하고 밖으로 안 나온다.

암호화 흐름은 이렇다.

1. DEK = 무작위 32바이트 생성
2. 데이터를 DEK로 암호화 (AES-256-GCM)
3. DEK를 KEK로 감싼다(wrap) (AES-256-GCM)
4. [감싼 DEK] + [암호화된 데이터] 를 함께 저장

복호화는 반대다. 저장된 감싼 DEK를 KEK로 풀어(unwrap) DEK를 얻고, 그 DEK로 데이터를 푼다. 암호문 포맷은 이렇게 잡았다.

env:v1:{base64 감싼DEK}:{base64 암호화된데이터}
↑ 버전 — 어느 KEK로 감쌌는지

버전을 앞에 박아둔 게 중요하다. 복호화할 때 “이건 v1 KEK로 감쌌구나”를 알아야 하기 때문이다.

2. 그래서 키 로테이션이 값싸진다

이제 마법 같은 부분이다. KEK를 v1 → v2로 바꾸고 싶으면?

데이터는 한 바이트도 안 건드린다. DEK만 다시 감싸면 된다.

String rewrapToCurrent(String ciphertext) {
// 옛 KEK로 DEK만 풀어서
// current KEK로 다시 감싸고
// 암호화된 데이터(dataBlob)는 그대로 둔다
return "env:v2:" + newWrappedDek + ":" + 원래dataBlob; // 데이터 불변!
}

데이터 암호문(dataBlob)은 그대로다. 바뀌는 건 32바이트짜리 감싼 DEK뿐이다. 수백만 건이어도 각 행의 작은 DEK만 옛 KEK로 풀어 새 KEK로 다시 감싸면 끝. 큰 데이터를 다시 암호화하지 않는다. 이게 envelope의 진짜 이점이다. 키 로테이션이 감당 가능한 작업이 된다.

테스트로 확인했다.

v1으로 암호화 → env:v1:...
rewrapToCurrent(current=v2) → env:v2:... ← 프리픽스만 v1→v2
그래도 복호화하면 → 원래 평문 그대로 ← 데이터 부분(dataBlob)은 불변

프리픽스는 v2로 바뀌었지만 데이터 조각은 동일하고, 복호화하면 원래 값이 나온다. “키만 갈아끼웠다”가 증명된 셈이다.

3. KMS는 추상화로, 전환은 설정 한 줄로

마스터키(KEK)는 어디서 가져오나. 실서비스는 KMS(AWS KMS, HashiCorp Vault)에서 가져온다. 마스터키가 애플리케이션 메모리 밖, HSM 안에 사는 것이다. 로컬 데모는 프로퍼티에서 읽는다.

그래서 MasterKeyProvider라는 추상화를 뒀다.

interface MasterKeyProvider {
String currentVersion();
SecretKey keyFor(String version);
}

로컬은 PropertyMasterKeyProvider(프로퍼티에서 버전별 KEK)를 쓰고, 운영은 이 인터페이스의 KMS 구현으로 교체하면 된다. 나머지 코드는 그대로다.

기존 단일 키 방식과도 공존하게 했다. FieldCipher 인터페이스는 그대로 두고, envelope 구현을 @Primary로 두되 mode=envelope(기본) 설정으로 켠다. mode=simple로 두면 옛 단일 키 방식이 쓰인다. 설정 한 줄로 전환되고, 인터페이스·JPA 컨버터·도메인 코드는 한 줄도 안 바꿨다.

마치며

AES-GCM 자체는 이미 충분히 강했다. 부족했던 건 키를 바꿀 수 있느냐였다. 키를 두 겹으로 나누면 자주 바꿔야 하는 것(KEK)과 방대한 것(데이터)이 분리되고, 키 로테이션이 데이터 재암호화와 디커플링된다. 보안을 한 번 잠그고 끝내는 대신 계속 관리 가능하게 만든, 이 절의 마감이었다.

토큰·암호화까지 정리하고 나서, 트래픽 문제로 다시 돌아왔다. 대기열을 만들어뒀지만 그건 권고일 뿐이었기 때문이다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, envelope 암호화·rewrap 로테이션을 단위 테스트와 실기동으로 검증했다(ADR-006).


갑자기 10만 명이 결제하면? 폭주를 429로 쳐내고 성공 요청의 속도를 지키기

0. “갑자기 사람이 많이 결제하면?”

rate limiter, 대기열 입장권, 빠른 실패 설정. 유입 제어를 3층으로 쌓았고, 같은 스파이크에서 성공 요청 p95가 737.81ms에서 52.01ms로 내려갔다.

시작은 “갑자기 사람이 많이 결제하면?”이라는 질문으로 시스템을 점검한 것이었다. 층별로 세어보니 유입 제어가 절반뿐이었다.

  • JWT로 인증은 싸졌는데, 싸진 만큼 더 많이 받아버린다. 속도 제한이 없으니까.
  • 선착순 대기열을 만들었지만 권고일 뿐이라, 클라이언트가 무시하고 결제 API를 직접 치면 그대로 DB까지 간다.
  • Hikari connectionTimeout이 기본 30초라, 폭주하면 요청들이 커넥션을 30초씩 기다리며 톰캣 스레드를 다 물고 늘어진다. 지연으로 그치지 않고 전면 마비로 번지는 경로다.

원칙은 이거다.

감당 못 할 요청은 가장 바깥 층에서, 가장 싸게 거절한다. 안쪽(DB)으로 갈수록 요청 처리 비용이 커지니, 밖에서 429 한 방으로 쳐내는 게 모두를 살린다. 어차피 한정판 100개에 10만 명이 오면 99,900명은 언젠가 거절당한다. 문제는 그 거절이 “DB를 태운 뒤”냐 “문 앞에서”냐다.

3층을 쌓았다.

1. 층 ①: Redis 분산 rate limiter로 연타를 문 앞에서

가장 바깥 층. 주문·승인 API에 속도 제한을 걸었다. 사용자별 5회/초 + 전역 100회/초. Redis 고정 윈도우(INCR + EXPIRE) 한 방이라 O(1)이고, 다중 인스턴스에서도 카운트가 정확하다. 초과분은 429 + Retry-After: 1.

데모 콘솔에서 같은 사용자가 주문을 8연타하면 이렇게 된다.

8연타 → 5건 201 (통과) + 3건 429 RATE_LIMITED "요청이 너무 잦습니다"

정당한 사용자에게 5회/초는 충분하고, 봇/오작동 클라이언트의 폭탄은 Redis에서 끝난다. DB는 구경도 못 한다.

세부 판단이 둘 있었다. 필터를 @Component로 두지 않고 SecurityConfig에서 직접 만들어 Bearer 인증 뒤에 삽입했다. principal(userId)로 사용자별 키를 만들 수 있고, 빈 자동등록에 의한 이중 적용이 원천 차단된다. 또 하나, 고정 윈도우는 경계 순간 최대 2배가 통과하는 특성이 있는데, 단순성을 택하고 그 한계를 주석에 명시했다(뒤층이 흡수).

2. 층 ②: 대기열을 권고에서 강제로 만드는 입장권

앞 절의 대기열은 “결제 경로와 독립”으로 설계했는데, 그 대가는 착한 클라이언트만 줄을 선다는 것이었다. 이번에 옵트인 방식으로 보완했다.

  • 대기열이 admitted를 판정하는 순간, 입장권(admit:{eventId}:{userId}, TTL 10분)을 Redis에 발급한다.
  • 게이트 상품(프로퍼티로 지정, 예: 한정판)을 주문하면 서버가 이 입장권을 검증한다. 없으면 이렇게 된다.
한정판 주문 (입장권 없음) → 429 QUEUE_PASS_REQUIRED "선착순 대기열 입장 후 주문할 수 있습니다"
대기열 입장 → admitted:true (입장권 발급)
한정판 재주문 → 201 성공

이제 대기열을 무시하고 API를 직접 쳐도 서버가 막는다. 권고가 강제가 됐다. 중요한 건 옵트인이라는 점. 게이트 상품 목록이 비어 있으면(기본) 검증 자체를 안 타서 기존 동작이 100% 불변이다. 일반 상품 결제는 대기열과 여전히 무관하다.

데모 콘솔에서 두 층이 동작하는 모습이다.

폭주 제어 데모: 연타 3건이 429 RATE_LIMITED로 쳐내지고, 한정판은 입장권 없이 429 → 대기열 입장 → 201

3. 층 ③: 매달리지 말고 빠르게 실패

마지막 층은 설정이다. 카오스 테스트에선 짧은 타임아웃을 걸었으면서, 정작 운영 설정은 기본값이었다.

hikari:
connection-timeout: 3000 # 30초 대기 → 3초 빠른 실패
maximum-pool-size: 20
tomcat.threads.max: 100 # 스레드 무한 증식 대신 상한

폭주가 위 두 층을 뚫고 와도 커넥션을 3초만 기다리고 실패한다. 모두가 30초씩 매달려 다 같이 죽는 대신, 일부가 빠르게 실패하고 나머지는 산다.

4. 실측: 같은 스파이크, 전과 후

늘 하던 대로 수치로 확인했다. k6로 0→150 VU를 10초 만에 꽂는 스파이크를 제어 OFF/ON으로 두 번. 이 테스트에서 429는 의도된 거절(shed)로 따로 센다. 실패로 집계하지 않는다.

지표제어 없음제어 있음
유입304 req/s 전량 DB까지398 req/s 중 97.5%를 429로 거절
DB 도달15,445건 (100%)~510건 (2.5%)
성공 요청 p95737.81ms52.01ms (14배↓)
성공 요청 max1.48s105ms

제어가 없으면 폭주가 전량 DB로 흘러 정상 요청까지 같이 느려진다(p95 738ms, 평시 37ms의 20배). 제어를 켜면 감당 못 할 요청은 문 앞에서 429로 끝나고, 통과한 요청은 평시에 가까운 속도를 유지한다. “모두가 느려짐”에서 “일부는 기다리고, 나머지는 정상”으로 바뀌었다. load shedding이 노리는 게 이것이다.

5. 실측이 또 하나를 드러냈다

정직하게 적어둘 발견이 있다. 제어 ON에서만 5xx가 113건(0.55%) 나왔다. 추적해보니 이랬다.

고정 윈도우 rate limiter는 통과 요청을 윈도우 경계마다 버스트로 내보낸다(매초 초입에 5건이 동시에). 그 동시 커밋들이 아웃박스(event_publication) INSERT에서 MySQL 데드락으로 부딪혔다. 제어가 없을 땐 오히려 지연이 요청을 자연스럽게 직렬화해서 0건이었다.

임계치(<1%) 이내라 테스트는 통과했지만, 스파이크 실측이 아니면 영영 몰랐을 특성이다. 데드락 재시도 또는 sliding window를 후속 과제로 남겼다. 부하테스트가 BCrypt를 짚어줬듯, 이번에도 측정이 다음 개선 지점을 짚었다.

마치며

“갑자기 사람이 많이 결제하면?”의 답은 더 빠른 서버가 아니었다. 감당할 수 있는 만큼만 안으로 들이고, 나머지는 바깥에서 명확하게(429 + Retry-After) 거절하는 것. 그리고 layered defense다. rate limiter가 연타를, 대기열 게이트가 이벤트 폭주를, 빠른 실패 설정이 마지막 방어를 맡는다. 한 층이 뚫려도 다음 층이 받는다.

남은 건 실측이 드러낸 그 데드락 113건이다. 바로 다음 절에서 갚는다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 스파이크 전/후 실측 수치는 docs/performance §7에 있다.


DB가 “재시작하라”고 말할 때: 데드락은 재시도해도 되는 실패다

0. 앞 절의 숙제

데드락을 “재시도해도 되는 실패”로 분류해 IdempotencyService에 자동 재시도를 넣었고, 재실측에서 5xx가 0.00%가 됐다.

앞 절의 폭주 제어 스파이크 실측에서 이상한 걸 발견했었다. 제어를 켰을 때만 5xx가 113건(0.55%) 났다. 고정 윈도우 rate limiter가 통과 요청을 매초 초입에 버스트로 내보내니, 그 동시 커밋들이 아웃박스(event_publication) INSERT에서 부딪힌 것이다.

SQL Error: 1213, SQLState: 40001
Deadlock found when trying to get lock; try restarting transaction

에러 메시지를 다시 읽어보면 MySQL이 스스로 답을 말하고 있다. “try restarting transaction.” 데드락은 DB가 두 트랜잭션 중 하나를 골라 죽이고(전체 롤백), “다시 하면 될 거야”라고 알려주는 일시적(transient) 실패다. 그럼 재시도하면 되겠네? …그런데 이 시스템엔 걸리는 게 하나 있다.

1. “승인은 재시도 금지”와 충돌하지 않나

이 프로젝트의 철칙 중 하나가 승인은 재시도하지 않는다이다. 멱등키 없는 승인 재시도는 이중결제니까. 그런데 데드락 났다고 confirm을 재실행하면 그 철칙을 깨는 것 아닌가?

아니다. 핵심은 실패의 종류가 다르다는 것이다.

타임아웃결과를 모르는 실패다. PG가 승인을 했는지 안 했는지 알 수 없으니, 재시도하면 이중결제가 날 수 있다. 그래서 UNKNOWN으로 보존하고 조회로 확정한다.

데드락결과가 확정된 실패다. DB가 트랜잭션을 통째로 롤백했다고 보장한다. 우리 쪽 상태는 깨끗하게 원점이다. 그래서 재실행은 사실상 “처음부터 하는” 것이 된다.

걱정이 하나 남는다. 롤백돼도 그 트랜잭션 안에서 이미 나간 외부 호출(PG approve)은 어쩌나? 재실행하면 같은 paymentKey/orderNo/amount로 PG를 다시 부르는데, 실제 PG(토스 등)는 동일 파라미터 confirm을 멱등 처리한다(같은 결과 반환). 그래서 이 재시도는 외부에도 안전하다. 결제 시스템의 재시도 규칙은 실패를 무엇이 확정됐고 무엇이 미상인지로 나누는 데서 나온다. “재시도 가능/불가”라는 이분법만으로는 부족하다.

2. 어디에 넣을까: 멱등 계층 한 곳

재시도를 각 서비스마다 흩뿌리면 지저분하다. 좋은 지점이 이미 있었다. IdempotencyService다. 주문·승인·취소, 모든 변경 API가 이걸로 감싸져 있기 때문이다.

try {
T result = executeWithDeadlockRetry(action); // 데드락이면 최대 3회, 지터 백오프
record.complete(serialize(result));
...
}

action은 자기 트랜잭션을 가지니, 데드락 롤백 후 재실행이 깨끗하다. 덤으로 좋은 성질이 하나 있다. 재시도하는 동안 PROCESSING 멱등 레코드가 살아 있어서, 같은 키의 동시 중복 요청은 계속 409로 막힌다. 재시도가 중복 처리 창을 열지 않는 것이다. 발동 여부는 idempotency.deadlock.retry 카운터로 관측한다.

3. 재실측, 그리고 정직한 각주

같은 스파이크(150VU)를 다시 돌렸다.

server_errors: 0.00% (이전 0.55%)
성공 요청 p95: 66ms (유지)

5xx가 사라졌다. 다만 정직하게 적어둘 게 있다. 로그를 확인하니 이번 런은 데드락 자체가 0건이었다. 데드락은 커밋 타이밍이 겹쳐야 나는 확률적 현상이라 매번 발생하지 않는다. 그래서 정확한 문장은 “재시도는 단위 테스트 3종으로 검증된 안전망으로 들어갔고, 다음에 데드락이 나면 카운터에 찍히며 조용히 흡수된다”까지다. “재시도가 113건을 흡수했다”고 쓰면 과장이 된다. 측정 결과를 서사에 맞춰 부풀리지 않는 것, 영속 버그 때처럼 이번에도 지켰다.

마치며

작은 수정이지만 결제 시스템다운 마무리다. “재시도해도 되나?”라는 질문에 기계적으로 답하는 대신 먼저 실패를 분류했다. 결과 미상(타임아웃)은 절대 재시도 금지, 결과 확정(데드락 롤백)은 재시도가 정석. 같은 “실패”라도 성질이 다르면 대응이 정반대가 된다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 재시도 전후 스파이크 수치는 docs/performance §7에 있다.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.