본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 람다(lambda)가 이끌어 갈 모던 JAVA(2)

아래 포스트는 학습용도로 네이버 개발자 센터 기술 포스팅에서 가져온 내용입니다. 원본자료는 참고링크(https://d2.naver.com/helloworld/4911107)를 따라가셔서 확인 바랍니다. 

 

그 외에 람다 대수(lambda calculus)에 대해 참고할 만한 사이트입니다.

http://nirvana-wiki.appspot.com/Lambda_calculus

https://ko.wikipedia.org/wiki/%EB%9E%8C%EB%8B%A4_%EB%8C%80%EC%88%98

 

Java8은 느린 발전으로 고리타분한 언어라는 느낌까지 주던 Java 언어에 생기를 불어넣어 모던 Java 시대를 열었다고 평할만 합니다. 변화의 핵심은 여러 라이브러리와 다른 언어에서도 살펴본 함수 표현 객체와 람다 표현식의 도입입니다. 이 절에서는 람다 표현식을 도입하는 과정에서의 논란과 Java 언어 개발자의 선택을 살펴보겠습니다.

 

 

클로저와 람다 표현식을 둘러싼 논란


유명한 Perl 개발자인 Mark Json Dominus는 2005년에 "지금 재귀 호출이 없는 언어를 발명하려는 사람이 비웃음을 받듯이 앞으로 30년 뒤에는 클로저(closure)가 없는 언어를 발명하려는 사람은 비웃음을 받게 될 것이다"라고 언급한 적이 있습니다.

 

클로저는 어휘적(lexical) 클로저 또는 함수(function) 클로저를 간단하게 부르는 말입니다. 단순하게 말하면 자신을 감싼 영역에 있는 외부 변수에 접근하는 함수입니다. 클로저에서 접근하는 함수 밖의 변수를 자유 변수(free variable)라 합니다. 이 정의에 따르면 람다 표현식으로 정의한 익명 함수 가운데 일부는 클로저고 일부는 클로저가 아닙니다.

 

변수의 범위와 연관해 클로저를 정의하기도 하지만 단순히 함수를 감싼 객체를 모두 클로저라고 표현하기도 합니다. Groovy에서는 자유 변수를 참조하는지 여부와 상관없이 익명 함수를 클로저라합니다. 그 외에도 함수를 객체로 감싸는 패턴은 Function Object, Functor, Functionid 등 다양하게 불리웁니다.

 

이 글에서는 자유 변수를 참조하는 클로저로 한정해서 논의를 이어가겠습니다. 클래식 Java에서도 익명 클래스로 클로저의 개념을 구현할 수 있습니다. 앞에서 나온 Guava 등의 예제에서 필터링 조건을 기술하는 익명 클래스에서 외부의 company 변수를 참조하는 코드가 그 예입니다. 그러나 자유변수를 final로 선언해야 하는 제약이 있어 Java의 익명 클래스를 클로저로 인정할지는 논란이 있습니다.

 

"Effective Java"의 저자이며 Java 언어에 많은 영향을 미치는 인물인 Joshua Bloch의 이야기에서 Java의 클로저와 람다가 어떤 형태로 구현될지 미리 들여다 볼 수 있었습니다. Joshua Bloch는 2006년에 Java에는 투박한 형태지만 이미 클로저가 있다고 하면서 다음과 같이 말했습니다.

 

우리가 Java에 클로저를 더한다면 그것은 이미 지원되고 있는 문법의 테두리 안에서 조심스럽게 이뤄져야 할 것입니다. 즉, 클로저가 내부에 하나의 메서드만 가지고 있는 인터페이스를 구현하는 형태를 가져야 한다는 뜻입니다. Runnable 같은 인터페이스나 TimerTask 같은 클래스처럼 말입니다. 이미 존재하는 익명 클래스 문법에 약간의 수정을 가할 필요도 있습니다. final 관련된 요구 사항도 조금 현실적으로 변할 필요가 있습니다.

 

그로부터 4년 뒤인 2010년에는 다음과 같은 의견을 밝혔습니다.

 

이미 익명 클래스로 할 수 있는 일을 더 쉽게 하고, 불필요하게 장황해지지 않게 하는 것이 가장 중요하다고 생각합니다. 람다 표현식에서 변하는(mutable) 변수에 접근해 값을 덮어 쓸 수 있는 것은 좋기도 하고 나쁘기도 한것이 아니라 더 나쁜 것이라고 봅니다.

 

Joshua Bloch는 하나의 메서드만 정의된 인터페이스를 구현한 익명 클래스를 더 편하게 생성할 수 있게 문법을 개선할 필요는 있다고 했습니다. 클로저 안에서 final 키워드로 선언된 변수에만 접근할 수 있다는 제약은 덜어야겠지만 변하는 변수에 접근하는 것은 제한해야 한다고 주장했습니다. Java에 객체나 인터페이스와 같은 수준의 새로운 구성 요소로 함수 타입을 추가하길 기대했던 사람들은 여전히 함수 표현을 인터페이스에 의존하자는 Joshua Bloch의 의견에 실망했습니다. 언어의 타입 시스템에 람다 표현을 넣기를 거부한 것은 함수형 패러다임 자체를 거부한 것과 다르지 않다는 목소리도 있었습니다.

 

 

람다 표현식과 Stream 인터페이스의 도입


Java 언어에 람다 표현식을 도입하려는 '프로젝트 람다'가 2009년에 시작되고 5년 만인 2014년에 Java 8이 정식으로 공개됐습니다. 숙고를 거쳐 모던 Java의 시대가 열렸습니다. "클래식 Java"에서 구현한 필터링, 정렬, 변환을 Java8로 구현하면 다음과 같습니다.

public List<String> findGuestNamesByCompany(String company) {
	List<Guest> guests = repository.findAll();
	return guests.stream()
		.filter(g -> company.equals(g.getCompany()))
		.sorted(Comparator.comparing(g -> g.getGrade()))
		.map(g -> g.getName())
		.collect(collectors.toList());
}

이를 구성 요소별로 분석해 보겠습니다.

 

 

-> 기호와 :: 기호

 

위의 예제에서 filter(), sorted(), map() 메서드의 파라미터로 핵심 행위를 다른 선언없이 바로 파라미터로 전달합니다.

 

  필터링: g -> company.equals(g.getCompany())

  정렬: Comparator.comparing(g -> g.getGrade())

  변환: g -> g.getName()

 

-> 기호로 함수를 정의한 부분이 Java8의 람다 표현식입니다. Groovy나 Kotlin과 동일합니다. 아직은 it 키워드처럼 파라미터가 한 개일 때 참조할 수 있는 예약어는 없습니다.

 

변환의 g -> g.getName()처럼 파라미터를 실행할 메서드만 전달하면 되는 경우에는 ::기호로 메서드 레퍼런스를 직접 전달할 수 있습니다. 정렬과 변환에 사용한 방법을 :: 기호로 다시 쓰면 다음과 같습니다.

.filter(g -> company.equals(g.getCompany()))
.sorted(Comparator.comparing(Guest::getGrade))
.map(Guest::getName)

정렬 로직에서는 Comparator.comparing() 메서드의 파라미터로 Guest.getGrade() 메서드를 전달하고 함수 역할을 하는 java.util.Comparator 타입으로 결과를 반환받았습니다. 고차 함수의 개념이 쓰인 것입니다.

 

 

Stream 인터페이스

 

Java8에서는 함수를 적용할 수 있는 새로운 인터페이스로 java.util.stream.Stream을 도입했습니다. 앞에서 본 Functional Java에서 같은 역할을 하는 클래스도 Stream이었고, Scala나 Kotlin에도 Stream 타입이 있습니다. Java8에서도 java.util.List.stream() 메서드를 호출해 List 타입의 객체로부터 Stream 타입의 객체를 얻었습니다.

 

java.util.Collection이나 Iterable 등 기존의 인터페이스에 새로운 메서드를 추가하는 대신 새로운 역할을 하는 인터페이스를 분리했습니다. Collection.stream(), Iterable.forEach() 메서드 등 몇 가지를 기본 메서드로 추가했습니다. 기본 메서드는 인터페이스 정의에 기본적인 구현을 포함한 것입니다. 이를 통해 인터페이스에 새로운 메서드를 추가해도 이전 버전의 구현체가 규약을 어기지 않게 지원할 수 있습니다.

 

이러한 설계는 이전 버전의 인터페이스에 의존하는 코드를 보호해 JDK를 안정적으로 업그레이드할 수 있게 고려한 것입니다. 앞에서 설명한 GS Collections에는 java.util.Collection이나 List를 직접 구현한 클래스가 있습니다. java.util.Collection에 기본 메서드가 아닌 새로운 메서드가 추가됐다면 Java8로는 이 라이브러리의 소스를 컴파일할 수 없습니다. 이를 사용하는 애플리케이션에서도 이전 구현체 클래스의 클래스의 인스턴스를 새로운 버전의 인터페이스로 참조한다면 추가된 메서드를 호출하는 순간 오류가 발생합니다.

 

예를 들어 JDBC(Java Database Connectivity) API를 구현한 클래스에서 인터페이스의 변화와 관련된 문제가 있습니다. Statement.closeOnCompletion() 메서드는 Java7에 추가된 메서드입니다. JDK6의 Statement 인터페이스에만 맞춰서 구현한 코드가 있다면 Java7로 업그레이드한 이후에는 javac -source 1.6 -target 1.6과 같이 버전을 지정해도 코드를 컴파일할 수 없습니다. 그래서 Apache Commons DBCP와 같이 JDBC 인터페이스에 의존하는 라이브러리는 JDK 버전에 맞는 라이브러리 버전을 주의해서 선택해야 합니다.

 

Stream 인터페이스는 TotallyLazy 라이브러리처럼 지연 연산도 지원합니다. Stream 인터페이스의 연산을 중간 단계를 반환하는 것과 최종 값을 반환하는 것으로 구분해서 중간 단계를 처리할 때는 지연된 연산을 적용합니다.

 

Java8에서는 int, double과 같은 원시 타입을 지원하는 IntStream, DoubleStream과 같은 인터페이스도 정의합니다. GS Collections나 GNU Trove에서 IntList와 DoubleList 같은 타입을 제공하는 것과 유사합니다. 원시 타입을 쓰면 객체 생성 비용이 없기 때문에 데이터를 훨씬 효율적으로 처리할 수 있습니다.

 

Stream 인터페이스는 병렬 처리에도 유리한 구조를 제공합니다. guests.stream() 메서드 부분만 guests.parallelStream() 메소드로 바꾸면 이 Stream 타입의 객체는 내부적으로 병렬로 처리됩니다. 병렬 처리를 했을때 효율적일지 여부는 작업의 성격에 따라 다르지만 추상화된 틀이 있어 코드를 조금만 수정해 기존의 작업을 병렬로 처리할 수 있다는 점은 큰 장점입니다.

 

참고로 parallelStream() 메서드로 하는 병렬 처리는 내부적으로 ForkJoinPool.commonPool() 메서드에서 반환하는 공통 스레드 풀을 사용합니다. 스레드 개수의 기본값은 'CPU 개수 -1'로 Runtime.getRuntime().availableProcessors() -1에서 얻은 결과를 사용합니다. 이 개수를 지정하고 싶다면 다음과 같이 시스템 속성값을 지정합니다.

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "20");

 

