코드 한 줄의 기록

Java 클래스 로딩부터 실행까지: 바이트코드와 ClassLoader의 모든 것 본문

JAVA

Java 클래스 로딩부터 실행까지: 바이트코드와 ClassLoader의 모든 것

CodeByJin 2025. 12. 9. 10:03
반응형

처음 Java를 배울 때 이상한 점이 하나 있었어요. C나 C++는 소스 코드를 컴파일하면 바로 실행 파일(*.exe)이 나오는데, Java는 왜 *.class라는 중간 산물이 생기고, JVM이라는 것이 필요할까? 이 의문이 저를 Java의 아주 흥미로운 세계로 끌어당겼습니다.

 

이 글에서는 Java 코드가 어떻게 JVM에 로드되고, 실행되는지를 단계별로 파헤쳐 봅시다. 특히 ClassLoader의 동작 방식, 바이트코드의 정체, 그리고 메모리에 어떻게 클래스가 관리되는지 알아보겠습니다. 이 지식은 성능 최적화, 메모리 누수 디버깅, 그리고 복잡한 프레임워크의 작동 원리를 이해하는 데 정말 중요합니다.

1단계: 소스 코드의 여행은 javac부터 시작

우리가 작성한 *.java 파일은 사람이 읽을 수 있는 형태의 코드입니다. 하지만 컴퓨터는 이를 바로 실행할 수 없어요. 그래서 필요한 것이 Java 컴파일러(javac)입니다.

Human.java → [javac 컴파일러] → Human.class (바이트코드)

javac 컴파일러가 하는 역할은 정확히 무엇일까요? Java 소스 코드를 JVM이 이해할 수 있는 중간 언어인 바이트코드로 변환합니다. 이것이 Java의 가장 핵심적인 특징입니다.

 

바이트코드는 정말 뭘까?

바이트코드는 JVM이 해석할 수 있도록 설계된 저수준의 명령어 집합입니다. C나 C++처럼 특정 OS의 기계어로 컴파일되지 않기 때문에, 한 번 컴파일된 .class 파일은 어떤 운영체제에서든 실행될 수 있습니다. 이것이 "Write Once, Run Anywhere(WORA)"라는 Java의 슬로건이 탄생한 배경입니다.

 

생각해 보면 정말 똑똑한 설계죠. C로 만든 Windows용 exe 파일을 Mac에서 실행할 수 없지만, Java의 .class 파일은 JVM만 설치되어 있으면 어디서나 동일하게 실행됩니다.

2단계: ClassLoader가 무대에 등장

이제 .class 파일이 생겼습니다. 하지만 이 파일이 실제로 메모리에 로드되지 않으면 아무 소용이 없어요. 이때 ClassLoader가 등장합니다.

 

ClassLoader는 JVM의 일부로서, 디스크에 있는 .class 파일을 찾아서 JVM의 메모리(Method Area/Runtime Data Area)로 읽어 들입니다. 단순히 파일을 읽는 것이 아니라, 클래스 메타데이터, 메소드 정보, 상수 풀 등을 JVM이 인식할 수 있는 형태로 변환하는 작업을 수행합니다.

 

ClassLoader의 계층 구조

가장 흥미로운 부분은 ClassLoader가 계층적으로 구성되어 있다는 것입니다. 마치 조직도처럼요.

Bootstrap ClassLoader (최상위)
    ↓
Extension ClassLoader (java.ext.dirs)
    ↓
Application ClassLoader (classpath)
    ↓
User-Defined ClassLoader (사용자 정의)

이 계층 구조가 왜 필요할까요? 보안과 성능 때문입니다. 각 ClassLoader는 자신이 로드한 클래스를 관리하고, 같은 클래스가 여러 번 로드되는 것을 방지합니다.

 

ClassLoader의 작동 원리 - 위임 모델(Delegation Model)

ClassLoader가 어떤 클래스를 로드해야 할 때, 그냥 자신이 로드하지 않습니다. 대신 부모 ClassLoader에게 먼저 요청합니다. 부모가 로드하지 못하면, 그제야 자신이 나서서 로드합니다. 이를 위임 모델(Delegation Model)이라고 합니다.

 

