프로젝트
약 28분 분량 학습 프로젝트/결제 시스템

동시성: 재고 20개에 30명이 동시에 오면

Payment동시성부하 테스트결제 시스템
목차

개요

재고가 20개인데 30명이 동시에 주문하면 재고가 음수가 됩니다. 같은 자원에 요청이 몰리면 누군가는 기다려야 하고, 진짜 문제는 어디서 기다리게 하느냐입니다.

이 글은 재고 차감, 폭주 유입, 데드락, 포인트 적립 경합 네 자리에서 그 답을 실측으로 고른 기록입니다. 네 번 다 재 보고 정했고, 한 번은 재는 방법이 틀려서 순위가 뒤집혔습니다.

상황다룬 것
상황 1재고를 동시에 깎는 세 방법을 실제 DB에서 재서 골랐다
상황 2넘치는 요청은 잠시 뒤 오게 하고 통과한 결제의 속도를 지켰다
상황 3데드락은 재시도해도 되는 실패다
상황 4유입 제어가 가려온 결함을 부하로 꺼냈다

개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.

상황 1. 재고를 동시에 깎는 세 방법을 실제 DB에서 재서 골랐다

비관적 락·낙관적 락·조건부 UPDATE를 셋 다 만들어 같은 부하로 쟀습니다. 초과판매는 셋 다 0건이었고 속도가 갈렸습니다. 그런데 인메모리에서 잰 순위가 실 MySQL에서 뒤집혔습니다.

  • 대안 비교 : 셋 다 “재고가 음수가 되지 않는다”는 안전성은 지킵니다. 갈리는 것은 경합을 어떻게 다루느냐입니다.
    • ① 비관적 락 행을 잠그고 차감합니다. 확실한 대신 잠긴 동안 나머지 요청이 전부 대기합니다
    • ② 낙관적 락 버전을 비교해 어긋나면 재시도합니다. 경합이 드물면 가장 싸고, 몰리면 재시도가 곧 비용입니다
    • ③ 조건부 UPDATE quantity >= 1을 UPDATE 조건에 실어 락 없이 원자적으로 깎습니다. 대기도 재시도도 없습니다
  • 실측 : 재고 20개에 스레드 30개를 같은 순간 출발시켰습니다. 초과판매는 셋 다 0건이고 속도가 갈렸습니다. 조건부 UPDATE 40ms, 비관적 락 75ms, 낙관적 락 151ms
  • 함정 : 처음 쟀던 H2 인메모리에는 네트워크 왕복도 디스크도 없어 재시도가 공짜입니다. 낙관적 락에 유리하게 기울어진 조건이라 실 MySQL에서 순위가 뒤집혔습니다

→ 조건부 UPDATE를 골랐습니다.

0. “동시에 1000명이 결제하면?”

이 질문에 답하려면 재고 차감부터 봐야 합니다. 앞에서 코어를 다 만들었으니, 이제 이걸 빠르고 안전하게 만들 차례입니다.

재고 차감은 동시 요청이 몰리는 지점입니다. 선착순이나 인기 상품. 무서운 건 초과 판매(oversell)입니다. 재고 1개인데 두 명이 동시에 결제에 성공하면, 있지도 않은 물건을 판 것입니다. 막는 방법이 여럿인데, 나는 셋을 다 만들어서 수치로 골랐습니다.

1. 후보 3종

// ① 비관적 락 — 행을 잠그고 차감
SELECT quantity FROM stock WHERE id = 1 FOR UPDATE;
UPDATE stock SET quantity = quantity - 1 WHERE id = 1;
// ② 낙관적 락 — version 비교, 충돌 시 재시도
UPDATE stock SET quantity = quantity - 1, version = version + 1
WHERE id = 1 AND version = :read_version; // 0 rows면 재시도
// ③ 조건부 UPDATE — 락 없이 원자적
UPDATE stock SET quantity = quantity - 1 WHERE id = 1 AND quantity >= 1;

세 방식 모두 “재고가 음수가 되지 않는다”는 안전성은 지킵니다. 차이는 성능과 경합 처리 방식. 그래서 말로 고르지 않고 쟀습니다.

2. 실제 스레드로 두들긴 실측

StockLockComparisonTest를 만들었습니다. 재고 20개에 스레드 30개가 동시에 1개씩 차감을 시도합니다. 모든 스레드가 같은 순간 출발하도록 CountDownLatch로 정렬했습니다.

CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < THREADS; i++) pool.submit(() -> {
ready.countDown();
start.await(); // 다 같이 출발
if (deduct.deductOne()) success.incrementAndGet();
else failed.incrementAndGet();
});
start.countDown(); // 땅!

단언은 셋. 모든 전략이 공통으로 지켜야 할 안전 불변식입니다.

  • 초과판매 없음 (성공 ≤ 재고)
  • 재고 음수 없음
  • 일관성: 최종재고 = 재고 − 성공건수

결과.

