Java 람다와 함수형 인터페이스 | Lambda Expression

이 글의 핵심

람다 안에서 바깥 지역 변수를 바꾸려다 effectively final 컴파일 에러를 만나는 경우가 많습니다. 그 제약이 왜 있는지부터 짚고, 직접 인터페이스를 정의할지 java.util.function의 표준 인터페이스를 쓸지, 메서드 참조가 더 읽기 좋은 경우는 언제인지를 예제로 판단할 수 있게 합니다.

들어가며

람다는 메서드를 값처럼 넘기는 문법입니다. Stream API와 함께 쓰면 컨베이어 벨트 위에서 조건·변환을 짧게 연결하기 좋습니다.


익명 클래스에서 람다로: 문법 정리

기존 방식 vs 람다

람다는 익명 클래스를 훨씬 간결하게 표현합니다:

import java.util.*;
// 기존 방식: 익명 클래스 (Java 7 이전)
Runnable r1 = new Runnable() {
    // Runnable 인터페이스를 구현하는 익명 클래스 생성
    @Override
    public void run() {
        // run() 메서드 구현
        System.out.println("Hello");
    }
};
// 문제점:
// - 코드가 장황함 (8줄)
// - 의도가 명확하지 않음 (보일러플레이트 코드가 많음)
// 람다 방식 (Java 8+)
Runnable r2 = () -> System.out.println("Hello");
// () : 매개변수 없음
// -> : 람다 연산자 (화살표)
// System.out.println("Hello") : 실행할 코드
// 
// 장점:
// - 간결함 (1줄)
// - 의도가 명확함 ("Hello를 출력하는 작업")
// 실행
r1.run();  // Hello
r2.run();  // Hello
// Comparator 예제: 문자열 정렬
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
// 기존 방식: 익명 클래스
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        // compareTo: 사전순 비교
        // a < b → 음수, a == b → 0, a > b → 양수
        return a.compareTo(b);
    }
});
// 코드: 7줄, 의도: "이름순 정렬"
// 람다 방식
Collections.sort(names, (a, b) -> a.compareTo(b));
// (a, b) : 두 개의 매개변수 (타입 추론)
// -> a.compareTo(b) : 비교 로직
// 코드: 1줄, 의도: 명확
// 더 간결하게: 메서드 참조
names.sort(String::compareTo);
// String::compareTo : "String의 compareTo 메서드를 사용"
// 람다보다 더 간결하고 가독성 좋음
System.out.println(names);  // [Alice, Bob, Charlie]

람다가 등장하기 전에는 “동작 하나”를 넘기려면 인터페이스를 구현하는 익명 클래스를 통째로 써야 했습니다. 정렬 기준 하나를 바꾸는 데 7줄이 필요했고, 정작 중요한 한 줄(a.compareTo(b))은 보일러플레이트에 묻혔습니다. 람다는 이 한 줄만 남기고 나머지를 컴파일러가 채우게 합니다. Collections.sort의 두 번째 인자가 Comparator<String>이라는 것을 컴파일러가 알고 있으므로, (a, b)의 타입이 String이고 반환값이 int여야 한다는 것도 대상 타입(target type)에서 추론합니다. 람다 자체에는 타입이 없고, 대입되는 자리가 타입을 정한다는 점이 람다를 이해하는 핵심입니다.

람다와 익명 클래스는 완전히 같지 않습니다. 익명 클래스 안의 this는 익명 클래스 자신을 가리키지만, 람다 안의 this는 람다를 감싸는 바깥 객체를 가리킵니다. 또 익명 클래스는 컴파일할 때마다 Main$1.class 같은 별도 클래스 파일이 생기지만, 람다는 invokedynamic으로 런타임에 생성되어 클래스 파일이 늘지 않습니다. 익명 클래스는 바깥과 같은 이름의 지역 변수를 새로 선언할 수 있지만, 람다는 바깥 스코프와 같은 스코프로 취급되어 매개변수 이름이 바깥 지역 변수와 겹치면 “variable x is already defined” 에러가 납니다.

