2주차 Unit 3.3 — 심볼 참조 (Symbolic Reference)

Psj·2026년 5월 15일

F-lab

목록 보기
62/237

Unit 3.3 — 심볼 참조 (Symbolic Reference)

F-LAB JAVA · 2주차 · Phase 3 · 바이트코드와 상수 풀


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 컴파일 시점에 실제 메모리 주소를 박지 않는 이유는?
  • 심볼 참조(Symbolic Reference)직접 참조(Direct Reference) 의 차이는?
  • Resolution은 정확히 언제 일어나는가?
  • Lazy ResolutionEager Resolution 의 차이는?
  • NoClassDefFoundErrorClassNotFoundException 의 결정적 차이는?
  • Spring 클래스 로더 누수가 왜 일어나는가?
  • Late Binding 이 자바를 자바답게 만드는 이유는?

🎯 핵심 한 문장

심볼 참조는 "이름표가 붙어있지만 실제 주소는 비어있는 화살표"다.
컴파일러는 .class 파일에 이름(심볼)을 적어두고, 실제 메모리 주소는 비워둔다.
JVM이 런타임에 그 이름을 실제 주소로 채워 넣는 작업이 Resolution.
Late Binding 메커니즘이 자바의 모듈성 · AOP · 핫스왑의 근간이다.

비유 — 우편물의 주소

시점비유
컴파일우편물 봉투에 "수신자: 박승제 (한국, 서울)" 라고만 적음
JVM 시작우체국에 도착. 아직 수신자 위치 모름
Resolution우체국이 주소록 조회 → "서초구 강남대로 ..." 알아냄
Direct Reference봉투에 실제 주소 도장 찍힘. 이후 같은 사람에게 보낼 땐 도장 그대로 사용

→ 한 번 해소된 심볼은 캐시되어 빠르게 재사용.


🧭 9개 섹션 로드맵

1. 컴파일러의 딜레마 — 알 수 없는 미래
2. 심볼 참조 vs 직접 참조
3. Resolution의 5단계
4. Lazy Resolution — 사용 시점 해소
5. 캐싱 — 한 번 해소된 심볼은 영원히
6. Late Binding이 만드는 자바의 자유
7. ILIC 실무 — 클래스 로딩 사고 디버깅
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검

1️⃣ 컴파일러의 딜레마 — 알 수 없는 미래

1.1 컴파일러는 무엇을 모르는가

// 박승제의 ILIC 프로젝트
public class ShipmentService {
    public Shipment process(ShipmentRequest req) {
        Shipment s = new Shipment(req);    // ← 컴파일 시점
        return repository.save(s);
    }
}

컴파일러가 모르는 것:

  • JVM 시작 시 Shipment 클래스가 메모리 어느 주소에 적재될지
  • save 메서드의 바이트코드가 정확히 어느 위치에 있을지
  • repository 필드가 객체 안의 어느 오프셋에 있을지

이 정보들은 JVM이 클래스를 로딩한 후에야 결정된다.

1.2 만약 직접 주소를 박아둔다면?

가설: 컴파일러가 실제 메모리 주소를 미리 박아둔다면?

바이트코드 (가설):
  new 0x7f8c4d2a0010    // Shipment 클래스의 가상 주소
  invokevirtual 0x7f8c4d2b0030   // save 메서드의 가상 주소

문제:

  • 운영 서버마다 메모리 레이아웃이 다름 → 주소 무용
  • JVM 재시작 시마다 주소 바뀜 → 매번 재컴파일?
  • 클래스 로더 격리 안 됨 → 같은 클래스 두 번 로딩 불가
  • AOP, 핫스왑 불가 → 런타임에 클래스 교체 못 함

C/C++의 정적 링크 모델과 같은 문제. 자바가 이 길을 안 간 이유.

1.3 해결 — 추상화의 한 단계

컴파일러:
  "Shipment 클래스의 인스턴스를 만들어라"
  → "Shipment" 라는 이름만 적어둠 (심볼)
  → 실제 주소는 JVM이 런타임에 알아서

