프로그래밍/Java

모던 자바 인 액션 스터디 - 스트림 활용법과 문제 풀어보기

Jay Tech 2019. 11. 17. 15:09
반응형

4장

스트림

스트림은 자바 8 API에 새로 추가된 기능입니다. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있습니다. 여기서 선언형이란 데이터를 처리하는 임시 구현 코드 대신 질의로 표현하는 것을 말합니다. 또한 스트림을 사용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있습니다.

 

한마디로 정의하면 스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소(Sequence of elements)입니다.

말이 너무 어렵나요? 용어를 하나씩 살펴봅시다.

 

연속된 요소: 컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값을 제공합니다. ArrayList와 같은 컬렉션의 주제는 데이터이고 스트림의 주제는 계산식입니다.

소스: 스트림은 소스로부터 데이터를 제공받고 그 데이터를 소비합니다. 여기서 소스란 컬렉션 배열, I/O 자원 등입니다.

스트림의 특징

  • 선언형: 더 간결하고 가독성이 좋아집니다.
  • 조립성: 유연성이 좋아집니다.
  • 병렬화: 성능이 좋아집니다.
  • 파이프라이닝: 대부분의 스트림 연산은 스트림 연산끼리 연결이 되어 파이프라인을 구성합니다. 그 덕분에 게으름(Lazyiness), 쇼트서킷(Short-Circuit) 같은 최적화를 얻을 수 있습니다.
  • 내부 반복: ArrayList 같은 컬렉션은 반복자를 이용해서 명시적으로 반복을 수행합니다. 하지만 스트림은 내부 반복을 이용합니다. 이 부분은 잠시 후 알아보겠습니다.

간단한 예시

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 들의 컬렉션이 있다고 합시다.

List<String> collect = Dish.menu.stream()
        .filter(dish -> dish.getCalories() > 300)
        .map(Dish::getName)
        .limit(3)
        .collect(Collectors.toList());
System.out.println(collect);

filter: 람다를 인수로 받아 스트림에서 특정 요소들을 걸러냅니다.

Stream<T> filter(Predicate<? super T> predicate);

map: 람다를 이용해서 한 요소를 다른 요소로 변환합니다.

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

map은 보시는 것과 같이 Function을 인자로 받습니다. Function <T, R> 은 T 타입을 받아서 R 타입으로 변환시켜줍니다. Function에 대해서 모르시는 분들은 지난 게시글을 참고하거나 검색을 통해서 숙지하실 수 있습니다.

limit: 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 합니다.

collect: 스트림을 다른 형식으로 변환합니다.

filter, map 같이 다시 Stream을 반환하는 것을 중간 연산이라고 합니다. collect와 같이 특정 값을 반환하는 것을 최종 연산이라고 합니다. 그렇기 때문에 중간 연산은 체이닝으로 계속 붙일 수 있겠죠?

 

중간 연산: filter, map, limit, sort, distinct

최종 연산: forEach, count, collect

전체 예제는 여기서 볼 수 있습니다.

 

스트림과 컬렉션

컬렉션과 스트림은 어떤 차이가 있을까요?

그전에 스트림과 컬렉션은 연속된 요소 형식의 값을 저장하는 자료구조 인터페이스를 제공한다는 점에서는 동일합니다.

차이점은 DVD와 영화 스트리밍의 차이로 설명할 수 있습니다. DVD는 전체 자료구조가 저장되어 있습니다. 처음부터 틀어서 봐도 되고 원하는 부분을 플레이할 수 있겠죠?

반면에 스트리밍은 어떨까요? 스트리밍 플레이어에는 모든 프레임을 저장할 충분한 공간이 없을 수 있습니다. 그렇기 때문에 재생하려는 부분의 몇 프레임을 미리 내려받고 플레이합니다.

 

이 예시의 차이는 언제 데이터를 계산하느냐입니다. 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 구조입니다. 즉 컬렉션의 모든 요소는 컬렉션에 추가되기 전에 계산이 되어야 합니다.

반면 스트림은 이론적으로 요청할 때만 요소를 계산합니다. 그렇기 때문에 무한한 작업(조금 추상적이지만)이 가능하기도 합니다. Lazy 한 컬렉션으로도 볼 수 있겠죠. 사용자가 요청할 때만 값을 계산하기 때문입니다.(즉석 제조)

단 한 번의 탐색

스트림은 단 한 번만 탐색할 수 있습니다. 쉽게 말해 소비자원인 거죠. 스트리밍 서비스도 다시 재생 바를 뒤로 돌려서 플레이할 때 약간의 로딩이 걸리는 것을 경험해 보셨나요? 즉 한 번 돌리면 다시 내려받기 전에 보지 못한다는 것입니다. (일반적인 경우)

