들어가며

Kiosk프로그램 작성중 람다 표현식 내부에서 외부 변수를 사용할 때, “람다 표현식에 사용되는 변수는 final 또는 유사 final이어야 합니다” 라는 오류를 발견하였다. 해당 오류가 발생하는 이유부터 해결방법까지 알아보자.


List를 번호와 같이 출력하기

아래 코드는 키오스크에 저장되어 있는 메뉴를 번호와 출력하는 기능을 한다.

for 사용

List<Menu> menus; //메뉴가 저장되어있는 List

int index = 0;
for (Menu item : list) {
    System.out.println(++index + ". " + item);
}

for문을 사용하여 인덱스를 증가시키며, 메뉴 List의 각 요소에 번호를 붙여 출력한다.
해당 코드를 stream()을 사용하여 출력해보자.

stream() 사용

int index = 0;
menus.stream()
    .forEach(item -> System.out.println(++index + ". " + item));

해당 코드를 작성하면 “람다 표현식에 사용되는 변수는 final 또는 유사 final이어야 합니다” 라는 오류가 발생한다.

람다식 내부에서 사용하는 변수가 final 또는 유사 final이 아니라서 오류가 발생하는 것이다.

final은 알겠고, 유사 final은 무엇일까?
final부터 알아보자.


final & effectively final 정리

final

final int a = 10;
a = 20; // ❌ 컴파일 에러: cannot assign a value to final variable 'a'

final 키워드로 선언된 변수는 한번만 값을 할당할 수 있다.
할당된 값은 변경 불가능하다. 흔히 상수라고 많이 한다.

effectively final

오류에서 말한 유사 final은 effectively final을 의미한다.
effectively final은 final키워드로 선언한 변수는 아니지만, 값을 한번만 할당한 변수를 의미한다.

int a = 10;
System.out.println(a);

위와 같은 코드에서 a는 일반 int형 변수이지만, 값이 할당된 이후로 한번도 변경되지 않았기 때문에, effectively final이라 한다.
만약 뒤에서 a의 값을 변경한다면, a는 effectively final이 아니다.


오류 발생 이유

그렇다면, 람다 내부에서 사용되는 변수는 왜 final / effectively final이어야하는지, 일반 변수는 왜 오류가 발생하는지를 알아보자.

람다에 관한 자세한 내용과 JVM의 메모리 관리까지 각 단계별로 설명하기 위해 내용은 상당히 길다.


1. 사실 람다는 2종류로 나눈다. Capturing lambda & Non-Capturing lambda

람다는 Capturing 람다와 Non-Capturing 람다 두가지로 나눌 수 있다.
Capturing 람다는 외부 변수를 사용하는 람다를 의미하고, Non-Capturing 람다는 외부 변수를 사용하지 않는 람다를 의미한다.

그리고, 앞서 살펴본 오류가 모든 람다에서 발생하는 것이 아니다.

아래 예시 코드를 한번 살펴보자

Non-Capturing lambda

Runnable runnable = () -> {
    int a = 1;
    System.out.println(a); //람다식 내부의 변수만 사용
}

위 예시에서, 람다식 내부의 print메소드는 람다식 내부의 변수만 사용한다. 내부 변수만 사용하기 때문에, a를 출력하여도 오류가 발생하지 않는다.

즉, Non-Capturing lambda는 final 변수를 요구하지 않는다.

Capturing lambda

int a = 1;

//람다식 외부의 변수를 사용
Runnable runnable = () -> System.out.println(a);

위 예시에서, 람다식 내부의 print메소드는 람다식 외부의 변수 a를 사용한다. 해당 코드가 오류가 발생하지 않는 이유는, a가 앞서 알아본 유사 final이기 때문이다. 만약 ++a를 출력한다면, 오류가 발생할 것이다.

그렇다면 Capturing람다에서는 항상 final 외부변수만 사용 가능한것일까?

외부의 변수를 사용하는 람다식을 Capturing람다 라고하는데, 이 Capturing람다는 다시 Local Capturing과 Non Local Capturing 두가지 종류로 나뉜다.

