클래스 파일 구조 바이트코드 실행 invokedynamic · 람다 리플렉션 동적 프록시 · 위빙 📝 문제풀이
◀ 심화 CH 22 📋 목차 심화 CH 24 ▶
🧬 심화 CHAPTER 23 · 딥다이브 · 5년차 트랙

내 코드는 어떤 바이트코드가 되고, 프레임워크는 그걸 어떻게 조작할까?

스프링의 @Transactional, 모킹 라이브러리, 롬복, APM 에이전트 — 이 "마법"들의 정체는 결국 바이트코드·리플렉션·프록시예요. 이번 장은 javac가 내놓는 .class 파일의 해부학부터 스택 기반 VM의 실행 방식, 람다가 invokedynamic으로 태어나는 과정, 그리고 프레임워크가 런타임에 클래스를 만들어내는 동적 프록시·로드 타임 위빙까지 파고듭니다.

🎯 이 장을 끝내면
📄
클래스 파일 구조
.class 파일의 해부학
모든 프레임워크 마법의 출발점은 이 바이너리 포맷이에요.

javac가 만든 .class는 엄격하게 정의된 바이너리 포맷이에요. JVM 명세가 정한 순서대로 필드가 늘어서 있고, 어떤 JVM이든 이 구조를 읽어 클래스를 복원합니다. 큰 줄기는 다음과 같아요.

