Java 람다와 effectively final
최근 Java 프로젝트에서 XML 파일을 처리하면서 아래와 같은 코드를 작성하게 되었다.
boolean[] replaced = {false};
doc.traverse(new NodeVisitor() {
@Override
public void head(Node node, int depth) {
// 익명 클래스 내부에서 외부 변수의 상태를 변경
replaced[0] = true;
}
@Override
public void tail(Node node, int depth) {
// no-op
}
});
이 코드를 다른 개발자가 보고
"왜 굳이 원시 타입인 boolean
을 배열로 감싸서 사용하나요? 그냥 boolean
변수 사용하면 안되나요?"
라는 질문을 하였다.
그런데 부끄럽게도 람다와 익명 클래스의 변수 캡처(Variable Capture) 규칙에 대해 내가 만족할만하게 답변을 주지 못하였다.
그래서 Java의 final
, effectively final
개념과 람다의 캡처 규칙에 대해 정리하는 시간을 갖게 되었다.
Java 람다와 변수 캡처
Java SE 8부터 도입된 **람다 표현식(Lambda Expression)**과 그 이전부터 존재했던 **익명 클래스(Anonymous Class)**는 자신이 선언된 영역(enclosing scope)의 지역 변수를 사용할 수 있다. 이를 '변수를 캡처한다'고 표현하지만 이 캡처에는 매우 중요한 제약 조건이 따른다.
람다 및 익명 클래스는 final 또는 effectively final인 지역 변수만 참조(캡처)할 수 있다.
여기서 effectively final
이라는 개념을 알아야 한다.
- final 변수: final 키워드로 명시적으로 선언된 변수. 한 번 초기화되면 그 값을 절대 변경할 수 없다.
final int number = 10; Runnable r = () -> System.out.println(number); // OK number = 20; // 컴파일 오류: final 변수는 재할당 불가
- effectively final (실질적으로 final) 변수: final 키워드가 붙지 않았지만, 변수가 초기화된 이후 단 한 번도 값이 변경되지 않은 변수를 의미한다. 컴파일러는 이 변수를 final 변수와 동일하게 취급한다.
이런 제약이 필요한 이유 : 스택과 힙, 그리고 스레드 안전성
이러한 제약은 Java의 메모리 모델과 멀티스레딩 환경에서의 안정성을 보장하기 위해서이다.
- 스택(Stack)과 힙(Heap)의 생명주기 불일치
- 메서드 내에 선언된 지역 변수는 스택(Stack) 메모리에 저장됩니다. 이 변수들은 메서드 실행이 끝나면 스택에서 사라져 더 이상 존재하지 않는다.
- 하지만 람다 표현식이나 익명 클래스의 인스턴스는 힙(Heap) 메모리에 저장될 수 있다. 예를 들어, 람다가 다른 스레드에서 실행되거나, 메서드가 종료된 후에도 계속 사용될 수 있는 경우이다.
- 값의 일관성(Consistency) 유지
값의 복사본을 사용하기로 했다면, 또 다른 문제가 발생한다. 만약 원본 지역 변수의 값이 변경될 수 있다면 람다가 가진 복사본과 원본의 값이 달라지는 데이터 불일치가 발생한다.이러한 혼란과 예측 불가능한 동작을 원천적으로 차단하기 위해 Java는 "람다가 캡처하는 지역 변수는 절대 변경되어서는 안 된다"는final
/effectively final
규칙을 강제하는 것. 이는 값의 불변성을 보장하여 프로그램의 안정성을 높이는 장치이다. - 문제상황 코드로 예시 :
int count = 0; // 만약 이것이 허용된다면 Runnable task = () -> { // 람다는 복사된 count (0)을 가지고 있음 System.out.println(count); }; count = 10; // 원본 count 값 변경 new Thread(task).start(); // 스레드는 무엇을 출력해야 할까? 0? 10?
우회의 기술 : 왜 boolean[ ] 은 동작하는가?
// boolean replaced = false; // X 이렇게 하면 컴파일 오류
boolean[] replaced = {false}; // o 이렇게 하면 정상 동작
doc.traverse(new NodeVisitor() {
@Override
public void head(Node node, int depth) {
// replaced = new boolean[]{true}; // x 참조 변수 자체를 바꾸는 것은 금지! (not effectively final)
replaced[0] = true; // o 참조가 가리키는 *객체의 내용*을 바꾸는 것은 허용
}
});
boolean같은 원시 타입 변수를 직접 수정하면 effectively final
규칙에 위배되어 컴파일 오류가 발생한다.
하지만 boolean[]
배열을 사용하면 상황이 달라진다. 여기서 람다가 캡처하는 것은 배열 변수 replaced
가 가리키는 참조(주소값) 자체이기 때문이다.
replaced
라는 참조 변수: 이 변수는 힙에 생성된 배열 객체의 주소를 담고 있다. 이 주소값 자체는 익명 클래스 내부에서 변경되지 않으므로effectively final
규칙을 만족.replaced[0]
라는 배열의 요소: 이는 힙에 있는 객체 내부의 공간이다.replaced
참조를 통해 이 객체에 접근하고 그 내용을 변경하는 것은 캡처 규칙의 제약 대상이 아니다.
즉, '변수의 값'을 바꾸는 것은 금지되지만, '변수가 참조하는 객체의 내용(상태)'을 바꾸는 것은 허용되는 것이다.
더 나은 대안: 가독성과 명시성을 위한 대안
배열을 사용하는 트릭은 간편하지만, 코드의 의도를 파악하기 어렵게 만들고 가독성을 해칠 수 있다.
내가 겪은것 처럼
"왜 상태를 저장하는데 뜬금없이 배열을 쓰지?"
라는 의문을 남기기 때문이다. 그래서 Java는 더 명시적이고 안전한 대안들을 제공한다.
Atomic
클래스 활용 (java.util.concurrent.atomic
)
멀티스레드 환경에서도 안전하게 값을 변경할 수 있도록 설계된 클래스들이다.AtomicBoolean
,AtomicInteger
등이 있으며, 상태 변경의 의도를 명확하게 드러낸다.
import java.util.concurrent.atomic.AtomicBoolean; AtomicBoolean replaced = new AtomicBoolean(false); doc.traverse(new NodeVisitor() { @Override public void head(Node node, int depth) { replaced.set(true); } }); // 단일 스레드에서는 AtomicBoolean이 과할 수 있다고 한다.
- 사용자 정의 Wrapper 클래스
간단한 Holder 클래스를 직접 만들어 사용하는 방법도 있다. 코드의 의도를 명확하게 설명하는 클래스 이름을 사용할 수 있다는 장점이 있다.
코드 예시 :
class MutableHolder<T> { private T value; public MutableHolder(T value) { this.value = value; } public T get() { return value; } public void set(T value) { this.value = value; } } MutableHolder<Boolean> replaced = new MutableHolder<>(false); doc.traverse(new NodeVisitor() { @Override public void head(Node node, int depth) { replaced.set(true); } });
정리
- 규칙의 핵심: Java 람다와 익명 클래스는 스택 변수의 생명주기와 스레드 안전성 문제로 인해
final
또는effectively final
지역 변수만 캡처할 수 있다. 이는 변수 자체가 아닌 값의 복사본을 사용하기 때문이다. - 우회와 대안: 내부에서 외부 상태를 변경해야 할 경우, 참조 변수 자체는
effectively final
로 유지하면서 그 참조가 가리키는 객체의 상태를 변경하는 방식을 사용한다.- 배열: 가장 간단하지만 가독성이 떨어질 수 있는 방법.
Atomic*
클래스: 스레드 안전하며 상태 변경의 의도를 명확히 하는 권장 방식.- Wrapper 클래스: 특정 도메인에 맞는 의미 있는 이름을 부여하여 가독성을 높일 수 있는 유연한 방법.