클로저(Closure)란?
Java 에는 공식적으로 클로저(Closure)라는 개념은 없습니다. 자바 공식문서 어디를 뒤져봐도 클로저에 대한 언급은 없죠. 람다나 익명 클래스를 이용해 다른 언어에서 클로저라 부르는 동작을 구현할 수는 있지만 그 기능이 다른 언어의 클로저와 사뭇 다릅니다. 자바의 클로저에 대해 살펴보기 전에 우선 클로저의 범용적인 의미에 대해 짚고 넘어갑시다.
위키피디아의 Closure 문서에는 다음과 같이 정의되어있습니다.
A closure is a record storing a function together with an environment.
(클로저란 함수를 그 환경과 함께 저장한 레코드를 말한다.)
MDN 문서에도 Closure 에 관한 항목이 있습니다. 정확히는 자바스크립트의 클로저에 관한 내용이지만 클로저 자체를 잘 설명하고 있다고 생각해서 가져왔습니다. 이 문서에는 클로저가 아래와 같이 정의되어있습니다.
A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
(클로저는 함수와 그 주변 상태에 대한 참조의 조합이다.)
자바의 클로저
자바에서 클로저는 람다나 익명 클래스로 구현할 수 있습니다. 아래 코드 처럼요.
public class SomeClass {
public static void main(String[] args) {
int a = 10;
new Thread(() -> {
System.out.println(a);
}).start();
}
}
Thread
생성자의 인자로 Runnable
인터페이스의 구현체를 람다 형태로 전달하고 있습니다. 람다 내부에서 이 보다 외부 스코프인 main 메소드의 지역 변수를 참조합니다. 이게 가능한 이유는 람다가 생성될 때 외부 변수를 복사해서 이 값을 해당 람다 인스턴스와 힙에 같이 저장하기 때문입니다. 이 과정을 variable capture 라고 부르는데, 이렇게 외부 변수를 캡쳐하는 기능을 가진 람다를 자바에서의 클로저라고 부를 수 있겠습니다.
자바에서 클로저의 한계와 그렇게 설계된 이유
하지만 자바에서 익명 클래스나 람다가 외부 변수를 참조하는 경우 그 변수는 반드시 final
혹은 ‘effectively final’ 이어야 합니다. 자바 8 이전까지는 반드시 명시적으로 final 키워드가 붙은 변수만 참조할 수 있었지만 자바 8 이후에는 선언 이후 상태가 바뀌지 않는 ‘effectively final’(사실상 final) 이기만 하면 참조할 수 있도록 그 기준이 완화되었습니다. 그래서 아래 두 가지 코드는 컴파일되지않습니다.
public class SomeClass {
public static void main(String[] args) {
int a = 10;
new Thread(() -> {
a = a + 1;
}).start();
}
}
// OR
public class SomeClass {
public static void main(String[] args) {
int a = 10;
new Thread(() -> {
System.out.println(a);
}).start();
a = 11;
}
}
자바에서 클로저가 이런 한계를 가지도록 설계된 이유가 무엇일까요?
이를 이해하기 위해 먼저 자바의 메모리 관리와 클래스의 동작 방식을 생각해봅시다. 메서드가 실행되면 그 메서드의 지역 변수는 스택에 할당됩니다. 메서드 호출이 끝나면 이 변수들은 스택에서 사라지고, 더 이상 접근할 수 없게 됩니다. 그러나 이 메서드 안에서 정의된 로컬 클래스나 익명 클래스의 인스턴스는 힙 메모리에 할당되고 메서드가 끝난 후에도 힙에 계속 존재할 수 있습니다. 객체에 대한 참조가 유효한 동안에는 Garbage Collector 에 의해 회수되지 않기 때문입니다.
익명 클래스나 람다의 인스턴스가 외부 메서드의 지역 변수에 접근하려고 한다면 어떻게 해야 할까요? 만약 메서드가 종료된 후라면 스택은 제거되었기 때문에 그 변수는 더 이상 존재하지 않는데 말이죠. 이 문제를 해결하기 위해 자바에서는 지역 클래스나 익명 클래스가 외부 지역 변수를 참조할 때, 그 값을 자신의 인스턴스에 복사합니다. 즉, 이 인스턴스는 원래 변수의 ‘스냅샷’을 가지게 되는겁니다. 이를 통해 메소드가 종료된 후에도 해당 변수의 값을 계속 사용할 수 있게되는거죠. 이 과정이 위에서 설명했던 ‘variable capture’ 입니다.
하지만 이후에 이 지역 변수가 변경된다면 어떻게 될까요? 지역 클래스나 익명 클래스의 인스턴스는 오래된 값을 가지고 있을 것이고, 이는 큰 혼란을 초래할 수 있습니다. 결과적으로, 이런 상황을 연출하고 싶지 않던 자바는 외부 지역 변수를 final
혹은 ‘effectively final’로 만들 것을 강제했습니다. 이렇게 하면 이 변수의 값은 변경되지 않으므로, 지역 클래스나 익명 클래스의 인스턴스가 안전하게 참조할 수 있기 때문이죠.
코틀린의 클로저
자바의 아들격이자 같은 JVM 가족인 코틀린의 클로저는 어떨까요? 당연히 자바처럼 외부 범위의 변수를 캡처할 수 있습니다. 다만 자바에서의 final
또는 ‘effectively final’에 대한 규칙이 훨씬 유연해졌습니다.
코틀린에서는 람다나 익명 클래스가 외부 범위의 mutable 한 변수를 캡처하고 수정할 수 있습니다. 예를 들어, 위에서 컴파일조차 되지 않았던 자바코드를 그대로 코틀린으로 옮겨보겠습니다.
fun main() {
var a = 10
Thread {
a += 1
}.start()
}
// OR
fun main() {
var a = 10
Thread {
println(a)
}.start()
a = 11
}
코틀린에서는 위 코드가 문제없이 컴파일되고 실행됩니다. 외부 변수를 캡처하고 변경할 수 있는 클로저라는 개념에 대한 구현이 두 언어가 다소 다르죠.
코틀린의 클로저가 자바와 달라진 이유?
왜 코틀린은 자바와 다르게 캡쳐된 변수의 값을 수정할 수 있게 했을까요?
코틀린은 자바와 다르게 함수형 프로그래밍에 조금 더 친화적인 설계를 가지고 있습니다. 코틀린은 언어의 태생부터 함수형 프로그래밍을 목적으로 했거든요. 그 설계 원칙 중 하나는 함수형의 근간이 되는 람다와 고차 함수의 사용을 향상시키는 것일겁니다. 그래서 자바와는 다르게 클로저의 한계를 확장하면서 개발자들이 더 표현력이 높고 간결한 코드를 작성할 수 있게하고자 한거죠.
Conclusion
하지만 코틀린이 그렇게 했다고 해서 자바의 방식이 틀렸다는건 절대 아닙니다. 코틀린처럼 클로저가 외부 변수를 자유롭게 수정할 수 있게 하면 코드의 표현력이 올라가는건 맞지만 위험성도 같이 가져갑니다. 자바가 괜히 기능을 제한한건 아니니까요.
자바의 메소드 내부의 지역변수는 스레드 안정성을 보장받습니다. 메소드는 곧 하나의 스택프레임이고 이는 하나의 스레드에만 고유하게 생기는거니까요. 하지만 여기서 지역변수를 클로저가 수정할 수 있도록 만들면 스레드안정성은 더 이상 보장받을 수 없습니다.
그리고 클로저가 외부 범위의 변수를 수정하면 해당 클로저는 side effect 를 가지게 됩니다. 이는 코드의 복잡성을 상당히 증가시키죠. 특정 변수를 참조하고 수정하는 클로저가 한두개면 그래도 괜찮겠지만 늘어날수록 이를 추적해야하는 개발자가 느끼는 복잡도는 기하급수적으로 늘어날겁니다. 어떻게 보면 함수형 프로그래밍의 side effect 를 최소화하려는 원칙과 상충하는 것 처럼 보이기도하네요.
etc
근데 가만 생각해보면 코틀린도 결국은 자바와 같은 JVM 에서 실행됩니다. 코틀린의 클로저도 자바처럼 variable capture 형태로 외부 변수를 가져온다면 수정이 불가능해야할 것 같은데말이죠.
그래서 코틀린은 클로저의 외부 변수 참조를 조금 다른 방식으로 구현했습니다. 위에서 예시로 들었던 코틀린 코드 중 첫번째 코드를 바이트코드로 컴파일한 후 자바로 다시 디컴파일해보겠습니다.
public static final void main() {
final Ref.IntRef a = new Ref.IntRef();
a.element = 10;
(new Thread((Runnable)(new Runnable() {
public final void run() {
++a.element;
}
}))).start();
}
모양이 조금 특이한데요. 스코프 외부 변수를 미리 사전에 정의된 스태틱 클래스(kotlin.jvm.internal.Ref
)에 담아서 클로저가 참조합니다. 그래서 외부 변수에 자유롭게 접근할 수 있고 수정도 가능한거죠.