이 글은 아래의 카카오 테크 유튜브에 나온 영상을 기반으로 정리해본 것이다.
소개
위의 영상의 요지는 Java Application을 시작할 때 초기에 JIT Compiler가 충분히 캐시되지 않은 상태에서 실 사용자들의 인입이 너무 많아 쓰레드가 묶여서 속도 저하가 발생된다는 것이다.
JVM 흐름
자바 애플리케이션의 프로세스 흐름은 글로 설명하면 아래와 같다.
우선 .java 파일을 자바 컴파일러를 통해 컴파일을 하면 바이트 코드 형식의 .class 파일이 생긴다.
그리고 .class 파일이 묶인 .war나 .jar 파일을 JVM이 실행할 수 있는데 해당 파일 안에있는 .class 파일을 위의 그림처럼 Class Loader가 처음 실행시키고 후에 Execution Engine에 의해 Interpreter나 JIT Compiler를 통해 기계어로 바꿔서 작동한다.
C++이나 Golang은 컴파일 과정에서 바로 기계어로 번역한다.
하지만 Java는 처음에는 바이트 코드로 번역하고 후에 기계어로 번역하는 두번의 과정을 거쳐서 더 느리다고 한다.
하지만 바이트코드는 프로그램의 이식성이 좋아 어느 운영체제든 다 실행이 된다는 장점이 존재한다.
Interpreter와 JIT Compiler를 같이 쓰는 이유
Execution Engine에서 기계어로 번역하는 역할은 JIT Compiler와 Interpreter가 모두 진행한다.
왜 두 개를 혼용해서 쓰는지 특징을 알아보자.
JIT Compiler
- Interpreter가 읽은 소스가 여러번 실행되면 해당 소스를 캐시 및 최적화를 진행하여 해당 소스 접근이 더 빠르도록 만드는 컴파일러다.
- 그 외에도 여러번 실행되지 않는 코드는 자체적으로 제거한다. 자바에는 전처리기가 존재하지 않는데 전처리기가 존재하는 C에서는 ifdef와 같은 조건부 컴파일 기능을 자바에서는 JIT Compiler가 제거 하는 방식으로 작동한다고 볼 수 있다.
Interpreter
- 소스 코드를 한 줄 한 줄 읽는 특징으로 인해 개발 단계에서 에러를 만나면 더 빨리 확인이 가능해서 디버깅이 더 수월해지는 장점이 존재한다.
- 런타임에 동적으로 생성되는 Class나 리플렉션이 사용된 소스는 JIT Compiler가 처리할 수 없기 때문에 최초는 Interpreter가 읽어서 처리하는 방식으로 처리해야한다.
- JIT 컴파일러는 코드를 실행하기 전에 코드를 컴파일하여 기계어로 변환하는 초기 부하(initial overhead)가 발생한다. 따라서 초기 메소드 처리 시에는 JIT Compiler보다 Interpreter가 보통 더 빠른 속도로 처리된다.
-Xint 설정 등으로 Interpreter만을 사용할 수도 있지만 위의 설명을 보자면 어느 하나라도 꺼버리면 애플리케이션이 제대로 안 돌아갈 가능성이 크다. JIT Compiler를 끄면 캐시된 내용이 없어서 서버가 느리게 돌아갈 것이고 Interpreter가 없으면 동적 생성되는 객체나 리플렉션 소스를 처리하지 못해 서버가 오류를 뱉을 것이다.
그렇기에 두가지를 다 혼용해서 사용하는 것이 일반적이다.
JVM Warmup
Warmup이라고 한다면 애플리케이션 시작 후 고객 인입량이 적은 시간에 Warmup을 하여 충분히 캐시를 하거나 혹은 유저 인입량을 일단 막고 Warmup을 하는 것으로 보인다.
카카오같이 대형 서비스 기업에서는 애플리케이션을 시작하자마자 Warmup이 되지도 않았는데 유입량이 너무 많아서 그로인해 설정된 쓰레드가 뺏겨서 속도의 문제가 생긴 듯 하다.(심지어 영상에서는 기존 시작 쓰레드가 10개 였으니 당연히 느렸을 것이다.)
보통의 일반적인 대다수의 애플리케이션이라면 인입량이 그렇게 많지 않기 때문에 쓰레드가 묶일 일이 없을 것이다.
혹은 예를 들어 서버 다중화로 서버를 한 5개 돌리고 쓰레드 개수를 200개로 설정하면 총 1000개로 동시 접속자를 한 4000~5000명 정도는 감내할 수 있을 것이다.
하지만 느리답시고 무한정으로 스케일을 크게할 수도 없는 법이기에 Warmup 절차는 아주 중요하다고 보여진다.
캐시를 하는 방법에는 애플리케이션 시작 시 아래와 같이 미리 클래스와 메소드를 적어서 캐시에 올려놓고 쓸 수도 있다.
-XX:CompileCommand=compileonly,MyClass.myMethod
하지만 이 방법은 너무 번거롭기 때문에 Warmup 절차를 거치는 것이 더 효율적이다.
영상에서는 쿠버네티스와 Spring Boot 소스 코드를 활용해서 Warmup 절차를 거친 것 같은데 그 이외에도 방법은 많다.
Warmup은 Spring Container가 초기 Bean 객체를 생성하고 로드하는 것도 Warmup 절차의 하나라고 볼 수 있다.
GenericApplicationContext context = new GenericApplicationContext();
context.refresh(); // 애플리케이션 컨텍스트를 초기화하고 빈을 로드합니다.
위와 같이 refresh를 통해 빈을 여러번 호출하는 것도 Warmup을 하는 과정이라고 볼 수 있으며 이 외에도 수많은 Warmup도구가 존재한다고 한다.
혹은 자주쓰이는 API만 직접호출해서 Warmup시키는 것도 하나의 방법이라고 볼 수 있다.
그리고 서버가 이중화되어 있다면 A서버는 운영을 유지한 다음에 유저가 들어오지 않는 상태의 B 서버를 올리고 해당 서버가 충분히 웜업이 되면 프론트 영역을 연결시키고 A서버를 다시 유저가 들어오지 않는 상태로 돌리면 될 듯 하다.
JVM Warmup Count
영상 중반에 보면 최초 조치 후 얼마 안있어 또 같은 현상이 나타나 Warmup Count를 늘렸다고 한다.
왜냐면 JIT Compiler는 단 한번 API를 읽어들였다고 해서 모든 것을 완벽하게 캐시하는 것이 아니기 때문이다.
JIT Compiler는 메소드를 컴파일하고 그 후에 Profiling 과정을 거쳐 최적화 정보를 수집할 때 실행 경험치를 바탕으로 수집하게 되는데, 이러한 과정을 거치다 보니 실행이 얼마나 됐느냐에 따라 경험치가 더 높아져서 그에 따라 최적화가 달라 질 수 있다는 것이다.
Tiered Compilation
JIT Compiler에는 Tiered Compilation의 단계가 존재한다.
Tiered Compilation은 대표적으로 C1 Compiler, C2 Compiller 2가지로 나뉜다.
영상에서 설명하는 것은 Interpreter -> C1 -> C2 순서로 컴파일이 진행된다고 한다. 일반적인 수순은 그렇지만 예외의 경우는 있다. C1이 최적화를 못하는 코드라거나 C1이 처리하지 않은 코드를 C2가 처리할 수도 있다.
즉, C1과 C2의 순서는 JIT Compiler 내에서 동적으로 결정하며 언제든지 순서가 바뀔 수 있다.
Java 9부터는 GraalVM을 사용하면 Graal JIT Compiler를 사용할 수 있다.
Graal은 C2보다 더 빠른 Compile 속도와 뛰어난 최적화 기능을 가지고 있다.
C1 Compiler
- 컴파일 빠름
- 캐시 용량 작음
- 빈번하게 호출되는 메소드 컴파일
- 서버 모드, 클라이언트 모드에 사용
C2 Compiler
- 최적화의 성능이 뛰어남.
- 캐시 용량 큼
- 컴파일 시간이 느림
- 긴 메소드에 사용
- 서버 모드에 사용
서버 모드는 대규모 어플리케이션에 적합하며 C1과 C2를 사용한다.
클라이언트 모드는 C2의 컴파일 속도 저하로 인해 C1만 사용한다. 그러므로 상대적으로 작은 애플리케이션은 C1만 사용하는 것이 속도면에서 더 좋다.
JVM을 시작할 때 별도의 설정을 해주지 않는다면 C1 Compiller만 작동한다.
그래서 아래와 같이 Tiered Compilation 기능을 활성화 해줘야한다.
-XX:TieredCompilation
C1이나 C2나 깊게 들어가면 더 내용이 많다. 예를 들어 C2 Compiler는 최적화 기능이 더 많기 때문에 인라이닝, 루프 최적화, 코드 이동 등의 기능도 있다. C1 Compiler는 Simple, Limited, Full 등의 개념이 존재하는데 아직까지는 깊게 파는 것보다는 C1에 비해 C2가 더 최적화가 뛰어나기 때문에 코드 컴파일 내용을 C2 캐시에 적재 시키는 것이 속도에 더 좋다는 것만 알아도 될 듯하다.
추후에 직접 경험해볼 일이 있을 때 관련된 내용과 더불어 Warmup 도구를 살펴보는 것이 좋을 듯 하다.
'☕ Java' 카테고리의 다른 글
[Kotlin] Optional vs Kotlin Nullable 문법 비교하기 (0) | 2023.06.21 |
---|---|
java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter (0) | 2023.06.21 |
[Java] Object, Objects 차이 (1) | 2023.02.20 |
Java에서 일급 객체(First-Class Citizen)와 일급 컬렉션(First-Class Collection)의 의미 (0) | 2022.10.25 |
Optional의 orElse, orElseGet, orElseThrow 사용법 (0) | 2022.06.06 |