람다가 사실 익명 클래스가 아닌 이유 — 함수형 프로그래밍과 invokedynamic

(x, y) -> x + y를 컴파일하면 익명 내부 클래스가 생성될까? 대부분의 Java 개발자는 그렇게 생각한다. 틀렸다. Java 8의 람다는 invokedynamic 바이트코드로 컴파일된다 — 런타임에 JVM이 람다 인스턴스를 생성하는 방법을 결정한다. (JEP-335, JLS §15.27)

이 차이가 왜 중요한가? 익명 클래스는 매번 새 .class 파일을 만들고, 인스턴스를 생성할 때마다 객체를 할당한다. invokedynamic 방식은 JVM이 상황에 따라 인스턴스를 재사용하거나 동적으로 생성할 수 있어 더 효율적이다. 이 글은 람다가 실제로 어떻게 실행되는지, 메서드 참조로 코드를 어떻게 줄이는지, 그리고 "effectively final"이 왜 필요한지 풀어간다.

함수형 인터페이스 — 람다의 타겟 타입

람다식은 단독으로 존재할 수 없다 — 함수형 인터페이스(abstract 메서드가 정확히 하나인 인터페이스)를 타겟으로 한다. (JLS §9.8)

// Java 25 — 함수형 인터페이스
@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);   // 단 하나의 abstract 메서드
    // default, static, private 메서드는 여러 개 OK
}

// 람다로 인스턴스 생성
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

java.util.function 패키지 — 표준 함수형 인터페이스

매번 함수형 인터페이스를 만들 필요 없이, 표준 패키지에서 제공하는 것을 쓴다:

인터페이스 시그니처 용도
Function<T,R> R apply(T) T → R 변환 String::length
Predicate<T> boolean test(T) T → boolean 검사 s -> s.isEmpty()
Consumer<T> void accept(T) T 소비(부작용) System.out::println
Supplier<T> T get() T 공급(지연 생성) () -> new ArrayList<>()
BiFunction<T,U,R> R apply(T,U) T, U → R (a, b) -> a + b
UnaryOperator<T> T apply(T) T → T (Function 특수형) x -> -x
BinaryOperator<T> T apply(T,T) T, T → T (a, b) -> a + b

기본형 특화 버전도 있다: IntFunction<R>, ToIntFunction<T>, IntPredicate 등. 박싱/언박싱 오버헤드를 피할 수 있다. (int x) -> x * 2 대신 IntUnaryOperator를 쓰면 박싱이 발생하지 않는다.

기본형 특화 함수형 인터페이스 — 박싱 회피

제네릭 람다 Function<Integer, Integer>는 매번 Integer 객체를 생성/해제하는 박싱 비용이 든다. 이를 피하기 위해 Java는 기본형 특화 버전을 제공한다:

// Java 25 — 박싱 비교
// 제네릭 (박싱 발생)
Function<Integer, Integer> square = x -> x * x;
// int → Integer(박싱) → 연산 → Integer → int(언박싱)

// 기본형 특화 (박싱 없음)
IntUnaryOperator fastSquare = x -> x * x;
// int → 연산 → int (객체 생성 없음)
제네릭 기본형 특화 시그니처
Function<Integer, R> IntFunction<R> R apply(int)
Function<T, Integer> ToIntFunction<T> int applyAsInt(T)
Function<Integer, Integer> IntUnaryOperator int applyAsInt(int)
Predicate<Integer> IntPredicate boolean test(int)
Consumer<Integer> IntConsumer void accept(int)

Stream API(IntStream, LongStream, DoubleStream)도 같은 원리로 박싱을 피한다. 성능이 중요한 수치 연산에서는 IntStream.range(0, 1000).map(x -> x * 2)Stream<Integer>보다 유의미하게 빠르다.

람다식 문법

// Java 25
// 기본: (매개변수) -> { 본문 }
Comparator<String> c1 = (String a, String b) -> { return a.compareTo(b); };

// 타입 추론 (매개변수 타입 생략)
Comparator<String> c2 = (a, b) -> a.compareTo(b);

// 매개변수 1개: 괄호 생략 가능
Predicate<String> isEmpty = s -> s.isEmpty();

// 본문이 단일 식: 중괄호 + return 생략
Function<String, Integer> len = s -> s.length();

// 본문이 여러 줄: 중괄호 + return 필요
Function<String, String> upper = s -> {
    if (s == null) return "";
    return s.toUpperCase();
};

// 매개변수 없음
Supplier<Double> random = () -> Math.random();

메서드 참조(method reference) — 람다의 축약형

이미 존재하는 메서드를 람다처럼 사용할 수 있다:

// Java 25
// 람다                    → 메서드 참조
s -> System.out.println(s) → System.out::println
s -> s.length()            → String::length
s -> s.isEmpty()           → String::isEmpty
(a, b) -> a.compareTo(b)   → String::compareTo
() -> new ArrayList<>()    → ArrayList::new       (생성자 참조)

