자바8 람다와 함수형 인터페이스 [3] : 디슈거링을 통한 메서드 변환과 MethodHandle, 바이트코드/JVM 분석
개요#
람다를 배울 때 "그냥 for문이 성능이 더 좋다", "오버헤드가 발생한다" 정도로만 알고 넘어갔습니다. 근데 어느 시점부터 궁금해졌습니다 — 익명 클래스를 만들지도 않는다면서 실제로 바이트코드에서는 뭐가 벌어지는 건지.
직접 javap로 바이트코드를 뜯어보니까 구조가 보이기 시작했습니다. 핵심은 두 가지입니다: 컴파일 타임에 람다가 일반 정적 메서드로 변환(디슈거링)되고, 런타임에 invokedynamic이 MethodHandle을 통해 그 메서드를 연결합니다.
디슈거링(Desugaring)#
자바 8 이전에는 익명 클래스를 만들어서 메서드를 넘겨야 했습니다. 자바 8에서 람다가 들어오면서 이 방식이 바뀌었는데, 컴파일러가 람다를 익명 클래스로 변환하는 대신 일반 정적 메서드(또는 인스턴스 메서드)로 변환합니다. 이 변환 과정이 디슈거링(desugaring)입니다.
변환된 메서드는 실행 시점에 invokedynamic 바이트코드가 MethodHandle로 연결해줍니다. 익명 클래스 파일이 안 생기니까 클래스 로딩 비용도 줄어듭니다.
람다 표현식의 디슈거링 예제#
위의 s -> s.length()가 컴파일되면 실제로 어떻게 바뀌는지, javap로 바이트코드를 뜯어봤습니다.
컴파일 후 바이트코드 분석 (javap 활용)#
invokedynamic와 LambdaMetafactory#
바이트코드를 보면 람다가 사용된 자리에 invokedynamic이 들어가 있습니다. invokedynamic은 자바 7에서 추가된 바이트코드인데, 호출 대상을 컴파일 타임이 아니라 런타임에 결정합니다. 람다에서는 LambdaMetafactory가 이 역할을 맡아서, 디슈거링된 메서드에 대한 MethodHandle을 생성해줍니다.
컴파일러가 실제로 하는 일#
컴파일러는 세 단계로 람다를 변환합니다. 하나씩 바이트코드로 확인해봤습니다.
1단계 — 정적 메서드 생성
LambdaDesugaringExample 클래스 안에 lambda$s$0이라는 정적 메서드가 새로 생깁니다.
private static Integer lambda$s$0(String s) {
return s.length();
}
2단계 — invokedynamic으로 MethodHandle 생성
invokedynamic이 LambdaMetafactory를 호출해서 위 메서드에 대한 MethodHandle을 만들고, Function<String, Integer> 타입으로 반환합니다.
3단계 — 런타임 연결
실행 시점에 invokedynamic이 lambda$s$0을 호출하도록 MethodHandle을 연결합니다.
왜 MethodHandle인가#
그러면 왜 굳이 MethodHandle이라는 장치를 거치는 걸까. 가장 큰 이유는 JVM이 런타임에 인라이닝 같은 최적화를 걸 수 있기 때문입니다. 익명 클래스는 별도 .class 파일이 생기고 클래스 로딩도 필요한데, MethodHandle은 정적 메서드를 직접 가리키니까 JVM 입장에서 인라인하기가 훨씬 쉽습니다. 클래스 파일이 안 생기니 클래스 로딩 비용도 없고요.
결국 자바 8의 람다는 단순한 문법 설탕이 아니라 바이트코드 레벨의 최적화입니다. 컴파일 타임에 디슈거링으로 정적 메서드가 되고, 런타임에 invokedynamic + LambdaMetafactory가 MethodHandle로 연결해줍니다. 익명 클래스가 사라지니까 클래스 로딩도 없고, JVM 인라이닝도 잘 먹습니다.
설계 과정에서 풀어야 했던 문제들#
Brian Goetz의 Translation of Lambda Expressions를 읽어보면, 람다를 JVM에 넣기까지 꽤 많은 설계 결정이 있었다는 걸 알 수 있습니다.
가장 먼저 부딪힌 건 바이트코드에서 함수 호출을 어떻게 표현하느냐였습니다. 기존 자바에는 메서드를 직접 참조하는 방법이 없었기 때문에, 익명 클래스나 인터페이스 구현으로 메서드를 넘겨야 했습니다. 이걸 invokedynamic + LambdaMetafactory 조합으로 풀었습니다.
그 다음은 함수 타입 변수의 인스턴스를 어떻게 만들 것인가 하는 문제였습니다. Comparator나 Runnable 같은 인터페이스를 익명 클래스로 구현하던 방식 대신, 람다를 Functional Interface의 인스턴스로 변환하도록 설계해서 기존 API와의 호환성을 유지했습니다.
타입 시스템 쪽에서는 변성(Variance) 처리가 걸렸습니다. 새로운 Function Type을 언어에 추가하면 타입 시스템이 복잡해지기 때문에, 기존 제네릭 기반 Functional Interface를 그대로 재활용하는 방향을 택했습니다. 람다를 항상 Functional Interface의 인스턴스로 변환하면 타입 시스템을 건드리지 않아도 되니까요.
마지막으로 JVM 실행 효율 문제가 있었는데, 이건 앞에서 본 것처럼 invokedynamic이 런타임에 최적의 구현을 동적으로 결정하고, 필요할 때만 인스턴스를 생성하는 방식으로 해결했습니다.