포스트

조회수 UPDATE 하나가 전체 API를 멈춘 이유 - 락 경합과 커넥션 풀 고갈 재현

핫 로우 UPDATE 경합이 어떻게 커넥션 풀을 고갈시키고 무관한 API까지 죽이는지 직접 측정해봤습니다.

조회수 UPDATE 하나가 전체 API를 멈춘 이유 - 락 경합과 커넥션 풀 고갈 재현

상황

특정 상품 하나에 트래픽이 몰린 적이 있습니다.

  • 조회수는 요청마다 UPDATE product SET view_count = view_count + 1 WHERE id = ?
  • 평소엔 요청이 여러 상품에 흩어져서 문제가 없었음
  • 한 상품에 몰리자 조회수와 아무 상관 없는 API까지 같이 느려짐

당시엔 “DB가 부하를 못 견딘다”고 생각했는데, MySQL CPU는 그렇게 높지 않았습니다. 이게 계속 걸려서 재현 환경을 만들어 직접 측정해봤습니다.

앞서 비관락 vs 낙관락 vs Redis 분산락 비교에서는 “오버부킹을 어떻게 막을 것인가”를 다뤘다면, 이번 글은 락을 거는 것 자체가 시스템에 어떤 부작용을 남기는가에 대한 기록입니다.

실습 환경

일부러 고갈이 빨리 보이도록 커넥션 풀을 작게 잡았습니다.

항목
MySQL8.0 (Docker)
innodb-lock-wait-timeout10초
HikariCP maximum-pool-size5
HikariCP connection-timeout3000ms
Tomcat threads.max200

핵심은 풀 크기 5 / 커넥션 타임아웃 3초 두 줄입니다. 이 조합 때문에 뒤에서 흥미로운 게 나옵니다.

측정 대상 코드는 이게 전부입니다.

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void increaseByDb(long productId) {
    jdbc.update("UPDATE product SET view_count = view_count + 1 WHERE id = ?", productId);
}

// 실제 서비스는 UPDATE 뒤에도 작업이 이어진다. 그만큼 락을 더 오래 쥔다.
@Transactional
public void increaseByDbSlow(long productId, int extraMillis) {
    jdbc.update("UPDATE product SET view_count = view_count + 1 WHERE id = ?", productId);
    sleep(extraMillis);
}

부하는 curl + xargs -P 로 걸었고, 응답 시간은 curl%{time_total} 값을 정렬해서 p95를 뽑았습니다.

아래 표의 평균/p95는 실패한 요청의 시간도 포함한 값입니다. 3초에 커넥션을 못 받고 터진 요청도 3.0초로 집계에 참여합니다.

측정 결과

300건 / 동시 50 기준입니다.

실험평균p95실패활성 커넥션(max)대기 스레드(max)
1. 요청 분산 (20개 상품)0.375s0.975s0545
2. 핫 로우 (id=1 집중)0.510s1.500s0545
3. 핫 로우 + 30ms 지연2.501s3.460s60건545
3b. 핫 로우 + 50ms 지연3.042s3.801s101건545
5. Redis INCR0.131s0.246s010

해석 1. 락은 쿼리를 느리게 만들지 않는다, 줄을 세울 뿐이다

실험 1과 2를 비교하면 평균보다 p95가 훨씬 크게 벌어집니다.

 평균p95
분산0.375s0.975s
핫 로우0.510s (+36%)1.500s (+54%)

락 경합은 “모든 요청이 조금씩 느려지는” 현상이 아닙니다. 대부분은 멀쩡하고 줄 뒤에 선 일부가 심하게 밀립니다. 그래서 평균만 보면 놓치고, p95가 먼저 반응합니다.

이 단계에서는 실패가 하나도 없습니다. 아직은 “조금 느린 서비스”일 뿐입니다.

해석 2. 트랜잭션 길이가 임계점을 넘기는 스위치다

UPDATE 뒤에 지연을 붙이자 성격이 바뀝니다.

지연실패
0ms0건
30ms60건 (20%)
50ms101건 (34%)

