2주차 Unit 2.1 — 메서드 호출의 2단계 처리

Psj·2026년 5월 15일

F-lab

목록 보기
56/240

Unit 2.1 — 메서드 호출의 2단계 처리

F-LAB JAVA · 2주차 · Phase 2 · JVM 메서드 실행 메커니즘
🚀 Phase 2 시작 — Phase 1의 메모리 구조 위에서 메서드 호출 추적


📌 학습 목표

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

  • 메서드 호출은 왜 1단계가 아니라 2단계로 분리되어 있나?
  • Method ResolutionMethod Invocation 의 차이는?
  • 컴파일 시점에 결정되는 메서드와 런타임에 결정되는 메서드를 어떻게 구분하나?
  • s.calculate() 한 줄이 JVM에서 정확히 어떤 순서로 처리되나?
  • 다형성·오버라이딩이 이 2단계 구조에 어떻게 의존하나?
  • Spring AOP, JPA Proxy가 이 메커니즘을 어떻게 활용하나?

🎯 핵심 한 문장

모든 메서드 호출은 "무엇을 호출하는가"(시그니처 확인) 와 "그 실체는 어디에 있는가"(바이트코드 실행) 의 2단계로 분리된다.
Class Metadata Zone에서 시그니처를 확인하고, Static/Non-Static Zone에서 실제 바이트코드를 실행한다.
이 분리가 다형성·AOP·동적 프록시를 모두 가능하게 만드는 근본 메커니즘이다.

비유 — 식당 주문 처리

단계식당 비유JVM
1단계손님이 "스파게티 하나" 라고 외침"calculate() 호출하자" — 시그니처
2단계주방에서 "스파게티 레시피" 찾아 조리실제 바이트코드 실행
  • 메뉴(시그니처)와 레시피(바이트코드)는 별도 보관
  • 같은 메뉴라도 주방장(객체 타입)이 다르면 다른 레시피 사용 → 다형성

🧭 9개 섹션 로드맵

1. 우리가 보는 한 줄 vs JVM이 하는 작업
2. 1단계 — Method Resolution (시그니처 확인)
3. 2단계 — Method Invocation (바이트코드 실행)
4. Static Dispatch vs Dynamic Dispatch
5. 메서드 영역 — 시그니처와 바이트코드 매핑
6. 다형성·오버라이딩이 의존하는 2단계
7. ILIC 실무 — Spring AOP · JPA Proxy의 비밀
8. 흔한 실수 + 성능 이슈
9. 면접 질문 + 자기 점검

1️⃣ 우리가 보는 한 줄 vs JVM이 하는 작업

1.1 단순해 보이는 코드 한 줄

Shipment s = repository.findById(1L).orElseThrow();
BigDecimal fare = s.calculate(100);    // ← 이 한 줄

우리가 보기에:

"s 객체의 calculate 메서드를 100 인자로 호출"

JVM이 실제로 하는 일은 훨씬 많다.

1.2 JVM이 내부적으로 하는 일

"s.calculate(100)" 한 줄을 만났을 때:

🔍 Phase A: Method Resolution (메서드 결정)
  1. s의 참조 따라가서 Heap의 객체 찾기
  2. 객체의 Class Pointer → Shipment Klass
  3. Klass의 Class Metadata Zone에서 "calculate(int)" 시그니처 찾기
  4. 메서드 접근 권한 검증 (public? private?)
  5. 메서드 호출 방식 결정 (virtual? special? static?)
  
🚀 Phase B: Method Invocation (메서드 실행)
  1. 해당 메서드의 바이트코드 위치 찾기 (Static or Non-Static Zone)
  2. 새 스택 프레임 생성 (LVA 크기 결정, Operand Stack 초기화)
  3. 매개변수와 this 전달
  4. PC를 메서드 첫 명령으로 이동
  5. 바이트코드 실행 시작

"무엇을 부를지" 와 "어떻게 부를지" 는 다른 단계.

1.3 왜 분리되어 있나 — 3가지 이유

이유 1 — 다형성을 가능하게 함

