BE-commerce 프로젝트
약 20분 분량 동시성·분산성능·용량

재고 동시성은 조건부 UPDATE를 고른 뒤에도 끝나지 않았다

목차

재고 20개에 스레드 30개를 동시에 앞세워 비관적 락, 낙관적 락, 조건부 UPDATE 세 가지 차감 전략을 실 MySQL에서 쟀습니다. 인메모리 H2에서는 낙관적 락이 비관적 락보다 빨랐지만 실 MySQL 8.4에서는 조건부 UPDATE 40ms, 비관적 락 75ms, 낙관적 락 151ms로 순서가 뒤집혔습니다. 조건부 UPDATE를 기본 전략으로 골랐지만 그 결정이 끝이 아니었습니다. 재고를 잡는 시점이 한 번 더 바뀌었습니다. 그리고 조건부 UPDATE가 지키는 한 행이 초당 몇 건까지 버티는지, 그 검증을 쓰기 시점에서 미루면 얼마나 버는지까지 다시 재야 했습니다.

결론부터

  • 조건부 UPDATE는 초과 판매를 막는 데이터베이스 경계로 남겼습니다.
  • 재고를 너무 일찍 차감하면 결제 실패 때 회수해야 할 예약이 늘고, 너무 늦게 차감하면 경쟁 요청이 결제 단계까지 몰립니다.
  • 느슨한 카운터는 표시용으로만 사용하고, 판매 가능 여부는 원자적인 재고 차감 결과로 판단합니다.

세 방법을 실 DB에서 쟀다

재고가 20개인데 30명이 동시에 주문하면 재고가 음수가 됩니다. 후보는 셋이었습니다. 비관적 락은 SELECT ... FOR UPDATE로 행을 잠그고 차감합니다. 확실한 대신 잠긴 동안 나머지 요청이 대기합니다. 낙관적 락은 @Version을 비교해 어긋나면 재시도합니다. 경합이 드물면 가장 싸지만 몰리면 재시도가 곧 비용입니다. 조건부 UPDATE는 quantity >= n을 UPDATE 조건에 실어 락 없이 원자적으로 깎습니다.

셋 다 만들어 같은 부하로 쟀습니다. 초과판매는 셋 다 0건이었고 속도가 갈렸습니다. 처음엔 H2 인메모리로 쟀는데 인메모리에는 네트워크 왕복도 디스크도 없어 재시도가 사실상 공짜였습니다. 그 조건에서는 낙관적 락이 비관적 락보다 빨랐습니다. Testcontainers로 실제 MySQL 8.4를 띄워 다시 재자 순서가 뒤집혔습니다.

전략초과판매30스레드150스레드
조건부 UPDATE040ms79ms
비관적 락075ms재지 않음
낙관적 락0151ms429ms

실 MySQL 8.4(InnoDB), 재고 20, 워밍업 후 5회 중앙값. 데드락과 락 대기 타임아웃은 0건이었습니다. 단일 행 경합이라 대기 그래프가 선형이기 때문입니다.

낙관적 락은 충돌마다 조회와 갱신을 처음부터 다시 왕복합니다. H2에서는 그 왕복이 거의 공짜라 낙관적 락이 앞섰지만 실 DB에서는 왕복이 진짜 비용이라 순위가 뒤집혔습니다. 여기서 얻은 것은 숫자보다 측정 환경 자체를 의심하는 습관이었습니다. 이 셋을 어떤 환경에서 재야 결론이 성립하는지, 고경합에서 격차가 얼마나 벌어지는지, 안전 불변식과 성능 검증을 왜 두 테스트로 나눠 두는지는 재고 차감 락을 인메모리 DB로 고르면 틀린다에 그대로 남겼습니다. 결정은 ADR-004에 있습니다.

잡는 시점이 한 번 더 바뀌었다

재고 차감의 원칙은 그대로입니다. 승인 성공 시점에 조건부 UPDATE로 뺍니다. 다만 그 시점 앞에 재고를 미리 “잡아 두는” 예약 단계가 새로 생겼습니다.

원래 설계(ADR-003)는 주문을 만들 때 재고 여유만 확인하고 잡지는 않는 것이었습니다. 그런데 그 확인이 문서에만 있고 코드에는 없었습니다. 매진된 상품도 결제 승인까지 다 간 뒤에야 망취소됐습니다. 한정 상품 부하(재고 100, 주문 300, 이탈 20%, 결과를 모르는 결제 10%)로 전략 넷을 동시에 재자 그 대가가 숫자로 나왔습니다.