철학적 접근

재밌는 부분은 철학적으로 차이를 설명할 수도 있습니다. 바로 시공간인데요. 컬렉션은 공간에 흩어진 값이고 스트림은 시간에 흩어진 값이라고 말할 수 있습니다. 컬렉션은 메모리 상에 흩어진 값을 조합합니다. 하지만 스트림은 특정 시간에 존재하는 값들을 조합하는 것이죠.

내부 반복

컬렉션은 외부적으로 반복합니다. 아이에게 바닥에 흩어진 장난감을 주우라고 하는 것을 생각해볼까요?

"책을 담아", "공을 담아", "인형을 담아"와 같이 명시적으로 컬렉션(장난감) 항목을 하나씩 가져와서 처리하라고 명령합니다. 이것이 외부 반복입니다. 외부에서 병렬성을 스스로 관리를 해야 합니다.

내부 반복은 어떨까요? 아이에게 그냥 "장난감들을 주워"라고 말하는 것입니다. 그렇게 되면 아이는 양손을 이용해서 장난감을 주울 수 있겠고, 한 번에 장난감들을 한 곳에 모아서 담을 수 있겠죠? 즉 내부적으로 알아서 효율적으로 처리할 수 있는 기회를 주는 것입니다.

5장

스트림 활용

슬라이스 치기

아까 간단한 예시에서 Menu 컬렉션이 있었죠. 거기서 320 칼로리 이하의 음식을 선택하는 것을 생각해볼까요? 그런데 이번에는 다음과 같이 칼로리가 정렬이 되어 있네요.

public static final List<Dish> SORTED_MENU = Arrays.asList(
      new Dish("season fruit", true, 120, Dish.Type.OTHER),
      new Dish("rice", true, 350, Dish.Type.OTHER),
      new Dish("chicken", false, 400, Dish.Type.MEAT),
      new Dish("salmon", false, 450, Dish.Type.FISH),
      new Dish("french fries", true, 530, Dish.Type.OTHER),
      new Dish("pizza", true, 550, Dish.Type.OTHER),
      new Dish("beef", false, 700, Dish.Type.MEAT),
      new Dish("pork", false, 800, Dish.Type.MEAT)
);

여기서 단순 filter를 거는 것보다 320 칼로리가 넘는 Dish 가 나타날 때 끊으면 되지 않을까요? (정렬이 되어 있기 때문에)

Dish.SORTED_MENU.stream()
                .takeWhile(dish -> dish.getCalories() < 320)
                .collect(Collectors.toList());

이럴 때 takeWhile을 쓸 수 있습니다. takeWhile을 이용하면 무한 스트림을 포함한 모든 스트림에 Predicate를 활용해 스트림을 슬라이스 할 수 있습니다. 단 takeWhile은 자바 9부터 가능합니다!!

스트림의 평면화 (FlatMap)

"Hello"와 "World"라는 문자열에 속해 있는 단어들을 중복 없이 출력하고 싶다고 해봅시다.

H
e
l
o
W
r
d

먼저 문자열 두 개의 List를 만들어 봅시다.

List<String> strings = Arrays.asList("Hello", "World");

스트림을 써봅시다.

List<String[]> result = strings.stream()
          .map(word -> word.split(""))
          .distinct()
          .collect(Collectors.toList());

이런 식으로 하면 될 것 같나요?

잘 생각해보면 word.split을 호출하면 배열이 나옵니다. 그것을 map으로 했으니 요소가 2개가 나오겠죠? String 2개에서 String [] 2개로요. distinct() 불러도 당연히 겹치지도 않겠죠. 결국 우리가 원하는 결과는 나오지 않습니다.

 

어떻게 해야 할까요? 우리가 필요한 문자 하나하나의 스트림이 필요합니다. 그래야 distinct로 거를 수 있겠죠. 그렇다면 중간에 저 String []에 해당하는 Stream을 String의 Stream으로 변경해야 합니다.

다시 말해 다음과 같이 말이죠.

Stream<String[]> -> Stream<String>

그럴 때 사용하는 게 flatMap입니다.

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

flatMap의 인자를 볼까요? map과 마찬가지로 Function이네요. 하지만 Function <T, R>에서 R이 스트림이네요. 즉 가공해서 스트림을 만들 수 있습니다.

List<String> result2 = strings.stream()
        .map(word -> word.split(""))
                .flatMap(words -> Arrays.stream(words))        
                .distinct()
        .collect(Collectors.toList());
result2.forEach(System.out::println);

flatMap을 활용해서 배열을 다시 하나의 요소를 가진 스트림으로 map 했습니다.

저 부분을 메서드 레퍼런스 방식으로 줄여보면 다음과 같습니다.

