Java 8은 Java 역사상 가장 큰 변화였습니다. Lambda, Stream, Optional은 매일 쓰는 기능이지만 "왜 이렇게 동작하는지"를 설명하려면 내부 원리가 필요합니다. 이 챕터는 사용법이 아니라 동작 원리와 트레이드오프에 집중합니다.
Lambda는 "이름 없는 함수"를 코드 안에 바로 정의하는 문법입니다. Java에서 Lambda가 가능한 이유는 @FunctionalInterface — 추상 메서드가 딱 하나뿐인 인터페이스가 있기 때문입니다.
// Comparator를 구현하려면 익명 클래스 필요
List<String> names = Arrays.asList("B","A","C");
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
}); // 장황하고 가독성 나쁨
// Comparator는 @FunctionalInterface
// → 추상 메서드 1개(compare) → Lambda 가능
List<String> names = Arrays.asList("B","A","C");
names.sort((a, b) -> a.compareTo(b));
// 메서드 레퍼런스로 더 간결하게
names.sort(String::compareTo);
Java 8이 제공하는 핵심 Functional Interface 4종:
| 인터페이스 | 시그니처 | 용도 | 실무 예시 |
|---|---|---|---|
Function<T, R> |
R apply(T t) |
입력 → 다른 타입으로 변환 | stream.map(user -> user.getName()) |
Predicate<T> |
boolean test(T t) |
조건 검사 (true/false) | stream.filter(user -> user.isActive()) |
Consumer<T> |
void accept(T t) |
입력 소비, 반환값 없음 | stream.forEach(log::info) |
Supplier<T> |
T get() |
입력 없이 값 생성 | optional.orElseGet(() -> defaultUser()) |
Stream은 컬렉션 데이터를 선언형(declarative)으로 처리하는 파이프라인입니다. Stream의 가장 중요한 특성은 지연 평가(Lazy Evaluation)입니다.
중간 연산(filter, map, sorted 등)은 호출해도 즉시 실행되지 않습니다.
내부적으로 "이런 처리를 하겠다"는 파이프라인 명세서만 쌓습니다.
collect(), forEach(), findFirst() 같은
최종 연산이 호출될 때 비로소 전체 파이프라인이 실행됩니다.
stream
.filter(u -> u.getAge() > 20) // 조건 필터
.map(u -> u.getName()) // 타입 변환
.flatMap(List::stream) // 중첩 컬렉션 펼치기
.distinct() // 중복 제거
.sorted(Comparator.naturalOrder())// 정렬
.limit(10) // 최대 10개
.skip(5) // 앞 5개 건너뜀
.peek(log::debug) // 중간 확인(디버그용)
// 컬렉션으로 수집
.collect(Collectors.toList())
.collect(Collectors.toMap(k, v))
.collect(Collectors.groupingBy(k))
// 단일 값
.count()
.findFirst() // Optional 반환
.findAny()
.anyMatch(p) // boolean
.reduce(0, Integer::sum)
// 부작용
.forEach(System.out::println)
list.parallelStream()은 ForkJoinPool.commonPool()을 사용해 병렬 처리합니다.
빠른 경우: 데이터 수 많음 + 각 요소 처리 비용 큼 + 독립적인 연산.
느린 경우: 데이터 수 적음 (스레드 오버헤드 > 이득), I/O 작업 포함 (ForkJoinPool은 CPU 코어 수 기반이라 I/O에 부적합),
sorted()처럼 전체 병합이 필요한 연산.
실무에서는 순서 보장이 필요하거나 데이터가 수천 건 미만이면 parallelStream을 피하는 것이 좋습니다.
Optional은 "값이 있을 수도, 없을 수도 있다"는 사실을 타입 시스템으로 명시하는 컨테이너입니다.
null을 반환하는 메서드는 호출자가 null 체크를 해야 한다는 사실을 강제할 수 없지만,
Optional을 반환하면 컴파일러가 처리를 강제합니다.
| 메서드 | 동작 | 주의점 |
|---|---|---|
orElse(T other) |
값 없으면 other 반환 | other는 항상 평가됨. other 생성 비용이 크면 orElseGet 사용 |
orElseGet(Supplier) |
값 없을 때만 Supplier 호출 | 지연 평가 — 생성 비용이 클 때 권장 |
orElseThrow() |
값 없으면 예외 던짐 | Java 10+: 기본 NoSuchElementException. 커스텀: orElseThrow(RuntimeEx::new) |
map(Function) |
값 있으면 변환, 없으면 Optional.empty() | null 안전한 변환 체이닝에 사용 |
flatMap(Function) |
Function이 Optional 반환할 때 중첩 방지 | Optional<Optional<T>> 되지 않도록 |
ifPresent(Consumer) |
값 있을 때만 Consumer 실행 | 반환값 없음. 값 여부와 무관한 처리는 ifPresentOrElse (Java 9+) |
filter(Predicate) |
조건 미충족 시 Optional.empty() | if-else 없이 조건부 처리 가능 |
// 1. 메서드 매개변수로 Optional 사용 → ❌
public void process(Optional<User> user) { }
// 호출자가 Optional.of/empty를 직접 만들어야 함
// 그냥 @Nullable User user를 쓰는 것이 나음
// 2. 값이 항상 있는데 Optional로 포장 → ❌
Optional<String> name = Optional.of("항상있음");
String result = name.get(); // 그냥 String name 쓰면 됨
// 3. isPresent() + get() 조합 → ❌
if (opt.isPresent()) {
return opt.get(); // orElseThrow()가 더 명확
}
// Repository에서 없을 수 있는 값 반환
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
// 호출 측 처리
User user = userService.findById(userId)
.filter(User::isActive) // 비활성이면 empty
.map(u -> enrichWithProfile(u)) // 변환
.orElseThrow(() ->
new UserNotFoundException(userId));
// orElse vs orElseGet 차이
// orElse: 항상 createDefault() 호출됨!
opt.orElse(createDefault());
// orElseGet: 값 없을 때만 호출됨
opt.orElseGet(() -> createDefault());
중간 연산은 최종 연산 호출 전까지 실행되지 않고 파이프라인 명세만 쌓입니다.
이로 인해 두 가지 최적화가 가능합니다.
첫째, limit(10)과 같이 일부만 필요한 경우 조건을 만족하는 10개를 찾는 순간 나머지 요소 처리를 중단합니다.
100만 건 컬렉션에서 조건을 만족하는 10개를 찾으면 나머지 999,990건을 처리하지 않습니다.
둘째, JVM이 파이프라인 전체를 분석해 루프 퓨전(loop fusion) 최적화를 적용할 수 있습니다.
Parallel Stream은 ForkJoinPool.commonPool()을 사용합니다.
이 풀은 JVM 전역으로 공유되고 CPU 코어 수 기준으로 크기가 결정됩니다.
문제는 세 가지입니다.
첫째, 데이터가 적으면 스레드 분배/결합 오버헤드가 이득보다 큽니다.
둘째, I/O 작업(DB 조회, 네트워크)은 CPU 기반 풀에서 스레드를 블로킹시켜 전체 시스템의 병렬 처리를 방해합니다.
셋째, sorted()처럼 전체 정렬이 필요한 연산은 병렬 처리 후 결합 단계에서 순차 처리가 필요해 오히려 느려집니다.
독립적이고 CPU 집약적인 대용량 연산에만 Parallel Stream을 사용해야 합니다.
orElse(T other)는 other 식을 항상 평가합니다.
Optional에 값이 있어도 orElse(expensiveMethod())의 expensiveMethod()는 반드시 호출됩니다.
orElseGet(Supplier)는 Optional이 비어있을 때만 Supplier를 호출합니다(지연 평가).
기본값 생성 비용이 크거나 DB 조회, 객체 생성처럼 사이드 이펙트가 있는 경우에는
반드시 orElseGet()을 사용해야 합니다.
단순 상수값(orElse(""))은 어느 쪽이든 차이가 없습니다.
Lambda는 코드 단순화, Stream은 지연 평가로 불필요한 연산 제거, Optional은 null 대신 타입으로 "없음"을 명시한다. 세 기능 모두 "선언적으로 의도를 표현"한다는 공통된 목적이 있다.