Stream 인터페이스는 앞에서 본 오픈소스 라이브러리나 다른 JVM 언어와 유사한 편의성을 제공하면서도 하위 버전과의 안정된 조화를 고려했습니다.

 

 

클로저와 final

 

final이 아닌 변수도 람다 표현식 안에서 참조할 수 있다는 점도 주목할 만합니다. filter() 메서드에 전달한 익명 함수는 함수 범위 밖의 자유 변수인 company를 참조하는 클로저입니다. 그러나 첫 줄의 메서드 선언부에 있는 findGuestNamesByCompany() 메서드의 파라미터인 company는 final이 아닙니다.

 

그렇다고해서 자유 변수인 company의 값을 클로저 안에서 마음대로 바꿀수 있는 것은 아닙니다. 아래 예제처럼 company를 재할당하려 하면 Local variable company defined in an enclosing scope must be final or effectively final이라는 오류가 나오면서 컴파일 오류가 발생합니다.

public List<String> findGuestNamesByCompany(String company) {
	List<Guest> guests = repository.findAll();
	return guests.stream()
	     .filter(g -> {
		if (company == null) {
			company = ""; // compile error
		}
		return company.equals(g.getCompany());
	     })
	// 생략
}

 

심지어 클로저 밖에서 company를 재할당하려 해도 클로저 안에서 처음으로 company에 접근하는 줄에서 컴파일 오류가 발생합니다. 즉 final 키워드를 붙이지 않았을 뿐 사실상 final과 같이 취급한 변수만 클로저 안에서 접근할 수 있는 것입니다. Joshua Bloch가 2010년 인터뷰에서 밝혔던 의견대로입니다.

 

 