Cargo c = getCargo();    // DryCargo? ReeferCargo? HazmatCargo?
c.calculateFee();        // 어느 메서드 호출?

만약 1단계만으로 끝나면 컴파일 시점에 모든 게 결정돼야 함.
2단계 구조 덕에 1단계(시그니처)는 컴파일 타임에, 2단계(실제 메서드)는 런타임에 결정 가능.

이유 2 — AOP·Proxy 같은 메타 프로그래밍

@Transactional
public Shipment save(Shipment s) { ... }

Spring은 이 메서드 호출을 가로채서 @Transactional 동작을 추가.
어떻게? → 1단계(시그니처)는 그대로 두고 2단계(실행)를 프록시 객체로 바꿔치기.

이유 3 — 클래스 로딩 지연(Lazy Loading)

// 컴파일 시점:
SomeClass.someMethod();    // ← 호출만 컴파일

// 런타임 시점:
// 1단계에서 SomeClass가 처음 로딩됨
// 2단계에서 실제 메서드 바이트코드 실행

→ 호출되지 않는 클래스는 메모리에 로딩 안 됨. JVM의 lazy loading 전략.


2️⃣ 1단계 — Method Resolution

2.1 Class Metadata Zone에서 일어나는 일

Unit 1.3에서 본 Class Metadata Zone 의 내용 중:

Shipment 클래스의 Metadata:
  ...
  ├─ 메서드 시그니처 목록:
  │   ├─ calculate(int) : BigDecimal       (public)
  │   ├─ setBlNo(String) : void            (public)
  │   ├─ validate() : void                 (private)
  │   ├─ getId() : Long                    (public)
  │   └─ ... 
  └─ 상수 풀 (Constant Pool)

이 시그니처 목록에서 호출하려는 메서드를 찾는 작업이 1단계.

2.2 시그니처가 뭔가

자바에서 메서드 시그니처 = 이름 + 매개변수 타입 (순서대로).
반환 타입은 시그니처에 포함 안 됨 (오버로딩 규칙).

public BigDecimal calculate(int weight)              // calculate(I)
public BigDecimal calculate(int weight, BigDecimal rate)  // calculate(I, BigDecimal)
public Integer calculate(int weight)                 // ❌ 시그니처 충돌 (반환 타입만 다름)

JVM 내부 표기:

calculate(I)Ljava/math/BigDecimal;
         ↑                        ↑
   매개변수 디스크립터          반환 디스크립터

I = int
Ljava/math/BigDecimal; = BigDecimal 객체
V = void

→ 1단계에서 JVM은 이 디스크립터로 정확한 메서드를 식별.

2.3 Resolution의 세부 단계

1단계 — Method Resolution 흐름:

  ┌─────────────────────────────────────────┐
  │ 1. 호출 사이트(call site)의 상수 풀 인덱스   │
  │    예: invokevirtual #15                 │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 2. 상수 풀의 #15 항목 = MethodRef         │
  │    → "Shipment.calculate(I)BigDecimal"   │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 3. Shipment 클래스의 Method Table 검색    │
  │    → calculate(I) 발견                   │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 4. 접근 권한 / abstract 여부 검증         │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 5. 메서드 항목(Method 구조체) 반환        │
  │    → 바이트코드 위치 포함                  │
  └─────────────────────────────────────────┘

상수 풀의 인덱스 → 실제 메서드 객체 로 변환되는 과정.
→ Phase 3에서 상수 풀과 함께 더 깊이 본다.

2.4 부모 클래스 탐색

해당 클래스에 메서드가 없으면 부모 체인을 따라 탐색.

class BaseEntity {
    public void touch() { lastModifiedAt = LocalDateTime.now(); }
}

class Shipment extends BaseEntity {
    // touch() 안 정의
}

Shipment s = new Shipment();
s.touch();   // 호출됨

Resolution 흐름:

Shipment.touch() 검색
  → 없음
→ 부모 BaseEntity.touch() 검색
  → 발견
→ 그 메서드 사용

→ 상속 관계는 Resolution 단계에서 처리.