전략망취소팔지 못한 재고
확인 없이 승인 뒤 차감(고치기 전)1160
주문 생성 때 여유만 확인(지금 기본값)90
결제 시작 때 예약, 승인에 확정04
주문 생성 때 예약024

넷 다 초과판매는 0이었습니다. 기본값을 “확인만 하고 잡지 않는” 쪽으로 바꿔 망취소를 116건에서 9건으로 줄였습니다. 재고를 미리 잡으면 망취소는 0이 되지만 그 대가로 결과를 모르는 결제가 10분 넘게 재고를 물고 있는 만큼 팔지 못한 재고가 생깁니다. 이 대가를 감수할지는 상품이 정합니다. 매진 경합이 잦은 한정 상품에는 결제 시작 때 예약하는 쪽을 옵트인으로 열어 두고 일반 상품은 지금 기본값으로 충분합니다. 주문 생성 때부터 잡는 안은 버렸습니다. 이탈한 주문이 만료까지 재고를 물어 팔지 못한 재고가 가장 컸기 때문입니다.

잡는 방식이 무엇이든 재고를 실제로 빼는 연산은 여전히 조건부 UPDATE 한 문장입니다. 달라진 것은 그 UPDATE를 언제 부르느냐이지 무엇으로 부르느냐가 아닙니다.

상품을 여러 개 한 번에 사면 잠그는 순서가 문제였다

단일 상품 차감 실험에는 없던 문제가 하나 생겼습니다. 결제 확정이 한 주문의 여러 상품을 차례로 차감하는데 잠그는 순서가 주문 항목이 들어온 순서를 그대로 따르면 두 주문이 같은 두 상품을 반대 순서로 담았을 때 서로가 상대의 행을 기다려 교착합니다. 단일 상품 주문에서는 드러나지 않아 처음 실측에서도 잡히지 않았습니다.

List<OrderItem> lockOrdered = order.getItems().stream()
.sorted(Comparator.comparing(OrderItem::getProductId))
.toList();
for (OrderItem item : lockOrdered) {
if (stockDeductionService.tryDeduct(item.getProductId(), item.getQuantity())) {
deducted.add(item);
} else {
allDeducted = false;
break;
}
}

상품 ID 오름차순으로 잠그는 순서를 모든 트랜잭션에서 같게 고정했습니다. 재고 부족으로 중간에 멈추면 예외를 던지지 않고 그동안 뺀 재고를 되돌리고 승인된 카드를 자동으로 망취소합니다. 트랜잭션을 깨끗이 커밋하고 보상하는 쪽을 택한 이유도 첫 실험 때와 같습니다. 예외를 던지면 트랜잭션이 rollback-only로 오염돼 보상 작업 적재까지 함께 사라집니다.

한 행이 초당 몇 건까지 버티는가

첫 실험은 재고 20개를 다 파는 시간만 쟀습니다. 한 행이 버티는 처리량 상한은 재지 않았습니다. 결제 확정은 재고 행을 조건부 UPDATE로 잠근 뒤 같은 트랜잭션에서 주문 상태 변경, 포인트 적립, 이벤트 발행 기록까지 쓰고 커밋합니다. 그 잠금은 커밋까지 풀리지 않으므로 한 행의 상한은 대략 1초를 잠금 유지 시간으로 나눈 값입니다.

뒤따르는 쓰기 3건을 실제와 같은 개수로 흉내 내 재고를 넉넉히 두고 실 MySQL 8.4에서 쟀습니다.

모양동시성 20 처리량잠금 유지 p50완판 검사 데드락
지금: 재고 UPDATE 먼저, 쓰기 3건 뒤 커밋301/s2.99ms0
순서만 바꿔 쓰기 3건 먼저, 재고 UPDATE 뒤 커밋444/s1.86ms0
재고를 4칸으로 나눠 임의 칸부터 잡기718/s4.74ms230
재고를 16칸으로 나눠 임의 칸부터 잡기1,245/s7.43ms1,181

동시성을 20에서 64로 올려도 지금 방식은 처리량이 290에서 301/s에 머물렀습니다. 한 행 앞에서 줄을 서기 때문입니다. 순서만 바꾸면 잠금 시간이 3.0ms에서 1.9ms로 줄어 1.47배를 법니다. 남은 1.9ms는 거의 커밋의 로그 fsync입니다. 재고를 여러 칸으로 나누면 처리량은 칸 수만큼 늘지만 매진 직전에 대가를 치릅니다. 빈 칸을 지나가는 트랜잭션들이 각자 다른 칸에서 시작해 서로를 기다리면서 16칸에서는 데드락이 1,181건 났고 선착순 순서도 깨졌습니다.