처음엔 H2 인메모리에서 쟀습니다. CI에서 매번 같은 결과가 나와야 해서입니다. 그런데 인메모리에는 네트워크 왕복도 디스크도 없습니다. 재시도가 공짜라는 뜻이고, 그건 낙관적 락에 유리하게 기울어진 조건입니다. 그래서 Testcontainers로 실제 MySQL 8.4를 띄워 다시 쟀습니다.

전략초과판매완판30스레드150스레드
조건부 UPDATE020/2040ms79ms
비관적 락020/2075ms
낙관적 락020/20151ms429ms

실 MySQL 8.4(InnoDB), 재고 20, Hikari 풀은 스레드 수보다 크게 잡아 풀 대기 0, 워밍업 후 5회 중앙값.

셋 다 초과판매 0, 정확히 20개 완판. 안전성은 모두 통과입니다. 그런데 속도가 갈렸고, H2에서 본 순서와 달랐습니다.

3. 왜 이 순서인가

  • 조건부 UPDATE가 최속. 명시적인 SELECT ... FOR UPDATE도, 앱에서 도는 재시도도 없이 DB 한 번의 UPDATE로 끝납니다. WHERE quantity >= 1이 검사와 차감을 한 문장에 묶습니다.
    “락이 없다”가 아닙니다, InnoDB는 UPDATE 대상 행에 배타적(X) 락을 잡습니다. 셋 다 결국 같은 hot row에서 직렬화됩니다. 다른 건 왕복 횟수와 재시도 비용입니다.
  • 낙관적 락이 최저속. 충돌할 때마다 조회와 갱신을 처음부터 다시 왕복합니다. H2에서는 그 왕복이 거의 공짜라 낙관적(17ms)이 비관적(32ms)보다 빨랐는데, 실 DB에서는 왕복이 진짜 비용이라 순위가 뒤집혔습니다.
  • 비관적 락은 중간. 한 번 줄을 서면 왕복이 한 번뿐이라, 충돌이 잦은 구간에서는 재시도보다 쌉니다. 대신 커넥션을 트랜잭션 내내 점유합니다.

여기서 얻은 게 숫자보다 큽니다. 측정 환경이 결론을 바꿉니다. 인메모리 DB는 빠르고 결정적이라 CI 검사에는 좋지만, 왕복 비용이 승부를 가르는 비교에서는 답 자체를 바꿔 놓습니다. 같은 테스트를 실 엔진에 붙여 보기 전까지는 몰랐습니다.

고경합에서는 격차가 더 벌어집니다. 스레드를 150개로 올리면 조건부 79ms 대 낙관적 429ms, 5.4배입니다. 재시도 상한을 50회로 두면 미달판매(재시도가 소진돼 남은 재고를 못 파는 것)까지 가지는 않지만, 그 상한을 지키느라 지연이 그만큼 늘어납니다. 인기 상품 재고처럼 한 행에 쓰기가 몰리는 hot row에 낙관적 락이 부적합하다는 건 그대로입니다. 참고로 데드락과 락 대기 타임아웃은 0건이었습니다. 단일 행 경합이라 대기 그래프가 선형이기 때문입니다.

4. 그래서 조건부 UPDATE를 배선했다

실험 결론을 코드에 반영했습니다. CheckoutService가 승인 성공 시 조건부 UPDATE 전략을 씁니다.

if (result.isApproved()) {
for (OrderItem item : order.getItems()) {
stockDeductionService.deductConditional(item.getProductId(), item.getQuantity());
}
order.markPaid();
}

이 선택을 설계 결정 기록으로 남겼습니다. 나중에 “왜 이렇게 했더라”를 다시 묻지 않으려고, 고른 것과 함께 버린 것과 그 이유를 적는 문서입니다. 여기에는 셋을 다 만들어 실 DB에서 비교했다는 것, 단일 행 차감에는 조건부 UPDATE가 가장 빨랐다는 것, 그래서 그걸 골랐다는 것을 수치와 함께 적었습니다.

처음엔 “멀티 인스턴스면 Redis 분산락이 필요하다”고 적었는데 틀렸습니다. 모든 인스턴스가 같은 MySQL을 보면 이 UPDATE는 멀티 인스턴스에서도 안전합니다. 동시성 제어를 공유 DB가 하기 때문입니다. Redis 락을 얹는다고 DB 부하가 분산되지도 않고, 장애 지점과 정합성 문제만 하나 늘어납니다.

규모가 커지면 검토할 것은 분산락이 아니라 상품별 대기열, 재고 예약 모델, hot 상품 분리, 파티셔닝 쪽입니다.

5. 엔드투엔드 부하테스트

락 비교가 “한 지점”의 미시 성능이라면, 전체 흐름은 k6로 잽니다. k6/checkout-load.js가 주문 생성 → 결제 승인을 실제 사용자 시나리오로 두들깁니다. (FakePgClient가 승인을 성공 처리하니 실제 PG 키 없이 부하를 줄 수 있습니다.)