2.5 한 번 Resolve된 결과는 캐시됨

for (int i = 0; i < 1000000; i++) {
    shipment.calculate(100);   // 100만 번 호출
}

매번 Resolution하면 너무 비싸. JVM은 한 번 Resolve된 결과를 인라인 캐시에 저장.

첫 호출:  Resolution 수행 (느림)
이후 호출: 캐시된 메서드 직접 호출 (매우 빠름)

Resolution 비용은 첫 호출에서만. 이후는 빠른 호출.


3️⃣ 2단계 — Method Invocation

3.1 Static/Non-Static Zone에서 일어나는 일

1단계에서 결정된 메서드의 바이트코드를 실제로 실행하는 단계.

2단계 — Method Invocation 흐름:

  ┌─────────────────────────────────────────┐
  │ 1. 메서드의 바이트코드 위치 확정            │
  │    → Static Zone (static) 또는           │
  │      Non-Static Zone (instance)         │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 2. 새 스택 프레임 생성                    │
  │    - LVA 크기 결정 (메타데이터에서 읽음)    │
  │    - Operand Stack 초기화                │
  │    - Return Address 저장                │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 3. 매개변수 전달                          │
  │    - 호출자 Operand Stack → 새 LVA       │
  │    - 인스턴스 메서드면 this도 함께          │
  └──────────────┬──────────────────────────┘
                 ▼
  ┌─────────────────────────────────────────┐
  │ 4. PC 이동 + 바이트코드 실행 시작          │
  └─────────────────────────────────────────┘

→ Unit 1.4에서 본 스택 프레임 동작 + Unit 1.5의 invoke 명령이 결합.

3.2 바이트코드 실행은 Unit 1.4의 연장

public BigDecimal calculate(int weight) {
    BigDecimal base = new BigDecimal("100");
    return base.multiply(BigDecimal.valueOf(weight));
}

2단계 실행 흐름:

PC=0: new BigDecimal              ← Heap에 BigDecimal 객체 생성
PC=N: ldc "100"                   ← 상수 풀에서 String 푸시
PC=N: invokespecial <init>        ← 생성자 호출 (재귀적으로 2단계 발생)
PC=N: astore_2                    ← base에 저장
PC=N: aload_2                     ← base 로드
PC=N: iload_1                     ← weight 로드
PC=N: invokestatic BigDecimal.valueOf  ← 또 다른 메서드 호출 (재귀적 2단계)
PC=N: invokevirtual multiply      ← 또 다른 메서드 호출
PC=N: areturn                     ← 결과 반환

메서드 안에서 또 메서드를 호출하면 또 1+2단계 발생. 재귀적.

3.3 메서드 종료 시

1. 반환값을 호출자의 Operand Stack에 push
2. 현재 프레임 pop
3. Return Address로 PC 복원
4. 호출자가 자기 코드 계속

→ Unit 1.4의 메서드 반환과 정확히 동일.


4️⃣ Static Dispatch vs Dynamic Dispatch

4.1 두 가지 디스패치 방식

Static DispatchDynamic Dispatch
결정 시점컴파일 타임런타임
사용 명령invokestatic, invokespecialinvokevirtual, invokeinterface
대상static, private, final, super, 생성자일반 인스턴스 메서드
다형성
속도빠름빠름 (인라인 캐시 후)

4.2 Static Dispatch — 컴파일 타임 결정

// 컴파일러가 알 수 있는 것:
Math.max(10, 20);                  // ← Math 클래스의 static max
super.toString();                  // ← 부모 클래스 명확
new Shipment();                    // ← 생성자, Shipment 클래스 명확
this.privateMethod();              // ← private, 다른 클래스에서 못 부름
final void finalMethod() {}        // ← override 불가

이런 호출은 컴파일러가 정확한 메서드를 미리 알 수 있다.
→ 1단계 Resolution이 컴파일 타임에 끝남.

4.3 Dynamic Dispatch — 런타임 결정