측정 전에 적어 둔 기준은 커넥션 풀 크기와 같은 동시성 20에서 초당 300건 이상이면 유지하는 것이었고 301건으로 그 기준을 겨우 넘겼습니다. 대기열 실험의 수요가 초당 100명 도착이고 결제 확정까지 가는 것은 그보다 적어 지금은 3배 여유가 있습니다. 그래서 한 행을 유지했습니다. 다만 경계에서 통과했다는 것도 함께 남겼습니다. 가장 낮은 회차는 288건이었고 실제 확정은 이 실험보다 JPA 플러시와 이벤트 기록을 더 하므로 잠금 시간이 더 길 수 있습니다. 한 상품에 초당 100건을 넘게 몰리는 행사가 생기면 순서 바꾸기부터 다시 봅니다.

같은 자원(재고 행, 원장 계정 행)에 쓰기가 몰리는 hot row는 결제 시스템에서 반복해 마주치는 문제입니다. 결제 원장의 hot entity를 append-only로 유지하면서 스냅샷을 아직 넣지 않은 이유는 결제 원장의 Hot Entity에 있습니다.

Shopify라면 어떻게 했을까

재고를 여러 칸으로 나누는 안은 Shopify가 실제로 쓰는 패턴과 대조하려고 넣었습니다. Shopify 엔지니어링 블로그는 재고 하나당 행 하나(quantity 열)로는 특가 판매의 경합을 못 버텼다고 적습니다.

“A single row with a quantity column couldn’t handle the contention.”

그래서 상품 하나당 수량만큼 행을 여러 개 두고(one row per sellable unit), MySQL 8의 SELECT ... FOR UPDATE SKIP LOCKED로 이미 잠긴 행은 건너뛰고 비어 있는 다른 행을 잡습니다.

“if another transaction has locked some rows, MySQL skips them and returns other available rows. No waiting on the same row, less contention.”

행 수는 상품당 1,000개로 제한해 스캔을 빠르게 유지합니다. 이 실험의 4칸, 16칸 구성은 Shopify의 “행을 나눈다”는 아이디어와 같은 방향입니다. 다만 SKIP LOCKED 없이 임의 칸부터 순서대로 잡다 보니 빈 칸을 지나가는 트랜잭션들이 서로 다른 칸에서 출발해 맞물리며 데드락이 났습니다. SKIP LOCKED가 정확히 이 경우를 풀기 위한 장치이므로 나눈 칸을 실제로 살리려면 SKIP LOCKED 자체를 들여야 합니다. 임의 순서 스캔만으로는 부족합니다. 지금은 여유가 3배 있어 이 장치까지는 들이지 않았습니다.

쓰기 시점 검증을 미루면 얼마나 버는가

앞의 실험들은 전부 쓰기 시점에 정확히 막는 전략끼리의 비교였습니다. 반대 축도 재 봤습니다. 쓰기 시점에는 안 막고 느슨한 카운터(Redis DECR)로 무조건 예약한 뒤 나중에 정정하면 조건부 UPDATE보다 처리량이 오르는지, 그 대가는 얼마인지입니다. 재고 20, 스레드 150(고경합)으로 쟀습니다.

지표조건부 UPDATE느슨한 카운터
burst 중앙값34~35ms9~12ms
정정 전 초과 판매0130
정정 후 초과 판매00
취소 건수0130

느슨한 카운터의 burst 중앙값이 65~74% 짧았습니다. 둘 다 같은 자원에 원자적으로 접근해 동시성 150에서도 직렬화되지만 Redis DECR는 MySQL 행 락 획득과 트랜잭션 커밋의 디스크 fsync가 없어 건당 비용이 낮습니다. 대신 150건이 전부 무조건 예약돼 초과 판매가 130건 났고 가장 늦게 들어온 예약부터 취소하는 사후 정정으로 재고 정확히 0, 초과 판매 0으로 수렴시켰습니다.

