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

최강일·2024년 9월 19일
0

Effective Java

목록 보기
16/17

스트림 api는 다량의 데이터 처리 작업을 돕고자 java8에 추가되었다.
이 api가 제공하는 추상 개념 중 핵심은 2가지다.

🐧 스트림의 추상 개념
1. 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
2. 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다. (1번의 원소)

스트림 원소

스트림의 원소들은 어디로부터든 올 수 있다.
대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림이 있다.
스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다

스트림 파이프라인

스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.

1. 중간 연산

각 중간연산은 스트림을 어떠한 방식으로 변환한다.
ex. 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있다.

중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다.

filter(), map(), sorted()

2. 종단 연산

종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다.
원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.

forEach(), collect(), match(), count(), reduce()

스트림 간단 예제(추가)

List<Integer> transactionsIds = 
    transactions.stream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .sorted(comparing(Transaction::getValue).reversed())
                .map(Transaction::getId)
                .collect(toList());


출처 : https://www.geeksforgeeks.org/java-8-stream-tutorial/

스트림 파이프라인 특징

지연 평가

스트림 파이프👇라인은 지연 평가된다.
평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.
종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op와 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.

지연평가
결과값이 필요할때까지 계산을 늦추는 기법
대용량 데이터에서 실제로 필요하지 않은 데이터들을 탐색하는 것을 방지해 속도를 높일 수 있다.
즉, 종단 연산에 안쓰이는 원소는 계산 자체에 안쓴다.

플로언트 api

스트림 api는 메서드 연쇄를 지원하는 플루언트 api다.
즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.

병렬 사용 효과 👇

기본적으로 스트림 파이프라인은 순차적으로 실행된다.
파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면되나, 효과를 볼 수 있는 상황은 많지 않다.

스트림은 언제 써야하는가

스트림 api는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있다.
하지만 할 수 있다는 뜻이지, 해야 한다는 뜻은 아니다.
스트림을 제대로 활용하면 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.
스트림을 언제 써야한다는 규칙은 없지만 참고할만한 노하우가 있다.

1. 모든 반복문을 스트림으로 바꾸기 보다, 반복문과 스트림을 적절히 조합하자.

사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력한다.

아나그램
철자를 구성하는 알파벳이 같고 순서만 다른 단어

예제코드

public class Anagrams {
	  public static void main(String[] args) throws IOException {
      File dictionary = new File(args[0]);
      int minGroupSize = Interger.parseInt(args[1]);
      
      Map<String, Set<String>> groups = new HashMap<>();
      try(Scanner s = new Scanner(dictionary)){
      	while(s.hasNext()){
        	String word = s.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);
    }
    
    public static String alphabetie(String s){
    	char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

과사용 예제

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);
        }
    }
}

코드를 이해하기 어렵다. 정상이다.
사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 처리된다.
이처럼 스트림을 과용하면 읽거나 유지보수하기가 어려워진다.

스트림을 적절하게 활용하기

 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));
  }

(스트림을 전에 본 적 없더라도 이해하기 쉬울 것이다.)
alphabetize() 과 같은 세부 구현은 주 프로그램 로직 밖으로 빼내 전체적인 가독성을 높였다.
이처럼 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 커진다.

2. char 값을 처리할 때는 스트림을 사용하지 말자.

자바는 기본 타입인 char용 스트림을 지원하지 않는다.
따라서 아래 코드를 실행시켜보면 정수값이 출력된다.
참고로 boolean[], byte[], short[], char[], float[] 도 해당 타입의 기본형 스트림이 존재하지 않는다.

// 정수값이 출력됨 : 739488237102..
"Hello World!".chars().forEach(System.out::print);

☁️ 스트림의 적절한 활용

스트림 파이프라인은 되풀이 되는 계산을 주로 함수 객체(람다/메서드 참조)로 표현하고 반복 코드에는 코드 블록을 사용해 표현한다.(for문)

스트림으로 처리하기 힘든 경우

스트림과 함수 객체로는 할 수 없지만, 코드 블록으로는 할 수 있는 경우를 구분하면 다음과 같다.

  1. 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다. 하지만 람다에서는 사실상 final 인 변수만 읽을 수 있고, 지역변수 수정은 불가능하다.

  2. 코드 블록에서는 return / break / continue 문으로 블록의 흐름을 제어하거나, 메서드 선언에 명시된 검사 예외를 던질 수 있다. 하지만 람다는 불가능하다.

스트림을 적용하기 좋은 경우

  1. 원소들의 시퀀스를 일관되게 변환해야 하는 경우 : map()
  2. 원소들의 시퀀스를 필터링 해야 하는 경우 : filter()
  3. 원소들의 시퀀스를 하나의 연산을 사용해 결합해야 하는 경우(더하기, 연결하기, 최솟값 구하기 등) : reduce(), sum(), max()...
  4. 원소들의 시퀀스를 컬렉션에 모으는 경우(공통된 속성을 기준으로) : collect()
  5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾을 경우 : filter()

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

한 데이터가 파이프라인의 여러 단계를 통과해야할때, 이 데이터의 각 단계에서의 값들에 동시에 접근하는 경우에는 스트림을 사용하기 힘들다.
파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.
(흐름이기때문... 위 처리하기 힘든 1번과 같은 맥락)

🐼 핵심 정리
스트림과 반복 중 어느쪽이 나은지 확신하기 어렵다면, 둘 다 테스트해보고 더 나은 쪽을 택하는 것이 좋다.

profile
Search & Backend Engineer

0개의 댓글