export const options = {
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1500', 'p(99)<3000'],
},
};

thresholds를 걸어서 p95가 1,500ms를 넘거나 오류율이 1%를 넘으면 k6가 실패로 종료합니다. PR마다 소규모 부하로 성능 회귀를 커밋 단위로 잡습니다. 개선 스토리는 카카오페이 프레임(목표→한계→병목→개선→전후 수치)으로 총정리 편에 정리했습니다.


상황 2. 넘치는 요청은 잠시 뒤 오게 하고, 통과한 결제의 속도를 지켰다

rate limiter, 대기열 입장권, 빠른 실패 설정으로 유입 제어를 3층 쌓았습니다. 같은 스파이크에서 97.5%를 문 앞에서 429로 거절하고 통과한 요청의 p95는 738ms에서 52ms로 지켰습니다. 다만 이 실험은 계정 하나로 돌린 반쪽이었다는 걸 나중에 알았습니다.

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

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

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

  • JWT로 인증은 싸졌는데, 싸진 만큼 더 많이 받아버립니다. 속도 제한이 없으니까.
  • 선착순 대기열을 만들었지만 권고일 뿐이라, 클라이언트가 무시하고 결제 API를 직접 치면 그대로 DB까지 갑니다.
  • 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. 이 실험은 반만 성립했다

먼저 적어둘 게 있습니다. 위 실험은 계정 하나로 돌렸습니다.

사용자당 한도가 5/s이고 경로가 둘이니 그 계정은 무슨 짓을 해도 합쳐서 10/s까지만 통과합니다.
전역 상한은 100/s입니다. 전역 층까지 요청이 도달할 수가 없습니다. 층을 둘 쌓아놓고
바깥 층은 한 번도 부하를 받은 적이 없는 채로 “유입 제어를 실측으로 검증했다”고 적은 것입니다.

나중에 40계정으로 다시 재니 결론이 갈렸습니다. 서버는 제어가 없어도 무너지지 않고 그냥 느려졌고,
포화점 아래에서는 제어가 오히려 순손실이었습니다. 유입 제어가 실제로 사는 값은
“붕괴 방지”가 아니라 “포화 이후 지연 억제 + 싼 거절” 이었습니다. 그 재측정은 8편에 있습니다.

위의 14배는 사용자별 층 하나만 켠 수치로 읽어야 맞습니다.

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

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

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

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


상황 2에서 이어서: 선착순 대기열로 줄을 세운다

돌려보내는 것만으로 안 되는 자리가 있습니다. 한정판 100개에 10만 명이 오면 어차피 99,900명은 거절당하는데, 문제는 그 거절이 “DB를 태운 뒤”냐 “문 앞에서”냐입니다. Redis Sorted Set으로 줄을 세웠습니다.

0. 문을 넓히면 무너진다

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

상황 2에서 부하를 다뤘지만, 무서운 건 선착순입니다. 한정판 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 자료구조가 또 한 번 제 일을 맡은 사례이기도 합니다.


상황 3. 데드락은 재시도해도 되는 실패다

상황 2에서 제어를 켜자 없던 5xx가 113건(0.55%) 생겼습니다. 아웃박스 INSERT에서 부딪힌 MySQL 데드락이었습니다. 데드락은 결과가 확정된 실패라 재시도해도 됩니다. 승인을 재시도하면 안 되는 것과 어디가 다른지를 가렸습니다.

0. 남아 있던 숙제

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

유입 제어(rate limiter)를 붙이고 스파이크를 재던 중에 이상한 걸 발견했었습니다. 제어를 켰을 때만 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)은 어쩌나?

DB 롤백은 PG 호출을 되돌리지 않습니다. 승인은 PG에 그대로 남아 있습니다. 재실행이 안전한 건 그걸 되돌려서가 아니라, 승인 요청에 Idempotency-Key 헤더(주문번호)를 실어 보내서 같은 승인을 다시 받기 때문입니다.

처음엔 “같은 파라미터면 PG가 멱등 처리한다”고 적었는데, 토스페이먼츠가 문서로 보장하는 건 파라미터가 아니라 같은 멱등키면 같은 응답이라는 계약입니다. 코드는 그 헤더를 보내고 있었는데 글의 근거가 약했습니다. “롤백됐으니 깨끗하다”는 확신으로 넘어가면 위험한 자리입니다. 결제 시스템의 재시도 규칙은 실패를 무엇이 확정됐고 무엇이 미상인지로 나누는 데서 나옵니다. “재시도 가능/불가”라는 이분법만으로는 부족합니다.

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 기반 결제 시스템에 있고, 재시도 전후 스파이크 수치는 위에 그대로 적었습니다(5xx 0.55% → 0.00%).


상황 4. 유입 제어가 가려온 결함을 부하로 꺼냈다

앞의 셋은 “어디서 기다리게 할 것인가”였습니다. 넷째는 기다릴 이유가 없는데 기다리게 만들고 있던 자리입니다.
그리고 그걸 상황 2에서 만든 유입 제어가 가려주고 있었습니다.