이 실험(재고 20, 스레드 150)에서는 예약의 87%가 나중에 취소 대상이었습니다. 실서비스에 적용하려면 취소된 고객에게 알리고 보상하는 절차가 따로 필요하고 그 비용은 이 실험 범위 밖입니다. 이 패턴은 수요가 재고를 크게 넘지 않는 평시에 burst 구간의 지연을 줄이는 용도에 맞습니다. 수요가 재고의 7.5배인 이번 같은 극단적 한정 판매에는 맞지 않습니다. 취소 대상이 다수가 되면 “일단 성공”이라는 사용자 경험 자체가 무의미해지기 때문입니다. 그래서 채택하지 않았습니다. 정정 순서를 늦게 들어온 것부터로 고른 이유는 먼저 예약을 완료한 고객이 취소당하면 확정됐다고 믿은 것이 뒤집히는 비용이 더 크다고 판단했기 때문입니다. 무작위 취소나 금액 낮은 순 취소는 비교하지 않았습니다.

재시도가 관측을 지우고 있었다

낙관적 락을 이렇게 설명했습니다. 버전을 비교해 어긋나면 재시도합니다. 경합이 드물면 가장 싸고 몰리면 재시도가 곧 비용입니다. “몰리면 재시도가 곧 비용”인데 그 비용이 어디에도 안 보인다는 것을 한참 뒤에 알았습니다.

} 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는 재시도를 다 썼다는 것을 말합니다. 여기서부터 사용자가 실패를 봅니다.

이 자리를 찾은 경위도 적어 둡니다. 장애 로그에 모델을 붙여 보려고 공개 포스트모템 분류에서 장애 유형을 가져와 하나씩 주입하고 로그를 모으는 중이었는데 낙관적 락 충돌은 아무리 넣어도 로그가 안 나왔습니다. 왜 안 나오는지 파고들다 이 자리를 봤습니다. 모델은 결국 붙이지 않았습니다. 로그를 모으려던 시도가 로그가 없는 자리를 찾았고 그 자리는 처음 의도와 아무 상관이 없었습니다.

유입이 몰릴 때는 문 앞에서 막았다

감당 못 할 요청은 가장 바깥 층에서 가장 싸게 거절합니다. 안쪽(DB)으로 갈수록 요청 처리 비용이 커지니 밖에서 429 한 방으로 쳐내는 것이 모두를 살립니다. 이 원칙으로 Redis 기반 요청 수 제한, 한정판 상품의 선착순 대기열, 빨리 실패하는 커넥션 풀 설정까지 세 겹을 쌓았습니다.

요청 수 제한 필터는 SecurityConfig에서 직접 만들어 Bearer 인증 필터 뒤에 끼워 넣었습니다. @Component 자동 등록은 쓰지 않았습니다. 인증이 끝난 뒤라야 principal(userId)로 사용자별 키를 만들 수 있고 자동 등록에 의한 이중 적용도 원천 차단됩니다. 대기열 쪽에서는 결제 경로를 건드리지 않는 원칙을 지켰습니다. 대기열은 입장과 상태 조회, 이탈만 하는 독립 프리미티브로 두고 다른 모듈을 참조하지 않습니다. 대신 한정판 상품은 옵트인으로 게이트를 걸어, 대기열을 거치지 않고 결제 API를 직접 두드리면 서버가 막습니다. 게이트 상품 목록이 비어 있으면 검증 자체를 안 타서 일반 상품 결제는 기존 동작이 그대로입니다.

이 세 겹을 얼마나 실측했고 그 실측이 왜 세 번이나 다시 계산됐는지는 별도로 정리했습니다. 계정 하나로 잰 첫 실측은 전역 상한에 부하가 닿지도 않은 반쪽 실험이었고 계정을 늘려 다시 재니 닫힌 루프의 함정에 걸려 결론이 또 뒤집혔습니다. 그 세 번의 재측정과 최종 결론은 유입 제어를 세 번 쟀고 결론이 두 번 바뀌었다에 있습니다. 대기열이 매진 뒤에도 사람을 계속 들여보내 헛걸음을 줄이지 못했던 이야기는 매진이면 대기열을 닫게 했는데 한 번도 닫히지 않았다에 있습니다.

데드락은 재시도해도 되는 실패였다

유입 제어를 켜고 스파이크를 재자 없던 5xx가 생겼습니다. 고정 윈도우가 통과 요청을 매초 초입에 버스트로 내보내면서 그 동시 커밋들이 아웃박스 INSERT에서 MySQL 데드락으로 부딪힌 것입니다. 이 프로젝트의 철칙 중 하나는 승인을 재시도하지 않는 것입니다. 멱등키 없는 승인 재시도는 이중결제이기 때문입니다. 그런데 데드락이 났다고 재실행하면 이 철칙을 깨는 것은 아닌지가 문제였습니다.

