한 줄 정의

핵심 메시지

자바 가상 머신은 개발자의 부담을 줄이기 위한 관리되는 추상화 계층입니다. 클래스 로딩·바이트코드 인터프리팅·JIT 컴파일·GC·스레딩이 모두 JVM 내부에서 자동으로 일어나기 때문에, 성능 문제를 분석하려면 이 스택이 어떻게 맞물려 돌아가는지부터 알아야 합니다.

쉽게 말하면

JVM은 자바 코드를 대신 운전해주는 운전기사 같은 존재입니다. 개발자는 “이 클래스 실행해줘”라고만 말하면, 운전기사가 알아서 클래스 파일을 찾아 읽고, 검증하고, 인터프리터로 한 줄씩 실행하다가 자주 다니는 길은 JIT으로 지름길을 만들고, 쓸모없어진 짐(객체)은 GC가 치워줍니다.

문제는 운전기사가 너무 알아서 잘해주다 보니, 정작 차가 막힐 때(성능 문제) 운전기사가 어떤 길로 가고 있었는지 모르면 원인을 찾을 수가 없다는 점입니다.

왜 중요한가?

자바는 고수준 설계로 개발자의 인지 부담을 낮추는 것을 목표로 합니다. 가비지 컬렉션과 실행 최적화 같은 작업을 JVM이 대신 처리해주는 덕분에 많은 개발자가 저수준 세부 사항을 깊이 알지 않아도 개발할 수 있습니다.

그 결과 내부적인 동작 방식은 성능 문제가 발생하여 고객이 이를 제기하는 등의 상황에서야 비로소 직면하는 경우가 많습니다. 평소에 신경 쓰지 않다가, 장애가 났을 때 비로소 JVM 내부 구조를 공부하기 시작하는 패턴입니다.

성능에 관심이 있는 개발자라면 JVM 기술 스택의 기본을 이해하는 것이 중요합니다. 깊이 이해하면 더 효율적인 소프트웨어를 작성할 수 있을 뿐만 아니라, 성능 문제를 효과적으로 분석하고 해결하는 데 필요한 이론적 배경도 갖출 수 있습니다.

핵심 내용

3.1 인터프리팅과 클래스 로딩

스택 기반 인터프리터 머신

JVM 사양에 따르면 JVM은 스택 기반 인터프리터 머신(stack-based interpreted machine) 입니다. 하드웨어 CPU처럼 레지스터를 사용하는 대신, 연산의 중간 결과를 저장하는 실행 스택을 사용하며, 스택의 최상위 값(또는 값들)을 기반으로 계산을 수행합니다.

인터프리터의 동작은 단순합니다. while 루프 안의 switch으로 이해하면 됩니다. 각 연산 코드(opcode)를 이전 연산과 독립적으로 처리하고, 계산 결과와 중간 결과를 평가 스택에 저장합니다.

실제 인터프리터의 복잡도

오라클/OpenJDK 핫스팟의 내부 구조를 깊이 탐구하면, 실제 프로덕션 인터프리터는 훨씬 더 복잡합니다. 다만 기본 이해를 위해서는 while + switch 모델로 충분합니다.

클래스 로딩 절차

java HelloWorld 명령으로 애플리케이션을 시작하면 OS가 JVM 프로세스를 띄우고, JVM은 그 안에서 인터프리터를 초기화합니다. 진입점은 HelloWorld.classmain() 메서드인데, 메인이 실행되려면 그 전에 클래스가 로드되어 있어야 합니다. 이를 위해 클래스 로더 체인이 동작합니다.

graph TD
    A[부트스트랩 클래스 로더<br/>java.base 등 핵심 모듈] --> B[플랫폼 클래스 로더<br/>나머지 JDK 모듈]
    B --> C[애플리케이션 클래스 로더<br/>사용자 클래스 경로]
    C -.->|찾지 못하면 위임| B
    B -.->|찾지 못하면 위임| A
    A -.->|최종 실패| D[ClassNotFoundException]
클래스 로더 3계층
  • 부트스트랩 클래스 로더: java.lang.Object, Class, ClassLoader 같은 필수 클래스를 로드합니다. 실제로는 java.base 모듈을 비롯해 java.security.sasl, java.datatransfer 등 자바 런타임에 필수적인 모듈들을 로드합니다.
  • 플랫폼 클래스 로더: 부트스트랩이 로드하지 않는 나머지 JDK 모듈(자바 8 이전의 rt.jar 일부)을 담당합니다. ClassLoader::getPlatformClassLoader로 접근할 수 있고, 기존의 확장(extension) 클래스 로더를 대체합니다.
  • 애플리케이션 클래스 로더: 클래스 경로에서 사용자 클래스를 로드합니다. ‘시스템 클래스 로더’라고도 불리지만 시스템 클래스를 로드하지 않으므로 적절한 이름은 아닙니다.

부트스트랩은 검증을 생략합니다

부트스트랩 클래스 로더는 시작 성능을 위해 로드하는 클래스를 검증하지 않습니다. 대신 부트 클래스 경로가 안전하다는 전제에 의존합니다. 부트스트랩이 로드한 모든 항목에는 전체 보안 권한이 부여되므로, 이 모듈 그룹은 가능한 한 최소한으로 유지됩니다.

자바 모듈 시스템과 모듈 그래프

자바 9부터 모든 JVM은 모듈화되어 있으며, 자바 8까지 쓰이던 모놀리식 런타임을 복원하는 ‘호환성 모드’는 더 이상 존재하지 않습니다.

애플리케이션이 모듈화되어 있지 않더라도, 실행 시 반드시 모듈 그래프가 생성됩니다. 이 그래프는 방향이 있는 비순환 그래프(DAG, directed acyclic graph)여야 하며, 모듈 메타데이터가 순환을 포함하면 치명적 오류가 발생합니다.

모듈 그래프의 장점은 두 가지입니다.

  • 필요한 모듈만 로드합니다.
  • 모듈 간 메타데이터의 유효성을 시작 시점에서 확인할 수 있습니다.

애플리케이션이 모듈화되지 않은 경우, 모듈 경로와 클래스 경로를 모두 사용하게 되며 애플리케이션 코드는 UNNAMED 모듈에 포함됩니다.

클래스 식별과 중복 로드

