[Modern Java In Action] - Chapter 4 : Introducing streams

청주는사과아님·2024년 8월 4일
0
post-thumbnail

4 장은 스트림이 무엇인지, 핵심적인 개념은 어떤 것이 있는지 설명한다.

스트림을 깊게 파고들기 전 개요와 필수 지식들을 알려주는 목적으로 4 장을 구성한 듯 하다.


A. 스트림이란?

교재는 스트림을 “선언적 관리 방식의 데이터 처리 작업이 가능한 어느 일련의 원소들” 라고 정의한다.

📜 Stream is a sequence of elements from a source that supports data-processing operations.
Streams let you manipulate collections of data in a declarative way.

여기서 “선언적” 이라는 건 도대체 뭘 의미하는 걸까?

예시를 통해 이를 알아보자.

/* Before Java 8 */

List<String> lowCaloricDishes = new ArrayList<>();

for (Dish dish : menu)                // menu : List<Dish>
    if (dish.getCalories() < 400)
        lowCaloricDishes.add(dish);

Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
    @Override
    public int compare(Dish o1, Dish o2) {
        return Integer.compare(o1.getCalories(), o2.getCalories());
    }
});

List<String> lowCaloricDishesNames = new ArrayList<>();

for (Dish dish : lowCaloricDishes)
    lowCaloricDishesNames.add(dish.getName());

/* Seriously.. Do I have to do these every time? */
/* All that for just a names with certain order?? */
/* What if i have to get calories, instead of names? */

먼저 위 코드는 매우 난잡해 보인다. 코드를 잘 보면 결국, menu 내 원소 중 칼로리가 400 이하인 원소들을 칼로리 순으로 정렬해, 그들의 이름을 저장하는 것을 알 수 있다.

→ (400 이하, 칼로리 순 정렬, 이름만 저장)

더불어 만약 칼로리가 500 이하, 이름이 아닌 다른 속성 또는 전체 개수를 3 개로 제한 같은 요구사항이 발생하면, 위 for 또는 if 문 내 코드를 각 요구사항에 맞춰 매번 수정해야 한다.

즉, 코드가 너무 구체적인 방법 (how) 에 집중되어 있다는 것이다.

/* Onward Java 8 */

List<String> lowCaloricDishesNames 
                      = menu.parallelStream()
                            .filter(dish -> dish.getCalories() < 400)
                            .sorted(Comparator.comparing(Dish::getCalories))
                            .map(Dish::getName)
                            .collect(Collectors.toUnmodifiableList());

하지만 Java 8 이후, 스트림을 이용하면 how 에 집중된 코드가 아니라 what 에 집중된 코드를 만들 수 있다.

위 코드를 보면 filter( ... ), sorted( ... ), map( ... ) 처럼 무엇을 수행하는지 직관적으로 알 수 있으며, 각 메서드들이 chaining 된 것을 볼 수 있다.

교재는 위처럼 무엇을 수행 하는지에 초점이 맞춰진 코드를 ”선언적 방식으로 작성된 코드” (code written in a declarative way) 라 부른다.

그래서 스트림의 정의를 아주 날것으로 표현하면, “filter, sorted 등의 데이터 처리 작업을 이해하기 쉽게 서술된 (선언적 방식) 일련의 원소들” 라고 할 수 있다.


B. 스트림의 특징

스트림의 정의를 통해 이들의 특징을 알아보자.

📜 Stream is a sequence of elements from a source that supports data-processing operations.

  • i. 일련의 원소를 제공 가능함 - Sequence of Elements

    List, Set 등의 자료구조 처럼, 스트림은 특정 타입의 값들을 순차적인 집합으로 제공할 수 있다. (Set.values() 처럼 값들을 제공(?) 해 줄 수 있다는 뜻)

    다만 컬렉션 (자료구조) 들은 일정 시•공간 복잡도 내에서 데이터를 저장하는게 목적인 반면, 스트림은 데이터를 계산, 처리하는 데 목적이 있다.

  • ii. 데이터 원천 - Data Source

    스트림은 컬렉션, 배열, I/O 자원 등으로 부터 데이터를 제공받고 소비한다.

  • iii. 데이터 처리 작업 - Data-processing Operations

    스트림은 DB 쿼리 (database-like operations) 와 유사한 작업들을 제공한다. (filter, map, reduce, sort, find 등)

    또한 스트림 작업들은 직렬 또는 병렬적으로 실행 가능하다.

