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

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

이 글의 핵심

Java 람다와 함수형 인터페이스에 대한 실전 가이드입니다. 개념부터 실무 활용까지 예제와 함께 상세히 설명합니다.

들어가며

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


1. 람다 표현식 (Lambda Expression)

기존 방식 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]

람다의 장점:

  1. 간결성: 익명 클래스 대비 코드 양 90% 감소
  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 필수

2. 함수형 인터페이스

커스텀 함수형 인터페이스

@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
    }
}

표준 함수형 인터페이스

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)  // BiFunction을 reduce에 전달
    .intValue();
System.out.println(sum);  // 15

표준 함수형 인터페이스 요약:

인터페이스메서드시그니처용도
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의 특수 케이스)

3. 메서드 참조 (Method Reference)

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();

실전 활용

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);

4. 실전 예제

예제: 사용자 필터링

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);  // [홍길동, 이영희]
    }
}

예제: 계산기

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);
    }
}

정리

핵심 요약

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

다음 단계

  • Java 예외 처리
  • Java I/O
  • Java 멀티스레딩

관련 글

  • Java Stream API | filter, map, reduce 완벽 정리
  • C++ constexpr Lambda |
  • C++ 람다 캡처 에러 |
  • C++ Generic Lambda |
  • C++ Init Capture |