자바는 한 클래스를 한 번만 로드하며, 런타임 환경에서 해당 클래스를 나타내는 Class 객체가 생성됩니다. 하지만 특정 상황에서는 동일한 클래스가 서로 다른 클래스 로더로 중복 로드될 수 있습니다.

따라서 시스템 내의 클래스는 클래스 로더 + 패키지 이름을 포함한 완전히 정규화된 클래스 이름(FQCN, fully qualified class name) 을 기준으로 구분됩니다.

멀티테넌트 환경의 의도적 중복 로드

톰캣이나 제이보스 EAP 같은 애플리케이션 서버에서는 동일한 클래스가 서로 다른 클래스 로더로 여러 번 로드되는 동작이 의도적으로 발생합니다. 여러 테넌트 애플리케이션이 서버에서 실행될 때, 각 테넌트가 필요한 클래스의 다른 버전을 사용할 수 있도록 하기 위함입니다.

또한 자바 에이전트 같은 일부 도구는 바이트코드 위빙(bytecode weaving) 과정에서 클래스를 다시 로드하거나 변환할 수 있습니다. 주로 모니터링과 관측성을 강화하는 용도로 활용됩니다.

3.2 바이트코드 실행

컴파일 단계

자바 소스 코드는 실행되기 전에 여러 단계의 변환을 거칩니다. 첫 단계는 javac를 사용하는 컴파일 과정이며, 결과로 .class 파일(바이트코드) 이 생성됩니다.

javac는 컴파일 과정에서 최적화를 거의 수행하지 않습니다. 그 결과 생성된 바이트코드는 javap 같은 디스어셈블리 도구로 확인하면 여전히 자바 코드와 유사한 형태로 읽고 이해하기 쉽습니다. JVM의 진짜 강력함은 컴파일 시점이 아닌 런타임에서 발휘됩니다.

바이트코드의 의미

바이트코드는 특정 머신 아키텍처에 종속되지 않은 중간 표현(intermediate representation) 입니다. 이를 통해 두 가지 추상화를 제공합니다.

  • 하드웨어 추상화: JVM이 지원하는 모든 플랫폼에서 같은 바이트코드를 실행할 수 있습니다.
  • 언어 추상화: 자바 언어와 JVM은 독립적으로 발전했습니다. JVM은 유효한 클래스 파일을 생성할 수 있는 모든 언어를 실행할 수 있습니다(예: 코틀린 컴파일러도 JVM 바이트코드를 생성합니다).
클래스 파일 구조

JVM은 클래스를 로드할 때 클래스 파일이 예상되는 형식에 부합하는지 검증합니다. 모든 클래스 파일은 매직 넘버 0xCAFEBABE 로 시작합니다. 이 값은 클래스 파일 형식의 고유 식별자로, 첫 4바이트를 차지합니다. 그 뒤 4바이트는 마이너 버전과 메이저 버전을 나타냅니다.

구성 요소설명
매직 넘버0xCAFEBABE (클래스 파일임을 나타내는 고유 식별자)
버전마이너 버전 / 메이저 버전
상수 풀클래스, 인터페이스, 필드, 메서드, 문자열, 숫자 등의 참조 테이블
접근 제어 플래그public, final, 인터페이스/추상 클래스 여부, 합성·어노테이션·열거형 여부
현재 클래스현재 클래스의 이름
슈퍼 클래스슈퍼 클래스의 이름
인터페이스구현하는 인터페이스 목록
필드멤버 변수 목록
메서드정의된 메서드 목록(바이트코드 포함)
속성클래스 속성(예: 소스 파일 이름, Code 속성 등)

JVM은 클래스 파일의 버전을 확인하여 현재 실행 중인 JVM이 해당 파일을 실행할 수 있는지 검증합니다. 클래스 로더가 마이너 또는 메이저 버전을 검사해 호환성을 확인하며, 버전이 맞지 않으면 실행 시 UnsupportedClassVersionError 가 발생합니다. 이는 런타임 JVM 버전이 클래스 파일을 컴파일한 버전보다 낮다는 것을 의미합니다.

클래스 파일 구조 암기법

MVCATSIFMAMy Very Cute Animal Turns Savage In Full Moon Areas 매직(M) - 버전(V) - 상수(C) - 접근 제어(A) - 현재 클래스(T) - 슈퍼 클래스(S) - 인터페이스(I) - 필드(F) - 메서드(M) - 속성(A)

0xCAFEBABE와 0xCAFEDADA

매직 넘버는 유닉스 환경에서 파일 유형을 식별하는 방법(윈도우의 확장자 대안)입니다. 한 번 결정되면 변경하기 어렵기 때문에 자바는 앞으로도 0xCAFEBABE라는 다소 논란이 될 수 있는 값을 유지할 가능성이 큽니다. 자바 9에서는 모듈 파일을 위해 새로운 매직 넘버 0xCAFEDADA를 도입했습니다.

javap로 바이트코드 들여다보기

다음 코드를 javac로 컴파일한 뒤 javap -c HelloWorld로 디스어셈블하면 바이트코드 구조를 직접 확인할 수 있습니다.

public class HelloWorld {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            System.out.println("Hello World");
        }
    }
}
public class HelloWorld {
  public HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1   // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: bipush        10
       5: if_icmpge     22
       8: getstatic     #2   // Field java/lang/System.out ...
      11: ldc           #3   // String Hello World
      13: invokevirtual #4   // Method java/io/PrintStream.println ...
      16: iinc          1, 1
      19: goto          2
      22: return
}

소스 코드에는 main()만 정의했지만, javac가 자동으로 추가한 기본 생성자(default constructor) 가 함께 출력됩니다. 생성자에서 aload_0this 참조를 스택에 올리고, invokespecial이 부모(Object)의 생성자를 호출합니다.

main() 안의 동작을 따라가 보면 자바 인터프리터가 어떻게 작동하는지 명확히 보입니다.

  • iconst_0istore_1: 정수 상수 0을 평가 스택에 푸시하고, 로컬 변수 인덱스 1(반복문 변수 i)에 저장합니다.
  • 로컬 인덱스 0은 인스턴스 메서드에서 항상 this를 가리키므로 정적 메서드에서도 인덱스 1부터 사용자 변수가 시작됩니다.
  • iload_1bipush 10if_icmpge 22: i를 스택에 로드한 뒤 10을 푸시하고, “크거나 같으면 인덱스 22번으로 점프”하는 비교를 수행합니다.
  • 거짓이면(i < 10) 인덱스 8번으로 흘러가 System.out을 가져오고, 상수 풀에서 "Hello World"ldc로 로드한 뒤 invokevirtualprintln을 호출합니다.
  • iinc 1, 1i를 1 증가시키고, goto 2로 다시 비교 지점으로 돌아갑니다.
  • 비교가 참이 될 때(i가 10 이상) 인덱스 22의 return으로 메서드를 종료합니다.

연산 코드는 단순화되어 있습니다

JVM의 연산 코드는 명령어를 간소화하여 효율적이고 플랫폼 독립적인 코드 실행을 지원합니다. 데이터 타입, 수행 연산, 로컬 변수·상수 풀·스택 간 상호작용을 압축적으로 표현합니다.

3.3 핫스팟 소개

1999년 4월, 썬 마이크로시스템즈는 핫스팟 JVM을 도입해 자바 성능을 C·C++와 비교할 수 있는 수준까지 끌어올렸습니다(일부 경우에는 이를 능가합니다).

제로-오버헤드 추상화 vs 동적 적응 최적화

언어 설계에는 두 가지 큰 갈래가 있습니다.

  • C++ 식 제로-오버헤드 원칙: “사용하지 않으면 비용이 들지 않는다. 사용하는 경우, 직접 코딩하는 것보다 더 나은 코드를 제공한다.” (비야네 스트로스트룹)
  • 자바 식 동적 적응: 런타임 동작을 분석하여 성능을 극대화할 수 있는 부분에 최적화를 적용합니다.

제로-오버헤드 원칙은 이론적으로 훌륭하지만, 현실에서는 언어 사용자가 운영체제와 컴퓨터의 저수준 동작을 직접 다뤄야 한다는 부담을 줍니다. 게다가 이 원칙을 따르려면 소스 코드를 빌드 시점에 특정 플랫폼에 맞는 기계어로 변환해야 하며, 이를 AOT 컴파일(ahead-of-time compilation) 이라고 합니다.

핫스팟이 택한 길

자바는 제로-오버헤드 추상화 철학을 따르지 않았습니다. 대신 핫스팟 JVM은 프로그램의 런타임 동작을 분석하여 성능을 극대화할 수 있는 부분에 최적화를 적용하는 방식을 택했습니다.

핫스팟의 목표는 개발자가 자바 언어의 관용적 사용을 따르고, 좋은 설계 원칙을 지키면서 성능을 최적화할 수 있도록 돕는 것입니다. 이는 개발자가 가상 머신에 맞추기 위해 프로그램 구조를 왜곡하지 않도록 합니다.

graph LR
    A[자바 소스 코드] --> B[javac]
    B --> C[.class 파일]
    C --> D[클래스 로더]
    D --> E[인터프리터]
    E -.->|hot| F[프로파일러]
    F --> G[이미터/JIT]
    G --> H[코드 캐시]
    H --> E
    D --> I[메모리 캐시]
핫스팟의 장점
  • 재컴파일 없는 성능 향상: 수십 년에 걸친 엔지니어링이 누적되어 있으며, 새 릴리스마다 최적화와 기능이 추가됩니다. 덕분에 자바 애플리케이션을 다시 컴파일하지 않아도 최신 핫스팟의 성능 최적화를 그대로 활용할 수 있습니다.
  • PGO(profile-guided optimization): 런타임 정보를 활용해 동적 인라이닝(dynamic inlining) 이나 가상 호출 최적화(virtual call optimization) 같은 최적화를 적용합니다. 대부분의 AOT 플랫폼에서는 불가능한 방식입니다.
  • CPU 기능 감지: 핫스팟은 가상 머신이 실행될 때 CPU의 정확한 유형을 감지하고, 이를 기반으로 특정 프로세서 기능을 활용한 최적화를 활성화합니다.

성능 문제 조사 시 핵심 인사이트

자바 소스 코드가 바이트코드로 변환된 후, JIT 컴파일을 거치면서 실제로 실행되는 코드는 원래 작성된 소스 코드와 매우 다르게 변합니다. JIT 컴파일된 코드는 JVM에서 실행될 때 원래의 자바 소스 코드와 전혀 다른 형태일 가능성이 높습니다.

제로-오버헤드의 한계

“더 예측할 수 있다”는 것이 반드시 “더 우수한 성능”을 의미하지는 않습니다. AOT 컴파일러는 다양한 프로세서에서 실행될 코드를 생성해야 하므로, 특정 프로세서 기능이 지원된다고 가정할 수 없습니다. 반면 핫스팟은 PGO 덕분에 실행 중인 머신에 특화된 최적화가 가능합니다.

단순화된 사고 모델의 위험

핫스팟이 채택한 정교한 접근 방식은 일반 개발자에게 큰 이점을 제공합니다. 그러나 제로-오버헤드 추상화를 포기한 이러한 접근 방식은, 특히 고성능 자바 애플리케이션을 개발할 때 주의가 필요합니다. 개발자가 ‘상식적인’ 추론이나 자바 애플리케이션의 실행 방식에 대한 지나치게 단순한 사고 모델을 기반으로 판단해서는 안 됩니다.

3.4 JIT 컴파일 소개

JIT의 동작 원리

자바 프로그램은 실행 시 바이트코드 인터프리터에서 시작하며, 명령어는 가상화된 스택 머신에서 처리됩니다. CPU로부터 추상화된 이 방식은 클래스 파일의 이식성이라는 장점을 제공하지만, 최적의 성능을 얻으려면 프로그램이 네이티브 기능을 최대한 활용해야 합니다.

핫스팟은 해석된 바이트코드를 네이티브 코드로 컴파일하여 성능을 향상시킵니다. 핫스팟의 컴파일 단위는 메서드와 루프이며, 이 과정을 JIT 컴파일이라고 부릅니다.

프로파일 기반 컴파일

JIT 컴파일은 애플리케이션이 해석 모드로 실행되는 동안 실행 빈도가 높은 코드 부분을 관찰하며 작동합니다. 특정 메서드의 실행 빈도가 일정 임계값을 초과하면, 프로파일러가 해당 코드를 컴파일하고 최적화를 적용합니다.

이 접근 방식의 큰 장점은 메서드가 해석되는 동안 수집된 실행 추적 정보(trace information) 를 기반으로 최적화 결정을 내릴 수 있다는 점입니다.