또한 스트림의 작업들은 다음과 같은 특징이 존재한다.

  • i. 파이프라인 - Pipelining

    많은 스트림 메서드는 자기 자신을 반환한다. 때문에 스트림 작업들은 chaining 된 형태로 정의할 수 있으며, 이를 “파이프라인 되어 있다” 고 부른다. (하드웨어의 Pipelining 과 연결점이 있는지는 모르겠음.)

  • ii. 내부 반복 - Internal Iteration

    컬렉션은 for, iterator 를 이용해 직접 개발자가 반복문을 구현하는 반면, 스트림 작업들은 개발자가 직접 반복문을 구현하지 않는다.

    즉, 스트림 작업의 반복은 내부적으로 자기가 알아서 처리한다.

예시를 통해 스트림의 특징을 나열해보면 다음과 같다.

import static java.util.stream.Collectors.toList;

List<String> threeHighCalaricDishNames 
    = menu.stream()                                  // -> Gets a stream from menu (list of dishes)
          .filter(dish -> dish.getCalories() > 300)  // Creates a pipeline of operations: first filter high-calorie dishes
          .map(Dish::getName)                        // Gets the names of the dishes
          .limit(3)                                  // Selects only the first three
          .collect(toList());                        // Stores the results in another list

System.out.println(threeHighCalaricDishNames);   // Gives result [port, breef, chicken]
  • menu (List<Dish>) 로 부터 (Data Source) 어느 일련의 데이터 (Sequence of Elements) 를 제공받는다. ( == 스트림)
  • 스트림에 filter, limit 등의 데이터 처리 작업 (Data-processing Operations) 을 적용한다. 이는 또한 직렬 혹은 병렬적으로 실행 가능하다.
  • filter, map 등의 메서드는 자기 자신을 반환해, method chaining 형태 (Pipelining) 로 데이터 처리 작업을 적용할 수 있다.
  • 모든 원소에 데이터 처리 작업을 적용하기 위해 반복문을 사용하지 않는다. (Internal Iteration)

C. 스트림 vs 컬렉션 1

어찌보면 스트림과 컬렉션은 비교될 수밖에 없다. 애초에 스트림을 이용하지 않고 컬렉션 만으로도 우리가 원하는 기능을 구현할 수 있기 때문이다. 그럼 이들의 차이점은 무엇일까?

(교재에서 스트림과 컬렉션의 차이를 설명하지만 잘 정리되지 않아서 더 좋은 글 [1] 을 찾아 정리하였다. 교재의 설명 또한 뒤에 정리해놓긴 했다.)

  • 실질적 저장 공간이 없다 - No Storage

    스트림은 데이터를 저장하는 자료구조가 아니다. 스트림은 데이터 원천 (Data Source) 에서 원소를 제공받고, 그 원소들을 특정 처리 작업으로 전달할 뿐이다.

  • 원본을 훼손하지 않는다 - Functional in nature

    스트림의 데이터 처리 작업을 통해 어느 결과를 얻을 순 있지만, 이 처리 작업으로 인해 원본 데이터가 훼손되지 않는다.

  • 게으를 수 있다 - Laziness-seeking

    필터링, 맵핑 등 대부분의 스트림 처리 작업은 게으르게 처리되어 최적화가 수행될 수 있다. 예를 들어 *“세개의 모음이 연속된 첫번째 문자열을 찾는”* 스트림이 있다 하자. 이 때 스트림은 *“조건에 부합하는 첫번째 원소를 발견하면 즉시 중단”* 된다.

    ( ←→ 모든 원소에 대해 조건이 부합하는지 확인하고, 그 중 첫번째 원소만 반환 )

  • 크기가 제한되지 않은 데이터 처리 작업도 수행 가능하다 - Possibly unbound

    컬렉션은 유한한 크기로 존재해야 어느 연산이 가능한 반면, 스트림은 그렇지 않을 수도 있다.

    스트림의 데이터 처리 작업 (Data-processing Operations) 은 Short-circuiting 을 이용해 무한한 크기의 스트림에 대해서도 작업을 수행할 수 있다.

  • 소비된다 - Consumable

    스트림 내 원소들은 그 수명동안 오직 한번만 방문될 수 있다. 이미 방문한 원소를 다시 방문하기 위해선 새로운 스트림이 생성되어야 한다.

교재 내용

컬렉션과 스트림은 “언제 계산이 수행되는지” 가 다르다.