Cargo c = getCargo();   // 어떤 서브타입?
c.calculateFee();       // ← 컴파일러는 Cargo의 메서드인 줄만 앎
컴파일 시점:
  바이트코드 → invokevirtual Cargo.calculateFee
  
런타임 시점:
  c가 가리키는 객체의 Class Pointer 확인
  → DryCargo Klass의 VMT에서 calculateFee 슬롯 찾기
  → DryCargo.calculateFee 실제 실행

→ Unit 1.5의 VMT가 정확히 이 메커니즘.

4.4 두 방식이 한 메서드 안에서 섞임

public BigDecimal processCargo(Cargo cargo) {
    BigDecimal fee = cargo.calculateFee();        // Dynamic (invokevirtual)
    BigDecimal vat = Math.multiplyExact(           // Static  (invokestatic)
        fee.longValue(), 10);
    log.info("Fee: {}", fee);                      // Dynamic (invokeinterface)
    return new BigDecimal(vat);                    // Static  (invokespecial)
}

같은 메서드 안에서 4가지 invoke 모두 등장.
→ JVM이 매번 어떤 디스패치 방식을 쓸지 결정.

4.5 컴파일러의 똑똑함 — devirtualization

JIT 컴파일러는 일부 dynamic dispatch를 static처럼 최적화.

// 코드는 dynamic이지만
animal.sound();

// 만약 animal이 항상 Dog만 들어온다면
// JIT가 추론: "거의 항상 Dog이니, Dog.sound를 직접 부르자"
// → 인라이닝까지 가능

이게 인라인 캐시 + 인라이닝. 다형성의 성능 비용을 거의 0으로.

→ "다형성은 성능이 느리다"는 잘못된 통념. JIT 덕에 거의 차이 없음.


5️⃣ 메서드 영역 — 시그니처와 바이트코드 매핑

5.1 큰 그림 다이어그램

Method Area:
┌──────────────────────────────────────────────────┐
│  Shipment Klass                                  │
│  ┌────────────────────────────────────────────┐  │
│  │  Class Metadata Zone                       │  │
│  │  • Method Table (시그니처 목록)             │  │
│  │      [0] calculate(I)BigDecimal → ref #A   │  │
│  │      [1] getId()Long           → ref #B   │  │
│  │      [2] setBlNo(String)V      → ref #C   │  │
│  │      [3] validate()V (private) → ref #D   │  │
│  └────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────┐  │
│  │  Non-Static Zone                           │  │
│  │  ref #A: calculate 바이트코드               │  │
│  │           0: new                            │  │
│  │           3: ldc                            │  │
│  │           ...                               │  │
│  │  ref #B: getId 바이트코드                   │  │
│  │           0: aload_0                        │  │
│  │           ...                               │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

핵심 통찰:

  • Class Metadata Zone의 Method Table은 "시그니처 → 바이트코드 위치" 매핑
  • 1단계는 Method Table에서 시그니처를 찾고
  • 2단계는 매핑된 바이트코드 위치로 점프

5.2 1단계와 2단계의 연결 — Method 구조체

JVM 내부에서 메서드 1개는 Method 구조체로 표현.

Method 구조체 (calculate 메서드):
  ├─ 이름: "calculate"
  ├─ 시그니처: "(I)Ljava/math/BigDecimal;"
  ├─ 접근 플래그: public, not static, not final
  ├─ 바이트코드 시작 주소: 0x...
  ├─ 바이트코드 크기: 47 bytes
  ├─ LVA 크기: 3 슬롯
  ├─ Operand Stack 최대 크기: 4
  ├─ Exception Table: (try-catch 정보)
  └─ Local Variable Table: (디버깅 정보)

→ 1단계의 결과 = 이 Method 구조체.
→ 2단계는 이 구조체의 정보로 프레임 만들고 실행.

5.3 인터페이스의 추가 복잡성 — itable

public interface Calculable {
    BigDecimal calculate(int weight);
}

public class Shipment implements Calculable {
    @Override
    public BigDecimal calculate(int weight) { ... }
}

Shipment의 Method Table은:

Shipment Method Table:
  [0] calculate(I)BigDecimal       (Calculable 인터페이스 구현)
  [1] getId()Long
  ...

invokeinterface로 호출 시:

Calculable c = new Shipment();
c.calculate(100);

→ c의 객체 → Shipment Klass
→ Shipment의 itable에서 Calculable 인터페이스 슬롯 찾기
→ 그 슬롯의 calculate 메서드 호출

추가 단계가 있어서 invokevirtual 보다 살짝 느리지만 실무에선 차이 무시 가능.

5.4 정리 — Phase 1과의 연결

Unit 1.3 — Method Area의 3개 존
  → Metadata Zone에 시그니처, Static/Non-Static Zone에 바이트코드 분리

Unit 1.5 — Object Header의 Class Pointer
  → 객체 → Klass로 가는 다리

Unit 2.1 (이번) — Method Resolution & Invocation
  → 그 Klass의 Metadata에서 시그니처 찾고 → 바이트코드 실행

→ Phase 1의 메모리 구조 위에서 메서드 호출이 정확히 어떻게 일어나는지 추적 완성.


6️⃣ 다형성·오버라이딩이 의존하는 2단계

6.1 오버라이딩의 본질

class Cargo {
    public BigDecimal calculateFee() {
        return BigDecimal.ZERO;
    }
}

class DryCargo extends Cargo {
    @Override
    public BigDecimal calculateFee() {
        return weight.multiply(new BigDecimal("100"));
    }
}

JVM에서의 모습:

Cargo Klass:
  Method Table:
    [N] calculateFee → ref #C1 (Cargo의 바이트코드)
  
DryCargo Klass (Cargo 상속):
  Method Table:
    [N] calculateFee → ref #D1 (DryCargo의 바이트코드)   ← 같은 슬롯 N

같은 시그니처, 같은 Method Table 슬롯 위치.
다른 클래스가 다른 바이트코드를 같은 슬롯에 매핑.

6.2 다형성 호출의 실제

Cargo c = new DryCargo();
c.calculateFee();
1단계: 
  - 컴파일러: "Cargo.calculateFee를 호출하는 invokevirtual"
  - 슬롯 N에서 메서드 호출하라는 명령

2단계:
  - 런타임: c가 가리키는 객체의 Class Pointer 확인
  - DryCargo Klass 발견
  - DryCargo의 Method Table[N] = ref #D1
  - ref #D1의 바이트코드 (DryCargo.calculateFee) 실행

핵심: 1단계는 "슬롯 N의 메서드"라고만 결정. 실제 어느 클래스의 메서드인지는 2단계에서 객체에 따라 결정.

6.3 만약 2단계가 분리되어 있지 않다면?

가설: 컴파일 시점에 모든 게 결정된다면?

Cargo c = new DryCargo();
c.calculateFee();   // 컴파일러가 Cargo.calculateFee로 결정

// 이 경우 무조건 Cargo의 메서드 실행 → DryCargo 메서드 안 불림
// → 다형성 깨짐

2단계 분리 = 다형성의 본질.

6.4 super 호출의 특수성

class DryCargo extends Cargo {
    @Override
    <public BigDecimal calculateFee() {
        BigDecimal base = super.calculateFee();   // ← 부모 명시적 호출
        return base.add(weight.multiply(...));
    }
}

super.calculateFee()invokespecial 로 컴파일.
→ 동적 디스패치 안 함. 무조건 Cargo의 calculateFee 실행.
→ "이 객체가 사실 DryCargo이지만, Cargo의 메서드를 강제로 호출".

→ static dispatch의 정확한 사례. 1단계에서 결정 끝.

6.5 final 메서드의 최적화

class Cargo {
    public final BigDecimal calculateBase() {
        return weight.multiply(BASE_RATE);
    }
}

final 키워드는 "이 메서드는 오버라이드 불가" 라는 약속.
JIT는 이걸 보고 static dispatch처럼 처리 + 인라이닝.

→ 다형성 비용 0.

