클로저(Closure): Java와 Kotlin 비교

allocProc
8 min readJul 16, 2023

--

Photo by Beth Jnr on Unsplash

클로저(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)에 담아서 클로저가 참조합니다. 그래서 외부 변수에 자유롭게 접근할 수 있고 수정도 가능한거죠.

--

--