스프링의 @Transactional, 모킹 라이브러리, 롬복, APM 에이전트 — 이 "마법"들의 정체는 결국 바이트코드·리플렉션·프록시예요. 이번 장은 javac가 내놓는 .class 파일의 해부학부터 스택 기반 VM의 실행 방식, 람다가 invokedynamic으로 태어나는 과정, 그리고 프레임워크가 런타임에 클래스를 만들어내는 동적 프록시·로드 타임 위빙까지 파고듭니다.
javap -c로 바이트코드를 읽어요.setAccessible의 의미를 알아요.javac가 만든 .class는 엄격하게 정의된 바이너리 포맷이에요. JVM 명세가 정한 순서대로 필드가 늘어서 있고, 어떤 JVM이든 이 구조를 읽어 클래스를 복원합니다. 큰 줄기는 다음과 같아요.
| 구성 요소 | 내용 | 핵심 포인트 |
|---|---|---|
| 매직 넘버 | 파일 첫 4바이트 0xCAFEBABE | "이건 클래스 파일"이라는 시그니처 |
| 버전 | minor·major 버전(예: major 61 = Java 17) | 더 높은 버전이면 UnsupportedClassVersionError |
| 상수 풀(Constant Pool) | 클래스·메서드·필드 이름, 문자열 리터럴, 심볼릭 참조 | 바이트코드가 참조하는 중앙 심볼 테이블 |
| access flags | public·final·abstract·interface 등 비트 플래그 | 클래스의 성격을 비트로 표현 |
| this/super/interfaces | 자신·부모·구현 인터페이스에 대한 상수 풀 인덱스 | 상속 관계도 결국 상수 풀 참조 |
| 필드·메서드 | 각 필드/메서드의 이름·디스크립터·access flags·attributes | 메서드 본문은 Code attribute 안에 |
| attributes | Code·LineNumberTable·BootstrapMethods·애노테이션 등 | 확장 정보를 담는 유연한 슬롯 |
"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로 반환
(Ljava/lang/String;I)Z는 "String과 int를 받아 boolean을 반환"이라는 뜻이에요. L…;=객체, I=int, Z=boolean, [=배열. 리플렉션·프록시 스택트레이스에서 이 표기를 자주 만납니다.JVM은 스택 기반 가상 머신이에요. CPU처럼 레지스터에 값을 넣고 연산하는 게 아니라, 각 메서드 프레임마다 있는 피연산자 스택(operand stack)에 값을 push/pop하며 계산합니다. 프레임에는 스택 외에도 파라미터·지역변수를 담는 지역변수 배열(local variable array)이 있어요.
대표 명령을 몇 갈래로 묶어두면 javap 출력이 훨씬 잘 읽혀요.
| 명령 | 하는 일 | 비고 |
|---|---|---|
iload / aload | 지역변수(int/참조)를 스택으로 push | load = 변수 → 스택 |
istore / astore | 스택 top을 지역변수에 저장 | store = 스택 → 변수 |
invokestatic | 정적(static) 메서드 호출 | 수신 객체 없음 |
invokevirtual | 일반 인스턴스 메서드 호출(동적 디스패치) | 실제 타입에 따라 오버라이드 결정 |
invokespecial | 생성자·private·super 호출 | 오버라이드 무시, 정확히 그 메서드 |
invokeinterface | 인터페이스 참조로 호출 | 구현 탐색 방식이 조금 다름 |
invokedynamic | 호출 대상을 런타임에 부트스트랩으로 결정 | 람다·문자열 연결의 핵심 |
invokevirtual은 실행 시점의 실제 타입을 보고 오버라이드된 구현으로 동적 디스패치합니다. 반대로 invokespecial은 오버라이드를 무시하고 정확히 지정된 메서드(생성자 <init>, private, super.foo())를 호출해요. 이 차이가 없으면 super.method()가 무한 재귀가 됩니다.흔한 오해: "람다는 컴파일 시 익명 클래스로 바뀐다." 아니에요. 자바 8부터 람다는 invokedynamic 명령 하나로 컴파일되고, 그 자리에 실제 구현 객체를 만드는 일은 런타임 첫 실행 때 LambdaMetafactory가 담당합니다.
동작을 단계로 보면 이래요. 컴파일러는 람다 본문을 private static 합성 메서드로 만들어 두고, 호출 지점엔 invokedynamic을 심습니다. 이 indy는 BootstrapMethods attribute에 적힌 부트스트랩 메서드(LambdaMetafactory.metafactory)를 가리켜요. 최초 실행 시 부트스트랩이 함수형 인터페이스를 구현한 클래스를 만들고 CallSite에 연결하며, 이후 호출은 이 링크를 그대로 씁니다.
Outer$1.class)이 무조건 생겨요. 람다는 그 클래스 생성을 런타임으로 미뤄, 실제 실행되지 않으면 만들지 않고 JVM이 구현 전략을 자유롭게 고를 여지를 줍니다(상태 없는 람다는 인스턴스 캐싱 가능).
그래서 "람다는 익명 클래스의 문법 설탕"은 절반만 맞아요. 의미는 비슷해도 구현 메커니즘이 완전히 다릅니다.
"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)에서 반복 호출하는 건 여전히 피하는 게 좋아요.Unsafe·AtomicXxxFieldUpdater의 안전한 후계자).
정리하면 "이름으로 유연하게"는 리플렉션, "빠르고 안전하게 반복 호출"은 MethodHandle/VarHandle이 유리합니다.
setAccessible(true)의 의미. 접근 검사를 끄고 private 멤버에도 접근하게 해요. 다만 Java 9 모듈 시스템(JPMS) 이후로는 모듈이 열어(open)주지 않으면 핵심 JDK 내부에는 막힙니다(InaccessibleObjectException). "무조건 뚫린다"가 아니라 "허용된 범위에서만"이에요.스프링 AOP, 트랜잭션, 모킹, APM — 이들의 공통 무기는 런타임에 새 클래스를 생성하거나 기존 바이트코드를 조작하는 것이에요. 크게 두 계열로 나뉩니다.
| 방식 | 어떻게 | 제약 · 특징 |
|---|---|---|
| JDK 동적 프록시 | Proxy.newProxyInstance + InvocationHandler | 인터페이스가 반드시 필요(인터페이스 기반) |
| CGLIB / ByteBuddy | 대상 클래스를 상속한 서브클래스를 런타임 생성 | 인터페이스 없어도 가능. final 클래스·메서드는 불가 |
| ASM / Javassist | 바이트코드를 직접 생성·수정하는 저수준/중수준 라이브러리 | ByteBuddy·CGLIB도 내부적으로 ASM 사용 |
| 자바 에이전트 | java.lang.instrument + ClassFileTransformer | 로드 타임 위빙(클래스 로딩 시 변환) |
this.method()로 부르면 프록시를 우회해 @Transactional이 안 먹습니다. 프록시가 "바깥을 감싼 껍데기"라는 걸 알면 이 함정이 이해돼요.
-javaagent:로 붙는 에이전트는 클래스가 로딩될 때 바이트코드를 가로채 변형해요. APM/프로파일러(메서드 진입·종료 계측 삽입), 모킹(예: final·static까지 조작하는 mockito-inline), 커버리지 도구(JaCoCo), 핫스왑/재정의가 대표 사례입니다. 소스 수정 없이 어디에나 코드를 주입할 수 있어 강력하죠.javap -c로 읽는다.setAccessible은 모듈 허용 범위 내.