핵심은 실패의 종류가 다르다는 것입니다. 타임아웃은 결과를 모르는 실패입니다. PG가 승인을 했는지 안 했는지 알 수 없으니 재시도하면 이중결제가 날 수 있어 미확정으로 보존하고 조회로 확정합니다. 데드락은 결과가 확정된 실패입니다. DB가 트랜잭션을 통째로 롤백했다고 보장하므로 재실행은 사실상 처음부터 다시 하는 것이 됩니다. 다만 DB 롤백이 PG 호출을 되돌리지는 않습니다. 재실행이 안전한 이유는 그 승인을 되돌리는 데 있지 않습니다. 요청에 Idempotency-Key 헤더를 실어 보내 같은 승인을 다시 받기 때문에 안전합니다.

이 헤더가 보장하는 범위를 토스페이먼츠 문서에서 직접 확인했습니다.

“토스페이먼츠 서버는 상점에서 API 요청 헤더로 보낸 멱등키와 API 키, API 주소, HTTP 메서드 조합이 같은 요청이 있는지 확인해서 멱등성을 보장합니다.” (토스페이먼츠 멱등키)

보장의 단위는 멱등키와 API 키, 주소, 메서드의 조합입니다. 요청 본문의 내용은 이 판단에 들어가지 않습니다. 처음에는 “같은 파라미터면 PG가 멱등 처리한다”고 적었는데 이 계약과 어긋난 표현이었습니다. 코드는 처음부터 이 헤더를 보내고 있었고 글의 근거가 약했던 쪽이었습니다. “롤백됐으니 깨끗하다”는 확신으로 넘어가면 위험한 자리입니다.

재시도는 모든 변경 API를 감싸는 IdempotencyService에 한 곳으로 모았습니다. 재시도하는 동안 처리 중 멱등 레코드가 살아 있어 같은 키의 동시 중복 요청은 계속 409로 막힙니다. 재시도가 중복 처리 창을 새로 여는 것은 아닙니다. 데드락 후 재실행이 안전한 근거는 한 겹 더 있습니다. 재실행은 확정 단계 맨 앞부터 다시 돌아 PG 조회로 기존 시도를 먼저 확정하고 주문의 조건부 상태 전이가 이미 확정된 주문의 새 시도를 막습니다. 재실행이 낳는 새 PG 호출은 정말로 새 시도일 때만 일어납니다.

이 재시도를 넣게 된 실측과 재측정에서 데드락이 실제로 몇 건 흡수됐는지는 유입 제어를 세 번 쟀고 결론이 두 번 바뀌었다에 있습니다.

포인트 적립 경합도 같은 처방으로 풀었다

같은 조건부 차감의 판단이 다른 자리에서 한 번 더 쓰였습니다. 결제 트랜잭션 안에서 포인트 계좌를 읽고 더하고 저장하는 적립이 같은 사용자의 동시 결제에서 @Version 낙관적 락 충돌을 내며 결제 승인 자체를 500으로 만들었습니다. 적립은 더하기이고 더하기는 순서가 무관합니다. 순서가 무관한 연산을 굳이 읽어서 순서를 만들 이유가 없어, 조건부 UPDATE에서 조건만 뺀 DB 원자 증가로 바꿨습니다. 최악 조건에서 승인 성공률이 39.6%에서 100%로 올랐습니다. 이 경합을 찾아낸 경위와 원자 증가가 낙관적 락 감시를 우회하는 함정, 원장과 대조한 재측정은 정산을 떼어 낼 근거 하나를 기각했다에 있습니다.

배치가 밀린 물량을 한 번에 다 읽고 있었다

카카오페이 데이터 조직의 기술 블로그를 훑다가 같은 주제가 반복해 나오는 것을 봤습니다. 원장 통계 배치가 수억 건을 한 번에 읽다 OOM이 나 파티셔닝으로 97분을 8분 55초로 줄인 사례, 정산팀 조회가 하루 25만 번에서 1억 번으로 늘면서 Limit-Offset이 5천만 번째부터 수십 초가 걸려 자체 커서 리더로 해결한 사례, 실시간 OLAP의 기본키가 1억 2천만 건을 넘으면 OOM이 나는 사례였습니다. 공통점은 한 번에 메모리에 올리지 않는다는 것이었습니다. 그래서 이 프로젝트의 배치를 세어 봤습니다.

조건에 맞는 것을 전부 List로 반환하는 상한 없는 조회가 15개, 페이징이 걸린 것이 5개였습니다. 정산 항목, 에스크로 자동해제, 주문 만료, 미확정 결제 복구, 웹훅 보류 재시도, 정기결제 청구까지 배치가 스캔하는 자리 전부였습니다. 실 MySQL에 밀린 상황을 만들어 재 보니 상한 없는 조회는 쌓인 대상이 500건이면 그 500건을 그대로 읽었습니다.

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

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