람다의 장점:

  1. 간결성: 익명 클래스의 보일러플레이트가 사라짐
  2. 가독성: 핵심 로직에 집중
  3. 함수형 프로그래밍: Stream API와 함께 사용
  4. 타입 추론: 컴파일러가 타입 자동 추론 언제 사용하나:
  • Stream API (filter, map, reduce)
  • 컬렉션 정렬 (sort)
  • 이벤트 핸들러
  • 비동기 작업 (CompletableFuture)

람다 문법

람다 표현식의 다양한 형태입니다:

// 1. 매개변수 없음
() -> System.out.println("Hello")
// () : 빈 괄호 필수 (매개변수 없음을 명시)
// -> : 람다 연산자
// System.out.println("Hello") : 실행할 표현식
// 2. 매개변수 1개
x -> x * x
// x : 매개변수 (타입 추론)
// 괄호 생략 가능 (매개변수가 1개일 때만)
// x * x : 반환값 (return 키워드 생략)
// 매개변수 1개 - 괄호 사용 (권장)
(x) -> x * x
// 가독성을 위해 괄호를 쓰는 것이 좋음
// 3. 매개변수 여러 개
(a, b) -> a + b
// (a, b) : 두 개의 매개변수 (괄호 필수)
// a + b : 반환값 (단일 표현식이면 return 생략)
// 4. 타입 명시 (타입 추론이 안 될 때)
(int a, int b) -> a + b
// int a, int b : 명시적 타입 선언
// 모든 매개변수의 타입을 명시하거나 모두 생략해야 함
// (int a, b) -> ... // ❌ 에러: 일부만 명시 불가
// 5. 여러 줄 (블록)
(a, b) -> {
    // 중괄호 {} 사용
    int sum = a + b;
    // 여러 문장 실행 가능
    System.out.println("합계: " + sum);
    // return 키워드 필수
    return sum * 2;
}
// 주의: 중괄호를 쓰면 return 키워드 필수
// 실전 예제
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 단일 표현식 (return 생략)
numbers.forEach(n -> System.out.println(n));
// 여러 줄 (return 필수)
numbers.stream()
    .map(n -> {
        int squared = n * n;
        System.out.println(n + "의 제곱: " + squared);
        return squared;
    })
    .collect(Collectors.toList());

람다 문법 규칙:

  1. 매개변수 0개: () 필수
  2. 매개변수 1개: () 생략 가능
  3. 매개변수 2개 이상: () 필수
  4. 단일 표현식: {} 와 return 생략 가능
  5. 여러 문장: {} 와 return 필수

5번 규칙의 “return 필수”는 반환값이 있는 인터페이스일 때의 이야기입니다. Runnable이나 Consumer처럼 반환 타입이 void인 자리라면 블록 람다에 return이 없어도 됩니다. 반대로 Function에 넘기는 블록 람다에서 return을 빠뜨리면 “missing return statement” 또는 “bad return type in lambda expression” 에러가 납니다. 블록 안에서 한 경로만 return하고 다른 경로를 빠뜨려도 같은 에러가 나므로, 분기가 있는 람다라면 차라리 별도 메서드로 빼고 메서드 참조로 넘기는 편이 읽기에도 좋습니다.

위 예제의 map 안에서 System.out.println을 호출하는 것은 문법 설명을 위한 것이고, 실무 스트림 코드에서는 피하는 편이 좋습니다. 스트림의 중간 연산(map, filter)은 지연 실행되어 collect 같은 최종 연산이 호출될 때에야 실행되고, 병렬 스트림에서는 실행 순서도 보장되지 않습니다. collect를 빼면 println이 한 번도 실행되지 않아 “왜 아무것도 안 찍히지?” 하는 혼란이 생깁니다. 중간 값을 확인하고 싶다면 디버깅 용도로 만들어진 peek()을 쓰세요.


함수형 인터페이스

