📑 목차
- 람다 함수(Lambda Expression) 개요
- 함수형 인터페이스 (Functional Interface)
- 메서드 참조 (Method Reference)
- Stream API와 람다
- 클로저와 변수 캡처
- 함수 조합 (Function Composition)
- 실전 예제
- 기본형 특화 스트림
- 디버깅 및 주의사항
- java.util.function 전체 인터페이스 요약
- 결론 및 베스트 프랙티스
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는 람다 표현식과 함께 사용될 때 가장 강력한 효과를 발휘합니다.
filter→ Predicatemap→ FunctionforEach→ Consumerreduce→ BinaryOperator
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+ 개발자