// JIT 최적화 결과 (개념적)
class Cargo {
    public void doSomething(Cargo c) {
        BigDecimal base = weight.multiply(BASE_RATE);   // ← 인라인됨, 메서드 호출 사라짐
        // ...
    }
}

7️⃣ ILIC 실무 — Spring AOP · JPA Proxy의 비밀

7.1 Spring AOP — 2단계 분리의 활용

@Service
public class ShipmentService {
    
    @Transactional
    public Shipment save(Shipment s) {
        return repository.save(s);
    }
}

@Transactional이 어떻게 동작하나?

Spring의 답: 프록시 객체를 만들어 실제 ShipmentService 호출을 가로채기.

일반 호출:
  controller → shipmentService.save()
  → ShipmentService 클래스의 save 메서드 직접 실행

AOP 호출:
  controller → shipmentServiceProxy.save()
  → ShipmentService$$EnhancerBySpringCGLIB.save() 실행
      1. 트랜잭션 시작
      2. 진짜 ShipmentService.save() 호출
      3. 트랜잭션 커밋/롤백

메모리에서 일어나는 일:

Heap:
  ShipmentServiceProxy 객체 ← Controller가 받음
    └─ target = 진짜 ShipmentService 객체

Method Area:
  ShipmentService 클래스 (원본)
  ShipmentService$$EnhancerBySpringCGLIB 클래스 (프록시)
    └─ save() 시그니처가 같음 → invokevirtual로 호출되면 프록시 메서드 실행

Method Resolution은 그대로, Invocation 단계에서 프록시 메서드가 실행.
→ 2단계 분리가 만드는 메타 프로그래밍.

7.2 AOP self-invocation 문제

이 분리 메커니즘 때문에 흔히 마주치는 함정:

@Service
public class ShipmentService {
    
    public void process() {
        save(shipment);     // ❌ AOP 동작 안 함!
    }
    
    @Transactional
    public Shipment save(Shipment s) { ... }
}

왜?

  • 외부에서 shipmentService.save() → Spring 프록시 호출 → AOP 동작 ✓
  • 같은 클래스 안에서 save()this의 메서드 직접 호출 → 프록시 안 거침 → AOP 무시 ❌

메모리 추적:

shipmentServiceProxy.process() 호출
  → 프록시 메서드 실행
  → 진짜 ShipmentService.process() 호출
      → 그 안의 save() 호출
        → this 가 진짜 객체이므로 this.save() 직접 호출
        → 프록시 안 거침
        → @Transactional 무시

해결:

// 1. 별도 빈으로 분리
@Service
public class TransactionalService {
    @Transactional
    public Shipment save(Shipment s) { ... }
}

@Service
public class ShipmentService {
    private final TransactionalService tx;
    
    public void process() {
        tx.save(shipment);   // ✓ 프록시 거침
    }
}

// 2. 또는 self-injection (안티 패턴 가까움)
@Autowired
private ShipmentService self;

public void process() {
    self.save(shipment);     // ✓
}

이 함정의 원인은 정확히 메서드 호출의 2단계 메커니즘 이해 부족.

7.3 JPA Lazy Loading의 원리

@Entity
public class Shipment {
    @OneToMany(fetch = FetchType.LAZY)
    private List<Cargo> cargoes;
}

Shipment s = repository.findById(1L).get();
s.getCargoes().size();    // ← 이 시점에 DB 쿼리 발생

Hibernate의 트릭:

s.getCargoes()
  → Hibernate가 List 자리에 실제 ArrayList가 아닌 PersistentBag (Proxy) 넣어둠
  → PersistentBag이 List 인터페이스 구현
  → size() 호출 시 invokeinterface → PersistentBag.size()
      → "아직 로딩 안 됨, DB 쿼리 실행"
      → 결과 채우고 size 반환

invokeinterface의 2단계 분리가 Lazy Loading을 가능하게 함.

7.4 Mockito도 같은 원리

@Test
void test() {
    ShipmentRepository mockRepo = Mockito.mock(ShipmentRepository.class);
    when(mockRepo.findById(1L)).thenReturn(Optional.of(testShipment));
    
    // mockRepo의 모든 메서드 호출이 가로채짐
    mockRepo.findById(1L);   // → 미리 설정한 값 반환
}

