본문으로 건너뛰기

12.3 람다식과 함수형 인터페이스 (Lambda & Functional Interface)

Java 8에서 도입된 람다식(Lambda Expression) 은 자바를 훨씬 더 간결하고 현대적으로 만들어준 혁신적인 기능입니다. "이름이 없는 함수(익명 함수)"를 마치 데이터처럼 다루는 것이 가능해졌습니다.

1. 함수형 프로그래밍 개념

람다는 함수형 프로그래밍(Functional Programming) 의 개념을 자바에 도입한 것입니다. 함수를 일급 시민(First-class Citizen)으로 취급하여 변수에 저장하고, 메서드의 인자로 전달하고, 반환값으로 사용할 수 있게 합니다.

// 전통적인 객체지향 방식
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("안녕하세요!");
}
};
r1.run();

// 람다식으로 단축
Runnable r2 = () -> System.out.println("안녕하세요!");
r2.run();

2. 함수형 인터페이스란?

람다식을 사용하려면 함수형 인터페이스(Functional Interface) 가 필요합니다. 함수형 인터페이스란 추상 메서드가 딱 하나만 있는 인터페이스입니다. @FunctionalInterface 어노테이션으로 표시합니다.

@FunctionalInterface
public interface Calculator {
int calculate(int a, int b); // 추상 메서드 1개만 있어야 함

// default 메서드는 추가 가능 (추상 메서드 아님)
default void printResult(int a, int b) {
System.out.println("결과: " + calculate(a, b));
}

// static 메서드도 추가 가능
static Calculator add() {
return (a, b) -> a + b;
}
}

3. 람다식 문법 3가지

// 문법 1: (params) -> expression (단일 표현식)
Calculator add = (a, b) -> a + b;

// 문법 2: (params) -> { statements; } (여러 문장)
Calculator multiWithLog = (a, b) -> {
System.out.println(a + " * " + b + " 계산 중...");
int result = a * b;
return result; // 블록 형태일 때는 return 필수
};

// 문법 3: () -> expression (파라미터 없는 경우)
Runnable greet = () -> System.out.println("Hello!");

// 파라미터 1개일 때 괄호 생략 가능
java.util.function.Consumer<String> print = s -> System.out.println(s);

// 타입 추론: 컴파일러가 타입을 알 때 생략 가능
Calculator sub = (int a, int b) -> a - b; // 타입 명시
Calculator sub2 = (a, b) -> a - b; // 타입 생략 (더 간결)

기존 익명 클래스 vs 람다 비교

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class AnonymousVsLambda {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Charlie", "Alice", "Bob", "David"));

// 익명 클래스 방식 (구식, 장황)
names.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});
System.out.println("익명 클래스: " + names);

// 람다 방식 (간결)
names.sort((a, b) -> a.compareTo(b));
System.out.println("람다: " + names);

// 메서드 참조 방식 (더욱 간결)
names.sort(String::compareTo);
System.out.println("메서드 참조: " + names);
}
}

4. java.util.function 표준 함수형 인터페이스

자바는 java.util.function 패키지에 자주 쓰이는 함수형 인터페이스를 미리 정의해두었습니다.

인터페이스메서드 시그니처설명
Predicate<T>boolean test(T t)T를 받아 boolean 반환 (조건 판별)
Function<T,R>R apply(T t)T를 받아 R로 변환
Consumer<T>void accept(T t)T를 받아 소비 (반환값 없음)
Supplier<T>T get()아무것도 받지 않고 T를 공급
BiFunction<T,U,R>R apply(T t, U u)T, U 두 값을 받아 R로 변환
BiPredicate<T,U>boolean test(T t, U u)T, U 두 값을 받아 boolean 반환
BiConsumer<T,U>void accept(T t, U u)T, U 두 값을 소비
UnaryOperator<T>T apply(T t)T를 받아 같은 T 타입 반환
BinaryOperator<T>T apply(T t1, T t2)T 두 개를 받아 T 반환

Predicate<T>: 조건 판별

import java.util.function.Predicate;
import java.util.List;