커스텀 함수형 인터페이스

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}
public class Main {
    public static void main(String[] args) {
        Calculator add = (a, b) -> a + b;
        Calculator subtract = (a, b) -> a - b;
        Calculator multiply = (a, b) -> a * b;
        Calculator divide = (a, b) -> a / b;
        
        System.out.println(add.calculate(10, 20));       // 30
        System.out.println(subtract.calculate(10, 20));  // -10
        System.out.println(multiply.calculate(10, 20));  // 200
        System.out.println(divide.calculate(10, 20));    // 0
    }
}

함수형 인터페이스는 추상 메서드가 정확히 하나인 인터페이스이고, 람다는 그 하나의 메서드를 구현하는 것으로 해석됩니다. @FunctionalInterface 애노테이션은 없어도 람다를 쓸 수 있지만, 붙여 두면 누군가 인터페이스에 추상 메서드를 하나 더 추가했을 때 “Multiple non-overriding abstract methods found in interface Calculator” 컴파일 에러로 막아 줍니다. 추상 메서드가 둘이 되는 순간 이 인터페이스를 쓰던 모든 람다가 깨지므로, 람다로 쓰일 인터페이스라면 붙이는 것이 좋습니다. default 메서드와 static 메서드는 개수에 포함되지 않으므로 자유롭게 추가할 수 있습니다.

divide.calculate(10, 20)이 0인 것은 int 나눗셈이라 소수점이 버려지기 때문이고, b가 0이면 ArithmeticException: / by zero가 납니다. 람다 안에서 난 예외는 호출한 쪽(calculate를 부른 곳)으로 그대로 전파되며, 스택 트레이스에는 lambda$main$3 같은 컴파일러가 만든 이름이 찍힙니다.

직접 인터페이스를 정의할지 표준 인터페이스를 쓸지는 이름이 주는 정보로 판단하면 됩니다. Calculator처럼 도메인 의미가 분명하고 여러 곳에서 쓰인다면 직접 정의하는 편이 코드를 읽기 쉽게 하고, 한두 곳에서 잠깐 쓰는 동작이라면 아래의 표준 인터페이스로 충분합니다. 이 Calculator는 사실 표준 IntBinaryOperator와 모양이 같습니다.

표준 함수형 인터페이스

Java는 자주 사용하는 함수형 인터페이스를 java.util.function 패키지에서 제공합니다:

import java.util.function.*;
// 1. Predicate<T>: T -> boolean (조건 판별)
// test() 메서드: 입력을 받아 boolean 반환
Predicate<Integer> isEven = n -> n % 2 == 0;
// n % 2 == 0: 짝수 판별 (나머지가 0이면 짝수)
System.out.println(isEven.test(4));   // true (4는 짝수)
System.out.println(isEven.test(5));   // false (5는 홀수)
// 실전 사용: Stream filter
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> evens = numbers.stream()
    .filter(isEven)  // Predicate를 filter에 전달
    .collect(Collectors.toList());
System.out.println(evens);  // [2, 4]
// 2. Function<T, R>: T -> R (변환)
// apply() 메서드: 입력 T를 받아 출력 R 반환
Function<String, Integer> length = s -> s.length();
// 문자열을 받아 길이(정수)를 반환
System.out.println(length.apply("Hello"));    // 5
System.out.println(length.apply("World"));    // 5
// 실전 사용: Stream map
List<String> words = Arrays.asList("a", "bb", "ccc");
List<Integer> lengths = words.stream()
    .map(length)  // Function을 map에 전달
    .collect(Collectors.toList());
