스터디

[스터디: 모던 자바] 스트림

GYUD 2024. 4. 1. 19:47

 

이번 장에서는 스트림에 대해서 간략하게 알아본다.

 

자바 8에서 새롭게 도입된 스트림을 활용하면 코드를 간결하고 가독성 좋게 작성할 수 있으며, 유연하게 조립할 수도 있다. 또한 병렬화를 효율적으로 구현하여 프로그램의 성능도 높일 수 있다는 장점이 있다.

 

우테코 프리코스를 하면서 스트림을 처음 접하고 이를 코드에 적용하였는데 이번 장에서 스트림에 대해서 조금 더 깊게 공부할 수 있었다.

https://gyuwon-tech.tistory.com/7

 

Stream 적용기

평소에 프로젝트를 진행할 때는 stream의 map 메서드만 사용하여 코드를 작성했다. stream을 사용하면 코드를 더 간단하게 작성할 수 있다고 생각하여 이번 기회에 stream을 직접 사용하면서 공부하기

gyuwon-tech.tistory.com

 


스트림

Stream 이란 개울이라는 뜻으로, 개울물이 위에서 아래로 연속해서 흐르는 것처럼 Stream 연산을 연속해서 이어붙일 수 있다.

 

먼저 스트림을 이해하기 위해서 예시를 보면서 어떻게 사용하는지 알아보자.

 

Dish 라는 객체에 calories와 name 이라는 필드 값이 있을 때, List<Dish> 자료형인 menu에 대하여 칼로리가 400 미만인 음식을 오름차순으로 뽑아서 음식 이름 리스트를 구해보자.

 

자바 8 이전에는 가장 먼저 칼로리 400미만 음식을 필터링하고, 칼로리를 기준으로 오름차순 정렬하고, 이름을 추출해야한다.

List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish dish: menu) {
    if(dish.getCalories() < 400) {
        lowCalaricDishes.add(dish);
    }
}

Collections.sort(lowCaloricDishes, new Comparator<Dish> () {
    public int compare(Dish dish1, Dish dish2) {
        return Integer.compare(dish1.getCalories(), dish2.getColories());
    }
});

List<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish: lowCaloricDishes) {
    lowCaloricDishesName.add(dish.getName());
}

요구사항에 비해서 코드가 길다고 느껴진다.

또한 우리가 최종으로 필요한 것은 정렬된 음식 리스트이지만, 중간에 List<Dish> 형의 lowCaloricDishes을 선언하여 추가적인 메모리 공간을 소모하였다.

 


스트림의 특징

간단한 코드 작성

이를 Stream API를 활용하면 매우 간단하고, 추가적인 공간 사용 없이 코드를 구현할 수 있다.

List<String> lowCaloricDishesName = menu.stream()
                                        .filter(d -> d.getCalories() < 400)
                                        .sorted(comparing(Dish::getName))
                                        .map(Dish::getName)
                                        .toList();

기존에는 반복문과 조건문을 활용하여 동작을 모두 구현하였지만, Stream API를 활용하면 칼로리가 400 미만인 음식만 추출하라는 구문을 간단하고 명확하게만 작성할 수 있다.

불필요하게 for문을 반복하고, if 문 조건을 검사하고는 등 어떻게 동작할지를 구현하는 것이 아니라, 무엇을 할지만 선언적으로 명시하여 기능을 구현하는 것이다.

 

병렬 처리

만약 병렬 처리가 필요하다면 stream()을 parallelStream() 으로 바꿔주기만 하면 된다.

List<String> lowCaloricDishesName = menu.parallelStream()
                                        .filter(d -> d.getCalories() < 400)
                                        .sorted(comparing(Dish::getName))
                                        .map(Dish::getName)
                                        .toList();

기존에 Thread를 사용해서 복잡하던 병렬처리 연산을 간단하게 구현할 수 있다는 것도 stream의 가장 큰 장점 중 하나이다.

 

파이프라이닝

제일 처음 스트림을 시냇물로 소개하면서 연산을 이어붙일 수 있다고 설명했다.