Mockito도 프록시 클래스를 동적 생성.
→ 1단계 Resolution은 정상, 2단계 Invocation에서 mock 동작.

7.5 @SpringBootApplication의 마법

@Configuration
public class AppConfig {
    
    @Bean
    public ShipmentService shipmentService() {
        return new ShipmentService();
    }
}

Spring은 AppConfigCGLIB 프록시로 감쌈.
shipmentService() 호출 시 단순히 새 객체 만들지 않고, 캐시된 싱글톤 반환.

@Bean
public Foo foo() {
    return new Foo(bar());   // ← 매번 새 Bar?
}

@Bean
public Bar bar() {
    return new Bar();
}

답: 아님. bar()도 프록시를 통해 호출 → 싱글톤 보장.
→ 2단계 분리로 가능한 메타 프로그래밍.


8️⃣ 흔한 실수 + 성능 이슈

실수 1 — AOP self-invocation

@Service
public class ShipmentService {
    public void doAll() {
        save(s);        // ❌ @Transactional 무시
    }
    
    @Transactional
    public void save(Shipment s) { ... }
}

→ 7.2 참조. 가장 흔한 함정.

실수 2 — final 클래스/메서드를 AOP 적용

@Service
public final class ShipmentService {   // ❌ AOP 못 함
    @Transactional
    public final Shipment save(Shipment s) { ... }
}

Spring CGLIB AOP는 클래스 상속으로 프록시 생성.
final 클래스/메서드는 상속/오버라이드 불가 → 프록시 못 만듦.

해결: final 제거 또는 인터페이스 기반 JDK Proxy 사용.

실수 3 — private 메서드에 @Transactional

@Service
public class ShipmentService {
    @Transactional
    private void save(Shipment s) { ... }   // ❌ 무시됨
}

private 메서드는 invokespecial (static dispatch).
프록시가 가로챌 수 없음.

@Transactional 은 반드시 public 메서드에.

실수 4 — Reflection 호출이 느리다는 오해

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

Reflection 호출도 결국 invoke 명령으로 변환.
첫 호출은 Resolution이 비싸지만, 반복 호출은 캐시되어 빨라짐.

→ 한 번만 부르면 느림, 반복 호출은 일반 호출과 큰 차이 없음.

실수 5 — invokedynamic 무시

람다와 String concat이 사용하는 invokedynamic:

Runnable r = () -> System.out.println("hello");

내부적으로 invokedynamic → 1단계 Resolution을 런타임에 동적으로 수행.
5번째 invoke (Java 7+, Unit 1.5에서 다룬 4종 외에 추가).

람다가 익명 클래스보다 메모리 효율이 좋은 이유.

실수 6 — 가상 호출의 성능 과대평가

// "다형성은 느리다" — 잘못된 인식
Animal a = getAnimal();
a.sound();    // 느릴 거라 생각하지만

JIT의 인라인 캐시 + devirtualization + 인라이닝.
→ 거의 항상 같은 타입이면 정적 호출만큼 빠름.

성능을 의심하기 전에 측정. 보통 다형성이 병목이 아님.

디버깅 — 호출 경로 추적

# 1. JIT가 어떤 호출을 인라이닝 했는지 보기
java -XX:+PrintInlining -XX:+UnlockDiagnosticVMOptions MyApp

# 2. 메서드 호출 카운트
java -XX:+CountInvocations MyApp

# 3. Async-Profiler로 호출 hot path 확인
async-profiler -d 30 -f profile.html <PID>

운영에서 메서드 호출 병목 의심 시 위 도구들로 측정.


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

9.1 면접 단골 질문 매핑

