이펙티브 자바 7장) 람다와 스트림

동동주·2025년 11월 27일

이펙티브 자바

목록 보기
8/13

자바 8에서는 함수형 인터페이스, 람다, 메서드 참조라는 개념이 추가되면서 함수 객체를 더 쉽게 만들 수 있게 됨. 이와 함께 스트림 api까지 추가됨!

🔎 아이템 42 - 익명 클래스보다는 람다를 사용하라

Collections.sort(words, new Comparator<String>() {
  public int compare(String s1, String s2) {
    return Integer.compare(s1.length(), s2.length());
  }
});

자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 특별한 대우를 받게 되었다. 지금은 함수형 인터페이스라 부르는 이 인터페이스들의 인스턴스를 람다식 을 사용해 만들 수 있게 된 것이다. 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.

Collections.sort(words,
    (s1, s2) -> Integer.compare(s1.length(), s2.length()));
  • 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자. 그런 다음 컴파일러가 "타입을 알 수 없다"는 오류를 낼 때만 해당 타입을 명시하면 된다.

더 깔끔한 방식: 비교자 생성 메서드

람다 익숙해지면 아래처럼 더 줄일 수 있음

Collections.sort(words, comparingInt(String::length));

List 자체의 sort() 사용하면 더 짧아짐

words.sort(comparingInt(String::length));

람다의 단점

1) 람다는 이름 없음, 문서화도 못함
→ 코드가 길거나 동작이 복잡하면 오히려 읽기 어려워짐
람다는 한 줄이 가장 좋고, 길어도 3줄 안이 적당

2) 람다는 함수형 인터페이스에서만 사용 가능
람다로 대체할 수 없는 곳도 있다. 람다는 함수형 인터페이스로만 쓰인다.
추상 클래스나 추상 메서드 여러 개 있는 인터페이스는
→ 익명 클래스 필요함

3) 람다는 자신을 참조할 수 없다.
람다 내부의 this는 바깥 클래스를 가리킴
익명 클래스는 자기 자신을 가리킴

public LambdaTest anonymous = new LambdaTest() {
    final int value = 200;
    public String getValue() { return "" + this.value; } // 200
};

public LambdaTest lambda = () -> "" + this.value; // 바깥의 100

4) 람다를 직렬화하는 일은 극히 삼가야한다.
람다도 익명 클래스처럼 직렬화 형태가 구현별로 다를 수 있기 때문이다. 직렬화해야만 하는 함수 객체가 있다면 private 정적 중첩 클래스의 인스턴스를 사용하자.


🔎 아이템 43 - 람다보다는 메서드 참조를 사용하라

1️⃣ 람다 사용 예

map.merge(key, 1, (count, incr) -> count + incr);

key 등장한 횟수 저장하는 멀티셋 같은 기능 구현하는 코드임
merge()

  • 키 없으면 {key, value} 그대로 저장
  • 키 있으면 remappingFunction(현재값, 새값) 적용해서 갱신

2️⃣ 같은 기능을 메서드 참조로

Integer.sum() 등장하면서 이런 코드 가능해짐

map.merge(key, 1, Integer::sum);

→ 람다보다 짧고 가독성 좋음

3️⃣ 메서드 참조가 좋은 이유

  • 코드 더 짧음
  • 의미 더 명확
  • 매개변수 이름 고민 안 해도 됨
  • 필요하면 별도 메서드 만들어 문서화 가능
  • 람다가 길어질수록 메서드 참조가 더 유리함

4️⃣ 그래도 람다가 더 나은 경우 있음

1) 매개변수 이름이 의미 전달할 때

  • 예: count, incr 같은 이름이 설명을 대신할 때는 람다가 더 읽힘

2) 메서드 참조가 더 길어지는 경우

service.execute(GoshThisClassNameIsHumongous::action);

보다

service.execute(() -> action());

이게 더 짧고 더 명확함

3) identity 함수

굳이 Function.identity()보다

x -> x

이게 훨씬 직관적임

정리: 메서드 참조는 람다의 간단명료한 대안이 될 수 있다. 메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라.


🔎 아이템 44 - 표준 함수형 인터페이스를 사용하라

1️⃣ 왜 표준 함수형 인터페이스를 먼저 써야 함?

자바 8부터 제공하는 함수형 인터페이스 세트가 이미 충분함
(Consumer, Function, Predicate, Supplier 등)

이걸 쓰면

  • 코드 일관성 올라감
  • API 사용성이 좋아짐
  • 다른 개발자가 봐도 동작 예측 쉬움
  • 불필요한 중복 인터페이스 줄어듦

-> 이미 있는 걸 굳이 만들 필요 없음.


2️⃣ 표준 함수형 인터페이스 주요 목록

✔ Consumer 계열 (반환값 없음)

인터페이스설명
ConsumerT 받고 아무것도 반환 안 함
BiConsumer<T,U>두 값 받음
IntConsumer / LongConsumer기본형 특화