재컴파일

일부 JIT 컴파일러는 실행 중 더 나은 최적화가 가능하다고 판단되면, 기존에 컴파일된 코드를 다시 컴파일할 수 있습니다. 핫스팟의 일부 컴파일러도 이 기능을 제공합니다.

3.5 자바 가상 머신 메모리 관리

수동 관리의 부담

C, C++, 오브젝티브 C에서는 프로그래머가 직접 메모리를 할당하고 해제해야 합니다. 객체의 생성·삭제를 명확하게 제어할 수 있어 성능을 예측하기 쉽고 리소스 수명 주기를 관리할 수 있다는 장점이 있습니다.

그러나 동시에 막대한 비용을 수반합니다. 메모리 구조와 동작을 완벽히 이해해야 하기 때문입니다. 수십 년간의 실무 경험에 따르면, 많은 개발자가 메모리 관리에 필요한 관용구와 패턴을 제대로 이해하지 못하며, 이로 인해 잘못된 메모리 관리가 애플리케이션 오류의 주요 원인이었습니다.

가비지 컬렉션과 힙

자바는 이 문제를 해결하기 위해 가비지 컬렉션을 통해 자동으로 관리되는 힙(heap) 메모리를 도입했습니다. JVM이 더 많은 메모리 할당이 필요할 때, 더 이상 필요하지 않은 메모리를 회수하고 재사용하는 비결정적 프로세스를 수행합니다.

Stop-the-world의 비용

GC에는 비용이 따릅니다. 전통적으로 GC가 실행되면 애플리케이션이 멈추는 ‘정지된 세상(stopped the world)’ 상태가 발생합니다. 이 정지 시간은 매우 짧지만, 애플리케이션이 과부하 상태에 놓이면 길어질 수 있습니다.

그럼에도 불구하고 JVM의 가비지 컬렉션은 업계 최고 수준이며, 컴퓨터 과학 학부 과정에서 다루는 기본 알고리즘보다 훨씬 정교합니다. 최신 알고리즘에서는 애플리케이션이 멈추는 시간을 최소화하거나 아예 제거하는 방식으로 발전하고 있습니다.

3.6 스레딩과 자바 메모리 모델

자바의 기본 스레드 모델

자바가 처음 출시될 때 도입한 주요 혁신 중 하나는 멀티스레드 프로그래밍을 기본적으로 지원하는 것이었습니다.

Thread t = new Thread(() -> {System.out.println("Hello World!");});
t.start();

모든 프로덕션 JVM은 멀티스레드 방식으로 동작합니다. 즉, 모든 자바 프로그램이 본질적으로 멀티스레드 환경에서 실행되며, JVM 프로세스의 일부로 동작하기 때문에 멀티스레드 환경의 복잡성을 자연스럽게 수반합니다. 성능 분석을 더 어렵게 만들기도 하지만, 반대로 JVM이 사용할 수 있는 모든 코어를 활용할 수 있도록 하여 성능 최적화 측면에서 장점을 제공합니다.

애플리케이션 스레드와 플랫폼 스레드의 역사

초기 자바에서는 애플리케이션 스레드가 플랫폼 스레드 풀에 매핑되거나 다중화되었습니다(솔라리스의 M:N 모델, 리눅스의 그린 스레드 모델). 그러나 이 방식은 만족스러운 성능을 제공하지 못했고 불필요한 복잡성을 초래했습니다.

결과적으로 주류 JVM 구현에서는 각 애플리케이션 스레드가 하나의 플랫폼 스레드(OS 스레드)에 정확히 대응하는 방식으로 변경되었습니다.

Project Loom과 가상 스레드

‘애플리케이션 스레드 == 플랫폼 스레드’ 전환 이후 20여 년 동안, 애플리케이션은 대규모로 성장하고 확장되었습니다. 그 과정에서 스레드(또는 더 일반적으로 실행 컨텍스트)의 수가 크게 증가했고, ‘스레드 병목(thread bottleneck)’ 문제가 발생했습니다. 이를 해결하는 것이 OpenJDK의 ‘룸 프로젝트(Project Loom)’ 의 주요 연구 과제가 되었습니다.

그 결과로 나온 것이 가상 스레드(virtual threads) 입니다. 자바 21 이상에서만 사용할 수 있는 새로운 스레드 형태로, 특히 네트워크 I/O와 같이 많은 동시 실행 컨텍스트를 필요로 하는 작업에서 강력한 성능을 발휘합니다.

프로그래머는 스레드를 명시적으로 가상 스레드로 생성하도록 선택해야 합니다. 그렇지 않으면 플랫폼 스레드로 생성되며 기존과 동일한 동작을 유지합니다. 따라서 가상 스레드 기능이 추가된 JVM에서도 기존 자바 프로그램의 동작은 그대로 보장됩니다.

자바 21 이전의 스레드

자바 21 이전의 모든 스레드(또는 플랫폼 스레드)는 Thread 객체의 start() 메서드가 호출될 때 생성되는 고유한 OS 스레드로 지원된다고 간주해도 안전합니다.

자바의 멀티스레드 설계 원칙

자바의 멀티스레드 접근 방식은 1990년대 후반에 설계되었으며, 다음과 같은 기본 설계 원칙을 따릅니다.

  • 자바 프로세스의 모든 스레드는 하나의 공통적인 가비지 컬렉션 힙을 공유합니다.
  • 하나의 스레드에서 생성된 객체는 해당 객체에 대한 참조를 가진 다른 스레드에서 접근할 수 있습니다.
  • 객체는 기본적으로 변경 가능합니다. 즉, 객체 필드에 저장된 값은 프로그래머가 명시적으로 final 키워드를 사용해 불변임을 표시하지 않는 한 변경될 수 있습니다.
자바 메모리 모델(JMM)

자바 메모리 모델(JMM, Java memory model) 은 서로 다른 실행 스레드가 객체에 저장된 변화를 어떻게 관찰하는지 정의합니다. 예를 들어, 스레드 A와 B가 모두 객체 obj에 대한 참조를 가지고 있을 때, A가 이를 변경하면 B가 그 변화를 어떻게 볼 수 있는지를 설명합니다.

