| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 백준
- 개발자취업
- 프로그래밍기초
- 자바프로그래밍
- 개발공부
- 자바
- 자바개발
- JVM
- 코딩공부
- HashMap
- 클린코드
- 객체지향
- 코딩인터뷰
- 코딩테스트팁
- 프로그래머스
- 알고리즘
- 코딩테스트준비
- 알고리즘공부
- 개발자팁
- 자바공부
- 예외처리
- 메모리관리
- 가비지컬렉션
- Java
- 정렬
- 파이썬
- 멀티스레드
- 자료구조
- 코딩테스트
- 자바기초
- Today
- Total
코드 한 줄의 기록
Java 클래스 로딩부터 실행까지: 바이트코드와 ClassLoader의 모든 것 본문
처음 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 클래스를 로드해야 한다고 가정합시다.
- Application ClassLoader: "부모님,
com.example.Human을 로드해 주세요" - Extension ClassLoader: "아, 나도 못 찾네요. 우리 부모님께 물어볼게요"
- Bootstrap ClassLoader: "어? 나도 모르겠는데? 직접 찾아봐"
- 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 클래스 초기화 완료!");
}
}
위 코드에서
- Preparation:
count는 0으로 기본값 설정 - 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 컴파일러를 도입했습니다.
처음에는 "왜 이렇게 복잡하지?"라고 생각할 수 있지만, 이 메커니즘을 이해하면
- 성능 최적화를 할 때 어디를 봐야 할지 알 수 있습니다
- 메모리 누수를 예방하고 디버깅할 수 있습니다
- 프레임워크들의 마법이 어떻게 동작하는지 이해하게 됩니다
- 분산 시스템에서 클래스 버전 관리를 올바르게 할 수 있습니다
- Spring, Hibernate 같은 고급 프레임워크의 동작 원리를 파악할 수 있습니다
Java를 깊이 있게 학습하고 싶다면, 이 ClassLoader와 바이트코드의 세계는 꼭 탐험해봐야 할 영역입니다. 처음에는 복잡해 보이지만, 한 번 이해하면 Java의 진정한 매력을 느낄 수 있을 겁니다.
다음번에는 GC(Garbage Collection)와 메모리 누수 디버깅에 대해 깊이 있게 다루어 봅시다!
[자바/Java] JVM 메모리 구조 해부: Stack, Heap, Metaspace 동작 원리와 특징 완벽 정리
안녕하세요! 오늘도 즐겁게 코딩하고 계신가요? 개발을 하다 보면 기능 구현에 급급해 정작 우리가 짠 코드가 '어디에', '어떻게' 저장되고 실행되는지 놓칠 때가 많습니다. 저 역시 주니어 시절
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java 성능 프로파일링 시작하기: 샘플러와 애널라이저 완벽 가이드 (0) | 2025.12.14 |
|---|---|
| Java GC 기초부터 로그 분석까지: 세대별 GC와 마크-스윕 완벽 이해하기 (0) | 2025.12.13 |
| [자바/Java] JVM 메모리 구조 해부: Stack, Heap, Metaspace 동작 원리와 특징 완벽 정리 (0) | 2025.12.08 |
| Java Concurrent 컬렉션과 병렬 스트림의 함정, 제대로 알고 사용하자 (0) | 2025.12.07 |
| Java 멀티스레드의 악몽, 데드락·라이블락·스타베이션 완벽 정리 (0) | 2025.12.06 |