자바 함수형 마스터 — 람다 표현식·메서드 참조

2026-05-03확률과 통계 마스터 노트

자바 함수형 마스터 노트 시리즈 2편. 익명 내부 클래스 → 람다로의 변환 흐름, 람다 4단계 간소화, @FunctionalInterface 어노테이션의 의미, 함수형 프로그래밍 3개념(일급 객체·고차 함수·순수 함수), 동작 파라미터화·지연 평가, effectively final 규칙, 메서드 참조 4종(static·instance·object·constructor)까지.

이 글은 자바 함수형 마스터 노트 시리즈의 두 번째 편입니다. 1편(기초)에서 다형성을 다졌다면, 이번엔 자바 8 함수형 프로그래밍의 출발점 — 람다 표현식.

람다는 단순한 문법 단축이 아닙니다. 함수를 값처럼 다루는 패러다임 전환. 이걸 이해하면 3편 함수형 인터페이스도 4편 Stream도 한 흐름으로 흡수됩니다.

처음 람다가 어렵게 느껴지는 이유

처음 강의를 들을 때 람다 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, (x) -> x + 1 같은 기호 조합이 갑자기 등장해서. 어느 부분이 입력이고 어디가 본문인지 헷갈립니다. 둘째, "왜 굳이 람다인가"가 안 보여서. 그냥 메서드 쓰면 안 되나? 같은 의문이 자꾸 생깁니다.

해결법은 한 가지예요. 람다를 **"이름 없는 메서드"**로 보는 것. 메서드를 변수에 담고, 인자로 넘기고, 반환할 수 있는 도구. 이 그림이 잡히면 4단계 간소화 규칙은 자연스럽게 따라옵니다.

익명 내부 클래스 → 람다로의 변환

람다의 출발점은 자바 8 이전의 익명 내부 클래스입니다.

// 자바 8 이전 — 정렬 비교자
Collections.sort(list, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
});

핵심 로직은 a.length() - b.length() 한 줄. 그런데 그걸 감싸는 보일러플레이트가 6줄. 이걸 모두 잘라낸 게 람다:

// 자바 8+
Collections.sort(list, (a, b) -> a.length() - b.length());

같은 동작, 1줄. 보일러플레이트 6줄이 사라진 자리에 핵심만 남았습니다.

여기서 정말 중요한 시험 함정 — 람다는 함수형 인터페이스의 인스턴스. 즉 Comparator처럼 추상 메서드 1개인 인터페이스의 구현. 추상 메서드 2개인 인터페이스에는 람다 못 씁니다.

람다 기본 문법

(파라미터) -> { 실행문 }

4단계 간소화

자바는 명확하면 생략 가능. 단계별로 줄여 갑니다.

1단계 — 풀어 쓴 형태

(int a, int b) -> { return a + b; }

2단계 — 타입 생략 (타입 추론)

(a, b) -> { return a + b; }

컴파일러가 함수형 인터페이스 시그니처에서 타입을 유추.

3단계 — 한 줄이면 {}·return 생략

(a, b) -> a + b

본문이 단일 표현식이면 {}·return 둘 다 빠집니다.

4단계 — 파라미터 1개면 () 생략

x -> x * 2

여기서 시험 함정이 하나 있어요. 파라미터 0개나 2개+이면 () 필수. 1개일 때만 생략 가능.

() -> "Hello"           // 0개 — () 필수
x -> x * 2              // 1개 — () 생략 OK
(a, b) -> a + b         // 2개 — () 필수

또 한 가지 — 타입 명시 시 () 필수:

(int x) -> x * 2  // OK
int x -> x * 2    // 컴파일 에러

@FunctionalInterface 어노테이션

람다를 받는 인터페이스에 명시.

@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);  // 추상 메서드 1개
}

역할 — 컴파일 타임 검증. 추상 메서드가 2개 이상이면 컴파일 에러:

@FunctionalInterface
public interface Wrong {
    void method1();
    void method2();  // 에러: Multiple non-overriding abstract methods
}

여기서 시험 함정이 하나 있어요. @FunctionalInterface 없어도 람다 사용 가능. 다만 어노테이션이 있으면 실수로 추상 메서드를 추가했을 때 즉시 에러로 잡힙니다. 권장 패턴.

default·static 메서드는 OK

@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);  // 추상 1개

    default int doubleResult(int a, int b) {  // default OK
        return calculate(a, b) * 2;
    }

    static Calculator add() {  // static OK
        return (a, b) -> a + b;
    }
}

