JVM 깊이 보기: 바이트코드·JIT·클래스 로딩·JMM·GC와 프로덕션 Java 패턴

이 글의 핵심

Java 문법은 익숙한데 GC 멈춤이나 동시성 버그, 클래스 로딩 충돌 앞에서 막히는 개발자를 위한 심화 글입니다. volatile만으로는 원자성이 보장되지 않는 예제처럼 JMM이 실제로 보장하는 범위를 짚고, GC 알고리즘 선택과 풀링·백프레셔·가상 스레드 같은 프로덕션 패턴을 판단 기준과 함께 정리합니다.

들어가며

이 글은 Java 문법 입문서가 아니라, JVM이 소스 코드를 실행하기까지의 경로와 런타임이 동시성·메모리·가비지 수집을 어떻게 다루는지를 정리한 심화 자료입니다. JDK 17 LTS 이후 환경을 기준으로 설명하되, 구버전이나 최신 버전과의 차이가 중요한 지점은 따로 짚습니다. 문법·컬렉션·람다 등 기초는 Java 시리즈와 스레드·Executor를 참고하며, 여기서는 내부 구조와 운영 관점에 집중합니다.


JVM 아키텍처: 바이트코드에서 기계어까지

클래스 파일과 바이트코드

Java 소스(.java)는 컴파일러(javac)에 의해 클래스 파일(.class)로 변환됩니다. 클래스 파일은 플랫폼 독립적인 바이트코드와 메타데이터(상수 풀, 필드·메서드 시그니처, 속성)를 담은 바이너리 형식입니다. 실행 시점에는 JVM이 이 형식을 읽어 검증·해석·실행합니다.

바이트코드는 스택 기반 가상 머신용 명령으로, 예를 들어 필드를 읽고 쓰는 연산은 getfield/putfield, 메서드 호출은 invokevirtual·invokeinterface·invokedynamic 등으로 표현됩니다. invokedynamic은 람다·메서드 핸들·스크립트 언어 바인딩 등에서 중요한 역할을 합니다.

인터프리터와 JIT

JVM은 처음에는 바이트코드를 인터프리터로 한 줄씩 실행합니다. 반복적으로 호출되거나 뜨거운 루프가 감지되면 JIT 컴파일러가 해당 메서드나 루프를 네이티브 코드로 컴파일합니다. Oracle/OpenJDK 계열 HotSpot에서는 보통 C1(Client Compiler, 빠른 최적화)과 C2(Server Compiler, 공격적 최적화)의 티어드 컴파일을 사용합니다. 메서드는 프로파일 수집을 거치며 한 단계씩 “승격”되며, 최종적으로 C2 수준의 최적화(인라인, 탈출 분석, 루프 최적화 등)를 받을 수 있습니다.

JIT은 관측 가능한 성능을 만드는 핵심이지만, 컴파일 자체에도 비용이 들고, 최적화 순서에 따라 일시적으로 느려졌다 빨라지는 현상이 나타날 수 있습니다. 마이크로 벤치마크에서 “워밍업”을 요구하는 이유가 여기에 있습니다.

런타임 영역과 GC와의 연결(개념)

논리적으로 JVM은 메서드 영역(클래스 메타데이터·상수 풀 등, 구현체에 따라 메타스페이스), 힙(객체 인스턴스), 각 스레드의 스택, PC 레지스터, 네이티브 메서드 스택 등으로 나뉩니다. 가비지 컬렉터는 주로 힙(및 일부 구현에서 메타데이터)을 대상으로 동작하며, JIT·인라인 최적화는 객체 할당 패턴과 함께 실제 GC 부하에 영향을 줍니다.

확인에 쓰는 도구(예시)

# 클래스 파일의 바이트코드 확인 (예: String.hashCode)
javap -c -p java.lang.String

# JIT 컴파일 이벤트 로깅 (JDK에 따라 옵션 이름 확인)
java -XX:+PrintCompilation -jar your-app.jar

javap으로는 컴파일러가 생성한 저수준 연산 순서를 직접 볼 수 있어, 성능 이슈(불필요한 오토박싱, 잘못된 다형 호출 등)를 추적할 때 유용합니다. -XX:+PrintCompilation은 어떤 메서드가 어느 단계로 컴파일되었는지를 보여 주지만, 프로덕션에서는 오버헤드가 있으므로 짧은 재현이나 JFR(Java Flight Recorder) 사용이 더 일반적입니다.


클래스 로딩 메커니즘

로딩·링킹·초기화

클래스 타입은 동적 로딩됩니다. JVM 스펙상 링킹은 검증(verify)·준비(prepare)·해석(resolve)으로 나뉘며, 이후 초기화 단계에서 static 필드 초기화와 static 블록이 실행됩니다. 초기화는 한 번만 일어나며, 스레드 안전하게 보장됩니다.

위임 모델과 ClassLoader

