Java Thread Contention High: 흔한 원인과 해결 방향
마지막 업데이트

Java Thread Contention High: 흔한 원인과 해결 방향


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 은 아예 진행이 멈춘 상태입니다.


Sources:

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.