본문 바로가기
Effective Java

[Effective Java] 아이템45 스트림은 주의해서 사용하라

by byeongoo 2021. 6. 27.

■ 스트림 API 핵심

  • 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
  • 스트림 파이프 라인은 이 원소들이 수행하는 연산 단계를 표현하는 개념이다.

스트림 원소들은 어디로부터든 올 수 있다. 대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매치, 난수 생성기, 혹은 다른 스트림이 있다.

 

■ 스트림 파이프라인 특징

스트림 파이프라인은 소스스트림 -> (중간연산) -> 종단연산 으로 이루어진다.

중간연산을 합친 다음에 합쳐진 중간연산을 최종 연산으로 한번에 처리 -> Lazy

 

◈ 중간연산

스트림을 변환하며 결과 스트림의 원소 타입은 시작 스트림의 원소 타입과 같을 수도 있고, 다를 수도 있다.

 

1. sorted

Stream<Integer> sorted = operands.stream().sorted();

 

2. filter

 

Stream<Integer> integerStream = operands.stream().filter((value) -> value > 2);

 

3. map : 요소들을 변경하여 새로운 컨텐츠를 생성하는 기능

 (ex) 소문자를 대문자로 변경

list.map(s -> s.toUpperCase());

 

◈ 종단연산

마지막 중간 연산의 스트림에 최후의 연산. 1개 이상의 중간연산들은 계속합쳐진 후 종단연산 시 수행된다.

즉, 스트림 파이프라인은 지연평가(lazy evaluation)된다. 

  • 종단 연산이 없는 파이프라인은 어떤 연산도 수행되지 않는다.
  • 지연평가는 무한 스트림을 다룰 수 있게 해주는 열쇠다.

 

1. forEach() : 요소의 출력

intList.stream().forEach(System.out::println); // 1,2,3
intList.stream().forEach(x -> System.out.printf("%d : %d\n",x,x*x)); // 1,4,9

2. reduce() : 요소의 소모

두개의 인자(n n+1)을 가지며 연산과는 n이 되고 다시 다음 요소와 연산을 한다.

int sum = intList.stream().reduce((a,b) -> a+b).get();
System.out.println("sum: "+sum);  // 6

3. findFirst(), findAny() : 요소의 검색

스트림에서 지정한 첫번째 요소를 찾는 메서드이다.

strList.stream().filter(s -> s.startsWith("H")).findFirst().ifPresent(System.out::println);  //Hwang
strList.parallelStream().filter(s -> s.startsWith("H")).findAny().ifPresent(System.out::println);  //Hwang or Hong

4. anyMatch(), allMatch(), noneMatch() : 요소의 검사

스트림의 요소중 특정 조건을 만족하는 요소를 검사하는 메서드. 원소중 일부, 전체 혹은 일치하는 것이 없는 경우를 검사하고 boolean 값을 리턴한다. noneMatch()의 경우 일치하는 것이 하나도 없을때 true.

boolean result1 = strList.stream().anyMatch(s -> s.startsWith("H"));  //true
boolean result2 = strList.stream().allMatch(s -> s.startsWith("H"));  //false
boolean result3 = strList.stream().noneMatch(s -> s.startsWith("T")); //true
System.out.printf("%b, %b, %b",result1,result2, result3);

 

5. count(), min(), max() : 요소의 통계

스트림의 원소들로부터 전체 갯수, 최소값, 최대값을 구하기 위한 메서드

intList.stream().count();	// 3
intList.stream().filter(n -> n !=2 ).count(); 	// 2
intList.stream().min(Integer::compare).ifPresent(System.out::println);; 		// 1
intList.stream().max(Integer::compareUnsigned).ifPresent(System.out::println);; // 3

strList.stream().count();	// 3
strList.stream().min(String::compareToIgnoreCase).ifPresent(System.out::println);	// Hong
strList.stream().max(String::compareTo).ifPresent(System.out::println);	// Kang

6. sum(), average() : 요소의 연산

스트림의 원소들의 합계를 구하거나 평균을 구하는 메서드이다.

 

intList.stream().mapToInt(Integer::intValue).sum();	// 6
intList.stream().reduce((a,b) -> a+b).ifPresent(System.out::println); // 6

intList.stream().mapToInt(Integer::intValue).average();	// 2
intList.stream().reduce((a,b) -> a+b).map(n -> n/intList.size()).ifPresent(System.out::println); // 2

7. collect() : 요소의 수집

스트림의 결과를 모으기 위한 메서드로 Collectors 객체에 구현된 방법에 따라 처리하는 메서드이다. 최종 처리 후 데이터를 변환하는 경우가 많기 때문에 잘 알아 두어야한다. 용도별로 사용할 수 있는 Collectors의 메서드는 기능별로 다음과 같다.

  • 스트림을 배열이나 컬렉션으로 변환 : toArray(), toCollection(), toList(), toSet(), toMap()
  • 요소의 통계와 연산 메소드와 같은 동작을 수행 : counting(), maxBy(), minBy(), summingInt(), averagingInt() 등
  • 요소의 소모와 같은 동작을 수행 : reducing(), joining()
  • 요소의 그룹화와 분할 : groupingBy(), partitioningBy()
