[Moder-Java-in-Action] 4장. 스트림 소개

이동엽·2023년 1월 25일
1

java

목록 보기
16/19

개요

  • 컬렉션이 없다면 무슨 일이 벌어질까?
    • 거의 모든 자바 애플리케이션은 컬렉션을 만들고 처리하는 과정을 포함한다.
    • 컬렉션으로 데이터를 그룹화하고 처리할 수 있다.
    • 컬렉션은 대부분의 프로그래밍 작업에 사용된다.

  • 어떤 사람이 컬렉션에서 칼로리가 적은 요리만 고르고 싶어 한다.

    • SQL 질의로는 SELECT name FROM dishes WHERE calorie < 400 으로 나타낼 수 있다.
    • 속성을 이용하여 어떻게 필터링할 것인지는 구현할 필요가 없다.
      • 즉, SQL에서는 질의를 어떻게 구현해야 할지는 명시할 필요가 없으며, 구현은 자동으로 제공된다.
  • 컬렉션으로도 이와 비슷한 기능을 만들 수 있지 않을까?

    • 많은 요소를 포함하는 커다란 컬렉션을 어떻게 처리해야 할까?
    • 멀티 코어 아키텍처를 활용한 병렬 방식? → 단순 반복 처리에 의해 복잡하고 어렵다.
    • 또한 복잡한 코드는 디버깅도 어렵다.

💡 시간을 절약하고, 편리한 삶을 누릴 수 있는 방법은 무엇일까? → 스트림!!



🔥 스트림이란 무엇일까?

  • 스트림은 Java 8 API에서 새로 추가된 기능이다.
    • 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.
    • 선언형 : 데이터를 처리하는 임시 구현 코드 대신 질의로 표현


  • 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬 처리할 수 있다.


  • 기존 Java 7 코드
    • 아래 코드에서는 lowCaloricDishes 라는 “가비지 변수”를 사용했다.
    • 즉, 컨테이너 역할만 하는 중간 변수일 뿐이다.
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish dish: menu) {
	if (dish.getCalories() < 400) {
    	lowCaloricDishes.add(dish);
	}
}
    
Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
	public int compare(Dish dish1, Dish dish2) {
    	return Integer.compare(dish1.getCalories(), dish2.getCalories());
    	}
});
    
Lish<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish: lowCaloricDishes) {
	lowCaloricDishesName.add(dish.getName());
}


  • 최신 Java 8 코드 : 세부 구현을 라이브러리 내에서 모두 처리한다.
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
    
List<String> lowCaloricDishesName = menu.stream()
					.filter(d -> d.getCalories() < 400)
    				.sorted(comparing(Dish::getCalories))
    				.map(Dish::getName)  //요리명 추출
    				.collect(toList());  //리스트에 저장


  • stream()parallelStream()으로 바꾸면 이 코드를 멀티코어 아키텍처에서 병렬 실행할 수 있다!
List<String> lowCaloricDishesName = menu.parallelStream()
					.filter(d -> d.getCalories() < 400)
    				.sorted(comparing(Dish::getCalories))
    				.map(Dish::getName)  //요리명 추출
    				.collect(toList());  //리스트에 저장


  • Java 8 최신 문법의 장점
    • 선언형으로 코드를 구현할 수 있다!
      • 루프와 조건문 등의 제어 블록을 사용해서 어떻게 동작을 구현할 지 지정할 필요가 없다.
      • 선언형 코드와 동작 파라미터화를 이용하면 변하는 요구사항에 쉽게 대응할 수 있다!
    • 여러 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다.
      • 가독성과 명확성을 유지할 수 있다!
      • 이때, 빌딩 블록 연산은 filter , sorted , map , collect를 말한다.


  • 위에 나온 연산들은 고수준 빌딩 블록으로 이루어져 있으므로 특정 스레딩 모델에 제한되지 않는다.
  • 결과적으로 우리는 데이터 처리 과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다!
    • 이 모든 것이 스트림 API 덕분이다.


  • 스트림 API는 매우 비싼 연산이다.
    • 4, 5, 6장을 학습하면 아래와 같은 코드를 구현할 수 있다.
Map<Dish.Type, List<Dish>> dishesByType =
	menu.stream().collect(groupingBy(Dish::getType));

