말귀 알아듣기.zip

[Java] 람다식(Lambda Expression) 완전 정리 - 문법부터 Stream API 실전 활용까지

zippy 2026. 4. 19. 01:31
반응형

📑 목차

  1. 람다 함수(Lambda Expression) 개요
  2. 함수형 인터페이스 (Functional Interface)
  3. 메서드 참조 (Method Reference)
  4. Stream API와 람다
  5. 클로저와 변수 캡처
  6. 함수 조합 (Function Composition)
  7. 실전 예제
  8. 기본형 특화 스트림
  9. 디버깅 및 주의사항
  10. java.util.function 전체 인터페이스 요약
  11. 결론 및 베스트 프랙티스

Java 8부터 도입된 람다 표현식은 익명 함수를 간결하게 작성할 수 있는 문법으로, Stream API와 결합해 함수형 프로그래밍 스타일을 Java에 도입한 핵심 기능입니다. 이 글에서는 람다의 기본 문법부터 실전 활용까지 완전하게 정리합니다.


1. 람다 함수(Lambda Expression) 개요

람다 함수란? 익명 함수를 간결하게 작성하는 문법으로, Java 8부터 도입되었습니다. 함수형 인터페이스의 단 하나의 추상 메서드를 구현하며, 병렬 처리와 Stream API와 함께 함수형 프로그래밍 스타일을 지원합니다.

기본 문법

(파라미터) -> {구현 로직}

// 예시
(int x, int y) -> { return x + y; }
s -> s.length()
() -> Math.random()

문법 변형 정리

형태 설명 예시
(x, y) -> x + y 파라미터 2개, 반환문 1줄 (a, b) -> *
x -> * 파라미터 1개 (괄호 생략 가능) n -> n + 1
() -> 42 파라미터 없음 () -> System.currentTimeMillis()
(x) -> { ... return x; } 복수 로직 (블록 사용) 멀티라인 처리

주요 특징

  • 간결성: 익명 클래스보다 훨씬 짧은 코드
  • 함수형 인터페이스 필요: @FunctionalInterface를 가진 인터페이스 대상
  • 타입 추론: 컴파일러가 자동으로 파라미터/반환 타입 추론
  • 클로저: 외부 변수 접근 가능 (단, final 또는 effectively final)

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

정확히 1개의 추상 메서드를 가지는 인터페이스를 함수형 인터페이스라고 합니다. @FunctionalInterface 어노테이션으로 표시하는 것을 권장하며, 람다 표현식의 대상 타입 역할을 합니다.

@FunctionalInterface
public interface MyFunction {
    int apply(int x);  // 추상 메서드 1개만
}

MyFunction f = x ->  * ;
System.out.println(f.apply(5));  // 10

Function 계열: 입력 값을 받아 출력 값을 반환

인터페이스 메서드 설명 예시
Function<T, R> R apply(T t) T → R 변환 Function<String, Integer> f = String::length;
BiFunction<T, U, R> R apply(T t, U u) 입력 2개 → 1개 결과 (a, b) -> a + b
UnaryOperator<T> T apply(T t) T → T (같은 타입) x -> *
BinaryOperator<T> T apply(T t1, T t2) (T, T) → T (a, b) -> Math.max(a, b)

기본형 특화형: IntFunction<R>, LongFunction<R>, DoubleFunction<R> (int/long/double 입력), ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T> (각 기본형으로 반환), IntToDoubleFunction, LongToIntFunction 등 기본형 간 변환을 지원합니다.

Predicate 계열: 입력값에 대해 조건을 검사하고 boolean을 반환

인터페이스 메서드 설명 예시
Predicate<T> boolean test(T t) T에 대한 조건 판단 s -> s.isEmpty()
BiPredicate<T, U> boolean test(T t, U u) 입력 2개 조건 판단 (a, b) -> a.equals(b)
IntPredicate boolean test(XXX) 기본형 조건 판단 i -> i > 10

Consumer 계열: 입력값을 받아 처리만 하고 반환 없음 (void)

인터페이스 메서드 설명 예시
Consumer<T> void accept(T t) T 처리 (결과 없음) s -> System.out.println(s)
BiConsumer<T, U> void accept(T t, U u) 입력 2개 처리 (k, v) -> map.put(k, v)
IntConsumer void accept(XXX) 기본형 처리 i -> total += i
ObjIntConsumer<T> void accept(T obj, int i) 객체 + int 처리 (obj, i) -> obj.setAge(i)

Supplier 계열: 입력 없이 값을 제공(공급)

인터페이스 메서드 설명 예시
Supplier<T> T get() T 타입 값 제공 () -> new Random().nextInt()
BooleanSupplier boolean getAsBoolean() boolean 값 제공 () -> true
IntSupplier XXX getAsXXX() 기본형 값 제공 () -> System.currentTimeMillis()

