공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 03 / 10

람다식과 함수형 인터페이스

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 익명 클래스에서 람다로 — 변천사

정렬 기준을 넘기는 코드가 어떻게 변해 왔는지 보면 람다의 목적이 보입니다.

java
// JDK 1.1 ~ 7: 익명 클래스(anonymous class). 6줄 중 실제 로직은 1줄
Collections.sort(orders, new Comparator<Order>() {
    @Override
    public int compare(Order a, Order b) {
        return Long.compare(a.amount(), b.amount());
    }
});

// JDK 8: 람다. 로직만 남음
orders.sort((a, b) -> Long.compare(a.amount(), b.amount()));

// JDK 8: 메서드 참조 + Comparator 유틸
orders.sort(Comparator.comparingLong(Order::amount));

익명 클래스의 문제는 의식(ceremony) 이 로직을 가린다는 점입니다. 클래스 이름, 메서드 시그니처, @Override, 중괄호 — 전부 컴파일러가 이미 알고 있는 정보입니다. 람다는 "컴파일러가 추론할 수 있는 것은 전부 생략"한 형태입니다.

2.2 람다 문법 변형

java
(int a, int b) -> { return a + b; }    // 완전한 형태: 타입 명시, 블록 본문
(a, b) -> { return a + b; }            // 타입 추론
(a, b) -> a + b                        // 표현식 본문: return 과 중괄호 생략
a -> a * 2                             // 파라미터 1개: 괄호 생략
() -> 42                               // 파라미터 없음
() -> { System.out.println("hi"); }    // void 반환 블록
() -> System.out.println("hi")         // void 표현식
(var a, var b) -> a + b                // JDK 11+: var 사용 (어노테이션 붙일 때 유용)
규칙 설명
파라미터 타입 전부 생략하거나 전부 명시. 섞으면 에러
괄호 파라미터가 정확히 1개이고 타입 생략 시에만 생략 가능
표현식 본문 결과가 반환값. void 인터페이스면 결과 버림
블록 본문 return 필수(반환 타입이 있으면), 여러 문장 가능

람다는 그 자체로는 타입이 없습니다. 대입되는 자리의 타입(target type, 목표 타입) 이 함수형 인터페이스일 때 그 인터페이스의 인스턴스로 해석됩니다. 같은 () -> 42 가 Supplier<Integer> 도, Callable<Integer> 도, IntSupplier 도 될 수 있습니다. 그래서 var f = () -> 42; 는 컴파일 에러입니다 — 목표 타입이 없으니까요.

2.3 함수형 인터페이스(Functional Interface)

추상 메서드가 정확히 하나인 인터페이스입니다. 람다는 그 하나의 추상 메서드를 구현하는 것으로 해석됩니다. 추상 메서드가 둘이면 어느 것을 구현하는지 모호하므로 람다를 쓸 수 없습니다.

java
@FunctionalInterface                      // 추상 메서드가 1개가 아니면 컴파일 에러 (실수 방지용, 선택)
public interface FeeCalculator {
    long calculate(long amount);          // 유일한 추상 메서드 (SAM: Single Abstract Method)

    default FeeCalculator plus(long fixed) {   // default 메서드는 개수 무관
        return amt -> calculate(amt) + fixed;
    }
    static FeeCalculator none() { return amt -> 0; }   // static 메서드도 무관
    // boolean equals(Object o);  ← Object 의 public 메서드 재선언은 세지 않음
}

Runnable, Comparator<T>, Callable<V>, ActionListener 처럼 JDK 8 이전부터 있던 SAM 인터페이스도 자동으로 함수형 인터페이스입니다. 어노테이션은 "의도 선언"일 뿐 필수가 아닙니다.

2.4 java.util.function 표준 인터페이스

매번 인터페이스를 만들지 않도록 JDK 가 43개를 제공합니다. 핵심 6개 + 변형만 외우면 됩니다.

인터페이스 시그니처 의미 대표 용도
Function<T, R> R apply(T t) T 를 R 로 변환 map(), DTO 변환
BiFunction<T, U, R> R apply(T t, U u) 두 입력 → 하나 Map.merge, 합치기
Supplier<T> T get() 입력 없이 생산 지연 생성, orElseGet, 팩토리
Consumer<T> void accept(T t) 소비, 반환 없음 forEach, 콜백, 로깅
BiConsumer<T, U> void accept(T t, U u) 두 개 소비 Map.forEach
Predicate<T> boolean test(T t) 조건 판정 filter, 검증
BiPredicate<T, U> boolean test(T t, U u) 두 입력 판정
UnaryOperator<T> T apply(T t) 같은 타입 변환 (Function<T,T>) replaceAll, 정규화
BinaryOperator<T> T apply(T a, T b) 같은 타입 둘 → 하나 (BiFunction<T,T,T>) reduce, 합계·최대