상한만큼 읽고 없어질 때까지 반복하는 구조로 만들 수도 있었습니다. 하지 않았습니다. 이 배치들은 한 건이 실패해도 멈추지 않고 다음 주기에 다시 시도합니다. 비울 때까지 도는 구조로 만들면 영영 성공하지 못하는 건 하나가 배치를 붙잡습니다. 틱당 일이 일정한 편이 부하도 예측되고 밀린 물량은 여러 주기에 걸쳐 빠집니다.

private Pageable chunk() {
return PageRequest.of(0, readChunkSize > 0 ? readChunkSize : 500);
}

PageRequest는 크기가 0이면 예외를 던집니다. 잘못된 설정 하나로 돈을 다루는 배치가 멈추는 것보다 기본값으로 도는 편이 낫다고 판단해 기본값 500을 넣었습니다. 필드 초기값도 같이 뒀습니다. @Value는 스프링이 만들어 줄 때만 채워지는데 단위 테스트는 서비스를 직접 생성해, 초기값이 없어 0이 되면서 배치 테스트가 무더기로 깨졌던 것을 겪었습니다.

커서는 쓰지 않았습니다. 지금은 처리된 건이 대상에서 빠지므로 항상 첫 페이지만 읽어 뒤로 갈수록 느려지는 문제가 안 생깁니다. 상한만으로 메모리 문제가 사라지는데 커서를 들이면 트랜잭션 수명과 커넥션 점유를 따로 관리해야 합니다. 대상이 남는 조회로 바뀌면 그때 커서가 필요합니다. 필요가 증명되기 전에 무거운 장치를 들이지 않는다는 판단이고 이 판단을 적어 둬야 나중에 무엇이 바뀌었는지 알 수 있습니다. 상한을 걸어 메모리를 지키는 대신 한 틱이 처리할 수 있는 양에도 천장이 생겼다는 것이 이 선택의 대가입니다.

건수는 50건으로 묶여 있었는데 30만 행을 훑고 있었다

배치의 무제한 조회를 상한으로 묶고 나서 서빙 쪽은 안 봤다는 것을 알았습니다. 내 주문 목록 조회를 열어 보니 findTop50ByUserIdOrderByIdDesc였습니다.

public List<OrderSummaryView> myOrders(long authenticatedUserId) {
return orderRepository.findTop50ByUserIdOrderByIdDesc(authenticatedUserId).stream()
.map(OrderSummaryView::from)
.toList();
}

50건으로 이미 묶여 있으니 됐다고 넘길 뻔했습니다. 건수가 묶인 것과 그 건수를 싸게 찾는 것은 다른 일입니다. orders에 걸린 인덱스는 (status, created_at)과 기본키뿐이었는데 이 조회가 거는 조건은 user_id였습니다.

인덱스가 없으면 ORDER BY id DESC LIMIT 50은 기본키를 역방향으로 훑으며 걸러냅니다. 그 사용자의 주문이 50건 이상이면 위쪽에서 금방 채우고 멈추지만 주문이 50건 미만인 사용자는 그 50건을 영영 못 채워 테이블 끝까지 갑니다. 실제 서비스에서는 주문 50건 미만인 사용자가 대다수입니다. 30만 행(사용자 1만 명, 30건씩)을 넣고 실제 MySQL에서 쟀습니다.

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

107배. 그런데 이 표에서 더 중요한 것은 왼쪽 칸입니다. 옵티마이저는 rows=50이라고 추정했습니다. 계획만 보면 나무랄 데가 없습니다. 기본키를 쓰고 정렬도 없고 읽는 행도 50건입니다. 실제로 훑은 것은 30만 행이었고 추정이 6,000배 틀렸습니다. 그 사용자에게 30건밖에 없다는 것은 테이블 통계로 알 수 없기 때문입니다. 이런 문제는 EXPLAIN ANALYZE로만 보입니다. EXPLAIN 추정만으로는 드러나지 않습니다. 65ms는 보통 느린 쿼리 임계값 아래라 로그에도 잘 안 걸립니다.

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

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