Non Local Capturing람다는 외부 변수를 사용하는 Capturing람다에서도, 클래스의 멤버 변수 및 static 변수를 사용하는 람다를 의미한다.

Local Capturing람다는 외부 지역 변수를 사용하는 람다를 의미한다.

두 종류의 Capturing람다를 비교해보자

Non Local Capturing lambda

class Example {
    private int a = 1;
    private static int b = 1;
    
    public void test() {
        Runnable r1 = () -> System.out.println(++a);
        r1.run();
        Runnable r2 = () -> System.out.println(++b);
        r2.run();
    }
}

해당 코드는 Non Local Capturing 람다의 예시를 보여준다. a, b는 각각 Example클래스의 멤버변수, static 변수(클래스 변수)이다.
두개의 람다식에서 외부 변수에 접근하여도 오류가 발생하지 않는다.

Local Capturing lambda

class Example {
    public void test() {
        int a = 1;
        Runnable r = () -> System.out.println(++a);
        r.run();
    }
}

이 코드에서는 람다식이 외부 지역변수 a를 참조하고있다. 람다가 외부 지역변수를 사용할때, 외부 지역변수는 final, 유사 final로 제한된다. 코드에서 사용된 a가 final도, 유사 final도 아니기 때문에(++a를 하고있기 때문) 오류가 발생하는 것이다.

그렇기 때문에 람다 표현식에 사용되는 변수가 항상 final이어야 한다는것은 엄밀하게는 틀리다.


2. 사실 Local Capturing Lambda가 final을 요구하는 것이다

중간 정리를 하자면, 모든 lambda가 final 변수를 필요로 하는게 아닌, Local Capturing Lambda만 final 혹은 effectively final을 요구하는 것이다.
앞서 살펴본 예시에서, 다른 람다식들은 외부 변수가 final이 아니어도 잘 작동했다.

왜 Local Capturing 람다에서만 이러한 제약 조건이 붙는 것일까?

주목해야할 점은, 외부 지역변수를 사용할때 문제가 발생한다는 점이다.
람다 표현식이 외부 지역변수를 사용할때 (Local Capturing Lambda), 외부 지역변수 자체를 사용하는 것이 아닌 복사본을 사용한다. 그래서 Cpaturing이라는 명칭이 붙은것이다.

람다에서 외부 지역변수를 그대로 사용하지 못하고 복사본을 사용할수 밖에 없는 이유가 있다. 서로 다른 종류의 변수들이 JVM의 메모리에 저장되는 방식 차이 때문이다.


3. 스코프와 생명주기

지역변수는 블록에서 선언된다. 블록에서 선언된 지역변수는 스택 영역에 저장된다. 따라서, 지역변수가 선언된 블록이 종료되면, 스택에 저장된 지역변수는 소멸된다.

람다는 Consumer, Predicate 같은 함수형 인터페이스를 대체하는 문법이고, 람다식을 작성하면, 컴파일러가 자동적으로 익명 객체로 변환하여 실행된다. 즉 람다는 함수형 인터페이스의 구현체이며, 런타임에 객체로 존재한다. JVM에서 객체는 힙영역에 존재한다.

람다와 지역변수가 저장되는 공간이 다르기 때문에 문제가 발생한다. 람다가 외부 지역변수를 사용할때, 해당 지역변수와 람다식이 적혀있는 블록이 있다고 가정하자.

해당 블록이 실행될때, 지역변수와 람다 함수객체는 각각 스택과 힙 영역에 저장된다. 문제는, 블록이 종료될때 지역변수는 스택에서 사라지지만, 람다 함수객체는 가비지 컬렉터에 의해 소멸되기 전까지 힙영역에 존재한다. 이때, 람다가 실행될때 지역변수는 존재하지 않는다. 이 말은, 람다 내부에서 지역 변수를 수정하거나 참조하게 하면 지역 변수가 사라진 이후의 시점에도 접근하려는 문제가 발생할 수 있다는 것이다. 이를 막기 위해, 람다에서 외부 지역변수를 사용할때는, 지역 변수에 접근하는것이 아닌, 복사를 하여 사용하는 것이다.

