Java Lambda (7) 람다와 클로저

⏱ 6 분

람다와 클로저

자바 커뮤니티에서는 클로저와 람다를 혼용하면서 개념 상 혼란이 있었습니다. 그래서 자바 8부터 클로저를 지원한다는 글을 보기도 합니다. 이번 포스트에서는 자바에서의 람다와 클로저를 명확히 구분해보고자 합니다.

먼저 일반적인 클로저부터 살펴보겠습니다.

클로저 Closure

보통의 함수는 외부에서 인자를 받아서 로직을 처리합니다. 그런데 클로저는 자신을 둘러싼 context 내의 변수에 접근할 수 있습니다. 즉, 외부 범위의 변수를 함수 내부로 바인딩하는 기술입니다.

특이한 점은 자신을 둘러싼 외부 함수가 종료되더라도 이 값이 유지가 됩니다. 함수에서 사용하는 값들은 클로저가 생성되는 시점에서 정의되고 함수 자체가 복사되어 따로 존재하기 때문입니다. 코드를 살펴보겠습니다.

1
2
3
4
function add(x)
function addX(y)
return y + x
return addX

함수 addX 내부에서 x 는 현재 함수를 둘러싸고 있는 외부 컨텍스트에 존재합니다. add 에서 이 함수가 리턴되는데 x 의 값(value)이나 참조(reference)를 복사한 클로저가 리턴됩니다. 함수를 일급 객체 (first-class citizens) 로 취급하는 언어에서는 이를 변수로 받아서 사용이 가능합니다. 넣어주는 x 값에 따라 함수가 생성되는 것을 볼 수 있습니다.

1
2
3
4
5
variable add1 = add(1) // return y + 1
variable add5 = add(5) // return y + 5

assert add1(3) = 4
assert add5(3) = 8

자바 속 클로저

그렇다면 자바에서의 클로저는 어떨까요? 클래스 내부의 필드에 접근하는 메소드를 생각해봅시다. 이미 자바에서는 클로저가 있습니다. 메소드 내에서 외부 컨텍스트의 변수를 참조하기 때문이죠. 하지만 메소드는 함수가 아니고, 일급 객체도 아니다.

자바에서의 클로저 예제를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClosureTest {
private Integer b = 2;

private Stream<Integer> calculate(Stream<Integer> stream, Integer a) {
return stream.map(t -> t * a + b);
}

public static void main(String... args) {

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ClosureTest()
.calculate(list.stream(), 3)
.collect(Collectors.toList());
System.out.println(result); // [5, 8, 11, 14, 17]
}
}

calculate 메소드에서 map 메소드를 호출하고 있습니다. 여기서 인자로 넘어가는 람다가 있는데 내부에서 외부 변수인 ab를 참조하고 있습니다. 이 때 ab 는 컴파일러가 final 로 간주합니다. 내부에서 필요로 하는 정보를 넘길 때 값이 변경되면 의도하지 않은 결과가 나올 수 있기 때문에, 해당 값은 변하지 않아야 합니다. 따라서 컴파일러는 해당 값을 상수로 취급하는 거죠. 이전 포스트에서 유사 파이널(effectively final)을 살펴봤었죠. 따라서 값을 변경하려고 하면 다음과 같은 컴파일 에러를 볼 수 있습니다.

1
2
3
4
5
private Stream<Integer> calculate(Stream<Integer> stream, Integer a) {
a = 10; // 값 변경 불가
// Variable used in lambda expression should be final or effectively final
return stream.map(t -> t * a + b);
}

이를 우회하는 방법은 이전 포스트에서 살펴본 것처럼 객체를 이용하면 됩니다. 객체를 사용하기 위해서 다음과 같이 예제를 변경해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ClosureTest {
private Integer b = 2;

private Integer getB() {
return b;
}

private Stream<Integer> calculate(Stream<Integer> stream, Int a) {
a.setValue(10); // 값 변경 가능
return stream.map(t -> t * a.value + getB());
}
}

class Int {
public int value;
public Int(int value) {
this.value = value;
}
public void setValue(int value) {
this.value = value;
}
}

그렇게 되면 참조값이 final 이기 때문에 객체 안의 값은 변경할 수 있게 됩니다.

1
2
3
4
5
6
7
8
public static void main(String... args) {

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ClosureTest()
.calculate(list.stream(), new Int(3))
.collect(Collectors.toList());
System.out.println(result); // [12, 22, 32, 42, 52]
}

하지만 이는 side effect 를 발생시킬 수 있어 위험한 방법입니다.

차이점

람다와 클로저는 모두 익명의 특정 기능 블록입니다. 차이점은 클로저는 외부 변수를 참조하고, 람다는 매개변수만 참조한다는 겁니다.

1
2
3
4
5
// Lambda.
(server) -> server.isRunning();

// Closure. 외부의 server 라는 변수를 참조
() -> server.isRunning();

클로저는 외부에 의존성이 있고, 람다는 외부에 의존성이 없는 스태틱 메소드와 비슷합니다.

다음 예제는 폴링 후 조건에 따라 대기하는 메소드입니다. Predicate 를 이용해 검증하고 결과에 따라 짧은 시간 동안 멈춥니다.

1
2
3
4
5
static <T> void waitFor(T input, Predicate<T> predicate) throws InterruptedException {
while (!predicate.test(input)) {
Thread.sleep(250);
}
}

어떤 HTTP 서버가 구동 중인지 확인하는 간단한 람다를 넘겨보겠습니다. 이때 매개변수를 참조하고 있기 때문에 그 외의 외부 변수에 대한 참조가 없죠. 런타임에 제공하는 서버 변수를 컴파일러가 참고할 필요가 없는 람다입니다. 외부 변수에 영향이 없기 때문에 더 효율적으로 동작합니다.

1
2
waitFor(new HttpServer(), (server) -> !server.isRunning());
waitFor(new HttpServer(), HttpServer::isRunning); // 메소드 참조

이번엔 동일한 로직을 클로저를 이용해 구현해보겠습니다. 인자 없이 boolean 값을 리턴하는 함수형 인터페이스를 하나 만듭니다.

1
2
3
4
5
6
7
8
9
10
static <T> void waitFor(Condition condition) throws InterruptedException {
while (!condition.isSatisfied()) {
Thread.sleep(250);
}
}

@FunctionalInterface
interface Condition {
boolean isSatisfied();
}

받아오는 정보가 없기 때문에 여기서는 판단에 필요한 정보를 외부에서 참조해야한다는 것을 알 수 있습니다. 이 변수는 컴파일러에 의해 복사됩니다. 이것은 클로저입니다.

1
2
3
4
void closure() throws InterruptedException {
HttpServer server = new HttpServer();
waitFor(() -> !server.isRunning());
}

자바에서 클로저는 함수의 인스턴스입니다. 람다가 스태틱 메소드와 비슷하다면 외부 변수를 참조하는 익명 클래스가 클로저와 비슷하다고 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
void anonymousClassClosure() throws InterruptedException {
HttpServer server = new HttpServer();
waitFor(new Condition() {
@Override
public boolean isSatisfied() {
return !server.isRunning();
}
});
}

설명이 길었는데, 결론은 람다는 클로저를 포함하는 더 큰 개념이라고 볼 수 있습니다. 람다가 자신의 범위 밖에 있는 변수를 사용하면 그것은 람다인 동시에 클로저입니다.

참고