들어가며
미리 쌓아둔 재고에서 N건을 선점해 발급하는 API를 대상으로, 트래픽이 올라갈 때 어느 지점이 먼저 한계에 닿는지 따져본 기록이다.
처음에는 이렇게 계산했다.
1
2
스레드 하나가 요청 하나를 100ms에 처리한다
→ 톰캣 기본 스레드 200개 × (1초 / 0.1초) = 2,000 TPS
이 계산이 왜 성립하지 않는지가 이 글의 출발점이다. 결론부터 적으면 이렇다.
TPS 상한을 정하는 건 스레드 수가 아니라 직렬 구간의 길이, 그리고 그 구간에 몰리는 키의 분포다.
예제는 쿠폰 코드 발급 API로 재구성했다. 캠페인별로 쿠폰 코드를 미리 적재해두고, 제휴사가 수량을 지정해 요청하면 그만큼 발급해주는 구조다. “재고 행을 선점해 상태를 바꾸고, 잔량이 임계치에 닿으면 운영자에게 알린다”는 골격만 같으면 아래 논의는 재고 차감·좌석 배정·번호 발급 어디에나 그대로 적용된다.
아래 내용은 코드 리딩과 산술 추정 기반이다. 실측 결과가 아니므로 수치는 “이 정도 규모로 갈릴 수 있다”는 의미로만 읽어야 한다. 측정 설계는 뒤쪽에 따로 적었다.
가장 심플한 구조
가장 단순한 형태의 웹 서비스에서 발급 API 하나만 떼어내 본다.
1
client server --> nginx --> api server (spring boot) --> DB
테이블은 네 개다.
| 테이블 | 역할 |
|---|---|
campaign | 캠페인 마스터 (유효기간 정책 등). 거의 변하지 않음 |
coupon_code | 발급 대상 재고. 행 하나가 쿠폰 코드 하나 |
issue_request | 발급 요청 이력 |
campaign_stock_notice | 잔량 알림을 어디까지 보냈는지 기록 |
발급은 하나의 @Transactional 메서드 안에서 아래 순서로 진행된다.
1
2
3
4
5
6
7
8
9
10
@Transactional
public CouponIssueResult issue(String clientId, String clientRequestId,
String campaignId, int quantity) {
validateCampaign(clientId, campaignId); // 캠페인 조회
String issueNo = issueRequestStore.save(...); // 발급요청 INSERT
List<String> codes = couponIssuer.issueCodes(issueNo, campaignId, quantity); // 코드 발급
LocalDateTime expiresAt = campaignReader.getExpirationDate(campaignId, now); // 만료일 조회
stockNotifier.notifyIfLowStock(campaignId); // 잔량 확인 + 알림
return new CouponIssueResult(issueNo, codes, expiresAt);
}
코드 발급은 비관적 락을 쓴다.
1
2
3
4
5
SELECT code FROM coupon_code
WHERE campaign_id = #{campaignId}
AND status = 'AVAILABLE'
LIMIT #{quantity}
FOR UPDATE
조회한 코드를 발급 상태로 UPDATE 하고, 마지막에 잔량이 임계치(1000/500/300/100/50/30)에 닿았으면 운영자에게 메일과 문자를 보낸다.
생각해볼 부분
| 계층 | 따져볼 것 |
|---|---|
| nginx | client connection, upstream connection, keepalive |
| api server | tomcat max threads, accept queue, DB 커넥션 풀 사이즈 |
| DB | 비관적 락 + UPDATE 가 많을 때 어디에 부하가 걸리는가 |
| 요청 자체 | 한 요청이 DB를 몇 번 왕복하는가, 락을 얼마나 오래 잡는가 |
한 요청이 DB를 몇 번 왕복하나
트랜잭션 안의 쿼리 수를 세는 것이 병목 분석의 출발점이다.
처음에는 “3번쯤”이라고 생각했는데, 호출을 펼쳐 세어보면 그보다 많다.
| 순서 | 호출 | 쿼리 |
|---|---|---|
| 1 | validateCampaign | getCampaign (SELECT) |
| 2 | issueRequestStore.save | INSERT |
| 3 | issueCodes 내부 | getCampaign (SELECT, 2번째) |
| 4 | 〃 | selectAvailableCodesForUpdate (SELECT FOR UPDATE) |
| 5 | markCodesIssued | UPDATE |
| 6 | getExpirationDate | SELECT |
| 7 | notifyIfLowStock | getRemainCount (COUNT) |
| 8 | 〃 | 메일 + 문자 외부 HTTP I/O |
getCampaign 이 한 트랜잭션에서 세 번 호출된다. 요청을 검증할 때, 코드를 발급할 때, 그리고 알림 메시지 파라미터를 만들 때 각각 부른다.
MyBatis 1차 캐시가 이걸 막아주지 않는 이유
MyBatis 로컬 캐시는 SqlSession 단위이고, Spring에서 @Transactional 안이면 같은 세션을 재사용하므로 동일 statement + 동일 파라미터 조회는 캐시된다. 그래서 세 번 호출해도 실제 쿼리는 한 번일 것 같다.
문제는 BaseExecutor.update() 가 INSERT/UPDATE/DELETE 실행 시 무조건 clearLocalCache() 를 호출한다는 점이다. 쓰기 한 번이면 로컬 캐시가 통째로 비워진다.
| 순서 | 호출 | 캐시 상태 |
|---|---|---|
| 1 | getCampaign | miss → DB 조회, 적재 |
| 2 | 발급요청 INSERT | flush |
| 3 | getCampaign | DB 조회 (1번 캐시 소멸) |
| 4 | selectAvailableCodesForUpdate | — |
| 5 | markCodesIssued (UPDATE) | flush |
| 6~7 | 만료일 조회, 잔량 COUNT | — |
| 8 | getCampaign | DB 조회 (5번에서 flush) |
읽기가 연속으로 오면 캐시가 동작하지만, 이 메서드는 읽기와 쓰기가 번갈아 나오는 패턴이라 캐시 히트가 한 번도 발생하지 않는다.
1차 캐시는 “같은 세션에서 같은 조회를 반복하면 아낀다”가 아니라 “쓰기가 끼어들지 않는 동안 반복하면 아낀다”에 가깝다.
datasource-proxy 나 p6spy 로 요청당 쿼리 수를 세면 바로 확인된다.
TPS 높아지면 어디가 병목이 될까
SELECT ... FOR UPDATE는 커밋 시점까지 락을 유지한다. 이 한 줄이 앞의 계산 전체를 바꾼다.
락 획득(4번)부터 COMMIT까지가 같은 campaign_id 에 대한 완전 직렬 구간이다. 그 안에 무엇이 들어있는지 다시 보면,
1
2
FOR UPDATE → UPDATE → 만료일 SELECT → COUNT → 메일 HTTP → 문자 HTTP → COMMIT
└──────────────────── 직렬 구간 ────────────────────────────────────────┘
외부 메일 발송이 200ms 걸린다면, 그 캠페인의 이론상 최대 처리량은 다음과 같다.
1
1 / 0.2초 = 초당 5건
톰캣 스레드가 200개든 2,000개든 이 숫자는 변하지 않는다. 스레드를 늘리면 처리량이 아니라 락 대기 큐의 길이가 늘어난다.
세 가지 법칙으로 다시 보기
암달의 법칙 — 직렬 구간의 비율이 s 면 아무리 병렬 자원을 늘려도 처리량 상한은 1/s 로 고정된다. 여기서 s 는 락 보유 시간이다.
Little’s Law — L = λW 에서 W 를 “전체 응답시간”으로 잡으면 안 된다. 스레드 200개에 응답시간 100ms를 넣어 2,000 TPS를 얻는 계산은 스레드가 병목일 때만 성립한다. 실제로는 락 구간에 대해 따로 적용해야 하고, 락 구간이 50ms면 캠페인당 20 TPS가 상한이다.
Universal Scalability Law — 동시성을 계속 올리면 경합(contention) 항 때문에 처리량이 평평해지고, 일관성 유지 비용(coherency) 항 때문에 어느 지점부터는 오히려 감소한다. 부하를 올리며 TPS를 그리면 이 역U자 곡선이 나온다.
요청 하나의 응답시간이 100ms여도, 그중 락을 잡고 있는 구간이 얼마인지를 따로 봐야 한다.
10,000 TPS가 몰린다면
톰캣 가용 스레드가 부족해지고 큐가 차는 것은 맞지만, 그 전에 락 대기가 먼저 온다. 그리고 진짜 문제는 다른 API까지 같이 죽는다는 점인데, 이유는 커넥션 풀 쪽에 있다(아래).
경합은 전체 TPS가 아니라 키 분포의 함수다
락 경합은 요청량이 아니라 “같은
campaign_id에 몰리는 정도”에서 나온다.
캠페인 1,000개에 요청이 균등 분산되면 10,000 TPS라도 락 경합은 거의 없다. 반대로 인기 캠페인 하나에 90%가 몰리면 100 TPS에서도 무너진다. 같은 총량인데 결과가 완전히 갈린다.
| 시나리오 | 캠페인당 실효 부하 | 예상 병목 |
|---|---|---|
| 균등 분산 (캠페인 1,000개) | 낮음 | DB CPU, 커넥션 풀 |
| Zipf 분포 (상위 소수에 편중) | 상위 캠페인에 집중 | 해당 캠페인 행의 락 대기 |
| 단일 핫 캠페인 | 전량 집중 | 락 대기, 사실상 직렬 처리 |
부하 테스트 시나리오를 짤 때 이 분포를 정하지 않으면 측정한 TPS 숫자가 아무 의미를 갖지 못한다. “TPS 5,000이 나왔다”보다 “균등 분산에서는 X, 단일 캠페인 집중에서는 Y로 N배 차이”가 훨씬 많은 것을 말해준다.
각 층에서 신경 쓸 부분
애플리케이션 코드 밖에도 한계에 먼저 닿는 지점들이 있다.
커넥션 풀
HikariCP 기본 풀 사이즈는 10이다. 톰캣 200 스레드가 커넥션 10개를 두고 경합하면 나머지는 getConnection() 에서 대기한다(기본 connectionTimeout 30초). 스레드 덤프를 뜨면 대부분이 HikariPool.getConnection 에 몰려 있다.
여기서 흔한 대응은 풀을 키우는 것인데, 그렇게 좋아지지 않는다. HikariCP 문서의 권장 공식은 다음과 같다.
1
connections = (core_count × 2) + effective_spindle_count
DB 코어 수보다 많은 동시 쿼리는 컨텍스트 스위칭과 락 경합만 늘린다. 풀을 200으로 올렸더니 더 느려지는 결과가 나올 수 있다.
더 중요한 함정이 하나 있다.
락을 대기 중인 커넥션도 풀에서는 점유 상태다.
코드 발급이 락 대기로 느려지면 풀이 마르고, 락과 아무 상관 없는 취소·조회 API까지 커넥션을 얻지 못해 같이 죽는다. 장애 범위가 API 하나에서 서비스 전체로 번지는 경로다. 대응은 두 방향이다.
- 격벽(bulkhead): 발급 경로와 나머지 경로의 커넥션 풀을 분리해 서로 잠식하지 못하게 한다
- 빠른 실패: 락 대기 타임아웃을 짧게 잡아 커넥션을 오래 붙들지 못하게 한다
큐가 몇 겹인지 세어보기
요청 하나가 처리되기까지 통과하는 대기열은 하나가 아니다.
1
2
3
4
5
6
7
커널 accept queue (somaxconn)
→ nginx worker_connections
→ 톰캣 acceptCount (기본 100)
→ 톰캣 maxConnections (기본 8192)
→ 톰캣 maxThreads (기본 200)
→ HikariCP 대기 큐
→ InnoDB 락 대기 큐
각 층이 포화될 때 사용자에게 보이는 에러가 다르므로, 역으로 어느 층이 터졌는지 추적할 수 있다.
| 증상 | 유력한 원인 층 |
|---|---|
| connection refused / reset | 커널 accept queue 또는 톰캣 acceptCount 초과 |
| 502 Bad Gateway | upstream 연결 실패 — 톰캣이 연결을 못 받는 상태 |
| 504 Gateway Timeout | nginx proxy_read_timeout 초과 — 톰캣이 응답을 못 냄 |
| 500 + 커넥션 획득 실패 로그 | HikariCP connectionTimeout 초과 |
500 + Lock wait timeout exceeded | InnoDB 락 대기 초과 |
큐를 늘리면 지연이 늘어날 뿐 처리량은 늘지 않는다.
이건 네트워크의 bufferbloat와 같은 구조다. 처리 능력이 부족한 상태에서 큐만 키우면, 이미 클라이언트가 포기한 요청을 뒤늦게 처리하느라 자원을 쓰게 된다. 짧은 큐와 빠른 실패(load shedding)가 나은 선택인 경우가 많다.
타임아웃 계층의 정합성
innodb_lock_wait_timeout 의 기본값은 50초다. 클라이언트는 이미 연결을 끊었는데 서버 스레드와 DB 커넥션은 50초 동안 잡혀 있을 수 있다.
타임아웃은 바깥 계층이 안쪽보다 길어야 한다.
1
nginx proxy_read_timeout > 톰캣 처리 시간 > HikariCP connectionTimeout > 락 대기 타임아웃
이 순서가 어긋나면, 안쪽 작업이 아직 진행 중인데 바깥에서 먼저 끊어 취소된 요청의 작업이 계속 도는 상태가 된다. 클라이언트가 끊어도 톰캣 스레드와 DB 쿼리는 자동으로 중단되지 않기 때문이다. 부하 상황에서는 이 유령 작업이 자원을 상당히 잡아먹는다.
nginx와 부하 생성기
- upstream
keepalive를 설정하지 않으면 요청마다 TCP 핸드셰이크가 일어나고TIME_WAIT이 쌓여 ephemeral port 고갈로 이어진다 - 부하 생성기 자체가 병목인지 먼저 확인해야 한다 (파일 디스크립터 한계, 단일 머신 CPU)
부하 테스트에서 처음 만나는 벽은 대개 애플리케이션이 아니라 커널·네트워크 설정이다.
개선 포인트
비용이 낮은 것부터 적용한다. 코드에서 해결되는 것을 인프라 증설로 덮지 않는다.
순서를 이렇게 잡는 이유는 단순히 비용 때문만은 아니다. 락 구간이 긴 상태에서 서버를 늘리면 락 경합이 오히려 심해진다. 동시에 같은 행을 노리는 트랜잭션 수만 늘어나기 때문이다. 코드 레벨 개선이 인프라 증설보다 먼저 와야 하는 실질적인 이유다.
| 순서 | 레벨 | 개선 | 비용 |
|---|---|---|---|
| 1 | 코드 | 락 구간 밖으로 빼기 (알림, 만료일 조회, 캠페인 캐싱) | 낮음 |
| 2 | 코드 | 멱등키 (client_request_id 유니크) | 낮음 |
| 3 | 코드 + 스키마 | UPDATE ... ORDER BY pk LIMIT n 으로 원자적 선점 + 인덱스 | 중간 |
| 4 | 설정 | 커넥션 풀 / 타임아웃 계층 정리, 풀 분리 | 중간 |
| 5 | 쿼리 | SKIP LOCKED + 바운드 재시도 | 중간 (거짓 품절 감수) |
| 6 | 구조 | 재고 세그먼트화, Redis 선점 | 높음 (정합성 복잡도) |
| 7 | 구조 | 비동기 발급(202), 파티션 단일 라이터 | 높음 (API 계약 변경) |
| 8 | 인프라 | 서버 증설, DB 스케일업/리드 리플리카 | 높음 (지속 비용) |
1단계 — 락 구간에서 뺄 수 있는 것부터
가장 먼저인 이유는 알림 로직에서 잘 드러난다. 잔량 소진 알림은 실시간성이 필요 없다. 운영자가 30초 늦게 받아도 차이가 없다. 그런데 그걸 위해 발급 한 건마다 아래 COUNT가 락 구간 안에서 돈다.
1
2
3
SELECT COUNT(*) FROM coupon_code
WHERE campaign_id = #{campaignId}
AND status = 'AVAILABLE'
잔량이 수십만이면 요청마다 대량 스캔 집계가 발생한다. 주기 실행(스케줄러)으로 옮기면 전체 캠페인을 GROUP BY 한 번으로 훑고 끝난다. 부수적으로 따라오는 것들이 있다.
- 쓰는 주체가 한 스레드뿐이라 중복 발송 방지용 CAS 자체가 필요 없어진다
- 직전 잔량과 현재 잔량을 비교하므로 임계값 점프로 인한 알림 누락이 구조적으로 사라진다
- 커밋된 데이터만 집계하므로 롤백으로 인한 거짓 알림이 불가능해진다
- 알림 발송 실패가 발급 경로에 영향을 줄 수 없다
동시성 문제를 정교하게 푸는 것보다, 동시성 문제가 생기는 위치에서 빼내는 쪽이 낫다.
캠페인 마스터 조회도 마찬가지다. 거의 변하지 않는 데이터이므로 애플리케이션 레벨 캐시로 옮기면 트랜잭션·clearLocalCache() 와 무관해지고, 락 구간 안의 DB 왕복이 사라진다.
3단계 — claim 패턴
SELECT FOR UPDATE 로 후보를 고르고 나중에 UPDATE 하던 두 문장을, 조건부 UPDATE 한 문장으로 합친다.
1
2
3
4
5
6
7
8
9
10
11
12
-- 원자적 선점
UPDATE coupon_code
SET status = 'ISSUED',
issued_at = NOW(),
issue_no = #{issueNo}
WHERE campaign_id = #{campaignId}
AND status = 'AVAILABLE'
ORDER BY id
LIMIT #{quantity}
-- 회수: 방금 내 트랜잭션이 쓴 행이라 락 불필요
SELECT code FROM coupon_code WHERE issue_no = #{issueNo}
얻는 것은 세 가지다.
- 락 왕복 2회 → 1회. 락 보유 시간이
UPDATE실행 시간으로 줄어든다 - 부분 발급 방지가 공짜. affected rows가
quantity보다 작으면 롤백하면 된다. 조회 결과의 size를 코드에서 비교하던 구조는 조회와 판정 사이가 갈라져 있다 ORDER BY pk로 락 획득 순서 고정 → 데드락 예방. 정렬 없는FOR UPDATE는 동시 트랜잭션이 서로 다른 순서로 행을 잡을 수 있다
전제 조건도 있다. (campaign_id, status, id) 처럼 조건절을 커버하는 복합 인덱스가 없으면 UPDATE 가 넓게 스캔하며 불필요한 행과 갭까지 잠가 오히려 악화된다. 그리고 UPDATE ... ORDER BY ... LIMIT 은 statement 기반 복제에서 unsafe 경고가 뜬다(ROW 기반이면 무관).
5단계 — SKIP LOCKED 의 대가
잠긴 행을 건너뛰므로 대기 없이 병렬 발급이 가능해진다. 다만 세 가지가 남는다.
거짓 품절 — 재고는 충분한데 다른 트랜잭션이 잡고 있어 quantity 만큼 모으지 못하면 “재고 부족”으로 실패한다. 특히 그 트랜잭션이 나중에 롤백될 예정이었다면 완전히 헛된 실패다. 재시도로 완화하되, 동시에 재시도가 몰리면 thundering herd가 되므로 백오프와 지터가 필요하다.
스캔 비용 증가 — LIMIT n 을 채우려면 잠긴 행들을 계속 건너뛰며 훑어야 한다. 경합이 심할수록 스캔량이 늘어난다.
락 보유 시간은 그대로 — SKIP LOCKED 가 없애는 건 대기지 보유가 아니다. 커밋까지 잡는 건 변하지 않는다.
6~7단계에서 포기하는 것
| 방법 | 포기하는 것 |
|---|---|
| 재고 세그먼트화 (코드 풀을 N개 버킷으로 분할) | 버킷 간 불균형, 잔량 계산 복잡도 |
| Redis 선점 후 DB 반영 | 정합성 — Redis 장애 시, 선점 후 DB 실패 시 재고 누수 복구 |
| 비동기 발급 (202 응답 + 폴링/콜백) | API 계약 변경 — 동기 응답이 요구사항이면 불가 |
| 파티션 키 기반 단일 라이터 | 락은 사라지지만 운영 복잡도 급증 |
정합성 — 재시도가 만드는 이중 발급
성능을 위해 재시도를 도입하면 멱등성 문제가 따라온다.
타임아웃이 나서 재시도했는데 첫 요청이 사실 서버에서 커밋됐다면, 재시도는 그대로 쿠폰 이중 발급이 된다. 금전 가치가 있는 재고라면 성능보다 이쪽이 먼저다.
issue_request(client_id, client_request_id)에 유니크 제약이 있는가- 중복 요청이 들어오면 에러가 아니라 기존 발급 결과를 그대로 반환하는가
재시도에는 또 다른 위험이 있다. 느려짐 → 타임아웃 → 재시도 → 부하 증가 → 더 느려짐의 순환이 걸리면, 원래 부하가 정상으로 돌아와도 회복되지 않는 상태(metastable failure)에 빠진다. 재시도 예산(retry budget)이나 서킷 브레이커로 증폭을 막아야 한다.
측정 설계
TPS 하나만 보면 병목이 어디인지 알 수 없다.
부하를 어떻게 주고 그 결과를 어떻게 읽을 것인가 자체는 별도 글로 정리했다. 여기서는 이 API에 필요한 부분만 적는다.
부하를 단계적으로 올리며 TPS는 평평한데 응답시간만 오르기 시작하는 지점을 찾는다. 그 지점이 병목 도달점이고, 그 이후 증가분은 전부 큐에 쌓이는 시간이다.
같이 볼 지표는 다음과 같다.
| 계층 | 지표 | 확인 방법 |
|---|---|---|
| 톰캣 | busy threads, 큐 길이 | tomcat.threads.busy (Actuator) |
| 커넥션 풀 | active, pending, 획득 대기 시간 | hikaricp.connections.pending |
| MySQL | Threads_running | SHOW GLOBAL STATUS |
| MySQL | 락 대기 건수·대기 시간 | performance_schema.data_lock_waits |
| MySQL | 락 경합 상세 | SHOW ENGINE INNODB STATUS |
| 응답 | p99 응답시간, 에러율 | 부하 도구 리포트 |
Threads_running 이 DB 코어 수를 넘어가면 DB는 이미 포화 상태다. TPS보다 이 지표가 먼저 신호를 준다.
실험은 조건을 하나씩만 바꿔가며 진행한다.
- 락 구간 안에 인위적 지연(200ms)을 넣었다 뺐다 하며 직렬 구간 길이와 TPS 상한의 관계 확인
- 캠페인 분포를 균등 / 단일 핫 캠페인으로 바꿔 키 분포의 영향 확인
- 커넥션 풀 사이즈를 10 / 30 / 100 / 200으로 바꿔 “키우면 좋아진다”가 성립하는 구간 확인
- 개선을 순서대로 적용(알림 분리 → 캠페인 캐시 → claim UPDATE → 멱등키)하며 각 단계의 TPS/p99 변화 기록
4번이 핵심이다. 개선 항목을 한꺼번에 넣고 “좋아졌다”고 하는 것보다, 어느 개선이 실제로 몇 배를 벌어줬는지가 남는 정보가 된다.
정리
부하가 올라갈 때 무너지는 순서는 자원의 총량이 아니라 구조가 결정한다.
- 스레드 수로 계산한 TPS는 스레드가 병목일 때만 맞는다. 락이 있으면 직렬 구간 길이가 상한을 정한다
- 락 경합은 총 요청량이 아니라 같은 키에 몰리는 정도의 함수다. 부하 테스트 시나리오에서 키 분포를 정하지 않으면 결과가 의미를 갖지 못한다
- 커넥션 풀은 키운다고 좋아지지 않는다. 락 대기 중인 커넥션도 점유 상태라 장애가 다른 API로 번진다
- 큐를 늘리면 지연만 늘어난다. 짧은 큐와 빠른 실패가 나은 경우가 많다
- 개선은 코드 → 설정 → 구조 → 인프라 순으로 간다. 락 구간이 긴 상태에서 서버를 늘리면 경합이 더 심해진다
참고 자료
- About Pool Sizing (github.com/brettwooldridge/HikariCP)
- MySQL 8.0 Reference Manual — Locking Reads (dev.mysql.com)
- MySQL 8.0 Reference Manual — innodb_lock_wait_timeout (dev.mysql.com)
- MySQL 8.0 Reference Manual — UPDATE Statement (dev.mysql.com)
- MySQL 8.0 Reference Manual — data_lock_waits Table (dev.mysql.com)
- Apache Tomcat 9 — The HTTP Connector (tomcat.apache.org)
- MyBatis 3 — Configuration (localCacheScope) (mybatis.org)
- Metastable Failures in Distributed Systems (sigops.org)
- Universal Scalability Law (perfdynamics.com)