JVM:
  "Shipment 라는 이름의 클래스를 메모리에서 찾아"
  → 클래스 로더가 .class 파일 읽기
  → Method Area에 적재
  → 그 주소를 심볼에 채워 넣음 (= Resolution)
  → 이후 같은 이름은 즉시 그 주소 사용

이게 Late Binding (또는 Dynamic Linking).

1.4 C/C++ vs Java 비교

항목C/C++Java
링크 시점컴파일 + 정적 링크런타임 (Dynamic Linking)
외부 참조절대/상대 주소심볼 참조 (이름)
라이브러리 교체재컴파일 필요JAR 교체만
클래스 격리어려움ClassLoader로 격리
핫스왑어려움가능
보안메모리 직접 접근 위험JVM 샌드박스

→ 자바의 모든 "마법"의 출발점이 Symbolic Reference.


2️⃣ 심볼 참조 vs 직접 참조

2.1 정의

심볼 참조 (Symbolic Reference):

  • 클래스/메서드/필드를 이름과 시그니처로 표현
  • 실제 메모리 주소는 모름
  • Constant Pool 항목으로 저장됨

직접 참조 (Direct Reference):

  • 실제 메모리 주소 또는 포인터
  • JVM 내부 구조체(Klass, Method, Field 객체)를 가리킴
  • Resolution 후에 만들어짐

2.2 같은 호출의 두 가지 모습

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
  ↓ 즉시 그 주소의 메서드 실행

2.3 시각화