함수 인터페이스

 

실제 위 예제들은 축약해서 썼기 때문에 파라미터나 메서드의 반환 타입이 무엇인지 파악하기 어렵습니다. 타입이 보이도록 다시 작성했습니다.

public List<Guest> findGuestNamesByCompany(String company) {
	List<Guest> all = repository.findAll();
	
	Stream<Guest> stream = all.stream();

	// filtering
	Predicate<Guest> filterFunc = g -> company.equals(g.getCompany());
	Stream<Guest> filtered = stream.filter(filterFunc);

	// sorting
	Comparator<Guest> sortFunc = Comparator.comparing(Guest::getGrade);
	Stream<Guest> sorted = filtered.sorted(sortFunc);

	// mapping
	Function<Guest, String> mapFunc = Guest::getName;
	Stream<Guest> mapped = sorted.map(mapFunc);
	Collector<String, ?, List<String>> collector = Collectors.toList();
	return mapped.collect(collector);
}

 

함수를 한 줄로 쓴 g -> g.getName() 같은 코드를 반환 타입으로 받아보면 Function, Predicate와 같은 인터페이스가 나옵니다. Guava와 GS Collections에 있던 인터페이스와 이름과 역할이 동일합니다. 이제 보편적인 함수 인터페이스가 정의돼 중복된 코드를 만들 필요가 없습니다. 이러한 인터페이스는 java.util.function 패키지에 선언돼 있습니다. 대표적인 인터페이스와 시그니처는 다음과 같습니다.

public interface Supplier<T> {
	T get();
}
public interface Predicate<T> {
	boolean test(T t);
}
public interface Function<T, R> {
	R apply(T t);
}
public interface BiFunction<T, U, R> {
	R apply(T t, U u);
}
public interface Consumer<T> {
	void accept(T t);
}
public interface BiConsumet<T, U> {
	void accept(T t, U u);
}

 

Java8에서는 논란 끝에 함수를 위한 새로운 타입 시스템을 도입하지 않고 인터페이스로 표현했습니다. 언어에서 가장 제약이 적은 요소를 일급 시민에 속한다고 하는데, Java에서 함수는 여전히 일급 시민이 아닙니다. java.util.function 패키지 아래에 정의된 인터페이스는 무려 43개나 됩니다. 다양한 메서드 시그니처에 대응한 함수를 일일이 별도 인터페이스로 정의하고 있습니다. 이전부터 있던 Runnable 인터페이스, Callable 인터페이스 등과 합치면 45개의 인터페이스가 함수를 기본적으로 표현합니다.

 

int를 받아서 int를 반환하는 함수가 있다면 아래처럼 IntUnaryOperator 인터페이스를 이용합니다.

public static void main(String[] args) {
	FunctionParameterExam printer = new FunctionParameterExam();
	int base = 7;
	printer.printWeighted(weigt -> base * weight, 10);
}
public void printWeighted(IntUnaryOperator calc, int weight) {
	System.out.print(calc.applyAsInt(weight));
}

 

만약 언어의 새로운 구성 요소 수준으로 함수 타입이 도입됐다면 아래처럼 쓸 수도 있을 것입니다. 2010년 제안된 람다 표현식의 초안은 이런 형태였습니다.

public void print(#int(int) calc, int weight) {
	System.out.print(clac.(weight));
}

 

람다 표현식을 지원할 API를 설계할 사람은 많은 인터페이스를 새로 알아야 하니 부담이 될 것 같기도 합니다. 그런데 Java 8에서 추가된 인터페이스가 아니더라도 조건만 맞다면 람다 표현식으로 정의할 수 있습니다. 아래와 같이 Consumer과 똑같은 메서드 시그니처를 가진 StringAction 인터페이스는 String 한 개를 파라미터로 받는 람다 표현식으로 정의할 수 있습니다.

@FunctionalInterface
public static interface StringAction {
	void execute(String str);
}
...
Consumer<String> f1 = System.out::println;
StringAction f2 = System.out::println;
StringAction f3 = s -> System.out.println("!!" + s);

 

@FunctionalInterface라는 어노테이션은 Javadoc에서 이 인터페이스가 함수형 인터페이스라는 점을 표시하고 인터페이스의 추상 메서드가 한 개를 초과했을때 컴파일 오류를 일으킵니다. 문서화와 실수 예방에 도움을 주지만 람다 표현식으로 쓸 인터페이스에 @FunctionalInterface를 붙이는 것이 필수는 아닙니다.

 

