본문 바로가기

신입 개발자 면접 기초

자바 람다에서 final이거나 final처럼 쓰인 지역 변수만 접근할 수 있는 이유

Java lambda effectively final local variable

1. 자바 람다에서 final이거나 final처럼 쓰인 지역 변수에만 접근할 수 있는 이유

면접에서 또 하나 배워왔습니다. (기술 면접에서 떨어졌지만...ㅜㅜ)

먼저 질문부터 말씀드리면 원래 질문은 "Anonymous Inner Class에서 외부의 지역 변수에 접근할 때 final처럼 쓰여야만 가능한데 왜 그렇게 동작하는지에 대해 자유롭게 답을 해보라"는 질문이었습니다.

답변은 제대로 못했는데 나름대로 생각하기를 뭔가 동시성 문제가 있지 않을까?하고 단순하게 이정도만 생각하고 답변했던 것 같습니다. (근거도 없이...)

(물론 final 키워드로 지정된 변수는 재할당을 못하게 할뿐이지 동시성 문제를 해결하는 키워드는 아닙니다.)

2. 컴파일러는 이미 거부하고 있었다

위에서 말한 질문을 받았을 때 솔직히 조금 당황스러웠습니다.

람다를 사용하고 있는 입장에서 너무도 당연하게 컴파일러가 final처럼 쓰이지 않으면 컴파일 에러를 내줬기 때문에 왜 그런지까지는 별로 생각을 안 해봤기 때문입니다.

"final처럼"이라고 자꾸 이야기를 하는데, 용어 정리를 하면 원래는 "effectively final"이라고 표현합니다.

제 방식대로 해석하기로 변수에 final 키워드를 붙이진 않았지만 final 키워드를 붙인 것 처럼 사용하는 변수 즉, 재할당하지 않고 참조가 변경되지 않는 변수를 effectively final variable이라고 하는 것 같습니다.

어찌됐든 컴파일러가 거부하고 있는 이유를 알아보려고 합니다.

(컴파일러는 지역 변수의 참조가 변경되지 않은 것을 인지할 수 있습니다.)

Supplier<Integer> incrementer(int start) {
  return () -> start++;
}

위의 코드는 start++;에서 컴파일 에러가 납니다.

메서드의 파라미터 start는 람다표현식 기준으로 외부에 있는 지역 변수(=자유 변수)입니다.

실행 흐름 상 incrementer 메서드를 호출한 스레드의 스택 영역(메모리)에 start가 생성될 것이지만 람다표현식을 리턴한 후에는 함수가 종료되었으니 스택 영역에서 start가 사라질 것입니다.

리턴된 람다표현식은 다른 어떤 스레드에서 호출될지 모르는데 start가 사라졌을 뿐더러, 다른 스레드의 스택 영역에 있으므로 접근도 못합니다.

그래서 이 부분에서 문제를 해결하고자 자유 변수의 복사본을 만들어 접근을 허용하도록하게 했습니다.

이걸 람다 캡쳐링(capturing lambda)이라고 합니다.

캡쳐링했으면 내 마음대로 변경해도 되는거 아냐?라고 할 수 있지만, 리턴된 람다식은 언제 몇 개의 스레드에서 사용될 지 모릅니다.

그 때는 start 값이 '10'으로 복사되어 왔어도 11일지 12일지 그 이상일지를 모릅니다.

결과적으로 final로 처리되지 않으면 자유 변수 참조 값의 동기(Sync)를 맞출 수가 없습니다.

만약 컴파일에러를 내지 않으면, 개발자가 동기를 맞춰주는 작업을 해야하고, 컴파일러도 역할을 다하지 못하고 함부로 이 코드를 보장(guarantee)한다고 표현하게 됩니다.

그렇기 때문에 자유 변수를 캡쳐링했을 때는 final 또는 effectively final 변수를 이용해야합니다.

답변

자유 변수는 람다 캡쳐링에 의해 복사되기 때문에 다른 스레드에서 참조할 수 있고,

람다 캡쳐링에 의해 복사된 참조 값을 변경하는 코드는 람다 실행 시점에 따라 복사된 참조 값이 어떤 값인지 예측할 수 없기 때문(= 동기(Sync)를 맞출 수 없기 때문)에 final 또는 effectively final로 쓰입니다.

조금 어려운 말로 표현하면 자바의 '스레드 한정(Thread Comfinement)' 기법(또는 원칙)을 위배하지 않기 위해서 final 또는 effectively final로 정했다고 합니다.

3. 스태틱 또는 인스턴스 변수는?

자유 변수에서의 매커니즘과 이유를 알아봤습니다.

그러면 같은 원리를 스태틱 변수와 인스턴스 변수에 적용해보도록 하겠습니다.

private int start = 0;

Supplier<Integer> incrementer() {
    return () -> start++;
}

위 코드는 정상적으로 컴파일되고 start의 경우 인스턴스 변수입니다.

왜 이 경우는 문제가 없다고 판단할까요?

인스턴스 변수는 자바 메모리 구조상 힙 영역에 생성되기 때문에 여러 스레드에서도 동일한 변수를 참조할 수 있기 때문입니다.

그러면 컴파일러는 람다가 가장 최신의 start값(heap영역 안의 값)을 참조하도록 보장할 수 있습니다.

뭐 가능하다한들 결국은 자유변수든 인스턴스 변수든 그 변수 자체가 멀티 스레드에 안전하지 않으면 동시성 문제는 발생할 수 있습니다.

  • 참고 사이트

https://perfectacle.github.io/2019/06/30/java-8-lambda-capturing/

https://www.baeldung.com/java-lambda-effectively-final-local-variables

https://www.slipp.net/questions/278

https://perfectacle.github.io/2019/06/30/java-8-lambda-capturing/