public class PredicateExample {
public static void main(String[] args) {
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> isPositive = n -> n > 0;
Predicate<String> isLong = s -> s.length() > 5;

System.out.println(isEven.test(4)); // true
System.out.println(isEven.test(7)); // false
System.out.println(isLong.test("Hello")); // false
System.out.println(isLong.test("Hello World")); // true

// Predicate 조합
Predicate<Integer> isEvenAndPositive = isEven.and(isPositive);
Predicate<Integer> isEvenOrPositive = isEven.or(isPositive);
Predicate<Integer> isOdd = isEven.negate();

System.out.println(isEvenAndPositive.test(4)); // true
System.out.println(isEvenAndPositive.test(-4)); // false (음수)
System.out.println(isEvenOrPositive.test(3)); // true (홀수이지만 양수)
System.out.println(isOdd.test(5)); // true

// 실전: 리스트 필터링
List<Integer> numbers = List.of(-4, -3, -2, -1, 0, 1, 2, 3, 4, 5);
System.out.print("양의 짝수: ");
numbers.stream()
.filter(isEvenAndPositive)
.forEach(n -> System.out.print(n + " ")); // 2 4
System.out.println();
}
}

Function<T,R>: 변환

import java.util.function.Function;

public class FunctionExample {
public static void main(String[] args) {
Function<String, Integer> strToLen = s -> s.length();
Function<Integer, String> intToStr = n -> "숫자: " + n;
Function<String, String> trim = String::trim;

System.out.println(strToLen.apply("Hello")); // 5
System.out.println(intToStr.apply(42)); // 숫자: 42

// Function 조합
// andThen: f.andThen(g) = g(f(x))
Function<String, String> strToLenStr = strToLen.andThen(intToStr);
System.out.println(strToLenStr.apply("Hello")); // 숫자: 5

// compose: f.compose(g) = f(g(x))
Function<Integer, String> composed = intToStr.compose(strToLen);
System.out.println(composed.apply("Java")); // 숫자: 4

// Function.identity(): 입력값을 그대로 반환
Function<String, String> identity = Function.identity();
System.out.println(identity.apply("unchanged")); // unchanged

// 실전: 변환 파이프라인
Function<String, String> pipeline = trim
.andThen(String::toUpperCase)
.andThen(s -> "[" + s + "]");
System.out.println(pipeline.apply(" hello world ")); // [HELLO WORLD]
}
}

Consumer<T>: 소비 (반환값 없음)

import java.util.function.Consumer;
import java.util.function.BiConsumer;
import java.util.List;

public class ConsumerExample {
public static void main(String[] args) {
Consumer<String> print = System.out::println;
Consumer<String> printUpper = s -> System.out.println(s.toUpperCase());
Consumer<String> printLen = s -> System.out.println("길이: " + s.length());

print.accept("Hello"); // Hello

// Consumer 조합: andThen
Consumer<String> printAndLen = print.andThen(printLen);
printAndLen.accept("Java"); // Java \n 길이: 4

// BiConsumer: 두 개의 값 소비
BiConsumer<String, Integer> printScore = (name, score) ->
System.out.printf("%s의 점수: %d%n", name, score);
printScore.accept("김철수", 95);

// 실전: 리스트 처리
List<String> names = List.of("Alice", "Bob", "Charlie");
names.forEach(print); // forEach는 Consumer를 인수로 받음
}
}

Supplier<T>: 공급 (파라미터 없음)

import java.util.function.Supplier;
import java.util.Random;

public class SupplierExample {
public static void main(String[] args) {
Supplier<String> greeting = () -> "Hello, World!";
Supplier<Double> random = Math::random;
Supplier<Integer> dice = () -> new Random().nextInt(6) + 1;

System.out.println(greeting.get()); // Hello, World!
System.out.println(random.get()); // 0.xxx...
System.out.println(dice.get()); // 1~6

// 실전: 지연 초기화 (Lazy Initialization)
Supplier<java.util.ArrayList<String>> listFactory = () -> {
System.out.println("리스트 생성!");
return new java.util.ArrayList<>();
};

System.out.println("Supplier 생성 완료"); // 아직 리스트 생성 안 됨
java.util.ArrayList<String> list = listFactory.get(); // 이 시점에 리스트 생성
list.add("item");
}
}