ClassLoader는 부모에게 먼저 로딩을 위임하는 부모 위임(parent delegation) 모델을 따릅니다. 부트스트랩 클래스 로더가 java.base 같은 핵심 모듈을, 플랫폼 클래스 로더(JDK 8까지의 익스텐션 로더를 대체)가 나머지 JDK 모듈 일부를, 애플리케이션 로더가 클래스패스와 모듈 경로를 담당하는 계층입니다. 다만 톰캣 같은 서블릿 컨테이너는 웹 애플리케이션 격리를 위해 자기 클래스를 먼저 찾는 방식으로 이 순서를 일부 뒤집습니다. 이를 통해 코어 클래스를 애플리케이션이 덮어쓰는 사고를 줄이며, 동일한 클래스 이름이라도 로더가 다르면 다른 타입으로 취급될 수 있습니다.

모듈 시스템(JPMS)과 경계

JDK 9 이후 모듈 경로와 module-info.java는 패키지 가시성과 의존성 선언을 컴파일 타임·런타임에 강제합니다. 라이브러리를 패키징할 때는 “어떤 패키지를 export할지”, “어떤 서비스를 provides할지”가 클래스 로딩 설계와 직결됩니다.

리플렉션·프록시와 주의점

Spring·Hibernate 등은 CGLIB·JDK Dynamic Proxy로 런타임에 클래스를 생성합니다. 이때 메타스페이스 사용량이 늘거나, 잘못된 로더 경계로 ClassCastException이 발생할 수 있습니다. 동적 프록시가 많은 애플리케이션에서는 메타스페이스 OOM을 GC 로그와 함께 모니터링하는 것이 좋습니다.


메모리 모델과 동기화(JMM)

happens-before

Java Memory Model(JMM)은 프로그램의 각 읽기·쓰기 동작에 대해 어떤 순서 관계가 보장되는지를 정의합니다. 한 동작의 결과가 다른 동작에서 관측 가능하려면, 두 동작 사이에 happens-before 관계가 있어야 합니다.

대표적인 happens-before 관계는 다음과 같습니다.

  • 같은 모니터에서의 unlock → 이후 lock
  • volatile 쓰기 → 이후 같은 필드에 대한 읽기
  • 스레드 start 이전의 동작 → 시작된 스레드 내 동작
  • Thread.join이 완료된 스레드의 동작 → join을 호출한 스레드 이후 동작

volatile과 synchronized

volatile은 필드에 대한 읽기·쓰기에 대해 가시성과 순서를 보장하지만, i++ 같은 복합 연산의 원자성은 보장하지 않습니다. 락(synchronized)은 상호 배제와 함께 happens-before를 형성해, 임계 영역 안에서의 메모리 연산이 밖으로 일관되게 드러나게 합니다.

java.util.concurrent와 원자성

java.util.concurrent.atomic의 원자 변수와 ConcurrentHashMap 등은 Lock-free·세밀한 락 전략으로 확장성을 높입니다. 그러나 비즈니스 불변 조건이 여러 변수에 걸쳐 있으면, 원자 변수 하나만으로는 부족하고 단일 락·불변 객체·트랜잭션 스레드 같은 상위 설계가 필요합니다.

코드 예: volatile만으로는 부족한 경우

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    // volatile int count = 0;  // i++는 원자적이지 않음
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int get() {
        return count.get();
    }
}

AtomicInteger는 하드웨어의 CAS(Compare-And-Set) 등을 활용해 단일 변수의 원자적 갱신을 보장합니다. 여러 필드를 한 순간의 스냅샷으로 읽어야 한다면, volatile 참조가 가리키는 불변 객체를 바꿔치기하거나, synchronized/ReadWriteLock으로 묶는 편이 안전합니다.


가비지 컬렉션 알고리즘(개요)

세대 가설과 영역

대부분의 객체는 짧게 살고, 소수만 오래 산다는 세대 가설에 기반해, 힙은 Young(Eden·Survivor)과 Old로 나뉘는 방식이 널리 쓰였습니다. G1 GC는 힙을 리전(region) 단위로 쪼개 부분 수집과 이동(evacuation)을 조합합니다.

G1 (Garbage-First)

G1은 “가비지가 많은 리전부터” 수집하는 방향으로 STW(Stop-The-World) 시간을 통제하려 합니다. Mixed GC 단계에서 Old 영역 일부를 함께 정리할 수 있으며, Humongous 객체는 큰 연속 영역 요구 등으로 동작이 달라질 수 있어, 대용량 배열·바이트 버퍼는 힙 파편화와 함께 검토 대상이 됩니다.

ZGC·Shenandoah (저지연 지향)

ZGC와 Shenandoah는 대부분의 작업을 동시(concurrent)에 가깝게 수행하려는 설계로, 매우 짧은 STW를 목표로 합니다. 대신 CPU·메모리 오버헤드, JDK·OS 지원 범위, 특정 워크로드에서의 처리량 트레이드오프를 반드시 벤치마크로 확인해야 합니다.