추상 메서드만 1개면 default·static 몇 개든 상관없음. 1편에서 본 자바 8 인터페이스 변화가 여기서 빛납니다.

함수형 프로그래밍 3개념

1. 일급 객체 (First-Class Citizen)

함수를 값처럼 다룸 — 변수 할당·인자 전달·반환.

// 변수 할당
Function<Integer, Integer> square = x -> x * x;

// 인자 전달
list.stream().filter(x -> x > 5).count();

// 반환
Function<Integer, Integer> doubler() {
    return x -> x * 2;
}

2. 고차 함수 (Higher-Order Function)

함수를 인자로 받거나 반환하는 함수.

// 받는 예
Stream.filter(Predicate)
Stream.map(Function)

// 반환하는 예
Function<Integer, Integer> compose(Function<Integer, Integer> f, Function<Integer, Integer> g) {
    return x -> f.apply(g.apply(x));
}

3. 순수 함수 (Pure Function)

같은 입력 → 같은 출력 + 외부 부작용 X.

// 순수 함수
int add(int a, int b) {
    return a + b;
}

// 비순수 (외부 상태 변경)
int counter = 0;
int increment(int x) {
    counter++;  // 부작용
    return x + counter;
}

여기서 정말 중요한 시험 함정 — 병렬 스트림이 안전한 이유 = 순수 함수. 부작용 있는 람다를 병렬에 넣으면 race condition 발생. 함수형 프로그래밍의 안전성 = 불변 + 순수.

동작 파라미터화 (Behavior Parameterization)

행위 자체를 인자로 넘기는 패턴.

// Before — 조건마다 메서드 따로
List<Apple> filterRedApples(List<Apple> apples) { ... }
List<Apple> filterHeavyApples(List<Apple> apples) { ... }

// After — Predicate를 인자로
List<Apple> filter(List<Apple> apples, Predicate<Apple> p) {
    return apples.stream().filter(p).collect(toList());
}

// 호출
filter(apples, a -> a.getColor().equals(RED));
filter(apples, a -> a.getWeight() > 150);

같은 메서드, 다른 동작. DRY 원칙의 함수형 버전.

지연 평가 (Lazy Evaluation)

필요한 시점에만 평가.

list.stream()
    .filter(x -> {
        System.out.println("filter: " + x);
        return x > 5;
    })
    .map(x -> {
        System.out.println("map: " + x);
        return x * 2;
    })
    .findFirst();  // 최종 연산

전체 리스트가 아닌 첫 번째 매치만 평가됨. 4편 Stream에서 핵심.

여기서 시험 함정이 하나 있어요. 중간 연산만 있고 최종 연산 없으면 아무것도 안 일어남. 람다는 "정의"일 뿐, 실행은 최종 연산이 트리거.

effectively final 규칙

람다 안에서 외부 지역 변수를 참조할 때.

int factor = 3;
Function<Integer, Integer> multiply = x -> x * factor;  // OK

factor = 5;  // 람다 만든 후 변경 → 컴파일 에러

람다 내부에서 참조한 외부 변수는 사실상 최종(effectively final). 한 번 할당 후 변경 X.

// 자바 7까지 — final 키워드 명시 필수
final int factor = 3;

// 자바 8+ — effectively final이면 OK
int factor = 3;  // final 안 써도 OK

여기서 정말 중요한 시험 함정 — 인스턴스 변수는 effectively final 룰 X. 클래스 필드는 자유롭게 변경 가능. 지역 변수에만 적용.

public class Counter {
    private int count = 0;  // 인스턴스 변수

    public void increment() {
        Runnable r = () -> count++;  // OK (인스턴스 변수)
    }
}

이유 — 람다는 별도 스레드에서 실행될 수 있는데, 지역 변수는 스택에 있어 스레드 종료 후 사라짐. 그래서 람다는 복사본을 캡처하고, 복사본의 일관성 위해 변경 금지.

메서드 참조 4종

람다를 더 짧게 쓰는 도구. 람다가 단순히 기존 메서드를 호출만 한다면 메서드 참조로 치환.

// 람다
list.forEach(x -> System.out.println(x));

// 메서드 참조
list.forEach(System.out::println);

1. 정적 메서드 참조

ClassName::staticMethod

// 람다
Function<String, Integer> parser = s -> Integer.parseInt(s);

// 메서드 참조
Function<String, Integer> parser = Integer::parseInt;