💡 Java 8의 스트림 API의 특징

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.


  • 일반적인 명령형 프로그래밍의 루프를 이용한다면 이 문제를 어떻게 해결했을지 생각해보자.
    • 지금부터 당분간은 아래 코드의 메뉴를 주요 예제로 사용한다.
public static final List<Dish> menu = Arrays.asList(
	new Dish("pork", false, 800, Dish.Type.MEAT),
    new Dish("beef", false, 700, Dish.Type.MEAT),
    new Dish("chicken", false, 400, Dish.Type.MEAT),
    new Dish("french fries", true, 530, Dish.Type.OTHER),
    new Dish("rice", true, 350, Dish.Type.OTHER),
    new Dish("season fruit", true, 120, Dish.Type.OTHER),
    new Dish("pizza", true, 550, Dish.Type.OTHER),
    new Dish("prawns", false, 400, Dish.Type.FISH),
    new Dish("salmon", false, 450, Dish.Type.FISH)
);


  • Dish는 아래와 같이 불변형 클래스다.
    public class Dish {
    
      private final String name;
      private final boolean vegetarian;
      private final int calories;
      private final Type type;
    
      public Dish(String name, boolean vegetarian, int calories, Type type) {
        this.name = name;
        this.vegetarian = vegetarian;
        this.calories = calories;
        this.type = type;
      }
    
      public String getName() { return name; }
    
      public boolean isVegetarian() { return vegetarian; }
    
      public int getCalories() { return calories; }
    
      public Type getType() { return type; }
    
      public enum Type { MEAT, FISH, OTHER }
    
      @Override
      public String toString() {
        return name;
      }
    }

→ 이제 스트림 API를 사용하는 방법을 자세히 살펴보자.



🔥 스트림 시작하기

  • Java 8 컬렉션에는 스트림을 반환하는 stream() 메서드가 추가되었다.

  • 스트림은 ‘데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소’로 정의할 수 있다.



스트림의 두 가지 주요 특징

  1. 파이프라이닝
    a. 대부분의 스트림 연산은 스트림 연산끼리 연결해 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다!
    b. 덕분에 게으름(laziness), 쇼트서킷(short-circuiting)같은 최적화도 얻을 수 있다.
  2. 내부 반복
    a. 반복자를 이용해 명시적으로 반복하는 컬렉션과 달리, 스트림은 내부 반복을 지원한다.


  • 예제 코드
    List<String> threeHighCaloricDishNames 
    		= menu.stream()               //스트림 얻기
    				  .filter(dish -> dish.gerCalories() > 300)
    					.map(Dish::getName)     //요리명 추출
    					.limit(3)               //개수를 3개로 제한
    					.collect(toList());     //결과를 리스트로 저장
    
    System.out.println(threeHighCaloricDishNames); //[pork, beef, chicken]


🔥 스트림과 컬렉션

DVD에 어떤 영화가 저장되어 있다고 가정. (DVD에 전체 자료구조가 저장되어 있으므로 얘도 컬렉션.)

이번에는 인터넷 스트리밍으로 비디오를 시청한다고 가정. (스트리밍 → 스트림.)
스트리밍으로 비디오를 재생할 때는 사용자가 시청하는 부분의 몇 프레임을 미리 내려받는다.
→ 이 경우, 다른 값을 처리하지 않은 상태에서 미리 내려받은 프레임을 재생할 수 있다.



🌱 컬렉션과 스트림의 가장 큰 차이점은 “데이터를 언제 계산하느냐”이다.

  • 컬렉션
    → 컬렉션은 현재 자료구조가 모든 값을 메모리에 저장하는 자료구조다.
    → 즉, 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.
    적극적으로 생산된다. (생산자 중심: 팔기도 전에 창고를 가득 채움)

  • 스트림
    → 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조다.
    → 이는 사용자가 요청하는 값만 스트림에서 추출한다는 뜻으로, 생산자와 소비자 관계를 형성한다.
    → 또한 스트림은 게으르게 만들어지는 컬렉션과 같다.



딱 한번만 탐색할 수 있다.

  • 반복자와 마찬가지로 한 번만 탐색할 수 있다. → 즉, 탐색된 스트림의 요소는 소비된다!
    • 또한 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
      List<String> title = Arrays.asList("Java8", "In", "Action");
      Stream<String> s = title.stream();
      s.forEach(System.out::println); //title의 각 단어를 출력
      s.forEach(System.out::println); //IllegalStateException 발생


