자바 람다와 지역 변수 이해하기(capturing lambda)

허진혁·2024년 3월 24일
0

기본기를 다지자

목록 보기
10/10

Java 8부터 람다 표현식(Lambda Expression)은 자바의 중요한 기능 중 하나로 자리 잡았어요. 람다를 사용함으로써 개발자는 보다 간결하고 명확한 코드를 작성할 수 있게 되었죠. 하지만 람다 표현식의 도입은 단지 문법적인 단순화를 넘어서, 함수형 프로그래밍 패러다임을 자바에 통합하는 데 중요한 역할을 했어요. 이 글에서는 람다의 기본 개념과 작동 방식, 특히 람다 표현식에서 지역변수를 사용하는 방법에 초점을 맞출거에요.

람다 표현식이란?

람다 표현식은 메서드를 하나의 식(expression)으로 표현한 것으로 이해하면 편해요. 기존에 이름이 있는 메서드혹은 익명 메서드를 대체할 수 있으며, 함수형 인터페이스를 구현하는 간단하지만 강력한 방법이에요. 함수형 인터페이스는 오직 하나의 추상 메서드를 가진 인터페이스로, 람다 표현식과 함께 사용 되요.

함수형 인터페이스

Function<T, R> 인터페이스는 자바에서 함수형 인터페이스 중 하나이며, 제공하는 T 타입의 인자를 받아 R 타입의 결과를 반환하는 하나의 추상 메서드 apply를 정의하고 있어요.

@FunctionalInterface
public interface Function<T, R> {

    /**
     * Applies this function to the given argument.
     *
     * @param t the function argument
     * @return the function result
     */
    R apply(T t);

		// ...
}

@FunctionalInterface는 컴파일러에게 함수형 인터페이스임을 알려주고, 다음 내용이 아니라면 오류 메시지를 생성합니다.

  • 해당 애노테이션의 target은 interface입니다. (class, enum, annotation → 오류 메시지 생성)
  • 기능적 인터페이스의 요구 사항을 충족합니다.

기능적 인터페이스의 요구사항을 충족한다는 것은 다음을 의미해요.

  • 인터페이스는 반드시 하나의 추상 메서드를 가져야 합니다. 이것이 바로 함수형 인터페이스의 핵심적인 특징입니다.
  • 기본 메서드(default 메서드)는 구현이 있기 때문에 추상 메서드로 카운트하지 않습니다.
  • java.lang.Object 클래스의 public 메서드를 추상 메서드로 오버라이딩한다 해도, 모든 인터페이스 구현체가 Object의 구현을 상속받기 때문에, 이것 역시 추상 메서드로 카운트하지 않습니다.

자바에서는 기본적으로 다양한 함수형 인터페이스를 제공하고 있으며, 여기에서 확인할 수 있습니다.

지역변수와 람다

람다 표현식 내에서 지역변수를 사용하는 것은 가능하지만, 몇 가지 제약 사항이 있어요. 람다에서 참조하는 지역변수는 final이거나 effectively final이어야 해요. 즉, 람다 표현식이 실행되는 동안 해당 변수의 값이 변경될 수 없어야 한다는 의미에요.

코드로 예시를 들어볼게요.

public static void main(String[] args) {
     int time = 1; // 지역변수 정의

     Runnable runnable = () -> {
         System.out.println(time); // 지역변수 출력
     };

     new Thread(runnable).start();
 }
    
 // 출력: 1

하지만 다음 코드 두 개는 컴파일 에러가 나요.

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

public static void main(String[] args) {
     int time = 1; // 지역변수 정의
     time *= 10; // 지역변수 값 변경

     Runnable runnable = () -> {
         System.out.println(time); // 지역변수 출력
     };

     new Thread(runnable).start();
}

이렇게 나중에 변경이 일어나도 컴파일 에러가 나요.(당연한 얘기)

public static void main(String[] args) {
     int time = 1; // 지역변수 정의

     Runnable runnable = () -> {
         System.out.println(time); // 지역변수 출력
     };

     time *= 10; // 지역변수 값 변경
     new Thread(runnable).start();
}

예외 내용을 해석하면 람다에서 지역 변수는 명시적으로 final 이거나 묵시적으로 final(effectively final) 이어야 해야 해요.

public static void main(String[] args) {
     final int time = 1; // 명시적 final
     int hour = 10; // 묵시적 final (이후에 hour 변경 내역 없다면)
}

정리하면, 람다에서의 지역 변수참조는 변경 가능성이 없어야 한다!

이러한 제약은 변수가 람다 표현식에 의해 캡처링되는 방식과 관련이 있어요.

캡처링 람다(Capturing Lambda)

람다 표현식에서 사용되는 지역변수는 ‘캡처링' 되어 람다 내부에서 사용되요. 캡처링 람다는 실행 시점에서 해당 변수의 값을 '복사' 하여 사용한다는 의미에요. 이러한 방식은 변수가 람다 표현식에 의해 안전하게 참조하게 만들어, 변수의 값이 변경될 가능성이 없기 때문에, 람다 표현식이 실행되는 동안 일관된 값을 유지할 있는 거에요.

위에서 예시로 들었던 함수를 메모리 상태를 그림으로 표현할게요.

저는 당연히 메모리 상태를 (그림 1)처럼 생각을 했어요.

하지만 캡처링 한다는 것은 다음 메모리 상태 (그림 2)와 같아요.

왜 그럴까요?

지역변수는 스택 메모리에 저장되는데, 람다 표현식이 실행될 때 원래의 변수가 존재하지 않게 되면 문제가 발생하기 때문이에요. 예를 들어, 각각의 쓰레드들은 자기 쓰레드 안에 스택을 갖고 있고, 그 스택에 지역변수를 저장해요. 각각의 스레드들은 다른 스레드가 언제 끝날지 모를 뿐더러, 다른 스레드에서 기존의 스택을 참조하려고 했을 때 그 스택이 이미 종료될 가능성이 있어요. 그럼 해당 값을 참조하지 못하게 되고, 만약 다른 참조 값을 읽어버린다면 예상하지 못한 동작이 될 것에요.

정리하자면, 자바에서 capturing lambda는 람다 실행 시점에 지역 변수 값을 캡쳐해서 지역변수 복사본을 어딘가에 저장하고 람다식 내에서는 복사본을 참조하는 거에요.

왜 이런 제약이 있을까?

이 제약의 주된 이유는 멀티스레드 환경에서의 안정성과 일관성을 보장하기 위함이에요. 따라서, 람다 표현식에 의해 참조되는 변수는 final 또는 effectively final이어야 하며, 이는 변수가 안전하게 '캡처링' 되어 사용될 수 있음을 보장해주는 거에요.

결론

람다 표현식은 자바 프로그래밍에 혁신적인 변화를 가져왔어요. 함수형 프로그래밍의 강력한 기능을 제공하면서도 자바의 타입 안정성, 멀티스레드 환경에서의 안전성을 보장도 해주고 있죠. 람다에서 지역변수를 사용할 때의 제약은 이러한 안정성을 유지하기 위한 중요한 요소이니 이러한 이해를 바탕으로, 람다 표현식을 효과적으로 활용하여 더 나은 코드를 작성해 보아요!

참고자료

[JAVA] 람다(lambda) 의 작동 방식과 지역변수 사용(capturing lambda)

profile
Don't ever say it's over if I'm breathing

0개의 댓글