이 질문은 단순해 보이지만 실제로는 훨씬 복잡합니다. OS 스케줄러가 플랫폼 스레드를 CPU 코어에서 강제로 제거할 수 있기 때문입니다. 이 과정에서 다른 스레드가 실행을 시작하면, 원래 스레드가 객체 처리를 완료하기 전에 객체에 접근할 수 있습니다. 그 결과 객체가 이전 상태로 되돌아가거나 심지어 유효하지 않은 상태로 간주될 수 있습니다.

자바는 동시 실행 중 발생할 수 있는 객체 손상을 방지하기 위해 상호 배제 잠금(mutual exclusion lock) 이라는 핵심 메커니즘을 제공합니다.

3.7 자바 가상 머신 모니터링 또는 도구

JVM은 성숙한 실행 플랫폼으로, 실행 중인 애플리케이션의 계측(instrumentation), 모니터링, 관측성을 지원하기 위한 다양한 기술적 대안을 제공합니다.

JVM 관측 도구 4가지
  • 자바 관리 확장 프로그램(JMX, Java Management Extensions): JVM과 그 위에서 실행되는 애플리케이션을 제어하고 모니터링하기 위한 범용 기술. 클라이언트 애플리케이션에서 매개변수를 변경하거나 메서드를 호출할 수 있습니다. RMI(원격 메서드 호출, Remote Method Invocation)와 결합하여 원격 관리도 가능합니다.
  • 자바 에이전트: java.lang.instrument 인터페이스를 활용하여 클래스가 로드될 때 메서드의 바이트코드를 수정할 수 있는 도구 구성 요소.
  • 자바 가상 머신 도구 인터페이스(JVMTI, JVM tool interface): JVM의 네이티브 인터페이스로, 에이전트가 본질적으로 C나 C++로 작성되어야 합니다. JVM에서 발생하는 이벤트를 모니터링하거나 통보받을 수 있는 통신 인터페이스입니다.
  • 서비스 지원 에이전트(SA, serviceability agent): 자바 객체와 핫스팟 데이터 구조를 모두 노출할 수 있는 API와 도구의 집합. 대상 JVM에서 코드를 실행할 필요가 없으며, 실행 중인 자바 프로세스뿐만 아니라 코어 파일(또는 크래시 덤프 파일)도 디버깅할 수 있습니다.
자바 에이전트의 동작 방식

자바 에이전트는 매우 강력한 기능을 갖고 있으며, 설치하면 기존의 표준 애플리케이션 수명 주기가 변경될 수 있습니다.

에이전트를 설치하려면 JAR 파일로 패키징한 후 JVM 시작 시 플래그를 통해 제공해야 합니다.

-javaagent:<path-to-agent-jar>=<options>

에이전트 JAR 파일에는 META-INF/MANIFEST.MF라는 매니페스트(manifest) 파일이 포함되어야 하며, 이 파일에는 Premain-Class 속성이 반드시 포함되어야 합니다. 이 속성은 에이전트 클래스의 이름을 포함하며, 해당 클래스는 자바 에이전트의 등록 훅(hook)으로 동작하는 public static premain() 메서드를 반드시 구현해야 합니다.

premain() 메서드는 애플리케이션의 main() 메서드가 실행되기 전에 메인 애플리케이션 스레드에서 실행됩니다(이름이 premain인 이유입니다). premain()이 종료되지 않으면 메인 애플리케이션이 시작되지 않는다는 점에 유의해야 합니다.

바이트코드 변환은 에이전트의 주된 목적이며, ClassFileTransformer 인터페이스를 구현한 바이트코드 변환 객체를 생성하고 등록하여 수행됩니다.

네이티브 에이전트(JVMTI)

자바 계측 API가 충분하지 않은 경우 JVMTI를 사용할 수 있습니다. JVM의 네이티브 인터페이스이기 때문에 이를 사용하는 에이전트는 C 또는 C++ 같은 네이티브 컴파일 언어로 작성되어야 합니다.

-agentlib:<agent-lib-name>=<options>
또는
-agentpath:<path-to-agent>=<options>

JVMTI 에이전트의 단점은 명확합니다. 에이전트에서 발생하는 프로그래밍 오류는 실행 중인 애플리케이션을 손상시키거나 심지어 JVM 자체를 충돌시킬 수도 있습니다. 따라서 가능하다면 JVMTI 코드 대신 자바 에이전트를 작성하는 것이 일반적으로 더 선호됩니다. 다만 자바 API로 접근할 수 없는 데이터가 필요하다면 JVMTI가 유일한 대안입니다.

VisualVM

비주얼 가상 머신(VisualVM)은 NetBeans 플랫폼 기반의 그래픽 도구입니다. 과거에는 JDK 68 또는 GraalVM 1923.0 버전에 포함되어 있었지만, 이후 메인 배포에서 제외되어 별도 다운로드가 필요합니다.

jconsole의 후속

jvisualvm은 이전 자바 버전에서 사용되었지만 이제는 더 이상 사용되지 않는 jconsole 도구를 대체합니다. 여전히 jconsole을 사용하고 있다면 VisualVM으로 전환하는 것이 좋습니다. VisualVM은 jconsole 플러그인을 실행할 수 있도록 호환성 플러그인을 제공합니다.

VisualVM은 실행 중인 프로세스를 실시간으로 모니터링하는 도구로, JVM의 attach 메커니즘을 활용합니다. 로컬 프로세스는 화면 왼쪽에 자동으로 나열되며, 더블 클릭하면 우측 창에 새 탭으로 나타납니다.

원격 프로세스에 연결하려면 원격 측에서 JMX를 통해 인바운드 연결을 허용해야 합니다. 일반적인 자바 프로세스의 경우 원격 호스트에서 jstatd 가 실행 중이어야 합니다(기본 포트 1099, 변경 가능).

컨테이너 환경에서의 jstatd

많은 애플리케이션 서버와 실행 컨테이너는 서버 내부에서 jstatd와 동일한 기능을 제공합니다. 이러한 프로세스는 JMX와 RMI 트래픽을 포트 포워딩할 수 있는 경우, 별도의 jstatd 프로세스가 필요하지 않습니다.

