F-LAB JAVA · 2주차 · Phase 3 · 바이트코드와 상수 풀
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
NoClassDefFoundError 와 ClassNotFoundException 의 결정적 차이는?심볼 참조는 "이름표가 붙어있지만 실제 주소는 비어있는 화살표"다.
컴파일러는.class파일에 이름(심볼)을 적어두고, 실제 메모리 주소는 비워둔다.
JVM이 런타임에 그 이름을 실제 주소로 채워 넣는 작업이 Resolution.
이 Late Binding 메커니즘이 자바의 모듈성 · AOP · 핫스왑의 근간이다.
| 시점 | 비유 |
|---|---|
| 컴파일 | 우편물 봉투에 "수신자: 박승제 (한국, 서울)" 라고만 적음 |
| JVM 시작 | 우체국에 도착. 아직 수신자 위치 모름 |
| Resolution | 우체국이 주소록 조회 → "서초구 강남대로 ..." 알아냄 |
| Direct Reference | 봉투에 실제 주소 도장 찍힘. 이후 같은 사람에게 보낼 땐 도장 그대로 사용 |
→ 한 번 해소된 심볼은 캐시되어 빠르게 재사용.
1. 컴파일러의 딜레마 — 알 수 없는 미래
2. 심볼 참조 vs 직접 참조
3. Resolution의 5단계
4. Lazy Resolution — 사용 시점 해소
5. 캐싱 — 한 번 해소된 심볼은 영원히
6. Late Binding이 만드는 자바의 자유
7. ILIC 실무 — 클래스 로딩 사고 디버깅
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
// 박승제의 ILIC 프로젝트
public class ShipmentService {
public Shipment process(ShipmentRequest req) {
Shipment s = new Shipment(req); // ← 컴파일 시점
return repository.save(s);
}
}
컴파일러가 모르는 것:
이 정보들은 JVM이 클래스를 로딩한 후에야 결정된다.
가설: 컴파일러가 실제 메모리 주소를 미리 박아둔다면?
바이트코드 (가설):
new 0x7f8c4d2a0010 // Shipment 클래스의 가상 주소
invokevirtual 0x7f8c4d2b0030 // save 메서드의 가상 주소
문제:
→ C/C++의 정적 링크 모델과 같은 문제. 자바가 이 길을 안 간 이유.
컴파일러:
"Shipment 클래스의 인스턴스를 만들어라"
→ "Shipment" 라는 이름만 적어둠 (심볼)
→ 실제 주소는 JVM이 런타임에 알아서
JVM:
"Shipment 라는 이름의 클래스를 메모리에서 찾아"
→ 클래스 로더가 .class 파일 읽기
→ Method Area에 적재
→ 그 주소를 심볼에 채워 넣음 (= Resolution)
→ 이후 같은 이름은 즉시 그 주소 사용
이게 Late Binding (또는 Dynamic Linking).
| 항목 | C/C++ | Java |
|---|---|---|
| 링크 시점 | 컴파일 + 정적 링크 | 런타임 (Dynamic Linking) |
| 외부 참조 | 절대/상대 주소 | 심볼 참조 (이름) |
| 라이브러리 교체 | 재컴파일 필요 | JAR 교체만 |
| 클래스 격리 | 어려움 | ClassLoader로 격리 |
| 핫스왑 | 어려움 | 가능 |
| 보안 | 메모리 직접 접근 위험 | JVM 샌드박스 |
→ 자바의 모든 "마법"의 출발점이 Symbolic Reference.
심볼 참조 (Symbolic Reference):
직접 참조 (Direct Reference):
shipment.calculate(100);
.class 파일 시점 (심볼 참조):
Constant Pool:
#20 = Methodref #4.#15
#4 = Class Utf8 "com/ilic/Shipment"
#15 = NameAndType "calculate" "(I)Ljava/math/BigDecimal;"
바이트코드:
invokevirtual #20 ← 인덱스만. 실제 주소 없음
런타임 시점, Resolution 후 (직접 참조):
JVM 내부:
#20 → Method 구조체의 주소 (예: 0x7f4a8d3c2010)
바이트코드 실행:
invokevirtual 0x7f4a8d3c2010
↓ 즉시 그 주소의 메서드 실행
컴파일 시점:
[ #20 = Methodref ]
↓ 가리키는 곳:
┌──────────────────────────────────┐
│ Constant Pool │
│ #4: Class "com/ilic/Shipment" │
│ #15: NameAndType ... │
└──────────────────────────────────┘
↓ 실제 메모리에는 ?
↓ 모름!
Resolution 후:
[ #20 = Methodref ]
↓ 동일한 인덱스, 하지만:
┌──────────────────────────────────┐
│ Klass의 Method Table │
│ calculate 메서드 → 0x7f4a8d3c2010│
└──────────────────────────────────┘
↓ 직접 메모리 주소
↓ 즉시 실행!
→ Resolution = 심볼 → 직접 변환 의 핵심 작업.
Resolution 결과는 어디 저장되나?
Method Area:
┌─────────────────────────────────┐
│ ShipmentService Klass │
│ 런타임 상수 풀: │
│ #20: Methodref │
│ ├ Symbolic: "Shipment.calc"│
│ └ Resolved: 0x7f4a8d3c2010 │ ← 캐시!
└─────────────────────────────────┘
JVM이 런타임 상수 풀(Runtime Constant Pool)에 해소 결과 함께 보관.
→ 처음 사용 시 1번 해소, 이후엔 즉시 직접 참조.
Unit 1.3, 2.3에서 본 클래스 로딩의 3단계:
1. Loading — .class 파일 읽기
2. Linking ┐
- Verification │
- Preparation │ ← Resolution이 여기 포함
- Resolution ┘
3. Initialization — static 초기화
이번 Unit에서 Resolution을 자세히.
"calculate 메서드 호출" 의 심볼을 해소하는 과정:
┌─────────────────────────────────────────────┐
│ 1. Constant Pool에서 심볼 정보 읽기 │
│ #20 = Methodref → 어느 클래스의 어느 메서드?│
├─────────────────────────────────────────────┤
│ 2. 대상 클래스 확보 │
│ "com/ilic/Shipment" 클래스가 로딩됐나? │
│ 아니면 → ClassLoader에 위임 │
├─────────────────────────────────────────────┤
│ 3. 대상 클래스의 Method Table 검색 │
│ "calculate" + "(I)Ljava/math/BigDecimal;" │
│ 일치하는 메서드 찾기 │
│ 없으면 → 부모 클래스 탐색 │
├─────────────────────────────────────────────┤
│ 4. 접근 권한 검증 │
│ public? 접근 가능한가? │
│ abstract? 호출 가능한가? │
├─────────────────────────────────────────────┤
│ 5. 직접 참조로 캐싱 │
│ 런타임 상수 풀에 결과 저장 │
│ 다음 호출 시 1~4 단계 생략 │
└─────────────────────────────────────────────┘
new Shipment();
1. Constant Pool에서 "com/ilic/Shipment" Utf8 확인
2. ClassLoader에 Shipment 로딩 요청
- 이미 로딩됐나? Yes → Klass 반환
- No → .class 파일 찾기 → Loading → Linking
3. 접근 권한 검증 (public 클래스인가?)
4. Klass 포인터를 직접 참조로 캐싱
shipment.calculate(100);
1. Constant Pool에서 Methodref 정보 확인
2. 대상 Klass 확보 (클래스 Resolution 먼저)
3. Method Table 검색:
- 자신의 클래스에서 시그니처 일치 검색
- 없으면 부모 클래스 (Object까지)
- 인터페이스 메서드도 검색
4. 접근 권한 + abstract 검증
5. Method 구조체 포인터를 직접 참조로 캐싱
shipment.blNo
1. Constant Pool에서 Fieldref 정보 확인
2. 대상 Klass 확보
3. Field Table 검색:
- 자신의 클래스에서 이름+타입 일치 검색
- 없으면 부모 클래스
- 인터페이스 상수도 검색 (static final)
4. 접근 권한 검증
5. 필드 오프셋(객체 안의 위치)을 캐싱
→ 필드는 메모리 오프셋(객체 시작 위치로부터의 거리)이 직접 참조.
각 단계에서 발생 가능한 예외:
| 단계 | 실패 시 예외 |
|---|---|
| 1. Constant Pool | (드물게) ClassFormatError |
| 2. 클래스 확보 | NoClassDefFoundError |
| 3. 멤버 검색 | NoSuchMethodError, NoSuchFieldError |
| 4. 접근 검증 | IllegalAccessError |
| 5. 캐싱 | (보통 실패 없음) |
→ 박승제씨가 운영에서 자주 만나는 에러들의 출처.
JVM은 가능한 한 늦게 Resolution 한다.
public class App {
public static void main(String[] args) {
System.out.println("Hello");
// ... 1000줄 후
new ShipmentService(); // ← 여기서 처음 ShipmentService 사용
}
}
JVM 시작:
App 클래스 로딩
→ main 메서드의 바이트코드 적재
→ Constant Pool에 "ShipmentService" 심볼 등록
→ ★ 하지만 ShipmentService 클래스는 아직 로딩 안 됨
→ ★ Resolution도 안 함
main 실행 중:
...print("Hello")...
...1000줄 후 new ShipmentService() 도달...
→ 이 시점에 ShipmentService 클래스 로딩
→ Resolution 수행
→ 객체 생성
→ 사용 안 되는 클래스는 영원히 로딩 안 됨.
→ 메모리 절약 + 시작 시간 단축.
시나리오: ILIC 앱이 100개 라이브러리 의존.
Eager 방식 (모든 클래스 미리 로딩):
- 모든 .class 파일 읽기
- 메모리 적재
- 시작 시간: 10초+
- 메모리: 큼
Lazy 방식 (JVM 기본):
- 처음엔 일부만 로딩
- 실제 사용 시 점진적 로딩
- 시작 시간: 빠름
- 메모리: 점진적
특히 사용 안 되는 라이브러리는 영원히 로딩 안 됨.
→ 라이브러리 의존성 추가의 부담 감소.
JLS(Java Language Specification) 기준, Resolution이 일어나는 시점:
1. 처음 클래스를 사용할 때
- new MyClass()
- MyClass.staticMethod()
- MyClass.staticField
2. 메서드 첫 호출 시
- instance.method()
- 첫 호출에만 Resolution
- 이후 캐시 사용
3. 필드 첫 접근 시
- 마찬가지
4. instanceof, .class 같은 타입 검사
public class App {
public static void main(String[] args) {
// 99% 정상 실행
normalPath();
if (rareCondition) {
// 1% 실행되는 코드
new ProblemClass(); // ← 1년 후 발견되는 에러
}
}
}
만약 ProblemClass 가 빠진 JAR로 배포:
→ 운영 중 어느 날 갑자기 발생.
→ 테스트 커버리지의 중요성.
특별한 경우 Eager Resolution 강제:
java -Xverify:all App # 모든 클래스 verify
또는 코드로:
// 클래스 로딩 강제
Class.forName("com.ilic.ProblemClass");
→ 시작 시점에 모든 의존성 검증.
→ 일반적으론 안 함 (Lazy의 이점 포기).
for (int i = 0; i < 1_000_000; i++) {
shipment.calculate(100); // 100만 번 호출
}
첫 호출:
1. Constant Pool의 #20 (Methodref) 조회
2. Shipment 클래스 Resolution
3. calculate 메서드 검색
4. 접근 권한 검증
5. Method 구조체 주소 캐싱
→ 결과: 직접 참조 확보
두 번째 호출:
1. Constant Pool의 #20 조회
2. 이미 직접 참조 있음 → 그것 사용
→ 즉시 메서드 실행
세 번째 ~ 100만 번째:
같음
→ Resolution 비용은 첫 호출 1번만.
→ 이후는 즉시 직접 호출.
JIT 컴파일러가 한 발 더 나아감:
인라인 캐시 (호출 사이트별 캐싱):
shipment.calculate(100) 호출 사이트:
┌──────────────────────────────┐
│ 캐시: │
│ 객체 클래스 = Shipment │
│ 메서드 주소 = 0x7f4a8d3c... │
└──────────────────────────────┘
다음 호출 시:
- 현재 객체의 Klass 포인터 비교
- 같으면 → 캐시 사용 (1-2 명령어!)
- 다르면 → 다시 Resolution
→ 다형성 호출도 거의 정적 호출 만큼 빠름.
→ Unit 2.3의 JIT 효과의 메모리 메커니즘.
.class 파일:
Constant Pool (Symbolic Only)
Method Area:
런타임 상수 풀
- Symbolic Reference
- Resolved Direct Reference ★ (해소 후 추가됨)
JIT가 생성한 네이티브 코드:
Inline Cache ★ (호출 사이트별)
→ 여러 레벨의 캐싱이 협력.
// 디버거가 클래스를 핫스왑 했다면?
JVM의 동작:
→ 핫스왑이 가능한 메모리 메커니즘.
Late Binding 덕에 가능해진 것들:
1. ClassLoader 격리
- 같은 클래스 이름을 다른 클래스로 로딩 가능
- 톰캣의 webapp별 격리, OSGi 등
2. 동적 프록시 / AOP
- 런타임에 클래스 동적 생성
- 기존 코드는 심볼 참조만 가지고 있어서 영향 없음
3. 핫스왑 (HotSwap)
- 실행 중에 클래스 교체
- 개발 시 IDE의 핫리로드
4. JAR 교체만으로 라이브러리 업데이트
- 재컴파일 불필요
- 의존성 버전 변경 쉬움
톰캣 서버:
┌─────────────────────────────────┐
│ Common Class Loader │
│ tomcat 코어 클래스 │
├─────────────────────────────────┤
│ App A의 Class Loader │
│ Shipment v1.0 │
├─────────────────────────────────┤
│ App B의 Class Loader │
│ Shipment v2.0 │
└─────────────────────────────────┘
같은 com.ilic.Shipment 클래스가 2개 동시 존재 가능.
→ App A의 코드는 v1.0의 Shipment 심볼을 v1.0 Klass로 해소.
→ App B의 코드는 v2.0의 Shipment 심볼을 v2.0 Klass로 해소.
이게 클래스 격리.
Late Binding 없으면 불가능.
@Service
public class ShipmentService {
@Transactional
public Shipment save(Shipment s) { ... }
}
Spring 시작 시:
1. ShipmentService 클래스 로딩 (원본)
2. Spring AOP가 CGLIB로 새 클래스 동적 생성:
ShipmentService$$EnhancerBySpringCGLIB$$abcdef
3. 그 클래스를 ClassLoader에 등록
4. Bean 인스턴스 생성 시 Enhancer 클래스 사용
호출자 코드:
@Autowired
private ShipmentService service; // ← 컴파일 시점엔 ShipmentService 심볼
service.save(s);
런타임:
service 변수는 ShipmentService 심볼을 가짐save() 호출 → VMT에서 Enhancer의 save() 실행→ 클라이언트 코드는 심볼만 알면 됨. 실제 클래스가 바뀌어도 무관.
public BigDecimal calculate(int weight) {
return BigDecimal.valueOf(weight * 100);
}
개발 중에 코드 수정:
public BigDecimal calculate(int weight) {
return BigDecimal.valueOf(weight * 200); // 변경
}
IDE의 HotSwap:
→ JVM 재시작 없이 코드 반영.
→ Late Binding 없으면 불가능.
앱 jar:
- 우리 코드 (Shipment, Service 등)
- 의존: lib-foo-1.0.jar
lib-foo-1.0.jar → lib-foo-2.0.jar 교체:
앱 재시작
→ Class Loader가 새 JAR 읽음
→ 같은 클래스명, 새 구현
→ 우리 코드는 심볼 참조만 가지고 있어서 영향 없음
C/C++ 라이브러리는 ABI(Application Binary Interface) 호환성이 매우 까다로움.
Java는 심볼 참조 덕에 훨씬 유연.
JPMS (Java Platform Module System, Java 9+):
module com.ilic.shipment {
requires java.base;
requires org.springframework.boot;
exports com.ilic.shipment.api;
}
모듈 시스템도 결국 Late Binding 위에서:
→ Late Binding이 진정한 모듈성의 기반.
상황: 운영 중 갑자기 에러
java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
at com.ilic.ShipmentService.process(ShipmentService.java:45)
의미:
StringUtils 가 있었음원인:
디버깅:
# 1. 실행 중인 JVM의 classpath 확인
jcmd <PID> VM.system_properties | grep class.path
# 2. 클래스가 로딩 가능한지 확인
jcmd <PID> VM.classloader_stats
# 3. JAR 안에 클래스가 있는지
unzip -l app.jar | grep StringUtils
# 4. Gradle 의존성 트리
./gradlew dependencies
둘은 다르다:
ClassNotFoundException:
- Checked Exception
- Reflection 사용 시
- 예: Class.forName("missing.Class") → 예외
NoClassDefFoundError:
- Error (unchecked)
- JVM이 자동 로딩 시도 시
- 예: new SomeClass() → 클래스 못 찾음 → Error
try {
Class.forName("missing");
} catch (ClassNotFoundException e) {
// ✓ 예측 가능. 안전하게 처리
}
try {
new missing(); // 컴파일 안 됨
} catch (?) {
// 컴파일 단계에서 잡힘
}
면접 단골 질문.
ILIC 앱을 Tomcat에 redeploy 할 때:
1. 기존 webapp의 ClassLoader 해제 시도
2. 그러나 다른 곳에서 참조 남아있음:
- Thread (살아있는 스레드 풀)
- ThreadLocal
- JDBC Driver (DriverManager가 들고 있음)
- 정적 캐시
3. → 기존 ClassLoader가 GC 안 됨
4. → Method Area에 클래스 정보 누적
5. → 결국 OutOfMemoryError: Metaspace
해결 패턴:
@PreDestroy
public void cleanup() {
// ThreadLocal 정리
userContext.remove();
// ScheduledExecutor 종료
scheduler.shutdownNow();
// JDBC Driver 정리 (필요 시)
DriverManager.deregisterDriver(driver);
}
→ 운영에서 매우 흔한 클래스 누수 패턴.
java.lang.IllegalAccessError:
class com.ilic.Service cannot access class sun.misc.Unsafe
원인:
해결:
--add-opens 옵션 (운영 권장 X)ClassCastException:
com.ilic.Shipment cannot be cast to com.ilic.Shipment
당황스러운 에러:
원인 예:
해결:
obj.getClass().getClassLoader()Caused by: java.lang.NoSuchMethodError:
'java.lang.String com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(java.lang.Object)'
원인:
디버깅:
# 어느 jar에서 클래스가 로딩되는지
java -verbose:class App | grep ObjectMapper
# 클래스 파일 확인
jar tf jackson-databind.jar | grep ObjectMapper
unzip -p jackson-databind.jar com/.../ObjectMapper.class | javap -p
ILIC 앱 시작 시간 최적화 시:
# 모든 클래스 로딩 시간 측정
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintClassLoaderData App
# 또는 JFR
java -XX:StartFlightRecording=duration=60s,filename=startup.jfr App
→ 어느 클래스 로딩이 가장 오래 걸리는지 파악.
→ Spring Boot 워밍업 최적화의 출발점.
면접관: "둘 차이가 뭔가요?"
❌ "같은 거 아니에요?"
✅ "Checked vs Error. Reflection vs JVM 자동 로딩. (위 7.2 참조)"
// 컴파일은 됐지만 운영에서 NoClassDefFoundError
public Shipment process() {
return new SomeOptionalClass(); // optional 의존성?
}
해결:
gradle dependencies 로 transitive 확인boolean available = true;
try {
Class.forName("com.optional.SomeClass");
} catch (ClassNotFoundException e) {
available = false;
}
// ❌ 라이브러리에서
public class Util {
public static Map<String, Object> globalCache = new HashMap<>();
}
// 톰캣 webapp A, B가 같은 라이브러리 사용
// A의 데이터가 B에도 보임 → 격리 실패
→ static 캐시는 위험. ClassLoader 별 격리 못 함.
→ ThreadLocal, WeakReference 활용.
// ❌ static 변수에 Class 객체 저장
public class Service {
private static Class<?> cachedClass = SomeClass.class;
}
→ Tomcat redeploy 시 cachedClass가 옛 ClassLoader 참조.
→ 옛 ClassLoader가 GC 안 됨 → Metaspace 누수.
해결:
Class.forName (성능 손해지만 안전)라이브러리 A → 우리 코드 의존.
라이브러리 A 업데이트 시:
// v1.0
public Result process(String input) { ... }
// v2.0 (호환 안 됨)
public Result process(String input, Options opts) { ... }
우리 코드 컴파일은 v1.0으로:
process(Ljava/lang/String;)LResult;→ 라이브러리 API 호환성 매우 중요.
Method m = clazz.getMethod("calculate", int.class);
m.invoke(obj, 100);
Reflection도 결국 같은 Resolution 거침:
→ 메서드 누락 시 NoSuchMethodException.
→ Resolution 메커니즘은 같음.
# 1. 어떤 .class가 어디서 로딩됐는지
java -verbose:class App
# 2. 실행 중 클래스 로딩 통계
jcmd <PID> VM.classloader_stats
# 3. Metaspace 사용량
jcmd <PID> GC.heap_info
# 4. 클래스 히스토그램
jmap -histo <PID> | head -30
운영에서 클래스 로딩 의심 시 위 도구들.
| Q | 핵심 답변 |
|---|---|
| 심볼 참조와 직접 참조의 차이? | 이름 vs 메모리 주소. 컴파일 vs 런타임 |
| 왜 컴파일 시점에 주소를 안 박나? | 모름. 운영마다 다름. 격리/AOP/핫스왑 불가 |
| Resolution이 일어나는 시점? | 클래스/메서드/필드 처음 사용 시 (Lazy) |
| NoClassDefFoundError vs ClassNotFoundException? | Error/Reflection. JVM 자동/명시적 |
| Resolution 5단계? | 풀 조회 → 클래스 확보 → 멤버 검색 → 권한 검증 → 캐싱 |
| Lazy Resolution의 이점? | 시작 시간 단축, 사용 안 되는 클래스 메모리 절약 |
| Inline Cache는? | 호출 사이트별 캐시. 다형성 호출도 빠르게 |
| ClassLoader 격리가 가능한 이유? | 심볼 참조 → ClassLoader 별 다른 클래스 가능 |
| Late Binding이 만드는 자유? | AOP, 동적 프록시, 핫스왑, JAR 교체 |
| IllegalAccessError 원인? | 모듈 시스템(Java 9+), 접근 권한 변경 |
1. 심볼 참조 = "이름표만 있는 화살표"
.class의 Constant Pool에 보관#N)로 심볼 참조2. Resolution = "이름을 실제 주소로 변환"
3. Late Binding이 자바의 자유를 만든다
이번 Unit까지 Constant Pool과 Symbolic Reference를 봤다면, 다음은 모든 것을 종합.
Phase 3의 정점:
javap -c -v 출력을 처음부터 끝까지 분석new, invokespecial, dup 의 비밀 완전 해독이걸 끝내면 박승제씨는 바이트코드 읽기의 전문가.
🎯 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
✅ Unit 3.1 바이트코드란 무엇인가
✅ Unit 3.2 상수 풀의 생성과 구조
✅ Unit 3.3 심볼 참조 ← 여기
⏭ Unit 3.4 바이트코드 실전 분석 (정점의 정점)
<# 박승제씨가 운영 사고 시
javap -c -v -p MysteryClass.class > analysis.txt
# 30분 후 정확한 진단 작성:
# "이 클래스는 X 라이브러리 v2.3을 참조하는데,
# 실제 런타임은 v3.0이 로딩되어 method foo의 시그니처가 변경됨.
# 따라서 NoSuchMethodError 발생."
이게 박승제씨가 가질 능력. 다음 Unit으로 완성.
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
🎯 Phase 3 — 바이트코드와 상수 풀 (3/4 완주)
⏭ Phase 4 — G1 GC 심화
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer