[JVM] JVM 의 웜업과 JIT Compiler

이영재·2025년 5월 28일
0

JVM

목록 보기
5/5
post-thumbnail

0. 들어가며

애플리케이션 레벨에서 부하 테스트와 쿼리 튜닝을 진행하면서 APM 도구로 병목 지점을 모니터링하던 중, 애플리케이션이 처음 실행될 때 예측하지 못한 지표들이 포착되었다.
특히 테스트 초반에는 동일한 조건임에도 불구하고 응답 속도가 눈에 띄게 느렸음.

이 현상의 원인을 파악하다가 JVM의 웜업(warm-up) 과정과 JIT(Just-In-Time) 컴파일러가 성능에 미치는 영향이 크다는 걸 알게 되었고,
이번 글에서는 JVM이 실행 초기 성능을 끌어올리는 방식과, 자주 호출되는 API 요청에 대해 어떤 최적화 전략을 적용하는지 정리해보자.

아래 시리즈를 읽었다고 가정하고 작성함.

1. 인터프리터와 JIT 컴파일러

JIT 컴파일러란?

JIT(Just-In-Time) 컴파일러는 JVM이 애플리케이션을 실행하면서 성능을 동적으로 최적화하는 핵심 요소다.

  • 처음에는 바이트코드를 인터프리터 방식으로 한 줄씩 해석하면서 실행한다.
  • 그런데 같은 메서드나 코드 블록이 자주 실행되면 JVM은 이를 핫스팟(HotSpot) 코드로 인식한다.
  • 이때 JIT 컴파일러가 등장해, 해당 코드를 기계어 수준의 네이티브 코드로 변환하고, JVM 내부의 코드 캐시에 저장해둔다.
  • 이후 동일한 코드가 다시 실행될 때는 더 이상 인터프리팅 없이 기계어로 바로 실행되므로 훨씬 빠르게 처리된다.

JVM의 웜업과 JIT Compiler를 이해하려면 컴파일과 인터프리트가 어떻게 작동하는지 부터 짚고 넘어갈 필요가 있다.

인터프리터? 컴파일러?

❓ Java 는 컴파일 언어인가 인터프리터 언어인가?
👉 정답은 컴파일 언어와 인터프리터 언어의 특징을 가지고 있다.

  • 컴파일 : 인간이 작성한 고급언어(소스코드)를 컴퓨터가 실행가능한 형식의 기계어로 변환하는 작업
    • ex) Java, C, C++, Go 등
  • 인터프리트 : 코드를 한 줄씩 읽어 들이면서 즉시 실행하는 방식(소스코드를 직접 해석)
    • Python, JavaScript 등

그러면 Java는 왜 이 두가지 특징을 가지고 있을까?

  • 컴파일러의 특징: 소스 코드를 바이트코드로 변환 (정적 컴파일)
    • .java 파일을 javac로 컴파일하면 바이트코드(.class 파일)가 생성됨.
    • 이 바이트코드는 기계어가 아님. 대신 JVM이 이해할 수 있는 중간 단계의 코드임.
    • 즉, Java는 한 번 컴파일되지만, 그 결과물은 OS나 CPU에 종속되지 않음.
  • 인터프리터의 특징: JVM이 바이트코드를 읽고 해석 (동적 인터프리팅)
    • 컴파일된 바이트코드는 JVM 위에서 실행될 때 인터프리터가 한 줄씩 해석하면서 실행됨.
    • 이 과정은 느릴 수 있지만, 유연하고 디버깅이 쉬움.
    • JVM은 실행 중에도 프로그램의 동작을 분석하면서 성능 최적화를 진행함.
  • 그래서 등장한 JIT 컴파일러: 실행 중 일부 코드를 다시 컴파일
    • JVM은 자주 호출되는 메서드나 반복적으로 실행되는 코드를 감지하면, JIT(Just-In-Time) 컴파일러를 통해 해당 코드를 기계어 수준으로 다시 컴파일함.
    • 이걸 통해 인터프리터 방식의 느린 실행을 보완하고, 성능을 최적화함.

요약하면, Java는 컴파일 언어처럼 중간 코드를 생성해 플랫폼 독립성을 확보하고, 인터프리터 언어처럼 런타임에 실행되며 유연성과 최적화 기회를 확보하려는 전략을 선택 그리고 이 두 장점을 융합한 게 바로 JVM + JIT 구조이다.

2. JVM 웜업의 이해

2.1 JVM 웜업이란?