.flatMap(Arrays::stream)

FlatMap 문제 1

두 개의 숫자 리스트가 있을 때 모든 숫자 쌍의 리스트를 반환하시오. 예를 들어 [1,2,3] [3,4]가 주어지면 [(1,3), (1,4), (2,3), (2,4), (3,3), (3,4)] 가 반환되어야 한다.

List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);

List<Integer[]> results = numbers1.stream()
        .flatMap(num1 -> numbers2.stream()
                .map(num2 -> new Integer[]{num1, num2}))
        .collect(Collectors.toList());

flatMap을 통해서 numbers 1과 numbers2를 합쳐서 쌍으로 만든 것에 대한 스트림을 다시 만들었습니다.

Reduce

리듀싱이란 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 것을 의미합니다. 함수형 프로그래밍에서는 이것을 폴드(fold)라고 부릅니다. 스트림이 작은 조각이 될 때까지 접는 것을 뜻하죠.

요소의 합을 구해볼까요?

int sum = 0;
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

for (Integer number : numbers) {
    sum += number;
}

sum에 리스트 숫자의 합을 모두 더해 15를 얻었습니다. 이것을 리듀스로 표현해보겠습니다.

Integer result = numbers.stream().reduce(0, (Integer a1, Integer a2) -> a1 + a2);

reduce는 첫 번째 인자로 초기값을 받고 두 번째 인자로 BinaryOperator를 받습니다. 두 개의 인자를 받아 결과를 반환합니다. 지난 시간에 살펴본 람다의 추론으로 다음과 같이 축약할 수 있고,

Integer result = numbers.stream().reduce(0, (a1, a2) -> a1 + a2);

메서드 레퍼런스를 사용해서 더 줄일 수 있습니다.

Integer result = numbers.stream().reduce(0, Integer::sum);

Reduce 장점

Integer count = Dish.MENU.stream()
                .map(dish -> 1)
                .reduce(0, Integer::sum);

다음과 같은 코드는 메뉴의 개수를 세는 스트림입니다. 단계적 반복으로 합계를 구하는 것과 reduce를 이용하여 구하는 것의 차이는 병렬 실행입니다.

기본형 스트림

Dish.MENU.stream()
                .map(Dish::getCalories)
                .sum();

이런 코드는 불가능합니다. 중간에 map이 Stream를 반환하기 때문에 sum을 호출할 수가 없습니다. 그렇기 때문에 기본형을 다루는 스트림으로 변경해야 합니다.

int sum = Dish.MENU.stream()
                .mapToInt(Dish::getCalories)
                .sum();

이렇게 하면 mapToInt 가 IntStream을 반환합니다. 기본형 스트림에서 일반 스트림으로 다시 가려면 boxed()를 붙여주면 됩니다.

실전 연습

전체 코드: https://github.com/tech-book-study/modern-java-in-action/blob/master/src/main/java/com/techbookstudy/chap5/quiz/Quiz.java

 

스트림 실전 연습 예제를 풀어 보았습니다. 연습 예제에는 2가지 클래스가 있습니다. Trader 거래자와 Transaction 트랜잭션 두 가지 클래스가 있습니다.

 

Trader.java

public class Trader {
    private String name;
    private String city;

    public Trader(String n, String c) {
        name = n;
        city = c;
    }

    public String getName() {
        return name;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String newCity) {
        city = newCity;
    }

    @Override
    public int hashCode() {
        int hash = 17;
        hash = hash * 31 + (name == null ? 0 : name.hashCode());
        hash = hash * 31 + (city == null ? 0 : city.hashCode());
        return hash;
    }

    @Override
    public boolean equals(Object other) {
        if (other == this) {
            return true;
        }
        if (!(other instanceof Trader)) {
            return false;
        }
        Trader o = (Trader) other;
        boolean eq = Objects.equals(name, o.getName());
        eq = eq && Objects.equals(city, o.getCity());
        return eq;
    }

    @Override
    public String toString() {
        return String.format("Trader:%s in %s", name, city);
    }
}

Transaction.java

public class Transaction {
    private Trader trader;
    private int year;
    private int value;

    public Transaction(Trader trader, int year, int value) {
        this.trader = trader;
        this.year = year;
        this.value = value;
    }

    public Trader getTrader() {
        return trader;
    }

    public int getYear() {
        return year;
    }

    public int getValue() {
        return value;
    }

    @Override
    public int hashCode() {
        int hash = 17;
        hash = hash * 31 + (trader == null ? 0 : trader.hashCode());
        hash = hash * 31 + year;
        hash = hash * 31 + value;
        return hash;
    }

