IT_Programming/Java

Java theory and practice: : 클로저(closure) 논의

JJun ™ 2013. 7. 27. 05:56

 


 출처 : 한국 IBM


 

 

 

 

클로저를 자바에 추가하는 문제

 

 

 

누구나 자바™ 언어에 대한 한두 가지 정도의 아이디어를 갖고 있습니다.

자바 플랫폼의 오픈 소스화와 서버 측 애플리케이션용 언어들(JavaScript 와 Ruby)의 대중화로,

자바 언어의 미래에 대한 논의가 더욱 열기를 띄고 있습니다.

 

자바 언어가 클로저(closure) 같은 새로운 개념을 포용해야 할까요?

오히려 너무 많은 개념을 추가하면 역효과가 나는 것은 아닐까요?

이번 달, Java theory and practice 에서는 Brian Goetz가 클로저 개념을 설명하고,

두 개의 상반되는 클로저 제안에 대해 상세히 설명합니다.

 

 

 

Crossing borders 시리즈의 최신 글에서, 필자의 친구이자 동료인 Bruce Tate는 Ruby를 사용하여

클로저의 힘에 대한 글을 썼다. 최근 JavaPolis 컨퍼런스(Antwerp)에서, 가장 참여도가 높은 세션들 중

하나는 Neal Gafter의 "자바에 클로저 추가하기" 였다.

 

참석자들이 자바와 관련된(또는 관련 없는) 모든 것에 대한 생각을 작성할 수 있는 JavaPolis의 공개 게시판에, 거의 반 이상의 공간이 클로저 논의로 가득 찼다. 클로저는 자바 언어가 개발되기 20년 전부터 생겨난

개념이지만, 요즘 자바 커뮤니티에서는 클로저 논의로 뜨겁다.

이 글에서는 자바 언어의 클로저를 적용하는 것에 대한 논의를 다루고자 한다.

클로저의 개념부터 설명하고, 어떻게 적용할 것인지, 그리고 현재 논의 선상에 있는 제안들을 요약할 것이다.

 

 

 

클로저: 기초

 

클로저(closure)는 자유로운(구속되지 않은) 변수들을 포함하고 있는 코드 블록이다.

이러한 변수들은 코드 블록이나 글로벌 콘텍스트에서 정의되지 않고, 코드 블록이 정의된 환경에서 정의된다. "클로저"라는 명칭은 실행할 코드 블록(자유 변수의 관점에서, 변수 레퍼런스와 관련하여 폐쇄적이지 않은)과 자유 변수들에 대한 바인딩을 제공하는 평가 환경(범위)의 결합에서 탄생한 것이다.

 

클로저 지원의 다양함은 Scheme, Common Lisp, Smalltalk, Groovy, JavaScript, Ruby, Python에서 찾아볼 수 있다.

 

 

클로저의 가치는 함수 객체( function objects ) 또는 익명 함수( anonymous functions )로서 작용하고,

유형 시스템(type system)이 데이터뿐만 아니라 코드도 나타낼 수 있어야 한다는 점에서 유형 시스템에

대한 결과도 갖고 있다.

 

클로저가 있는 대부분의 언어들은 함수들을 퍼스트-클래스 객체들로서 지원하는데, 함수들은 변수에 저장될 수 있고, 매개변수로서 다른 함수들에 저장되며, 동적으로 생성되고, 함수들에서 리턴된다.

Scheme (SICP 3.3.3)의 예제를 생각해 보자. (Listing 1)


Listing 1. 인자로서 함수를 취하고 그 함수의 Memoize 버전을 리턴하는 함수의 Scheme 프로그래밍 언어 예제

(define (memoize f) (let ((table (make-table))) (lambda (x) (let ((previously-computed-result (lookup x table))) (if (not (null? previously-computed-result)) previously-computed-result (let ((result (f x))) (insert! x result table) result))))))


이 코드는 인자로서 f 함수를 취하고, f 와 같은 함수를 계산하지만 이전에 계산된 결과를 테이블에 저장하여 보다 효율적으로 리턴될 수 있는 또 다른 함수를 리턴하는 memoize 라고 하는 함수를 정의한다. 리턴된 함수는 lambda 구조를 사용하여 구현되는데, 이것은 새로운 함수 객체를 동적으로 생성한다. 이탤릭 체로 된 식별자는 새롭게 정의된 함수에서 자유로운(free) 함수이다. 이들의 값은 이 함수를 생성하는 환경에 속해 있다. 예를 들어, 캐싱 된 값을 저장하는데 사용되는 테이블 변수는 memoize가 호출될 때 생성되고, 이것이 새롭게 생성된 함수에 의해 참조되기 때문에 결과 함수가 수집될 때까지 가비지 컬렉션이 되지 않는다. 결과 함수가 인자 x 로 호출될 때, 이것이 이미 f(x) 를 계산했는지를 확인한다. 만약 그렇다면, 알려진 f(x) 를 리턴한다. 그렇지 않으면, f(x) 를 계산하고, 이것을 리턴하기 전에 향후 사용을 위해 테이블에 저장한다.

 