30ms 붙였다고 30ms만큼 느려지는 게 아닙니다. 락을 쥐고 있는 시간이 곧 뒤차의 대기 시간이라, 대기가 곱해집니다.

실무에서 트랜잭션 안에 외부 API 호출이나 파일 업로드를 넣지 말라는 이유가 이거였습니다. 그게 몇십 ms면 충분합니다. 트랜잭션 전파와 격리 수준을 정리하면서 “트랜잭션 범위를 좁게 잡아라”는 말을 여러 번 썼는데, 그 대가가 숫자로 나온 셈입니다.

해석 3. 진짜 범인은 락 타임아웃이 아니라 커넥션 타임아웃

여기가 이번 재현에서 가장 의외였던 부분입니다.

실패 응답의 예외를 직접 찍어봤습니다.

1
2
3
4
5
=== /view/db (@Transactional) ===
FAIL / CannotCreateTransactionException

=== /health/db (단순 조회, 트랜잭션 없음) ===
FAIL / CannotGetJdbcConnectionException

둘 다 커넥션을 못 받아서 난 예외입니다. 락 대기 타임아웃(innodb-lock-wait-timeout)은 단 한 건도 발생하지 않았습니다.

이유는 설정을 다시 보면 명확합니다.

  • 커넥션 타임아웃: 3초
  • 락 대기 타임아웃: 10초

락을 10초 기다리기 전에, 커넥션을 3초 못 받아서 먼저 터집니다. 실제로 실험 3/3b의 최대 응답이 3.9초 / 4.1초에서 멈추는 것도 이 3초 벽 때문입니다.

정리하면 이렇게 됩니다.

  1. 핫 로우에 X-Lock 경합 발생
  2. 락을 기다리는 스레드가 커넥션을 쥔 채로 대기
  3. 풀(5개)이 전부 대기 상태에 묶임 → 활성 5 / 대기 45
  4. 새 요청은 커넥션을 못 받고 3초 뒤 실패

락이 커넥션을 잡아먹는 게 문제지, 락 자체가 느린 게 아닙니다.

해석 4. 그래서 무관한 API까지 죽는다

이번 재현의 목적이었던 부분입니다. 조회수와 아무 상관 없는 단순 조회 API(/health/db)를 부하 중에 호출해봤습니다.

1
2
3
4
5
6
7
8
9
10
활성/대기  코드  응답시간
5/75      200   3.035s
5/75      200   2.925s
5/75      500   3.003s
5/71      200   2.870s
5/69      500   3.005s
5/35      200   1.991s
3/0       200   0.042s   <- 부하 종료
1/0       200   0.144s
0/0       200   0.132s

평소 0.04초짜리 조회가 부하 중엔 3.0초가 되고, 일부는 500으로 죽습니다.

이 API는 락을 쓰지도, 문제의 테이블을 수정하지도 않습니다. 단지 같은 커넥션 풀을 공유할 뿐입니다.

당시 “조회수 하나 때문에 전체가 느려졌다”고 느낀 현상의 정체가 이거였습니다. 장애는 락을 타고 번진 게 아니라 커넥션 풀을 타고 번졌습니다.

해석 5. CPU는 안 오른다 (진단 단서)

락 대기 중인 스레드는 블로킹 상태라 CPU를 쓰지 않습니다.

실험 3b는 실패율 34%에 평균 3초였지만, MySQL 컨테이너 CPU는 평균 44% 였습니다. 응답이 8배 나빠지는 동안 CPU는 그만큼 오르지 않았습니다.

증상원인
CPU 높음 + 쿼리 느림연산 부하 (풀스캔, 정렬, 트래픽 급증)
CPU 낮음 + 쿼리 느림대기 문제 (락, I/O, 네트워크)

CPU 그래프만 보고 “DB는 여유 있는데?” 하고 넘어가면 영영 못 찾습니다.

해석 6. Redis INCR 비교

같은 부하를 Redis INCR 로 받으면 이렇게 됩니다.

 DB 직접 UPDATERedis INCR
