Java thread contention 이 높을 때는 JVM 스케줄러가 이상해진 경우보다, 너무 많은 thread 가 같은 shared path 를 두고 경쟁하는 경우가 더 많습니다. 보통은 하나의 hot monitor, 너무 넓은 synchronized block, lock 을 잡은 채 수행하는 느린 I/O, 혹은 특정 critical section 으로 트래픽이 몰리는 설계가 원인입니다.
짧게 말하면 핵심은 이것입니다. thread 수나 JVM 옵션을 먼저 만지기 전에 가장 뜨거운 lock 을 찾아야 합니다. 같은 monitor 를 기다리는 thread 만 늘어나면, concurrency 를 높여도 throughput 은 거의 늘지 않고 contention 만 커질 수 있습니다.
CPU 보다 blocked thread 부터 본다
높은 contention 은 보통 이렇게 보입니다.
- latency spike
- blocked thread 수 증가
- CPU time 이 실제 작업보다 wait 와 coordination 쪽으로 이동
- thread 수를 늘려도 throughput 이 잘 늘지 않음
그래서 contention 은 대개 JVM 튜닝 문제보다 shared-state 설계 문제에 가깝습니다.
첫 질문도 “thread 가 부족한가” 가 아니라 “모두가 어떤 lock 을 두고 경쟁하고 있는가” 여야 합니다.
contention 은 실전에서 어떻게 보이나
운영 환경에서는 다음 같은 모습이 흔합니다.
- synchronized cache 나 map 이 hotspot 이 된다
- lock 을 잡은 채 remote work 를 수행한다
- 여러 요청 경로가 같은 shared object 를 갱신한다
- pool size 는 커졌는데 응답 시간은 좋아지지 않는다
- thread dump 에서 같은 monitor 를 기다리는 thread 가 반복해서 보인다
동일한 lock 이름이나 object 주소가 blocked stack trace 에 계속 나오면, capacity 부족보다 설계 병목일 가능성이 높습니다.
흔한 원인
1. 하나의 synchronized section 이 너무 뜨겁다
애플리케이션의 많은 트래픽이 하나의 lock 으로 몰릴 수 있습니다.
예를 들면:
- 공용 cache wrapper
- 전역 registry
- singleton state holder
- 요청량이 많은 경로의 synchronized logging 또는 metrics 코드
critical section 하나하나는 짧아 보여도, 트래픽이 많으면 그 monitor 가 전체 병목이 될 수 있습니다.
2. lock hold time 이 너무 길다
lock 횟수보다 더 중요한 경우가 많습니다.
thread 가 synchronized block 안에 들어간 뒤 비싼 계산, serialization, 혹은 느린 downstream 작업을 하면, 기다리는 모든 thread 가 그 긴 hold time 비용을 같이 부담하게 됩니다.
synchronized (cache) {
return remoteClient.fetch(key);
}
synchronized block 안의 remote call 하나만으로도 blocked thread 수가 예상보다 훨씬 빨리 늘 수 있습니다.
3. 더 많은 thread 가 같은 병목을 증폭한다
latency 가 늘면 pool size 나 concurrency 를 올리고 싶어지지만, 추가된 thread 가 모두 같은 lock 을 기다린다면 throughput 은 늘지 않습니다.
대신 이런 결과가 생깁니다.
- blocked thread 증가
- scheduling overhead 증가
- memory pressure 증가
- 증상만 더 시끄러워짐
4. downstream wait 가 lock 안에서 발생한다
가장 비용이 큰 contention 패턴 중 하나입니다.
코드 리뷰만 보면 harmless 해 보여도, protected block 안에 아래 같은 작업이 들어 있으면 장애 때 contention 이 급격히 커집니다.
- database call
- HTTP request
- disk 또는 object storage access
- queue wait
- retry
5. shared-state scope 가 필요 이상으로 넓다
노골적으로 느린 작업이 없더라도, 너무 많은 코드가 같은 lock 아래 들어가 있으면 문제가 됩니다.
예를 들면:
- validation 과 computation 이 모두 synchronized section 안에 있다
- 서로 무관한 여러 필드가 하나의 monitor 를 공유한다
- read 와 write 가 같은 큰 lock 을 쓴다
이 경우 lock 구현을 바꾸기보다 critical section 을 줄이는 쪽이 더 효과적일 때가 많습니다.
실전 점검 순서
contention 이 눈에 보일 때는 보통 아래 순서가 빠릅니다.
1. 느려지는 순간의 thread dump 를 잡는다
여기서 볼 것은:
BLOCKED상태 thread 가 많은가- 같은 stack 이 반복해서 monitor owner 로 보이는가
- 같은 lock 이름이나 object 주소가 계속 등장하는가
즉, 가장 뜨거운 shared resource 가 무엇인지 찾는 단계입니다.
2. lock 시간의 대부분이 어디서 쓰이는지 본다
질문은 이렇습니다.
- lock 을 얼마나 오래 잡고 있는가
- 그동안 어떤 코드가 실행되는가
- CPU 작업인가, downstream wait 인가
핵심은 “짧지만 자주 잡는 lock” 인지, “덜 자주 잡아도 너무 오래 잡는 lock” 인지 구분하는 것입니다.
3. critical section 안의 I/O 또는 retry 를 찾는다
lock 안에서 외부 시스템을 기다린다면, contention 은 그 외부 시스템이 느려지는 순간 바로 악화됩니다.
즉, 실제 fix 는 lock 코드 바깥에 있을 수 있습니다.
4. thread 증가와 throughput 증가를 같이 본다
thread 수는 늘어나는데 throughput 이 평평하다면, worker capacity 보다 shared state 가 병목일 가능성이 큽니다.
이건 concurrency 추가가 정답이 아니라는 강한 신호입니다.
5. JVM 옵션보다 shared-state scope 를 먼저 줄인다
hot path 를 찾은 뒤에는 coordination 이 필요한 범위를 줄여야 합니다.
- synchronized block 축소
- 독립적인 state 분리
- 느린 작업을 lock 밖으로 이동
- 완전한 mutual exclusion 이 정말 필요한지 재검토
예시: 하나의 hot cache lock
public String load(String key) {
synchronized (cache) {
String value = cache.get(key);
if (value == null) {
value = remoteClient.fetch(key);
cache.put(key, value);
}
return value;
}
}
겉으로는 안전해 보이지만 cache miss 가 날 때 remote call 이 lock 안에서 수행됩니다. 트래픽이 몰리면 여러 thread 가 같은 miss path 뒤에 줄을 설 수 있습니다.
더 나은 방향은 대체로 이렇습니다.
- lock 안에서는 cache 상태만 최소한으로 확인
- 가능하면 remote fetch 는 lock 밖에서 수행
- synchronized block 범위를 줄이기
hotspot 을 찾은 뒤 무엇을 바꾸면 좋나
critical section 을 짧게 만든다
대개 가장 효과가 큰 수정입니다.
shared-state mutation 에 꼭 필요한 최소 작업만 lock 안에서 하고, 비싼 작업은 밖으로 빼는 편이 좋습니다.
무관한 state 를 분리한다
하나의 lock 이 너무 많은 필드나 workflow 를 보호하고 있다면, ownership 을 나눠서 독립적인 트래픽이 서로 직렬화되지 않게 해야 합니다.
lock 안의 blocking downstream work 를 제거한다
network 나 database 작업이 있다면, 우선 그 경로부터 다시 설계하는 편이 맞습니다.
thread 증가가 정말 도움이 되는지 다시 본다
병목이 hot lock 이라면 thread 를 늘릴수록 증상만 악화될 때가 많습니다.
deadlock 으로 번지는 패턴도 같이 본다
심한 contention 은 때로 lock cycle 을 가리거나 deadlock 직전 상태로 이어질 수 있습니다. 여러 lock 이 엮여 서로 기다리는 구조라면 더 이상 단순 contention 만의 문제는 아닙니다.
장애 중에 던져볼 질문
이 질문이 꽤 유용합니다.
내일 요청량이 두 배가 되면, 가장 먼저 줄이 생길 lock 은 어디인가?
이 질문을 던지면 정말 중요한 shared path 가 빨리 드러납니다.
FAQ
Q. thread 를 더 늘리는 게 맞나
같은 lock 을 기다리는 thread 만 늘어난다면 보통은 아닙니다.
Q. 이건 JVM 튜닝 문제인가
대개 처음부터 그렇지는 않습니다. 많은 thread contention 문제는 애플리케이션 수준의 shared-state 설계에서 시작합니다.
Q. 가장 빠른 첫 단계는 무엇인가
thread dump 에서 가장 뜨거운 monitor 를 찾고, 그 lock 안에서 무슨 일이 일어나는지 보는 것입니다.
Q. 높은 contention 이면 항상 deadlock 인가
아닙니다. contention 은 대개 진행은 되지만 느린 상태이고, deadlock 은 아예 진행이 멈춘 상태입니다.
Read Next
- blocked pattern 이 hot lock 보다 true cycle 에 가깝다면 Java Thread Deadlock 를 이어서 보세요.
- contention 이 executor saturation 으로도 이어진다면 Java ExecutorService Tasks Stuck 와 함께 보세요.
- CPU 도 같이 높다면 Java JVM CPU High 와 비교해 보는 편이 좋습니다.
- 전체 Java 운영 이슈 지도는 Java Troubleshooting Guide 에서 이어서 볼 수 있습니다.
Related Posts
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.