기본형 특화(primitive specialization): Function<Integer, Integer> 는 박싱/언박싱 비용이 듭니다. 이를 피하려고 int/long/double 전용 버전이 있습니다.

이름 패턴 예 시그니처
IntXxx (입력이 int) IntFunction<R>, IntPredicate, IntConsumer, IntUnaryOperator R apply(int), boolean test(int) ...
ToIntXxx (출력이 int) ToIntFunction<T>, ToIntBiFunction<T,U> int applyAsInt(T)
IntToXxx (int → 다른 기본형) IntToLongFunction, IntToDoubleFunction long applyAsLong(int)
XxxSupplier IntSupplier, BooleanSupplier int getAsInt()
ObjIntConsumer<T> void accept(T, int)

long, double 도 같은 패턴입니다. 스트림의 mapToInt(ToIntFunction), IntStream.map(IntUnaryOperator) 가 이것들을 받습니다.

2.5 메서드 참조(Method Reference) 4종

이미 있는 메서드를 람다 대신 :: 로 가리킵니다. 람다 본문이 "메서드 하나 호출"일 때만 가능합니다.

종류 문법 동등한 람다 예
정적 메서드 Class::staticMethod x -> Class.staticMethod(x) Integer::parseInt, Math::abs
특정 객체의 인스턴스 메서드 obj::method x -> obj.method(x) System.out::println, this::validate
임의 객체의 인스턴스 메서드 Class::instanceMethod x -> x.method() 또는 (x, y) -> x.method(y) String::length, String::compareTo
생성자 Class::new x -> new Class(x) ArrayList::new, Order::new

세 번째가 헷갈립니다.

String::length 는 첫 번째 파라미터가 수신 객체(receiver) 가 됩니다: Function<String, Integer> f = String::length → s -> s.length(). String::compareTo 는 BiFunction<String, String, Integer> → (a, b) -> a.compareTo(b). 첫 인자가 this 역할, 나머지가 메서드 인자입니다.

생성자 참조는 목표 타입에 따라 어떤 생성자를 고를지 결정됩니다: Supplier<List<String>> s = ArrayList::new 는 기본 생성자, Function<Integer, List<String>> f = ArrayList::new 는 ArrayList(int capacity) 입니다. 배열도 됩니다: IntFunction<int[]> arr = int[]::new.

2.6 람다 캡처와 effectively final — 왜 그런가

람다는 자기 밖의 지역 변수를 쓸 수 있습니다(캡처, capture). 단, 그 변수는 final 이거나 사실상 final(effectively final, 선언 후 한 번도 재대입되지 않음) 이어야 합니다.

java
int base = 100;
Function<Integer, Integer> add = x -> x + base;   // OK: base 는 effectively final
base = 200;    // 이 줄을 추가하면 위 람다가 컴파일 에러: "local variables referenced from a lambda expression must be final or effectively final"

왜 그럴까요? 스택 프레임 때문입니다.

text
main() 스택 프레임                       힙
┌─────────────────┐                    ┌────────────────────────┐
│ int base = 100  │ ── 복사(캡처) ───► │ 람다 객체 { base = 100 } │
└─────────────────┘                    └────────────────────────┘
   main 이 끝나면 사라짐                   나중에, 다른 스레드에서도 실행될 수 있음

지역 변수는 스택에 있고, 메서드가 끝나면 사라집니다. 람다 객체는 힙에 있고, 메서드가 끝난 뒤에도(스레드 풀에 넘겨졌다면 다른 스레드에서도) 실행될 수 있습니다. 그래서 람다는 변수를 참조하는 것이 아니라 값을 복사해서 가집니다.

복사본이니까 원본이 나중에 바뀌면 둘이 달라집니다 — 자바 설계자들은 "복사본인데 원본처럼 보이는" 혼란을 막으려고 아예 재대입을 금지했습니다. C++ 이나 JavaScript 처럼 참조 캡처를 허용하면 스레드 안전성 문제도 함께 따라오므로, 불변 캡처는 동시성 관점에서도 안전한 선택입니다.

주의할 점: 제한은 변수 재대입이지 객체 변경이 아닙니다.

java
List<String> log = new ArrayList<>();
Runnable r = () -> log.add("hit");    // OK: log 변수는 재대입 안 함. 리스트 내용 변경은 자유
int[] counter = {0};
Runnable inc = () -> counter[0]++;    // 편법: 배열 요소 변경. 단일 스레드에서만 쓸 것

인스턴스 필드나 static 필드는 힙에 있으므로 제한 없이 읽고 쓸 수 있습니다(단, 스레드 안전성은 별개).

2.7 람다 vs 익명 클래스 — this 와 컴파일 방식

겉보기엔 같지만 두 가지가 근본적으로 다릅니다.

