
이전에 작성했던 게시글에서 Jmeter로 한 메서드의 성능을 반복 측정하면 안 되는 이유를 JVM Warm-up과 DB Cache와 관련해서 설명을 드리겠습니다.
https://coding-self-study.tistory.com/26
Clokey 리펙토링 : JPA 성능 최적화
최적화에 필요한 지식들을 공부를 했고 이전 게시물(🔗)에 정리를 해두었습니다,이를 바탕으로 다음과 같은 목표를 가지고 최적화 작업을 하기로 결정했습니다.양방향 매핑을 사용하지 않는다
coding-self-study.tistory.com
해당 게시물에서 겪었던 시행착오 중, Jmeter를 이용해서 단일 Repository 메서드의 성능을 측정하려 한 일이 있었습니다. 이때 메서드가 반복 호출됨에 따라 발생했던 문제가 있습니다.
- 첫 번째 메서드 호출 이후에 급격하게 시간이 줄어든다 (JVM warm-up을 의심)
- 메서드 호출이 반복됨에 따라서 소요시간이 줄어든다 (InnoDB 캐싱을 의심)
결과적으로, 제가 생각했던 원인이 맞았습니다. 하지만, 조금 더 개념과 근거를 포함해서 설명을 드리겠습니다.
📌 JVM Warm-up
JVM Warm-up이 발생하는 이유는 크게 2가지 정도를 꼽을 수 있습니다.
첫 번째 이유는 Java 언어 자체의 성격 때문이고 두 번째 이유는 JVM의 메모리 적재 방식 때문입니다.
먼저 Java 언어 자체에 대해서 알아보겠습니다.
Java의 언어 철학
이 부분에 대해 이해를 하기에 앞서서 우선 자바라는 언어의 철학에 대해서 이해할 필요가 있습니다.
보통 C, C++, Go와 같이 한번에 기계어로 모두 번역한 뒤 실행하는 언어를 컴파일 언어라고 부릅니다.
이런 컴파일 언어는 컴파일 과정이 오래 걸릴 수도 있는 대신 이미 컴파일된 프로그램이라면 굉장히 빠른 속도로 실행이 가능하다는 장점이 있습니다.
반대로 R, Python, JavaScript 같이 번역과 동시에 실행이 되는 언어를 인터프리터 언어라고 말하며 이는 줄 단위로 번역과 실행을 하기 때문에 실행 시 속도 자체는 컴파일 언어에 비해서 늦춰지게 됩니다.
그렇다면 Java는 어떤 언어일까요?
Java는 .class 확장자를 가지는 중간 언어로 우선 컴파일이 되어서 JAR 파일로 구워지게 됩니다. 이때 중간 언어는 기계어가 아니기 때문에 실행 시점에 인터프리터를 통해서 기계어로 번역이 되어야 합니다. 이러한 과정을 가지고 있는 이유로 Java는 하이브리드 언어라고도 불립니다.
Java가 이런 구조를 가지고 있는 이유는 높은 이식성(Portabiliy)을 가지기 위함입니다. 컴파일 언어들은 애플리케이션이 돌아가는 하드웨어에 따라서 다른 구조를 가지고 있기 때문에 따로 컴파일해주어야 합니다. 하지만. class (바이트 코드)는 어떤 OS든 동일하고 JVM이 이를 OS 종속적인 네이티브 기계어로 번역을 해주기 때문에 높은 이식성을 가지게 됩니다. 이런 구조를 가지고 있기 때문에 물론 인터프리터 언어가 가지는 실행이 느리다는 단점은 그대로 가져가게 됩니다.
JIT Tiered Compliers
이런 단점을 보완하고자 JVM은 JIT Tiered Compliers를 사용합니다. 간단히 개념을 설명하자면
"매번 기계어 변환이 이루어지면 오래 걸리니까 많이 호출되는 HotSpot은 네이티브 언어로 캐싱해서 가지고 있어줄게 + 정도에 따라서 더 효율적인 캐싱을 적용해 줄게"라고 생각하시면 됩니다.

여기서 사용 빈도에 따라서 C1 Compile이 이루어지게 되고 더 많은 빈도로 호출이 된다면 C2 Complie이 이루어 지게 됩니다.
C1 - 클라이언트 컴파일러
C1 컴파일러는 앱 시작 속도를 높이는데 최적화된 JIT 컴파일러로 가능한 빨리 코드를 최적화하고 컴파일하는 것에 초점을 두고 있습니다. (단기적 효율성을 추구). 과거에는 짧은 라이프 사이클을 가진 애플리케이션이나 시작이 중요한 곳에서 사용되었습니다.
C2 - 서버 컴파일러
C2 컴파일러는 장기적인 전체 성능을 높이는데 최적화된 컴파일러이며 C1보다 더 오래 코드르 분석해 더욱 최적화된 컴파일을 하는 반면 초기 소모 시간이 더 오래 걸리게 됩니다.
Tiered Compile이란 이 두 개의 장점을 합친 컴파일을 의미합니다.
처음 시작할 때는 기본적으로 모든 바이트 코드를 인터프리터로 실행하며 프로파일링 정보(얼마나 호출되는지, 얼마나 반복되는지 등)을 수집합니다. 이 정보를 바탕으로 자주 호출되는 HotSpot을 찾게 되며 초기에는 C1으로 컴파일해 가지고 있다가 지속적으로 재호출이 되게 된다면 C2가 재컴파일하면서 더욱 최적화된 컴파일을 수행하게 됩니다. 반대로 호출이 점점 더 적게 된다면, 역으로 인터프리터로 실행되도록 deoptimize 할 수도 있습니다.
코드 캐시 (Code Cache)
이렇게 컴파일된 언어는 네이티브 코드로 컴파일된 바이트 코드를 저장하는 메모리 공간인 코드 캐시에 저장이 됩니다.