결제가 아니라 적립이 무너지고 있었다

체크아웃 부하 스크립트를 처음 돌렸을 때 결제 승인이 대량으로 500을 반환했습니다. 로그의 범인은 결제가 아니었습니다.

ObjectOptimisticLockingFailureException:
Row was updated or deleted by another transaction ... [PointAccount#1]

적립 구조는 단순합니다. 결제 완료 시 실결제액의 1%를 적립하는데, 그게 결제 트랜잭션 안에서
계좌 로드 → balance += n → 저장으로 돌고 계좌에 @Version 낙관적 락이 걸려 있습니다.

부가 기능이 본 기능을 죽이고 있었습니다. 적립 충돌이 결제 승인 자체를 500으로 만듭니다.

왜 여태 안 보였나

적립 경합은 같은 사용자의 동시 결제에서만 납니다. 서로 다른 사용자는 서로 다른 행입니다.

그런데 상황 2에서 만든 사용자별 rate limit이 5/s입니다. 같은 사용자의 폭주를 유입 층에서 잘라내니
이 경합은 운영에서 거의 노출되지 않습니다. limiter가 동시성 결함의 완충 역할을 해온 것입니다.

노출 빈도가 낮다고 결함이 아니게 되지는 않습니다. limiter 설정을 바꾸거나, 배치가 일괄 적립하거나
(배치엔 rate limit이 없다), 적립 경로가 하나 더 생기면 그때 밟습니다.

그래서 limiter를 끄고 계정 하나로 30VU가 주문과 승인을 반복하는 최악 조건을 만들었습니다.

승인 성공률 39.6%. 7,625건 중 4,603건이 실패했고 전부 낙관적 락 충돌이었습니다.

네 가지를 놓고 골랐다

포인트는 돈이라 근사치도 유실도 허용되지 않습니다. 조회수 카운터였으면 답이 달랐을 문제입니다.

판단
낙관적 락 재시도기각. 경합 자체는 그대로라 부하가 오르면 재시도도 소진된다. 증상 완화지 치료가 아니다
DB 원자 증가채택. UPDATE ... SET balance = balance + n
적립을 비동기로 분리보류. 취소가 적립보다 먼저 오면 회수분이 0으로 계산돼 취소된 주문의 적립이 영구히 남는다
Redis 집계 후 주기 flush기각. flush 전 크래시가 곧 돈의 유실이다

채택 이유는 상황 1과 같습니다. 적립은 더하기고, 더하기는 순서가 무관합니다. 순서가 무관한 연산을
굳이 읽어서 순서를 만들 이유가 없습니다. 재고에서 조건부 UPDATE를 고른 것과 같은 처방이고,
차이는 재고엔 조건(quantity >= n)이 필요했고 적립엔 필요 없다는 것뿐입니다.

계좌가 아직 없는 첫 적립의 생성 레이스는 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 흡수합니다.

함정: 네이티브 증가가 낙관적 락을 우회한다

여기에 조용한 함정이 있었습니다.

네이티브 UPDATE@Version을 우회합니다. version을 안 올리면, 동시에 엔티티로 로드된
다른 경로(포인트 사용·환불)가 낡은 balance로 덮어써도 버전 검사를 통과합니다.
적립분이 소리 없이 사라집니다(lost update).

그래서 version = version + 1을 함께 올려 그 경로들이 기존처럼 충돌을 감지하게 유지했습니다.
경합을 없애려고 넣은 최적화가 다른 경로의 안전장치를 꺼버릴 뻔했습니다.

재실측

같은 조건으로 다시 돌렸습니다.

지표전 (낙관적 락)후 (원자 증가)
승인 성공률39.6% (3,022 / 7,625)100.00% (6,761 / 6,761)
실패 원인전부 @Version 충돌없음
승인 p95134ms

잔액이 원장과 맞는지도 대조했습니다.

balance = 978,400 / Σ(EARN) − Σ(EARN_REVERSAL) = 978,400 / EARN 9,784건
→ 불일치 0

뒷이야기 — 재시도가 관측을 지우고 있었습니다

상황 1에서 낙관적 락을 이렇게 설명했습니다.

버전을 비교해 어긋나면 재시도합니다. 경합이 드물면 가장 싸고, 몰리면 재시도가 곧 비용입니다.

“몰리면 재시도가 곧 비용”인데, 그 비용이 어디에도 안 보인다는 걸 한참 뒤에 알았습니다.

} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (++attempts >= MAX_RETRY) { // 상한을 넘길 때만 예외
throw new OrderException("STOCK_CONCURRENCY", ...);
}
} // ← 성공하면 아무것도 안 남는다

경합이 있어도 끝내 성공하면 로그도 지표도 없습니다. 재고 차감과 월렛 잔액 변경 두 곳이 같은 모양이었습니다.

왜 이게 위험한가

