본문 바로가기
스터디/이펙티브 자바

[이펙티브 자바] 06. 람다와 스트림

by 디토20 2022. 9. 14.
반응형

 

 

 

 

[이펙티브 자바] 06. 람다와 스트림

 

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

  • 예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스(드물게는 추상 클래스)를 사용했다.
  • 이런 인터페이스를 함수 객체(function object)라고 하여, 특정 함수나 동작을 나타내는 데 썼다.

 

익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다!

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

 

  • 전략 패턴처럼, 함수 객체를 사용하는 과거 객체 지향 디자인 패턴에는 익명 클래스면 충분했다.
  • 하지만 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍에 적합하지 않았다.
  • 자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받았고, 지금은 함수형 인터페이스라 부르는 이 인터페이스들의 인스턴스를 람다식(lambda expression)을 사용해 만들 수 있게 되었다.
  • 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.

 

람다식을 함수 객체로 사용 - 익명 클래스 대체

Collections.sort(words, 
	(s1, s2) -> Integer.compare(s1.length(), s2.length()));

 

  • 여기서 람다, 매개변수(s1, s2), 반환값의 타입은 각각 (Comparator<String>), String, int지만 코드에서는 언급이 없다. 이는 컴파일러가 문맥을 살펴 타입을 추론해준 것이다.
  • 상황에 따라 컴파일러가 타입을 결정하지 못할 수도 있는데, 그럴 때는 프로그래머가 직접 명시해야 한다.
  • 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하도록 한다.
  • 컴파일러는 타입을 추론하는 데 필요한 타입 정보 대부분을 제네릭에서 얻는다.
  • 따라서 우리가 이 정보를 제공하지 않으면 컴파일러는 람다의 타입을 추론할 수 없게 되어, 결국 일일이 타입 정보를 명시해야 한다.

 

 

람다 자리에 비교자 생성 메서드를 사용하면 더 간결하다!

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

 

 

 

함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입

public enum Operation {
    PLUS("+", (x, y) -> x + y),
    MINUS("-", (x, y) -> x - y),
    TIMES("*", (x, y) -> x * y),
    DIVIDE("/", (x, y) -> x / y);

    private final String symbol;
    private final DoubleBinaryOperator operator;

    Operation(String symbol, DoubleBinaryOperator operator) {
        this.symbol = symbol;
        this.operator = operator;
    }

    @Override
    public String toString() {
        return symbol;
    }

    public double apply(double x, double y) {
        return operator.applyAsDouble(x, y);
    }
}

 

  • 람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로 상수별로 다르게 동작하는 코드를 쉽게 구현할 수 있다.
  • 단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.
  • 그런 다음 apply 메서드에서 필드에 저장된 람다를 호출하기만 하면 된다.

 

람다를 사용할 때의 고려할 점

1. 메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.

  • 람다는 한 줄일 때 가장 좋고 길어야 세 줄안에 끝내는 게 좋다. 세 줄을 넘어가면 가독성이 심하게 나빠진다.
  • 만약 람다가 길거나 읽기 어렵다면 더 간단히 줄여보거나 람다를 쓰지 않는 쪽으로 리팩터링 하도록 한다.

 

2. 열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일타임에 추론된다. 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다(인스턴스는 런타임에 만들어지기 때문이다).

  • 상수별 동작을 단 몇 줄로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.

 

3. 람다는 함수형 인터페이스에서만 쓰인다.

  • 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니, 익명 클래스를 써야 한다.
  • 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 쓸 수 있다.

 

4.람다는 자신을 참조할 수 없다.

  • 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다.
  • 반면 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.
  • 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.

 

5. 람다도 익명 클래스처럼 직렬화 형태가 구현별로(가령 가상머신별로) 다를 수 있기 때문에, 람다를 직렬화하는 일은 극히 삼가야 한다(익명 클래스의 인스턴스로 마찬가지다).

  • 만약 직렬화해야만 하는 함수 객체가 있다면(가령 Comparator처럼) private 정적 중첩 클래스의 인스턴스를 사용하도록 한다.

 

 

 

 

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

  • 람다가 익명 클래스보다 나은 점 중에서 가장 큰 특징은 간결함이다.
  • 자바에는 함수 객체를 람다보다도 더 간결하게 만드는 방법은 메서드 참조(method reference)다.

 

메서드 참조

  • 자바 8이 되면서 Interger 클래스와 모든 기본 타입의 박싱 타입은 람다와 기능이 같은 정적 메서드들을 제공하기 시작했다.
  • 람다 대신 이 메서드의 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.
  • 람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다.
  • 그래도 메서드 참조를 사용하는 편이 보통은 더 짧고 간결하므로, 람다로 구현했을 때 너무 길거나 복잡하다면 메서드 참조가 좋은 대안이 될 수 있다.
  • 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 식이다.
  • 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고 설명을 문서로 남길 수도 있다.

 

메서드와 람다가 같은 클래스에 있을 때는 람다가 메서드 참조보다 간결하다.

service.execute(GoshThisClassNameIsHumongous::action);

위 코드를 메서드로 대체하면 아래와 같다.

 

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

메서드 참조 쪽은 더 짧지도, 더 명확하지도 않다. 따라서 람다 쪽이 낫다.

 

 

메서드 참조의 유형

  • 메서드 참조의 유형은 다섯 가지이다.
    1. 정적 메서드를 가리키는 메서드 참조하는 유형
    2. 수신 객체(receiving object; 참조 대상 인스턴스)를 특정하는 한정적(bound) 인스턴스 메서드 참조.
      • 한정적 참조는 근본적으로 정적 참조와 비슷하다. 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다.
    3. 수신 객체를 특정하지 않는 비한정적(unbound) 인스턴스 메서드 참조.
      • 비한정적 참조에서는 함수 객체를 적용하는 시점에 수신 객체를 알려준다.
      • 이를 위해 수신 객체 전달용 매개변수가 매개변수 목록의 첫 번째로 추가되며, 그 뒤로는 참조되는 메서드 선언에 정의된 매개변수들이 뒤따른다.
      • 비한정적 참조는 주로 스트림 파이프라인에서의 매핑과 필터 함수에 쓰인다.
    4. 클래스 생성자를 가리키는 메서드 참조. 생성자 참조는 팩터리 객체로 사용된다.
    5. 배열 생성자를 가리키는 메서드 참조.

 

메서드 참조 유형 같은 기능을 하는 람다
정적 Integer::parseInt str -> Integer.parseInt(str)
한정적(인스턴스) Instant.now()::isAfter Instant then Instant.now();
t -> then.isAfter(t)
비한정적(인스턴스) String::toLowerCase str -> str.toLowerCase()
클래스 생성자 TreeMap<K,V>::new () -> new TreeMap<K,V>()
배열 생성자 int[]::new len -> new int[len]

 

 

 

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

  • 자바가 람다를 지원하면서 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력이 크게 줄었다.
  • 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것으로 대체할 수 있기 때문이다.
  • 이때, 함수형 매개변수 타입을 올바르게 선택해야 한다.

 

불필요한 함수형 인터페이스 - 대신 표준 함수형 인터페이스를 사용하라.

@FunctionalInterface
public interface EldestEntryRemovalFunction<K, V> {
    boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}

 

  • 위 인터페이스는 잘 동작하지만, 굳이 사용할 이유는 없다. 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
  • java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다.
  • 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하도록 한다.
  • 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 좋아질 것이다.

 

표준 함수형 인터페이스

인터페이스 함수 시그니처
UnaryOperator<T> T apply(T t) String::toLowerCase
Binaryoperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

 

  • Operator 인터페이스는 인수가 1개인 UnaryOperator와 2개인 BinaryOperator로 나뉘며, 반환값과 인수의 타입이 같은 함수를 뜻한다.
  • Predicate 인터페이스는 인수 하나를 받아 boolean을 반환하는 함수를 뜻한다.
  • Function 인터페이스는 인수와 반환 타입이 다른 함수를 뜻한다.
  • Supplier 인터페이스는 인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
  • Consumer 인터페이스는 인수를 하나 받고 반환값은 없는, 인수를 소비하는 함수를 뜻한다.

 

표준 함수형 인터페이스 사용시 주의사항

  • 표준 함수형 인터페이스 대부분은 기본 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 않도록 한다.
  • 계산량이 많을 때는 성능이 처참히 느려질 수 있다.
  • 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안 된다. 클라이언트에게 불필요한 모호함만 줄 뿐이며, 이 때문에 실제로 문제가 일어나기도 한다.
  • 표준 인터페이스 중 필요한 용도에 맞는 게 없다면 직접 작성해야 하며, 구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야만 할 때가 있다.

 

언제 전용 함수형 인터페이스를 구현해야 할까?

아래 세 특성중 한가지 이상을 만족한다면 전용 함수형 인터페이스 구현에 대해 진지하게 고민해보자.

 

  • 자주 쓰이며, 이름 자체가 용도를 명확히 설명해 준다.
  • 반드시 따라야 하는 규약이 있다.
  • 유용한 디폴트 메서드를 제공할 수 있다.

만약 전용 함수형 인터페이스를 작성하기로 했다면, '인터페이스'임을 명심해야 한다. 아주 주의해서 설계해야 한다.

 

@FunctionalInterface 애너테이션

