본문 바로가기
독서/모던 자바 인 액션

데이터 컬렉션의 반복 처리 - 스트림(Stream)

by kriorsen 2023. 9. 26.

스트림이란?

데이터 컬렉션 반복 처리를 간결하게 하면서, 데이터의 병렬 처리를 쉽게 제공하는 기능

5.1 스트림의 유용한 기능들

요소가 많이 포함된 컬렉션 처리의 성능을 높이기 위해서는 멀티코어를 활용해 병렬로 컬렉션의 요소를 처리해야 합니다. 하지만 병렬 처리 코드를 구현하는 것은 매우 어렵고, 디버깅 또한 복잡한 절차입니다. 이런 문제점을 해결하기 위해 자바 언어 설계자들이 스트림이라는 것을 만들었습니다.

우선 스트림이 사용되기 전에 사용되었던 코드를 살펴보겠습니다.

List<Book> thinBooks = new ArrayList<>();
for (Book book : bookshelf) {
    if (book.getPage() < 500) {
        thinBooks.add(book);
    }
}

Collections.sort(thinBooks, new Comparator<Book>() {
    public int compare(Book book1, Book book2) {
        return book1.getTitle().compareTo(book2.getTitle());
   }
});

List<String> thinBooksTitle = new ArrayList<>();
for (Book book : thinBooks) {
    thinBooksTitle.add(book.getTitle());
}

페이지가 500 이하임을 만족하는 Book 인스턴스들을 추출하고 이를 제목순으로 정렬하여 제목들의 리스트를 얻기 위해서는 위와 같은 코드를 작성해야 했습니다. 그런데 여기에 람다를 더하면 다음과 같은 코드가 탄생합니다.

List<Stirng> thinBooksTitle = bookshelf.stream()
                            .filter(book -> book.getPage() < 500)
                            .sorted(comparing(Book::getTitle)
                            .map(Book::getTitle)
                            .collect(toList());

위 코드에서 stream()을 parallelStream()으로 바꾸면 이 코드를 병렬로 실행할 수 있습니다. 우선 bookshelf에 stream 메서드를 호출해서 스트림을 얻었습니다. 이때 데이터 소스는 책의 리스트입니다. 데이터 소스는 연속된 요소를 스트림에 제공합니다. 그 다음 filter, sorted, map, collect 등은 데이터 처리 연산에 해당되며, 그중 filter, sorted, map은 파이프라인이 형성될 수 있도록(다음 데이터 처리 연산이 현재 데이터 처리 연산의 결과를 사용할 수 있도록) 스트림을 반환합니다.

5.2 스트림의 데이터 처리 연산

public interface Stream<T> extends BaseStream {
    Stream<T> filter(Predicate<? super T> predicate);
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    IntStream mapToInt(ToIntFunction<? super T> mapper);
    LongStream mapToLong(ToLongFunction<? super T> mapper);
    DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
    IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
    Stream<T> distinct();
    Stream<T> sorted(Comparator<? super T> comparator);
    Stream<T> limit(long maxSize);
    void forEach(Consumer<? super T> action);
    Optional<T> max(Comparator<? super T> comparator);
    long count();
    boolean anyMatch(Predicate<? super T> predicate);
    boolean allMatch(Predicate<? super T> predicate);
    boolean noneMatch(Predicate<? super T> predicate);
    Optional<T> findFirst();
    Optional<T> findAny();
    //...
 }

Stream 인터페이스에는 이렇게 다양한 메서드를 제공하는데 간단한 예시를 통해 이중 일부의 메서드를 활용해 보도록 하겠습니다.

1.filter(Predicate<? super T> predicate): 조건을 충족하는 요소만 추출

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
        .filter(n -> n % 2 == 0) 
        .collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4, 6]

2.map(Function<? super T, ? extends R> mapper): 기존 요소에 대한 변환 함수 적용

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Integer> nameLengths = names.stream()
       .map(String::length)
       .collect(Collectors.toList());
System.out.println(nameLengths); // [5, 3, 7]

3.limit(long maxSize): 스트림의 크기를 제한

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> limitedNumbers = numbers.stream()
       .limit(3)
       .collect(Collectors.toList());
System.out.println(limitedNumbers); // [1, 2, 3]

4.forEach(Consumer<? super T> action): 각 요소에 대한 함수 실행

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream().forEach(System.out::println);
// Alice
// Bob
// Charlie

5.distinct(): 중복된 요소를 제거

List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 4);
List<Integer> distinctNumbers = numbers.stream()
       .distinct()
       .collect(Collectors.toList());
System.out.println(distinctNumbers); // [1, 2, 3, 4]

5.3 중간 연산과 최종 연산

Stream와 같은 반환 형식을 갖는 메서드들은 중간 연산에 해당되고(ex - filter, map, limit, sorted, distinct) Stream을 반환하지 않는, 즉 파이프라인을 형성하지 않는 foreach, count, collect와 같은 연산을 중간 연산이 끝난 마지막에 추가해 연산의 결과를 얻을 수 있습니다.

5.4 컬렉션 외부 반복과 스트림을 이용한 내부 반복

컬렉션을 사용하기 위해는 반복자를 지정해서 코드를 작성해야 하는데 이를 외부 반복이라고 합니다.

List<String> bookTitles = new ArrayList<>();
for (Book book : bookshelf)    {
    bookTitles.add(book.getName());
}

이와 같이 직접적으로 처리할 Book의 인스턴스에 대한 변수를 할당하고 이에 대한 연산을 정의해야 하는데 스트림을 사용하는 경우 book이라는 반복자 자체를 내부로 숨길 수 있습니다.

List<String> bookTitles = bookshelf.stream()
                .map(Book::getName)
                .collect(toList());

내부 반복을 이용할 경우 하드웨어를 활용한 병렬성 구현을 자동으로 선택하기 때문에 개발자가 병렬성 관리를 스스로 해야 하는 부담을 줄일 수 있고, 최적화를 쉽게 달성할 수 있게 됩니다.