최종 결과만 세면 네 번 만에 성공한 요청이 한 번에 성공한 요청과 구별되지 않습니다. 늘어난 백오프 지연은 응답 시간 안에 묻히고, 에러율은 그대로입니다.

업계에서 보고된 사고가 정확히 이 모양입니다.

두 서비스 사이의 재시도 루프가 DB 호출을 두 배로 늘렸는데 경보가 하나도 안 울렸다. 체크아웃 지연이 40% 늘었는데 에러 로그는 없었다.

잘 만든 재시도가 관측을 지운 겁니다. 그리고 이건 의도한 동작이라 재시도 자체를 없앨 것도 아닙니다.

처방은 정해져 있었습니다

논리적 호출과 실제 시도를 나눠 셉니다.

} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
// 흡수하기 <전에> 센다. 성공으로 끝나도 경합이 있었다는 사실은 남아야 한다.
meterRegistry.counter("stock.deduct.retry", "strategy", "optimistic").increment();
...

소진(exhausted)도 따로 셉니다. 거기서부터는 사용자가 실패를 봅니다. 알림 규칙 둘을 더했습니다.

규칙무엇을 말하나
OptimisticLockRetryRising경합이 실제로 있다. 성공률·에러율에는 안 나타나는 것
OptimisticLockExhausted재시도를 다 썼다. 여기서부터 사용자가 실패를 본다

어쩌다 찾았냐면

장애 로그에 AI를 붙여 보려다 찾았습니다.

공개 포스트모템 분류에서 장애 유형을 가져와 하나씩 실제로 주입하고 로그를 모으는 중이었는데, 낙관적 락 충돌은 아무리 넣어도 로그가 안 나왔습니다. 왜 안 나오는지 파고들다 이 자리를 봤습니다.

모델은 결국 안 켰습니다. 그런데 모델을 붙이려고 로그를 모으다 로그가 없는 자리를 찾았고, 그 자리는 AI와 아무 상관이 없었습니다.

상황 5. 배치가 밀린 물량을 한 번에 다 읽고 있었습니다

앞의 넷은 동시에 들어오는 요청이 문제였습니다. 이건 쌓인 일감이 문제였고,
찾은 경위가 달랐습니다.

카카오페이 데이터 조직의 기술 블로그를 훑다가 같은 주제가 반복해 나오는 것을 봤습니다.

  • 원장 통계 배치가 수억 건을 한 번에 읽다 OOM. 파티셔닝으로 97분에서 8분 55초
  • 정산팀 조회가 하루 25만 번에서 1억 번으로 늘면서 Limit-Offset 이 5천만 번째부터
    수십 초. 자체 커서 리더를 만들어 해결
  • 실시간 OLAP 의 기본키가 1억 2천만 건을 넘으면 OOM

공통점이 “한 번에 메모리에 올리지 않는다”였습니다. 그래서 제 배치를 세어 봤습니다.

상한 없는 조회가 15개였습니다

조건에 맞는 것을 전부 List 로 반환하는 조회가 15개, 페이징이 걸린 것이 5개였습니다.
정산 항목, 에스크로 자동해제, 주문 만료, 미확정 결제 복구, 웹훅 보류 재시도, 정기결제
청구까지 배치가 스캔하는 자리 전부였습니다.

주장하지 않고 쟀습니다

실 MySQL 에 밀린 상황을 만들었습니다.

고치기 전 쌓인 대상 500건 → 한 번의 조회가 읽은 행 500건
고친 뒤 쌓인 대상 500건 → 한 번의 조회가 읽은 행 100건 (상한 100)

평소에는 안 터집니다. 하루치는 작으니까요. 배치가 며칠 밀리거나 물량이 튀었을 때
커지고, 그때 죽는 것은 돈을 다루는 배치입니다.

비울 때까지 돌지 않습니다

“상한만큼 읽고 없어질 때까지 반복”으로 만들 수도 있었습니다. 안 했습니다.

실패한 건이 대상에 남기 때문입니다. 이 배치들은 한 건이 실패해도 멈추지 않고 다음
주기에 다시 시도합니다. 그런데 비울 때까지 도는 구조로 만들면, 영영 성공하지 못하는
건 하나가 배치를 붙잡습니다.

틱당 일이 일정한 편이 부하도 예측됩니다. 밀린 물량은 여러 주기에 걸쳐 빠집니다.

설정이 0이어도 안 죽습니다

PageRequest 는 크기가 0이면 예외를 던집니다. 잘못된 설정 하나로 돈을 다루는 배치가
멈추는 것보다 기본값으로 도는 편이 낫습니다.

필드 초기값도 같이 뒀습니다. @Value 는 스프링이 만들어 줄 때만 채워지는데 단위 테스트는
서비스를 직접 생성합니다. 초기값이 없어 0이 되면서 배치 테스트가 무더기로 깨졌습니다.

커서는 안 썼고, 왜 안 썼는지 적었습니다