System.out.println(lengths);  // [1, 2, 3]
// 3. Consumer<T>: T -> void (소비)
// accept() 메서드: 입력을 받아 처리 (반환값 없음)
Consumer<String> print = s -> System.out.println(s);
print.accept("Hello");  // Hello 출력
print.accept("World");  // World 출력
// 실전 사용: forEach
List<String> names = Arrays.asList("홍길동", "김철수");
names.forEach(print);  // 각 이름 출력
// 4. Supplier<T>: () -> T (공급)
// get() 메서드: 매개변수 없이 값을 생성하여 반환
Supplier<Double> random = () -> Math.random();
// 호출할 때마다 새 난수 생성
System.out.println(random.get());  // 0.123456...
System.out.println(random.get());  // 0.789012... (다른 값)
// 실전 사용: 지연 초기화
Supplier<List<String>> listSupplier = () -> new ArrayList<>();
List<String> list = listSupplier.get();  // 필요할 때 생성
// 5. BiFunction<T, U, R>: (T, U) -> R (두 입력 → 한 출력)
// apply() 메서드: 두 입력을 받아 하나의 출력 반환
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(add.apply(10, 20));  // 30
System.out.println(add.apply(5, 15));   // 20
// 실전 사용: reduce 초기값과 함께
List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5);
int sum = nums.stream()
    .reduce(0, add::apply)  // reduce는 BinaryOperator를 받으므로 메서드 참조로 변환
    .intValue();
System.out.println(sum);  // 15

마지막 예제에서 reduce(0, add)라고 그대로 넘기면 “incompatible types: BiFunction<Integer,Integer,Integer> cannot be converted to BinaryOperator” 에러가 납니다. reduce의 두 번째 매개변수 타입은 BinaryOperator<T>이고, BinaryOperator가 BiFunction을 상속하는 관계라서 부모 타입인 BiFunction 변수를 자식 타입 자리에 넣을 수는 없기 때문입니다. 모양(시그니처)이 같아도 Java의 함수형 인터페이스는 이름으로 구분되는 별개의 타입입니다. 람다나 메서드 참조(add::apply, Integer::sum)는 대입될 자리에 맞춰 타입이 정해지므로 이 문제가 없습니다. 이미 만들어 둔 함수형 인터페이스 변수를 다른 인터페이스 자리에 넘겨야 할 때는 이렇게 메서드 참조로 한 번 감싸면 됩니다.

표준 인터페이스를 쓸 때 알아 둘 비용이 하나 있습니다. Predicate<Integer>, Function<Integer, Integer>처럼 제네릭 인터페이스는 기본형을 쓸 수 없어서 int가 Integer로 박싱됩니다. 수백만 개의 숫자를 처리하는 스트림이라면 이 박싱이 눈에 띄는 비용이 되므로, IntPredicate, IntFunction, IntUnaryOperator, ToIntFunction 같은 기본형 특화 인터페이스와 IntStream을 쓰는 편이 좋습니다. nums.stream().mapToInt(Integer::intValue).sum()이 reduce(0, Integer::sum)보다 박싱이 적은 이유입니다.

한 가지 더, 표준 함수형 인터페이스의 메서드는 checked 예외를 선언하지 않습니다. Function<String, String> read = path -> Files.readString(Path.of(path));는 IOException 때문에 “unreported exception java.io.IOException; must be caught or declared to be thrown” 에러가 납니다. 람다 안에서 try/catch로 잡아 UncheckedIOException으로 감싸거나, 예외를 선언한 함수형 인터페이스를 직접 정의해야 합니다. 이 불편함이 예외 처리 글에서 다루는 checked 예외 논쟁의 한 원인이기도 합니다. 표준 함수형 인터페이스 요약:

인터페이스메서드시그니처용도
Predicate<T>test()T -> boolean조건 판별 (filter)
Function<T,R>apply()T -> R변환 (map)
Consumer<T>accept()T -> void소비 (forEach)
Supplier<T>get()() -> T생성 (lazy init)
BiFunction<T,U,R>apply()(T,U) -> R두 입력 변환

변형 인터페이스:

  • BiPredicate<T, U>: 두 입력 → boolean
  • BiConsumer<T, U>: 두 입력 → void
  • UnaryOperator<T>: T → T (Function의 특수 케이스)
  • BinaryOperator<T>: (T, T) → T (BiFunction의 특수 케이스)

메서드 참조 네 가지 유형

4가지 유형