✔ Supplier 계열 (입력 없음 → 반환만)

인터페이스설명
SupplierT 반환
BooleanSupplier / IntSupplier기본형 특화

✔ Function 계열 (입력 → 출력)

인터페이스설명
Function<T,R>T 입력 → R 출력
UnaryOperatorT → T
BinaryOperatorT,T → T
BiFunction<T,U,R>두 값 입력

✔ Predicate 계열 (조건 판단)

인터페이스설명
Predicateboolean 반환
BiPredicate<T,U>두 값 판단

3️⃣ 그래도 직접 만들어야 하는 경우

1) 표준 인터페이스가 동작을 정확히 표현하지 못할 때

예:

  • 입력/출력 타입 구조가 애매함
  • Function / Predicate 로는 의미가 명확히 안 보임
  • 도메인 의미를 명확히 전달해야 할 때

2) checked exception을 던져야 할 때

표준 함수형 인터페이스는 checked exception 허용 안 함
→ 특별히 throw 해야 하면 직접 만들어야 함

4️⃣ 직접 만들 때는 반드시 @FunctionalInterface 붙이기

@FunctionalInterface
public interface MyHandler {
    void handle(String message);
}

이 애너테이션 쓰면 얻는 이점:

  • 추상 메서드가 정확히 하나인지 컴파일 시점에 확인
  • 실수로 메서드 더 만들면 컴파일 오류
  • “이 인터페이스는 람다로 쓰려는 것이다” 라는 의도 명확해짐

5️⃣ 표준 함수형 인터페이스가 불편한 경우 (주의점)

❗ 이름이 너무 기술적임
Function<T,R> 같은 건 의미 전달이 약함
도메인 의미 드러내고 싶으면 직접 정의하는 게 더 읽기 좋음

❗ boolean만 반환해야 하는 제한
Predicate는 boolean만 반환 가능 → 다른 타입이면 애매해짐

❗ checked exception 사용 불가

예외 던지는 로직 필요하면 표준 인터페이스로는 처리 불가능


🔎 아이템 45 - 스트림은 주의해서 사용해라

스트림 API의 특징

  • 대량 데이터 처리를 간결하고 선언적으로 작성하도록 돕기 위해 도입됨 (Java 8)
  • 지연 평가(Lazy evaluation): 최종 연산이 실행될 때 중간 연산이 계산됨
  • 플루언트 API 지원: 메서드 체이닝으로 읽기 쉬움
  • 기본은 순차 실행, 필요하면 .parallel()로 병렬 처리 가능

하지만 “할 수 있다고 해서 해야 하는 것은 아니다”

  • 스트림으로 모든 걸 해결하려고 하면 가독성·유지보수성이 크게 떨어질 수 있음
  • 스트림은 함수형 방식(람다, 메서드 참조)로 계산을 표현하지만
    반복문에서 가능한 복잡한 로직은 스트림에서 오히려 표현하기 어려울 수 있음

스트림이 좋은 작업

스트림을 쓰면 다음 작업은 깔끔하고 직관적으로 처리됨:

  • 원소들을 일관된 방식으로 변환 (map)
  • 원소들을 필터링 (filter)
  • 원소들을 하나의 결과로 결합 (reduce)
  • 원소들을 컬렉션으로 수집 (collect)
  • 조건에 맞는 특정 원소 탐색 (findFirst, anyMatch)

➡️ 이런 작업은 스트림이 반복문보다 더 명확하고 짧아지고, 오류 가능성도 감소함

스트림이 어려운 작업

  • 데이터가 스트림의 여러 단계를 거칠 때,
    각 단계에서의 중간 값에 동시에 접근하거나 디버깅이 필요한 경우

  • 예:

    • 중간 결과를 여러 곳에서 써야 하는 복잡한 로직
    • stage 간 상호작용이 많은 알고리즘
    • 높은 가독성을 요구하거나 로직이 분기·예외가 많은 경우

➡️ 이런 경우는 일반 for문이 더 자연스럽고 이해하기 쉽다.


🔎 아이템 46 - 스트림에서는 부작용 없는 함수를 사용하라

스트림 패러다임은 계산 과정을 순수한 변환(Transformation)의 연속으로 재구성하는 방식이다.
따라서 각 단계의 함수는 이전 단계의 결과만을 입력으로 받아 처리하는 순수 함수(Pure Function)여야 한다.

즉, 스트림에 전달하는 모든 함수 객체는 부작용(사이드 이펙트)이 없어야 한다.

안 좋은 예시 — 스트림을 가장한 반복 코드

Map<String, Long> freq = new HashMap<>();

try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

왜 문제인가?

  • 모든 로직이 forEach 내부에서 수행됨
  • 외부 상태(freq)를 직접 수정 → 부작용 발생
  • 병렬 스트림에서 안전하지 않음
  • 사실상 스트림을 쓴 for-loop 대체품일 뿐