이게 가능한 이유가 스트림의 중간 연산은 스트림을 반환하고, 이를 연결하여 거대한 파이프 라인을 구성할 수 있기 때문이다.

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

위 코드에서는 fitler(), map(), limit(), collect() 라는 연산을 연결하여 파이프라인을 구축했다.

각각의 연산에 대한 설명은 아래와 같다.

 

  • filter: 람다를 인수로 받아서 스트림의 특정 요소를 선택한다.
  • map: 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다.
  • limit: 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림의 크기를 truncate 한다
  • collect: 스트림을 List와 같은 다른 요소로 변환한다

 

이 중 filter, map, limit과 같이 반환값으로 다른 스트림을 반환하여 다른 스트림 연산을 이어 붙일 수 있는 연산중간 연산 이라고 한다.

반면, collect와 같이 List, Integer, void 등을 반환하여 파이프라인에서 결과를 도출하는 연산최종 연산 이라고 한다.

 

앞서 작성한 코드를 실행하면 아래와 같은 결과가 나온다.

filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]

스트림을 사용하지 않고 반복문을 사용한 코드를 실행했을 때의 결과와 비교해보자.

filtering:pork
filtering:beef
filtering:chicken
filtering:fish
mapping:pork
mapping:beef
mapping:chicken
mapping:fish
[pork, beef, chicken]

스트림을 사용하지 않는다면 menu에 포함된 칼로리 300 초과의 음식을 모두 고른 후3개 요소만을 추출한다.

따라서 최종적으로 필요없는 fish 인스턴스에 대한 연산도 수행된다.

 

하지만 스트림을 사용하면 filter, map, limit 과 같은 중간연산들은 호출 즉시 실행되는 것이 아니라 최종 연산이 호출 되는 시점까지 기다렸다가 수행되는 laziness 특성을 가진다.

또한 anyMatch, findFirst, limit 와 같이 남은 연산에 상관없이 결과가 정해지는 경우에는 필요없는 연산을 생략하는 short-circuiting 특성도 가진다.

 

이러한 특징들로 인해 필요한 데이터만 메모리에 올려서 공간을 효율적으로 사용하고, 불필요한 데이터 처리는 생략한다.

결국 laziness 와 short-circuiting 덕분에 병렬 처리의 효율도 더욱 상승하게 된다.

 

내부반복

반복문을 사용하여 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

 

기존의 컬렉션은 for-loop 나 iterator를 통해서 컬렉션의 각 요소를 순회하며 반복을 처리해야한다.

List<String> highCaloricDishes = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) {
    Dish dish = iterator.next();
    if(dish.getCalories() > 300) {
        highCaloricDishes.add(d.getName());
    }
}

반면 스트림 API를 사용하면 반복을 추상화 하고 반복 처리는 스트림이 관리하기 때문에 코드가 간결해지고, 병렬 처리가 용이해진다.

List<String> highCaloricDish =
    menu.stream()
        .filter(dish -> dish.getCalories() > 300)
        .collect(toList());

이러한 장점으로 인해 개발자는 어떻게 조건을 탐색할지가 아닌 무엇을 조건으로 선택 할지에만 집중할 수 있다.

 

한번만 탐색

스트림은 컬렉션과 달리 딱 한번만 탐색 할 수 있다.

List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // IllegalStateException 발생

위 코드에서 title List의 경우에는 두번 순회하며 출력해도 값도 어떤 에러도 발생하지 않는다.

 

하지만 Stream을 사용하면 한번만 탐색이 가능하기 때문에 두번째 forEach() 구문에서 예외가 발생한다.

그 이유는 stream은 단 한번만 탐색할 수 있다는 특징을 가지기 때문이다.

 


이번 장에서는 스트림의 몇가지 함수와 스트림의 특징 5가지를 공부했다.

 

다음 장부터 스트림의 구체적인 함수에 대해 알아보고, 어떻게 활용하면 될지에 대해서 더욱 자세히 공부한다.

 

이전에 스트림을 공부하긴 했지만, 대부분 for 문, 반복문을 사용하여 코드를 작성하고 있었는데, 이번 기회에 스트림을 제대로 공부해서 더 쉽고 명확한 코드를 작성하고 싶다.