클로저는 간소하고 중립적인 방식을 제공하여 매개변수화 된 결과를 생성 및 조작한다. 클로저 지원을 퍼스트 클래스 인자로서 "코드 블록"을 처리하는 기능을 제공하는 것으로 생각할 수 있다. 이들을 전달하고, 호출하여, 동적으로 새로운 것을 생성한다. 클로저를 완전히 지원하려면, 언어는 런타임 시 함수를 조작, 호출, 생성하는 지원을 제공하고, 함수가 생성되었던 환경을 포착하도록 해야 한다. 많은 언어들은 이러한 기능의 하위 세트를 제공하는데, 전부는 아니지만 일부 클로저의 혜택을 누리고 있다. 클로저를 자바 언어에 추가해야 하는지에 대한 논의해서, 핵심적인 질문은 표현성의 증가가 복잡함이라는 비용을 희생할 만한 가치가 있는가 이다.

 

 

 

익명 클래스와 함수 포인터

 

C 언어는 함수 포인터를 제공하는데, 이것으로 함수를 인자로서 다른 함수들로 전달할 수 있다.
하지만, C의 함수들은 자유 변수들을 가질 수 없다. 모든 변수들은 컴파일 시 알려져야 하는데,
이는 추상 메커니즘으로서의 함수 포인터의 표현성을 떨어트린다.

자바 언어는 내부(inner) 클래스를 제공하는데, 여기에는 인클로징 객체의 필드에 대한 레퍼런스를 포함할 수 있다. 이러한 기능은 함수 포인터보다 더욱 풍부하다. 내부 클래스 인스턴스가 이것이 생성되었던 환경에 대한 레퍼런스를 유지할 수 있기 때문이다. 실제로, 내부 클래스는 전부는 아니지만 클로저 값의 대부분을 제공하는 것처럼 보인다. 사람들은 UnaryFunction이라고 하는 인터페이스를 쉽게 구현할 수 있고 단일(unary) 함수를 memorize 할 수 있는 memoizing 래퍼를 생성할 수 있다. 하지만, 이러한 방식은 실제로는 널리 쓰이지 못한다. 함수와 상호 작동하는 모든 코드들이 "프레임웍"을 인식하고 작성되어야 하기 때문이다.





 

 

패턴 템플릿으로서 클로저

 

익명 클래스는 이들이 정의되었던 환경의 일부를 캡처하는 객체를 생성할 수 있지만, 객체들과 코드 블록들은 같은 것이 아니다. 예를 들어, Lock 이 걸린 상태로 코드 블록을 실행하는 것 같은 반복적인 코딩 패턴에 대해 생각해 보자. Lock 이 걸린 카운터를 늘리고자 한다면, 코드는 Listing 2와 같은 모습이 된다. 이와 같은 간단한 연산에도 너무 장황해진다.


Listing 2. Lock이 걸린 상태로 코드 블록을 실행하는 이디엄

lock.lock(); try { ++counter; } finally { lock.unlock(); }


잠금 관리 코드를 제거한다면 더 나을 것이다. 이는 코드를 보다 간결하게 하고 에러의 가능성을 줄여준다. 첫 번째 시도로서, withLock() 을 생성할 수 있다. (Listing 3)


Listing 3. "잠금이 걸린 상태에서 실행하기" 개념을 배제시키고 코드 실행하기

public static void withLock(Lock lock, Runnable r) { lock.lock(); try { r.run(); } finally { lock.unlock(); } }


불행히도, 이러한 방식으로는 여러분이 원하는 것의 일부분만을 달성한다. 추상화(abstraction) 목적 중 하나는 코드를 보다 간결하게 하는 것이다. 안타깝게도, 익명의 내부 클래스의 신택스는 그다지 간결하지 않고, 호출하는 코드는 Listing 4와 같은 모습이다.


Listing 4. Listing 3의 withLock() 메소드에 대한 클라이언트 코드