import java.util.*;
List<String> names = Arrays.asList("홍길동", "김철수", "이영희");
// 1. 정적 메서드 참조
Function<String, Integer> parseInt = Integer::parseInt;
System.out.println(parseInt.apply("123"));  // 123
// 2. 인스턴스 메서드 참조
String str = "Hello";
Supplier<String> upper = str::toUpperCase;
System.out.println(upper.get());  // HELLO
// 3. 특정 타입의 임의 객체 메서드 참조
Function<String, String> toUpper = String::toUpperCase;
System.out.println(toUpper.apply("hello"));  // HELLO
// 4. 생성자 참조
Supplier<ArrayList<String>> listSupplier = ArrayList::new;
ArrayList<String> list = listSupplier.get();

2번과 3번은 모양이 비슷해 가장 헷갈립니다. 차이는 어떤 객체의 메서드를 부르느냐입니다. str::toUpperCase는 이미 정해진 객체 str의 메서드를 가리키므로 인자가 필요 없는 Supplier가 되고, String::toUpperCase는 “나중에 넘어올 어떤 String의 메서드”를 가리키므로 그 String을 첫 번째 인자로 받는 Function<String, String>이 됩니다. 즉 3번 유형에서는 첫 번째 매개변수가 메서드를 호출할 대상(수신 객체)이 됩니다. 앞의 String::compareTo가 Comparator<String>(두 인자)로 쓰일 수 있는 것도 같은 원리로, (a, b) -> a.compareTo(b)의 a가 수신 객체가 됩니다.

2번 유형에는 캡처 시점의 함정이 있습니다. str::toUpperCase는 메서드 참조를 만드는 순간의 str 객체를 붙잡습니다. str이 null이면 참조를 만드는 그 줄에서 바로 NullPointerException이 나는데, 같은 동작의 람다 () -> str.toUpperCase()는 get()을 호출할 때에야 NPE가 납니다. 예외가 나는 위치가 달라서 디버깅할 때 혼란을 줍니다. 오버로드가 여러 개인 메서드(String::valueOf 등)를 참조하면 대상 타입에 따라 어느 오버로드인지 정해지는데, 후보가 여럿 맞으면 “reference to valueOf is ambiguous” 에러가 나므로 그럴 때는 람다로 명시하는 편이 낫습니다.

실전 활용

List<String> names = Arrays.asList("홍길동", "김철수", "이영희");
// 람다
names.forEach(name -> System.out.println(name));
// 메서드 참조 (더 간결)
names.forEach(System.out::println);
// 정렬
names.sort((a, b) -> a.compareTo(b));
names.sort(String::compareTo);

메서드 참조가 항상 더 나은 것은 아닙니다. 람다가 인자를 그대로 메서드에 넘기기만 할 때는 메서드 참조가 간결하고 의도가 분명하지만, 인자를 가공하거나 순서를 바꾸거나 여러 메서드를 조합해야 한다면 람다가 더 읽기 쉽습니다. 정렬이라면 Comparator.comparing(User::getAge).thenComparing(User::getName)처럼 비교 기준을 조합하는 정적 메서드와 함께 쓸 때 메서드 참조의 장점이 가장 잘 드러납니다. Arrays.asList로 만든 리스트는 크기는 고정이지만 원소 교체는 가능해서 sort가 동작한다는 점도 알아 두세요. 반면 List.of(...)로 만든 불변 리스트에 sort를 호출하면 UnsupportedOperationException이 납니다.


사용자 필터링과 계산기 예제

예제: 사용자 필터링

import java.util.*;
import java.util.function.*;
import java.util.stream.*;
class User {
    String name;
    int age;
    