Q핵심 답변
메서드 호출의 2단계?Resolution(시그니처 확인) + Invocation(바이트코드 실행)
왜 2단계로 분리?다형성·AOP·동적 프록시 가능. lazy class loading
Static vs Dynamic Dispatch?컴파일 타임 결정 vs 런타임 결정
invokespecial 사용 시점?생성자, private, super 호출
다형성이 2단계에 의존하는 이유?1단계는 슬롯 결정, 2단계에서 객체 타입 따라 실제 메서드
Spring AOP 동작 원리?프록시 객체로 invoke 단계에서 가로채기
AOP self-invocation 함정?this의 메서드 호출은 프록시 안 거침
final 메서드 최적화?static dispatch + 인라이닝 가능
Reflection이 정말 느린가?첫 호출만 느림. 반복 호출은 일반 호출과 큰 차이 없음
invokedynamic 사용처?람다, String concat, switch pattern (Java 21+)

9.2 자기 점검 체크리스트

기본 이해

  • Method Resolution과 Invocation의 차이를 안다
  • Static과 Dynamic Dispatch의 결정 시점을 안다
  • 4가지 invoke 명령이 어느 디스패치에 해당하는지 안다
  • Method Table과 바이트코드 위치의 매핑을 설명할 수 있다
  • 다형성이 2단계에 의존하는 메커니즘을 안다

실전 적용

  • AOP self-invocation 함정을 인식하고 회피할 수 있다
  • @Transactional이 동작하지 않는 케이스를 진단할 수 있다
  • final/private과 AOP의 충돌을 안다
  • JPA Proxy의 Lazy Loading 원리를 설명할 수 있다
  • JIT 최적화 효과를 측정할 수 있다

면접 대비 — 5분 답변

  • 메서드 호출 2단계 구조의 의미
  • Static vs Dynamic Dispatch 결정 기준
  • Spring AOP의 메모리 메커니즘
  • AOP self-invocation 함정의 원인과 해결
  • invokedynamic이 만드는 람다의 효율성

🎯 핵심 요약 — 3줄 정리

1. 메서드 호출은 반드시 2단계로 분리

  • 1단계 (Resolution): Class Metadata Zone에서 시그니처 확인
  • 2단계 (Invocation): Static/Non-Static Zone의 바이트코드 실행
  • 이 분리가 다형성·AOP·Proxy의 근본 메커니즘

2. Static vs Dynamic Dispatch

  • Static: 컴파일 타임 결정 (invokestatic, invokespecial)
  • Dynamic: 런타임 결정 (invokevirtual, invokeinterface)
  • JIT 덕에 둘의 성능 차이는 사실상 없음

3. ILIC 실무 직결

  • Spring AOP = 2단계의 Invocation을 프록시로 가로채기
  • AOP self-invocation 함정의 근본 원인
  • JPA Lazy Loading, Mockito, @Bean 캐싱 모두 같은 원리

📚 다음으로...

Unit 2.2 — Static vs 인스턴스 메서드 호출 경로

이번 Unit에서 메서드 호출의 일반론을 봤다면, 다음은 Static과 Instance의 호출 경로를 정확히 비교.

  • Static → Static: 가장 단순
  • Static → Instance: 객체 필요
  • Instance → Static: 자유
  • Instance → Instance: this 매개변수
  • 메모리 그림으로 4가지 경우 모두 추적

2주차 진행 상황

✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
🚀 Phase 2 — JVM 메서드 실행 메커니즘
  ✅ Unit 2.1 메서드 호출의 2단계 처리 ← 여기
  ⏭ Unit 2.2 Static vs 인스턴스 호출 경로
  ⏭ Unit 2.3 인스턴스 메서드 호출의 전 과정 (Case Study)
  ⏭ Unit 2.4 new 연산자의 실제 동작
⏭ Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점

Phase 3까지의 거리

이번 Unit에서:

  • "invokevirtual #15" 같은 명령
  • 상수 풀의 메서드 참조

Phase 3에서:

  • #15가 정확히 무엇인지
  • 상수 풀의 모든 종류
  • 심볼 참조 → 실제 참조 해소 메커니즘
  • javap -v 로 클래스 파일 완전 분해

→ Phase 2 끝나면 Phase 3 정점.

profile
Software Developer

0개의 댓글