Java

Java 람다와 effectively final

devgenie 2025. 6. 23. 15:34

최근 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의 메모리 모델과 멀티스레딩 환경에서의 안정성을 보장하기 위해서이다.

  1. 스택(Stack)과 힙(Heap)의 생명주기 불일치
    • 메서드 내에 선언된 지역 변수스택(Stack) 메모리에 저장됩니다. 이 변수들은 메서드 실행이 끝나면 스택에서 사라져 더 이상 존재하지 않는다.
    • 하지만 람다 표현식이나 익명 클래스의 인스턴스힙(Heap) 메모리에 저장될 수 있다. 예를 들어, 람다가 다른 스레드에서 실행되거나, 메서드가 종료된 후에도 계속 사용될 수 있는 경우이다.
    만약 람다가 지역 변수를 직접 참조한다면, 메서드 실행이 끝나 스택에서 사라진 변수에 접근하려는 위험한 상황이 발생할 수 있다. 이를 방지하기 위해 Java는 지역 변수의 실제 값이 아닌, 값의 복사본(copy)을 람다에게 전달하는 방식을 선택한다.
  2. 값의 일관성(Consistency) 유지
    값의 복사본을 사용하기로 했다면, 또 다른 문제가 발생한다. 만약 원본 지역 변수의 값이 변경될 수 있다면 람다가 가진 복사본과 원본의 값이 달라지는 데이터 불일치가 발생한다.이러한 혼란과 예측 불가능한 동작을 원천적으로 차단하기 위해 Java는 "람다가 캡처하는 지역 변수는 절대 변경되어서는 안 된다"는 final / effectively final 규칙을 강제하는 것. 이는 값의 불변성을 보장하여 프로그램의 안정성을 높이는 장치이다.

  3. 문제상황 코드로 예시 :
    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는 더 명시적이고 안전한 대안들을 제공한다.

  1. 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이 과할 수 있다고 한다.
  2. 사용자 정의 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 클래스: 특정 도메인에 맞는 의미 있는 이름을 부여하여 가독성을 높일 수 있는 유연한 방법.