람다에서 로컬 변수 사용하기 - local variables referenced from a lambda expression must be final or effectively final
Java 2024. 07. 03. 04:03
자바 코드를 작성하다보면 콜백을 사용해야 할 때가 있다.
콜백은 람다로 작성하는 일이 많은데, 이 때 다음과 같은 에러가 발생하는 경우가 있다.
콜백은 람다로 작성하는 일이 많은데, 이 때 다음과 같은 에러가 발생하는 경우가 있다.
java: local variables referenced from a lambda expression must be final or effectively final
이번 포스트에서는 이 에러가 나는 이유와 이를 해결할 수 있는 여러 방법들을 소개하고자 한다.
시나리오
에러를 재현하기 위해 다음과 같은 상황을 가정해보자.
- 긴 정수 배열에 대해 특정 작업을 반복해야 한다.
- 중간 중간 진행 상황을 알고자 한다. 이를 위한 메서드 runTasks()를 다음과 같이 작성했다고 하자.
private static void runTasks(int[] array, Consumer<Integer> onProgressUpdate) {
int tasksDone = 0;
for (int element : array) {
// 오래 걸리는 작업
task(element);
// 10개 작업이 끝날 때 마다 진행도 업데이트
if (++tasksDone % 10 == 0) {
onProgressUpdate.accept(tasksDone);
}
}
if (tasksDone % 10 != 0) {
onProgressUpdate.accept(tasksDone);
}
}
이 메서드를 호출할 때, 진행 상황을 다음과 같이 트래킹해보자.
public static void trackProgress() {
int currentProgress = 0;
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress = progress;
});
}
currentProgress = progress;
부분에서 문제의 에러가 발생함을 확인할 수 있다.에러의 원인
에러 메세지를 보면 람다에서 참조하는 지역 변수는 final이거나 effectively final이어야 한다고 한다.
final 변수는 초기화 이후 값 변경이 불가능한 변수이며,
effectively final 변수는 final 키워드가 명시적으로 붙지는 않았지만 사실상 값이 변경되지 않는 변수를 의미한다.
final 변수는 초기화 이후 값 변경이 불가능한 변수이며,
effectively final 변수는 final 키워드가 명시적으로 붙지는 않았지만 사실상 값이 변경되지 않는 변수를 의미한다.
위 예시에서는 currentProgress 변수가 람다 내부에서 값이 변경되기 때문에 문제가 발생하는 것이다.
왜 이러한 제약이 있는걸까?
람다에서 지역 변수의 변경이 제한되는 이유
람다에서 지역 변수의 값 변경이 제한되는 이유를 이해하기 위해 람다 표현식의 특성과 자바의 변수 스코프 개념을 되짚어보자.
- 람다는 자신을 둘러싼 외부 범위의 변수에 접근할 수 있다. (람다 캡처링, lambda capturing)
- 람다는 메서드 실행 종료 후에도 비동기적으로 실행될 수 있지만, 지역 변수는 메서드 실행이 종료되면 스택에서 사라진다.
- 예를 들어, runTasks()에 전달된 람다를 클래스 멤버 변수로 저장해 놓았다고 생각해보자.
- trackProgress() 메서드가 종료되는 시점에 currentProgress 변수는 스택에서 할당 해제된다.
- 반면, 전달된 onProgressUpdated 람다 객체는 여전히 살아있다.
이러한 문제를 해결하기 위해 자바는 람다 표현식이 캡처하는 지역 변수를 복사하여 별도의 공간에 저장하고, 람다 표현식은 복사된 값을 사용하도록 한다. 만약 원본 지역 변수의 값이 변경될 수 있다면 복사된 값과의 일관성이 깨져 오류가 발생할 수 있다.
따라서 람다 표현식 내부에서 사용되는 지역 변수는 final 또는 effectively final로 선언하여 값 변경을 방지해야 한다. 이를 통해 람다 표현식이 안전하게 변수에 접근하고 사용할 수 있도록 보장할 수 있다.
해결 방법
위에서 살펴보았듯, 람다에서 지역 변수를 직접 변경하는 것은 어렵다. 이를 해결할 수 있는 방법들에 대해 알아보자.
배열로 감싸기
currentProgress를 배열로 감싸는 것으로 이 문제를 우회할 수 있다. 자바에서 배열은 객체이므로, 람다 표현식 내부에서 배열의 요소 값을 변경하는 것은 허용된다.
private static void trackProgress() {
int[] currentProgress = {0}; // 배열로 감싸기
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress[0] = progress; // 배열 요소 변경
});
}
위 코드에서 currentProgress는 길이가 1인 정수 배열로 선언되었다. 람다 표현식 내부에서는 currentProgress[0]을 변경하여 진행 상황을 업데이트한다. 이렇게 하면 currentProgress 변수 자체는 변경되지 않고 배열의 요소 값만 변경되므로, effectively final 조건을 만족하게 된다.
이 방법은 가장 간단하게 문제를 해결할 수 있는 방법 중 하나이지만, 코드 가독성이 떨어지고 멀티 스레드 환경에서는 문제가 발생할 수 있다는 점을 유의해야 한다. 단일 스레드 환경에서 간단하게 문제를 해결하고 싶을 때 유용하게 사용할 수 있다.
장점
- 간단하고 빠르게 적용 가능: 코드를 크게 수정하지 않고도 문제를 해결할 수 있다.
- 기존 코드의 수정 최소화: 람다 표현식 외부의 코드를 거의 변경하지 않아도 된다.
단점
- 코드 가독성 저하: 배열을 사용하는 의도를 명확히 파악하기 어려워 코드 가독성이 떨어질 수 있다. 다른 개발자가 코드를 이해하기 어려울 수 있다.
- 멀티 스레드 환경에서 동기화 문제 발생 가능: 멀티 스레드 환경에서는 여러 스레드가 동시에 currentProgress[0] 값을 변경하려고 할 때 문제가 발생할 수 있다.
멤버 필드로 만들기
두 번째 해결 방법은 currentProgress 변수를 클래스의 멤버 필드로 선언하는 것이다. 멤버 필드는 람다 표현식의 외부에 선언되므로, 람다 표현식이 캡처하는 변수에 해당하지 않는다. 따라서 effectively final 조건의 적용 대상이 아니며, 람다 표현식 내부에서 자유롭게 값을 변경할 수 있다.
private static int currentProgress = 0; // 멤버 필드로 선언
private static void trackProgress() {
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress = progress; // 멤버 필드 변경
});
}
멤버 필드를 사용하는 방법은 람다 표현식 내부에서 변수에 직접 접근할 수 있어 코드가 간결해지고 가독성이 높아진다는 장점이 있다. 하지만 멀티 스레드 환경에서는 동기화 문제가 발생할 수 있으므로 주의해야 한다. 또한, 객체 지향 설계 원칙을 고려하여 신중하게 사용해야 한다.
장점
- 람다 표현식 내부에서 직접 접근 가능: 람다 표현식 내부에서 currentProgress 변수에 직접 접근하여 값을 변경할 수 있으므로, 코드가 더 간결해진다.
- 코드 가독성 향상: 배열을 사용하는 것보다 의도를 파악하기 쉽고, 코드 가독성이 높아진다.
단점
- 멀티 스레드 환경에서 동기화 문제 발생 가능: 멀티 스레드 환경에서는 여러 스레드가 동시에 currentProgress 값을 변경하려고 할 때 문제가 발생할 수 있다.
- 객체 지향 설계 원칙에 어긋날 수 있음: 멤버 필드를 사용하면 클래스의 상태가 외부에 노출될 수 있으며, 이는 객체 지향 설계 원칙에 어긋날 수 있다.
AtomicInteger 사용하기
세 번째 해결 방법은 AtomicInteger 클래스를 사용하여 currentProgress 변수를 선언하는 것이다. AtomicInteger는 멀티 스레드 환경에서 안전하게 정수 값을 변경할 수 있도록 설계된 클래스이다.
private static void trackProgress() {
AtomicInteger currentProgress = new AtomicInteger(0); // AtomicInteger 사용
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress.set(progress); // AtomicInteger 값 변경
});
}
위 코드에서 currentProgress는 AtomicInteger 객체로 선언되었다. 람다 표현식 내부에서는 currentProgress.set(progress) 메서드를 호출하여 진행 상황을 업데이트한다. AtomicInteger는 내부적으로 동기화 처리를 수행하므로, 멀티 스레드 환경에서도 안전하게 값을 변경할 수 있다.
이 방법은 멀티 스레드 환경에서 안전하게 값을 변경해야 하는 경우 가장 적합한 방법이다. Atomic 클래스의 다양한 기능을 활용하여 효율적인 코드를 작성할 수 있다. 단일 스레드 환경에서는 굳이 AtomicInteger를 사용할 필요는 없지만, 멀티 스레드 환경으로 확장될 가능성이 있다면 미리 AtomicInteger를 사용하는 것을 고려해 볼 수 있다.
또, 지역 변수로 선언할 수 있으므로 캡슐화 원칙도 지킬 수 있다.
장점
- 멀티 스레드 환경에서 안전하게 값 변경 가능: AtomicInteger는 멀티 스레드 환경에서 발생할 수 있는 경쟁 조건(race condition) 문제를 해결하여 안전하게 값을 변경할 수 있도록 보장한다.
- Atomic 클래스의 다양한 기능 활용 가능: AtomicInteger는 getAndIncrement, getAndUpdate 등 다양한 메서드를 제공하여 원자적인 연산을 수행할 수 있도록 지원한다.
단점
- Atomic 클래스 사용에 대한 이해 필요: AtomicInteger 클래스를 사용하려면 Atomic 클래스에 대한 기본적인 이해가 필요하다.
- 박싱/언박싱 오버헤드 발생 가능: AtomicInteger는 내부적으로 int 값을 객체로 감싸기 때문에, 박싱/언박싱 오버헤드가 발생할 수 있다. 하지만 대부분의 경우 성능에 큰 영향을 미치지 않는다.
마무리
지금까지 자바 람다 표현식에서 발생하는 "local variables referenced from a lambda expression must be final or effectively final" 에러의 원인과 해결 방법 세 가지를 살펴보았다.
람다 표현식은 자바 코드를 간결하고 효율적으로 작성하는 데 유용하지만, 외부 변수를 사용할 때 주의해야 할 점이 있다. 특히 람다 캡처링과 변수 범위를 이해하고, final 또는 effectively final 조건을 준수해야 한다.
이번 포스트에서 소개한 세 가지 해결 방법은 각각 장단점을 가지고 있으므로, 상황에 맞는 방법을 선택하여 적용하면 된다. 특히 멀티 스레드 환경에서는 AtomicInteger와 같은 Atomic 클래스들을 사용하는 것이 가장 안전하고 효율적인 방법이다.
이 글이 람다 표현식을 사용하는 데 어려움을 겪는 개발자들에게 도움이 되었기를 바란다.
댓글 0
로그인이 필요합니다.
로그인