예를 들어 Application ClassLoader가 com.example.Human 클래스를 로드해야 한다고 가정합시다.

  1. Application ClassLoader: "부모님, com.example.Human을 로드해 주세요"
  2. Extension ClassLoader: "아, 나도 못 찾네요. 우리 부모님께 물어볼게요"
  3. Bootstrap ClassLoader: "어? 나도 모르겠는데? 직접 찾아봐"
  4. Application ClassLoader가 직접 classpath에서 찾아서 로드

이 과정이 매우 중요한 이유는, 만약 classpath와 java.ext.dirs에 같은 이름의 클래스가 존재한다면, java.ext.dirs의 클래스가 우선순위를 가진다는 뜻입니다. 따라서 어느 ClassLoader가 로드했느냐에 따라 실제 동작이 달라질 수 있습니다.

3단계: ClassLoader의 세 가지 작업 - Loading, Linking, Initialization

실제로 ClassLoader는 단순히 파일을 읽는 것이 아니라, 세 단계의 복잡한 과정을 거칩니다.

 

Loading (로딩)

로딩 단계에서는 .class 파일의 바이너리 데이터를 읽어서 JVM의 메모리에 올립니다.

 

이 과정에서

  • 클래스의 메타데이터가 Method Area에 저장됩니다
  • 상수 풀(Constant Pool)이 생성됩니다
  • 메소드, 필드, 생성자 정보가 메모리에 배치됩니다

하지만 이 단계는 단순히 정보를 읽어들이는 것일 뿐, 아직 사용 가능한 상태는 아닙니다.

 

Linking (링킹)

링킹은 세 부분으로 나뉩니다.

 

Verification (검증)
- .class 파일의 형식이 유효한지 확인
- 보안 위험이 없는지 검사
- 예를 들어, 타입 체킹, 메모리 접근 범위 확인 등

 

Preparation (준비)
- 클래스 변수의 메모리를 할당
- 기본값으로 초기화 (int는 0, boolean은 false, 참조형은 null 등)
- 단, 명시적 초기화 코드나 static 블록은 아직 실행되지 않습니다

 

Resolution (해석)
- 상수 풀의 심볼릭 참조를 실제 메모리 참조로 변환
- 다른 클래스의 참조를 실제 주소로 해석

 

Initialization (초기화)

이제 정말 중요한 단계입니다. 클래스가 실제로 "준비 완료" 상태가 되는 것이죠.

public class Counter {
    public static int count = 0;  // 1단계: Preparation에서 0으로 초기화
    
    static {  // 2단계: Initialization에서 이 블록이 실행
        count = 5;
        System.out.println("Counter 클래스 초기화 완료!");
    }
}

위 코드에서

  1. Preparation: count는 0으로 기본값 설정
  2. Initialization: static 블록이 실행되어 count가 5로 변경

이 초기화 과정은 클래스당 단 한 번만 실행됩니다. 여러 개의 인스턴스를 만들어도, 클래스 초기화는 첫 번째 로드 시점에만 일어나요.

4단계: 바이트코드의 실행 - Interpreter vs JIT Compiler

이제 메모리에 로드된 바이트코드를 어떻게 실행할까요? 여기서 두 가지 방식이 등장합니다.

 

Interpreter (인터프리터) 방식

Interpreter는 바이트코드를 한 줄씩 읽으면서 해석하고 실행합니다.

바이트코드 명령어 1 → 해석 → 기계어로 변환 → 실행
바이트코드 명령어 2 → 해석 → 기계어로 변환 → 실행
바이트코드 명령어 3 → 해석 → 기계어로 변환 → 실행
...

장점
- 빠른 시작. 컴파일 오버헤드가 없음
- 메모리 효율적

 

단점
- 느린 실행 속도. 같은 코드가 반복되면 매번 해석 과정을 거쳐야 함
- 대규모 애플리케이션에서 성능 저하가 심각

 

JIT(Just-In-Time) Compiler 방식

JIT Compiler는 더 똑똑합니다. 자주 실행되는 코드를 감지하면, 그것을 미리 기계어로 컴파일해서 캐싱합니다.

바이트코드 실행 (Interpreter) → 
실행 패턴 분석 → 
자주 실행되는 코드 발견 →
[JIT Compiler] 기계어로 컴파일 및 캐싱 →
다음 실행부터는 캐시된 기계어 직접 사용 (매우 빠름!)

장점
- Interpreter보다 훨씬 빠른 실행 속도
- 런타임에 최적화를 수행 (정적 컴파일러보다 유리할 수 있음)
- 프로그램이 오래 실행될수록 성능 개선

 