상한만으로 메모리 문제가 사라지는데 커서를 들이면 트랜잭션 수명과 커넥션 점유를 따로
관리해야 합니다.

지금은 처리된 건이 대상에서 빠지므로 항상 첫 페이지만 읽습니다. 뒤로 갈수록
느려지는 문제가 안 생깁니다. 대상이 남는 조회로 바뀌면 그때 커서가 필요합니다.

필요가 증명되기 전에 무거운 장치를 들이지 않습니다. 그 판단을 적어 둬야 나중에
필요해졌을 때 무엇이 바뀌었는지 알 수 있습니다.

상황 6. 건수는 50건으로 묶여 있었는데 30만 행을 훑고 있었습니다

배치가 한 번에 다 읽던 것을 상한으로 묶고 나서, 서빙 쪽은 안 봤다는 걸 알았습니다.

내 주문 목록 조회를 열어 보니 findTop50ByUserIdOrderByIdDesc 였습니다. 50건으로 이미
묶여 있으니 됐다고 넘길 뻔했습니다. 건수가 묶인 것과 그 건수를 싸게 찾는 것은 다른 일입니다.

orders 에 걸린 인덱스는 (status, created_at) 과 기본키뿐이었습니다. 그런데 이 조회가
거는 조건은 user_id 입니다.

흔한 경우가 곧 최악의 경우였습니다

인덱스가 없으면 ORDER BY id DESC LIMIT 50 은 기본키를 역방향으로 훑으며 걸러냅니다.
그 사용자의 주문이 50건 이상이면 위쪽에서 금방 채우고 멈춥니다. 여기까지는 괜찮습니다.

문제는 반대쪽입니다. 주문이 50건 미만인 사용자는 그 50건을 영영 못 채웁니다. 채울 때까지
멈추지 않으니 테이블 끝까지 갑니다. 그리고 실제 서비스에서 주문 50건 미만은 예외가 아니라
대다수 사용자입니다.

30만 행(사용자 1만 명 × 30건)을 넣고 실제 MySQL에서 쟀습니다.

EXPLAIN 추정EXPLAIN ANALYZE 실제중앙값
인덱스 없음key=PRIMARY rows=50rows=30000065.6ms
(user_id, id)type=ref rows=30rows=300.6ms

EXPLAIN만 봤으면 못 잡았습니다

이 표에서 저에게 제일 중요했던 건 오른쪽 숫자가 아니라 왼쪽 칸입니다.

옵티마이저는 rows=50 이라고 추정했습니다. 계획만 보면 나무랄 데가 없습니다. 기본키를 쓰고,
정렬도 없고, 읽는 행도 50건입니다. 실제로 훑은 것은 30만 행이었고 추정이 6,000배 틀렸습니다.

옵티마이저는 “user_id로 걸러도 50건은 금방 나오겠지”라고 봤는데, 그 사용자에게 30건밖에
없다는 것은 테이블 통계로 알 수 없습니다. 그래서 이 종류의 문제는 EXPLAIN 이 아니라
EXPLAIN ANALYZE 로만 보입니다. 느린 쿼리 로그에도 잘 안 걸립니다. 65ms는 보통 임계값
아래니까요. 사용자 수만 늘면 그대로 커집니다.

status 인덱스는 통념이 반만 맞았습니다

status 는 값이 몇 개뿐이라 “카디널리티가 낮아 인덱스를 걸어도 소용없다”는 말의 단골 대상입니다.
찾는 값을 갈라서 재 봤습니다. 정산 항목 30만 행 중 PENDING 이 300행(0.1%)인 상태입니다.

무엇을 찾나인덱스 없음(status, id)
드문 값 PENDING 0.1%15.2ms0.7ms20배 빨라짐
흔한 값 DONE 99.9%0.6ms1.0ms1.7배 느려짐

같은 인덱스가 한 조회는 살리고 다른 조회는 죽입니다. 흔한 값을 찾을 때는 기본키 역방향
스캔이 바로 100건을 채우는데, 인덱스를 타면 세컨더리 인덱스에서 기본키로 한 번 더 건너가는
값만 냅니다.

그래서 판단 기준은 컬럼의 성질이 아니라 그 조회가 무엇을 찾는지입니다. 제 배치들이 거는
조회는 전부 할 일을 찾는 조회였습니다. PENDING, UNKNOWN, HELD, OPEN. 성숙한
테이블에서는 드문 값 쪽입니다.

예외가 하나 있었습니다. 구독 청구 배치는 ACTIVE 를 찾는데 이건 흔한 값입니다. 그래도 값을
하는 이유는 선택도가 상태가 아니라 날짜에서 나오기 때문입니다(next_billing_date <= 오늘).
그래서 날짜를 반드시 둘째 자리에 뒀습니다. status 만 걸었다면 아무 값도 못 했을 겁니다.

소스만 봐서는 양쪽으로 틀렸습니다