외부 반복과 내부 반복

  • 컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다.
    • 이를 외부 반복이라고 한다.
      List<String> names = new ArrayList<>();
      for(Dish dish: menu) {
      		names.add(dish.getName());
      }
      List<String> names = new ArrayList<>();
      Iterator<String> iterator = menu.iterator();
      while (iterator.hasNext()) { //명시적 반복
      		Dish dish = iterator.next();
      		names.add(dish.getName());
      }


  • 반면에 스트림 라이브러리는 내부 반복을 사용한다.
    • 함수에 어떤 작업을 수행할지만 지정하면 모든 것이 알아서 처리된다.
      List<String> names = menu.stream()
      						  .map(Dish::getName)
      					 	  .collect(toList());


  • 내부 반복이 외부 반복보다 좋은 이유 2가지!
    1. 작업을 투명하게 병렬로 처리하거나, 더 최적화된 다양한 순서로 처리할 수 있다.
    2. 스트림의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택한다.
      1. 반면, for-each와 같은 외부 반복에서는 병렬성을 스스로 관리해야 한다. (synchronized)


🔥 스트림 연산

스트림의 연산은 크게 두 가지로 구분할 수 있다.

  • 스트림을 이어서 연결할 수 있는 중간 연산
  • 스트림을 닫는 최종 연산

중간 연산

중간 연산의 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 하지 않는다는 것.
즉, 게으르게 처리한다. → 합쳐진 중간 연산을 최종 연산으로 한 번에 처리한다!


  • 람다가 현재 처리중인 요리를 출력하기
    public class HighCaloriesNames {
        public static void main(String[] args) {
            List<String> names = menu.stream()
                    .filter(dish -> {
                        System.out.println("filtering " + dish.getName());
                        return dish.getCalories() > 300;
                    })
                    .map(dish -> {
                        System.out.println("mapping " + dish.getName());
                        return dish.getName();
                    })
                    .limit(3)
                    .collect(toList());
            System.out.println(names);
        }
    }

최종 연산

최종 연산은 스트림 파이프라인에서 결과를 도출한다.
보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과를 반환한다.

  • 스트림의 모든 요리 출력 예제
    menu.stream().forEach(System.out::println);

스트림 이용하기

스트림 이용 과정은 아래 세 가지로 요약할 수 있다.

  • 질의를 수행할 데이터 소스(like 컬렉션)
  • 스트림 파이프라인을 구성할 중간 연산 연결
  • 스트림 파이프라인을 실행하고 결과를 만들 최종 연산
  • 스트림 파이프라인의 개념은 빌더 패턴과 비슷하다!
    • 빌터 패턴에서는 호출을 연결해서 설정을 만든다(= 중간 연산)
    • 그리고 준비된 설정에 build() 메서드를 호출한다. (= 최종 연산)

한 눈으로 보는 Java 7과 Java 8 비교

public class StreamBasic {

    public static void main(String... args) {
        // 자바 7
        getLowCaloricDishesNamesInJava7(Dish.menu).forEach(System.out::println);

        System.out.println("---");

        // 자바 8
        getLowCaloricDishesNamesInJava8(Dish.menu).forEach(System.out::println);
    }

    public static List<String> getLowCaloricDishesNamesInJava7(List<Dish> dishes) {
        List<Dish> lowCaloricDishes = new ArrayList<>();
        for (Dish d : dishes) {
            if (d.getCalories() < 400) {
                lowCaloricDishes.add(d);
            }
        }
        List<String> lowCaloricDishesName = new ArrayList<>();
        Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
            @Override
            public int compare(Dish d1, Dish d2) {
                return Integer.compare(d1.getCalories(), d2.getCalories());
            }
        });
        for (Dish d : lowCaloricDishes) {
            lowCaloricDishesName.add(d.getName());
        }
        return lowCaloricDishesName;
    }

    public static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
        return dishes.stream()
                .filter(d -> d.getCalories() < 400)
                .sorted(comparing(Dish::getCalories))
                .map(Dish::getName)
                .collect(toList());
    }
}
profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

0개의 댓글