BiFunction<T,U,R>: 두 입력 변환

import java.util.function.BiFunction;
import java.util.function.BinaryOperator;

public class BiFunctionExample {
public static void main(String[] args) {
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("abc", 3)); // abcabcabc

BiFunction<Integer, Integer, Integer> power = (base, exp) -> {
int result = 1;
for (int i = 0; i < exp; i++) result *= base;
return result;
};
System.out.println(power.apply(2, 10)); // 1024

// BinaryOperator: 같은 타입 두 개를 받아 같은 타입 반환
BinaryOperator<Integer> add = (a, b) -> a + b;
BinaryOperator<String> concat = (a, b) -> a + " " + b;

System.out.println(add.apply(10, 20)); // 30
System.out.println(concat.apply("Hello", "World")); // Hello World

// andThen으로 후처리 추가
BiFunction<String, Integer, String> repeatAndUpper =
repeat.andThen(String::toUpperCase);
System.out.println(repeatAndUpper.apply("ab", 3)); // ABABAB
}
}

5. 람다에서 외부 변수 사용 (Effectively Final 규칙)

람다식 내부에서 외부 변수를 사용할 때는 effectively final 규칙이 적용됩니다.

public class EffectivelyFinalExample {
public static void main(String[] args) {
int multiplier = 3; // effectively final (변경되지 않음)
// multiplier = 5; // 이 줄이 있으면 람다에서 사용 불가!

java.util.function.Function<Integer, Integer> triple = n -> n * multiplier;
System.out.println(triple.apply(4)); // 12

// 인스턴스 변수는 제약 없음 (참조가 변하지 않으면 내부 상태는 변경 가능)
java.util.ArrayList<Integer> results = new java.util.ArrayList<>();
java.util.List.of(1, 2, 3, 4, 5).forEach(n -> {
results.add(n * multiplier); // results 참조 자체는 변하지 않음
});
System.out.println(results); // [3, 6, 9, 12, 15]
}
}
effectively final 규칙

람다식 내부에서 참조하는 지역 변수는 반드시 final이거나 사실상 final(effectively final)이어야 합니다. 멀티스레드 환경에서의 안전성 때문입니다.

6. 람다 조합: andThen, compose, and, or, negate

import java.util.function.*;

public class LambdaComposition {
public static void main(String[] args) {
// Function 조합
Function<Integer, Integer> doubleIt = n -> n * 2;
Function<Integer, Integer> addThree = n -> n + 3;

Function<Integer, Integer> doubleFirst = doubleIt.andThen(addThree); // (n*2) + 3
Function<Integer, Integer> addFirst = doubleIt.compose(addThree); // (n+3) * 2

System.out.println("andThen(5): " + doubleFirst.apply(5)); // 13 = (5*2)+3
System.out.println("compose(5): " + addFirst.apply(5)); // 16 = (5+3)*2

// Predicate 조합
Predicate<String> notEmpty = s -> !s.isEmpty();
Predicate<String> hasAt = s -> s.contains("@");
Predicate<String> longEnough = s -> s.length() >= 5;

Predicate<String> isValidEmail = notEmpty.and(hasAt).and(longEnough);
Predicate<String> isInvalidEmail = isValidEmail.negate();

System.out.println(isValidEmail.test("alice@example.com")); // true
System.out.println(isValidEmail.test("alice")); // false (@ 없음)
System.out.println(isInvalidEmail.test("alice")); // true

// Consumer 조합
Consumer<String> logConsumer = s -> System.out.print("[LOG] " + s);
Consumer<String> saveConsumer = s -> System.out.println(" → 저장 완료");
Consumer<String> logAndSave = logConsumer.andThen(saveConsumer);

logAndSave.accept("사용자 데이터"); // [LOG] 사용자 데이터 → 저장 완료
}
}

7. 메서드 참조 (Method Reference)

람다식을 더욱 간결하게 표현하는 방법입니다.

import java.util.function.*;
import java.util.List;

