[Back-end]/[Java]

람다식과 외부 변수 참조

DevLoki 2021. 12. 18. 20:43

모던 Practical 자바 5장 스트림 API를 읽다 흥미로운 코드를 발견했다.

(설명을 위해 약간 변형했습니다.)

List<Integer> intList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
int sum = 0;

intList.stream().forEach(n -> sum+=n);

System.out.println("sum : " + sum);

문제가 전혀 없어보이는 코드지만, 다음과 같은 메시지와 함께 컴파일 에러가 발생한다.

 

java: local variables referenced from a lambda expression must be final or effectively final

해석하자면 '람다식에서 참조되는 변수는 final 이어야 한다.'이다.

 

진짜 흥미로운 점은 아래 코드는 문제가 전혀 없이 동작한다는 점이다.

List<Integer> intList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
int[] sum = new int[]{0};

intList.stream().forEach(n -> sum[0]+=n);

System.out.println("sum : " + sum[0]);

 

람다식 참조 변수를 final로 선언하지도 않았고, 단순히 정수형 변수 sum을 크기가 1인 정수형 배열로 바꿔주었지만 정상적으로 실행되었다.

 

도대체 뭐가 다를까?

로컬 변수와 스레드

위 코드의 차이점을 이해하기 전에 자바 주요 기능을 한 가지 짚고 넘어가야 한다.

*자바는 메서드 내의 로컬 변수에 대한 안정성을 보장한다.*

public class ThreadSafeTest {
    public static void main(String[] args) {

        Thread[] threads = new Thread[10];

        for(int i=0;i<10;i++){
            threads[i] = new MyThread();
        }


        for(int i=0;i<10;i++){
            threads[i].start();
        }

    }

    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println(loop());
        }
    }

    private static int loop(){
        int n = 0;
        for(int i=0;i<10000000;i++){
            n++;
        }
        return n;
    }
}

위 코드를 보면 loop() 메서드를 10개의 스레드가 동시에 호출하며 사용한다. 스레드 안전성을 보장하지 않는다면 1번째 스레드가 n을 0에서 1로 변경 한 뒤, 다음 스레드에서는 이 값을 1을 2로 바꾸고 결국 각 스레드가 반환하는 결과 값이 모두 다를 것이다. 하지만 자바는 메서드 내의 로컬 변수에 대한 안정성을 보장하기 때문에 이러한 문제가 발생하지 않는다.

 

즉, 로컬 변수와 배열(=객체)의 차이가 이러한 문제를 만들었다는 것까지는 이해가 되었다. 

 

그렇다면 로컬 변수의 안정성과 람다식은 무슨 관계가 있을까?

 

다음 코드를 보자

public class LocalVarReference {

    Consumer<Integer> consumer;

    public static void main(String[] args) {
        LocalVarReference test = new LocalVarReference();

        test.run();

        test.consumer.accept(20);
    }

    private void run(){
        int n = 10;
        Consumer<Integer> c = i -> System.out.println(i + n);
        this.consumer = c;
        c.accept(10);
    }
}

람다식과 인터페이스 

함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다. 익명 클래스를 이용하여 익명 구현 객체를 구현하는데 함수형 프로그래밍을 이용하여 간략하게 표현하는 것이 람다식이다. 따라서 모든 익명 내부 객체의 장점이 람다식에도 동일하게 적용된다.

그렇다고 람다식이 익명 내부 객체와 완전히 동일한 것은 아니다.

동일한 방식으로 동작하지만, 익명 내부 클래스는 새로운 클래스 파일을 생성하고 람다식은 새로운 메서드를 만든다. 

암튼, 여기서는 람다식을 사용하거나 익명 내부 객체를 사용하면 함수 객체가 힙 영역에 생성되는 것만 알고 넘어가자. 

 

main() 메서드를 한 줄씩 들여다보자.

첫 줄 LocalVarReference test = new LocalVarReference(); 을 실행하면 메모리는 다음과 같은 상태이다.

heap 영역에 LocalVarReference 객체인 test가 등록된다.

두 번째 줄, test.run()을 실행하면 stack 영역에 run() 메서드가 올라가며, 새로운 함수 객체 c를 생성하여 heap에 저장하고 test의 멤버 변수인 consumer는 c를 참조하게 된다. 

문제는 여기서부터 이다. 마지막 코드를 실행하는 시점에는 stack에 더 이상 run() 메서드가 존재하지 않는다. 즉, 객체가 참조할 run() 메서드의 로컬 변수인 n이 존재하지 않는다. 그렇다면 오류가 발생해야 정상이지만 결과를 보면 정상적으로 출력이 된다.

이게 어떻게 된 일인가?

 

Variable Capture(람다 캡쳐링)

이처럼 람다를 포함하여 익명 클래스, 메서드에 중첩된 클래스들에서 위의 문제를 해결하기 위해 내부에서 사용하는 지역 변수들을 미리 복사해서 가지고 있는 것을 Variable Capture라고 한다.

 

지역변수를 캡쳐링 하기 위해서는 2가지 제약조건이 있다.

  1. 지역변수는 final로 선언되어있어야 한다.
  2. final로 선언되어 있지 않다면 final처럼 동작해야 한다. 즉, 값이 변경될 수 없다.

이 문장을 영어로 번역하면

local variables referenced from a lambda expression must be final or effectively final

이 된다.

처음에 보았던 에러 메시지와 정확히 일치한다. 원인을 발견한 것이다!!


결론

즉, 처음에 보았던 람다식의 외부 변수 참조는 스레드 안전성을 지키기 위해 로컬 변수의 변경을 막고, 참조는 variable capture를 통해 가능하게 한다. 메서드의 로컬 변수는 스레드의 안전성을 보장하는데 익명 내부 객체를 사용하면 지역변수를 바꿀 수 있기 때문에 이러한 문제를 막기 위해 컴파일 에러가 나타나는 것이었다. 

 

(이제 int sum을 int[] sum으로 변경했을 때에는 왜 정상적으로 동작했는지 유추하실 수 있겠죠?)

 

'[Back-end] > [Java]' 카테고리의 다른 글

[Java] 인터페이스의 문제점과 발전 방향  (0) 2021.12.18