단점
- 초기 컴파일 오버헤드
- 메모리 사용량 증가

 

Java는 하이브리드 방식을 사용합니다. 처음에는 Interpreter로 시작하다가, 자주 실행되는 부분(hot spot)을 JIT Compiler가 감지해서 최적화합니다. 이것이 "HotSpot JVM"이라는 이름의 유래입니다.

5단계: JVM 메모리 구조 이해하기

ClassLoader가 로드한 클래스들이 JVM의 메모리 어디에 저장될까요?

┌─────────────────────────────────────┐
│       JVM Runtime Data Area         │
├─────────────────────────────────────┤
│                                     │
│   Method Area (Class Area)          │  ← 클래스 메타데이터, static 변수
│   - 클래스 구조 메타데이터            │
│   - 메소드 데이터                    │
│   - 메소드 코드                     │
│   - 상수 풀 (Constant Pool)         │
│   - static 변수                     │
│                                     │
├─────────────────────────────────────┤
│                                     │
│   Heap                              │  ← 객체 인스턴스들
│   - 모든 객체와 배열이 할당됨         │
│   - 가비지 컬렉션의 대상              │
│                                     │
├─────────────────────────────────────┤
│   Stack (Thread 당 할당)            │  ← 메소드 호출, 지역변수
│   - 지역 변수                       │
│   - 메소드 참조                     │
│   - 연산 스택                       │
├─────────────────────────────────────┤
│   Program Counter (PC) Register     │
│   Native Method Stack               │
└─────────────────────────────────────┘

Method Area에는 ClassLoader가 로드한 클래스의 메타데이터가 저장됩니다. 여기에 저장된 정보는 프로그램이 실행되는 동안 계속 메모리에 남아있고, 모든 스레드가 공유합니다.

6단계: 클래스 초기화 순서의 미스터리

이제 실제 코드를 살펴봅시다. 정말 재미있는 부분입니다.

class Parent {
    static {
        System.out.println("Parent 클래스 static 블록 실행");
    }
}

class Child extends Parent {
    static int value = 10;
    
    static {
        System.out.println("Child 클래스 static 블록 실행");
        value = 20;
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("=== Child 클래스 처음 참조 ===");
        System.out.println(Child.value);
    }
}

실행 결과

=== Child 클래스 처음 참조 ===
Parent 클래스 static 블록 실행
Child 클래스 static 블록 실행
20

왜 Child보다 Parent가 먼저 초기화될까요? 상속 관계 때문입니다! JVM은 클래스를 초기화할 때, 부모 클래스부터 먼저 초기화합니다. 이것은 의존성 관리를 위한 안전장치입니다.

 

또 다른 흥미로운 점

class Example {
    static final int CONSTANT = 30;  // 컴파일 타임 상수
    static int value = 40;           // 런타임 변수
    
    static {
        System.out.println("static 블록 실행");
    }
}

public class Main {
    public static void main(String[] args) {
        // CONSTANT 접근 - static 블록이 실행되지 않음!
        System.out.println(Example.CONSTANT);
        
        System.out.println("---");
        
        // value 접근 - 이제 static 블록 실행됨
        System.out.println(Example.value);
    }
}

실행 결과

30
---
static 블록 실행
40

왜 그럴까요? static final로 선언된 상수는 컴파일 단계에서 값이 결정되므로, ClassLoader의 초기화 단계를 거칠 필요가 없기 때문입니다. JVM은 스마트하게 최적화합니다!

7단계: 동적 로딩의 힘

Java의 가장 놀라운 특징 중 하나는 동적 로딩(Dynamic Loading)입니다. 클래스를 런타임에 필요할 때만 로드할 수 있다는 뜻입니다.

public class DynamicLoadingExample {
    public static void main(String[] args) throws Exception {
        System.out.println("프로그램 시작");
        
        // 이 시점에 HeavyClass는 아직 로드되지 않았습니다
        System.out.println("HeavyClass를 로드하기 전");
        
        // 실행 중에 동적으로 클래스를 로드
        Class<?> clazz = Class.forName("com.example.HeavyClass");
        System.out.println("HeavyClass가 로드되었습니다!");
        
        // 필요한 경우만 로드하므로 메모리 효율적
        System.out.println("프로그램 종료");
    }
}