VisualVM의 5가지 탭
제공하는 정보
개요자바 프로세스 요약, 전달된 플래그, 시스템 속성, 정확한 자바 버전
모니터jconsole과 가장 유사한 뷰. CPU·힙 사용량 등 JVM 상위 수준 텔레메트리, 로드/언로드 클래스 수, 실행 스레드 개요
스레드각 스레드(애플리케이션·VM 스레드 포함)의 타임라인과 상태, 스레드 덤프 생성
샘플러와 프로파일러CPU·메모리 사용량의 간소화된 샘플링

VisualVM의 플러그인 아키텍처는 코어 플랫폼에 추가 도구를 통합하여 기본 기능을 확장할 수 있습니다. JMX 콘솔과의 상호작용, 레거시 JConsole과의 연결, 매우 유용한 GC 플러그인 VisualGC 등이 포함됩니다.

3.8 자바 구현, 배포 또는 릴리스

자바 = 오라클 JDK 라는 오해

많은 개발자가 자바라고 하면 오라클이 제작한 자바 바이너리(오라클 JDK)만 떠올립니다. 그러나 자바 배포본 시장은 생각보다 훨씬 복잡합니다. 자바 구현을 빌드하는 데 필요한 소스 코드는 두 부분으로 나뉩니다.

  • 가상 머신 소스 코드 (핫스팟 등)
  • 클래스 라이브러리 소스 코드

이 둘을 합쳐서 빌드한 결과물이 우리가 사용하는 JDK 바이너리입니다. 그리고 이 결합을 가장 표준적인 형태로 제공하는 것이 OpenJDK 프로젝트입니다.

OpenJDK가 모든 것의 출발점

OpenJDK는 자바의 오픈 소스 레퍼런스 구현으로, GPLv2 + Classpath Exception(GPLv2+CE) 라이선스를 따릅니다. 오라클이 주도하고 있으며 OpenJDK 코드베이스에 기여하는 대부분의 엔지니어는 오라클 소속입니다.

중요한 점은 OpenJDK가 소스 코드만 제공한다는 사실입니다. 가상 머신(핫스팟)과 클래스 라이브러리 모두 소스 코드 형태로만 존재합니다. 소스 코드만으로는 개발자에게 큰 유용성이 없으므로, 누군가는 이를 빌드·테스트·인증(certified) 절차를 거쳐 바이너리 배포판으로 만들어야 합니다.

이 구조는 리눅스와 비슷합니다. 리눅스 커널 소스 코드는 공개되어 있지만 대부분의 개발자는 우분투·RHEL·아마존 리눅스 같은 바이너리 배포판을 사용합니다. 자바도 마찬가지로 여러 벤더가 OpenJDK를 빌드해 각자의 배포본을 제공합니다.

graph TD
    A[OpenJDK 소스 코드<br/>GPLv2+CE] --> B[오라클 JDK]
    A --> C[이클립스 어답티움<br/>Temurin]
    A --> D[레드햇 OpenJDK]
    A --> E[아마존 코레토]
    A --> F[마이크로소프트 OpenJDK]
    A --> G[애저 줄루]
    A --> H[벨소프트 Liberica]
    A --> I[그랄VM]
    J[이클립스 OpenJ9<br/>독립 JVM] --> K[IBM Semeru<br/>OpenJDK 라이브러리 + OpenJ9]

3.8.1 배포본 선택

선택의 3가지 기준

자바 배포본 선택은 개발자와 아키텍트에게 매우 중요한 결정입니다. 트위터, 알리바바 같은 일부 대규모 조직은 OpenJDK 기반으로 자체 프라이빗 또는 세미-퍼블릭 빌드를 유지하기도 하지만, 대부분의 회사가 감당하기 어려운 엔지니어링 노력을 요구합니다.

배포본을 선택할 때 일반적으로 고려해야 할 핵심 질문은 세 가지입니다.

  • 프로덕션에서 사용하려면 비용을 지불해야 하는가?
  • 발견한 버그를 어떻게 수정할 수 있는가?
  • 보안 패치는 어떻게 받을 수 있는가?

비용 측면에서 보면, OpenJDK 소스 코드(GPLv2+CE 라이선스)에서 빌드된 바이너리는 프로덕션 환경에서 무료로 사용할 수 있습니다. 이클립스 어답티움, 레드햇, 아마존, 마이크로소프트, 벨소프트(BellSoft)의 모든 바이너리가 여기에 해당하며, 오라클의 일부 바이너리도 이 범주에 속합니다(전부는 아님).

버그 수정의 3가지 경로

OpenJDK에서 발견된 버그를 수정하려면 발견자가 선택할 수 있는 세 가지 길이 있습니다.

  • 지원 계약 구매: 벤더에 비용을 지불하고 수정을 요청합니다. 엔터프라이즈 환경에서 가장 일반적입니다.
  • 커뮤니티 요청: OpenJDK 기여자에게 요청하여 OpenJDK 리포지토리에 버그를 등록한 후, 누군가가 이를 수정해 주기를 정중히 요청합니다.
  • 직접 패치 제출: 모든 오픈 소스 소프트웨어가 제공하는 방법으로, 스스로 수정한 뒤 패치를 제출합니다.

보안 업데이트의 특수성

자바에 대한 거의 모든 변경 사항은 깃허브의 공개 OpenJDK 리포지토리에 커밋으로 시작됩니다. 예외는 아직 공개되지 않은 보안 수정입니다. 보안 수정은 공개와 동시에 다양한 OpenJDK 리포지토리로 전파되며, 그 다음 벤더가 이를 가져와 바이너리로 빌드·릴리스합니다. 이 과정에 미묘한 차이가 있기 때문에 대부분의 조직이 LTS(long-term support) 버전을 유지하려는 이유 중 하나가 됩니다.