JVM 웜업은 말 그대로 Java 애플리케이션이 최적의 성능을 낼 수 있도록 준비하는 과정이다. 운동 전에 스트레칭으로 몸을 풀듯, 애플리케이션도 본격적으로 요청을 처리하기 전에 JVM이 내부적으로 성능을 끌어올리는 준비 동작을 수행한다.

2.2 웜업이 필요한 이유

JVM 웜업이 중요한 이유는 단순히 처음엔 느리다가 아니라, 애플리케이션의 초기 상태에서는 성능을 제대로 끌어올릴 수 없는 구조적인 이유가 있다.

▶️ 클래스 로딩 지연

Java의 클래스 로딩은 Lazy Loading 방식으로 동작한다.
즉, 모든 클래스를 애플리케이션 시작 시 한꺼번에 로드하지 않고, 해당 클래스가 실제로 처음 호출될 때 메모리에 올라간다.

따라서 배포 직후 대부분의 클래스가 아직 메모리에 올라와 있지 않다면,
초기 요청 시 이 로딩 작업이 겹쳐져 응답 지연이 발생한다.

▶️ JIT 컴파일 최적화가 아직 이루어지지 않음

JVM은 처음엔 인터프리터 방식으로 코드를 한 줄씩 해석해서 실행한다.
이 방식은 느리지만, 빠르게 실행을 시작할 수 있다는 장점이 있다.

하지만 반복적으로 실행되는 핫스팟 코드가 감지되면,
JIT 컴파일러가 해당 코드를 네이티브 코드(기계어)로 변환하여 성능을 대폭 끌어올린다.

문제는, 애플리케이션 초기에는 JIT 컴파일이 거의 이루어지지 않았기 때문에
모든 코드가 상대적으로 느린 인터프리터 방식으로 동작한다는 점이다.

▶️ 최적화를 위한 실행 정보가 부족함

JIT 컴파일러는 단순히 바이트코드를 기계어로 변환하는 데 그치지 않는다.
실행 중에 수집한 다양한 프로파일링 정보를 바탕으로, 성능을 극대화하기 위한 정교한 최적화 작업을 수행한다.

대표적인 실행 정보는 다음과 같다

  • 메서드 호출 빈도: 얼마나 자주 호출되는지 (핫스팟 판별 기준)
  • 분기문 통계: if/elseswitch문에서 어떤 경로가 자주 실행되는지
  • 객체 타입 정보: 다형성 메서드 호출 시 실제로 어떤 클래스가 사용되는지
  • 루프 반복 횟수: 루프가 몇 번 반복되는지에 따라 최적화 전략이 달라짐

애플리케이션이 막 시작된 시점엔, JIT이 참고할 실행 데이터가 부족해서 제대로 된 최적화가 이뤄지지 않는다.

3. JVM 웜업 과정에서 일어나는 내부 동작

아래는 JVM 웜업 동안 내부에서 일어나는 주요 동작들이다.

3.1 클래스 로딩: 필요한 클래스들을 미리 메모리에 올리기

Java는 Lazy Class Loading 방식을 사용하기 때문에, 실제로 필요한 시점이 되어야 해당 클래스가 JVM 메모리에 로드된다.
하지만 이 로딩 과정은 비용이 꽤 크다. 클래스를 로드하고, 링크하고, static 초기화를 실행하는 등 복잡한 과정이 포함되어 있기 때문이다.

그래서 웜업 과정에서는 주요 로직에서 사용되는 클래스들을 의도적으로 먼저 호출해서 미리 메모리에 로드되도록 유도한다.

3.2 JIT 컴파일 유도: 반복 호출로 핫스팟 만들기

JVM은 처음엔 바이트코드를 인터프리터로 실행하지만 같은 메서드가 일정 횟수 이상 호출되면 HotSpot 으로 간주해서 JIT 컴파일을 적용한다.

이 과정을 수동으로 유도해 JIT 컴파일이 빨리 일어나도록 만들 수 있다.
예를 들어, 자주 쓰이는 API나 로직을 반복 호출해서 JVM이 이건 자주 쓰이는 코드다라고 인식하게 하는 것.

3.3 코드 캐시 준비: 빠른 실행을 위한 저장소

JIT 컴파일러가 만든 기계어(native code)는 일회성으로 사라지지 않는다. JVM은 이 코드를 Code Cache라는 공간에 저장해서, 이후에도 빠르게 재사용한다.

웜업을 통해 주요 메서드들이 미리 컴파일되고 캐시에 적재되면, 실제 사용자 요청이 들어왔을 때 인터프리팅 없이 곧바로 기계어로 실행할 수 있다.