strList.stream().map(String::toUpperCase).collect(Collectors.joining("/"));	 	// Hwang/Hong/Kang
strList.stream().collect(Collectors.toMap(k -> k, v -> v.length()));	// {Hong=4, Hwang=5, Kang=4}

intList.stream().collect(Collectors.counting());
intList.stream().collect(Collectors.maxBy(Integer::compare));
intList.stream().collect(Collectors.reducing((a,b) -> a+b));	// 6
intList.stream().collect(Collectors.summarizingInt(x -> x));	//IntSummaryStatistics{count=3, sum=6, min=1, average=2.000000, max=3}

Map<Boolean, List<String>> group = strList.stream().collect(Collectors.groupingBy(s -> s.startsWith("H")));
group.get(true).forEach(System.out::println);  // Hwang, Hong

Map<Boolean, List<String>> partition = strList.stream().collect(Collectors.partitioningBy(s -> s.startsWith("H")));
partition.get(true).stream().forEach(System.out::println);  // Hwang, Hong

 

 

스트림 파이프 라인은 지연 평가된다 평가는 종단 연산이 호출될 때 이루어지며 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 종단 연산이 없으면 아무일도 수행되지 않는다. 따라서 종단 연산을 빼먹는 일이 절대 없도록 하자.

 

다음 코드는 중간 연산만 실행되는 코드이다.

import java.util.stream.Stream;

public class StreamTest1 {

    public static void main(String[] args) {
        Stream.of("d2", "a2", "b1", "b3", "c")
                .map(s -> {     //중간연산
                    System.out.println("map: " + s);
                    return s.toUpperCase();
                })
                .filter(s -> {  //중간연산
                    System.out.println("filter: " + s);
                    return s.startsWith("A");
                });
    }

}

중간연산만 있을 경우 아무것도 수행되지 않았다.

 

여기에 종단 연산을 넣으면 아래 코드와 같다.

import java.util.stream.Stream;

public class StreamTest1 {

    public static void main(String[] args) {
        Stream.of("d2", "a2", "b1", "b3", "c")
                .map(s -> {     //중간연산
                    System.out.println("map: " + s);
                    return s.toUpperCase();
                })
                .filter(s -> {  //중간연산
                    System.out.println("filter: " + s);
                    return s.startsWith("A");
                }).forEach(s -> System.out.println("필터 결과 : " + s)); //종단 연산
        ;
    }

}

수행 결과 A2만 최종 결과에 출력되는 것을 볼 수 있다. 기본적으로 스트림은 순차적으로 수행되며 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수도 있다.

 

■ 스트림 사용시 주의

대부분의 연산은 스트림으로 구현할 수 있다. 하지만 과도한 스트림은 읽기도 어렵고 유지보수도 힘들다. 또한 성능상 좋지 않을 수 있다. 아래 코드처럼 스트림을 과하게 사용하면 읽기 힘들다. 무조건 스트림만 사용하는 것이 아니라 절충 지점을 찾아야한다

public static void anagram(List<String> words){
	words.stream().collect(Collectors.groupingBy(word ->
					word.chars().sorted().collect(StringBuilder::new, (sb, c) -> sb.append((char) c),
							StringBuilder::append).toString()))
			.values().stream()
			.map(group -> group.size() + ": " + group)
			.forEach(System.out::println);
}

 

또한 자바는 char용 스트림을 지원하지 않는다.

IntStream chars = "Hello".chars(); // IntStream이 반환된다.

 

스트림을 처음 사용하면 모든 반복문을 스트림으로 바꾸고 싶지만 가독성과 유지보수 측면에서 손해를 볼 수 있기 때문에 중간 정도 복잡한 작업에서 스트림과 반복문을 적절히 조합해서 사용하는게 최선이다. 즉, 기존 코드는 스트림으로 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.

 

■ 스트림이 적절할 때

아래의 일 중 하나를 수행하는 로직이라면 스트림을 적용하기에 좋은 후보이다.

 

1. 원소들의 시퀀스를 일관성 있게 변환할 때

2. 원소들의 시퀀스를 필터링할 때

3. 원소들의 시퀀스를 연산 후 결합할 때

4. 원소들의 시퀀스를 모을 때

5. 원소들의 시퀀스 중 특정 조건을 만족하는 원소를 찾을 때

 

■ 스트림으로 처리하기 어려운 경우

원본 스트림을 계속 써야할 때 : 스트림은 중간 연산을 지나고 나면 원래 스트림을 잃는 구조이다. 파이프라인의 순서를 바꿈으로써 해결할 수 있는지 고민해보자.

 

REFERENCE