2. 인스턴스 메서드 참조 (특정 타입)

ClassName::instanceMethod — 첫 인자가 호출 대상.

// 람다
Function<String, String> upper = s -> s.toUpperCase();

// 메서드 참조
Function<String, String> upper = String::toUpperCase;

String::toUpperCase = "어떤 String이든, 그것의 toUpperCase()를".

3. 인스턴스 메서드 참조 (특정 객체)

instance::instanceMethod

List<String> list = new ArrayList<>();

// 람다
Consumer<String> add = s -> list.add(s);

// 메서드 참조
Consumer<String> add = list::add;

이미 있는 객체의 메서드를 그대로 참조.

4. 생성자 참조

ClassName::new

// 람다
Supplier<ArrayList<String>> factory = () -> new ArrayList<>();

// 메서드 참조
Supplier<ArrayList<String>> factory = ArrayList::new;

// 인자 있는 생성자
Function<String, Person> personFactory = Person::new;
// 내부적으로 (s) -> new Person(s)

여기서 시험 함정이 하나 있어요. 메서드 참조 종류 4가지를 시그니처로 구분. 컴파일러가 어떤 종류인지 자동 판단. 외울 때 패턴으로 묶는 게 도움됩니다 — 클래스+정적 / 클래스+인스턴스 / 객체+인스턴스 / 클래스+new.

람다 vs 익명 클래스 — this의 차이

public class Outer {
    public void test() {
        Runnable anonymous = new Runnable() {
            @Override
            public void run() {
                System.out.println(this);  // 익명 클래스 자신
            }
        };

        Runnable lambda = () -> {
            System.out.println(this);  // Outer 인스턴스!
        };
    }
}

여기서 정말 중요한 시험 함정 — 람다 안 this = 둘러싼 클래스. 익명 클래스의 this = 익명 클래스 자신. 이 차이가 디버깅에서 종종 함정.

람다는 어떻게 컴파일되나

람다는 새 클래스 파일을 만들지 않습니다. invokedynamic 명령으로 동적 생성.

익명 클래스 → Outer$1.class 파일 생성 (각 익명 클래스마다)
람다       → invokedynamic으로 런타임 생성 (파일 X)

이래서 람다가 익명 클래스보다 메모리 효율적. 대량 람다 사용 시 클래스로딩 비용 차이 큼.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 람다 = 이름 없는 메서드, 함수형 인터페이스의 인스턴스
  • 익명 클래스의 보일러플레이트 제거판
  • 문법 — (파라미터) -> { 실행문 }
  • 4단계 간소화 — 풀어쓰기·타입 생략·{}·return 생략·() 생략
  • 파라미터 1개일 때만 () 생략
  • 타입 명시 시 () 필수
  • @FunctionalInterface = 컴파일 타임 검증, 추상 메서드 1개 보장
  • default·static 메서드는 몇 개든 OK
  • 함수형 프로그래밍 3 개념 — 일급 객체 / 고차 함수 / 순수 함수
  • 일급 객체 = 함수를 변수·인자·반환값으로
  • 고차 함수 = 함수를 받거나 반환
  • 순수 함수 = 같은 입력 → 같은 출력 + 부작용 X
  • 병렬 스트림 안전성 = 순수 함수
  • 동작 파라미터화 = 행위 자체를 인자로
  • 지연 평가 = 최종 연산 전엔 실행 X
  • 중간 연산만 있고 최종 연산 없으면 아무 일 안 일어남
  • effectively final = 람다가 캡처한 외부 지역 변수, 변경 X
  • final 명시 안 해도 OK (자바 8+)
  • 인스턴스 변수는 effectively final 룰 X — 자유 변경
  • 메서드 참조 4종 — static / 인스턴스(타입) / 인스턴스(객체) / 생성자
  • Integer::parseInt (static)
  • String::toUpperCase (인스턴스 타입)
  • list::add (인스턴스 객체)
  • ArrayList::new (생성자)
  • 람다 안 this = 둘러싼 클래스 (익명 클래스와 다름)
  • 람다는 invokedynamic으로 동적 생성 — 익명 클래스보다 효율적

시리즈 다른 편

공식 문서: Oracle Lambda Expressions Tutorial 에서 더 깊이.

다음 글(3편)에서는 함수형 인터페이스 12종 — Supplier·Consumer·Predicate·Function·Bi*·UnaryOperator·BinaryOperator·Runnable·Callable까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!