이것은 플러그인 시스템, 프레임워크의 동적 프록시, 데이터베이스 드라이버 로딩 등 수많은 고급 기능의 기반이 됩니다.

 

커스텀 ClassLoader 만들기

더 나아가, 우리는 우리만의 ClassLoader를 만들 수도 있습니다.

public class CustomClassLoader extends ClassLoader {
    private String classPath;
    
    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 커스텀 경로에서 클래스 파일을 읽음
        byte[] classData = readClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException(name);
        }
        return defineClass(name, classData, 0, classData.length);
    }
    
    private byte[] readClassData(String name) {
        // 실제로는 파일 시스템, 네트워크, DB 등 어디서든 읽을 수 있습니다
        return null;
    }
}

커스텀 ClassLoader의 활용 사례

  • 네트워크에서 클래스를 다운로드해서 로드
  • 암호화된 클래스 파일을 복호화해서 로드
  • 클래스 로드 시점에 바이트코드 변조 (bytecode manipulation)
  • 다양한 버전의 같은 클래스를 동시에 로드

8단계: 메모리 누수와 ClassLoader

ClassLoader를 잘못 이해하면 심각한 메모리 누수가 발생할 수 있습니다.

// 위험한 패턴
CustomClassLoader loader = new CustomClassLoader(...);
Class<?> clazz = loader.loadClass("com.example.MyClass");
MyClass instance = (MyClass) clazz.newInstance();

// 나중에 loader = null로 설정해도...
loader = null;
// MyClass는 여전히 힙에 존재하고, 메모리에서 제거되지 않습니다!
// 왜냐하면 MyClass의 메타데이터는 Method Area에 영구적으로 저장되기 때문입니다.

특히 애플리케이션 서버(Tomcat, JBoss 등)에서 웹 애플리케이션을 reload할 때 이런 문제가 발생합니다. 구 ClassLoader가 참조하던 클래스 메타데이터가 완전히 제거되지 않아서 메모리가 누적됩니다.

실전 팁: 성능 최적화와 디버깅

클래스 로딩 시간 측정

long startTime = System.currentTimeMillis();
Class<?> clazz = Class.forName("com.example.HeavyClass");
long endTime = System.currentTimeMillis();
System.out.println("로딩 시간: " + (endTime - startTime) + "ms");

ClassLoader 체인 확인

ClassLoader loader = Thread.currentThread().getContextClassLoader();
while (loader != null) {
    System.out.println(loader.getClass().getName());
    loader = loader.getParent();
}

JVM 옵션으로 클래스 로딩 로그 확인

java -XX:+TraceClassLoading -XX:+TraceClassUnloading MyApplication

 

Java의 ClassLoader와 바이트코드는 단순한 기술적 구현이 아니라, 뛰어난 설계 철학입니다. 플랫폼 독립성, 보안성, 유연성을 모두 제공하면서도, 성능을 포기하지 않기 위해 JIT 컴파일러를 도입했습니다.

 

처음에는 "왜 이렇게 복잡하지?"라고 생각할 수 있지만, 이 메커니즘을 이해하면

  1. 성능 최적화를 할 때 어디를 봐야 할지 알 수 있습니다
  2. 메모리 누수를 예방하고 디버깅할 수 있습니다
  3. 프레임워크들의 마법이 어떻게 동작하는지 이해하게 됩니다
  4. 분산 시스템에서 클래스 버전 관리를 올바르게 할 수 있습니다
  5. Spring, Hibernate 같은 고급 프레임워크의 동작 원리를 파악할 수 있습니다

Java를 깊이 있게 학습하고 싶다면, 이 ClassLoader와 바이트코드의 세계는 꼭 탐험해봐야 할 영역입니다. 처음에는 복잡해 보이지만, 한 번 이해하면 Java의 진정한 매력을 느낄 수 있을 겁니다.

 

다음번에는 GC(Garbage Collection)와 메모리 누수 디버깅에 대해 깊이 있게 다루어 봅시다!

 

 

[자바/Java] JVM 메모리 구조 해부: Stack, Heap, Metaspace 동작 원리와 특징 완벽 정리

안녕하세요! 오늘도 즐겁게 코딩하고 계신가요? 개발을 하다 보면 기능 구현에 급급해 정작 우리가 짠 코드가 '어디에', '어떻게' 저장되고 실행되는지 놓칠 때가 많습니다. 저 역시 주니어 시절

byteandbit.tistory.com

 

반응형