한 곳을 고치고 끝내면 나머지는 그대로입니다. 전수로 세려고 리포지터리의 조회 메서드에서
조건 컬럼을 뽑아 인덱스와 대조하는 스크립트를 썼는데, 결과가 두 방향으로 틀렸습니다.

JPA가 유니크 제약으로 만들어 준 인덱스를 못 봐서 있는데 없다고 했고, 조건이 없는
findAllByOrderBy... 를 조건으로 세서 없는데 있다고 했습니다.

그래서 마이그레이션을 실제로 다 돌린 뒤 information_schema 에 물었습니다. 추정을 버리고
만들어진 스키마에 직접 묻는 쪽으로 바꾼 겁니다. 조회 조건 22개가 인덱스 선두에 없었습니다.
18개에 인덱스를 걸고 4개는 일부러 안 걸었습니다. 감사 로그처럼 쓰기가 많고 조회는 사후
조사뿐인 것, 평가용이라 행이 수백 단위인 것들입니다.

버는 쪽만 적으면 인덱스가 공짜라는 주장이 됩니다

조회가 107배 빨라졌다는 수치만 적으면 인덱스에 값이 없다고 말하는 셈입니다. 인덱스는 조회에서
버는 것을 쓰기에서 갚는 구조물이고, 결제는 쓰기가 많은 시스템입니다.

처음에는 한 번씩만 재고 “쓰기가 3.8% 느려진다”고 적었습니다. 다시 돌려 보니 -2.3%
나왔습니다. 인덱스 있는 쪽이 오히려 빨랐다는 뜻입니다. 3%대 차이를 1회 측정으로 주장한 것이
잘못이었습니다. 이 저장소의 락 비교는 5회 중앙값을 쓰는데 여기만 1회였습니다.

교대로 5회 재고 회차 간 폭을 함께 적는 것으로 고쳤습니다. 몰아서 재면 디스크 상태의 표류가
한쪽에만 실립니다.

중앙값처리량회차 간 폭
인덱스 1개37.08s2,697행/s2.0%
인덱스 3개38.14s2,622행/s2.3%

차이 +2.9%, 노이즈 폭 2.3%. 노이즈보다 크지만 아슬아슬합니다. 다만 5회 전부 인덱스 쪽이
느렸으므로 방향은 믿고, 크기는 “3% 안쪽” 정도로만 말할 수 있습니다.

쓰기 3% 안쪽을 내고 조회 107배를 받습니다. 이 교환은 받습니다.

고친 18개보다 오래 가는 것

같은 실수가 다시 들어오는 걸 막는 쪽이 이번에 고친 18개보다 오래 갑니다. 마이그레이션을 다
돌린 뒤 실제 스키마에 물어보는 테스트를 두어, 조회 조건 컬럼이 인덱스 선두에 없으면 CI에서
깨지게 했습니다.

안 걸기로 한 4개는 목록에서 빼되 근거를 마이그레이션과 테스트 주석 양쪽에 적었습니다.
빠진 것과 일부러 뺀 것을 구별할 수 있어야 나중에 보는 사람이 판단할 수 있습니다.

상황 7. 한 건이 싸다고 동시에도 싼 건 아닙니다

인덱스로 조회 한 건을 65.6ms에서 0.6ms로 줄였습니다. 그런데 그건 커넥션 하나로 잰 값입니다.
동시에 들어올 때도 그런지는 다른 질문입니다.

그래서 k6 스크립트를 세어 봤더니 아홉 개가 전부 쓰기 경로였습니다. 체크아웃, 스파이크, 정산
경합, 재배포까지 다 재면서 조회는 한 번도 안 쟀습니다.

도착률을 고정합니다

이 프로젝트는 한 번 닫힌 루프로 재서 용량을 과소평가한 적이 있습니다. VU를 늘리는 방식은 서버가
느려지면 VU가 다음 요청을 늦게 보내서 도착률이 같이 줄어듭니다. 병목이 가려집니다.

조회는 응답이 빨라 그 함정에 더 잘 빠집니다. 그래서 VU가 아니라 도착률을 고정했습니다.

주문 30만 건(사용자 1만 명 × 30건)을 넣고 계정 40개로 돌렸습니다. 사용자를 라운드로빈으로
흩뿌리는 것
이 중요합니다. 한 사람의 주문을 연속으로 넣으면 id가 뭉쳐서 인덱스 없이도 금방
찾아집니다. 실제 주문 순서와 다르고, 측정이 좋게 나오도록 데이터를 고르는 셈이 됩니다.

세 번 잘못 쟀습니다

하나. 판정 기준이 조회에 안 맞았습니다. 쓰기 경로에서 쓰던 “성공률이 온전한 마지막 단계”를
그대로 가져왔더니 2,400/s까지 성공률 100%가 나와서 무릎을 못 찾았습니다.

조회는 아무것도 실패하지 않고 지연만 오릅니다. 요청이 그냥 줄을 섭니다. 쓰기는 포화가 연결
거부나 5xx로 나타났는데 조회는 그렇지 않습니다. 판정을 지연 기반으로 바꿨습니다. 평탄 구간
p95의 세 배를 넘는 첫 단계 직전이 무릎입니다.