익명 클래스 람다
this 의 의미 익명 클래스 자기 자신 람다를 감싸는 객체(enclosing instance)
컴파일 결과 Outer$1.class 파일이 생성됨 클래스 파일 없음. invokedynamic + private static 메서드
객체 생성 매번 new (캡처 없어도) 캡처 없으면 싱글턴 재사용, 캡처 있으면 생성
변수 섀도잉 바깥 변수와 같은 이름 선언 가능 불가 (같은 스코프로 취급)
추상 메서드 여러 개인 인터페이스/추상 클래스 가능 불가 (SAM 만)
상태(필드) 보유 가능 불가

this 차이는 실제 버그를 만듭니다.

java
class Button {
    String name = "save";
    void bind() {
        Runnable a = new Runnable() {
            String name = "anon";
            public void run() { System.out.println(this.name); }    // "anon" — 익명 클래스 자신
        };
        Runnable b = () -> System.out.println(this.name);           // "save" — Button 인스턴스
    }
}

invokedynamic 이란: JDK 7 에서 동적 언어 지원용으로 추가된 바이트코드 명령입니다. 람다는 컴파일 시 (1) 본문을 lambda$bind$0 같은 private static 메서드로 빼내고 (2) 호출 지점에 invokedynamic 을 심습니다.

최초 실행 시 LambdaMetafactory 가 런타임에 함수형 인터페이스 구현 클래스를 동적으로 생성하고, 이후로는 그 결과를 재사용합니다. 클래스 파일이 미리 생성되지 않으므로 jar 크기가 줄고, JVM 이 나중에 더 나은 구현 전략(예: 숨겨진 클래스)으로 바꿀 수 있는 여지가 생깁니다.

캡처가 없는 람다는 매번 같은 인스턴스를 돌려주므로 == 비교가 true 가 나오기도 하지만, 이에 의존하면 안 됩니다.

2.8 함수 합성(Composition)

표준 인터페이스에는 작은 함수를 이어 붙이는 default 메서드가 있습니다.

인터페이스 메서드 의미
Function f.andThen(g) x -> g(f(x)) — f 먼저, 그다음 g
Function f.compose(g) x -> f(g(x)) — g 먼저, 그다음 f
Function Function.identity() x -> x
Predicate p.and(q), p.or(q), p.negate() 논리 조합
Predicate Predicate.not(p) (JDK 11) 메서드 참조를 부정할 때: not(String::isBlank)
Consumer c.andThen(d) c 실행 후 d 실행
Comparator thenComparing, reversed, nullsFirst 정렬 기준 조합
UnaryOperator andThen, compose (Function 상속)

andThen 과 compose 는 방향이 반대입니다. f.andThen(g) 는 "f 하고 나서 g" 로 읽으면 헷갈리지 않습니다. 실무에서는 거의 andThen 만 씁니다.

합성은 파이프라인을 데이터로 만듭니다. List<Function<Order, Order>> 를 reduce(Function.identity(), Function::andThen) 으로 접으면 설정에 따라 단계를 켜고 끄는 처리 체인이 됩니다.

2.9 Comparator 체인

Comparator.comparing 계열은 람다 활용의 교과서입니다.

java
Comparator<Order> byStatusThenAmountDesc =
    Comparator.comparing(Order::status)                       // 1차: 상태
              .thenComparing(Order::amount, Comparator.reverseOrder())   // 2차: 금액 내림차순
              .thenComparing(Order::id);                      // 3차: id
메서드 역할
comparing(keyExtractor) 키 추출 후 자연 순서
comparing(keyExtractor, keyComparator) 키 추출 후 지정 비교자
comparingInt/Long/Double 기본형 키, 박싱 없음
thenComparing(...) 앞 기준이 같을 때 다음 기준
reversed() 지금까지의 순서 뒤집기
nullsFirst(c) / nullsLast(c) null 안전
naturalOrder() / reverseOrder() Comparable 기반

comparing(Order::amount).reversed() 에서 타입 추론이 실패하면(Object 로 추론) comparing((Order o) -> o.amount()) 처럼 파라미터 타입을 명시하면 됩니다. 메서드 참조가 있는 첫 comparing 은 대부분 추론이 잘 됩니다.

핵심 원리
  • 2.1 익명 클래스에서 람다로 — 변천사
  • 2.2 람다 문법 변형
  • 2.3 함수형 인터페이스(Functional Interface)
  • 2.4 java.util.function 표준 인터페이스
  • 2.5 메서드 참조(Method Reference) 4종
  • 2.6 람다 캡처와 effectively final — 왜 그런가
  • 2.7 람다 vs 익명 클래스 — this 와 컴파일 방식
  • 2.8 함수 합성(Composition)
  • 2.9 Comparator 체인
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제