JVM 언어로 개발된 애플리케이션 서버가 롤링 배포와 같은 이유로 재배포가 된다면 배포 직후 TPS는 일정하지만 큰 지연이 발생하는 것을 확인할 수 있다.
왜 배포 직후에는 레이턴시가 발생하는 것일지 원인을 살펴보도록 하겠다.
latancy 원인을 알기 전에 자바 클래스가 로드 되는 과정을 먼저 간단히 알아워야하는데 JVM 에서 자바의 클래스를 읽어오기 위해 자바 클래스로더가 사용된다. 클래스 로더는 클래스 파일을 찾고, 메모리에 로드해 실행 가능한 상태로 만드는 역할을 한다. 아래의 순서로 동작된다.
- Class Loading: 클래스 파일을 가져와 JVM 메모리에 적재한다. 이 단계도 크게 JVM 기본 클래스와 Java 코드를 로딩하는 Bootstrap Class Loading, 자바 핵심 라이브러리를 로딩하는 Extension Class Loading, 개발자가 직접 작성해 클래스패스에 있는 클래스를 로딩하는 Application Class Loading 단계로 나뉜다.
- Class Linking: 클래스가 참조하는 다른 클래스, 메서드, 필드 등을 확인하고 메모리 상에서 연결하는 단계다. 이 단계도 크게 Verification, Prepare, Resolution 단계로 나뉜다.
- Class Initialization: 클래스 변수를 초기화하거나, static 블록 내의 코드를 실행하는 등의 클래스 초기화 작업을 수행한다.
클래스는 위와 같은 작업들로 여러 단계를 거쳐 진행되고, 따라서 클래스 로딩은 상대적으로 무거운 작업으로 간주된다. 그런데 왜 JVM Warm up을 위해 클래스 로더에 대한 이야기를 꺼낸 것일까 설명해보자면
클래스 로더는 일반적으로 Lazy Loading 방식으로 동작한다. Lazy Loading 은 애플리케이션이 시작될 때 로딩되는 것이 아니라 클래스가 필요한 시점까지 로딩을 지연하는 방식이다. 쉽게 말해서 클래스 로더는 클래스가 최초로 필요해진 시점에 클래스를 로딩한다.
이 Lazy Loading이 latancy가 발생하는 첫 번째 이유다. 배포 직후에는 대부분의 클래스들이 한 번도 사용되지 않았으므로 클래스 로더에 의해 메모리에 적재되지 않은 상태다. 그런 상태에서 웹 애플리케이션에 요청이 들어오게 되면, 그재서야 클래스 로더가 부랴부랴 클래스를 메모리에 적재한다. 이 과정속에서 레이턴시가 발생하게 되는 것이다.
자바의 컴파일 과정을 먼저 살펴보면 자바로 작성된 코드는 중간언어인 바이트코드로 컴파일된다. .class
확장자를 가진 파일이며 이 바이트코드는 애플리케이션에서 사용되는 여러 리소스들과 함께 묶여서 실행될 수 있는 Jar, War 파일로 아카이브되며 이런 빌드 과정을 거쳐서 Jar, War를 실행하게 되면, JVM은 바이트코드를 한줄씩 읽어 기계어로 번역한다. 이 과정을 인터프리터라고 한다.
이렇게 바이트 코드를 사용하는 이유는 JVM의 핵심 철학 "Write Once Run Anywhere" 를 생각해보면 되겠다. 자바는 플랫폼 독립적인 언어를 지향하고 C, C++, Go, Rust와 같이 소스코드가 기계어로 직접 컴파일 되는 언어들은, 애플리케이션을 실행하는 하드웨어에 따라 컴파일 해주어야하며 x86, x64, ARM 같은 cpu 아키텍쳐마다 서로 다른 명령어 세트, 레지스터 구조등을 가지고 있기 때문이다.
자바는 바이트코드를 사용해 이런 문제를 해결했고 JVM 바이트 코드를 기계어로 번역할 때 플랫폼 종속적인 작업을 처리하고. 이것이 자바가 높은 portability를 가질 수 있게 한다.
그렇지만 이러한 방식의 문제점은 당연하게도 실행 속도가 비교적 느리다. 라는 것이다. 이건 모든 인터프리팅 언어들이 가지고있는 고질적인 문제이며, 종종 컴파일 언어 번역서를 읽는 것이, 인터프리터 언어 원서를 구매해 한줄 한줄 해석해 가면서 읽는 것에 비요한다. 당연히 후자가 더 느릴 것이다. 또 컴파일 언어는 소스코드를 컴파일 할 때 코드 최적화를 수행한다. 따라서 이미 최적화되어 준비된 기계어를 읽는 컴파일 언어에 비해 인터프리터 성능이 더 떨어질 수 밖에 없다.
위와 같은 문제를 해결하기 위해서 JVM은 JIT 컴파일러를 도입했다. JIT Compiler는 애플리케이션 실행중 동적으로 바이트코드를 기계어로 컴파일(그리고 캐싱해서 사용) 한다. 이렇게 컴파일된 코드를 실행하게 되면, 인터프리터가 바이트코드를 실행하는 것 보다 훨씬 빠른 속도로 실행이 가능하다.
그런데 애플리케이션이 실행되고 모든 바이트코드를 기계어로 번역하면 애플리케이션 기동 시간이 너무 길어질 것 같다. 그래서 애플리케이션 실행 시간과 최적화 사이의 밸런스를 잘 맞춰야하며 JIT 컴파일러는 코드의 어떤 부분을 기계어로 번역할지 정해야한다.
JIT 컴파일러는 애플리케이션에서 자주 실행된다고 판단되는 특정 부분만 기계어로 컴파일한다. 이 부분을 핫스팟이라고 하며 JIT 컴파일러는 실행중인 애플리케이션의 동작을 분석하고 코드 실행 횟수, 루프 반복 횟수, 메서드 호출 등의 정보를 측정하고 기록한다. 이를 프로파일링이라고 부른다. JIT 컴파일러는 프로파일링 결과를 토대로 핫스팟을 식별하며, 핫스팟이 식별되었다면, JIT 컴파일러는 메서드 단위로 바이트코드를 기계어로 번역한다.
JIT 컴파일러는 이렇게 번역된 기계어를 code cache에 저장한다. code cache에 기계어를 저장하면 핫스팟으로 판단된 코드는 다시 컴파일 하지 않고도 code cache에서 꺼내 재사용할 수 있어 성능 향상을 이루어낸다.
JIT Compiler의 내부를 더 자세히 보면 컴파일 과정이 최적화 수준에 따라 여러개의 단계로 나누어져있는 것을 확인할 수 있으며 이것을 Tiered Compilation
(계층형 컴파일)이라고 부른다.
JIT Compiler는 C1, C2 두 개의 컴파일러가 존재하는데 알아보자.
C1 컴파일러는 가능한 빠른 실행 속도를 위해 코드를 가능한 빠르게 최적화하고 컴파일한다. 그 중 특정 메서드가 C1 컴파일러의 임계치 설정 이상으로 호출되면, 해당 메서드의 코드는 C1을 통해 제한된 수준으로 최적화되며, 그리고 컴파일된 기계어 코드는 code cache에 저장된다.
이후 메서드가 c2 컴파일러의 임계치보다 많이 호출되면, c2에 의해 코드가 최적화되고 컴파일된다. c2 컴파일러는 c1 컴파일러보다 더 높은 수준의 최적화를 수행한다. 최적화와 컴파일이 끝나면 마찬가지로 코드 캐시에 기계어를 저장한다.
보통 c1은 실행을 빨리 해야하는 데스크톱 애플리케이션에 적합하고 c2는 실행 이후 속도가 중요한 서버 애플리케이션에 적합하다.
JIT Tiered Compilation은 인터프리터, c1, c2 를 통해 총 5가지의 level로 나뉘고 level 0은 인터프리터, level 1 ~ 3 은 c1 level 4는 c2 컴파일러에 의해 수행된다.
- level 0 interpreted code: JVM은 초기에 모든 코드를 인터프리터를 통해 실행한다. 이 단계는 앞서 살펴본 것과 같이, 컴파일된 기계어를 실행하는 것 보다 성능이 낮다.
- level 1 simple c1 compiled code: level 1은 jit 컴파일러가 단순하다고 판단한 메서드에 대해 사용된다. 여기서 컴파일된 메서드들은 복잡도가 낮아 c2로 컴파일한다고 해도 성능이 향상되지 않을 것이다. 따라서 추가적인 최적화가 필요 없어서 프로파일링 정보도 수집하지 않는다.
- level 2 limited c1 compiled code: 제한된 수준으로 프로파일링, 최적화를 진행하는 단계고 c2 컴파일러 큐가 꽉 찬 경우에 수행된다.
- level 3 full c1 compiled code: 최대 수준으로 프로파일링과 최적화를 진행한다. 즉 일반적인 상황에서 수행된다.
- level 4 c2 compiled code: 애플리케이션의 장기적인 성능을 위해 c2 컴파일러가 최적화를 수행한다. level 4에서 최적화된 코드는 완전회 최적화 되었다고 간주되며 더이상 프로파일링 정보를 수집하지 안흔ㄴ다.
서버가 배포된 직후에는 JIT 컴파일러는 아무런 코드도 기계어로 컴파일하지 않았으며, 따라서 코드 캐시에 적재된 기계어도 존재하지 않는다. 따라서 배포 직후 시점의 코드는 인터프리터에서 실행되거나 c1, c2 컴파일러가 최적화하고 컴파일하는 과정이 동반되어 필연적으로 성능저하가 발생한다 이 것이 두 번째 레이턴시의 원인이다.
이 문제를 해결하기 위해 JVM Warm-UP을 한다. 이는 다음글에서 알아보겠다.