withLock(lock, new Runnable() { public void run() { ++counter; } });


여전히 잠금이 보유된 채로 카운터를 증가시키기만 하는 코드가 많이 있다. 더욱이, 잠금에 의해 보호되는 코드 블록을 메소드 호출로 변환함으로써 생기는 장벽은 상황을 더욱 복잡하게 한다. 보호되고 있는 코드 블록이 예외를 던진다면 어떻게 하겠는가? 이제 우리는 태스크 표현으로서 Runnable 을 사용할 수 없다. 예외가 메소드 호출을 통해 던져질 수 있도록 하는 새로운 표현을 만들어야 한다. 불행히도, 제너릭은 여기에서 큰 도움이 되지 못한다. 예외를 구분하는 제너릭 유형의 매개변수 E 를 갖고 있는 메소드를 생성할 수 있지만, 이러한 방식은 한 개 이상의 검사된 예외 유형을 던지는 메소드로 일반화 되지 않는다. ( Callable 의 call() 메소드는 유형 매개변수에 의해 지정된 유형이 아닌 Exception 을 던지도록 선언된다.) Listing 3 의 방식은 예외 투명성 부족이라는 문제가 있다. 또한, 다른 형태의 비투명성 문제도 겪는다. return 또는 break 같은 문장은 Listing 4 에서는 Listing 2 의 try 블록과는 다른 Runnable 의 정황에서의 무엇인가를 의미한다.

 

이상적으로는, 보호되는 increment 연산이 Listing 5처럼 보이게 할 수 있고, 블록에 있는 코드가 Listing 2 에서 확장된 형태인 것처럼 할 수 있다.


Listing 5. Listing 3의 클라이언트 코드에 대한 이상적인(하지만 가정의) 형태

withLock(lock, { ++counter; });


클로저를 언어에 추가함으로써, "execute this code with a lock held," "operate on this stream and close it when you're done" 또는 "time how long it takes to execute this block of code" 같이, 컨트롤 흐름 구조 같이 작동할 수 있는 메소드를 만들 수 있다. 이러한 전략은 Listing 2 의 잠금 이디엄 같이, 특정 코딩 패턴이나 이디엄을 반복적으로 사용하는 특정 코드 유형들을 단순화 하는데 효과적이다. (비슷한 표현성을 제공했던 또 다른 기술들은 C preprocessor였다. 이것은 withLock() 연산을 preprocessor 매크로로서 표현할 수 있지만, 매크로는 구성하기 더 어렵고 클로저보다는 안전성이 덜하다.)

 

 

 

제너릭 알고리즘을 위한 클로저

 

클로저가 코드를 단순화 하는 기회를 제공하는 또 다른 부분은 제너릭 알고리즘(generic algorithm)이다. 멀티프로세서 머신 가격이 저렴해지면서, 세분화 된 병렬성을 활용하는 것이 더욱 중요해졌다. 제너릭 알고리즘을 사용하여 연산을 설정하면 문제 공간에서 병렬성을 활용하는 라이브러리 구현에 중립적인 기회를 제공한다.

 

큰 수의 제곱을 계산하는 경우를 생각해 보자. Listing 6은 이러한 계산을 수행하는 한 가지 방법을 보여주지만, 이러한 방식은 순차적으로 결과를 계산하는데, 이는 대형 멀티프로세서 시스템에서 원하는 결과를 계산하는 효율적인 방법은 될 수 없다.


Listing 6. 제곱의 총합을 순차적으로 계산하기

double sum; for (Double d : myBigCollection) sum += d*d;


각 루프 반복은 두 개의 연산을 갖고 있다. 값을 제곱하는 것과 그 값을 총합에 추가하는 것이다. 각 제곱 연산은 서로 독립적이고 병렬로 실행될 수 있고, N 추가 연산을 실행하는 대신, 계산이 올바르게 구성된다면, 총합은 log(N) 연산으로 계산될 수 있다.

 

Listing 6의 연산은 map-reduce 알고리즘의 예제이다. 여기에서 함수는 각각의 데이터 엘리먼트에 적용되고, 적용 결과는 결합 함수와 결합된다. 데이터 세트를 취하는 map-reduce 구현, 각 엘리먼트에 적용되는 단일 함수, 결과를 결합하는 바이너리 함수를 갖고 있다면, 제곱의 합 계산을 Listing 7과 같이 나타낼 수 있다.


Listing 7. MapReduce로 제곱의 합을 계산하면서, 병렬 실행에 적용하기