JVM은 하나의 인터프리터와 두 종류의 JIT 컴파일러(C1, C2)를 사용하지만, 총 다섯 단계의 컴파일 레벨이 존재합니다.
이는 C1 컴파일러가 세 단계로 동작하기 때문입니다.
Level 0 (인터프리트된 코드)
- 처음에는 모든 자바 코드가 인터프리터로 실행됩니다.
- 이 단계에서 성능은 낮지만, 프로파일링 정보가 수집됩니다.
Level1 (단순 C1 컴파일된 코드)
- C1 컴파일러가 코드를 컴파일하되, 프로파일링 정보는 수집하지 않습니다.
- 메서드 복잡도가 낮아 C2 최적화로도 성능 향상이 기대되지 않는 경우 사용됩니다.
Level2 (제한된 C1 컴파일 코드)
- C1 컴파일러가 가벼운 프로파일링과 함께 코드를 컴파일합니다.
- 주로 C2 큐가 꽉 찼을 때, 빠른 실행을 위해 사용됩니다.
- 나중에 JVM은 이 코드를 레벨 3(풀 프로파일링) 또는 레벨 4(C2 최적화)로 재컴파일합니다.
Level3 (풀 C1 컴파일된 코드)
- C1 컴파일러가 최대 수준의 프로파일링과 최적화를 수행합니다.
- 가장 일반적인 경로이며, 대부분의 코드가 인터프리터에서 바로 레벨 3으로 이동합니다.
Level3 (풀 C1 컴파일된 코드)
- 이 단계의 코드는 완전히 최적화된 것으로 간주되어 더 이상 프로파일링 하지 않습니다.
- 하지만 가정이 깨지면 디옵티마이제이션으로 다시 레벨 0으로 돌아갈 수 있습니다.
이런 구조를 가지고 있기 때문에 반복해서 호출되면서 C1을 거쳐 C2로 컴파일 되게 되면서 속도가 빨라질 수밖에 없는 구조입니다.
또한, 실행 되어야 하는 코드가 메모리에 올라오는 과정에서도 JVM warm-up의 이유가 있습니다.
Class Loader
위의 인터프리터가 작동하기 전에 일단 JVM의 메모리 상으로 클래스를 로딩해야 합니다.
이때 필요한 모든 클래스는 클래스 로더(Class Loader)의 인스턴스에 의해서 메모리에 로드되며 이는 3단계로 나누어집니다.
1. 부트스트랩 클래스 로딩 (Bootstrap Class Loading)
“Bootstrap Class Loader”가 java.lang.Object 같은 필수적인 자바 클래스들을 메모리에 적재합니다. 이 클래스들은 JRE\lib\rt.jar에 들어 있습니다.
2. 확장 클래스 로딩 (Extension Class Loading)
ExtClassLoader는 java.ext.dirs 경로에 있는 모든 JAR 파일을 로드합니다. Maven/Gradle 같은 빌드 툴을 쓰지 않고 개발자가 수동으로 추가한 JAR들도 여기서 로딩됩니다.
3. 애플리케이션 클래스 로딩 (Application Class Loading)
AppClassLoader는 애플리케이션의 클래스패스에 있는 모든 클래스를 로드합니다.
이 초기화 과정은 지연 로딩(lazy loading) 방식을 따릅니다. 즉, 필요할 때 로딩됩니다.
그래서 JVM 웜업이란 무엇인가?
클래스 자체도 지연 로딩이 되기 때문에 처음 실행된 클래스 파일은 JVM의 메모리 위로 적재되는데 소요 시간이 걸리게 됩니다. 또한, 기계어로 컴파일되어 네이티브 언어로 캐싱되기 까지도 시간이 소요됩니다. 이런 현상으로 인해 보통 앱을 배포하거나 시작한 직후에 성능이 저하되는 경우가 많은 것입니다.
따라서, 이런 이유로 Jmeter에서 첫 번째 호출 이후에 시간이 급격히 줄었던 것이고, 그 이후에도 시간이 계속 줄어든 이유 중 하나입니다.
📌 MySQL 입장에서 캐싱은 일어나지 않는가?
단계적으로 쿼리를 날리게 되면 어떤 일이 일어나는지 생각해 봅시다.
만약 다음과 같은 SQL을 날리게 되면...
SELECT * FROM user WHERE id = 1;
MySQL은 이를 먼저 파싱해야 합니다. 이는 CPU Bound Job이기 때문에 굉장히 비싼 비용을 가지고 있는 작업입니다.
이 파싱이라는 작업은 Syntex, Semantic 체크와 파싱 트리를 만드는 행위입니다.
간단하게 아래와 같은 예시를 들 수 있습니다.
문법 검사 결과
"SELECT" 다음에 칼럼이 와야 한다, "FROM" 뒤에는 테이블이 있어야 한다 → 이게 올바른지 검증됨.
의미 검사 결과
user라는 테이블이 실제 존재하는지, id와 name 컬럼이 맞는지 확인됨.
내부 구조체
이제 Optimizer가 이 트리를 보면서 "어떤 인덱스를 쓰면 빨리 찾을까?" 같은 실행 계획을 세울 수 있음.
그다음 앞의 트리와 다양한 정보 바탕으로 SQL Optimizer가 다양한 실행 경로를 비교 후, 가장 효율적인(Optimization) 경로를 찾게 됩니다.
예를 들어 다음과 같은 정보를 받아올 수 있습니다.
테이블 구조: row 개수, row 크기
컬럼 통계: 값의 분포(카디널리티), NULL 비율 등
인덱스 정보: 어떤 컬럼에 인덱스가 있는지, 인덱스 선택도가 어떤지
시스템 통계: CPU 비용, 디스크 I/O 비용, 메모리 사용량
옵티마이저 파라미터: optimizer_mode, join_buffer_size 같은 DB 설정값
그리고, 여러 가지 경로(Plan)을 가정해 보게 됩니다.
- Plan A: user 테이블을 Full Table Scan (전부 읽기)
- Plan B: user.id 인덱스를 타고 바로 찾아가기
- Plan C: (조인이 있다면) Nested Loop, Hash Join, Merge Join 등 다양한 조인 전략
각 후보 실행 경로마다 비용(Cost)을 추정해서
- 디스크에서 몇 블록 읽을지 (I/O 비용)
- 인덱스 탐색 비용
- CPU 연산 비용
- 메모리 소모량
가장 효율적인 방법을 찾는 것이죠. 이 최적화 과정 역시 CPU Bound Job입니다.
이런 오버헤드가 드는 작업들을 Oracle 데이터 베이스는 실행 전략들을 Library Cache에 저장해 두고, 똑같은 쿼리를 수행할 때는 캐싱된 실행 전략을 사용하여 소프트 파싱을 유도합니다.
그런데!!!
MySQL은 그런 것들이 없어요. 코드를 찢다 블로그의 글에서 확인한 바로는 🔗
- Query Plan Cache를 사용하면 단일 쿼리 속도는 빨라질 수 있지만, 확장성 측면에서는 속도가 느려질 수 있다.
- MySQL의 최적화 전략 자체가 다른 DB와 다르다. ( MySQL은 최적화 과정 자체를 빠르게 해서 성능 향상을 얻는다, 다른 DB는 계획을 캐싱해서 성능 향상을 얻는다)
정도가 있는 것 같습니다.
어쨌든 중요한 점은. MySQL은 실행 계획 캐싱이 없다!라는 점입니다.
Jpa의 기본 메서드를 사용하거나, 다음과 같이 파라미터 바인딩을 하는 쿼리를 사용할 경우 Prepared Statement를 사용하긴 하지만...
@Query("SELECT m FROM Member m WHERE m.id = :id")
Optional<Member> findMemberById(@Param("id") Long id);
이는 SQL Parser의 내부 구조체를 캐싱하는 것에 불과하고 매번 최적화를 수행하는 것은 동일합니다.
따라서, 반복 수행에 대해서 실행 전략등이 최적화되는 것은 아닙니다.
실행 계획 캐싱은 없어도 innoDB 캐싱은 있다

위와 같은 SQL문을 실행해 보겠습니다.

처음에는 8ms정도 걸렸고요..

2번째부터는 2.2ms정도로 반복 실행해도 더 줄어들지는 않았습니다.
이 현상은 InnoDB의 Buffer Pool에 해당 정보를 담는 페이지가 남아있기 때문이라고 생각하고 있습니다.
더 줄어들지 않으니까요.
📌 결론
Q : 첫 번째 실행에서 급격히 실행이 줄어드는 이유는???
JVM Warm-up + InnoDB Buffer Pool 캐싱 때문이다.
Q: 그런데 그 이후로도 계속 줄잖아요?
이것 역시 JVM이 여전히 warm-up 중이기 때문이다..!!( DB 실행 계획 캐싱은 없음 ) + 외부 요인이 있을 수 있다 (OS 등..)
따라서, jmeter는 최대한 실제 환경과 비슷한 테스트를 진행해야 할 것이다!
반복해서 해봤자 정확한 성능을 알 수가 없다는 점...
Reference:
Baeldung Java Blog - Tiered Compilation 🔗
Baeldung Java Blog - How To Warm Up The JVM 🔗