이를 알고나면 위의 Non-Local Capturing람다에서는 오류가 발생하지 않는 이유도 알 수 있다. 인스턴스 변수와 static변수는 모두 생명주기가 람다 함수객체보다 같거나 길기 때문에 문제없이 사용 가능한것이다.

이문제 말고도 멀티 스레드 환경에서도 문제가 있을 수 있다.


4. 멀티 스레드 환경에서의 안정성

멀티 스레드 환경에서도 문제가 발생할 수 있다.

람다 표현식은 멀티 스레드 환경에서도 자주 사용된다. Runnable같은 경우가 그 예시다. 이때, 외부 지역 변수가 람다내에서 변경 가능하다면, 몇가지 문제가 발생할 수 있다.

먼저 람다가 여러 스레드에서 동시에 실행될때, 하나의 지역변수를 여러 스레드에서 동시에 수정하려 할 수 있기 때문에, 동시성 문제가 발생할 수 있다. 또한 지역변수를 관리하는 스레드와 람다가 실행되는 스레드도 다를 수 있다. 스택은 각 스레드 마다 관리하는 공간이 되며, 스레드 간에 스택 메모리를 공유하지 않기 때문에 람다 함수객체가 실행될때, 값을 참조할 수 없게 된다.

예시를 살펴보면 이해가 더 쉽다.

public void executeThread() {
    boolean isRunning = true;
    executor.execute(() -> {
        while (isRunning) {
            //내부 로직 수행
        }
    });
    isRunning = false;
}

위의 코드가 있다고 가정해보자.
어떤 스레드에서 람다식이 실행될지는 실행 전까지는 알 수 없다. 앞선 내용에서 지역변수의 스레드와 람다식이 실행되는 스레드가 다를 수 있다고 했다. 지역 변수 값(isRunning) 을 제어하는 스레드와 람다식을 실행하는 스레드가 따로 있다면, 람다식을 실행하는 스레드의 isRunning 값이 항상 최신 값으로 메모리에서 동기화되지 않는다. isRunning이 변경이 가능한 지역 변수이고, 지역 변수를 쓰레드 간에 sync 해주는 것은 불가능 하기 때문이다. (지역 변수는 스택 영역에 존재하고, 다른 스레드에서 접근이 불가능하다. volatile 키워드가 지역 변수에서 사용될 수 없는 이유와 같다.)

이러한 불확실성을 막기위해, 람다식(정확히는, Local Capturing 람다)에서는 외부 지역 변수를 직접 참조하지 않고 복사하여 사용하고, 전달되는 복사본이 변경되지 않는 값 임을 보장하기 위해 fianl 혹은 effectively final 이어야 한다는 제약이 있는것이다.


5. 공식문서 - Java Language Specification (JLS)

공식문서에서 람다의 제약 내용을 확인가능하다.

JLS §15.27.2. Lambda Body

“Any local variable, formal parameter, or exception parameter used but not declared in a lambda body must be effectively final.”

“람다에서 선언되지 않았지만 사용되는 모든 지역 변수, 형식 매개변수, 또는 예외 매개변수는 final이어야 한다.”

자세한 내용은 아래의 링크에서 확인가능하다.
JLS §15.27.2. Lambda Body


해결 방법

그렇다면 오늘의 문제가 발생한 코드에서, 어떻게 문제를 해결 가능할까?


1. AtomicInteger

List<Menu> menus; //메뉴가 저장되어있는 List

AtomicInteger index = new AtomicInteger(0);
menus.stream()
    .forEach(item -> System.out.println(i.getAndIncrement() + ". " + item));

람다식에서 사용하는 외부 지역변수의 동시성 문제를 AtomicInteger를 사용하여 해결 가능하다. 이 방식은 Non Local Capturing 람다로 문제를 처리하는 방식이라 볼 수 있다. AtomicInteger는 힙 영역에 저장되고, 여러 스레드간 안정성 있게 공유 가능하기 때문에, 람다식 내부에서도 사용 가능하다.