Double sumOfSquares = mapReduce(myBigCollection, new UnaryFunction<Double> { public Double apply(Double x) { return x * x; } }, new BinaryFunction<Double, Double> { public Double apply(Double x, Double y) { return x + y; } });


Listing 7의 mapReduce() 구현은 어떤 연산이 병렬로 계산될 수 있는지를 알고 있고, 함수 적용과 결합 단계들을 병렬화 하여, 병렬 시스템에서 향상된 처리량을 보이고 있다. Listing 7의 코드는 복잡하다. Listing 6의 세 줄에 해당하는 제너릭 알고리즘을 표현하기 위해 훨씬 더 많은 코드 라인을 취하고 있다.

클로저는 Listing 7의 코드를 더욱 관리 가능한 것으로 만드는 방식을 제공한다. 예를 들어, Listing 8의 클로저 신택스는 자바 언어를 위한 현재의 클로저 제안에 상응하지 않지만, 클로저가 제너릭 알고리즘을 어떻게 지원하는지에 대한 개념은 충분히 전달한다.


Listing 8. MapReduce와 가상 클로저 신택스를 사용하여 제곱의 합 계산하기

sumOfSquares = mapReduce(myBigCollection, function(x) {x * x}, function(x, y) {x + y});


Listing 8의 클로저 기반의 버전은 가장 좋다. 코드는 읽고 쓰기가 쉽고, 순차적 루프 보다는 고급의 추상화 레벨에서 지정되며, 라이브러리에 의해 효율적으로 병렬화 될 수 있다.





 

 

클로저 제안

 

적어도, 자바에 클로저를 추가하는 것과 관련하여 최소한 두 개의 제안이 논의 선상에 있다. "BGGA" (Gilad Bracha, Neal Gafter, James Gosling, Peter von der Ahe)는 유형 시스템을 확장하여 함수 유형들을 결합한다. "CICE" (Concise Inner Class expressions)는 Joshua Bloch, Doug Lea, "Crazy Bob" Lee에 의해 지원을 받고 있고, 익명의 내부 클래스 인스턴스의 생성을 단순화 하는 것이 목표이다. 향후 자바 버전에 제안될 클로저 지원의 형태와 정도를 고려하기 위해 JSR도 제안될 전망이다.

 

 

 

BGGA 제안

 

BGGA 제안은 함수 유형의 개념을 만들었는데, 여기에서 함수는 유형화 된 인자 리스트, 리턴 유형, throws 구문을 갖고 있다. BGGA 제안에서, 제곱의 합(sum-of-squares) 코드는 Listing 9에 나타난 코드와 같다.


Listing 9. BGGA 클로저 신택스를 사용하여 제곱의 합 계산하기

sumOfSquares = mapReduce(myBigCollection, { Double x => x * x }, { Double x, Double y => x + y });


괄호 안에 있는 코드는 => 부호의 왼쪽은 인자의 이름과 유형을 가리킨다. 오른쪽에 있는 코드는 정의되고 있는 익명의 함수 구현을 나타낸다. 이 코드는 이 블록 내에서 정의된 로컬 변수, 클로저에 대한 인자, 클로저가 생성된 범위에서 나온 변수를 나타낼 수 있다.

 

BGGA 제안에서, 변수, 메소드 인자, 함수 유형이 되는 메소드 리턴 값을 선언할 수 있다. 하나의 추상 메소드 클래스( Runnable 또는 Callable )의 인스턴스가 기대되는 정황에 클로저를 제공할 수 있다. 익명으로 유형화 된 클로저의 경우, invoke() 메소드가 제공되어, 여러분은 지정된 인자 리스트를 사용하여 이들을 호출할 수 있다.

 

BGGA 제안의 기본적인 목표들 중 하나는 프로그래머가 컨트롤 구조 같이 작동하는 메소드를 생성할 수 있도록 하는 것이다. 따라서, BGGA는 새로운 키워드인 것처럼 클로저를 받아들이는 메소드를 호출할 수 있도록 하는 문법을 제안하여, 여러분이 withLock() 또는 forEach() 같은 메소드를 생성하고, 이들이 컨트롤 프리머티브인 것처럼 호출할 수 있도록 한다. Listing 10은 withLock() 메소드가 BGGA 제안에서 정의되는 방식을 보여주고 있다. Listing 11 과 Listing 12 는 표준 폼과 "control construct" 폼을 사용하여 호출되는 방법을 보여준다.


