들어가며: 이 글은 어떤 질문에서 시작됐나
Spring 서버를 배포하고 로드밸런서에 붙인 지 1분이 채 안 돼 모니터링을 보면, 같은 API 요청의 응답 시간이 200ms에서 50ms로 뚝 떨어진다. 처음엔 캐시 워밍업이라고 생각했는데, 알고 보니 JIT 컴파일러 때문이었다.
이 글은 "JIT가 뭐죠?" 하는 면접 답변 수준을 넘어서, 실무에서 서버 성능을 직접 다루는 엔지니어가 알아야 할 JIT의 원리를 다룬다. 특히 다음 질문들에 답한다:
- 왜 처음엔 느리고 계속 실행하면 빨라지나?
- C1, C2 컴파일러는 뭐가 다른가?
- 배포 직후 응답 지연을 어떻게 피할까?
문제 상황: 실무에서는 어디서 헷갈리나
상황 1: 배포 후 초기 응답 지연
서버를 Kubernetes로 무중단 배포하는 상황을 생각해보자.
시간 API 응답 시간 상태
0초 - Pod 부팅 중
5초 180ms Pod 실행 시작, readiness probe 통과
10초 150ms 트래픽 들어오기 시작
20초 90ms
40초 50ms 응답 안정화
처음엔 180ms, 40초 후엔 50ms. 같은 코드인데 왜 빠르니?
일반적인 추측:
- "아, 데이터베이스 연결 풀이 워밍업됐나?" → 아니다, 이미 readiness probe 단계에서 연결됐다.
- "캐시가 찰 때까지 시간이 걸리나?" → 캐시도 아니다, 첫 요청 자체가 느리다.
정답: JIT 컴파일러 때문이다. 실행되는 코드가 번역 완료되지 않은 상태에서 요청을 받으니까 느린 것이다.
상황 2: 꼬리질문
면접에서:
- "그럼 전부 다 미리 번역하면 안 됩니까?"
- "코드 캐시가 찰 수도 있지 않습니까?"
- "C1과 C2는 왜 따로 있습니까?"
외운 답변만으로는 실무 판단이 안 선다. 이해가 필요하다.
핵심 개념: 무엇을 알아야 하나
1단계: 런타임 번역의 기본
자바는 C/C++과 다르게 실행하는 순간에 코드를 번역한다.
| 언어 | 번역 시점 | 형태 |
|---|---|---|
| C/C++ | 빌드 시점(컴파일) | 이미 기계어로 준비됨 |
| 자바 | 실행 시점(런타임) | 바이트코드 → 인터프리터/JIT 번역 |
자바가 런타임에 번역하는 이유는 여러 개인데(플랫폼 독립성 등), 그 대가로 초기 속도가 느리다.
2단계: 인터프리터 vs JIT
실행 중에 코드를 번역하는 방식은 두 가지다.
인터프리터 방식
for (int i = 0; i < 1000000; i++) {
sum += arr[i]; // 이 줄을 100만 번 해석한다
}
매번 같은 줄을 새로 해석한다. 느리다.
JIT 방식
1차: sum += arr[0]; // 해석
2차: sum += arr[1]; // 해석
...
2000차: 어? 이 코드 계속 반복되네? → 기계어로 번역해서 저장
2001차: sum += arr[2001]; // 저장된 기계어로 직접 실행
자주 쓰는 코드만 골라서 기계어로 미리 번역해두고 재사용한다. 빠르다.
3단계: C1과 C2 — 2단계 최적화
JIT는 하나가 아니라 두 종류의 컴파일러가 차례로 작동한다.
| 구분 | 발동 기준 | 특징 | 역할 |
|---|---|---|---|
| C1 (Client) | ~2,000회 호출 | 빠른 번역, 가벼운 최적화 | 일단 빠르게 |
| C2 (Server) | ~15,000회 호출 | 느린 번역, 공격적 최적화 | 진짜 빠르게 |
동작 흐름
첫 실행
↓
인터프리터로 한 줄씩 해석 (느림)
↓
호출 ~2,000회 → C1 활성화
↓
"C1이 와서 대충 빠르게 번역" (약간 빠름)
↓
호출 ~15,000회 → C2 활성화
↓
"C2가 와서 정교하게 번역" (매우 빠름)
왜 이렇게 복잡하나?
컴파일(번역)도 비용이다. CPU와 메모리를 쓴다. 극도로 최적화된 번역은 더 많은 비용이 든다.
- 한 번만 실행되는 코드를 C2로 최적화? → 번역 비용이 이득을 넘는다.
- 100만 번 실행되는 코드를 인터프리터로만 돌려? → 실행 비용이 너무 크다.
그래서 "자주"라는 기준을 두고 단계적으로 최적화한다.
4단계: 코드 캐시(Code Cache)
JIT가 번역한 기계어는 메모리의 코드 캐시에 저장된다.
JVM 메모리
├─ Heap (객체 저장)
├─ Stack (메서드 호출)
└─ Code Cache (JIT 번역본 저장) ← 여기
코드 캐시는 크기 제한이 있다(기본 ~240MB, JVM 옵션으로 조절 가능). 캐시가 꽉 차면 새 코드는 더는 번역 못 하고 인터프리터로 돌아가 느려진다.
실전 접근: 어떤 순서로 판단하나
배포 후 초기 느림을 피하려면
문제의 본질
Pod 부팅
↓
JVM 시작, 코드 캐시 비어있음
↓
readiness probe는 통과했지만 JIT 번역 미완료
↓
실제 트래픽 들어옴
↓
모든 요청이 인터프리터로 느리게 처리됨
↓
40초 후 호출 임계치 도달, C1/C2 번역 완료
↓
응답이 정상화
이 40초를 줄일 수 있나? 답은 워밍업(Warming Up)이다.
해결책 1: 서버 내부 루프 워밍업
// Spring Boot 애플리케이션 기동 후 자동 실행
@Component
public class JVMWarmup {
@Autowired
private UserService userService;
@EventListener(ApplicationReadyEvent.class)
public void warmup() {
// 실제 트래픽이 들어올 주요 경로를 미리 실행
for (int i = 0; i < 20000; i++) {
userService.getUser(1L);
userService.listUsers(0, 10);
}
}
}
이 코드가 실행되면:
- 주요 메서드들이 2만 번 호출됨 → C1, C2 임계치 도달
- 코드 캐시에 기계어 저장 완료
- 이 상태에서 readiness probe 통과 → 트래픽 받기 시작
- 실제 요청은 이미 번역된 빠른 코드로 처리
해결책 2: Kubernetes readiness probe + 워밍업 완료 신호
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: my-app
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30 # 워밍업이 끝날 때까지 대기
Spring Boot의 readiness 엔드포인트는 모든 워밍업이 완료된 후에 ready 상태를 반환하도록 커스터마이징할 수 있다.
해결책 3: 로드밸런서 레벨 - 신규 Pod 트래픽 점진적 증가
# 신규 Pod이 추가될 때 트래픽을 천천히 늘린다
upstream backend {
server pod-1 weight=100; # 기존 Pod
server pod-2 weight=10; # 신규 Pod (10% 트래픽만)
server pod-3 weight=10;
}
# 10초 후
# upstream {
# server pod-1 weight=80;
# server pod-2 weight=50;
# server pod-3 weight=50;
# }
이렇게 하면 신규 Pod이 워밍업되는 동안 적은 트래픽만 받는다.
예시: 코드와 실행 결과
예시 1: 워밍업 없이 배포
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUser(id));
}
}
// 애플리케이션 시작
// Application started in 3.245 seconds (JVM running for 3.512)
첫 요청:
첫 요청: 180ms
두 번째: 165ms
...
30초 후: 50ms
예시 2: 워밍업 포함 배포
@Component
public class ApplicationWarmup implements ApplicationRunner {
@Autowired
private UserService userService;
@Override
public void run(ApplicationArguments args) {
System.out.println("JVM Warming up...");
for (int i = 0; i < 20000; i++) {
userService.getUser(1L);
if (i % 5000 == 0) System.out.println(" " + i + " iterations...");
}
System.out.println("JVM Warmup complete!");
}
}
// 애플리케이션 시작
// JVM Warming up...
// 5000 iterations...
// 10000 iterations...
// 15000 iterations...
// 20000 iterations...
// JVM Warmup complete!
// Application started in 8.123 seconds
이제 첫 요청:
첫 요청: 52ms (이미 번역됨)
두 번째: 49ms
...
30초 후: 48ms (처음부터 안정적)
주의할 점: 자주 하는 오해와 Best Practice
오해 1: "JIT가 있으면 인터프리터는 필요 없다"
틀렸다. 둘은 협업한다.
- 인터프리터: 즉시 실행 필요 → 번역 시간 낭비 불가
- JIT: 장기 최적화 → 번역 비용 투자 가치 있을 때만
오해 2: "모든 코드를 미리 C2로 번역하면 안 되나?"
번역 비용 때문에 손실이 크다. 예:
- 한 번만 실행되는 에러 처리 코드 → 번역하면 손해
- 10만 번 루프 → 번역하면 이득
그래서 호출 횟수 기반 단계적 최적화가 필수다.
오해 3: "C1/C2 임계치는 정확한 상수다"
아니다, 기본값일 뿐이다.
JVM 버전, 옵션, 멀티스레드 상황에 따라 달라진다:
-XX:TieredStopAtLevel=4 # C2까지 사용 (기본값)
-XX:TieredStopAtLevel=3 # C1까지만 사용
-XX:CompileThreshold=10000 # C1 임계치 조정
실무에서는 "약 2천/1.5만 규모"로 이해하고, 정확한 동작은 JVM 로그로 확인한다.
오해 4: "워밍업은 항상 필요하다"
상황에 따라 다르다:
- 필요한 경우: 배포 후 즉시 높은 트래픽 (온라인 서비스, 배치 시작 타이밍)
- 필요 없는 경우: 개발/테스트 환경, 트래픽이 천천히 들어오는 경우
Best Practice
1. 워밍업은 명시적으로, 완료를 기록하라
@Component
public class JVMWarmup implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(JVMWarmup.class);
@Override
public void run(ApplicationArguments args) throws Exception {
long start = System.currentTimeMillis();
// 워밍업 로직
performWarmup();
long elapsed = System.currentTimeMillis() - start;
log.info("JVM warmup completed in {} ms", elapsed);
}
}
2. readiness probe는 워밍업 완료 후에 통과하라
@RestController
public class HealthController {
@Autowired
private WarmupService warmupService;
@GetMapping("/actuator/health/readiness")
public ResponseEntity<Map<String, String>> readiness() {
if (!warmupService.isWarmupComplete()) {
return ResponseEntity.status(503).body(Map.of("status", "warming up"));
}
return ResponseEntity.ok(Map.of("status", "ready"));
}
}
3. 코드 캐시 상태를 모니터링하라
jstat -printcompilation <pid> 1000 # 1초마다 컴파일 상황 출력
jcmd <pid> Compiler.perfmap # 성능 프로파일링용 맵 생성
4. GC 튜닝과는 별개로 생각하라
JIT는 코드 실행 속도 문제다. GC는 메모리 해제 속도 문제다. 둘을 혼동하지 말 것.
면접/실무 답변으로 압축하면
면접 질문: "JIT 컴파일러란?"
구조화된 답변:
자바는 런타임에 코드를 번역하는데, 모든 코드를 해석하면 느리니까 JIT는 자주 쓰는 코드를 기계어로 미리 번역해서 캐시해둡니다. 호출 횟수를 프로파일링해서 C1(약 2천회)에서 빠르게 번역하고, C2(약 15천회)에서 정교하게 번역합니다. 덕분에 실행할수록 빨라져서 장시간 실행되는 자바 서버가 C/C++에 가까운 속도에 도달합니다.
실무 상황: "배포 후 초기 응답이 느려요"
판단 순서:
- 서버 부팅 시간을 확인 (readiness probe까지)
- 첫 트래픽 시점의 응답 시간 기록
- 40-60초 후 안정화되나? → JIT 때문일 가능성 높음
- 워밍업 로직 추가 후 재배포 → 개선 여부 확인
마무리
JIT는 "자동 마법"이 아니다. 호출 횟수 기반, 비용을 고려한 실리적 선택이다.
실무 엔지니어로서 알아야 할 핵심 두 가지:
- 초기 지연은 당연하다 — 번역할 시간이 필요하다. 그래서 워밍업이 필요하다.
- 비용 감각이 중요하다 — 모든 코드를 번역하면 손해다. "자주 쓰는 것만"이 정답이다.
배포 후 첫 5분을 관찰하고, 응답이 급격히 빨라지는 패턴을 본다면 JIT를 이해한 것이다.
참고자료
- Oracle JVM Performance Tuning Guide
- JIT Compiler: What Does It Do? — OpenJDK HotSpot 공식 문서
- Understanding JIT Compilation and Optimization
jstat도구로 런타임 컴파일 통계 확인jcmd <pid> Compiler.queue— 컴파일 대기열 확인-XX:+PrintCompilation— 컴파일 활동 로그 활성화
'Language > Java & Kotlin' 카테고리의 다른 글
| Java 개발자라면 반드시 알아야 할 GC의 동작 원리와 실무에서의 성능 영향 (0) | 2026.07.17 |
|---|---|
| Java 개발자라면 알아야 할 JVM의 모든 것: 클래스 로더부터 실행 엔진까지 (0) | 2026.07.17 |
| [Java] Stream API 톺아보기 (0) | 2025.06.03 |
| [Java] 모듈 결합도와 응집도, SRP와 OCP 원칙 (3) | 2025.06.03 |
| [Kotlin] 자주 쓰이는 코틀린 문법 정리 1. 코틀린 스코프함수 (apply, run, with, let) (1) | 2024.08.23 |
댓글