다만, 순서를 출력하는 기능만을 위해 AtomicInteger를 사용한다는것이 오버스펙이 아닌지는 고민해볼 부분이다.


2. 배열 사용

List<Menu> menus; //메뉴가 저장되어있는 List

int[] index = {0};
menus.stream()
    .forEach(item -> System.out.println(++index[0] + ". " + item));

두번째 방법은 배열을 활용하는 방법이다. 배열 역시 참조타입이기 때문에 힙영역에 저장되며, 람다식 내부에서 값을 활용할 수 있다. 배열을 final로 선언하여도 참조 변수만 고정되고 배열 내부의 값은 변경 가능하다.

1번 방법과 근복적으로 같은 해결 방법이지만, AtomicInteger보다는 가벼운 처리방식이라고 볼수있다. 다만 단일 스레드에서는 동일 결과를 내겠지만, 람다식의 멀티 스레드 환경에서는 AtomicInteger 사용을 해아할것이다.


3. IntStream 사용

List<Menu> menus; //메뉴가 저장되어있는 List

IntStream.range(0, menus.size())
         .forEach(i -> System.out.println((i + 1) + ". " + menus.get(i)));

IntStream의 range() 메소드를 활용하여 인덱스를 처리하는 방법을 사용할 수 있다. 이는, Non Capturing 람다로 문제를 해결한다는 점에서 1번 2번방법과는 근본적으로 다르다. 인덱스는 range메소드 내부에서 처리하기때문에, 람다 제약에 애초에 걸리지 않는다


해결방법 정리

3가지 해결 방법을 표로 정리해보자.

방법 특징
IntStream Non-Capturing 람다
람다 제약이 없다
멀티 스레드에서 사용 가능
AtomicInteger Local-Capturing 람다
멀티스레드에서 사용 가능
멀티가 아니면 과한느낌
배열 Local-Capturing 람다
가볍고 빠름
단일 스레드에서 적절

3가지 해결방법 모두, 동일한 실행 결과를 가져온다. 하지만, 어떻게 가져오는지는 각 방법마다 장단점이 존재한다.


결론

오류의 원인을 파악하는 과정에서, 단순히 람다 내부에서는 final만 사용해야한다를 보고, 왜 final이 필요한지를 알고 싶었다. 이를 이해하기 위해 람다와 메모리 구조까지 깊이 있게 조사하게 되었다. 자료조사와 글 작성까지 약 3일이 소요되었는데, 단순히 “final이 필요하다”, “AtomicInteger나 다른 방법을 쓰면 된다”는 정도로 넘어갔다면 한 시간도 걸리지 않았을 것이다.

근본적인 이유를 찾고, 이해하는 과정은 때로는 비효율 적이다. 해결 방법을 빠르게 검색해 적용하는 것이 중요할 때도 있다. 하지만 근본적인 원인을 파악하는것은 최적의 방법을 선택하는 데 중요한 기준이 된다.

프로그래밍에는 기준이 없다. 다만, 특정한 상황에 가장 적합한 해법은 분명히 존재한다. 이번 사례처럼 근본 원인을 정확히 이해하고, 다양한 해결 방법의 장단점을 직접 비교해보는 과정이 있다면, 상황에 맞는 최적의 선택을 할 수 있을 것이다.

가령 이번 예시에서는 IntStream을 사용하였지만, 멀티스레드의 환경에서 동시성이 정말 중요한 작업을 해야한다면 AtomicInteger사용이 가장 적절한 방법인 것이다.

깊이 있는 사고와 근본적인 원인 탐구는 더 나은 코드와 안정적인 프로그램을 만드는 데 큰 도움이 될것이라고 믿는다. 항상 왜? 라는 질문을 묻는 습관을 잃지 않으며, 단순한 정답이 아닌 최적의 선택을 고민해보는 과정은 나를 더 성장시킬것이다.


댓글남기기