3. 메서드 참조 (Method Reference)

메서드 참조는 람다식을 더 간결하게 표현하는 방법으로, 대상::메서드이름 형태로 사용합니다.

종류 문법 람다식 대체 예시
정적 메서드 참조 ClassName::staticMethod (x) -> ClassName.staticMethod(x) Integer::parseInt
인스턴스 메서드 참조 instance::method () -> instance.method() System.out::println
클래스 메서드 참조 ClassName::instanceMethod (obj) -> obj.toString() String::length
생성자 참조 ClassName::new () -> new ClassName() ArrayList::new
// 정적 메서드 참조
Function<String, Integer> f1 = Integer::parseInt;
int num = f1.apply("123");  // 123

// 인스턴스 메서드 참조
List<String> list = List.of("a", "b", "c");
list.forEach(System.out::println);

// 클래스 메서드 참조
Function<String, Integer> f2 = String::length;
System.out.println(f2.apply("hello"));  // 5

// 생성자 참조
Supplier<ArrayList> s = ArrayList::new;
ArrayList<String> arr = s.get();

4. Stream API와 람다

Stream API는 람다 표현식과 함께 사용될 때 가장 강력한 효과를 발휘합니다.

  • filterPredicate
  • mapFunction
  • forEachConsumer
  • reduceBinaryOperator
List<Integer> numbers = List.of(1, 2, 3, 4, 5);

// filter: Predicate 사용
Stream<Integer> filtered = numbers.stream()
                                  .filter(n -> n > 2);

// map: Function 사용
Stream<Integer> mapped = numbers.stream()
                               .map(n ->  * );

// forEach: Consumer 사용
numbers.stream()
       .forEach(System.out::println);

// reduce: BinaryOperator 사용
int sum = numbers.stream()
                 .reduce(0, (a, b) -> a + b);

자주 사용되는 패턴

List<String> names = List.of("Java", "Spring", "Lambda");

// 필터링 및 변환
List<Integer> lengths = names.stream()
                             .filter(s -> s.length() > 4)
                             .map(String::length)
                             .toList();

// 조건 확인
boolean hasEmpty = names.stream()
                        .anyMatch(s -> s.isEmpty());

// 누적 계산
String result = names.stream()
                     .reduce("", (a, b) -> a + ", " + b);

5. 클로저와 변수 캡처

람다식은 외부 스코프의 변수에 접근할 수 있습니다. 단, 접근 가능한 변수는 effectively final(선언 후 값이 변하지 않는 변수) 또는 명시적 final 변수여야 하며, 람다식 내에서 외부 변수를 직접 변경하는 것은 불가능합니다.

int multiplier = 10;  // effectively final
Function<Integer, Integer> f = x ->  * ultiplier;
System.out.println(f.apply(5));  // 50

int x = 5;
// x = 10;  // 컴파일 에러! effectively final 위반

Function<Integer, Integer> f2 = y -> x + y;
System.out.println(f2.apply(3));  // 8

⚠️ 주의: 외부 변수를 람다 내에서 재할당하면 컴파일 에러가 발생합니다. 변경 가능한 상태가 필요하다면 AtomicInteger 등의 래퍼 클래스를 사용하세요.


6. 함수 조합 (Function Composition)

함수형 인터페이스의 디폴트 메서드를 이용하면 여러 함수를 조합할 수 있습니다.

Function의 조합

Function<Integer, Integer> multiply = x ->  * ;
Function<Integer, Integer> addTen = x -> x + 10;

// andThen: 먼저 실행 후 결과를 다음 함수에 전달
Function<Integer, Integer> combined1 = multiply.andThen(addTen);
System.out.println(combined1.apply(5));  // ( * ) + 10 = 20

// compose: 먼저 실행할 함수를 뒤에 명시
Function<Integer, Integer> combined2 = addTen.compose(multiply);
System.out.println(combined2.apply(5));  // ( * ) + 10 = 20

Predicate의 조합

Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> isEmpty = String::isEmpty;

// and, or, negate
Predicate<String> combined = isLong.and(s -> s.contains("a"));
System.out.println(combined.test("banana"));  // true

Predicate<String> negated = isEmpty.negate();
System.out.println(negated.test("hello"));  // true

7. 실전 예제

7.1 리스트 필터링 및 변환

List<String> words = List.of("hello", "world", "java", "stream");

// 길이 4 이상인 단어들을 대문자로 변환
List<String> result = words.stream()
                           .filter(w -> w.length() >= 4)
                           .map(String::toUpperCase)
                           .toList();
// [HELLO, WORLD, STREAM]

7.2 맵 순회 및 처리

Map<String, Integer> scores = Map.of("Alice", 90, "Bob", 85, "Charlie", 95);