컴파일 시점:
  [ #20 = Methodref ]
       ↓ 가리키는 곳:
  ┌──────────────────────────────────┐
  │ Constant Pool                    │
  │   #4: Class "com/ilic/Shipment" │
  │   #15: NameAndType ...           │
  └──────────────────────────────────┘
       ↓ 실제 메모리에는 ?
       ↓ 모름!


Resolution 후:
  [ #20 = Methodref ]
       ↓ 동일한 인덱스, 하지만:
  ┌──────────────────────────────────┐
  │ Klass의 Method Table              │
  │   calculate 메서드 → 0x7f4a8d3c2010│
  └──────────────────────────────────┘
       ↓ 직접 메모리 주소
       ↓ 즉시 실행!

Resolution = 심볼 → 직접 변환 의 핵심 작업.

2.4 캐시되는 위치

Resolution 결과는 어디 저장되나?

Method Area:
  ┌─────────────────────────────────┐
  │ ShipmentService Klass            │
  │   런타임 상수 풀:                  │
  │     #20: Methodref               │
  │       ├ Symbolic: "Shipment.calc"│
  │       └ Resolved: 0x7f4a8d3c2010 │ ← 캐시!
  └─────────────────────────────────┘

JVM이 런타임 상수 풀(Runtime Constant Pool)에 해소 결과 함께 보관.
→ 처음 사용 시 1번 해소, 이후엔 즉시 직접 참조.


3️⃣ Resolution의 5단계

3.1 클래스 로딩 단계 복습

Unit 1.3, 2.3에서 본 클래스 로딩의 3단계:

1. Loading        — .class 파일 읽기
2. Linking        ┐
   - Verification │
   - Preparation  │ ← Resolution이 여기 포함
   - Resolution   ┘
3. Initialization — static 초기화

이번 Unit에서 Resolution을 자세히.

3.2 Resolution의 5단계 상세

"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 단계 생략                 │
└─────────────────────────────────────────────┘

3.3 클래스 / 메서드 / 필드 별 Resolution

클래스 Resolution

new Shipment();
1. Constant Pool에서 "com/ilic/Shipment" Utf8 확인
2. ClassLoader에 Shipment 로딩 요청
   - 이미 로딩됐나? Yes → Klass 반환
   - No → .class 파일 찾기 → Loading → Linking
3. 접근 권한 검증 (public 클래스인가?)
4. Klass 포인터를 직접 참조로 캐싱

메서드 Resolution

shipment.calculate(100);
1. Constant Pool에서 Methodref 정보 확인
2. 대상 Klass 확보 (클래스 Resolution 먼저)
3. Method Table 검색:
   - 자신의 클래스에서 시그니처 일치 검색
   - 없으면 부모 클래스 (Object까지)
   - 인터페이스 메서드도 검색
4. 접근 권한 + abstract 검증
5. Method 구조체 포인터를 직접 참조로 캐싱

필드 Resolution

shipment.blNo
1. Constant Pool에서 Fieldref 정보 확인
2. 대상 Klass 확보
3. Field Table 검색:
   - 자신의 클래스에서 이름+타입 일치 검색
   - 없으면 부모 클래스
   - 인터페이스 상수도 검색 (static final)
4. 접근 권한 검증
5. 필드 오프셋(객체 안의 위치)을 캐싱

→ 필드는 메모리 오프셋(객체 시작 위치로부터의 거리)이 직접 참조.

3.4 Resolution이 실패하는 경우

각 단계에서 발생 가능한 예외:

단계실패 시 예외
1. Constant Pool(드물게) ClassFormatError
2. 클래스 확보NoClassDefFoundError
3. 멤버 검색NoSuchMethodError, NoSuchFieldError
4. 접근 검증IllegalAccessError
5. 캐싱(보통 실패 없음)

→ 박승제씨가 운영에서 자주 만나는 에러들의 출처.


4️⃣ Lazy Resolution — 사용 시점 해소

4.1 JVM의 기본 정책 — Lazy

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 수행
  → 객체 생성

→ 사용 안 되는 클래스는 영원히 로딩 안 됨.
→ 메모리 절약 + 시작 시간 단축.

4.2 Lazy의 이점

시나리오: ILIC 앱이 100개 라이브러리 의존.

Eager 방식 (모든 클래스 미리 로딩):
  - 모든 .class 파일 읽기
  - 메모리 적재
  - 시작 시간: 10초+
  - 메모리: 큼

Lazy 방식 (JVM 기본):
  - 처음엔 일부만 로딩
  - 실제 사용 시 점진적 로딩
  - 시작 시간: 빠름
  - 메모리: 점진적

특히 사용 안 되는 라이브러리는 영원히 로딩 안 됨.
→ 라이브러리 의존성 추가의 부담 감소.

4.3 Resolution 트리거 정확히

JLS(Java Language Specification) 기준, Resolution이 일어나는 시점:

1. 처음 클래스를 사용할 때
   - new MyClass()
   - MyClass.staticMethod()
   - MyClass.staticField

2. 메서드 첫 호출 시
   - instance.method()
   - 첫 호출에만 Resolution
   - 이후 캐시 사용

3. 필드 첫 접근 시
   - 마찬가지

4. instanceof, .class 같은 타입 검사

4.4 Lazy의 함정 — 운영에서

public class App {
    public static void main(String[] args) {
        // 99% 정상 실행
        normalPath();
        
        if (rareCondition) {
            // 1% 실행되는 코드
            new ProblemClass();  // ← 1년 후 발견되는 에러
        }
    }
}

만약 ProblemClass 가 빠진 JAR로 배포:

  • 일반 실행: 문제 없음
  • rare condition: NoClassDefFoundError

→ 운영 중 어느 날 갑자기 발생.
→ 테스트 커버리지의 중요성.

4.5 Eager 강제 옵션

특별한 경우 Eager Resolution 강제:

java -Xverify:all App   # 모든 클래스 verify

또는 코드로:

// 클래스 로딩 강제
Class.forName("com.ilic.ProblemClass");

→ 시작 시점에 모든 의존성 검증.
→ 일반적으론 안 함 (Lazy의 이점 포기).


5️⃣ 캐싱 — 한 번 해소된 심볼은 영원히

5.1 첫 호출 vs 이후 호출

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번만.
→ 이후는 즉시 직접 호출.

5.2 인라인 캐시 (Inline Cache)

JIT 컴파일러가 한 발 더 나아감:

인라인 캐시 (호출 사이트별 캐싱):

shipment.calculate(100) 호출 사이트:
  ┌──────────────────────────────┐
  │ 캐시:                         │
  │   객체 클래스 = Shipment      │
  │   메서드 주소 = 0x7f4a8d3c... │
  └──────────────────────────────┘
  
다음 호출 시:
  - 현재 객체의 Klass 포인터 비교
  - 같으면 → 캐시 사용 (1-2 명령어!)
  - 다르면 → 다시 Resolution

다형성 호출도 거의 정적 호출 만큼 빠름.
→ Unit 2.3의 JIT 효과의 메모리 메커니즘.

5.3 캐시의 위치

.class 파일:
  Constant Pool (Symbolic Only)

Method Area:
  런타임 상수 풀
    - Symbolic Reference
    - Resolved Direct Reference ★ (해소 후 추가됨)

JIT가 생성한 네이티브 코드:
  Inline Cache ★ (호출 사이트별)

→ 여러 레벨의 캐싱이 협력.

5.4 캐시 무효화 — Class Redefinition

// 디버거가 클래스를 핫스왑 했다면?

JVM의 동작:

  • 기존 직접 참조 무효화
  • 다음 호출 시 다시 Resolution
  • 새 클래스의 메서드 주소로 캐시 갱신

→ 핫스왑이 가능한 메모리 메커니즘.


6️⃣ Late Binding이 만드는 자바의 자유

6.1 자바의 핵심 마법 4가지

Late Binding 덕에 가능해진 것들:

1. ClassLoader 격리
   - 같은 클래스 이름을 다른 클래스로 로딩 가능
   - 톰캣의 webapp별 격리, OSGi 등

2. 동적 프록시 / AOP
   - 런타임에 클래스 동적 생성
   - 기존 코드는 심볼 참조만 가지고 있어서 영향 없음

3. 핫스왑 (HotSwap)
   - 실행 중에 클래스 교체
   - 개발 시 IDE의 핫리로드

4. JAR 교체만으로 라이브러리 업데이트
   - 재컴파일 불필요
   - 의존성 버전 변경 쉬움

6.2 ClassLoader 격리 시나리오

톰캣 서버:
  ┌─────────────────────────────────┐
  │ 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 없으면 불가능.

6.3 동적 프록시의 메모리 메커니즘

@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 심볼을 가짐
  • 그러나 실제 객체는 Enhancer (자식 클래스)
  • save() 호출 → VMT에서 Enhancer의 save() 실행
  • Enhancer가 트랜잭션 코드 추가 후 원본 호출

클라이언트 코드는 심볼만 알면 됨. 실제 클래스가 바뀌어도 무관.

6.4 핫스왑

public BigDecimal calculate(int weight) {
    return BigDecimal.valueOf(weight * 100);
}

개발 중에 코드 수정:

public BigDecimal calculate(int weight) {
    return BigDecimal.valueOf(weight * 200);   // 변경
}

IDE의 HotSwap:

  • JVM에 새 .class 전달
  • 기존 Klass 교체
  • 캐시된 직접 참조 무효화
  • 다음 호출 시 새 메서드 실행

JVM 재시작 없이 코드 반영.
→ Late Binding 없으면 불가능.

6.5 JAR 교체

앱 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는 심볼 참조 덕에 훨씬 유연.

6.6 진정한 모듈성

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 위에서:

  • 각 모듈이 자기 ClassLoader 가질 수 있음
  • 심볼 참조가 모듈 경계를 자연스럽게 표현
  • 캡슐화 + 의존성 명시

→ Late Binding이 진정한 모듈성의 기반.


7️⃣ ILIC 실무 — 클래스 로딩 사고 디버깅

7.1 NoClassDefFoundError — 가장 흔한 사고

상황: 운영 중 갑자기 에러

java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
    at com.ilic.ShipmentService.process(ShipmentService.java:45)

의미:

  • 컴파일 시점엔 StringUtils 가 있었음
  • 런타임에 클래스 로딩 시 못 찾음

원인:

  • JAR 누락 (gradle dependency 깜빡)
  • 버전 충돌 (transitive dependency)
  • ClassLoader 격리 문제

디버깅:

# 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

7.2 NoClassDefFoundError vs ClassNotFoundException

둘은 다르다:

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 (?) {
    // 컴파일 단계에서 잡힘
}

면접 단골 질문.

7.3 ClassLoader 누수 — Tomcat redeploy

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);
}