네 가지 형태:

형태 동등한 람다
정적 메서드 Integer::parseInt s -> Integer.parseInt(s)
인스턴스 메서드 (객체 지정) System.out::println s -> System.out.println(s)
인스턴스 메서드 (클래스 지정) String::length s -> s.length() (첫 매개변수가 수신자)
생성자 ArrayList::new () -> new ArrayList<>()

세 번째 형태(클래스::인스턴스메서드)가 직관적이지 않다. String::lengthFunction<String, Integer>로 해석된다 — 첫 번째 매개변수(String)가 메서드 수신자(length()this)가 된다.

람다 캡처(capture) — 외부 변수 사용과 effectively final

람다는 자신을 둘러싼 스코프의 지역 변수를 참조할 수 있다. 단, 그 변수는 effectively final이어야 한다 — final로 선언하지 않아도 값이 변경되지 않으면 된다. (JLS §15.27.2)

// Java 25 — effectively final (OK)
String prefix = "User: ";
Function<String, String> greeter = name -> prefix + name;

// Java 25 — 값이 변경되면 캡처 불가 (컴파일 에러)
int count = 0;
// Runnable r = () -> count++;   // 컴파일 에러: count는 effectively final이 아님
count = 5;

왜 effectively final인가 — 캡처의 구현 방식

람다가 지역 변수를 캡처할 때, 변수 자체가 아니라 값의 복사본을 캡처한다. (JLS §15.27.2) 복사본을 만들 때 값이 변경되면 일관성이 깨지므로, 변경하지 않는다는 보장(effectively final)이 필요하다.

flowchart LR
    SCOPE["외부 스코프<br/>int x = 42 (effectively final)"] -->|값 복사| LAMBDA["람다 인스턴스<br/>캡처된 x = 42"]
    SCOPE -.->|"x = 99 (변경 시도)"| ERROR["컴파일 에러!<br/>effectively final 위반"]

인스턴스 필드와 static 필드는 이 제약이 없다 — 힙에 존재하므로 복사가 아닌 참조로 접근한다. 지역 변수만 스택에 있어 복사가 필요하다.

람다에서 mutable 변수를 다루는 법

// Java 25 — AtomicReference로 람다 안에서 값 변경
import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger counter = new AtomicInteger(0);
Runnable increment = () -> counter.incrementAndGet();   // OK — 객체 참조는 effectively final
increment.run();
increment.run();
System.out.println(counter.get());   // 2

배열(int[] counter = { 0 })이나 AtomicInteger를 쓰면 람다 안에서 값을 변경할 수 있다 — 참조 자체는 변경하지 않고 객체 내부 상태만 변경하므로 effectively final 제약을 우회한다. 다만 남용하면 코드가 불명확해지므로, 상태가 필요하면 별도 클래스를 만드는 것이 더 깔끔하다.

람다는 익명 클래스가 아니다 — invokedynamic

// Java 25
Runnable r1 = () -> System.out.println("람다");
Runnable r2 = new Runnable() {     // 익명 클래스
    @Override public void run() {
        System.out.println("익명 클래스");
    }
};

r1r2의 바이트코드가 다르다:

javap -c -p LambdaDemo.class
// 람다
0: invokedynamic #2  // InvokeDynamic #0:run:()Ljava/lang/Runnable;

// 익명 클래스
0: new           #3  // class LambdaDemo$1
3: dup
4: invokespecial #4  // Method LambdaDemo$1."<init>":()V

람다는 invokedynamic으로 컴파일된다 — 런타임에 JVM이 LambdaMetafactory를 호출하여 람다 인스턴스를 생성한다. (JEP-335) 익명 클래스와의 차이:

항목 람다 (invokedynamic) 익명 클래스
.class 파일 생성 안 됨 (동적) 별도 .class 생성
인스턴스 JVM이 재사용 결정 (stateless 람다 → 싱글톤) 매번 new
성능 더 나음 (JIT 최적화 + 인스턴스 재사용) 객체 생성 오버헤드
this 외부 인스턴스this 익명 클래스 자신의 this

람다에서 this는 람다를 둘러싼 클래스의 인스턴스를 가리킨다. 익명 클래스에서 this는 익명 클래스 자신이다. 이 차이가 람다와 익명 클래스의 동작 차이를 만든다.

함수 합성(composition) — 함수형 프로그래밍의 조립

함수형 인터페이스는 합성을 위한 default 메서드를 제공한다:

// Java 25 — Function 합성
Function<String, Integer> parseInt = Integer::parseInt;
Function<Integer, String> toBinary = Integer::toBinaryString;

// andThen: f.andThen(g) = g(f(x))
Function<String, String> parseAndConvert = parseInt.andThen(toBinary);
System.out.println(parseAndConvert.apply("42"));   // "101010"

