Jmeter를 사용한 테스트 도중 특이한 점을 발견했다. 서버를 재실행한 후 API요청을 날리면 Response가 유독 늦게 도착한다는 점이었다. Redis, 비동기 처리를 한 후 진행했던 것이어서 "로직을 잘못 짰나?", "Redis 동작 방식에 문제가 있나?" 하는 생각으로 코드를 계속 고쳐보았지만 결과는 똑같았다. 그 후 구글링, GPT와 심도 깊은 대화를 해봐도 정확한 원인을 찾지 못했다. 그렇게 3~4시간의 시간이 지나갔고 마지막으로 애용하는 커뮤니티에 질문을 해보았다.
놀랍게도 원인은 생각지도 못했던 JVM, 내 상황에서는 Java와 관련이 있었다. 키워드를 알아냈으니 공부하러 출발~
Java의 컴파일 과정
문제의 원인을 이해하기 위해서는 java의 컴파일 과정부터 이해해야 했다. 이 과정에서 무릎을 탁! 치게 되는 순간도 있었다. 함께 보시죠~
- 우리가 작성한 java 파일(.java)을 compile 시키면 바이트 코드(.class)로 컴파일한다.
- 보통의 컴파일 언어는 컴퓨터(OS)가 이해할 수 있는 기계어(바이너리 코드)로 컴파일을 진행한다.
- 하지만 바이트 코드는 아직 컴퓨터가 읽고 이해할 수 없는 수준이다.
- 바이트 코드와 다른 부가적인 파일들이 모여 .jar 파일을 만든다. - 런타임 동안 바이트 코드를 JVM(인터프리터 역할)이 해당 코드를 실행한다.
- JVM은 크게 클래스 로더(Class Loader), 실행 엔진(Execution Engine), 런타임 데이터 영역(Runtime Data Areas)으로 이루어져 있다.
- 실행 엔진에서 인터프리터 역할 수행
위의 과정에서 알 수 있는 점이 두 개 있다. 자바는 컴파일러를 통해 기계어로 만드는 컴파일 언어(ex. c, c++)이면서 소스 코드를 실행 시점에 한 줄씩 읽고 해석하며 실행하는 인터프리어 언어(ex. python, javascript)이다. -> 하이브리드 언어
두 번째로 알 수 있는 점에서 개인적으로 무릎을 탁 치게 되었다. 1학년 때 내가 배울 때도 그랬고 여러 자바 기본서에 적혀있는 Write Once, Run Anywhere 자바는 한번 쓰면 어디서든 실행 가능하다는 말을 드디어 이해할 수 있었다. 집에 있는 "명품 Java Programming" 책의 앞부분을 발췌해 보면
플랫폼 독립성
자바는 하드웨어, 운영체제 등 플랫폼에 종속되지 않는 독립적인 바이트 코드로 컴파일되며 자바 가상 기계만 있으면 하드웨어/운영체제를 막론하고 자바 프로그램의 실행이 가능하다.
이 뜻을 이제야 이해할 수 있게 되었다. 부가 설명을 하자면 우리가 작성한 코드를 실행하는 기기마다 하드웨어 아키텍쳐, 운영체제가 다 다르기 때문에 기계어도 그에 따라 변해야 한다. 하지만! Java는 JVM에서 바이트 코드 -> 기계어로 변환하기 때문에, JVM만 설치되어 있다면 실행 환경이 바뀌어도 알아서 대처가 가능하다. (JVM을 사용하지 않는 다른 언어들은 실행 환경이 바뀔 때마다 다시 build 해주어야 한다.)
하지만 여기서 단점도 생기게 된다. 앞서 말했듯이 자바는 컴파일러 + 인터프리터의 방식을 혼합해 사용했다. 덕분에 플랫폼에 종속되지 않는다는 장점도 있었지만 시간이 너.무. 오래 걸린다는 단점이 생긴다. 컴파일 방식은 컴파일 시 모든 소스 코드를 다 읽어서 변환해야 하기 때문에 오래 걸리고, 인터프리터 방식은 런타임에 해석하여 변환하기 때문에 오래 걸린다.
자바는 컴파일할 때 한 번에 다 읽어서 오래 걸리고.. 런타임 시 JVM에서 한 줄씩 즉각 번역해야 해서 오래 걸리고.. 이 상황을 가만뒀을 리가 없고 새로운 개념이 추가되었다.
JIT Compiler
인터프리터 역할을 수행하던 JVM에 JIT Compiler를 추가했다. 응? 어떻게 런타임 시 즉시 한 줄씩 읽는 인터프리터랑, 컴파일 시 한 번에 전체를 해석해 놓는 컴파일러가 같이 있지?? 여기서 JIT이 Just In Time 즉시이다. 기존의 컴파일러와는 다르다.
JIT Compiler는 모든 코드를 기계어로 번역해 놓지 않는다. 런타임 동안 프로그램의 실행 패턴을 모니터링하여 자주 사용되는 코드를 컴파일하여 갖고 있는다! 즉 사용하는 패턴을 JVM이 분석해서 JIT Compiler를 사용하는 조건에 충족할 시, "아~ 얘는 자주 사용하니까 컴파일 방식으로(기계어로 번역해서 미리 저장) 사용해도 되겠다~"라고 판단하여 빠르게 실행할 수 있도록 최적화하여 캐싱해 놓는 것이다. 아래의 그림이 너무 적절하여 가져와 보았다.
Class Loader
여기서 한 가지 개념을 간단하게 더 알면 좋은데 바로 Class Loader이다. 위의 내용을 읽었으면 알 수 있듯이 바이트 코드로 컴파일 시에는 모든 코드를 다 읽지만, 실행 시에는 그때그때 필요한 부분만 인터프리터와 JIT Compiler가 코드를 읽는다. 이에 맞게 메모리 상에도 항상 모든 데이터들이 올라가 있지 않고 필요시에 메모리에 해당 데이터들이 올라간다는 것을 알 수 있다.
이것을 ClassLoader가 .class파일을 동적으로 지연로딩 했다고 한다. 다시 정리하면 JVM은 모든 클래스를 항상 메모리에 올려두지 않고 런 타임 중 필요할 때, 메모리에 올려둔다.
갑자기 실험 - (호출한지 오래된 API를 호출한다면?)
여기까지 알아보니드는 생각이 있었다.
- 시간이 지나 자주 사용하지 않는다고 판단한다면?
- Class Loader가 메모리에 관련 정보를 올리는데, 메모리니까 영구적으로 유지가 되지는 않겠는데?
두 가지의 궁금 증을 해결하기 위해 아래와 같은 순서로 실험을 진행했다.
- 서버 실행 후, 특정 API 호출 -> 처음 사용하는 것이니 느릴 것.
- 일정 시간이 지난 후 1에서 호출 했던 API를 호출
여러 다른 요인도 있겠지만, 자주 사용하지 않아 JIT Compiler의 캐싱이 사라져 다시 인터프리터 작업을 수행, GC가 사용하지 않는 데이터를 메모리에서 정리해서 다시 메모리에 올리는 과정이 일어나서 시간이 오래걸린다는 점을 알게 되었다.
결론
다시 처음으로 돌아가서 내가 테스트를 진행할 때 서버를 재시작한 후, 유독 첫 요청만 Response가 오기까지 시간이 오래걸렸던 이유는 두 가지로 정리해 볼 수 있다.
1. 처음 요청이기에 자주 실행되는 코드라고 판단되지 않아서 JIT Compiler의 영향을 받지 않고 인터프리터 방식으로 실행된다.
2. Class loader는 필요한 때! 클래스를 메모리에 올린다. 그렇기 때문에 첫 실행에서는 클래스를 메모리에 올리고 실행해야 한다.
두 가지의 답으로 나의 궁금 증이 해결되었다.
이를 해결하기 위한 방법으러 JVM Warm up이라는 것이 있다. 단어의 뜻에서 알 수 있듯이 JVM이 일 잘하도록 준비 운동 시켜주는 것이다. 당장에 프로젝트에 적용시켜야하는 문제 점이 아니고 혼자 테스트 중에 원인이 궁금해서 찾아본 것이기 때문에 warm up을 적용시키지는 않았지만 보면서 너무 흥미로웠어서 관련 영상 링크를 첨부해본다.(컨퍼런스 가고 싶다)
https://www.youtube.com/watch?v=CQi3SS2YspY
개인적으로 java는 플랫폼 종속적이지 않다를 이해했을 때 쾌감이 있었다. 요즘 프레임워크를 쫓지 말고 언어를 쫓으라는 말이 점점 체감이 되어간다. 궁극적으로 안정적인 설계와 성능은 언어 깊은 곳에서부터 시작하는 것 같다.. 깨닫고 이펙티브 자바를 샀지만 아직 책장에서 먼지 쌓이고 있다. 진짜 읽기 시작해야겠다는 다짐과 키워드를 주신 개발바닥 고수님들께 감사의 인사를 올리며 오늘도 끝~
참고한 블로그, 글
https://junuuu.tistory.com/830
https://hudi.blog/jvm-warm-up/
https://jerry92k.tistory.com/65
https://f-lab.kr/insight/understanding-class-loading-and-unloading