컬렉션은 메모리에 존재하는 자료구조로, 자료구조 내 모든 원소는 메모리 어딘가에 존재한다. 때문에 자료구조에 어느 원소를 저장하기 위해선 그 원소의 실체가 존재해야 한다.

즉, 컬렉션을 사용 하기 위해선 먼저 계산 되어야 한다.

반면 스트림은 필요에 의해서만 계산 될 수 있고, 스트림에 원소를 추가, 제거 등을 할 수 없어 (개념적으로) 고정된 자료구조이다.

(정말 자료구조라는 뜻이 아님. 스트림에 포함되는 원소 개수가 고정 되었다는 뜻임)

📜 The difference between collections and streams has to do with when things are computed.

A collection is an in-memory data structure that hold all the values the data structure currently has, hence in order to add data to collections, it has to be computed first.

  • You can add things or remove, etc, but at each moment in time, every element in the collection is stored in memory :
  • elements have to be computed before becoming part of the collection.

By contrast, a stream is a conceptually fixed data structure (you can’t add or remove elements from it) whose elements are computed on demand.

나름대로의 해석(?)

(사실 이 부분은 정확하게 이해한 것인지 모르겠다. 아무리 읽어봐도 compute 가 뭘 뜻하는지 모르겠다.)

앞서 말했듯이 컬렉션과 스트림은 사용하는 목적이 다르다. 컬렉션은 데이터를 저장하는 데 초점이 맞춰진 반면, 스트림은 그 데이터를 처리, 계산하는데 초점이 맞춰져있다.

이 때문에 컬렉션과 스트림은 근본적인 차이점이 존재하는데, 바로 “언제 계산이 수행되는지” 이다.

다음 예시를 생각해보자.

List<String> names = new ArrayList<>();
for (Dish d : menu)
    names.add(d.getName());     // #1

names.sort(String::compareTo);  // #2

for (String name : names)       // #3
    System.out.println(name);
    
/* Whatever #3 is, #1 & #2 are always executed. */

위 코드는 menu 원소들의 이름을 사전순으로 저장하고 출력하는 코드이다. 이 때 #3 에 어떤 코드가 적혀있든 #1#2반드시 실행된다.

menu.stream()
    .map(Dish::getName)             // #1
    .sorted(String::compareTo).     // #2
    .forEach(System.out::println);  // #3
    
/* If #3 doesn't exists, #1 & #2 will never be executed. */

하지만 스트림은 다르다. 만약 #3 이 존재하지 않는다면, #1, #2 에 해당하는 내부코드(?) (정말로 Dish 의 이름들을 map 하고 사전순으로 정렬하는 부분) 는 실행되지 않는다.

즉, 컬렉션은 “뭐가 있든 먼저 계산” 이 되는 반면, 스트림은 “필요에 의해서만 계산” 이 된다는 것이다.


D. 스트림 vs 컬렉션 2

앞서 개념적으로 스트림과 컬렉션의 차이를 보았다. 이번엔 우리가 사용할 때 더 체감되는 실질적인(?) 차이를 보자.

I. 방문의 제한 - Traversable only once

스트림은 컬렉션과 다르게 단 한번만 방문될 수 있다. 만약 이를 어길 시 IllegalStateException (RuntimeException) 이 발생한다.

Stream<String> someStream = menu.stream()
                                .map(Dish::getName);

someStream.forEach(System.out::println);

someStream.forEach(System.out::println);    // RuntimeException
/*
Exception in thread "main" java.lang.IllegalStateException: 
    stream has already been operated upon or closed
*/

II. 반복의 차이 - External vs Internal Iteration

컬렉션은 for, iterator 를 이용해 직접 반복문을 구현하는 반면, 스트림 작업들은 그렇지 않다.

때문에 컬렉션의 반복문 형태를 External Iteration, 스트림의 것을 Internal Iteration 이라 칭한다.

List<String> names = new ArrayList<>();
for (Dish dish : menu)           // -> '명시적'으로 반복함
    names.add(dish.getName());
List<String> names 
        = menu.stream()
              .map(Dish::getName)  // 위처럼 명시적 반복이 없음.
              .collect(toList());

이는 언뜻 보면 별거 아닌것 같지만 사실 개발하는 입장에서는 아주 고마운 존재다. 우리의 책임(?) 을 떠넘길 수 있기 때문이다.

한가지 아주 단편적인 예를 들어보자.

menuDish 들의 이름을 병렬적으로 출력하는 경우를 생각해보자. 이를 위해선 우리가 직접 클래스를 구현하고 내부 동작도 모두 구현해야 한다.

class ParallelFlow<T, R> extends Thread {
    private List<T> searchTarget;
    private Function<T, R> function;
    private int numOfThread;

    ParallelFlow(List<T> list, Function<T, R> func, int threadNum)    {
        searchTarget = list;
        function = func;
        numOfThread = threadNum;
    }

    class SubThread implements Runnable {
        int start, end;

        SubThread(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {     // WE HAVE TO implement these by hand...
            for (int i = start; i < end && i < searchTarget.size(); i++)
                System.out.print(
                        function.apply(searchTarget.get(i)).toString() + " "
                );
        }
    }

    @Override
    public void run() {
        List<SubThread> subThreads = new ArrayList<>(numOfThread);

        int increment = searchTarget.size() / numOfThread;

        if (searchTarget.size() % numOfThread != 0)
            increment++;

        int i = 0;
        for (; i < numOfThread - 1; i++)
            subThreads.add(new SubThread(i * increment, (i + 1) * increment));
        subThreads.add(new SubThread(i * increment, searchTarget.size()));

        for (SubThread subThread : subThreads)
            subThread.run();
    }
}
ParallelFlow<Dish, String> printParallel
        = new ParallelFlow<>(menu, Dish::getName, 5);

printParallel.start();
pork beef chicken french fries rice season fruit pizza prawns salmon 

위 예시의 SubThread.run 처럼 우리가 직접 잘 생각해서 반복문을 구현해줘야 한다.

거기다 만약 병렬적으로 다른 작업을 원한다면 그때마다 위 코드를 수정하거나 다른 클래스를 정의해야 한다.

하지만 스트림을 이용하면 아래처럼 아주 간단히 나타낼 수 있다.

menu.parallelStream()
    .map(Dish::getName)
    .forEach(d -> System.out.print(d + " "));
season fruit prawns chicken salmon pork beef rice french fries pizza 

E. 스트림 operations

결국 스트림을 이용해 유연하고 간결한 데이터 처리가 가능하다.

스트림의 데이터 처리 작업은 대부분 람다식을 이용해 정의할 수 있으며, java.util.stream.Stream 를 통해 자세히 알 수 있다.

스트림의 연산 (데이터 처리 작업) 은 크게 2 분류로 나눌 수 있는데, 중간 연산 (Intermediate Operation)최종 연산 (Terminal Operation) 이다.

  • 중간 연산 - Intermediate Operations

    스트림의 중간 연산은 filter, map 처럼 자기 자신을 반환하여 파이프라인 형태를 이룰 수 있는 작업들을 칭한다. 이들은 모두 게으른 (Lazy) 작업들로 스트림의 최적화 대상이 될 수 있다.

    더 중요한 점은 중간 연산을 호출 하더라도 종료 연산이 호출되지 않으면 이들이 실행되지 않는다 는 점이다.

  • 최종 연산 - Terminal Operations

    최종 연산은 스트림 파이프라인으로 부터 결과를 이끌어낸다.

    반환되는 결과는 스트림이 아닌 (non-stream) List, Integer, 심지어 void 형 일수도 있다.

이들을 예시를 통해 나타내면,

menu.stream()
    .map(Dish::getName)             // #1
    .sorted(String::compareTo).     // #2
    .forEach(System.out::println);  // #3
    
/* If #3 doesn't exists, #1 & #2 will never be executed. */

#1#2 는 중간 연산, #3 은 종료 연산이다. 이 때 #3 이 존재하지 않으면 #1, #2 에 해당하는 내부 코드(?) (정말로 Dish 의 이름들을 map 하고 사전순으로 정렬하는 부분) 는 실행되지 않는다.


Summary

정리하자면

  • 스트림은 컬렉션, 배열 등의 자료구조로 부터 (Data Source) 일련의 원소 (Sequence of Elements) 를 제공받고, 이들의 데이터 처리 작업 (Data-processing Operations)선언적 (Declarative way) 으로 구현할 수 있는 도구이다.
  • 스트림 연산은 중간 연산 (Intermediate Operations), 종료 연산 (Terminal Operations) 으로 나눠지며, 오직 종료 연산 호출을 통해서만 스트림 결과를 도출할 수 있다.
  • 스트림 연산은 내부 반복 (Internal Iteration) 으로 처리되어 개발자가 직접 반복문, 데이터 처리 등을 구현 하지 않을 수 있다.

Reference

profile
나 같은게... 취준?!

0개의 댓글