이 애너테이션을 사용하는 이뉴는 @Override를 사용하는 이유와 비슷하다. 프로그래머의 의도를 명시하는 것으로, 크게 세 가지 목적이 있다.

 

  • 첫 번째. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
  • 두 번째. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.
  • 세 번째. 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

따라서, 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하도록 한다.

 

 

 

 

아이템 45. 스트림은 주의해서 사용하라

  • 스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 위해 자바 8에 추가되었다.
  • 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻한다.
  • 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다.
  • 기본 타입 값으로는 int, long, double 이렇게 세 가지를 지원한다.

 

스트림 파이프라인

  • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝난다.
  • 스트림의 시작과 종단 연산의 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있다.
  • 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform)한다.
  • 스트림 파이프라인은 지연 평가(lazy evaluation)된다.
  • 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
  • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으므로, 종단 연산은 필수다.
  • 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있으며, 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
  • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
  • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 된다. 다만, 효과를 볼 수 있는 상황은 많지 않다.

 

 

사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력한다 - 스트림 사용 x

public class Anagrams {
    public static void main(String[] args) {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        Map<String, Set<String>> groups = new HashMap<>();
        try (Scanner scanner = new Scanner(dictionary)) {
            while (scanner.hasNext()) {
                String word = scanner.next();
                groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
            }
        }

        for (Set<String> group : groups.values()) {
            if (group.size() >= minGroupSize) {
                System.out.println(group.size() + ": " + group);
            }
        }
    }

    private static String alphabetize(String word) {
        char[] a = word.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

 

 

스트림을 과하게 사용한 코드

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> word.chars().sorted()
                            .collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString()))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .map(group -> group.size() + ": " + group)
                    .forEach(System.out::println);
        }
    }
}

 

  • 짧지만 읽기가 어렵다.
  • 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

 

 

스트림을 적절히 활용하면 깔끔하고 명료해진다

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> alphabetize(word)))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .forEach(g -> System.out.println(g.size() + ": " + g));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static String alphabetize(String word) {
        char[] a = word.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

 

  • 람다 매개변수의 이름은 주의해서 정해야 한다.
  • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다.
  • 자바는 기본 타입인 char용 스트림을 지원하지 않는다. 따라서 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
  • 스트림이 언제나 가독성과 유지보수 측면으로 뛰어난 것은 아니다.
  • 스트림과 반복문을 적절히 조합하는 게 최선이다.
  • 따라서 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하도록 한다.

 

함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일

  • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
  • 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
  • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
  • 또한, 메서드 선언에 명시된 검사 예외를 던질 수 있다.
  • 람다로는 이 중 어떤 것도 할 수 없다.

계산 로직에서 이상의 일들을 수행해야 한다면 스트림과는 맞지 않는 것이다.

 

 

스트림이 안성맞춤인 일

  • 원소들의 시퀀스를 일관되게 변환한다.
  • 원소들의 시퀀스를 필터링한다.
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등).
  • 원소들의 시퀀스를 컬렉션에 모은다(아마도 공통된 속성을 기준으로 묶어가며).
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

 

스트림으로 처리하기 어려운 일

  • 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하는 것은 처리하기 어렵다.
  • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.

 

 

 

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

  • 스트림은 함수형 프로그래밍에 기초한 패러다임이다.

 

스트림 패러다임

  • 스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.
  • 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
  • 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.
  • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
  • 이렇게 하려면 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.

 

스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라하지 말 것!

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에서 일어나는데, 이때 외부 상태(빈도표)를 수정하는 람다를 실행하면서 문제가 생긴다.

 

스트림을 제대로 활용해 빈도표를 초기화한다

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

 

  • for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다.
  • 하지만 forEach 연산은 종단 연산 중 기능이 가장 적고 대놓고 반복적이라서 병렬화할 수도 없다.
  • forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말도록 한다.

 

수집기(Collector)

  • java.util.stream.Collectors 클래스는 스트림의 원소들을 객체 하나에 취합하는 여러 메서드를 제공해준다.
  • 이를 이용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있으며, toList(), toSet(), toCollection(collectionFactory) 등이 있다.
  • Collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인의 가독성을 향상시킬 수 있다.

 

 

 

아이템 47. 반환 타입으로는 스트림보다 컬렉션이 낫다

  • 자바 7까지는 일련의 원소들을 반환하는 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다.
  • 기본은 컬렉션 인터페이스이며, for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때(주로 contains(Object) 같은)는 Iterable 인터페이스를 썼다.
  • 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다.

 

반복

  • 스트림은 반복(iteration)을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
  • Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하며, Iterable 인터페이스가 정의한 방식대로 동작한다.
  • 다만, for-each로 반복할 수 없는 이유는 Stream이 Iterable을 extends하지 않았기 때문이다.

 

 

