람다가 사실 익명 클래스가 아닌 이유 — 함수형 프로그래밍과 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::length는Function<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("익명 클래스");
}
};
r1과 r2의 바이트코드가 다르다:
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는 외부 인스턴스를 가리킨다. 익명 클래스와의 핵심 차이. 이 때문에 람다에서는 외부 클래스의 멤버에 자연스럽게 접근할 수 있다.
생각해 볼 문제
Runnable r = () -> {}와Runnable r = () -> {}가 같은 인스턴스를 참조하는가?==로 비교해 보자. (stateless 람다의 싱글톤 최적화)- 직렬화 가능한 람다(
Serializable를 구현한 함수형 인터페이스)의 위험성은 무엇인가? - 람다가 캡처한 변수의 값이 런타임에 바뀌면 어떻게 되는가? (컴파일러가 어떻게 막는가?)
var r = (Runnable) () -> {};에서var없이(Runnable) () -> {}를 단독으로 쓸 수 없는 이유는?IntFunction<int[]>vsFunction<Integer, int[]>의 성능 차이를 박싱 관점에서 설명하라.
참고
- JLS §15.27 - Lambda Expressions - 접근 2026-07-10
- JLS §9.8 - Functional Interfaces - 접근 2026-07-10
- JLS §15.13 - Method Reference Expressions - 접근 2026-07-10
- java.util.function 패키지 - 접근 2026-07-10
- JEP-335: Lambda and InvokeDynamic - 접근 2026-07-10
'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 |