    User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
public class Main {
    public static void main(String[] args) {
        List<User> users = Arrays.asList(
            new User("홍길동", 25),
            new User("김철수", 17),
            new User("이영희", 30)
        );
        
        // Predicate로 필터링
        Predicate<User> isAdult = u -> u.age >= 18;
        
        List<User> adults = users.stream()
            .filter(isAdult)
            .collect(Collectors.toList());
        
        // Function으로 변환
        Function<User, String> getName = u -> u.name;
        
        List<String> names = adults.stream()
            .map(getName)
            .collect(Collectors.toList());
        
        System.out.println(names);  // [홍길동, 이영희]
    }
}

조건과 변환을 isAdult, getName처럼 이름 있는 변수로 빼 둔 것이 이 예제의 요점입니다. 스트림 파이프라인이 .filter(isAdult).map(getName)처럼 문장처럼 읽히고, 같은 조건을 여러 곳에서 재사용할 수 있습니다. Predicate는 isAdult.and(isActive), isAdult.negate()처럼 조합 메서드도 제공하므로, 복잡한 조건을 한 람다에 몰아넣기보다 작은 조건들을 조합하는 편이 테스트하기도 쉽습니다.

이 코드를 실제로 확장하다 보면 곧 만나는 문제가 effectively final 제약입니다. 예를 들어 “기준 나이”를 변수로 빼서 int minAge = 18;을 람다에서 쓰는 것은 괜찮지만, 그 뒤에 minAge = 20;처럼 한 번이라도 다시 대입하면 “local variables referenced from a lambda expression must be final or effectively final” 에러가 납니다. 람다는 지역 변수의 값을 복사해 캡처하므로, 원본이 바뀌면 람다 안과 밖의 값이 어긋나게 됩니다. Java는 이 혼란을 막기 위해 아예 재대입을 금지합니다. 흔히 int[] count = {0};처럼 배열로 감싸 우회하는데, 병렬 스트림에서는 데이터 레이스가 되므로 count(), sum() 같은 스트림 연산이나 AtomicInteger를 쓰는 편이 안전합니다.

예제: 계산기

import java.util.function.BiFunction;
public class Calculator {
    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
        BiFunction<Integer, Integer, Integer> subtract = (a, b) -> a - b;
        BiFunction<Integer, Integer, Integer> multiply = (a, b) -> a * b;
        BiFunction<Integer, Integer, Integer> divide = (a, b) -> a / b;
        
        int a = 10, b = 5;
        
        System.out.println("덧셈: " + calculate(a, b, add));
        System.out.println("뺄셈: " + calculate(a, b, subtract));
        System.out.println("곱셈: " + calculate(a, b, multiply));
        System.out.println("나눗셈: " + calculate(a, b, divide));
    }
    
    static int calculate(int a, int b, BiFunction<Integer, Integer, Integer> op) {
        return op.apply(a, b);
    }
}

이 계산기는 “연산을 값으로 넘긴다”는 람다의 핵심을 보여 줍니다. calculate는 어떤 연산을 하는지 모르고, 넘어온 op에 계산을 맡깁니다. 새 연산(나머지, 거듭제곱)을 추가할 때 calculate를 고칠 필요가 없다는 점에서 Strategy 패턴과 같은 구조입니다. 실무라면 연산을 Map<String, BinaryOperator<Integer>>에 등록해 두고 "+" 같은 기호로 찾아 쓰는 식으로 확장하는 경우가 많습니다. 이 예제의 BiFunction<Integer, Integer, Integer>는 입력과 출력 타입이 모두 같으므로 BinaryOperator<Integer>나 기본형 전용 IntBinaryOperator로 바꾸면 선언이 짧아지고, 앞에서 본 reduce 호환 문제나 박싱 비용도 사라집니다.


람다와 함수형 인터페이스 요약

핵심 요약

  1. 람다: (매개변수) -> 표현식
  2. 함수형 인터페이스: 추상 메서드 1개, @FunctionalInterface
  3. 메서드 참조: ::로 간결하게
  4. 표준 인터페이스: Predicate, Function, Consumer, Supplier
  5. 활용: Stream API와 함께 사용

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 람다 안에서 바깥 지역 변수의 값을 바꾸려고 하면 왜 컴파일 에러가 나나요?

A. 람다가 캡처하는 지역 변수는 final이거나 한 번 대입된 뒤 바뀌지 않는(effectively final) 변수여야 합니다. 람다는 선언된 메서드가 끝난 뒤나 다른 스레드에서 실행될 수 있어서, 지역 변수는 값을 복사해 캡처하기 때문입니다. 합계를 누적하려고 바깥 변수를 바꾸는 대신 스트림의 sum()이나 reduce()를 쓰는 것이 자연스러운 해결책입니다.