주요 벤더 비교
벤더특징
오라클 JDKOpenJDK 코드베이스 기반, 오라클 독점 라이선스로 재라이선스. 2024년 기준 라이선스 정책 변경으로 상업적 사용·프로덕션에서는 유료 구독이 필요할 수 있음
이클립스 어답티움 (Temurin)커뮤니티 주도. AdoptOpenJDK에서 시작해 이클립스 재단으로 전환. 구성원은 주로 빌드/테스트 엔지니어. 여러 플랫폼에서 완전히 테스트된 바이너리
레드햇가장 오래된 비오라클 바이너리 제공업체. OpenJDK 두 번째 기여자(오라클 다음). RHEL/페도라/윈도우(역사적 이유) 지원, UBI 기반 무료 컨테이너 이미지 제공
아마존 코레토 (Corretto)AWS 클라우드 인프라용으로 설계. 일관된 개발자 경험을 위해 맥·윈도우·리눅스 빌드 모두 제공
마이크로소프트 OpenJDK2021년 5월부터 제공(OpenJDK 11.0.11 시작). 맥·윈도우·리눅스 + ARM64 지원. 주로 애저 클라우드용. LTS 릴리스 최소 6년 지원
애저 줄루 (Zulu)무료 OpenJDK 구현. Azul Platform Prime(구 Zing)이라는 고성능 독점 JVM도 제공하지만 이는 OpenJDK 배포본이 아님
그랄VM오라클 Labs 연구 프로젝트 → 프로덕션 자바 구현으로 발전. 동적 JVM 모드로 작동 + 자바로 작성된 JIT 컴파일러 포함. AOT 네이티브 컴파일도 지원
OpenJ9 / IBM SemeruIBM의 독점 JVM(J9) → 2017년 오픈 소스화. 이클립스 OMR 프로젝트 기반. IBM Semeru = OpenJDK 클래스 라이브러리 + OpenJ9 JVM
안드로이드는 자바가 아니다

구글의 안드로이드 프로젝트는 종종 ‘자바 기반’으로 간주되지만 실제로는 약간 더 복잡합니다. 안드로이드는 크로스 컴파일러를 사용해 클래스 파일을 다른 형식(.dex)으로 변환하며, 이 .dex 파일은 안드로이드 런타임(ART) 에서 실행됩니다.

ART는 자바 가상 머신이 아닙니다. 또한 구글은 이제 안드로이드 애플리케이션 개발에 자바보다 코틀린을 권장합니다. 이 기술 스택은 표준 JVM 생태계와 거리가 멀어서 성능 최적화 논의에서 별도로 다뤄야 합니다.

배포본 간 성능 차이는 거의 없다

모든 OpenJDK 배포판은 동일한 소스에서 빌드되므로 동일한 버전 간에는 기능적 차이가 없습니다. 동일 버전과 빌드 플래그 구성에서 다양한 핫스팟 기반 구현 간에는 체계적인 성능 차이가 없어야 합니다.

사소한 예외: GC 컬렉터 선택

오라클은 레드햇과 아마존이 개발한 셰넌도어(Shenandoah) GC를 제공하지 않으며, 대신 자체 ZGC 컬렉터를 홍보합니다. 즉 어떤 GC가 기본 제공되는지는 벤더별로 다를 수 있습니다.

클라우드 특화 빌드 플래그

일부 벤더는 특정 클라우드 환경에 매우 특화된 빌드 플래그 조합을 선택합니다. 일부 연구에 따르면 이러한 조합이 특정 작업 부하에 도움이 될 수 있다고 하지만, 명확하지 않은 부분이 많습니다. 소셜 미디어에서 종종 “특정 배포본 간에 상당한 성능 차이가 발견되었다”는 보고가 나오지만, 충분히 통제된 환경에서 검증되지 않은 이상 신중하게 받아들여야 합니다.

3.8.2 자바 릴리스 주기

6개월 주기와 LTS

새로운 기능 개발은 깃허브의 여러 저장소에서 공개적으로 이루어집니다. 소규모~중간 규모의 기능 추가와 버그 수정은 OpenJDK 메인 브랜치에 직접 풀 리퀘스트로 제출됩니다. 더 큰 기능이나 주요 프로젝트는 별도로 포크된 저장소에서 개발된 후 준비가 되면 메인 라인으로 통합됩니다.

자바의 새로운 릴리스는 6개월마다 메인 브랜치의 최신 내용을 기반으로 생성됩니다. 2017년 9월 이후로 이 6개월 주기와 엄격한 일정이 유지되고 있습니다. 이러한 릴리스를 기능 릴리스(feature release) 라고 하며, 오라클이 자바 관리자로서 이를 주관합니다.

오라클은 다음 기능 릴리스가 등장하면 이전 기능 릴리스에 대한 작업을 중단합니다. 그러나 적합한 역량과 지위를 가진 OpenJDK 멤버는 오라클이 물러난 후 해당 릴리스를 계속 유지하도록 제안할 수 있습니다. 지금까지 이런 상황은 자바 8, 11, 17, 21 에서만 발생했으며, 이를 업데이트 릴리스(update release) 라고 부릅니다.

LTS는 오라클의 판매 개념에서 출발한다

업데이트 릴리스의 중요성은 오라클의 장기 지원(LTS) 릴리스 개념과 일치한다는 점에 있습니다. 기술적으로 이는 순전히 오라클의 판매 프로세스에서 나온 개념으로, 6개월마다 자바를 업그레이드하고 싶지 않은 고객들을 위해 오라클이 특정 안정 버전을 지원하는 방식입니다.

실질적으로 자바 생태계는 오라클의 ‘6개월마다 JDK를 업그레이드하라’는 공식 지침을 거의 받아들이지 않았습니다. 프로젝트 팀과 엔지니어링 매니저들은 이러한 주기에 관심이 없기 때문입니다. 대신 팀들은 한 LTS 버전 → 다음 LTS 버전으로 업그레이드하며, 업데이트 릴리스 프로젝트(8u, 11u, 17u, 21u)는 활성 상태를 유지하면서 보안 패치와 소수의 버그 수정 또는 역이식(backport)을 제공합니다.

timeline
    title 자바 LTS 흐름 (백엔드 관점)
    2014 : Java 8 (LTS)
    2018 : Java 11 (LTS)
    2021 : Java 17 (LTS) : 레코드, 봉인 클래스 안정화
    2023 : Java 21 (LTS) : 가상 스레드 (Project Loom)
    2025 : Java 25 (예정 LTS)
실무 권장: LTS + 적절한 벤더

보안 패치를 받고 보안 문제(가능하다면 버그) 수정의 가능성이 있는 무료 자바 배포본을 원한다면, OpenJDK 벤더 중 하나를 선택하고 오라클의 LTS 버전을 사용하는 것이 가장 좋습니다.

이클립스 어답티움, 레드햇, 아마존, 마이크로소프트가 모두 훌륭한 선택지이며, 벨소프트 같은 다른 벤더도 적절한 대안입니다. 배포 방식과 사용 환경에 따라 특정 배포본을 선택할 수도 있습니다. 예를 들어 AWS에서 배포되는 애플리케이션의 경우 아마존의 코레토를 선호할 수 있습니다.