// compose: f.compose(g) = f(g(x))  (역순)
Function<String, String> compose = toBinary.compose(parseInt);
// 동일한 결과 but 순서가 반대로 해석됨

// Predicate 합성
Predicate<String> nonNull = Objects::nonNull;
Predicate<String> nonEmpty = s -> !s.isEmpty();
Predicate<String> valid = nonNull.and(nonEmpty);    // 두 조건의 AND
Predicate<String> invalid = valid.negate();          // NOT

Consumer 체이닝

// Java 25
Consumer<String> print = System.out::println;
Consumer<String> log = s -> System.err.println("[LOG] " + s);
Consumer<String> printAndLog = print.andThen(log);   // 순차 실행

printAndLog.accept("Hello");   // stdout: Hello, stderr: [LOG] Hello

함수 합성은 작은 함수를 조립하여 복잡한 동작을 만드는 함수형 프로그래밍의 핵심 기법이다. Stream API의 파이프라인(.filter().map().collect())이 이 원리의 확장이다. (장 13 상세)

실습 — 람다와 함수형 인터페이스 체감

// Java 25 — LambdaDemo.java
import java.util.*;
import java.util.function.*;

public class LambdaDemo {
    public static void main(String[] args) {
        List<String> names = List.of("Java", "Kotlin", "Scala", "Groovy");

        // Predicate로 필터
        names.stream()
            .filter(name -> name.length() <= 5)
            .forEach(System.out::println);

        System.out.println("---");

        // Function으로 변환
        names.stream()
            .map(String::toUpperCase)
            .forEach(System.out::println);

        System.out.println("---");

        // Comparator로 정렬
        List<String> sorted = new ArrayList<>(names);
        sorted.sort(Comparator.comparing(String::length));
        System.out.println(sorted);   // [Java, Scala, Kotlin, Groovy]

        // 메서드 참조의 네 가지 형태
        Consumer<String> printer = System.out::println;        // 인스턴스::메서드
        Function<String, Integer> len = String::length;        // 클래스::인스턴스메서드
        Function<String, Integer> parse = Integer::parseInt;   // 클래스::정적메서드
        Supplier<ArrayList<String>> factory = ArrayList::new;  // 생성자
    }
}
java LambdaDemo.java
Java
---
JAVA
Kotlin
SCALA
GROOVY
---
[Java, Scala, Kotlin, Groovy]

확인할 것: 람다와 메서드 참조로 컬렉션 조작이 선언적으로 표현된다. Comparator.comparing(String::length)는 메서드 참조로 정렬 기준을 만든다 — 별도 Comparator 구현 없이 한 줄로.

요약 — 이 글의 결론

  • 함수형 인터페이스(abstract 메서드 1개)가 람다의 타겟 타입이다. java.util.function 패키지의 표준 인터페이스(Function, Predicate, Consumer, Supplier)를 우선 사용한다. @FunctionalInterface로 실수를 방지한다.
  • 메서드 참조(::)는 람다의 축약형이다. 이미 존재하는 메서드를 참조할 때 람다보다 간결하다. 정적 메서드, 인스턴스 메서드, 생성자 모두 가능.
  • 람다는 외부 지역 변수를 캡처할 수 있지만, 그 변수는 effectively final이어야 한다. 람다는 값의 복사본을 캡처하므로, 변경되면 일관성이 깨진다.
  • 람다는 익명 클래스가 아니다. invokedynamic으로 컴파일되어, JVM이 런타임에 최적화된 인스턴스를 생성한다. 익명 클래스보다 가볍고 빠르다.
  • 람다의 this는 외부 인스턴스를 가리킨다. 익명 클래스와의 핵심 차이. 이 때문에 람다에서는 외부 클래스의 멤버에 자연스럽게 접근할 수 있다.

생각해 볼 문제

  1. Runnable r = () -> {}Runnable r = () -> {}가 같은 인스턴스를 참조하는가? ==로 비교해 보자. (stateless 람다의 싱글톤 최적화)
  2. 직렬화 가능한 람다(Serializable를 구현한 함수형 인터페이스)의 위험성은 무엇인가?
  3. 람다가 캡처한 변수의 값이 런타임에 바뀌면 어떻게 되는가? (컴파일러가 어떻게 막는가?)
  4. var r = (Runnable) () -> {};에서 var 없이 (Runnable) () -> {}를 단독으로 쓸 수 없는 이유는?
  5. IntFunction<int[]> vs Function<Integer, int[]>의 성능 차이를 박싱 관점에서 설명하라.

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 14. optional  (0) 2026.07.12
Java - 13. stream  (0) 2026.07.12
Java - 11. generics  (0) 2026.07.12
Java - 10. collections  (0) 2026.07.12
Java - 09. exception  (0) 2026.07.12