이처럼 메서드가 한 개인 인터페이스가 있고 파라미터와 반환 타입만 맞다면 인터페이스의 타입이나 메서드 이름과는 상관없이 람다 표현식으로 인스턴스를 할당할 수 있습니다. 이렇게 보면 람다 표현식은 추상 메서드가 한 개인 인터페이스의 인스턴스를 생성해서 할당하는 유연하고 암묵적인 캐스팅으로도 보이고 Java 5부터 도입된 오토 박싱(auto boxing)과 닮은 문법으로 이해할 수도 있습니다. 추상 메서드가 한 개인 인터페이스는 모두 람다 표현식의 수혜자가 되므로 Java를 업그레이드하기만 해도 예전 라이브러리를 새로운 방식으로 사용할 수 있습니다. 상위 호환성(forward-compatible)이 부여된 것입니다. 앞서 나온 Guava 등의 예제도 람다 표현식으로 다시 쓸 수 있습니다.

 

람다 표현식과 Stream 인터페이스는 기존의 자산을 안정적으로 활용하도록 설계되었습니다. 람다 표현식은 인터페이스의 구현체로 참조돼 이전에 만들어진 API에도 적용할 수 있습니다. 인터페이스를 아는 Java 개발자가 익혀야 할 새로운 개념도 적습니다. 기존 컬렉션 프레임워크의 체계를 뒤흔들기보다는 람다와 어울려 쓰기에 좋은 인터페이스로 Stream을 새롭게 도입하고 java.util.Collection 등의 기존 인터페이스에도 기본 메서드로 람다를 지원하는 메서드를 추가해 안정적인 개선을 추구했습니다.

 

 

람다 표현식의 내부 구현

 

Java에 새로운 함수 타입 체계를 도입하지 않은 이유는 내부 구현을 효율적으로 하기 위함도 있습니다. Java 언어의 아키텍트인 Brian Goetz에 따르면 JVM 차원의 표현 방식과 언어 차원의 표현에 거리가 생길수록 감당해야 하는 구현의 복잡함이 커지기때문에 이를 피하려고 했다고 합니다. 'int 한 개를 파라미터로 받아 int를 반환'과 같은 함수 타입의 표현은 기존의 메서드 시그니처와 같은 방식으로는 할 수 없고, JVM에서도 현재 바이트코드 수준에서는 함수 시그니처를 표현할 수 있는 명세가 없습니다. 새로운 타입을 위한 체계를 추가하거나 이를 다른 방식으로 우회해서 표현하는 것이 JVM 내부의 복잡도를 높이고 코너 케이스를 만드는 등 여러 면에서 고충이 많을 것으로 예상했습니다.

 

여기까지 본다면 Java의 람다는 앞에서 본 다른 JVM 언어가 그랬듯 익명 클래스 선언 문법을 단순히 대체한 것처럼 보입니다. 그러나 람다는 다른 JVM 언어처럼 컴파일 시점에 익명 클래스를 생성하지 않습니다. 컴파일된 소스 폴더나 역컴파일을 해도 익명 클래스의 흔적은 없습니다. 람다 표현식은 익명 클래스 문법과는 다른 바이트코드를 생성합니다.

 

람다 표현식이 기존의 익명 클래스 문법과 다르기 대문에 언어를 쓰는 사용자에게 우선 드러나는 차이는 this 키워드의 의미입니다. 아래 예제는 Runnable 타입으로 참조하는 고전적인 방식의 익명 클래스와 람다를 함께 생성합니다. 익명 클래스와 람다 안에서 this가 Runnable을 구현했는지 여부를 true나 false로 출력했습니다.

public class ThisDifference {
	public static void main(String[] args) {
		new ThisDirrerence().print();
	}
	public void print() {
		Runnable anonClass = new Runnable() {
			@Override
			public void run() {
				verifyRunnable(this);
			}
		};

		anonClass.run();

		Runnable lambda = () -> verifyRunnable(this);
		lambda.run();
	}

	private void verifyRunnable(Object obj) {
		System.out.println(obj instanceof Runnable);
	}
}

 

위의 예제를 실행하면 true와 false를 차례로 출력합니다. 즉 익명 클래스 내부에서 전달한 this는 Runnable을 구현한 익명 클래스 그 자체인데 반해 람다 표현식을 썼을 때는 익명클래스가 아닌 것입니다. 람다 표현식 안에서 선언한 this의 타입은 이를 생성한 클래스인 ThisDifference입니다. 익명 클래스 안에서 이를 생성한 객체를 전달하려면 ThisDifference.this처럼 직접 타입을 지정하면 됩니다. 뒤에 나올 바이트코드 생성 과정을 본다면 왜 이런 현상이 발생했는지 알 수 있습니다. 정적 타입 언어인 Java에서는 this가 어떤 타입인지 혼동할 위험이 적지만 같은 코드라도 this가 들어가면 익명 클래스 안과 람다 안은 다른 의미라는 점은 점은 인식해야 합니다.

 

참고로 IntelliJ에서는 익명 클래스로 선언된 코드 중 람다 표현식으로 교체할 수 있는 부분은 자동으로 리팩터링을 제안합니다. 그러니 예제의 anonClass 변수처럼 this를 포함한 익명 클래스는 제안 대상에서 제외됩니다. ThisDifference.this와 같이 람다 표현식에서도 같은 의미일 때는 다음 그림과 같이 리팩터링을 추천합니다.

 

 

이렇듯 람다 표현식은 이전의 익명 클래스와 다릅니다. 다른 JVM 언어처럼 람다 표현식을 익명 클래스 문법으로 치환하는 것이 적절하고 쉬운 방법으로 보입니다. 하지만 그런 익명 클래스로는 매번 새로운 인스턴스를 생성하고 단순한 바이트코드 명세로 표현되지 않는 등의 단점이 있습니다. JVM 개발자는 기존의 익명 클래스보다 성능면에서 더 유리하고 JVM 바이트코드로도 깔끔하게 연결되는 방법을 찾으려고 했습니다.

 