즉, 초기 지연 없이 바로 고성능으로 응답할 수 있게 되는 것이다. 캐시가 부족할 경우 CodeCache is full 경고가 발생하며, 성능 저하 원인이 될 수 있음.

3.4 계층형 컴파일(Tiered Compilation)

JVM은 C1과 C2라는 두 가지 JIT 컴파일러를 사용해 코드의 중요도실행 빈도에 따라 다단계 최적화를 적용한다. 이걸 계층형 컴파일이라 부른다.

컴파일러특징트리거 기준
C1 컴파일러빠른 컴파일, 낮은 최적화 수준메서드 호출 1,500회 이상
C2 컴파일러느린 컴파일, 높은 최적화 수준메서드 호출 10,000회 이상

웜업 과정에서 해당 메서드를 의도적으로 반복 호출하면, C1 → C2 순서로 점점 최적화가 이루어지면서, 해당 코드의 실행 속도는 눈에 띄게 향상된다.

4. 웜업 시간과 성능의 관계 & 트레이드오프

4.1 웜업 시간과 성능의 관계

JVM 웜업은 단순히 느린 시작을 감수하는 단계가 아니다. 웜업 시간이 길수록 더 많은 코드가 JIT 컴파일러에 의해 최적화되기 때문에, 애플리케이션 전반의 성능을 끌어올리는 데 중요한 역할을 한다.

하지만 웜업이 길어질수록 초기 구동 시간도 길어지므로, 성능과 배포 속도 사이의 균형을 맞추는 게 핵심이다.

실제 사례 연구: 카카오 모빌리티의 JVM 웜업 적용

카카오 모빌리티에서는 JVM 웜업의 효과를 정량적으로 분석하기 위해 핵심 메서드를 여러 번 호출하는 방식으로 웜업 카운트를 조절해봤다.

  • 50회, 100회, 250회, 500회 등 다양한 횟수로 테스트한 결과
  • 250회 호출이 가장 효율적인 웜업 포인트라는 결론이 나왔다.

이 수치는 C1 JIT 컴파일러가 작동하는 기준(기본 1,500회 호출)보다는 낮지만, 성능 향상과 구동 시간 증가 사이에서 가장 이상적인 타협점이었다.

4.2 웜업 트레이드오프

JVM 웜업을 적극적으로 적용할수록 성능은 좋아질 수 있지만, 그에 따른 단점이나 위험 요소도 함께 고려해야 한다.

▶️ 웜업 실패 = 배포 실패?

웜업 스크립트에서 문제가 발생하면 실제 애플리케이션은 정상이어도 배포가 실패할 수 있다. 특히 자동화된 배포 환경(Kubernetes, GitOps 등)에서는 치명적일 수 있다.

▶️ 외부 시스템에 부하 발생 가능성

웜업 과정에서 외부 API를 반복 호출하면 DDoS처럼 외부 시스템에 과도한 부하를 줄 수 있다. 특히 SaaS API나 민감한 백엔드 연동 환경에선 주의가 필요하다.

▶️ 애플리케이션 시작 시간이 지연됨

웜업 자체가 애플리케이션 시작 시점에 실행되기 때문에, 구동 시간이 길어질 수밖에 없다. 마이크로서비스 구조에선 각 서비스의 웜업 시간이 누적되며 전체 배포 속도가 떨어질 수 있음.

🧐 그래서 어떻게 해야 할까?
JVM 웜업은 무조건 적용하는 것이 아니라, 내 애플리케이션에 맞는 수준에서 전략적으로 적용하는 게 핵심이다.

  • 핵심 로직만 웜업할지, 전체 경로를 커버할지 판단
  • 웜업 실행 시점과 배포 시점 분리 고려 (예: readiness probe 이후에 웜업 수행)
  • 외부 API 호출은 mock 처리 또는 내부 호출로 대체

5. JIT 컴파일러와 코드 캐싱 메커니즘

JIT 컴파일러와 코드 캐싱 메커니즘에 대해서 좀 더 자세히 알아보자.

5.1 JIT 컴파일러의 동작 흐름

💡 HotSpot VM과 JIT 컴파일러의 관계
JIT 컴파일러는 독립적인 외부 도구가 아니라, HotSpot JVM 내부에 포함된 핵심 컴포넌트다. 우리가 일반적으로 사용하는 Oracle JDK나 OpenJDK는 대부분 HotSpot 기반 JVM을 사용한다.