// 80점 이상 사람만 출력
scores.entrySet().stream()
      .filter(entry -> entry.getValue() >= 80)
      .forEach(entry -> System.out.println(entry.getKey() + ": " + entry.getValue()));

7.3 데이터 수집 및 통계

List<Integer> numbers = List.of(10, 20, 30, 40, 50);

// 합계
int sum = numbers.stream().reduce(0, (a, b) -> a + b);

// 평균
double average = numbers.stream()
                        .mapToDouble(n -> n)
                        .average()
                        .orElse(0.0);

// 최대값
int max = numbers.stream()
                 .reduce(Integer.MIN_VALUE, Math::max);

7.4 그룹화 및 분류

List<String> items = List.of("apple", "banana", "apricot", "blueberry");

// 첫 글자별로 그룹화
Map<Character, List<String>> grouped = items.stream()
        .collect(Collectors.groupingBy(s -> s.charAt(0)));
// {a=[apple, apricot], b=[banana, blueberry]}

8. 기본형 특화 스트림

기본형 값을 처리할 때 박싱(Boxing) 오버헤드를 제거하기 위해 기본형 특화 스트림을 사용합니다.

클래스 설명 생성 활용
IntStream int 값 스트림 IntStream.range(0, 10) sum(), average(), max()
LongStream long 값 스트림 LongStream.range(0L, 100L) 대용량 숫자 처리
DoubleStream double 값 스트림 DoubleStream.of(1.0, 2.5, 3.7) 부동소수점 계산
// int 범위로 스트림 생성
int sum = IntStream.range(1, 11).sum();  // 55

// double 값들의 평균
double avg = DoubleStream.of(10.5, 20.3, 15.8).average().orElse(0.0);

// 조건 필터링
long count = IntStream.range(1, 100)
                      .filter(n -> n % 2 == 0)
                      .count();  // 49 (짝수 개수)

9. 디버깅 및 주의사항

람다식 디버깅 시 peek()을 활용하면 스트림 파이프라인 중간 값을 확인할 수 있습니다.

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
       .peek(n -> System.out.println("Processing: " + n))
       .filter(n -> n > 2)
       .peek(n -> System.out.println("After filter: " + n))
       .forEach(System.out::println);

주요 주의사항

  • Side Effect 주의: 람다식에서 외부 상태 변경을 피할 것
  • Effectively Final: 외부 변수 값 변경 시 컴파일 에러 발생
  • Null 처리: null 파라미터 체크 필수
  • 병렬 스트림: parallel() 사용 시 thread-safe 고려

10. java.util.function 전체 인터페이스 요약

Function 계열 (입력 → 출력)

Function<T, R>, BiFunction<T, U, R>, UnaryOperator<T>, BinaryOperator<T>, IntFunction<R>, LongFunction<R>, DoubleFunction<R>, ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T>, ToIntBiFunction<T, U>, ToLongBiFunction<T, U>, ToDoubleBiFunction<T, U>, IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction, DoubleToIntFunction, DoubleToLongFunction, IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator, IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator

Predicate 계열 (입력 → boolean)

Predicate<T>, BiPredicate<T, U>, IntPredicate, LongPredicate, DoublePredicate

Consumer 계열 (입력 → void)

Consumer<T>, BiConsumer<T, U>, IntConsumer, LongConsumer, DoubleConsumer, ObjIntConsumer<T>, ObjLongConsumer<T>, ObjDoubleConsumer<T>

Supplier 계열 (void → 출력)

Supplier<T>, BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier


11. 결론 및 베스트 프랙티스

✅ 람다식을 사용하면 좋은 경우

  • Stream API와 함께 사용할 때
  • 간단한 처리 로직 (1~3줄)
  • 이벤트 핸들러, 콜백 함수
  • Optional, CompletableFuture 활용 시

🚫 람다식을 피해야 하는 경우

  • 복잡한 다중 라인 로직: 일반 메서드로 추출
  • Side effect가 있는 작업
  • 재사용 가능한 로직: 메서드로 분리

코드 가독성 예시

❌ 나쁜 예: 복잡한 람다 (한 줄에 몰아넣기)

list.stream().filter(x -> x > 5 && x < 20 && x % 2 == 0).map(x ->  * ).forEach(System.out::println);

✅ 좋은 예: 의도가 명확한 스타일

list.stream()
    .filter(x -> x > 5 && x < 20)
    .filter(x -> x % 2 == 0)
    .map(x ->  * )
    .forEach(System.out::println);

📌 핵심 정리: Java 람다 표현식은 단순한 문법 설탕이 아니라, 함수형 프로그래밍 패러다임을 Java에 녹여낸 강력한 도구입니다. Stream API, Optional, CompletableFuture 등과 함께 익혀두면 코드 품질과 생산성을 크게 높일 수 있습니다.


작성일: 2026년 1월 15일 | 대상: Java 8+ 개발자

반응형