구성 요소내용핵심 포인트
매직 넘버파일 첫 4바이트 0xCAFEBABE"이건 클래스 파일"이라는 시그니처
버전minor·major 버전(예: major 61 = Java 17)더 높은 버전이면 UnsupportedClassVersionError
상수 풀(Constant Pool)클래스·메서드·필드 이름, 문자열 리터럴, 심볼릭 참조바이트코드가 참조하는 중앙 심볼 테이블
access flagspublic·final·abstract·interface 등 비트 플래그클래스의 성격을 비트로 표현
this/super/interfaces자신·부모·구현 인터페이스에 대한 상수 풀 인덱스상속 관계도 결국 상수 풀 참조
필드·메서드각 필드/메서드의 이름·디스크립터·access flags·attributes메서드 본문은 Code attribute 안에
attributesCode·LineNumberTable·BootstrapMethods·애노테이션 등확장 정보를 담는 유연한 슬롯
🗃️ 상수 풀(Constant Pool)이 왜 중요한가 바이트코드 안에는 "System" 같은 문자열이 직접 박혀 있지 않아요. 대신 상수 풀 인덱스(예: #7)를 가리키고, 실제 이름·타입 정보는 상수 풀에 단 한 번 저장됩니다. 덕분에 파일이 작아지고, 링킹 단계에서 심볼릭 참조 → 직접 참조로 해석(resolution)할 지점이 명확해져요. 그래서 클래스 로딩의 "해석" 단계, 리플렉션의 이름 조회, invokedynamic의 부트스트랩까지 전부 상수 풀을 중심으로 돌아갑니다.

javap -c(또는 javap -v)로 컴파일된 바이트코드를 사람이 읽을 수 있어요. 예를 들어 int r = a + b;는 대략 이렇게 풀립니다.

// $ javap -c Sum.class
int add(int a, int b) {
    return a + b;
}
//   0: iload_1        // 지역변수 1(a)을 피연산자 스택에 push
//   1: iload_2        // 지역변수 2(b)를 push
//   2: iadd           // 스택 top 두 값을 pop, 더한 뒤 push
//   3: ireturn        // 스택 top을 int로 반환
💡
디스크립터(descriptor)를 읽을 줄 알면 절반은 끝. (Ljava/lang/String;I)Z는 "String과 int를 받아 boolean을 반환"이라는 뜻이에요. L…;=객체, I=int, Z=boolean, [=배열. 리플렉션·프록시 스택트레이스에서 이 표기를 자주 만납니다.
🧮
바이트코드 실행
JVM은 레지스터가 아니라 스택으로 계산한다
피연산자 스택·지역변수 배열, 그리고 5가지 invoke 명령.

JVM은 스택 기반 가상 머신이에요. CPU처럼 레지스터에 값을 넣고 연산하는 게 아니라, 각 메서드 프레임마다 있는 피연산자 스택(operand stack)에 값을 push/pop하며 계산합니다. 프레임에는 스택 외에도 파라미터·지역변수를 담는 지역변수 배열(local variable array)이 있어요.

🗄️
지역변수 배열
파라미터·지역변수 슬롯 (인덱스로 접근)
📚
피연산자 스택
연산 중간값을 push/pop
⚙️
명령 실행
iadd·invoke… 등이 스택을 소비

대표 명령을 몇 갈래로 묶어두면 javap 출력이 훨씬 잘 읽혀요.

명령하는 일비고
iload / aload지역변수(int/참조)를 스택으로 pushload = 변수 → 스택
istore / astore스택 top을 지역변수에 저장store = 스택 → 변수
invokestatic정적(static) 메서드 호출수신 객체 없음
invokevirtual일반 인스턴스 메서드 호출(동적 디스패치)실제 타입에 따라 오버라이드 결정
invokespecial생성자·private·super 호출오버라이드 무시, 정확히 그 메서드
invokeinterface인터페이스 참조로 호출구현 탐색 방식이 조금 다름
invokedynamic호출 대상을 런타임에 부트스트랩으로 결정람다·문자열 연결의 핵심
⚠️
invokevirtual과 invokespecial을 헷갈리지 마세요. 둘 다 인스턴스 메서드를 부르지만, invokevirtual실행 시점의 실제 타입을 보고 오버라이드된 구현으로 동적 디스패치합니다. 반대로 invokespecial은 오버라이드를 무시하고 정확히 지정된 메서드(생성자 <init>, private, super.foo())를 호출해요. 이 차이가 없으면 super.method()가 무한 재귀가 됩니다.
🧾 왜 스택 기반인가 스택 기반은 플랫폼 독립적이에요. 레지스터 개수·이름은 CPU마다 다르지만, 추상적인 피연산자 스택은 어디서나 동일하게 정의됩니다. 명령이 짧고(피연산자를 거의 안 붙임) 컴파일러 구현도 단순해져요. 대신 실제 실행 시엔 JIT가 이 스택 연산을 물리 레지스터 연산으로 다시 매핑해 성능을 확보합니다. "명세는 스택, 실행은 레지스터"라고 기억하면 편해요.
🪄
invokedynamic · 람다
람다는 익명 클래스가 아니다
런타임에 구현이 "생성"되는 invokedynamic의 세계.

흔한 오해: "람다는 컴파일 시 익명 클래스로 바뀐다." 아니에요. 자바 8부터 람다는 invokedynamic 명령 하나로 컴파일되고, 그 자리에 실제 구현 객체를 만드는 일은 런타임 첫 실행 때 LambdaMetafactory가 담당합니다.

✍️
소스 람다
s -> s.length()
🧬
invokedynamic
BootstrapMethods로 연결
🏭
LambdaMetafactory
첫 호출 때 구현 CallSite 생성

동작을 단계로 보면 이래요. 컴파일러는 람다 본문을 private static 합성 메서드로 만들어 두고, 호출 지점엔 invokedynamic을 심습니다. 이 indy는 BootstrapMethods attribute에 적힌 부트스트랩 메서드(LambdaMetafactory.metafactory)를 가리켜요. 최초 실행 시 부트스트랩이 함수형 인터페이스를 구현한 클래스를 만들고 CallSite에 연결하며, 이후 호출은 이 링크를 그대로 씁니다.

📈 익명 클래스 대비 이점 익명 클래스는 컴파일 시점에 별도 .class 파일(Outer$1.class)이 무조건 생겨요. 람다는 그 클래스 생성을 런타임으로 미뤄, 실제 실행되지 않으면 만들지 않고 JVM이 구현 전략을 자유롭게 고를 여지를 줍니다(상태 없는 람다는 인스턴스 캐싱 가능). 그래서 "람다는 익명 클래스의 문법 설탕"은 절반만 맞아요. 의미는 비슷해도 구현 메커니즘이 완전히 다릅니다.
💡
문자열 연결도 indy로 바뀌었어요. Java 9부터 "a" + x + "b" 같은 연결은 예전의 StringBuilder 체인 대신 invokedynamic(StringConcatFactory)로 컴파일됩니다. 런타임이 최적의 연결 전략을 고를 수 있게 하려는 거예요 — invokedynamic이 "람다 전용"이 아니라 범용 확장 지점임을 보여주는 사례죠.
🔍
리플렉션
런타임에 타입을 들여다보고 조작하기
강력하지만 공짜가 아니에요 — 비용과 대안을 함께.

리플렉션은 실행 중에 Class·Method·Field·Constructor를 조회하고 호출·접근하는 API예요. 프레임워크의 의존성 주입, ORM의 필드 매핑, 직렬화가 모두 이걸 씁니다.

Class<?> c = Class.forName("com.example.User");
Object u = c.getDeclaredConstructor().newInstance();

Field f = c.getDeclaredField("name");
f.setAccessible(true);          // 접근 제어 우회(모듈 규칙 허용 시)
f.set(u, "Alice");

Method m = c.getDeclaredMethod("greet", String.class);
Object r = m.invoke(u, "hi");   // 이름으로 메서드 호출
⚠️
리플렉션은 일반 호출보다 느릴 수 있어요. 호출마다 접근 검사가 끼고, 인자를 Object[]박싱·언박싱하며, 대상이 런타임에야 정해져 JIT의 인라이닝·최적화가 제한됩니다. 물론 최근 JVM은 잦은 리플렉션 호출을 상당히 최적화하지만, 뜨거운 경로(hot path)에서 반복 호출하는 건 여전히 피하는 게 좋아요.
⚡ 더 빠른 대안 — MethodHandle · VarHandle MethodHandle(java.lang.invoke)은 리플렉션보다 JIT 친화적이에요. 링크가 한 번 확정되면 거의 직접 호출처럼 최적화되고, invokedynamic의 기반이기도 합니다. 필드·배열 원소의 원자적/volatile 접근은 VarHandle이 담당해요(예전 Unsafe·AtomicXxxFieldUpdater의 안전한 후계자). 정리하면 "이름으로 유연하게"는 리플렉션, "빠르고 안전하게 반복 호출"은 MethodHandle/VarHandle이 유리합니다.
🧭
setAccessible(true)의 의미. 접근 검사를 끄고 private 멤버에도 접근하게 해요. 다만 Java 9 모듈 시스템(JPMS) 이후로는 모듈이 열어(open)주지 않으면 핵심 JDK 내부에는 막힙니다(InaccessibleObjectException). "무조건 뚫린다"가 아니라 "허용된 범위에서만"이에요.
🎭
동적 프록시 · 위빙
프레임워크는 런타임에 클래스를 만든다
JDK 프록시·CGLIB·ByteBuddy·자바 에이전트의 지도.

스프링 AOP, 트랜잭션, 모킹, APM — 이들의 공통 무기는 런타임에 새 클래스를 생성하거나 기존 바이트코드를 조작하는 것이에요. 크게 두 계열로 나뉩니다.

방식어떻게제약 · 특징
JDK 동적 프록시Proxy.newProxyInstance + InvocationHandler인터페이스가 반드시 필요(인터페이스 기반)
CGLIB / ByteBuddy대상 클래스를 상속한 서브클래스를 런타임 생성인터페이스 없어도 가능. final 클래스·메서드는 불가
ASM / Javassist바이트코드를 직접 생성·수정하는 저수준/중수준 라이브러리ByteBuddy·CGLIB도 내부적으로 ASM 사용
자바 에이전트java.lang.instrument + ClassFileTransformer로드 타임 위빙(클래스 로딩 시 변환)
🎯 스프링 AOP와의 연결 스프링은 프록시로 부가기능(트랜잭션·로깅·보안)을 감싸요. 대상 빈이 인터페이스를 구현하면 기본적으로 JDK 동적 프록시를, 인터페이스가 없으면 CGLIB 서브클래스 프록시를 씁니다(스프링부트는 CGLIB를 기본 선호). 그래서 프록시의 자기 호출(self-invocation) 문제가 생겨요 — 같은 객체 안에서 this.method()로 부르면 프록시를 우회해 @Transactional이 안 먹습니다. 프록시가 "바깥을 감싼 껍데기"라는 걸 알면 이 함정이 이해돼요.
💡
자바 에이전트(로드 타임 위빙)의 용도. -javaagent:로 붙는 에이전트는 클래스가 로딩될 때 바이트코드를 가로채 변형해요. APM/프로파일러(메서드 진입·종료 계측 삽입), 모킹(예: final·static까지 조작하는 mockito-inline), 커버리지 도구(JaCoCo), 핫스왑/재정의가 대표 사례입니다. 소스 수정 없이 어디에나 코드를 주입할 수 있어 강력하죠.
🖼️ 그림으로 보기 — 두 프록시 방식 비교
JDK 동적 프록시 인터페이스 기반 interface Service $Proxy0 implements Service ServiceImpl 실제 대상 CGLIB / ByteBuddy 서브클래스 기반 (상속) class Service (인터페이스 불필요) Service$$Enhancer extends Service (런타임 생성 서브클래스) ※ final 클래스·메서드는 서브클래싱 불가
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·프레임워크 디버깅에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 24 · 딥다이브 🩺 GC 튜닝 케이스 스터디 — 실제 GC 로그로 증상→원인→처방