평균0.510s0.131s
p951.500s0.246s
활성 커넥션5 (포화)1
DB 쓰기300회0회
  • INCR 은 원자적 연산이라 별도 락이 필요 없습니다
  • DB 쓰기가 아예 발생하지 않으니 커넥션을 잡지 않습니다
  • 쌓인 값은 스케줄러가 5초마다 DB로 옮깁니다
1
2
3
4
5
6
@Scheduled(fixedDelay = 5000)
public void flushToDb() {
    // ...
    jdbc.update("UPDATE product SET view_count = view_count + ? WHERE id = ?", delta, productId);
    redis.opsForValue().decrement(key, delta);   // 반영한 만큼만 차감해야 그 사이 증가분이 안 날아간다
}

DB 쓰기 횟수가 “요청 수”에서 “주기 수”로 줄어드는 것이 핵심입니다. 300건이 1건이 됩니다.

대신 포기하는 것

공짜가 아닙니다. 정합성이 실시간에서 최종적 일관성으로 내려갑니다.

  • 최대 5초간 DB 값과 실제 조회수가 다릅니다
  • Redis가 죽으면 아직 flush 안 된 카운트는 사라집니다

그래서 판단 기준은 이렇게 잡았습니다.

  • 조회수, 좋아요 수 → 괜찮습니다. 5초 늦어도, 몇 건 틀려도 사고가 아닙니다.
  • 재고, 잔액, 포인트 → 절대 안 됩니다. 여기선 락을 제대로 걸거나 다른 설계를 써야 합니다.

측정하면서 틀렸던 것

부끄럽지만 남겨둡니다. 둘 다 하마터면 틀린 결론을 낼 뻔했습니다.

1. 첫 번째 측정값을 그대로 믿을 뻔했다

처음 측정한 표는 이랬습니다.

 평균p95
1. 분산 (첫 측정)0.888s2.197s
2. 핫 로우 (첫 측정)0.420s0.953s

락 경합이 있는 쪽이 2배 빠릅니다. 그대로 썼으면 “핫 로우가 더 빠르다”는 글이 나올 뻔했습니다.

원인은 JIT 워밍업이었습니다. 실험 1이 그날의 첫 실행이라 예열 비용을 혼자 다 뒤집어썼습니다. 전부 예열한 뒤 다시 재니 순서가 정상으로 돌아왔고(0.375s vs 0.510s), 이 글의 표는 재측정값입니다.

부하 테스트는 첫 회차를 버려야 합니다.

2. 장애 전파가 한 번에 재현되지 않았다

동시 60으로 걸었을 땐 /health/db 가 25건 전부 200에 평균 0.040초였습니다. 멀쩡했습니다.

동시 80으로 올리니 그제서야 3초로 늘어지고 500이 나왔습니다. 대기 스레드가 45에서 75로 넘어가는 구간이었습니다.

장애 전파에는 임계점이 있습니다. 풀에 여유가 조금이라도 남아 있으면 신규 요청은 그럭저럭 통과합니다. 완전히 포화된 다음에야 무너집니다. 부하를 살짝 걸어보고 “괜찮네” 하면 안 되는 이유입니다.

정리

  1. 락은 쿼리를 느리게 만들지 않는다. 줄을 세울 뿐이다. 그래서 평균보다 p95가 먼저 반응한다.
  2. 대기가 커넥션을 점유한다. 이게 진짜 문제다.
  3. 커넥션 풀이 고갈되면 무관한 API도 죽는다. 장애는 락이 아니라 풀을 타고 번진다.
  4. 락 타임아웃보다 커넥션 타임아웃이 먼저 터진다. 로그에서 찾아야 할 건 CannotGetJdbcConnectionException 쪽이다.
  5. CPU가 낮은데 느리면 대기를 의심한다.
  6. Redis INCR은 DB 쓰기를 요청 수에서 주기 수로 줄인다. 대신 정합성이 최종적 일관성으로 내려간다. 조회수는 되고 잔액은 안 된다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.