결과적으로 Java8에서 람다 표현식으로 객체를 생성하는 코드는 invokedynamic이라는 바이트코드로 변환됩니다. Java의 역어셈블러(disassembler)인 javap로 이를 확인했습니다. 아래 예제는 단순하게 람다 표현식을 Runnable 인터페이스의 인스턴스를 생성하였습니다.

public class SimpleLambda {
	public static void main(String[] args) {
		Runnable lambda = () -> System.out.println(1);
		lambda.run();
	}
}

 

컴파일된 클래스 파일이 있는 디렉터리로 가서 javap -c -p SimpleLambda.class 명령어로 private 메서드까지 포함해서 역어셈블하면 main() 메서드의 첫번째 줄에서 invokedynaic이 쓰인 것을 확인할 수 있습니다.

Compiled from "SimpleLambda.java"
public class com.naver.helloword.resort.SimpleLambda {
	public com.naver.helloworld.resort.SimpleLambda();
		code:
			0: aload_0
			1: invokespecial	#8  	// Method java/lang/Object."<init>":()V
	public static void main(java.lang.String[]);
		code:
			0: invokedynamic   #19, 0 	// InvokeDynamic #0:rund:()Ljava/lang/Runnable;
			5: astore_1
			6: aload_1
			7: invokeinterface	#20, 1	// InterfaceMethod java/lang/Runnable.run:()V
			12: return

	public static void lambda$0();
		Code:
			0: getstatic	#29	// Field java/lang/System.out:Ljava/io/PrintStream;
			3: iconst_1
			4: invokevirtual	#35	// Method java/io/PrintStream.println: (I)V
			7: return
}

 

즉, 람다 표현식으로 객체를 생성하는 코드는 invokedynamic으로 변환됐습니다. invokedynamic은 람다 표현식으로 반환될 인터페이스를 구현한 클래스를 동적으로 정의하고 인스턴스를 생성해서 반환합니다. 생성된 객체를 실행하는 lambda.run() 메서드는 invokeinterface로 치환되었습니다. 인터페이스인 Runnable로 참조된 객체이므로 두 번째 과정은 자연스러워 보입니다. 

 

원래 invokedynamic은 Java 언어가 아닌 JRuvy, Jython, Groovy와 같은 동적 타입 언어를 위한 명세였습니다. 동적 타입 언어는 컴파일 시점에 타입이 확정되지 않은 메서드를 런타임에 호출할 수 있는데 이를 효율적으로 지원하기 위해 Java7부터 invokedynamic 명세가 포함되었습니다. 람다 표현식에서 이를 활용하면서 더 이상 이 명세는 동적 타입 언어만을 위한 것이 아니게 되었습니다. 참고로 Java에서 트랜잭션 처리와 같은 반복적인 코드를 없애는 데 많이 사용하는 AOP(aspect oriented programming)를 구현하는 기술에서도 invokedynamic을 활용하려는 시도가 보입니다.

 

invokedynamic 호출은 Bootstrap 메서드, 정적 파라미터 목록, 동적 파라미터 목록 등 세 가지 정보를 필요로 합니다. Bootstrap 메서드는 호출 대상을 찾아서 연결하고 invokedynamic 을 쓰는 메서드가 처음 호출될 때만 실행됩니다. 정적 파라미터는 상수풀(constant pool)에 저장된 정보입니다. 동적 파라미터는 메서드의 런타임에서 참조할 수 있는 변수인데 람다 표현식으로 치면 클로저로 쓰였을 때의 자유 변수가 이에 해당합니다.

 

아래 예제를 컴파일한 파일에 javap -v SimpleLambda.class 명령을 실행하면 Bootstrap 메서드를 확인할 수 있습니다. 다음과 같이 BootstrapMethods 항목에서 LambdaMetafactory.metafactory를 호출합니다.

BootstrapMethods:
0:  	#50 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;:java/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
	 Method arguments:
	 #51 ()V
	 #54 invokestatic com/naver/helloworld/resort/SimpleLambda.lambda$0:()V
	 #55 ()V

 

결국 람다 표현식으로 선언된 객체를 어떻게 생성할지는 LambdaMetafacotry.metafactory() 메서드로 실행 시점에 결정합니다. 이렇게 람다 표현식의 해석을 컴파일 시점이 아닌 실행 시점으로 미뤘기 때문에 앞으로 나올 JDK에서도 더 유연하게 최적화를 구현할 수 있습니다.

 

그 다음으로 눈에 띄는 점은 private static 메서드가 추가된 것입니다. 람다 표ㅕ현식 안에서 구현한 코드가 그대로 lambda$0() 메서드로 옮겨가 있습니다. 람다 표현식 안에서 일부러 예외를 발생해 콜 스택에서 SimpleLambda.lambda$0 메서드와 같은 흔적을 발견할 수 있습니다. 람다 표현식 안에서 참조한 this가 Runnable의 구현체가 아니였던 이유는 이 과정 때문입니다.

 

이를 바이트코드에 가깝게 다시 쓰면 다음과 같습니다. invokedynamic의 호출 대상이 되는 인스턴스를 생성하는 정보는 Bootstrap메서드(LambdaMetafactory)와 정적 파라미터 목록인 staticargs(Runnable과 lambda$0)로 전달했습니다. 람다 표현식 안에서 자유 변수를 참조한다면 동적 파라미터 목록인 dynargs에도 정보가 들어갔을 테지만 이 예제는 그 경우에 해당하지 않습니다.

public class SimpleLambda {
	public static void main(String[] args) {
		Runnable lambda = invokedynamic(
			bootstrap=LambdaMetafactory,
			staticargs=[Runnable, lambda$0],
			dynargs=[]);
		lambda.run();
	}
	private static void lambda$0() {
		System.out.println(1);
	}
}

 