public class MethodReferenceExample {
static int doubleIt(int n) { return n * 2; }
int triple(int n) { return n * 3; }

public static void main(String[] args) {
// 1. 정적 메서드 참조: ClassName::staticMethod
Function<Integer, Integer> doubleFn = MethodReferenceExample::doubleIt;
System.out.println(doubleFn.apply(5)); // 10

// 2. 인스턴스 메서드 참조 (특정 인스턴스): instance::method
MethodReferenceExample obj = new MethodReferenceExample();
Function<Integer, Integer> tripleFn = obj::triple;
System.out.println(tripleFn.apply(5)); // 15

// 3. 인스턴스 메서드 참조 (임의 인스턴스): ClassName::instanceMethod
Function<String, String> upperFn = String::toUpperCase;
System.out.println(upperFn.apply("hello")); // HELLO

BiFunction<String, String, Boolean> startsWith = String::startsWith;
System.out.println(startsWith.apply("Hello", "He")); // true

// 4. 생성자 참조: ClassName::new
Supplier<java.util.ArrayList<String>> listSupplier = java.util.ArrayList::new;
java.util.ArrayList<String> list = listSupplier.get();
list.add("item");

Function<String, StringBuilder> sbBuilder = StringBuilder::new;
System.out.println(sbBuilder.apply("초기값")); // 초기값

// 실전 활용
List<String> names = List.of("charlie", "alice", "bob");
names.stream()
.map(String::toUpperCase) // 메서드 참조
.sorted(String::compareTo) // 메서드 참조
.forEach(System.out::println); // 메서드 참조
// ALICE, BOB, CHARLIE
}
}

8. 실전 예제: 주문 리스트 필터링/변환/정렬

import java.util.*;
import java.util.function.*;
import java.util.stream.Collectors;

public class OrderManagementExample {

record Order(int id, String product, int quantity, double price, String status) {
double totalPrice() { return quantity * price; }

@Override
public String toString() {
return String.format("Order#%d [%s x%d, %.0f원, %s]",
id, product, quantity, totalPrice(), status);
}
}

public static void main(String[] args) {
List<Order> orders = new ArrayList<>(List.of(
new Order(1, "노트북", 1, 1_200_000, "배송완료"),
new Order(2, "마우스", 3, 35_000, "처리중"),
new Order(3, "키보드", 2, 80_000, "배송완료"),
new Order(4, "모니터", 1, 450_000, "처리중"),
new Order(5, "이어폰", 5, 120_000, "취소"),
new Order(6, "웹캠", 1, 75_000, "배송완료"),
new Order(7, "스피커", 2, 200_000, "처리중")
));

// ========== Predicate: 조건 필터 ==========
Predicate<Order> isCompleted = o -> o.status().equals("배송완료");
Predicate<Order> isExpensive = o -> o.totalPrice() >= 200_000;
Predicate<Order> isProcessing = o -> o.status().equals("처리중");

System.out.println("=== 배송완료 주문 ===");
orders.stream()
.filter(isCompleted)
.forEach(System.out::println);

System.out.println("\n=== 20만원 이상 + 배송완료 주문 ===");
orders.stream()
.filter(isCompleted.and(isExpensive))
.forEach(System.out::println);

// ========== Function: 데이터 변환 ==========
Function<Order, String> toSummary = o ->
String.format("%s: %,d원", o.product(), (long) o.totalPrice());

System.out.println("\n=== 주문 요약 ===");
orders.stream()
.filter(isCompleted)
.map(toSummary)
.forEach(System.out::println);

// ========== Comparator: 정렬 ==========
System.out.println("\n=== 총금액 내림차순 정렬 ===");
orders.stream()
.sorted(Comparator.comparingDouble(Order::totalPrice).reversed())
.limit(3)
.forEach(System.out::println);

// ========== Consumer: 통계 출력 ==========
Consumer<List<Order>> printStats = list -> {
double total = list.stream().mapToDouble(Order::totalPrice).sum();
long count = list.size();
System.out.printf("주문 수: %d개, 총 금액: %,d원, 평균: %,d원%n",
count, (long) total, (long) (total / count));
};

System.out.println("\n=== 처리중 주문 통계 ===");
List<Order> processingOrders = orders.stream()
.filter(isProcessing).toList();
printStats.accept(processingOrders);

// ========== BiFunction: 상태별 그룹화 ==========
System.out.println("\n=== 상태별 주문 현황 ===");
Map<String, List<Order>> groupedByStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status));
groupedByStatus.forEach((status, orderList) -> {
double statusTotal = orderList.stream().mapToDouble(Order::totalPrice).sum();
System.out.printf("[%s] %d건, 총 %,d원%n",
status, orderList.size(), (long) statusTotal);
});