→ 운영에서 매우 흔한 클래스 누수 패턴.

7.4 IllegalAccessError — 모듈/가시성

java.lang.IllegalAccessError: 
    class com.ilic.Service cannot access class sun.misc.Unsafe

원인:

  • Java 9+의 모듈 시스템
  • 내부 API 직접 접근 금지

해결:

  • --add-opens 옵션 (운영 권장 X)
  • 공개 API 사용으로 리팩토링

7.5 동일 클래스명, 다른 클래스 — 신비한 ClassCastException

ClassCastException: 
    com.ilic.Shipment cannot be cast to com.ilic.Shipment

당황스러운 에러:

  • 같은 클래스를 같은 클래스로 캐스팅?
  • → 사실 다른 ClassLoader에서 로딩된 두 개의 같은 이름 클래스

원인 예:

  • Tomcat hot deploy 시 두 ClassLoader에 같은 클래스
  • OSGi 환경의 버전 충돌
  • 동적 프록시 + ClassLoader 격리

해결:

  • ClassLoader 식별: obj.getClass().getClassLoader()
  • 두 객체가 정말 같은 ClassLoader에서 왔는지 확인

7.6 Spring Boot 시작 실패 — Resolution 추적

Caused by: java.lang.NoSuchMethodError: 
    'java.lang.String com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(java.lang.Object)'