같은 인덱스가 한 조회는 살리고 다른 조회는 죽입니다. 흔한 값을 찾을 때는 기본키 역방향 스캔이 바로 채우는데 인덱스를 타면 세컨더리 인덱스에서 기본키로 한 번 더 건너가야 합니다. 판단 기준은 그 조회가 무엇을 찾는지에 있습니다. 이 배치들이 거는 조회는 전부 할 일을 찾는 조회(PENDING, UNKNOWN, HELD, OPEN)라 성숙한 테이블에서는 드문 값 쪽입니다. 예외로 구독 청구 배치는 흔한 값인 ACTIVE를 찾습니다. 다만 선택도는 날짜(next_billing_date)에서 나와 날짜를 둘째 자리에 뒀습니다.

한 곳을 고치고 끝내면 나머지는 그대로입니다. 리포지터리의 조회 메서드에서 조건 컬럼을 뽑아 인덱스와 대조하는 스크립트를 짜 봤는데 결과가 두 방향으로 틀렸습니다. JPA가 유니크 제약으로 만들어 준 인덱스를 못 봐서 있는데 없다고 했고 조건이 없는 findAllByOrderBy...를 조건으로 세서 없는데 있다고 했습니다. 그래서 마이그레이션을 실제로 다 돌린 뒤 information_schema에 직접 물었습니다. 조회 조건 22개가 인덱스 선두에 없었고 18개에 인덱스를 걸었습니다. 4개는 일부러 안 걸었습니다. 감사 로그처럼 쓰기가 많고 조회는 사후 조사뿐인 것, 평가용이라 행이 수백 단위인 것들이었습니다.

조회가 107배 빨라졌다는 수치만 적으면 인덱스가 공짜라는 주장이 됩니다. 결제는 쓰기가 많은 시스템이고 인덱스는 조회에서 버는 것을 쓰기에서 갚습니다. 처음에는 한 번씩만 재고 쓰기가 3.8% 느려진다고 적었는데 다시 돌려 보니 -2.3%가 나왔습니다. 인덱스 있는 쪽이 오히려 빨랐다는 뜻이고 3%대 차이를 1회 측정으로 주장한 것이 잘못이었습니다. 교대로 5회 재고 회차 간 폭을 함께 적는 것으로 고쳤습니다.

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

차이 +2.9%, 노이즈 폭 2.3%로 노이즈보다 크지만 아슬아슬합니다. 다만 5회 전부 인덱스 쪽이 느렸으므로 방향은 믿고 크기는 3% 안쪽으로만 말할 수 있습니다. 조회 107배를 쓰기 3% 안쪽으로 사는 교환이라 받았습니다.

같은 실수가 다시 들어오는 것을 막는 쪽이 이번에 고친 18개보다 오래 갑니다. 마이그레이션을 다 돌린 뒤 실제 스키마에 물어보는 테스트(SchemaIndexAuditMySqlTest)를 두어 조회 조건 컬럼이 인덱스 선두에 없으면 CI에서 깨지게 했습니다. 안 걸기로 한 4개는 목록에서 빼되 근거를 마이그레이션과 테스트 주석 양쪽에 적었습니다. 빠진 것과 일부러 뺀 것을 구별할 수 있어야 나중에 보는 사람이 판단할 수 있기 때문입니다.

한 건이 싸다고 동시에도 싼 건 아니다

인덱스로 조회 한 건을 65.6ms에서 0.6ms로 줄였습니다. 그런데 그건 커넥션 하나로 잰 값입니다. 동시에 들어올 때도 그런지는 다른 질문이었습니다. k6 스크립트를 세어 보니 아홉 개가 전부 쓰기 경로였습니다. 체크아웃, 스파이크, 정산 경합, 재배포까지 다 재면서 조회는 한 번도 안 쟀습니다.

이 프로젝트는 한 번 닫힌 루프로 재서 용량을 과소평가한 적이 있습니다. VU를 늘리는 방식은 서버가 느려지면 VU가 다음 요청을 늦게 보내 도착률이 같이 줄어 병목이 가려집니다. 조회는 응답이 빨라 그 함정에 더 잘 빠지므로 VU 수를 올리는 대신 도착률을 고정했습니다. 주문 30만 건(사용자 1만 명, 30건씩)을 넣고 계정 40개를 라운드로빈으로 흩뿌려 돌렸습니다.

세 번 잘못 재면서 판정 방법을 다듬었습니다. 쓰기 경로에서 쓰던 “성공률이 온전한 마지막 단계” 기준을 그대로 가져왔더니 2,400/s까지 성공률 100%가 나와 무릎을 못 찾았습니다. 조회는 실패하지 않고 지연만 오르기 때문입니다. 판정을 지연 기반(평탄 구간 p95의 3배를 넘는 첫 단계 직전)으로 바꿨습니다. 3,200/s부터는 k6가 목표 VU에 걸려 도착률을 못 낸 것을 그대로 서버의 한계로 읽을 뻔했고 20초 단계로는 4~6배씩 튀는 노이즈를 무릎으로 잘못 읽을 뻔했습니다. 단계를 60초로 늘려 단조롭게 만들었습니다.