Listing 10. 클로저 제안으로 withLock() 메소드 코딩하기

public static <T,throws E extends Exception> T withLock(Lock lock, {=>T throws E} block) throws E { lock.lock(); try { return block.invoke(); } finally { lock.unlock(); } }


Listing 10의 withLock() 메소드는 잠금과 클로저를 수락한다. 리턴 유형과 이 클로저의 throws 구문은 제너릭 인자들이다. 컴파일러의 유형 추론을 통해 T 와 E 의 값을 지정하지 않고 호출될 수 있도록 한다. (Listing 11, 12)


Listing 11. withLock() 호출하기

withLock(lock, {=> System.out.println("hello"); });



Listing 12. control construct를 사용하여 withLock() 호출하기

withLock(lock) { System.out.println("hello"); }


제너릭과 마찬가지로, BGGA에서 제안하는 클로저의 복잡성 대부분이 라이브러리 생성자에서 생긴다; 클로저를 받아들이는 라이브러리 메소드를 사용하는 것이 훨씬 더 단순하다.

BGGA 제안은 클로저의 혜택을 보기 위해 내부 클래스 인스턴스를 사용할 때 나타나는 많은 투명성 문제들을 해결한다. 예를 들어, return , break , this 의 문법은 같은 코드 블록을 나타내는 Runnable (또는 기타 내부 클래스 인스턴스)과 코드 블록에서 다르다. 이러한 비투명성의 엘리먼트들은 제너릭 알고리즘을 활용하기 위해 코드를 마이그레이션 할 때 혼란을 일으킨다.

 

 

 

CICE 제안

 

CICE 제안은 내부 클래스 인스턴스를 인스턴스화 하는 것이 너무 부담이 되는 문제를 해결하는 제안이다. 함수 유형의 개념을 만드는 대신, 단일 추상화 메소드( Runnable , Callable , Comparator )를 사용하여 내부 클래스의 인스턴스를 인스턴스화 하는 보다 간결한 신택스를 만든다.

Listing 13은 제곱의 합 코드가 CICE 제안에서는 어떤 모습인지를 나타낸다. mapReduce() 에 의해 사용되는 UnaryFunction 과 BinaryFunction 유형을 만든다. mapReduce() 에 대한 인자는 UnaryFunction 과 BinaryFunction 에서 파생된 익명의 클래스이다. 이 신택스는 익명의 인스턴스를 만드는 것과 관련된 많은 중복성을 제거한다.


Listing 13. CICE 클로저 제안에서의 제곱의 합 코드

Double sumOfSquares = mapReduce(myBigCollection, UnaryFunction<Double>(Double x) { return x*x; }, BinaryFunction<Double, Double>(Double x, Double y) { return x+y; });


mapReduce() 로 전달된 함수를 나타내는 객체는 보통의 익명 클래스 인스턴스이기 때문에 이들의 바디는 인클로징 범위에 정의된 변수를 나타낼 수 있다. Listing 13과 Listing 7 의 방식의 유일한 차이는 신택스의 장황함이다.





결론

 

BGGA 제안은 언어에 새로운 강력한 장치를 추가했다. 언어의 신택스와 의미에 복잡함도 가져왔다. 반면, CICE 제안은 조금 낫다. 이 언어에 이미 존재하는 장치를 사용하여 쉽게 사용할 수 있도록 하지만, 중요한 새로운 기능은 제공하지 않는다. 클로저는 추상화를 위한 강력한 장치이다. 이것에 익숙해지면 절대로 포기하고 싶지 않을 것이다. (Scheme, Smalltalk, Ruby 프로그래머인 친구들에게 클로저에 대해 물어보면, 그들은 아마도 숨쉬는 것에 대해 어떻게 생각하냐고 반문할 것이다.) 하지만, 언어는 유기적이기 때문에, 원래 디자인에서 예견하지 못했던 새로운 기능을 언어에 추가하는 것은 많은 타협점을 찾아야 하고 복잡함을 가져온다. 논의되고 있는 문제는 클로저가 좋은 개념인지 여부가 아니라, 자바 언어에 클로저를 적용하는 것이 과연 이득인지가 논의의 주제이다.

 

 

필자소개

 

Brian Goetz는 20년 경력의 전문 소프트웨어 개발자이다. Sun Microsystems의 시니어 엔지니어이며, 여러 JCP 전문가 그룹에서 활동하고 있다. Java Concurrency In Practice (Addison-Wesley) 2006년 5월에 출간되었다. Brian 기술자료 보기.