JIT 컴파일러는 다음과 같은 과정으로 동작한다.
  1. 프로파일링(Profiling): JVM은 코드 실행을 모니터링하며 메서드 호출 횟수, 루프 반복 횟수 등의 정보를 수집한다.
  2. 핫스팟(Hotspot) 감지: 수집된 프로파일링 정보를 바탕으로 자주 실행되는 코드 부분(핫스팟)을 식별한다.
  3. 컴파일 대상 선정: 특정 임계값(threshold)을 넘는 메서드나 코드 블록을 컴파일 대상으로 선정한다.
  4. 백그라운드 컴파일: 선정된 코드는 별도의 컴파일러 스레드에서 기계어로 컴파일된다. 이 과정은 애플리케이션 실행을 방해하지 않도록 백그라운드에서 이루어진다.
  5. 코드 캐시 저장: 컴파일된 기계어 코드는 코드 캐시(Code Cache)라는 특별한 메모리 영역에 저장된다.
  6. 컴파일된 코드 사용: 이후 해당 코드가 실행될 때는 인터프리터 대신 코드 캐시에 저장된 최적화된 기계어 코드가 사용된다.
  7. 재최적화(Re-optimization): 필요에 따라 이미 컴파일된 코드도 더 많은 프로파일링 정보가 수집되면 더 높은 수준으로 재최적화될 수 있다.

5.2 C1과 C2 컴파일러


HotSpot VM에는 두 가지 JIT 컴파일러가 포함되어 있다:

  • C1 컴파일러(클라이언트 컴파일러)
    • 상대적으로 간단하고 빠른 최적화를 수행한다.
    • 애플리케이션 시작 시간을 최소화하는 데 중점을 둔다.
    • 기본적으로 메서드가 약 1,500회 호출되면 활성화된다.
    • 데스크톱 애플리케이션과 같이 빠른 시작이 중요한 환경에 적합하다.
  • C2 컴파일러(서버 컴파일러)
    • 더 복잡하고 공격적인 최적화를 수행한다.
    • 장기적인 실행 성능을 최대화하는 데 중점을 둔다.
    • 기본적으로 메서드가 약 10,000회 호출되면 활성화된다.
    • 서버 애플리케이션과 같이 장시간 실행되는 환경에 적합하다.

계층형 컴파일(Tiered Compilation)

Java 7부터는 계층형 컴파일(Tiered Compilation)이 도입되었고, Java 8부터는 이것이 기본 동작 방식이 되었다. 계층형 컴파일은 C1과 C2 컴파일러를 함께 사용하여 시작 시간과 장기 실행 성능 사이의 균형을 맞추는 방식이다.

계층형 컴파일은 다음과 같은 단계로 이루어진다:

  1. 레벨 0: 인터프리터 모드로 코드 실행
  2. 레벨 1: 간단한 C1 컴파일(프로파일링 없음)
  3. 레벨 2: 제한된 프로파일링을 사용한 C1 컴파일
  4. 레벨 3: 전체 프로파일링을 사용한 C1 컴파일
  5. 레벨 4: C2 컴파일(최고 수준의 최적화)

코드는 일반적으로 이러한 단계를 순차적으로 거치며, 각 단계에서 수집된 프로파일링 정보는 다음 단계의 최적화에 활용된다. 이를 통해 애플리케이션은 빠르게 시작하면서도 점진적으로 최고 수준의 성능에 도달할 수 있다.

5.3 코드 캐시(Code Cache)의 이해

코드 캐시는 JIT 컴파일러가 만든 기계어 코드를 저장해두는 전용 공간이다.
한 번 컴파일된 코드를 다시 쓰기 위해 JVM이 따로 마련해둔 저장소라고 생각하면 된다.

🧐 그럼 코드 캐시는 어디에 저장될까?

  • 코드 캐시는 JVM 힙 메모리와는 전혀 별개의 공간이다.
  • JVM이 OS로부터 따로 메모리를 할당받아 관리하는 네이티브 메모리 영역에 존재한다.
  • Java 코드에서는 직접 접근할 수 없다. GC의 대상도 아니다.

6. 마무리

이번 글에서는 JVM이 애플리케이션 실행 초기에 어떤 동작을 하고, 왜 첫 요청이 유난히 느릴 수밖에 없는지, 그리고 이를 어떻게 개선할 수 있는지를 다뤘다.

웜업을 통해 미리 클래스 로딩과 JIT 컴파일을 유도하면 초기 응답 속도 저하 문제를 예방할 수 있다.
결과적으로 안정적인 사용자 경험과 더 나은 시스템 성능을 확보할 수 있다.

0개의 댓글