목표실제성공률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배 높았습니다.

2026-09-26 정정. 이 표를 만든 측정은 빈 목록을 쟀습니다. 부하 스크립트가 실행마다 새 계정을 만들어 로그인했고 주문은 시드 단계가 만든 다른 계정에만 들어 있었습니다. 조회한 40계정의 주문은 0건이었고 응답은 빈 배열이었는데 상태 코드가 200이라 성공으로 셌습니다. 스크립트를 고쳐 주문이 30건씩 있는 계정으로 다시 재니 p95가 4.4, 6.7, 10.1ms(400, 1,000, 1,800/s)로 이 표와 같은 크기였습니다. 수치는 그대로 두고 그 수치를 만든 측정이 틀려 있었다는 것만 적어 둡니다.(#352)

캐시는 넣지 않았습니다. 판정 기준을 결과보다 먼저 적어 뒀습니다. 평탄 구간의 p95가 이미 크면 조회 한 건이 비싼 것이라 캐시가 값을 합니다. 평탄 p95가 작고 무릎이 높으면 DB는 병목에서 벗어난 상태라는 뜻입니다. 평탄 p95가 5ms고 무릎이 1,800/s이니 조회 한 건은 이미 쌉니다. 1,800/s 위에서 오르는 지연은 줄을 서는 시간입니다. 쿼리 자체가 비싸진 것은 아닙니다. 캐시는 한 건의 비용을 줄이는 장치라 이 병목에 안 닿습니다. 게다가 이 데이터를 만드는 쓰기 경로가 120/s에서 꺾이니 읽을 것을 만드는 쪽이 15배 좁습니다. 넣으면 얻는 것 없이 무효화라는 더 어려운 문제만 늘어 안 넣었습니다. 조회 패턴이 바뀌거나 평탄 p95가 커지면 그때 다시 잽니다.

못 잰 것도 적었습니다. 부하를 건 것은 목록 조회 한 종류였고 타임라인 조립과 데이터가 메모리를 넘는 경우는 따로 쟀습니다. 타임라인은 요청당 SELECT 17개로 무릎이 400/s였고 쓰기 경로보다 높았습니다(#350). 자주 조회되는 사용자의 데이터가 버퍼 풀보다 크면 무릎이 400~1,000/s 사이로 내려가 DB가 병목이 됐습니다(#352). 그때는 캐시를 넣기 전에 버퍼 풀부터 봅니다. 부하기와 서버가 같은 노트북이라 2,200/s 위는 이 구성으로 못 잽니다.

관통하는 것

전부 “어디서 기다리게 할 것인가”의 문제였습니다. 재고는 앱에서 읽고 비교하고 쓰는 왕복을 없애고 DB의 행 락 한 곳에서만 기다리게 했습니다. 그 행이 버티는 상한을 다시 재고서야 얼마나 여유가 있는지 알았습니다. 유입 제어는 문 앞에서 돌려보내 DB까지 오는 손 자체를 줄였습니다. 그래도 부딪히면 기다리지 말고 다시 오게 했습니다. 포인트 적립은 순서가 무관한 연산이라 애초에 기다릴 이유가 없었습니다. 배치와 서빙 조회는 훑는 범위가 문제였습니다. 상한과 인덱스로 그 범위를 줄였습니다.

넷 다, 아니 일곱 자리 전부 재 보고 정했습니다. 재고는 인메모리에서 잰 순위가 실 DB에서 뒤집혔고 그 결정 이후에도 잡는 시점과 한 행의 상한을 두 번 더 다시 쟀습니다. 유입 제어는 제어를 켜자 없던 데드락이 생겼고 그 제어가 가려주던 포인트 적립 결함은 제어를 꺼야 보였습니다. 측정 환경을 의심하는 습관이 여기서 생겼습니다. 뒤에서 부하 측정 자체가 틀렸던 것을 발견하는 것도, 채점 기준이 스스로를 속이는 것을 잡는 것도 같은 자리에서 나왔습니다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있습니다. 성능 실측의 방법론과 원자료는 성능 리포트에 정리했습니다.

프로필 사진
작성자 @범수

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

댓글

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