Java - Lambda, 익명 클래스에서 final 변수를 참조해야하는 이유

JS · 04 Jul 2020

Lambda 또는 익명 클래스(Anonymous class)에서 외부에 정의된 변수를 참조할 때, 가능하면 final, 또는 effective final 변수를 참조하는 것이 좋습니다.

사실 non-final 변수를 참조하려고 하면 컴파일 에러가 발생하기 때문에 사용할 수도 없습니다.

이 글에서는 왜 final을 써야 하는지에 그 이유에 대해서 알아보려고 합니다.

final과 effective final

final은 초기화된 변수의 값을 변경하지 못하도록 막아주는 키워드입니다. 값을 변경하려고 하면 컴파일 에러가 발생합니다. 따라서 상수(constants)를 정의할 때 자주 사용됩니다. 상수가 아니더라도, 코드 중간에 값이 변경되면 안된다는 의미로 final을 사용할 수 있습니다.

Effective final은 Java8에서 추가된 기능으로, non-final로 선언했지만 변경이 없다면 컴파일러가 Lambda나 익명 클래스에서 final 변수로 취급하는 내용입니다.

더 자세히 알고 싶으시다면 아래 두개의 글을 읽어보시면 좋습니다.

Lambda, 익명 클래스에 final만 참조해야하는 이유

Lambda, 익명 클래스에서 외부에 선언된 변수를 참조할 때 가능한 final 변수만 참조하는 것이 좋습니다.

예제를 보면서 non-final을 사용하며 안되는 이유에 대해서 알아보겠습니다.

다음 코드를 실행해보면 test() 메소드가 동작하는 쓰레드와 submit()에 전달되는 lambda가 동작하는 쓰레드가 다른 것을 알 수 있습니다.

public void test() {
    log("run some code in the new thread");

    final int num = 10;
    Executors.newCachedThreadPool().submit(() -> {
        log("do something : " + num);
    });

    log("print num: " + num);
}

public void log(String msg) {
    System.out.println(LocalTime.now() + " ("
            + Thread.currentThread().getName() + ") " +  msg);
}

아래는 위의 코드를 실행한 결과 입니다. log()는 인자로 전달된 msg를 출력할 때 시간과 쓰레드의 이름을 함께 출력합니다.

12:29:53.378 (main) run some code in the new thread
12:29:53.386 (main) print num: 10
12:29:53.386 (pool-1-thread-1) do something : 10

출력되는 로그를 보시면 lambda에서 구현된 코드는 pool-1-thread-1 쓰레드에서 실행되었습니다.

지역변수

위와 같이 Lambda나 익명 클래스에 정의된 메소드는 멀티쓰레드에서 동작할 가능성이 있습니다. 멀티쓰레드에서 동작하지 않더라도 실행되는 시점은 Lambda가 생성되는 시점은 아닙니다.

Lambda는 늦게 실행될 수 있기 때문에 메소드 안에서 정의된 non-final 변수를 참조하게 된다면 문제가 발생할 수 있습니다. 메소드의 변수들은 stack 영역에 저장이 됩니다. 메소드가 종료되면 stack의 데이터도 삭제되기 때문에 Lambda가 실행되는 시점에 변수가 존재하지 않을 가능성이 있습니다.

멤버 변수, Static 변수

메소드에 정의된 변수가 아닌, 클래스에 정의된 멤버 변수나 static 변수는 lambda에서 접근할 수 있습니다.

하지만 Lambda는 멀티쓰레드에서 동작하기 때문에 외부 변수를 참조할 때 타이밍 문제나, 동시성 문제가 생길 수 있습니다.

아래 코드를 실행해보면 각각의 쓰레드에서 num이 어떤 값이 될 지 예측하기 어렵습니다.

int num = 10;

public void test() {
    log("run some code in the new thread");

    Executors.newCachedThreadPool().submit(() -> {
        num++;
        log("do something : " + num);
    });

    num++;
    log("print num: " + num);
}

실행해보니, Lambda가 가장 늦게 실행되어 12를 출력하고 있습니다.

14:19:35.497 (main) run some code in the new thread
14:19:35.506 (main) print num: 11
14:19:35.506 (pool-1-thread-1) do something : 12

하지만 다음과 같이 main에서 지연이 발생하면 Lambda가 main보다 먼저 실행됩니다.

public void test() throws InterruptedException {
    log("run some code in the new thread");

    Executors.newCachedThreadPool().submit(() -> {
        num++;
        log("do something : " + num);
    });

    sleep(1000);

    num++;
    log("print num: " + num);
}

실행 결과를 보면 Lambda는 11을 출력하고 있습니다.

14:40:11.560 (main) run some code in the new thread
14:40:11.567 (pool-1-thread-1) do something : 11
14:40:12.567 (main) print num: 12

위와 같이 어떤 쓰레드가 먼저 호출될지 예상할 수 없기 때문에 타이밍 문제가 발생할 수 있습니다. 또한, 멀티 쓰레드에서 동시에 접근할 때 동시성 문제가 발생할 수 있습니다.

동시성 문제(Concurrency issue) 회피 방법

동시성 문제를 피하려면 synchronized() 등으로 동기화하거나, Atomic 객체를 사용하여 Thread-safe하도록 만들어야 합니다.

다음과 같이 AtomicInteger를 사용하여 동시성 문제가 없도록 하였습니다.

AtomicInteger num = new AtomicInteger(10);

@Test
public void test() {
    log("run some code in the new thread");

    Executors.newCachedThreadPool().submit(() -> {
        num.set(num.get() + 1);
        log("do something : " + num.get());
    });

    num.set(num.get() + 1);
    log("print num: " + num.get());
}

결론

Lambda 또는 익명 클래스에서는 외부에서 정의된 변수를 사용할 때 주의해야 합니다.

가능하다면 final 변수만 참조하는 것이 좋습니다. final 변수를 사용하면 그 변수의 값은 변경되지 않습니다. 이것은 다른 쓰레드의 상태에 따라 Lambda가 처리하는 작업의 결과가 달라지지 않는 다는 것을 의미합니다.

예를 들어, 멀티쓰레드에서 동일한 변수를 접근하고 변경을 할 수 있다고 가정을 해보세요. 쓰레드가 실행되는 순서에 따라 결과가 달라질 수 있습니다. 하지만 final을 사용하게 되면 다른 쓰레드가 동일한 값을 접근한다고 해도 값은 변경되지 않기 때문에 쓰레드 실행 순서와 관련없이 동일한 결과를 출력할 수 있습니다.

만약 Lambda가 꼭 외부의 변수를 참조해야 한다면, synchronized 또는 atomic 객체를 사용하여 그 변수가 Thread-safe하도록 만들어야 합니다. 그리고 멀티 쓰레드의 실행 순서에 따라 문제가 발생할 수 있는지 검토를 해야 합니다.

참고

댓글을 보거나 쓰려면 이 버튼을 눌러주세요.
codechachaCopyright ©2019 codechacha