둘. 부하기가 굶은 것을 서버 한계로 읽을 뻔했습니다. 3,200/s부터 k6가 최대 VU에 걸려
Insufficient VUs 경고를 냈습니다. 목표 도착률을 못 낸 겁니다. 그때 오른 지연(946ms, 987ms,
1034ms)은 서버가 아니라 부하기가 굶은 값입니다.

이걸 그대로 적었으면 부하기의 한계를 서버의 한계로 기록할 뻔했습니다. 단계마다 실제로 보낸
요청 수를 세어, 목표의 95%에 못 미치면 그 줄을 판정에서 빼도록 고쳤습니다.

셋. 단계가 짧아 노이즈에 속을 뻔했습니다. 20초 단계로 재니 이렇게 나왔습니다.

1200/s 31ms 1400/s 58ms 1600/s 20ms 1800/s 134ms 2000/s 36ms

1,400/s에서 58ms인데 1,600/s에서 20ms입니다. 낮은 부하가 더 느리다는 것은 측정이 틀렸다는
뜻입니다.
4~6배씩 튀는데 여기서 무릎을 정하면 서버의 한계가 아니라 첫 번째 노이즈 튐
무릎이라 부르게 됩니다. 실제로 그렇게 900/s가 나왔습니다.

60초로 늘리니 단조로워졌습니다. 부하기와 서버가 한 기계에 있어서 GC와 JIT가 단계 경계에 걸리는
탓입니다.

결과

목표실제성공률p95
600/s600/s100.0%5ms
1000/s1000/s100.0%6ms
1400/s1400/s100.0%7ms
1800/s1800/s100.0%10ms← 무릎
2200/s2197/s100.0%27ms

조회 경로 약 1,800 req/s, 평탄 p95 5ms. 쓰기 경로의 무릎이 약 120 req/s였으니 15배입니다.

캐시는 안 넣기로 했습니다

전에는 “근거가 없어서” 안 넣었습니다. 그런데 근거가 없던 건 재 본 적이 없어서였습니다.

판정 기준을 결과보다 먼저 적어 뒀습니다. 결과를 보고 기준을 만들면 원하는 답이 나옵니다.

평탄 구간의 p95가 이미 크면 조회 한 건이 비싼 것이라 캐시가 값을 합니다.
평탄 p95가 작고 무릎이 높으면 DB는 병목이 아닙니다.

평탄 p95가 5ms고 무릎이 1,800/s입니다. 조회 한 건은 이미 쌉니다. 1,800/s 위에서 오르는 지연은
쿼리가 비싸서가 아니라 줄을 서기 때문입니다. 캐시는 한 건의 비용을 줄이는 장치라 이 병목에
안 닿습니다.

게다가 이 시스템이 읽는 데이터를 만드는 쓰기 경로가 120/s에서 꺾입니다. 읽을 것을 만드는
쪽이 15배 좁습니다.

그래서 안 넣습니다. 넣으면 얻는 것 없이 무효화라는 더 어려운 문제만 늡니다. 조회 패턴이
바뀌거나 평탄 p95가 커지면 그때 다시 잽니다.

못 잰 것도 적습니다. 부하를 건 것은 목록 조회 한 종류입니다. 타임라인 조립은 기여자가 늘수록
쿼리가 느는 구조라 따로 재야 하는데 안 했습니다. 부하기와 서버가 같은 노트북이라 2,200/s 위는
이 구성으로 못 잽니다.


일곱 상황을 관통하는 것

넷 다 “어디서 기다리게 할 것인가” 의 문제였습니다.

  • 상황 1. 앱에서 읽고 비교하고 쓰는 왕복을 없애고, DB의 행 락 한 곳에서만 기다리게 했습니다
  • 상황 2. 문 앞에서 돌려보내, DB까지 오는 손 자체를 줄였습니다
  • 상황 3. 그래도 부딪히면, 기다리지 말고 다시 오게 했습니다
  • 상황 4. 적립은 순서가 무관하니, 애초에 기다릴 이유가 없었습니다

그리고 넷 다 재 보고 정했습니다. 상황 1은 인메모리에서 잰 순위가 실 DB에서 뒤집혔고,
상황 2는 제어를 켜자 없던 데드락이 새로 생겨 상황 3이 나왔고,
상황 4는 그 상황 2의 제어가 가려주던 결함이라 제어를 꺼야 보였습니다.

측정 환경을 의심하는 습관이 여기서 생겼습니다. 뒤에서 부하 측정 자체가 틀렸던 걸
발견하는 것도(측정 방식을 의심한 8편), 채점 기준이 나를 속이는 걸 잡는 것도(잣대의 함정을 다룬 12편) 같은 자리에서 나왔습니다.

프로필 사진
작성자 @범수

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

댓글

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