애플리케이션 레벨에서 부하 테스트와 쿼리 튜닝을 진행하면서 APM 도구로 병목 지점을 모니터링하던 중, 애플리케이션이 처음 실행될 때 예측하지 못한 지표들이 포착되었다.
특히 테스트 초반에는 동일한 조건임에도 불구하고 응답 속도가 눈에 띄게 느렸음.
이 현상의 원인을 파악하다가 JVM의 웜업(warm-up) 과정과 JIT(Just-In-Time) 컴파일러가 성능에 미치는 영향이 크다는 걸 알게 되었고,
이번 글에서는 JVM이 실행 초기 성능을 끌어올리는 방식과, 자주 호출되는 API 요청에 대해 어떤 최적화 전략을 적용하는지 정리해보자.
아래 시리즈를 읽었다고 가정하고 작성함.
JIT(Just-In-Time) 컴파일러는 JVM이 애플리케이션을 실행하면서 성능을 동적으로 최적화하는 핵심 요소다.
핫스팟(HotSpot)
코드로 인식한다.JVM의 웜업과 JIT Compiler를 이해하려면 컴파일과 인터프리트가 어떻게 작동하는지 부터 짚고 넘어갈 필요가 있다.
❓ Java 는 컴파일 언어인가 인터프리터 언어인가?
👉 정답은 컴파일 언어와 인터프리터 언어의 특징을 가지고 있다.
그러면 Java는 왜 이 두가지 특징을 가지고 있을까?
요약하면, Java는 컴파일 언어처럼 중간 코드를 생성해 플랫폼 독립성을 확보하고, 인터프리터 언어처럼 런타임에 실행되며 유연성과 최적화 기회를 확보하려는 전략을 선택 그리고 이 두 장점을 융합한 게 바로 JVM + JIT 구조이다.
JVM 웜업은 말 그대로 Java 애플리케이션이 최적의 성능을 낼 수 있도록 준비하는 과정이다. 운동 전에 스트레칭으로 몸을 풀듯, 애플리케이션도 본격적으로 요청을 처리하기 전에 JVM이 내부적으로 성능을 끌어올리는 준비 동작을 수행한다.
JVM 웜업이 중요한 이유는 단순히 처음엔 느리다
가 아니라, 애플리케이션의 초기 상태에서는 성능을 제대로 끌어올릴 수 없는 구조적인 이유가 있다.
Java의 클래스 로딩은 Lazy Loading 방식으로 동작한다.
즉, 모든 클래스를 애플리케이션 시작 시 한꺼번에 로드하지 않고, 해당 클래스가 실제로 처음 호출될 때 메모리에 올라간다.
따라서 배포 직후 대부분의 클래스가 아직 메모리에 올라와 있지 않다면,
초기 요청 시 이 로딩 작업이 겹쳐져 응답 지연이 발생한다.
JVM은 처음엔 인터프리터 방식으로 코드를 한 줄씩 해석해서 실행한다.
이 방식은 느리지만, 빠르게 실행을 시작할 수 있다는 장점이 있다.
하지만 반복적으로 실행되는 핫스팟
코드가 감지되면,
JIT 컴파일러가 해당 코드를 네이티브 코드(기계어)로 변환하여 성능을 대폭 끌어올린다.
문제는, 애플리케이션 초기에는 JIT 컴파일이 거의 이루어지지 않았기 때문에
모든 코드가 상대적으로 느린 인터프리터 방식으로 동작한다는 점이다.
JIT 컴파일러는 단순히 바이트코드를 기계어로 변환하는 데 그치지 않는다.
실행 중에 수집한 다양한 프로파일링 정보를 바탕으로, 성능을 극대화하기 위한 정교한 최적화 작업을 수행한다.
대표적인 실행 정보는 다음과 같다
if/else
나 switch
문에서 어떤 경로가 자주 실행되는지애플리케이션이 막 시작된 시점엔, JIT이 참고할 실행 데이터가 부족해서 제대로 된 최적화가 이뤄지지 않는다.
아래는 JVM 웜업 동안 내부에서 일어나는 주요 동작들이다.
Java는 Lazy Class Loading 방식을 사용하기 때문에, 실제로 필요한 시점이 되어야 해당 클래스가 JVM 메모리에 로드된다.
하지만 이 로딩 과정은 비용이 꽤 크다. 클래스를 로드하고, 링크하고, static 초기화를 실행하는 등 복잡한 과정이 포함되어 있기 때문이다.
그래서 웜업 과정에서는 주요 로직에서 사용되는 클래스들을 의도적으로 먼저 호출해서 미리 메모리에 로드되도록 유도한다.
JVM은 처음엔 바이트코드를 인터프리터로 실행하지만 같은 메서드가 일정 횟수 이상 호출되면 HotSpot
으로 간주해서 JIT 컴파일을 적용한다.
이 과정을 수동으로 유도해 JIT 컴파일이 빨리 일어나도록 만들 수 있다.
예를 들어, 자주 쓰이는 API나 로직을 반복 호출해서 JVM이 이건 자주 쓰이는 코드다
라고 인식하게 하는 것.
JIT 컴파일러가 만든 기계어(native code)는 일회성으로 사라지지 않는다. JVM은 이 코드를 Code Cache라는 공간에 저장해서, 이후에도 빠르게 재사용한다.
웜업을 통해 주요 메서드들이 미리 컴파일되고 캐시에 적재되면, 실제 사용자 요청이 들어왔을 때 인터프리팅 없이 곧바로 기계어로 실행할 수 있다.
즉, 초기 지연 없이 바로 고성능으로 응답할 수 있게 되는 것이다. 캐시가 부족할 경우 CodeCache is full 경고가 발생하며, 성능 저하 원인이 될 수 있음.
JVM은 C1과 C2라는 두 가지 JIT 컴파일러를 사용해 코드의 중요도
와 실행 빈도
에 따라 다단계 최적화를 적용한다. 이걸 계층형 컴파일이라 부른다.
컴파일러 | 특징 | 트리거 기준 |
---|---|---|
C1 컴파일러 | 빠른 컴파일, 낮은 최적화 수준 | 메서드 호출 1,500회 이상 |
C2 컴파일러 | 느린 컴파일, 높은 최적화 수준 | 메서드 호출 10,000회 이상 |
웜업 과정에서 해당 메서드를 의도적으로 반복 호출하면, C1 → C2 순서로 점점 최적화가 이루어지면서, 해당 코드의 실행 속도는 눈에 띄게 향상된다.
JVM 웜업은 단순히 느린 시작을 감수하는 단계
가 아니다. 웜업 시간이 길수록 더 많은 코드가 JIT 컴파일러에 의해 최적화되기 때문에, 애플리케이션 전반의 성능을 끌어올리는 데 중요한 역할을 한다.
하지만 웜업이 길어질수록 초기 구동 시간도 길어지므로, 성능과 배포 속도 사이의 균형을 맞추는 게 핵심이다.
실제 사례 연구: 카카오 모빌리티의 JVM 웜업 적용
카카오 모빌리티에서는 JVM 웜업의 효과를 정량적으로 분석하기 위해 핵심 메서드를 여러 번 호출하는 방식으로 웜업 카운트를 조절해봤다.
- 50회, 100회, 250회, 500회 등 다양한 횟수로 테스트한 결과
- 250회 호출이 가장 효율적인 웜업 포인트라는 결론이 나왔다.
이 수치는 C1 JIT 컴파일러가 작동하는 기준(기본 1,500회 호출)보다는 낮지만, 성능 향상과 구동 시간 증가 사이에서 가장 이상적인 타협점이었다.
JVM 웜업을 적극적으로 적용할수록 성능은 좋아질 수 있지만, 그에 따른 단점이나 위험 요소도 함께 고려해야 한다.
웜업 스크립트에서 문제가 발생하면 실제 애플리케이션은 정상이어도 배포가 실패할 수 있다. 특히 자동화된 배포 환경(Kubernetes, GitOps 등)에서는 치명적일 수 있다.
웜업 과정에서 외부 API를 반복 호출하면 DDoS처럼 외부 시스템에 과도한 부하를 줄 수 있다. 특히 SaaS API나 민감한 백엔드 연동 환경에선 주의가 필요하다.
웜업 자체가 애플리케이션 시작 시점에 실행되기 때문에, 구동 시간이 길어질 수밖에 없다. 마이크로서비스 구조에선 각 서비스의 웜업 시간이 누적되며 전체 배포 속도가 떨어질 수 있음.
🧐 그래서 어떻게 해야 할까?
JVM 웜업은 무조건 적용하는 것이 아니라,내 애플리케이션에 맞는 수준에서 전략적으로 적용
하는 게 핵심이다.
- 핵심 로직만 웜업할지, 전체 경로를 커버할지 판단
- 웜업 실행 시점과 배포 시점 분리 고려 (예: readiness probe 이후에 웜업 수행)
- 외부 API 호출은 mock 처리 또는 내부 호출로 대체
JIT 컴파일러와 코드 캐싱 메커니즘에 대해서 좀 더 자세히 알아보자.
💡 HotSpot VM과 JIT 컴파일러의 관계
JIT 컴파일러는 독립적인 외부 도구가 아니라, HotSpot JVM 내부에 포함된 핵심 컴포넌트다. 우리가 일반적으로 사용하는 Oracle JDK나 OpenJDK는 대부분 HotSpot 기반 JVM을 사용한다.
HotSpot VM에는 두 가지 JIT 컴파일러가 포함되어 있다:
계층형 컴파일(Tiered Compilation)
Java 7부터는 계층형 컴파일(Tiered Compilation)이 도입되었고, Java 8부터는 이것이 기본 동작 방식이 되었다. 계층형 컴파일은 C1과 C2 컴파일러를 함께 사용하여 시작 시간과 장기 실행 성능 사이의 균형을 맞추는 방식이다.
계층형 컴파일은 다음과 같은 단계로 이루어진다:
- 레벨 0: 인터프리터 모드로 코드 실행
- 레벨 1: 간단한 C1 컴파일(프로파일링 없음)
- 레벨 2: 제한된 프로파일링을 사용한 C1 컴파일
- 레벨 3: 전체 프로파일링을 사용한 C1 컴파일
- 레벨 4: C2 컴파일(최고 수준의 최적화)
코드는 일반적으로 이러한 단계를 순차적으로 거치며, 각 단계에서 수집된 프로파일링 정보는 다음 단계의 최적화에 활용된다. 이를 통해 애플리케이션은 빠르게 시작하면서도 점진적으로 최고 수준의 성능에 도달할 수 있다.
코드 캐시는 JIT 컴파일러가 만든 기계어 코드를 저장해두는 전용 공간이다.
한 번 컴파일된 코드를 다시 쓰기 위해 JVM이 따로 마련해둔 저장소라고 생각하면 된다.
🧐 그럼 코드 캐시는 어디에 저장될까?
- 코드 캐시는 JVM 힙 메모리와는 전혀 별개의 공간이다.
- JVM이 OS로부터 따로 메모리를 할당받아 관리하는
네이티브 메모리 영역
에 존재한다.- Java 코드에서는 직접 접근할 수 없다. GC의 대상도 아니다.
이번 글에서는 JVM이 애플리케이션 실행 초기에 어떤 동작을 하고, 왜 첫 요청이 유난히 느릴 수밖에 없는지, 그리고 이를 어떻게 개선할 수 있는지를 다뤘다.
웜업을 통해 미리 클래스 로딩과 JIT 컴파일을 유도하면 초기 응답 속도 저하 문제를 예방할 수 있다.
결과적으로 안정적인 사용자 경험과 더 나은 시스템 성능을 확보할 수 있다.