조금더 내부를 들여다보면 LambdaMetafacotry는 java.lang.invoke.CallSite 객체를 반환합니다. CallSite는 java.lang.invoke.MethodHandle형의 객체를 멤버변수로 참조합니다. MethodHandle는 람다의 내부 내용을 옮긴 private 메서드로 연결됩니다.

 

참고로 Java 프로세스를 실행할 때 -Djdk,.internal.lambda.dumpProxyClasses 옵션을 붙이면 람다 표현식으로 생성하는 동적 클래스를 파일로 저장합니다. java -Djdk.internal.lambda.dumpProxyClasses SimpleLambda와 같이 실행하면 실행한 디렉터리에 SimpleLambda$$Lambda$1.class 파일이 생성되는 것을 확인할 수 있습니다.

 

invokedynamic을 이용한 람다 표현식은 성능과 자원 사용면에서 효율적입니다. 해당 코드 블럭이 처음 호출되기 전까지는 초기화를 하지 않습니다. 따라서 익명 함수가 생성됐어도 실제로 호출되지 않았다면 힙 메모리를 사용하지 않습니다. 외부 변수를 참조하지 않는, 상태가 없는 익명함수는 인스턴스를 하나만 생성해 다시 반환합니다. 결과적으로 상태 없는 익명 함수를 생성하는 실험 케이스에서는 익명 클래스를 쓸 때에 비해 1/67의 인스턴스 생성 비용(capturing cost)이 들었다고 합니다. 그리고 앞으로 최적화 여지도 많이 있다고 하니 더욱 성능이 개선될 것으로 기대합니다.

 

문법으로 람다 표현식을 지원하는 데는 다른 JVM 언어보다 몇 년이나 뒤쳐졌던 Java 언어가 Java 8 발표 시점에서는 성능과 자원의 효율성에서는 가장 앞서게 됐다는 점도 흥미롭습니다. Scala에서도 람다 표현식을 invokecynamic으로 변환하는 기능이 2.11.6 버전에 추가될 예정입니다. 다른 언어에서도 비슷한 시도를 할 것으로 예상됩니다.

 

람다 표현식을 쓴 코드를 Java 6과 Java 7에서도 실행할 수 있도록 컴파일하는 Retrolambda라는 프로젝트도 있습니다. Maven이나 Gradle의 플러그인으로 설정하면 익명 클래스를 생성하는 방식으로 람다 표현식을 컴파일합니다. Guava 같은 라이브러리르 쓰고 Retrolambda로 컴파일하면 클래식 Java 환경에서도 모던 Java를 맛볼 수 있습니다. Java 8의 문법을 지원하지 않는 Android 환경에서도 활용할만 합니다. Functional Java 라이브러리에서 이를 활용했습니다.

 

정리하자면 Java의 람다 표현식은 밖에서 보기에는 인터페이스에 기대어 최소한의 변환를 추구했습니다. 반면 내부적으로는 기존의 익명 클래스보다 더 성능이 좋도록 개선했습니다. 그 과정에서 기존의 JVM 명세였던 invokedynamic을 활용했습니다. 최대한 안정된 기반을 활용하면서도 내부적인 효율을 개선한 면이 Java 답습니다.

 

 

애플리케이션 코드의 개선


지금까지는 컬렉션과 관련된 코드 위주로 람다를 적용할 수 있는 예를 살펴봤습니다. 그 외에도 폭넓은 곳에 람다 표현식을 적용해 코드를 개선할 수 있습니다.

 

 

비동기 서블릿

 

서블릿3.0에서는 비동기 처리 기능을 제공합니다. 이 기능은 오래 걸리는 작업이 완료될 때까지 기다리는 동안 다른 요청을 처리할 수 있도록 요청 처리 스레드를 서블릿 컨테이너에 반납해 컨테이너의 스레드를 효율적으로 사용할 수 있습니다. 아래 예제처럼 AsyncContext.start() 메서드는 Runnable 구현체를 파라미터로 받아서 별도의 작업 스레드에서 실행합니다. 이를 그 아래 예제처럼 람다 표현식으로 바꿀 수 있습니다.

public void doGet(final HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
	final AsyncContext asyncContext = request.startAsync();
	asyncContext.start(new Runnable(){
		public void run() {
			// 오래 걸리는 작업 실행
			asyncContext.dispatch("/threadNames.jsp");
		}
	});
}
public void doGet(final HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
	AsyncContext asyncContext = request.startAsync();
	asyncContext.start(() -> {
		// 오래 걸리는 작업 실행
		asyncContext.dispatch("/threadNames.jsp");
	}); 
}

 

Spring JDBC

 

Spring JDBC는 JDBC API를 더 편리하게 사용하게 합니다. 이 라이브러리에는 PreparedStatementCreator, RowCallbackHandler, RowMapper 처럼 메서드가 한 개인 인터페이스가 많습니다. Spring의 창시자 Rod Johnson은 이를 단순한 인터페이스를 쓴 전략 패턴의 일종이라고 말했습니다. 2002년 이전에 만든 이런 코드가 람다 표현식을 의식하지는 않았겠지만 좋은 설계를 추구한 코드는 일맥상통하는 듯합니다.

 

Spring JDBC의 RowMapper 인터페이스는 JDBC의 ResultSet 객체를 도메인 객체로 변환하는 역할을 합니다. 아래 예제의 익명 클래스 정의를 그 아래처럼 람다 표현식으로 바꿀 수 있습니다.

 

public List<Guest> findAll() {
	return jdbc.query(SELECT_ALL, new RowMapper<Guest>() {
		@Override
		public Guest mapRow(ResultSet rs, int rowNum) throws SQLException {
			return new Guest(
				rs.getInt("id"),
				rs.getString("name");
				rs.getString("company");
				rs.getInt("grade")
				);
		}
	});
}
public List<Guest> findAll() {
	return jdbc.query(SELECT_ALL, (rs, rowNum) -> new Guest (
			rs.getInt("id"),
			rs.getString("name"),
			rs.getString("company"),
			rs.getInt("grade")
		)
	);
}

 