GC 로그 읽기와 힙 크기

JDK 9부터 GC 로그는 통합 로깅(-Xlog:gc*)으로 바뀌어, 그 이전의 -XX:+PrintGCDetails 계열 옵션은 더 이상 쓰이지 않습니다. 로그에서 보이는 증상은 원인을 좁혀 줍니다. 풀 GC가 잦다면 Old 영역으로 넘어가는 객체가 많거나 힙이 작은 경우가, 할당 속도가 급증했다면 특정 코드 경로나 캐시 폭주가, 메타스페이스 고갈이라면 동적 클래스 생성(프록시, 스크립트 엔진)이 의심 대상입니다.

힙을 크게 잡으면 GC 횟수는 줄지만, 수집기에 따라 한 번에 처리할 양이 늘어 일시 정지가 길어질 수 있습니다. G1은 -XX:MaxGCPauseMillis 목표에 맞춰 수집 범위를 조절하므로 힙을 키워도 정지 시간이 비례해 늘지는 않지만, 목표를 너무 낮게 잡으면 처리량이 떨어집니다. 결국 힙 크기는 응답 시간 목표와 함께 부하 테스트로 정해야 합니다.


프로덕션 Java 패턴

관측성: JFR, Micrometer, 로그 상관관계

프로덕션에서는 지연 분포(p95/p99), GC 이벤트, 스레드 상태, 풀 큐 길이를 함께 봐야 합니다. JFR은 프로덕션 안전 범위에서 샘플링 프로파일을 제공하며, Micrometer 등은 메트릭을 Prometheus·OTel로 내보내기 좋습니다. 요청 ID를 MDC에 심어 로그·트레이스를 엮으면, JIT·GC·락 경합이 의심될 때 원인 추적이 빨라집니다.

풀링·한계·백프레셔

DB 커넥션 풀(HikariCP 등), HTTP 클라이언트 풀, 스레드 풀은 자원 상한을 만듭니다. 풀 크기를 늘리기 전에 왜 대기가 생기는지(외부 지연, 락, 디스크)를 분리해 측정해야 합니다. 무제한 큐는 장애를 은폐했다가 메모리 폭주로 이어지기 쉬우므로, 유한 큐 + 거절·재시도 정책이 운영에 유리한 경우가 많습니다.

우아한 종료와 가상 스레드

Spring Boot 등은 SIGTERM에 맞춰 커넥션·스레드 풀을 닫는 그레이스풀 셧다운 훅을 제공합니다. Kubernetes 환경에서는 terminationGracePeriodSeconds와 맞춰 드레인 시간을 설계합니다.

JDK 21의 가상 스레드(Project Loom)는 블로킹 I/O를 저렴하게 표현하지만, 캐리어 스레드에 고정되는 핀(pinning), 네이티브 호출, 스레드마다 큰 객체를 두는 ThreadLocal 설계, 동시 실행 수를 풀 크기로 제한하던 기존 코드와의 충돌을 프로파일로 검증해야 합니다. JDK 21~23에서는 synchronized 블록 안에서 블로킹하면 핀이 발생하지만, JDK 24(JEP 491)부터는 synchronized로 인한 핀이 해소되었으므로 사용하는 JDK 버전에 따라 대응이 달라집니다. 자세한 이행 전략은 가상 스레드 마이그레이션 가이드를 참고합니다.

방어적 설계

동시성 버그의 상당수는 공유 상태에서 나오므로, 불변 객체와 명확한 소유권으로 공유 자체를 줄이는 것이 가장 효과적입니다. 외부 호출에는 타임아웃과 취소를 기본으로 두어야 합니다. HttpClient나 CompletableFuture는 기본적으로 무기한 기다릴 수 있어, 하류 서비스 하나가 멈추면 스레드나 풀이 모두 묶입니다. 캐시에는 상한과 만료를 두어 앞에서 말한 메모리 증가와 GC 부담을 막고, Java 직렬화처럼 신뢰할 수 없는 입력을 역직렬화하는 경로는 원격 코드 실행 취약점의 통로가 될 수 있으므로 필터(ObjectInputFilter)를 걸거나 JSON 같은 데이터 전용 형식으로 바꿉니다.


자주 묻는 질문 (FAQ)

Q. DB 커넥션 풀에서 대기가 생기면 풀 크기부터 늘리면 되나요?

A. 먼저 왜 커넥션을 오래 잡고 있는지부터 측정해야 합니다. 느린 쿼리, 락 대기, 트랜잭션 안에서의 외부 API 호출 같은 원인이 그대로라면 풀을 늘려도 DB 쪽 부하만 커지고 대기는 다시 생깁니다. JFR이나 Micrometer로 풀 대기 시간과 쿼리 지연 분포(p95/p99)를 함께 보고, 원인을 분리한 뒤에 풀 크기를 조정합니다.


같이 보면 좋은 글