자바 타입 추론의 한계로 컴파일되지 않는다

for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// 프로세스를 처리한다.

 

 

스트림을 반복하기 위한  '끔찍한' 우회 방법

for (ProcessHandle ph : (Iterable<ProcessHandle>) 
			ProcessHandle.allProcesses()::iterator) {
    // 프로세스를 처리한다.
}

작동은 하지만 난잡하고 직관성이 떨어진다.

 

 

Stream<E>를 Iterable<E>로 중개해주는 어댑터

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}


for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
            // 프로세스를 처리한다.
}

 

  • 어댑터 메서드를 사용하면 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
  • 어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.

 

Iterable<E>를 Stream<E>로 중개해주는 어댑터

    public static <E> Stream<E> streamOf(Iterable<E> iterable) {
        return StreamSupport.stream(iterable.spliterator(), false);
    }

 

  • 만약 API가 Iterable만 반환하면 스트림 파이프라인에서 이를 처리할 수 없게 된다. 이 경우, 위와 같이 어댑터를 구현해서 사용할 수 있다.
  • 만약 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 마음 놓고 스트림을 반환해도 된다.
  • 반대로 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하도록 한다.
  • 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 고려해야 한다.
  • Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하기 때문에 반복과 스트림을 동시에 지원한다.
  • 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.
  • 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수도 있다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

 

 

 

 

아이템 48. 스트림 병렬화는 주의해서 적용하라

동시성 프로그래밍을 할 때는 안전성(safety)과 응답 가능(liveness) 상태를 유지하도록 해야 한다. 이는 병렬 스트림 파이프라인 프로그래밍에서도 마찬가지다.

 

 

스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램

public static void main(String[] args) {
    primes().map(prime -> TWO.pow(prime.intValue()).subtract(ONE))
            .filter(mersenne -> mersenne.isProbablePrime(50))
            .limit(20)
            .forEach(System.out::println);
}

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

 

  • 위 코드의 속도를 높이고 싶어서 parallel을 호출하면 프로그램은 병렬화하는 방법을 찾지 못해 마비된다.
  • 환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
  • 파이프라인 병렬화는 limit를 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.
  • 스트림 파이프라인을 마구잡이로 병렬화하면 안 된다. 성능이 오히려 나빠질 수도 있다.
  • 대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.
  • 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손 쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기 좋다.
  • 이 자료구조들은 원소들을 순차적으로 실행할 때의 참조 지역성(locality of reference)이 뛰어나다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.
  • 하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 떨어진다.

 

 

참조 지역성

  • 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 멍하게 있는다.
  • 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.
  • 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다. 기본 타입 배열에서는 참조가 아닌 데이터 자체가 메모리에 연속해서 저장되기 때문이다.

 

스트림 파이프라인의 종단 연산

  • 스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.
  • 종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수밖에 업다.
  • 종단 연산 중에서 병렬화에 가장 적합한 것은 축소(reduction)다. 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업이다.
  • Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.
  • anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.
  • 가변 축소(mutable reduction)를 수행하는 Stream의 collect 메서드는 컬렉션들을 합치는 부담이 크기 때문에 병렬화에 적합하지 않다.

 

성능 저하 및 안전 실패

  • 스트림을 잘못 병렬화하면 응답 불가 상태에 빠지거나 성능이 나빠질 수 있고, 결과 자체가 잘못 되거나 예상치 못한 동작이 발생할 수 있다.
  • 결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure)라고 한다.
  • 안전 실패는 병렬화한 파이프라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있다.
  • Stream 명세는 이때 사용되는 함수 객체에 관해 엄격한 규약을 정의해놨다.
  • ex. Stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합법칙을 만족(associative)하고, 간섭받지 않고(non-interfering), 상태를 갖지 않아야(stateless) 한다.
  • 데이터 소스 스트림이 효율적으로 나눠지고, 병렬화하거나 빨리 끝나는 종단 연산을 사용하고, 함수 객체들도 간섭하지 않더라도, 파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있다.

병렬화 한 forEach의 순서는 순서가 올바르지 않을 수 있어, 순차 버전처럼 정렬하고 싶다면 종단 연산 forEach를 forEachOrdered로 바꿔주면 된다.

 

 

최적화

  • 스트림 병렬화는 오직 성능 최적화의 수단일 뿐이다. 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
  • 보통은 병렬 스트림 파이프라인도 공통의 포크 조인 풀에서 수행되므로(같은 스레드 풀을 사용하므로), 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있다.
  • 스트림 병렬화가 효과를 보는 경우는 많지 않으나, 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 얻을 수 있다. -> 머신러닝과 같은 데이터 처리

 

 

 

 

 

 

 

728x90
반응형

댓글