Android의 이벤트 처리

 

Android는 버튼 같은 UI 요소에서 발생하는 클릭 이벤트 등을 처리할 메서드를 연갈하는 코드를 많이 작성합니다. 그러나 이 런 작업은 함수 타입이 따로 없는 Java에서는 번거로운 작업입니다. JavaScript와 비교하면 UI 개발 영역에서 Java는 다소 어울리지 않는다는 느낌이 듭니다. JavaScript의 jQuery로는 유사한 일을 아래와 같이 처리할 수 있습니다.

function calculate() { ... }
function send() { ... }
...
$("#calcButton").on("click", calculate);
$("#sendButton").on("click", send);

 

클래식 Java의 문법을 쓰는 Android에서는 리스너 성격의 클래스를 익명 클래스로 생성해 버튼 객체에 지정합니다.

Button calcButton = (Button) view.findViewById(R.id.button1);
Button sendButton = (Button) view.findViewById(R.id.button2);

calcButton.setOnClickListener(new OnClickListener() {
	public void onClick(View v) {
		calculate();
	}
});
sendButton.setOnClickListener(new OnClickListener() {
	public void onClick(View v) {
		send();
	}
});

 

이런 측면 때문에 Android 애플리케이션의 코드는 대부분 구조가 상당히 복잡합니다. 그래서 Groovy 등 람다 표현식을 지원하는 언어로 Android 애플리케이션을 작성하려는 시도도 있습니다. Scaloid나 Xtendroid처럼 이를 보조하는 라이브러리도 있습니다.

 

Android에서는 아직 Java 8을 쓸 수 없지만 Retrolambda 프로젝트를 이용하면 Android에서도 람다 표현식을 사용할 수 있습니다. 위 예제도 아래처럼 간결해질 수 있습니다.

Button calcButton = (Button) view.findViewById(R.id.calcBtn);
Button sendButton = (Button) view.findViewById(R.id.sendBtn);

calcButton.setOnClickListener(v -> calculate());
sendButton.setOnClickListener(v -> send());

 

람다 표현식을 활용한 프레임워크


다음으로는 람다 표현식이 있어 태어날 수 있었던 프레임워크를 몇 개 발펴보겠습니다. 이 프레임워크가 뛰어나서 권장할만 하다는 의미는 아닙니다. 새로운 스타일이 싹트고 있다는 데 초점을 맞춰서 봤으면 합니다.

 

Lambda Behave

 

Lambda Behave는 JUnit처럼 테스트 코드를 실행하는 프레임워크입니다. BDD(behavior driven development)에서 권장하는 should 메서드 등으로 테스트를 작성하도록 지원합니다. Groovy를 이용한 Spock 프레임워크와 목적이 유사합니다. 아래 예제에서 Lambda Behave로 앞에서 작성한 ResortService를 테스트했습니다.

@RunWith(JunitSuiteRunner.class)
public class ResortServiceSpec {{
	GuestRepository repository = new MemoryRepository();
	ResortService service = new ModernJavaResort(repository);
	
	describe("ResortService with modern Java", it -> {
		it.isSetupWith(() -> {
			repository.save(
					new Guest(1, "jsh", "Naver", 15),
					new Guest(2, "hny", "Line", 10),
					new Guest(3, "chy", "Naver", 5)
				);
		});
		it.isConcludedWith(repository::deleteAll);

		it.should("find names of guests by company", expect -> {
			List<String> names = service.findGuestNamesByCompany("Naver");
			expect.that(names).isEqualTo(Arrays.asList("chy","jsh"));
		});
	});
}}

 

JUnit과 통합돼 실행되기 때문에 기존의 JUnit을 활용하는 IDE나 빌드 도구와 자연스럽게 연결됩니다. describe() 메서드와 should() 메서드에 전달된 문자열은 마치 JUnit의 클래스와 메서드 이름처럼 인식돼서 테스트가 성공하거나 실패했을 때 출력되는 메시지에 포함됩니다. 클래스 이름과 메서드 이름을 써야 해서 공백 문자를 쓸 수 없었던 한계를 극복할 수 있습니다.

 

아래 그림은 Eclipse에서 위 예제의 테스트를 실행한 모습입니다.

 

 

 

Jinq

 

Jinq는 SQL을 자동으로 생성해서 실행하는 프레임워크입니다. 이름처럼 닷넷의 통합 언어 쿼리인 LINQ(language-integrated query)의 영향을 받았습니다. java.util.stream.Stream 인터페이스를 상속한 JinqStream이라는 인터페이스를 제공합니다. JinqStream 인터페이스에 컬렉션을 조작하듯 필터링 메서드와 정렬 메서드를 호출하면 실행하려는 작업에 맞는 쿼리를 만들어 데이터베이스로 호출합니다. 앞에서 나온 Guest 클래스 예제를 Jinq로 구현하면 다음과 같습니다.

@Autowired
public JinqResort(EntityManager em) {
	this.em = em;
}
private EntityManager em;
public List<String> findGuestNamesByCompany(String company) {
	return stream(Guest.class)
		.where(g -> g.getCompany().equals(company))
		.sortedBy(Guest::getGrade)
		.select(Guest::getName)
		.toList();
}

private <T> JinqStream<T> stream(Class<T> clazz) {
	return new JinqJPAStreamProvider(em.getEntityManagerFactory()).streamAll(em, clazz);
}

 

위 예제에서는 Java의 표준 ORM(object relational mapping)의 명세인 JPA(Java Persistence API)를 활용했습니다. 이를 위해 Guest 객체에는 @Entity와 @Id와 같이 JPA에서 정의한 어노테이션을 추가해야 했습니다. 위의 JinqResort 클래스에서는 JPA에서 제공하는 EntityManager와 Spring 프레임워크의 의존성 주입 기능 등을 함께 이용했습니다. JinqStream 인스턴스에 where(), sortedBy(), select() 등과 같이 SQL 구문과 유사한 메서드를 호출해 쿼리를 만들었습니다.

 