    @Override
    public boolean equals(Object other) {
        if (other == this) {
            return true;
        }
        if (!(other instanceof Transaction)) {
            return false;
        }
        Transaction o = (Transaction) other;
        boolean eq = Objects.equals(trader, o.getTrader());
        eq = eq && year == o.getYear();
        eq = eq && value == o.getValue();
        return eq;
    }

    @SuppressWarnings("boxing")
    @Override
    public String toString() {
        return String.format("{%s, year: %d, value: %d}", trader, year, value);
    }
}

그리고 Main.java 에 초기 값들을 세팅합니다.

public static void main(String[] args) {
        Trader raoul = new Trader("Raoul", "Cambridge");
        Trader mario = new Trader("Mario", "Milan");
        Trader alan = new Trader("Alan", "Cambridge");
        Trader brian = new Trader("Brian", "Cambridge");

        List<Transaction> transactions = Arrays.asList(
                new Transaction(brian, 2011, 300),
                new Transaction(raoul, 2012, 1000),
                new Transaction(raoul, 2011, 400),
                new Transaction(mario, 2012, 710),
                new Transaction(mario, 2012, 700),
                new Transaction(alan, 2012, 950)
        );
//... 중략

같이 풀어봐도 좋을 것 같네요.

Q1. 2011년에 일어난 모든 트랜잭션을 찾아 값을 오름차순으로 정리

접근방식 → 무엇을 찾는 것은 filter를 사용하면 되고 정렬하는 것은 sorted의 인자로 정렬 기준을 주면 됩니다.

List<Transaction> answer1 = transactions.stream()
                .filter(transaction -> transaction.getYear() == 2011)
                .sorted(Comparator.comparingInt(Transaction::getValue))
                .collect(Collectors.toList());

Q2. 거래자가 근무하는 모든 도시를 중복 없이 나열

접근방식 → 우리는 Transaction과 Trader만 가지고 있습니다. 도시를 찾으려면 객체 안의 값을 반환해야 되겠죠. 그럴 때 사용하는 것이 map입니다. map을 이용해서 도시만을 빼봅시다. 그리고 중복을 없애야 하기 때문에 distinct()를 불러주죠.

List<String> answer2 = transactions.stream()
                .map(transaction -> transaction.getTrader().getCity())
                .distinct()
                .collect(Collectors.toList());

Q3. 케임브리지에서 근무하는 모든 거래자를 찾아서 이름순으로 정렬

접근방식 → 먼저 Trader를 map을 하고 케임브리지에서 근무하는 조건으로 filter를 합니다. 중복을 없애고 이름 순으로 기준을 주어서 정렬을 합니다.

List<Trader> answer3 = transactions.stream()
                .map(Transaction::getTrader)
                .filter(trader -> trader.getCity().equals("Cambridge"))
                .distinct()
                .sorted(Comparator.comparing(Trader::getName))
                .collect(Collectors.toList());

Q4. 모든 거래자의 이름을 알파벳순으로 정렬해서 반환

접근방식 → reduce를 이용해서 붙여도 되고 joining을 이용해서 문자열을 붙일 수 있습니다.

String answer4 = transactions.stream()
                .map(transaction -> transaction.getTrader().getName())
                .distinct()
                .reduce("", (name1, name2) -> name1 + " " + name2);

String answer4Opt = transactions.stream()
        .map(transaction -> transaction.getTrader().getName())
        .distinct()
        .collect(Collectors.joining(" "));

Q5. 밀라노에 거래자가 있는지

접근방식 → anyMatch라는 최종 연산을 이용하여 조건에 부합되는 것이 있는지 확인합니다.

transactions.stream()
                .anyMatch(transaction -> transaction.getTrader().getCity().equals("Milan"));

Q6. 케임브리지에 거주하는 거래자의 모든 트랜잭션 값 출력

접근방식 → 조건을 filter 하고 map으로 트랜잭션 값을 뽑아냅니다.

List<Integer> answer6 = transactions.stream()
                .filter(transaction -> transaction.getTrader().getCity().equals("Cambridge"))
                .map(Transaction::getValue)
                .collect(Collectors.toList());

Q7. 전체 트랜잭션 중 최댓값, 최솟값

// 기타 처리는 알아서
transactions.stream()
                .map(Transaction::getValue)
                .reduce(Integer::max)
                .orElse(-1);

transactions.stream()
                .map(Transaction::getValue)
                .reduce(Integer::min)
                .orElseThrow(RuntimeException::new);

transactions.stream()
                .min(Comparator.comparingInt(Transaction::getValue));

맨 아래처럼 바로 min으로 최솟값을 구해도 되고 reduce를 이용해도 됩니다. Optional을 반환하기 때문에 처리를 적절히 하면 될 것 같네요.

반응형