trouble.log

Trouble ID 2018-01-10.java-8-problematic-lambda-invokedynamic

Note: 이 글은 Tumblr 로 발행되었으나 (https://a4.aurynj.net/post/169563364223), Tumblr 마크다운 에디터의 고질적 문제로부터 도망쳐서 이곳으로 왔습니다.

Java 버전을 올리고 싶어요

Java 버전과 관련된 이슈에는 두 가지가 있다. 하나는 .java 소스 코드 파일에서 Java 언어의 버전을 결정하는 JDK의 Java 컴파일러 javac의 버전이고, 다른 하나는 .class 바이너리 코드 파일에 따라 JVM의 버전을 결정하는 JRE의 Java 런타임 java의 버전이다.

Java에는 버전 변화를 따라 다음과 같은 주요 변화가 있었다.

또한 거의 모든 주 버전 변경과 패치에서 JVM의 성능 개선이 이루어졌다.

이 글에서 다룰 내용은 Java SE 7에서 추가된 JVM의 동적 언어 지원과 Java SE 8에서 추가된 람다 표현식에 관한 것이다.

JVM의 동적 언어 지원

Java의 명세는 Java 언어 명세와 Java 가상 기계 (JVM) 명세로 구분되어 있다. Java 언어 명세는 우리가 작성하는 소스 코드(.java)에서 쓸 수 있는 Java 언어의 기능을 규정한다. JVM 명세는 컴파일된 클래스 바이너리(.class)의 구조와 그 내용이 되는 명령어, 그리고 JVM의 작동을 규정한다.

JDK의 Java 컴파일러 javac.java 파일을 .class로 바꿔 주며, .class 파일은 JDK 1.0 이래로 모든 JVM에서 호환되어 왔다. 익명 클래스와 내부 클래스는 물론, 일반화, 어노테이션, 열거형 등에 심지어 switch에서의 String 매칭까지도 (hashCode()를 이용한다) 클래스 바이너리 파일은 JVM의 태동 이래 하위 호환성을 잃지 않는 방식으로 구현되어 왔다. 그렇기에 일정한 버전의 JVM을 사용해야 하더라도 Java 언어의 새로운 기능을 활용하기 위해서는 그냥 JDK를 업그레이드하면 되었다. JRE 수준에서는 계속 호환이 되니 말이다. 남은 건 런타임 클래스 포팅 정도일 것이다.

자, invokedynamic 명령어가 그것을 깨뜨렸다는 점을 얘기해야 할 것이다. 이는 JDK 1.0 이래 처음으로 JVM에 추가된 명령어이다. 뜬금없지만 그 역사를 살펴보자면 사실 좀 긴데, 우선 Java 언어와 JVM의 성공에서 시작한다. JVM이 성공하자 많은 언어들의 JVM 구현체가 만들어졌고 (JPython, Rhino) 애초에 JVM에서 작동하도록 설계된 언어들도 (Groovy, Scala, Clojure) 덩달아 성공을 거둔다. 이에 따라 JVM 저자들도 Java 외의 JVM 언어들에 대해 언급하기 시작하고 이를 지원할 방법을 찾아 나서게 된다.

기존의 메서드 디스패칭 계열 명령어들(invokevirtual, invokespecial, invokestatic, invokeinterface)에 더해, JSR 292는 정적 타입 언어인 Java가 필요로 하지 않던 새로운 명령어를 JVM이 수용할 것을 제안하였다. 이것이 invokedynamic이다. JSR 292는 JVM이 흔히 덕 타이핑(duck typing)으로 불리는 메서드 시그니처 기반의 동적 타입 추론과 실행을 할 것을 요구한다. invokedynamic의 인자는 부트스트랩 메서드(bootstrap method)라 불리는 메서드 레퍼런스로, JVM은 이 부트스트랩 메서드가 생성하는 CallSite 인스턴스의 MethodHandle을 실행한다.

이처럼 JVM 수준에서 덕 타이핑과 함수 포인터 실행을 지원하게 되면, 동적 타입이나 혼합 타입, 약 타입을 지닌 JVM 언어는 더 이상 반영(reflection)을 사용하는 꼼수를 부리지 않아도 된다. java.lang.reflect.Method 대신 java.lang.invoke.MethodHandle을 넘기고 이를 처리하는 루틴으로 충분한 것이다. 메서드 핸들 인스턴스는 반영 관련 정보를 모두 들고 있는 대신 함수 포인터처럼 작동하기 때문에 작고 빠르다. 따라서 동적 언어들이 실질적으로 혜택을 받게 되었다.

Java 8의 람다 표현식

프로그래밍 언어에서 함수자(functor)의 개념을 도입한 것은 상당히 오래 된 일이다. 함수형 프로그래밍 언어가 그 시작에 있었고, 잇따라 절차형, 객체지향형 언어를 비롯한 여러 구조적 언어들이 함수를 일급 개체나 다름없이 취급해 주기 시작했다. C와 C++에는 함수 포인터가 있고, Python을 비롯한 많은 언어에 함수 레퍼런스와 람다가 있으며, C#에는 대리자(delegate)가 있다.

이들은 대체로 세 가지 경우 중 하나에 속한다. 흔히 함수형으로 분류되는 언어들에서는 한 번 정의된 함수가 여러 타입으로 추론되어 쓰일 수 있다. 그 외의 정적 타입 언어에서는 모든 정의된 함수가 특정한 함수 타입 시그니처를 갖고 있으며 함수형 언어에서처럼 여러 타입으로 추론되어 쓰이지는 못한다. 마지막으로 많은 동적 타입 언어에서는 모든 함수에 타입 시그니처가 없고 람다는 함수와 동일한 객체로 간주된다.

Java는 람다를 도입하면서 정적 타입 언어와 동적 타입 언어의 중간쯤 되는 방식을 택했다. 컴파일타임에는 함수의 타입 시그니처를 미리 대응시켜 안전성을 확보하고, 런타임에는 이때 만들어 놓은 메서드 핸들을 이용해 호출하는 것이다. 메서드 핸들은 가벼운 반영이기 때문에 메서드 이름을 갖고 있고, 명령어는 이를 호출 위치에서 명령어와 함께 제공된 인자들로부터 추론한 타입 시그니처 (호출 인자 부분) 로 골라서 디스패치해 쓰는 것이다. 컴파일타임에는 정적 타입 언어의 안전성을 확보하고, 런타임에는 정적 타입 언어가 아니어도 되는 확장성을 확보해 주는 우수한 방식이라고 할 만하다.

과연 그럴까? 꼭 이렇게 해야만 했을까?

동적 디스패칭과 람다

어떤 면에서 동적 디스패칭과 람다는 대체로 닮은 방식으로 구현되어 왔는데, 익명 함수인 람다는 결국 기명 함수에 프로그래머가 직접 이름을 짓지 않고 제자리에서 쓸 수 있게 해 주는 문법 설탕이기 때문이다. 임의 이름으로 기명 함수를 만들고 이 이름을 넘기면 동적 디스패처가 해당 함수를 실행할 수 있게 될 것이다. 함수의 반환형을 포함한 타입 시그니처는 실행 위치에서 검증될 것이다.

그러나 이런 방법만 있었다면 C++에서 람다가 쓰일 수 있는 일은 없었을 것이다. 함수를 실행할 수 있는 핸들을 제대로 된 타입의 위치에 넘겨야 한다는 제약만 컴파일타임에 문법적으로 구비할 수 있으면, 타입 검증은 이미 컴파일타임에 끝나는 셈이다. 런타임에 굳이 특별한 명령어, 메서드 핸들 타입, 예외 처리 등이 필요하지 않은 것이다.

JVM 6 이하에서 이미 잘 돌아가는 함수형 언어인 Scala, Clojure, Kotlin, 그리고 Retrolambda preprocessor가 이런 접근법을 취하고 있다. 그렇다면 JVM 7에서 동적 언어를 지원하기 위한 invokedynamic/java.lang.invoke가 추가된 것과는 별개로, Java 8에서의 람다 역시 같은 방식으로 구현될 수 있지 않았을까? 흥미롭게도 JDK 1.8의 javac는 람다를 항상 invokedynamic으로 컴파일하고 있다. Java 언어에 람다 표현식을 추가하는 명세 제안서인 JSR 335를 보면 다음과 같은 텍스트가 나온다.

The anticipated implementation strategy requires the use of Method Handles and dynamic invocation as specified in JSR 292.

도대체 왜 OpenJDK Project Lambda는 이런 결정을 했는가? 요약하자면 길지만, 프로젝트 멤버들은 동적 언어를 흉내낸 상기한 정적 언어들이 어떻게 람다 식을 구현했는지를 잊고, JVM의 밑바닥부터 이를 구현할 방법을 찾다 이런 아이디어를 냈다. Java뿐만 아니라 JVM을 뜯어고칠 수 있던 이들이 힘에 취해 벌인 실수인 것이다. 인터페이스나 클래스 수를 줄이려던 좋은 의도였다는 설도 있으나 실제로는 그렇지 않았다.

invokedynamic의 미래, Java의 미래

invokedynamic은 느리다. Android의 Dalvik VM은 invokedynamic을 추가로 포함하지 않기로 결정했고 대신 invoke-polymorphic, invoke-custom이라는 제약된 형태의 명령어를 포함하게 되었다. 다만 낮은 API 수준의 Android용 소스 코드에서도 람다나 메서드 핸들, 콜 사이트를 포함한 Java 8 기능 일부를 쓸 방법이 있다. 람다의 경우 Retrolambda가 있고, 메서드 핸들까지 쓰는 경우 Android 프로젝트 빌드 툴체인의 desugar 유틸리티가 있다. desugar 유틸리티는 invokedynamic을 포함하는 Java 8 상위 호환 class 파일을 Java 7 머신에 호환되는 class 파일로 바꾸어 준다. 이후 dex로 변환되는 과정은 이전과 같다.

JVM의 초창기 목표였던 동적 언어 지원은 어떨까? 동적 언어는 JVM에서 앞으로 뭐든지 invokedynamic으로 돌릴 수 있을 것이다. 사실 동적 언어뿐만 아니라 정적 언어 역시 invokedynamic으로 모든 호출을 하도록 컴파일할 수 있을 것이다. 이게 효율적인 방법인가에 대한 답은 자명하다. NetBeans 초창기 아키텍트였던 Jaroslav Tulach의 개인 위키에서는 이에 관해 “망치를 갖고 있으면 모든 문제가 못으로 보인다” 라는 격언을 인용한다. (http://wiki.apidesign.org/wiki/InvokeDynamic)

Ruby 등의 동적 언어를 돌리기 위한 도움을 JVM에서 꼭 주고 싶었다면, 이를테면 asm.js과 비슷한 방식으로, 기존 JVM 7 명령어 집합을 건드리지 않고 JVM 구현체가 인식할 수 있는 패턴을 주는 방식으로, 그리고 필요에 따라 JVM 구현체와 동적 언어 구현체가 통신할 수 있는 JVM API를 추가하는 방식으로 할 수 있었을 것이다. 실제로 GraalVM 프로젝트의 Truffle을 기반으로 하여 invokedynamic을 사용하지 않고 동적 언어를 돌리는 실험이 이루어졌고, invokedynamic을 통해 구현된 Ruby에 비해 훨씬 나은 성능을 보였다고 한다.

세월을 거쳐 invokedynamic은 작지 않은 실수였던 것으로 판명되었다. 여러 JVM variant에 제대로 보급되지도 않았고, VM에 새로운 반영 타입에 대한 부담을 지웠고, 가볍지만 그만큼 자주 쓰이게 되기에 VM와 애플리케이션 코드 양쪽에 또 부담이 되었고, 정적 타입 언어뿐만 아니라 동적 타입 언어에도 성능 상 큰 도움이 되지 못했다.

Android desugar는 충분히 신뢰할 만한 상태의 구현체인가? invokedynamic이 물씬 사용된 코드가 안정적으로 변환될 수 있는가? 이 외의 클래스들은 이내 backport되어 Java 8 코드가 컴파일되어도 JVM 7 수준의 호환성만을 확보한 JVM 구현체들에서도 빠른 속도로 돌아갈까? 나는 적어도 Java 커뮤니티가 이 정도의 미래를 설계할 사람들로는 이루어져 있으리라 생각하지만, javac가 여전히 invokedynamic을 열심히 쓰는 걸 보면, 잘 모르겠다. 어쩌면 Java 8은 Python 3보다도 더욱 강하게 과거와 선을 긋는 결정이었으리라.