위 예제를 실행하면 생성된 SQL을 로그 메시지로 확인할 수 있습니다. main() 메서드를 실행하면 표준 출력으로 로그 메시지가 출력됩니다.

 

Hibernate: select guest0_.id as id1_0_, guest0_.company as company2_0_, guest0_.grade as grade3_0_, guest0_.name as name4_0_ from guest guest0_ where guest0_.company=? order by guest0_.grade ASC limit ?  

 

단순히 테이블의 데이터를 읽어 Java에서 반복문으로 필터링과 정렬을 실행하는 것이 아니라 where와 order by를 활용한 SQL이 생성된 것이 보입니다.

 

 

Spark

 

Spark는 Ruby의 Sinatra 프레임워크에서 영감을 얻은 마이크로 웹 프레임워크입니다. 익명 클래스 문법만 있었다면 코드가 복잡해져서 프레임워크를 만들 시도조차 못했을 것입니다. 아래 예제는 Spark에서 GET 메서드로 받은 HTTP 요청을 처리하는 코드입니다. 경량 웹 애플리케이션 서버인 Jetty를 내장하고 있어 별도로 서버를 설치하지 않고 예제의 클래스만 직접 실행해도 사용자 요청을 처리할 수 있습니다.

import static spark.Spark.*;

public class SparkServer {
	public static void main(String[] args) {
		get("/guest/:company", (request, response) -> {
			String company = request.params(":company");
			return "No guests from " + company;
		});
	}
}

main() 메서드를 실행하고 http://localhost:4567/guests/Naver를 호출하면 No guests from Naver라는 메시지가 출력됩니다.

 

아래 예제에서는 Spark와 Spring Boot 프레임워크를 함께 이용했습니다. Spring 프레임워크의 의존성 주입으로 ResortServer 객체와 ResortService 구현체를 연결했습니다. JinqResort 클래스를 주입받도록 Spring 프레임워크로 설정하면 데이터베이스에서 불러온 데이터를 화면에서 확인할 수 있습니다. ModernJavaResort 클래스와  ModernJdbcRepository 클래스를 조합해서 구성해도 같은 결과가 나옵니다.

@SpringBootApplication
public class ResortServer {
	@Autowired
	private ResortService service;
	
	public void start() {
		get("/guests/:company", (request, response) -> {
			String company = request.params(":company");
			List<String> names = service.findGuestNamesByCompany(company);
			return "Guests from " + company + " : " + names;
		});
	}

	public static void main(String[] args) {
		ApplicationContext context = SpringApplication.run(ResortServer.class);
		context.getBean(ResortServer.class).start();
	}
}

 

마치며


Java 8의 람다 표현식과 이를 활용한 Stream 인터페이스는 여러 오픈소스 라이브러리와 JVM 언어에서 그 모습이 보였습니다. 그리고 Java 커뮤니티에서 오랜 기간 논의를 거쳐 하위 버전의 코드를 최대한 보호하고 재활용할 수 있는 방향으로 구현됐습니다. 이처럼 오픈소스 라이브러리와 다른 JVM 언어에서 혁신을 시도하고, 그 성과가 다시 JDK와 표준 명세에 반영돼 다음 세대의 발전에 든든한 토양이 되는 흐름은 JVM 생태계가 흘러가는 원리입니다. Java 9와 Java 10의 모습이 이미 오픈소스 라이브러리나 다른 JVM 언어에 구현돼 있을지도 모릅니다.

 

Java 8은 이미 성숙한 언어인 Java 세계에서 나름대로 최선의 안으로 람다를 구현했습니다. 새로운 함수 타입을 도입하지 않은 것이 덜 혁신적으로 비춰질 수도 있지만 기존 Java 세계의 많은 코드와 JVM의 내부 구조를 감안한다면 불가피한 선택이었을 것입니다. invokedynamic을 활용하는 등 내부 구현은 다른 JVM 언어보다 더 성능이 좋은 방식을 선택해 명백한 이득이 있습니다.

 

또는 다양한 프로그래밍 모델이 필요한 시대의 요구를 이제서야 반영한 것이기도 합니다. UI에서 이벤트, 비동기, 병렬, 지연 처리는 물론 여러 장소에서 읽은 데이터를 애플리케이션 수준에서 컬렉션으로 처리하는 일이 점점 늘어나는 시점에서 익명 클래스 문법만으로는 버거웠습니다. 예를 들면, 직접 for문을 문법으로 쓰는 것에 비해 Collection.forEach() 메서드와 같은 내부 반복자는 병렬과 지연 처리를 제공하는 추상화된 API를 제공하기에 유리합니다. 기존의 익명 클래스로는 이런 코드를 쓰기가 번거롭습니다. API 설계자 입장에서도 비동기 처리와 병렬 처리를 위한 콜백 인터페이스를 부담 없이 도입할 수 있게 됐습니다.

 

Java 8의 변화는 Java 언어에서 제2차 혁명이라 할 만합니다. 1차 혁명은 Java5의 제네릭, 어노테이션, Enum 이였습니다. 이 혁명을 토대로 많은 API와 라이브러리가 혁신을 이어갔습니다. Spark나 Jinq의 사례처럼 2차 혁명인 람다 표현식으로 어떤 시도가 이어질지 기대됩니다.

 

람다 표현식은 기회이자 숙제입니다. 명령형 스타일의 코드를 서술적으로 바꾸고 축약된 사고를 이끌어 기발하고 편리한 API를 내놓을 수 있는 바탕이 됩니다. 하지만 개발자는 이전보다 더 많이 고민해야 할 것입니다. 결국 람다 표현식은 2010년 대 중 반 '모던 Java 개발자'의 상징이 될 만합니다.