// ========== 람다 조합 실전 ==========
System.out.println("\n=== 취소 제외 주문의 고가 상품 목록 ===");
Predicate<Order> notCancelled = o -> !o.status().equals("취소");
Function<Order, String> toProductInfo = o ->
String.format("%-8s (%.0f원)", o.product(), o.totalPrice());

orders.stream()
.filter(notCancelled.and(isExpensive))
.sorted(Comparator.comparingDouble(Order::totalPrice).reversed())
.map(toProductInfo)
.forEach(System.out::println);
}
}

출력 결과:

=== 배송완료 주문 ===
Order#1 [노트북 x1, 1200000원, 배송완료]
Order#3 [키보드 x2, 160000원, 배송완료]
Order#6 [웹캠 x1, 75000원, 배송완료]

=== 20만원 이상 + 배송완료 주문 ===
Order#1 [노트북 x1, 1200000원, 배송완료]

=== 주문 요약 ===
노트북: 1,200,000원
키보드: 160,000원
웹캠: 75,000원

=== 총금액 내림차순 정렬 ===
Order#1 [노트북 x1, 1200000원, 배송완료]
Order#5 [이어폰 x5, 600000원, 취소]
Order#4 [모니터 x1, 450000원, 처리중]

=== 처리중 주문 통계 ===
주문 수: 3개, 총 금액: 905,000원, 평균: 301,666원

=== 상태별 주문 현황 ===
[처리중] 3건, 총 905,000원
[배송완료] 3건, 총 1,435,000원
[취소] 1건, 총 600,000원

9. 고수 팁: 람다 활용 패턴

import java.util.function.*;
import java.util.Map;
import java.util.HashMap;

public class LambdaAdvancedPatterns {
public static void main(String[] args) {
// 패턴 1: 전략 패턴 (Strategy Pattern)
Map<String, BinaryOperator<Integer>> operations = new HashMap<>();
operations.put("add", (a, b) -> a + b);
operations.put("sub", (a, b) -> a - b);
operations.put("mul", (a, b) -> a * b);
operations.put("div", (a, b) -> a / b);

String op = "mul";
int result = operations.get(op).apply(6, 7);
System.out.println("6 * 7 = " + result); // 42

// 패턴 2: 데코레이터 패턴
Function<String, String> addBrackets = s -> "[" + s + "]";
Function<String, String> addQuotes = s -> "\"" + s + "\"";
Function<String, String> toUpper = String::toUpperCase;

Function<String, String> formatTitle = toUpper.andThen(addBrackets).andThen(addQuotes);
System.out.println(formatTitle.apply("hello")); // "[HELLO]"

// 패턴 3: Null-safe 처리
Function<String, String> safeUpper = s -> s != null ? s.toUpperCase() : "NULL";
System.out.println(safeUpper.apply(null)); // NULL
System.out.println(safeUpper.apply("hello")); // HELLO

// 패턴 4: 커링 (Currying) - 여러 인자를 순차적으로 적용
Function<Integer, Function<Integer, Integer>> curriedAdd = a -> b -> a + b;
Function<Integer, Integer> add10 = curriedAdd.apply(10); // 10을 미리 적용
System.out.println(add10.apply(5)); // 15
System.out.println(add10.apply(20)); // 30
}
}

람다식과 스트림(Stream)은 자바 개발 생산성을 획기적으로 끌어올린 가장 중요한 현대 자바의 특징입니다.