forEach는 계산이 아니라 결과를 보고(reporting)할 때만 사용해야 한다.

좋은 예시 — Collector를 이용한 올바른 스트림 코드

Map<String, Long> freq;

try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

왜 좋은가?

  • 모든 계산이 Collector를 통해 수행됨
  • 외부 상태 변경 없음 → 부작용 X
  • 병렬 스트림에서도 안전
  • 스트림 패러다임을 충실히 따름

✔️ Collector 기초

java.util.stream.Collectors는 스트림의 원소를 특정 형태로 모으는 다양한 메서드를 제공한다.

대표 예:

List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

주요 Collector 상세

1. toMap(keyMapper, valueMapper)

가장 기본적인 Map 수집기.

private static final Map<String, Operation> stringToEnum =
    Stream.of(values())
          .collect(toMap(Object::toString, e -> e));

특징

  • 키가 모두 고유할 때 적합
  • 키 충돌 시 IllegalStateException 발생


2. groupingBy

카테고리로 묶어 Map<K, List> 형태로 모음.

words.collect(groupingBy(w -> alphabetSize(w)));


3. partitioningBy

  • groupingBy의 Boolean 버전
  • Predicate로 분류
  • 결과: Map<Boolean, List<T>>
partitioningBy(str -> str.length() > 5)


4. joining

문자열 스트림 연결.

joining()                // 구분자 없이 그대로
joining(", ")            // 구분자 제공
joining(", ", "[", "]")  // prefix, suffix 가능

핵심 정리

  • 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 계산 자체에는 이용하지 말자. 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

🔎 아이템 47 - 반환 타임으로는 스트림보다 컬렉션이 낫다

자바 7까지는 이런 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다. 그런데 자바 8이 스트림이라는 개념을 들고 오면서 이 선택이 아주 복잡한 일이 되어버렸다.

원소 시퀀스를 반환할 때는 당연히 스트림을 사용해야 한다는 이야기를 들어봤을지 모르겠지만, 스트림은 반복(iteration)을 지원하지 않는다.

핵심 정리

  • 원소 시퀀스를 반환하는 메서드를 작성할 때는, 이를 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있을 수 있음을 떠올리고, 양쪽을 다 만족시키려 노력하자.
  • 1) 컬렉션을 반환할 수 있다면 그렇게 하라.
  • 2) 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하라.
  • 3) 그렇지 않으면 앞서의 멱집합 예처럼 전용 컬렉션을 구현할지 고민하라.
  • 4) 컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하라.
  • 5) 만약 나중에 Stream 인터페이스가 Iterable을 지원하도록 자바가 수정된다면 그때는 안심하고 스트림을 반환하면 될 것이다.

🔎 아이템 48 - 스트림 병렬화는 주의해서 사용하라

병렬화가 실패하는 경우

❌ 병렬화할 수 없는 스트림 연산

static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

primes().map(...)
        .filter(...)
        .limit(20)
        .forEach(System.out::println);

여기서 parallel()을 추가하면 아무것도 출력하지 못하고 무한히 CPU만 사용한다.

이유

  • Stream.iterate는 병렬 처리에 적합하지 않음
  • limit()과 조합되면 병렬화 전략을 찾기 어려움
    → 스트림 프레임워크가 “어디까지 읽어야 하는지” 계산할 수 없음

병렬화로 성능이 더 나빠질 수도 있다

예: limit(20) 이 붙은 상태에서 병렬 실행

  • 20번째 결과는 찾았는데도
    → 다른 스레드들이 21, 22, 23번째 작업을 계속 실행 중
  • 전체 작업 종료가 오히려 늦어진다

병렬화에 적합한 스트림의 특징

✔ 가장 병렬 효과가 좋은 소스

  • ArrayList
  • HashMap
  • HashSet
  • ConcurrentHashMap
  • 배열 (int[], long[])
  • 범위 기반 스트림 (IntStream.range, LongStream.range)

✔ 이유

  1. 손쉽고 정확하게 청크로 분할 가능
  2. 참조 지역성(Locality of reference)이 좋아 CPU 캐시 효율이 높음
    → 배열은 특히 연속적으로 저장되어 있어 가장 우수

핵심정리

  • 병렬화 룰을지키고 성능이 빨라질거라는 확신이 있을때만(꼭 테스트해봐야한다) 스트림 파이프라인 병렬화를 시도해라
  • 잘못 병렬화하면 프로그램이 오동작, 성능저하가 일어난다.
  • 병렬화했을때 명확하게 성능이 좋아지고 계산이 정확할때만 사용해라

참고 블로그:
https://github.com/back-end-study/effective-java/tree/main/7%EC%9E%A5_%EB%9E%8C%EB%8B%A4%EC%99%80_%EC%8A%A4%ED%8A%B8%EB%A6%BC

0개의 댓글