"Java Is Still Free" 문서

다양한 옵션과 라이선스 복잡성에 대한 더 심층적인 정보를 원한다면 ‘자바는 여전히 무료입니다(Java Is Still Free)’ 문서를 참조할 수 있습니다. 이 문서는 자바 챔피언(Java Champions) 그룹이라는 독립적인 자바 전문가와 리더 그룹에서 작성한 자료입니다.

비교 / 트레이드오프

AOT 컴파일 vs JIT 컴파일
항목AOT (C++, Rust)JIT (자바 핫스팟)
컴파일 시점빌드 시점런타임
대상 머신 정보알 수 없음(일반 코드 생성)정확한 CPU 유형 감지
최적화 정보정적 분석에 의존실행 프로파일(PGO) 활용
시작 성능즉시 네이티브 실행인터프리터로 시작 후 워밍업 필요
성능 예측 가능성높음워밍업 동안 변동
동적 최적화불가능동적 인라이닝·가상 호출 최적화 가능

“더 예측할 수 있다”가 “더 우수한 성능”을 의미하지는 않습니다. AOT는 특정 프로세서 기능을 가정할 수 없는 반면, JIT은 실행 머신에 특화된 최적화가 가능합니다.

플랫폼 스레드 vs 가상 스레드
항목플랫폼 스레드가상 스레드 (Java 21+)
1:1 매핑OS 스레드와 1:1다수가 적은 수의 캐리어 스레드 위에서 다중화
생성 비용크다 (수 MB 스택)매우 작다
적합한 워크로드CPU 바운드I/O 바운드(네트워크 등)
기본 동작자동명시적으로 선택해야 함
기존 코드 영향-명시 선택이므로 동작 보장
JDK 배포본 선택 기준 (백엔드 관점)
배포 환경권장 배포본이유
AWS 위 컨테이너/EC2아마존 코레토AWS 인프라 최적화, AWS 공식 지원
애저 위 워크로드마이크로소프트 OpenJDK애저 환경 설계, LTS 6년 지원
온프레미스 RHEL레드햇 OpenJDKOS 벤더와 일치, UBI 컨테이너 이미지
멀티 클라우드/벤더 중립이클립스 어답티움 (Temurin)커뮤니티 주도, 가장 광범위한 플랫폼
엔터프라이즈 유료 지원 필요오라클 JDK / 레드햇 / 아줄SLA·핫픽스 보장
AOT 네이티브 이미지 필요그랄VM콜드 스타트가 짧아 서버리스에 유리

LTS가 아닌 버전을 프로덕션에 쓰지 말 것

6개월마다 나오는 기능 릴리스(예: Java 22, 23, 24)는 다음 기능 릴리스가 등장하면 오라클이 지원을 중단합니다. 프로덕션 안정성을 원한다면 LTS(8, 11, 17, 21) 만 선택하는 것이 안전합니다.

내 생각

  • “JIT 컴파일된 코드는 원래 자바 코드와 매우 다르다” 는 한 줄이 이 챕터의 핵심입니다. 백엔드 성능 문제를 만났을 때 가장 흔한 함정이 “코드를 읽고 추론으로 원인을 찾는 것”인데, JVM 환경에서는 이게 통하지 않습니다. 인라이닝이 일어났는지, 가상 호출이 단형성(monomorphic)으로 디바이얼된 채로 컴파일되었는지 모르면 런타임의 실제 모습을 알 수 없습니다.

  • 자바 에이전트 메커니즘은 APM(DataDog, NewRelic, Pinpoint) 류 도구의 작동 방식을 이해하는 토대입니다. 우리가 코드 수정 없이 메서드 단위 트레이싱을 얻는 이유는 결국 Premain-ClassClassFileTransformer로 클래스 로딩 시점에 바이트코드를 위빙하기 때문입니다. 이 메커니즘을 알면 APM이 왜 비용이 들고, 어떤 경우에 오버헤드가 폭증하는지 직관적으로 이해할 수 있습니다.

  • 가상 스레드는 Java 21 LTS와 함께 백엔드 설계의 전제를 바꾸고 있습니다. 기존엔 “스레드는 비싸니 비동기 코드로 짜야 한다(WebFlux, Project Reactor)“가 정답이었지만, 가상 스레드가 들어오면 동기 스타일로 짜고도 I/O 바운드 워크로드에서 수만 동시 요청을 처리할 수 있습니다. 다만 명시적으로 선택해야 하므로, 톰캣·스프링 같은 프레임워크가 가상 스레드 풀을 어떻게 다루는지 따로 학습해야 합니다.

  • “어차피 다 OpenJDK 기반이니 똑같다”는 생각은 틀렸습니다. 운영 환경에서는 보안 패치 받는 채널과 LTS 지원 기간, GC 선택지가 곧 운영 안정성입니다. AWS라면 아마존 코레토, 멀티 클라우드라면 어답티움이 무난합니다. 오라클 JDK는 2024년 라이선스 변경 이후 사용 가능 여부를 회사 법무팀과 반드시 확인해야 하는 단계로 바뀌었습니다.

  • “동일 소스에서 빌드되므로 배포본 간 성능 차이는 거의 없다”는 사실은 벤치마크 해석에 직접적으로 영향을 줍니다. 벤치마크 결과로 흥분하기 전에 빌드 플래그와 버전을 통제했는지부터 확인해야 합니다. 실제로 차이를 만드는 것은 셰넌도어·ZGC 같은 GC 선택지지, 배포본 브랜드가 아닙니다.

더 알아볼 것

  • -XX:+PrintCompilation, -XX:+PrintInlining으로 JIT 컴파일 로그 분석
  • Project Loom 가상 스레드의 캐리어 스레드 핀닝(pinning) 이슈
  • 자바 에이전트 직접 작성해서 메서드 실행 시간 측정해보기
  • 셰넌도어(Shenandoah)와 ZGC의 설계 차이와 워크로드별 선택 기준
  • 그랄VM 네이티브 이미지의 콜드 스타트 이점과 한계(리플렉션 등)

관련 개념

출처

  • 벤 에반스, 제임스 고프, 크리스 뉴랜드. 『자바 최적화 2판』. 한빛미디어. Ch03 자바 가상 머신 개요.