Datadog의 Continuous Profil (새 탭에서 열림)

Datadog은 자바 기반 어플리케이션에서 Akka 프레임워크를 사용할 때 발생하는 예상치 못한 CPU 사용량 급증 문제를 조사하였으며, 그 원인이 ForkJoinPool의 비효율적인 스레드 관리임을 밝혀냈습니다. 불규칙한 작업 흐름을 가진 액터(Actor)가 과도한 스레드 활성화 및 비활성화를 유발하여 전체 CPU의 20~30%를 낭비하고 있었으나, 이를 안정적인 작업 부하를 가진 디스패처로 이동시킴으로써 문제를 해결했습니다. 이 사례는 추상화된 프레임워크 하부의 스레드 풀 동작 원리를 이해하고 프로파일링을 통해 실질적인 병목 지점을 찾는 것이 얼마나 중요한지 보여줍니다.

프로파일링을 통한 병목 지점 탐색

  • 로그 파싱 알고리즘을 최적화했음에도 불구하고 CPU 사용량이 줄어들지 않거나 오히려 늘어나는 현상이 발생하여 Datadog Continuous Profiler를 통해 심층 분석을 수행했습니다.
  • 플레임 그래프(Flame Graph) 분석 결과, 실제 비즈니스 로직이 아닌 ForkJoinPool.scan()Unsafe.park() 메서드에서 상당한 CPU 시간이 소비되고 있음을 확인했습니다.
  • 특정 스레드 풀의 상태를 조사한 결과, 별도의 설정 없이 기본 Akka 디스패처를 사용하는 LatencyReportActor가 이 현상의 주범으로 지목되었습니다.

ForkJoinPool의 동작 원리와 병목 원인

  • ForkJoinPool은 대기 중인 작업 수에 따라 내부 워커 스레드를 동적으로 생성, 중지(park), 재개(unpark)하며 활성 스레드 수를 관리합니다.
  • 작업 흐름이 불규칙할 경우 스레드를 빈번하게 깨우고 다시 재우는 과정에서 비용이 큰 네이티브 호출인 Unsafe.park()unpark()가 대량으로 발생하여 CPU를 낭비하게 됩니다.
  • 문제의 액터는 매초 짧은 시간 동안만 데이터를 처리하고 나머지 시간은 대기하는 특성을 가졌는데, 이때마다 CPU 코어 수만큼 설정된 32개의 스레드가 일시에 깨어났다가 다시 잠드는 현상이 반복되었습니다.

설정 변경을 통한 성능 최적화

  • 불규칙한 작업을 수행하는 액터를 기본 디스패처에서 이미 안정적인 작업 흐름을 유지하고 있는 메인 'work' 디스패처로 이동시키는 단 한 줄의 설정 변경을 적용했습니다.
  • 이 변경을 통해 ForkJoinPool.scan()에서 소비되는 CPU 시간이 급격히 감소하였으며, 서비스 전체의 평균 CPU 사용량이 약 30% 줄어드는 성과를 거두었습니다.
  • 또한 32개까지 치솟았던 기본 디스패처의 스레드 풀 크기가 2개로 줄어들어 불필요한 컨텍스트 스위칭과 리소스 낭비가 해결되었음을 확인했습니다.

효율적인 ForkJoinPool 관리를 위한 권장 사항

ForkJoinPool이나 Akka를 사용하는 환경에서 성능을 유지하려면 ForkJoinPool.scan() 메서드의 CPU 점유율을 모니터링해야 합니다. 만약 이 수치가 10~15%를 초과한다면 다음과 같은 조치를 고려하십시오.

  • 액터 인스턴스 제한: 불필요하게 많은 액터가 생성되지 않도록 조절합니다.
  • 스레드 풀 최대치 제한: 워크로드에 맞춰 스레드 풀의 최대 크기를 적절히 제한합니다.
  • 스레드 풀 통합: 여러 개의 풀을 사용하기보다 풀의 개수를 줄여 작업 부하가 골고루 분산되도록 유도합니다.
  • 태스크 큐 활용: 급격한 작업 급증(Spike)을 완충할 수 있는 큐를 도입하여 스레드 풀이 급격하게 변동하는 것을 방지합니다.