원인:

  • Jackson 버전 충돌
  • 컴파일 시점엔 method A 있었음
  • 런타임엔 method B만 있음
  • Resolution 실패

디버깅:

# 어느 jar에서 클래스가 로딩되는지
java -verbose:class App | grep ObjectMapper

# 클래스 파일 확인
jar tf jackson-databind.jar | grep ObjectMapper
unzip -p jackson-databind.jar com/.../ObjectMapper.class | javap -p

7.7 클래스 로딩 시점 측정

ILIC 앱 시작 시간 최적화 시:

# 모든 클래스 로딩 시간 측정
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintClassLoaderData App

# 또는 JFR
java -XX:StartFlightRecording=duration=60s,filename=startup.jfr App

→ 어느 클래스 로딩이 가장 오래 걸리는지 파악.
→ Spring Boot 워밍업 최적화의 출발점.


8️⃣ 흔한 실수 + 디버깅

실수 1 — NoClassDefFoundError 와 ClassNotFoundException 혼동

면접관: "둘 차이가 뭔가요?"

❌ "같은 거 아니에요?"
✅ "Checked vs Error. Reflection vs JVM 자동 로딩. (위 7.2 참조)"

실수 2 — 런타임에 클래스 누락 확인 안 함

// 컴파일은 됐지만 운영에서 NoClassDefFoundError
public Shipment process() {
    return new SomeOptionalClass();   // optional 의존성?
}

해결:

  • gradle dependencies 로 transitive 확인
  • Maven shade 또는 Spring Boot fat JAR 사용
  • 또는 런타임 체크:
boolean available = true;
try {
    Class.forName("com.optional.SomeClass");
} catch (ClassNotFoundException e) {
    available = false;
}

실수 3 — ClassLoader 격리 가정 안 함

// ❌ 라이브러리에서
public class Util {
    public static Map<String, Object> globalCache = new HashMap<>();
}

// 톰캣 webapp A, B가 같은 라이브러리 사용
// A의 데이터가 B에도 보임 → 격리 실패

static 캐시는 위험. ClassLoader 별 격리 못 함.
→ ThreadLocal, WeakReference 활용.

실수 4 — Reflection으로 만든 Class 객체 캐싱

// ❌ static 변수에 Class 객체 저장
public class Service {
    private static Class<?> cachedClass = SomeClass.class;
}

→ Tomcat redeploy 시 cachedClass가 옛 ClassLoader 참조.
→ 옛 ClassLoader가 GC 안 됨 → Metaspace 누수.

해결:

  • 매번 fresh Class.forName (성능 손해지만 안전)
  • WeakReference 사용
  • 또는 클래스명만 String으로 저장

실수 5 — 메서드 시그니처 변경의 위험

라이브러리 A → 우리 코드 의존.
라이브러리 A 업데이트 시:

// v1.0
public Result process(String input) { ... }

// v2.0 (호환 안 됨)
public Result process(String input, Options opts) { ... }

