Java 람다(Lambda) 표현식 완전 정리
Java 8에서 도입된 람다(Lambda) 표현식은 Java를 함수형 프로그래밍 언어로 진화시킨 핵심 기능입니다. 단순한 문법 설탕(syntactic sugar)처럼 보이지만, 그 내부 동작 원리부터 실전 활용까지 깊이 있게 이해해야 제대로 쓸 수 있습니다.
1. 람다란? 왜 필요한가?
람다 이전의 세계
Java 8 이전에는 동작(behavior)을 파라미터로 전달하려면 익명 클래스(anonymous class)를 사용해야 했습니다.
// Java 8 이전 — 익명 클래스로 동작 전달
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});
이 코드의 문제점은 명확합니다. 실제로 하고 싶은 일은 a.compareTo(b) 한 줄인데, 그것을 감싸는 보일러플레이트(boilerplate) 코드가 6줄이나 됩니다.
람다로 개선
// Java 8 이후 — 람다 표현식
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names, (a, b) -> a.compareTo(b));
// 더 나아가 메서드 레퍼런스로
Collections.sort(names, String::compareTo);
람다가 필요한 이유
람다는 동작 파라미터화(behavior parameterization) 패턴을 간결하게 표현하기 위해 도입되었습니다.
핵심 아이디어:
"무엇을 할지(what)"를 "어떻게 할지(how)"와 분리하여,
동작 자체를 값처럼 다룬다.
// 전략 패턴을 람다로 — 검증 로직을 동적으로 교체
public static List<String> filter(List<String> list, Predicate<String> condition) {
List<String> result = new ArrayList<>();
for (String s : list) {
if (condition.test(s)) {
result.add(s);
}
}
return result;
}
// 호출 시 동작을 주입
List<String> longNames = filter(names, name -> name.length() > 5);
List<String> aNames = filter(names, name -> name.startsWith("A"));
2. 함수형 인터페이스 (@FunctionalInterface)
정의
람다 표현식은 함수형 인터페이스(functional interface) 의 인스턴스입니다. 함수형 인터페이스란 추상 메서드가 정확히 하나인 인터페이스입니다.
@FunctionalInterface
public interface Runnable {
void run(); // 추상 메서드 1개
}
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2); // 추상 메서드 1개
// equals()는 Object의 메서드이므로 제외
// default 메서드는 제외
}
@FunctionalInterface 어노테이션
이 어노테이션은 컴파일러에게 “이 인터페이스는 함수형 인터페이스여야 한다”고 알립니다. 추상 메서드가 2개 이상이면 컴파일 에러가 발생합니다.
@FunctionalInterface
public interface StringProcessor {
String process(String input);
// default 메서드는 허용 (추상 메서드 아님)
default StringProcessor andThen(StringProcessor after) {
return s -> after.process(this.process(s));
}
// static 메서드도 허용
static StringProcessor identity() {
return s -> s;
}
// Object의 메서드 오버라이드도 허용
@Override
String toString(); // 이건 추상 메서드로 카운트되지 않음
}
직접 만드는 함수형 인터페이스
// 예외를 던지는 함수형 인터페이스 — 표준 라이브러리에 없어서 자주 직접 만듦
@FunctionalInterface
public interface ThrowingSupplier<T> {
T get() throws Exception;
}
// 사용
ThrowingSupplier<Connection> connSupplier = () -> DriverManager.getConnection(url);
3. 람다 문법
기본 구조
(파라미터) -> { 바디 }
↑ ↑
파라미터 목록 실행할 코드
문법 변형
// 1. 파라미터 없음
Runnable r = () -> System.out.println("Hello");
// 2. 파라미터 1개 — 괄호 생략 가능
Consumer<String> c = s -> System.out.println(s);
Consumer<String> c2 = (s) -> System.out.println(s); // 동일
// 3. 파라미터 2개 이상 — 괄호 필수
Comparator<String> comp = (a, b) -> a.compareTo(b);
// 4. 타입 명시 (선택)
Comparator<String> comp2 = (String a, String b) -> a.compareTo(b);
// 5. 바디가 단일 표현식 — 중괄호, return, 세미콜론 생략
Function<Integer, Integer> square = x -> x * x;
// 6. 바디가 여러 문장 — 중괄호 필수, return 명시
Function<Integer, Integer> process = x -> {
int doubled = x * 2;
int shifted = doubled + 1;
return shifted;
};
// 7. void 반환 — 단일 표현식이어도 중괄호 없이 가능
Consumer<String> printer = s -> System.out.println(s);
// 8. 예외 처리 — 체크 예외는 선언 필요
@FunctionalInterface
interface IOAction {
void perform() throws IOException;
}
IOAction readFile = () -> new FileReader("test.txt").read();
4. 타입 추론
람다의 타입은 대입되는 컨텍스트(target type) 에서 추론됩니다.
// 컴파일러가 Comparator<String>임을 알아서 T=String으로 추론
Comparator<String> comp = (a, b) -> a.compareTo(b);
// ↑ ↑
// String으로 자동 추론
// 메서드 파라미터에서 추론
List<String> names = Arrays.asList("B", "A", "C");
names.sort((a, b) -> a.compareTo(b));
// sort(Comparator<? super String>) 시그니처에서 추론
// 제네릭 메서드에서 추론
<T> T firstOrDefault(List<T> list, Supplier<T> defaultSupplier) {
return list.isEmpty() ? defaultSupplier.get() : list.get(0);
}
String result = firstOrDefault(names, () -> "default");
// ↑ Supplier<String>으로 추론
타입 추론이 실패하는 경우
// 모호한 경우 — 명시적 캐스팅 또는 타입 지정 필요
Object o = (Runnable) () -> System.out.println("hi"); // OK
Object o2 = () -> System.out.println("hi"); // 컴파일 에러: target type 불명확
5. 변수 캡처와 effectively final 제약
람다는 외부 스코프의 변수를 캡처(capture) 할 수 있습니다. 단, 중요한 제약이 있습니다.
effectively final 규칙
// OK — final 변수
final int threshold = 10;
Predicate<Integer> p = x -> x > threshold;
// OK — effectively final (변경되지 않으면 final과 동일 취급)
int threshold2 = 10;
Predicate<Integer> p2 = x -> x > threshold2;
// threshold2를 이후에 변경하면 컴파일 에러 발생
// 컴파일 에러 — 변경된 변수는 캡처 불가
int count = 0;
Runnable r = () -> System.out.println(count); // count가 effectively final이면 OK
count++; // 이 줄이 있으면 위의 람다도 컴파일 에러
왜 effectively final 제약이 있는가?
스택 변수의 생명주기 문제:
메서드 스택 프레임:
┌────────────────────┐
│ count = 0 │ ← 메서드가 끝나면 사라짐
│ 람다 (Runnable r) │ ← 람다는 힙에서 더 오래 살 수 있음
└────────────────────┘
↓
람다가 스택 변수를 직접 참조하면 메서드 종료 후 댕글링 참조 발생
→ 해결책: 람다 생성 시점의 값을 복사(copy-by-value)
→ 복사 후 원본이 바뀌면 불일치 → 혼란 방지를 위해 변경 금지
우회 방법 — 변경 가능한 컨테이너 사용
// 1. 배열로 우회 (권장하지 않음 — 코드 의도 불명확)
int[] counter = {0};
Runnable r = () -> counter[0]++;
// 2. AtomicInteger 사용 (스레드 안전)
AtomicInteger atomicCounter = new AtomicInteger(0);
Runnable r2 = () -> atomicCounter.incrementAndGet();
// 3. 상태를 가진 클래스로 캡슐화
class Counter {
int value = 0;
}
Counter c = new Counter();
Runnable r3 = () -> c.value++;
// c 자체는 effectively final (재할당 안 함), c.value는 가변
인스턴스 변수와 정적 변수는 자유롭게 캡처
public class LambdaCapture {
private int instanceVar = 100;
private static int staticVar = 200;
public Runnable createLambda() {
// 인스턴스 변수 — this를 통해 접근, 제약 없음
return () -> System.out.println(instanceVar++); // OK
// 정적 변수 — 제약 없음
// return () -> System.out.println(staticVar++); // OK
}
}
6. 메서드 레퍼런스 (Method Reference)
메서드 레퍼런스는 이미 이름이 있는 메서드를 람다 대신 참조하는 간결한 문법입니다.
람다: x -> SomeClass.someMethod(x)
메서드 레퍼런스: SomeClass::someMethod
6.1 정적 메서드 참조 (Class::staticMethod)
// 람다
Function<String, Integer> parser = s -> Integer.parseInt(s);
// 메서드 레퍼런스
Function<String, Integer> parser2 = Integer::parseInt;
// 활용
List<String> numberStrings = Arrays.asList("1", "2", "3");
List<Integer> numbers = numberStrings.stream()
.map(Integer::parseInt)
.collect(Collectors.toList());
// 다른 예
Consumer<Object> printer = System.out::println; // 인스턴스 메서드 참조이기도 함
Comparator<String> comp = String::compareTo;
6.2 특정 인스턴스의 메서드 참조 (instance::method)
String prefix = "Hello, ";
// 람다
Function<String, String> greeter = name -> prefix.concat(name);
// 메서드 레퍼런스 — 특정 인스턴스(prefix)의 메서드
Function<String, String> greeter2 = prefix::concat;
// PrintStream 인스턴스의 println 참조
Consumer<String> consolePrinter = System.out::println;
// ↑ System.out이 특정 인스턴스
List<String> list = Arrays.asList("A", "B", "C");
list.forEach(System.out::println);
6.3 임의 인스턴스의 메서드 참조 (Class::instanceMethod)
파라미터로 들어오는 인스턴스의 메서드를 참조합니다.
// 람다
Function<String, String> toUpper = s -> s.toUpperCase();
// 메서드 레퍼런스 — String 타입의 어떤 인스턴스든 toUpperCase() 호출
Function<String, String> toUpper2 = String::toUpperCase;
// BiFunction으로 두 파라미터 중 첫 번째가 수신자
BiFunction<String, String, Boolean> startsWith = String::startsWith;
// 동일한 람다: (str, prefix) -> str.startsWith(prefix)
boolean result = startsWith.apply("Hello", "He"); // true
// Comparator에서 자주 사용
List<String> words = Arrays.asList("banana", "apple", "cherry");
words.sort(String::compareToIgnoreCase);
6.4 생성자 참조 (Class::new)
// 람다
Supplier<ArrayList<String>> listMaker = () -> new ArrayList<>();
// 생성자 참조
Supplier<ArrayList<String>> listMaker2 = ArrayList::new;
// 파라미터가 있는 생성자
Function<String, StringBuilder> sbMaker = StringBuilder::new;
StringBuilder sb = sbMaker.apply("initial");
// 배열 생성자
IntFunction<int[]> arrayMaker = int[]::new;
int[] arr = arrayMaker.apply(10); // new int[10]
// 실전 — Stream.toArray()에서 사용
String[] nameArr = names.stream().toArray(String[]::new);
메서드 레퍼런스 선택 기준
┌─────────────────────────────────────────────────────────┐
│ 종류 │ 문법 │ 예시 │
├─────────────────────────────────────────────────────────┤
│ 정적 메서드 참조 │ Class::method │ Integer::parseInt
│ 특정 인스턴스 참조 │ obj::method │ System.out::println
│ 임의 인스턴스 참조 │ Class::method │ String::toUpperCase
│ 생성자 참조 │ Class::new │ ArrayList::new
└─────────────────────────────────────────────────────────┘
주의: 정적 참조와 임의 인스턴스 참조는 문법이 동일하게 보이지만
컴파일러가 시그니처로 구분합니다.
7. java.util.function 패키지 핵심 인터페이스
Java 8은 자주 쓰이는 함수형 인터페이스를 java.util.function 패키지로 제공합니다.
7.1 Function<T, R>
T를 받아 R을 반환합니다.
Function<String, Integer> length = String::length;
Function<Integer, String> intToStr = Object::toString;
// andThen — 두 함수를 합성: f.andThen(g) = g(f(x))
Function<String, String> process = length.andThen(intToStr);
String result = process.apply("hello"); // "5"
// compose — andThen의 역순: f.compose(g) = f(g(x))
Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, Integer> plus3 = x -> x + 3;
Function<Integer, Integer> times2ThenPlus3 = plus3.compose(times2);
// times2ThenPlus3.apply(4) = plus3(times2(4)) = plus3(8) = 11
// identity — 입력을 그대로 반환
Function<String, String> id = Function.identity(); // s -> s
BiFunction<T, U, R>
두 개의 파라미터를 받아 R을 반환합니다.
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
String result = repeat.apply("ha", 3); // "hahaha"
// andThen만 지원 (compose 없음)
BiFunction<String, Integer, Integer> lengthAfterRepeat =
repeat.andThen(String::length);
7.2 Consumer
T를 받아 아무것도 반환하지 않습니다 (소비).
Consumer<String> printer = System.out::println;
Consumer<List<String>> listClearer = List::clear;
// andThen — 두 Consumer를 순서대로 실행
Consumer<String> printAndLog = printer.andThen(s -> log(s));
printAndLog.accept("Hello"); // 출력 후 로깅
// forEach에서 자주 사용
List<String> names = Arrays.asList("Alice", "Bob");
names.forEach(System.out::println);
BiConsumer<T, U>
BiConsumer<String, Integer> indexPrinter = (s, i) ->
System.out.printf("[%d] %s%n", i, s);
// Map.forEach에서 유용
Map<String, Integer> scores = Map.of("Alice", 90, "Bob", 85);
scores.forEach((name, score) -> System.out.println(name + ": " + score));
7.3 Supplier
아무것도 받지 않고 T를 반환합니다 (생산).
Supplier<String> greeting = () -> "Hello, World!";
Supplier<List<String>> listFactory = ArrayList::new;
Supplier<LocalDate> today = LocalDate::now;
// 지연 계산(lazy evaluation)에 유용
public <T> T getOrCompute(T cached, Supplier<T> expensive) {
return cached != null ? cached : expensive.get();
}
// Optional과 함께
String value = Optional.ofNullable(null)
.orElseGet(() -> "computed default"); // Supplier 사용
7.4 Predicate
T를 받아 boolean을 반환합니다.
Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNotEmpty = isEmpty.negate(); // 부정
Predicate<String> startsA = s -> s.startsWith("A");
Predicate<String> longName = s -> s.length() > 5;
// and, or 조합
Predicate<String> startsAAndLong = startsA.and(longName);
Predicate<String> startsAOrEmpty = startsA.or(isEmpty);
// 필터링에서 자주 사용
List<String> names = Arrays.asList("Alice", "Bob", "Alexander", "");
List<String> filtered = names.stream()
.filter(startsAAndLong)
.collect(Collectors.toList()); // ["Alexander"]
// isEqual — 특정 값과 동등한지 검사
Predicate<String> isAlice = Predicate.isEqual("Alice");
// BiPredicate
BiPredicate<String, String> startsWith = String::startsWith;
boolean result = startsWith.test("Hello", "He"); // true
7.5 UnaryOperator, BinaryOperator
입출력 타입이 동일한 Function/BiFunction의 특수화입니다.
// UnaryOperator<T> extends Function<T, T>
UnaryOperator<String> trim = String::trim;
UnaryOperator<Integer> negate = x -> -x;
UnaryOperator<String> identity = UnaryOperator.identity();
// List.replaceAll에서 사용
List<String> words = new ArrayList<>(Arrays.asList(" hello ", " world "));
words.replaceAll(String::trim); // ["hello", "world"]
// BinaryOperator<T> extends BiFunction<T, T, T>
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<Integer> max = Integer::max;
BinaryOperator<String> concat = String::concat;
// reduce에서 사용
int sum = IntStream.rangeClosed(1, 10)
.reduce(0, Integer::sum); // 55
기본형 특수화 인터페이스
박싱/언박싱 오버헤드를 줄이기 위한 특수화 버전입니다.
// IntFunction<R>, LongFunction<R>, DoubleFunction<R>
IntFunction<String> intToStr = i -> String.valueOf(i);
// ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T>
ToIntFunction<String> strLen = String::length;
// IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator
IntUnaryOperator doubler = x -> x * 2;
// IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
IntBinaryOperator add = (a, b) -> a + b;
// IntConsumer, LongConsumer, DoubleConsumer
IntConsumer printInt = System.out::println;
// IntSupplier, LongSupplier, DoubleSupplier
IntSupplier random = () -> (int)(Math.random() * 100);
// IntPredicate, LongPredicate, DoublePredicate
IntPredicate isPositive = x -> x > 0;
8. 람다 합성과 조합
Function 합성
Function<String, String> trim = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, String> exclaim = s -> s + "!";
// andThen: 왼쪽 → 오른쪽
Function<String, String> normalize = trim.andThen(lower).andThen(exclaim);
normalize.apply(" Hello "); // "hello!"
// compose: 오른쪽 → 왼쪽 (수학적 함수 합성 순서)
Function<String, String> normalize2 = exclaim.compose(lower).compose(trim);
// trim → lower → exclaim (compose는 역순이므로)
normalize2.apply(" Hello "); // "hello!"
Predicate 조합
Predicate<Integer> isPositive = x -> x > 0;
Predicate<Integer> isEven = x -> x % 2 == 0;
Predicate<Integer> isSmall = x -> x < 100;
// and — 모두 만족
Predicate<Integer> positiveEven = isPositive.and(isEven);
// or — 하나 이상 만족
Predicate<Integer> positiveOrSmall = isPositive.or(isSmall);
// negate — 부정
Predicate<Integer> isNegativeOrZero = isPositive.negate();
// 복잡한 조합
Predicate<Integer> complex = isPositive.and(isEven).and(isSmall.negate());
// 양수이고 짝수이고 100 이상인 수
Consumer 체이닝
Consumer<String> log = s -> System.out.println("[LOG] " + s);
Consumer<String> audit = s -> auditService.record(s);
Consumer<String> notify = s -> emailService.send(s);
// andThen으로 체이닝 — 순서대로 실행
Consumer<String> fullPipeline = log.andThen(audit).andThen(notify);
fullPipeline.accept("User login event");
9. 람다 vs 익명 클래스 차이
this의 의미 차이
public class ThisExample {
private String name = "outer";
public void demonstrate() {
// 익명 클래스 — this는 익명 클래스 인스턴스를 가리킴
Runnable anon = new Runnable() {
@Override
public void run() {
System.out.println(this.getClass().getSimpleName());
// 출력: ThisExample$1 (익명 클래스)
}
};
// 람다 — this는 람다를 감싸는 클래스(ThisExample)를 가리킴
Runnable lambda = () -> {
System.out.println(this.name);
// 출력: outer (ThisExample의 name 필드)
// this는 ThisExample 인스턴스를 참조
};
}
}
새 스코프 생성 여부
public void scopeExample() {
int x = 10;
// 익명 클래스 — 새로운 스코프 생성
Runnable anon = new Runnable() {
int x = 20; // OK — 외부 x와 다른 스코프
@Override
public void run() {
System.out.println(x); // 20 (내부 x)
}
};
// 람다 — 스코프 생성 안 함
Runnable lambda = () -> {
// int x = 20; // 컴파일 에러: 이미 x가 정의된 스코프
System.out.println(x); // 10 (외부 x)
};
}
직렬화(Serialization)
// 람다 직렬화는 구현에 따라 다르며 권장하지 않음
// Serializable을 구현하는 함수형 인터페이스에는 가능하지만 이식성 문제 있음
// 안전하게 하려면 명명된 클래스 사용 권장
바이트코드 차이
익명 클래스:
- 별도의 .class 파일 생성 (Outer$1.class)
- 클래스 로딩 비용 발생
- new 연산자로 힙에 인스턴스 생성
람다:
- 별도 .class 파일 없음 (원칙적으로)
- invokedynamic 명령어 사용
- 첫 호출 시 LambdaMetafactory가 런타임에 구현 생성
- 이후 호출은 캐시된 구현 재사용 (경우에 따라)
10. 람다의 내부 구현 — invokedynamic과 LambdaMetafactory
invokedynamic 명령어
Java 7에서 도입된 invokedynamic은 런타임에 메서드 연결을 결정할 수 있는 JVM 명령어입니다. 람다는 이를 활용합니다.
람다 바이트코드 처리 흐름:
1. 컴파일 시
┌─────────────────────────────────────────────┐
│ Runnable r = () -> System.out.println("hi") │
│ ↓ │
│ invokedynamic #1 (부트스트랩 메서드 참조) │
│ 람다 바디는 private static 메서드로 추출 │
└─────────────────────────────────────────────┘
2. 최초 실행 시
┌──────────────────────────────────────────────────────┐
│ invokedynamic 실행 │
│ → LambdaMetafactory.metafactory() 호출 │
│ → 런타임에 Runnable 구현 클래스를 동적 생성 │
│ → CallSite 객체 캐시 (이후 호출은 직접 사용) │
└──────────────────────────────────────────────────────┘
3. 이후 실행
┌──────────────────────────────────────────────────────┐
│ 캐시된 CallSite → 생성된 구현 직접 호출 │
│ (클래스 로딩 없이 직접 디스패치) │
└──────────────────────────────────────────────────────┘
LambdaMetafactory
// 이것이 내부적으로 호출되는 것 (직접 호출하지 않음)
// java.lang.invoke.LambdaMetafactory.metafactory(
// MethodHandles.Lookup caller, // 호출자 컨텍스트
// String invokedName, // 구현할 메서드 이름 ("run")
// MethodType invokedType, // 팩토리 시그니처
// MethodType samMethodType, // 함수형 인터페이스 메서드 타입
// MethodHandle implMethod, // 람다 바디 메서드 핸들
// MethodType instantiatedMethodType // 특수화된 타입
// )
캡처링 람다 vs 비캡처링 람다
// 비캡처링 람다 (non-capturing) — 외부 변수를 캡처하지 않음
// → 매번 동일한 인스턴스 재사용 가능 (JVM 최적화)
Runnable r1 = () -> System.out.println("hello");
Runnable r2 = () -> System.out.println("hello");
// JVM에 따라 r1 == r2일 수 있음 (동일 인스턴스)
// 캡처링 람다 (capturing) — 외부 변수를 캡처
String message = "hello";
Runnable r3 = () -> System.out.println(message);
// 캡처된 값을 저장하는 새 인스턴스 생성 필요
// r3마다 다른 인스턴스
실제 성능 영향
// 주의: 루프 내에서 람다 생성 — 캡처링이면 객체 생성 발생
for (int i = 0; i < 1000000; i++) {
int captured = i;
Runnable r = () -> System.out.println(captured); // 매번 새 객체
executor.submit(r);
}
// 비캡처링으로 개선하면 재사용 가능
Runnable constant = () -> System.out.println("done"); // 재사용
for (int i = 0; i < 1000000; i++) {
executor.submit(constant); // 동일 객체 재사용
}
정리 요약
람다 표현식 핵심 포인트:
1. 함수형 인터페이스의 인스턴스 — 추상 메서드 1개인 인터페이스
2. 타입 추론 — 대입 컨텍스트에서 타입 결정
3. effectively final — 캡처한 지역 변수는 변경 불가
4. this — 람다 안의 this는 감싸는 클래스를 가리킴
5. 메서드 레퍼런스 — 이름 있는 메서드를 람다로 참조
6. java.util.function — 43개의 표준 함수형 인터페이스
7. 합성 — andThen, compose, and, or, negate로 조합
8. 내부 구현 — invokedynamic + LambdaMetafactory
성능 고려사항:
- 비캡처링 람다는 최적화(재사용)될 수 있음
- 캡처링 람다는 매번 새 인스턴스 생성 가능
- 기본형 특수화 인터페이스로 박싱 오버헤드 제거 가능