우리 코드 컴파일은 v1.0으로:

  • 바이트코드의 Methodref: process(Ljava/lang/String;)LResult;
  • v2.0 런타임:
  • 그 시그니처의 메서드 없음 → NoSuchMethodError

→ 라이브러리 API 호환성 매우 중요.

실수 6 — Reflection이 항상 Resolution 우회한다고 착각

Method m = clazz.getMethod("calculate", int.class);
m.invoke(obj, 100);

Reflection도 결국 같은 Resolution 거침:

  • getMethod() 가 메서드 검색 + 캐시
  • invoke() 가 실제 호출

→ 메서드 누락 시 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

운영에서 클래스 로딩 의심 시 위 도구들.


9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

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+), 접근 권한 변경

9.2 자기 점검 체크리스트

기본 이해

  • 심볼 참조와 직접 참조의 차이를 안다
  • Resolution이 Lazy함을 안다
  • Resolution의 5단계를 안다
  • 캐시되는 위치를 안다 (런타임 상수 풀 + 인라인 캐시)
  • NoClassDefFoundError와 ClassNotFoundException의 차이를 안다

실전 적용

  • 클래스 로딩 사고를 디버깅할 수 있다
  • ClassLoader 격리 시나리오를 이해한다
  • Spring AOP의 동적 클래스 생성을 안다
  • Tomcat redeploy 시 ClassLoader 누수 패턴을 안다
  • static 캐시의 ClassLoader 위험을 안다

면접 대비 — 5분 답변

  • 심볼 참조의 필요성 (C/C++ 대비)
  • Resolution과 Lazy의 이점
  • Late Binding이 만드는 자바의 마법
  • 클래스 로딩 관련 에러 종류와 원인
  • ClassLoader 누수 디버깅 방법

🎯 핵심 요약 — 3줄 정리

1. 심볼 참조 = "이름표만 있는 화살표"

  • 컴파일 타임엔 실제 주소를 모름 → 이름(심볼)으로 적어둠
  • .class의 Constant Pool에 보관
  • 바이트코드는 인덱스(#N)로 심볼 참조

2. Resolution = "이름을 실제 주소로 변환"

  • Lazy: 첫 사용 시점에만
  • 5단계: 풀 조회 → 클래스 확보 → 멤버 검색 → 권한 검증 → 캐싱
  • 한 번 해소된 결과는 영원히 캐시

3. Late Binding이 자바의 자유를 만든다

  • ClassLoader 격리
  • AOP / 동적 프록시
  • 핫스왑
  • JAR 교체만으로 라이브러리 업데이트
  • ILIC 운영의 모든 마법

📚 다음으로...

Unit 3.4 — 바이트코드 실전 분석

이번 Unit까지 Constant Pool과 Symbolic Reference를 봤다면, 다음은 모든 것을 종합.

Phase 3의 정점:

  • 한 클래스 전체의 javap -c -v 출력을 처음부터 끝까지 분석
  • 상수 풀 + 바이트코드 + 어노테이션 + 디버그 정보
  • new, invokespecial, dup 의 비밀 완전 해독
  • Java 8 vs 17 vs 21 같은 코드의 바이트코드 차이
  • ILIC 실무 코드 분석

이걸 끝내면 박승제씨는 바이트코드 읽기의 전문가.

Phase 3 진행 상황

🎯 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
  ✅ Unit 3.1 바이트코드란 무엇인가
  ✅ Unit 3.2 상수 풀의 생성과 구조
  ✅ Unit 3.3 심볼 참조 ← 여기
  ⏭ Unit 3.4 바이트코드 실전 분석 (정점의 정점)

Phase 3 후의 능력

<# 박승제씨가 운영 사고 시
javap -c -v -p MysteryClass.class > analysis.txt

# 30분 후 정확한 진단 작성:
# "이 클래스는 X 라이브러리 v2.3을 참조하는데, 
#  실제 런타임은 v3.0이 로딩되어 method foo의 시그니처가 변경됨.
#  따라서 NoSuchMethodError 발생."

이게 박승제씨가 가질 능력. 다음 Unit으로 완성.

2주차 진행 상황

✅ 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
profile
Software Developer

0개의 댓글