Developer.

[멋사 백엔드 19기] TIL 31일차 데이터 흐름 Stream

📂 목차


📚 본문

Stream 심화

데이터를 저장하지 않고 흐르듯이 처리하는 내장 API 이다.

  • List->Stream: stream() 메서드로 생성가능하다.
  • Array->Stream: Arrays.stream() 정적 메서드로 생성 가능하다.
    • Arrays.stream(, from, to) 부분 stream 생성도 가능하다.
  • Stream 정적 메서드
    • <T> Stream<T> iterate(final T seed, final UnaryOperator<T> f) 를 활용해 무한 집합에 가까운 처리도 가능하다
    • <T> Stream<T> generate(Supplier<? extends T> s) 동일한 무한 스트림을 생성하지만, 초기 값이 없다는 것이 차이다.

모든 생성된 Stream 은 내부적으로 Generic 타입에 알맞은 인자가 들어가게 된다.

중간 연산

중간 연산을 통해 적절한 데이터로 형변환, 필터링 등등을 수행한다. 중간 연산은 호출해도 즉시 실행되지 않고, 최종 연산이 수행될 때 적용된다.

Lazy Evaluation 이다

distinct()

중복값을 제거한다.

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person p = (Person) o;
        return age == p.age && name.equals(p.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return name + "-" + age;
    }
}

public class DistinctObjectExample {
    public static void main(String[] args) {
        List<Person> people = Arrays.asList(
            new Person("Alice", 20),
            new Person("Bob", 25),
            new Person("Alice", 20)
        );

        List<Person> distinctPeople = people.stream()
                                            .distinct()
                                            .collect(Collectors.toList());

        System.out.println(distinctPeople); // [Alice-20, Bob-25]
    }
}

여기서 주의할 점은 내부 비교는 equals() + hashCode() 를 구현한 클래스에 대해서 중복 제거를 하게 된다.

java 16 부터는 collect(Collectors.toList()) 보다는 toList() 를 통해 더 간편하게 바꿀 수 있다.

flatMap()

stream 내부에서 collection 이 나올 때 이를 구조 분해하고 싶을 수도 있다. Collection 에서는 stream() 메서드를 활용하여 이를 평탄화 가능하다.

import java.util.*;
import java.util.stream.*;

public class FlatMapExample {
    public static void main(String[] args) {
        List<List<String>> listOfLists = Arrays.asList(
            Arrays.asList("A", "B"),
            Arrays.asList("C", "D", "E"),
            Arrays.asList("F")
        );

        // flatMap 사용
        List<String> flatList = listOfLists.stream()
            .flatMap(List::stream)  // 각 리스트를 스트림으로 변환하고 평탄화
            .collect(Collectors.toList());

        System.out.println(flatList); // [A, B, C, D, E, F]
    }
}

또한 내부적으로 Collection 이 아니더라도 해당 클래스에 대한 stream 으로 변환해주는 API 가 있다면 stream 내에서 구조 분해가 가능하다.

class Student {
    String name;
    List<String> courses;

    Student(String name, List<String> courses) {
        this.name = name;
        this.courses = courses;
    }
}

public class FlatMapStudentExample {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", Arrays.asList("Math", "Physics")),
            new Student("Bob", Arrays.asList("English", "History")),
            new Student("Charlie", Arrays.asList("Math", "History"))
        );

        // 모든 학생이 수강하는 과목을 하나의 스트림으로
        List<String> allCourses = students.stream()
            .flatMap(student -> student.courses.stream())
            .distinct()  // 중복 제거
            .collect(Collectors.toList());

        System.out.println(allCourses); // [Math, Physics, English, History]
    }
}
sorted()

중간 연산자인 정렬은 인자가 없는 것을 쓰려면 해당 stream 내부의 데이터에 대한 Comparable 이 정의되어 있어야 하며, 인자가 있을 때는 sorted(Comparator c) 의 Comparator 가 들어간다. 익명 객체를 통해 Comparator 를 생성해서 넣을 수도 있지만, Comparators.comparing() 을 통해 primitive 혹은 어떤 특정한 클래스의 정렬 조건을 따르게 간단히도 구현할 수 있다.

List<Integer> numbers = Arrays.asList(5, 2, 8, 1);
List<Integer> sortedDesc = numbers.stream()
                                  .sorted((a, b) -> b - a)
                                  .toList();
System.out.println(sortedDesc); // [8, 5, 2, 1]

class Person {
    String name;
    int age;
    Person(String name, int age) { this.name = name; this.age = age; }
    @Override public String toString() { return name + "-" + age; }
}

List<Person> people = Arrays.asList(
    new Person("Alice", 25),
    new Person("Bob", 20),
    new Person("Charlie", 30)
);

// 나이 순으로 정렬
List<Person> sortedByAge = people.stream()
                                 .sorted(Comparator.comparingInt(p -> p.age))
                                 .toList();

System.out.println(sortedByAge); 
// [Bob-20, Alice-25, Charlie-30]

// comparing 메서드
List<Person> sortedByNameThenAge = people.stream()
    .sorted(Comparator.comparing((Person p) -> p.name)
                      .thenComparingInt(p -> p.age))
    .toList();
peek()

peekforEach 와 비슷하지만 forEach 는 최종 연산자로서 작용되고, peek 는 중간연산자로 작동한다. 중간에 데이터가 어떻게 처리되는지 보고 싶거나 중간데이터를 써야 할 경우에 사용한다.

stream.peek(Consumer<? super T> action)

내부적으로 Consumer 를 넣어야 하며, stream 내의 데이터에 대해 소비를 하는 functional interface 가 들어가면 된다.

reduce()

reducestream 의 데이터를 축소, 합치는 연산을 수행한다. 첫 번째 인자는 초기값인데 선택이다. 두 번째 인자는 BinaryOperator 함수형 인터페이스를 넣어주면 된다.

public class ReduceExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        // 합계 구하기 (초기값 있음)
        int sum = numbers.stream()
            .reduce(0, (a, b) -> a + b);
        System.out.println("합계: " + sum); // 15

        // 합계 구하기 (메서드 참조)
        int sum2 = numbers.stream()
            .reduce(0, Integer::sum);
        System.out.println("합계2: " + sum2); // 15

        // 최댓값 구하기 (초기값 없음)
        Optional<Integer> max = numbers.stream()
            .reduce(Integer::max);
        max.ifPresent(n -> System.out.println("최댓값: " + n)); // 5

        // 문자열 연결
        List<String> words = Arrays.asList("Hello", " ", "World", "!");
        String sentence = words.stream()
            .reduce("", String::concat);
        System.out.println(sentence); // Hello World!
    }
}
collect()
<R, A> R collect(Collector<? super T, A, R> collector)

위 API 를 보기 전에 Collector 를 먼저 보자.

public interface Collector<T, A, R> {
    // ...
}

Collector 는 인터페이스로 선언되어 있다.

  • T: 스트림 요소 타입
  • A: 누적기, 스트림 요소를 임시로 모아두는 객체
  • R: 최종 반환 타입(collect 후 얻는 결과)
Collector<Employee, ?, Integer> summingSalaries
         = Collectors.summingInt(Employee::getSalary))

컬렉터 는 3개의 parametric type 이 들어가긴 하지만 생략도 가능하다. 이렇게 ? 로 생략할 수 있는 이유는 내부적으로 Integer 를 누적하기 위해 어떤 객체 A 를 사용하겠지만, 외부에서는 R 만 알면 충분하기 때문이다. 이는 컴파일러가 자동으로 타입 유추를 하여 누적기 A 의 타입을 자동으로 결정하기 때문이다.

위 내용을 몰라도 Collector 를 생성할 때는 Collectors 를 통해 더 쉽게쉽게 생성할 수 있다.

public class CollectorsExample {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", 85, "CS"),
            new Student("Bob", 92, "Math"),
            new Student("Charlie", 78, "CS"),
            new Student("David", 88, "Physics"),
            new Student("Eve", 95, "Math")
        );

        // toList로 수집
        List<String> names = students.stream()
            .map(Student::getName)
            .collect(Collectors.toList());

        // toSet으로 수집
        Set<String> departments = students.stream()
            .map(Student::getDepartment)
            .collect(Collectors.toSet());

        // toMap으로 수집
        Map<String, Integer> nameToScore = students.stream()
            .collect(Collectors.toMap(
                Student::getName,
                Student::getScore
            ));

        // joining으로 문자열 결합
        String allNames = students.stream()
            .map(Student::getName)
            .collect(Collectors.joining(", "));
        System.out.println("모든 학생: " + allNames);
        // 모든 학생: Alice, Bob, Charlie, David, Eve
    }

    static class Student {
        private String name;
        private int score;
        private String department;

        public Student(String name, int score, String department) {
            this.name = name;
            this.score = score;
            this.department = department;
        }

        // getter 메서드들
        public String getName() { return name; }
        public int getScore() { return score; }
        public String getDepartment() { return department; }
    }
}

최종 연산

allMatch, anyMatch, noneMatch
boolean allMatch(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)

Predicate 를 넣어 true/false 를 반환하도록 하며, all 은 모든, any 는 어떤, none 은 모든의 부정으로서 작용한다. stream 을 반환하지 않기 때문에 최종연산이다.

import java.util.*;
import java.util.stream.*;

public class MatchExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(2, 4, 6, 8, 10);

        boolean allEven = numbers.stream()
                                 .allMatch(n -> n % 2 == 0);  // 모든 수가 짝수인가?
        System.out.println("allEven: " + allEven);  // true

        boolean anyGreaterThan5 = numbers.stream()
                                        .anyMatch(n -> n > 5);  // 5보다 큰 수가 있는가?
        System.out.println("anyGreaterThan5: " + anyGreaterThan5); // true

        boolean noneNegative = numbers.stream()
                                     .noneMatch(n -> n < 0); // 음수가 없는가?
        System.out.println("noneNegative: " + noneNegative); // true
    }
}
findFirst, findAny

최종 연산자이며, 메서드 명대로 첫번째를 찾거나

  • findFirst(): 스트림에 데이터가 없으면 Optional.<T>empty() 가 반환된다
  • findAny(): 어떤 요소든 하나를 반환한다(병렬 스트림에서 주로 사용하며, 성능 최적화가 가능하다)

즉, 순차 스트림에서는 findFirst = findAny 이고, 병렬 스트림에서는 findAny 가 순서를 보장하지 않고 그냥 빠르게 온 요소를 바로 반환하기 때문에 findFirst 보다 더 성능이 좋다.

집계 연산

집계 연산도 최종 연산에 포함된다. 하지만 집계를 한 후에도 다른 처리가 필요할 수도 있는데 그때는 stream 으로 다시 생성시켜줘야 한다.

count

import java.util.*;
import java.util.stream.*;

public class CountExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(2, 4, 6, 8, 10);

        long count = numbers.stream()
                            .filter(n -> n > 5)  // 5보다 큰 수만
                            .count();

        System.out.println("5보다 큰 수 개수: " + count); // 3
    }
}

max

Optional<Integer> maxValue = numbers.stream()
                                    .max(Integer::compareTo);

maxValue.ifPresent(v -> System.out.println("최댓값: " + v)); // 10

min

class Person {
    String name;
    int age;
    Person(String name, int age) { this.name = name; this.age = age; }
}

List<Person> people = Arrays.asList(
    new Person("Alice", 25),
    new Person("Bob", 20),
    new Person("Charlie", 30)
);

Optional<Person> youngest = people.stream()
                                  .min(Comparator.comparingInt(p -> p.age));

youngest.ifPresent(p -> System.out.println("가장 어린 사람: " + p.name)); // Bob
groupingBy

collect 와 함께 사용되는 최종 연산용 Collector 의 기능이다.

Map<K, List<T>> grouped = stream.collect(
    Collectors.groupingBy(classifier)
);

기본적으로 Collectors.groupingBy 를 사용하여 K 에 대한 기준점을 classfier 로 잡고 묶고, K 에 해당하는 요소들을 List<T> 로 묶어 Map 을 반환하게 된다.

import java.util.*;
import java.util.stream.*;

public class GroupingByExample {
    static class Student {
        String name;
        int score;
        String department;

        Student(String name, int score, String department) {
            this.name = name;
            this.score = score;
            this.department = department;
        }

        @Override
        public String toString() {
            return name + "-" + score;
        }
    }

    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", 85, "CS"),
            new Student("Bob", 92, "Math"),
            new Student("Charlie", 78, "CS"),
            new Student("David", 88, "Physics"),
            new Student("Eve", 95, "Math")
        );

        // 학과별로 그룹화
        Map<String, List<Student>> grouped = students.stream()
            .collect(Collectors.groupingBy(s -> s.department));

        System.out.println(grouped);
    }
}

또한 단순히 Map 으로 grouping 을 하는 것 외에 두 번째 인자 downstream 으로 Collector 를 또 넣어서 집계 함수처럼 사용이 가능하다.

n 그룹으로 나뉨

// 학과별 평균 점수 계산
Map<String, Double> avgScore = students.stream()
    .collect(Collectors.groupingBy(
        s -> s.department,
        Collectors.averagingInt(s -> s.score)
    ));

System.out.println(avgScore);
partitioningBy

특정 parameter 를 기준으로 분할 할 수 있는 기능 또한 있다.

import java.util.*;
import java.util.stream.*;

public class PartitioningExample1 {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1,2,3,4,5,6,7,8,9,10);

        Map<Boolean, List<Integer>> evenOdd = numbers.stream()
            .collect(Collectors.partitioningBy(n -> n % 2 == 0));

        System.out.println("짝수: " + evenOdd.get(true));  // [2, 4, 6, 8, 10]
        System.out.println("홀수: " + evenOdd.get(false)); // [1, 3, 5, 7, 9]
    }
}

여기서 partitioningBy 는 무조건 Key 가 Boolean 형태로 나오게 되고, 이는 2개의 그룹으로만 나뉘는 것을 알 수 있다.

2개 그룹으로 나뉨

Parallel Stream

이런 stream 은 병렬 처리가 가능한데, 컬렉션 인터페이스에서는 디폴트 메서드로 parallelStream() 이라는 메서드를 지원한다. 또한 stream() 후에 parallel() 을 호출하여도 된다.

생성 방법

  • parallelStream()
  • stream().parallel()

이렇게 생성된 스트림들은 연산들이 병렬적으로 실행되게 되며 다음 주의사항을 포함한다:

  1. 작업 단위가 충분히 크지 않으면 성능 저하가 됨
    • 내부적으로 ForkJoinPool 이라는 스레드 풀을 사용하는데,
      스레드를 쪼개고 합치는 오버헤드가 있기에, 요소 수가 적어 연산이 가벼울 시 직렬 스트림보다 더 느리다.
  2. 공유 상태(Shared State) 접근 시 주의
    • 병렬 스트림은 여러 스레드가 동시에 작업하기 때문에, 외부에서 공유되는 가변 상태(mutable state)(자바는 PF 가 아니기 때문)를 건드리면 동기화 문제가 발생한다.
    • Race Condition 을 막기 위해 동기화 메커니즘이 반드시 필요하다.
  3. 순서가 중요한 연산에서 주의
    • 병렬 처리는 순서를 보장하지 않기에 결과 처리를 주의하자.
  4. 스레드 풀 제약
    • parallelStream 은 기본적으로 ForkJoinPool 을 사용한다
    • 다른 병렬 작업과 공유될 수 있기 때문에, 예기치 않게 성능이 저하될 수 있으며, 직접 커스텀 ForkJoinPool 을 쓰고 싶다면 stream().parallel().collect(...) 대신 poolsubmit(() -> stream.parallel()...) 과 같은 패턴을 사용해야 한다.
  5. Boxing / Unboxing 비용
    • 내부적으로 Stream 이라면 **Auto Boxing** 에서 (**-128 ~ 127** 는 객체 생성이 일어나지 않음, 이미 생성되어 있기 때문) 괜찮지만 큰 값은 매번 객체를 생성하여 Heap 을 사용하게 된다.
    • 이 말은 기본형은 스택/레지스터 수준에서 처리되지만, 래퍼 타입은 힙 객체로 참조된다는 의미인데, int 의 primitive 는 기본적으로 4 byte 인데 반해 Integer 는 8 byte 의 레퍼런스형 이므로 힙에 생성되는게 당연하다. 하지만 Integer 에서도 작은 값(-128 ~ 127) 에 대해서는 캐싱이 되어서 힙을 사용하지 않아 비용이 그렇게 크지 않다라는 의미이다.
    • 위와 같은 상황이 되면 GC 부하가 증가가되며, 불필요한 Boxing 으로 객체가 남발되어서 GC 부담이 커지게 된다.
    • 기본형은 CPU 명령어 하나로 계산이 가능하고, 래퍼 타입은 Unboxing 후 계산을 하기 때문에 다시 Boxing 을 또 해야하므로 추가 오버헤드가 발생한다.

사실 Integer 에 헤더 자체가 보통 12 ~ 16 byte 가 될 수도 있고, 최소 16 byte 이상 차지할 수도 있다(JVM 구현에 따라 다르다).

언제 사용하면 좋을까?

⭕️ 기본적으로 다음을 전부 만족할 때 사용하면 좋다.

  • CPU Bound 연산
    • map()
    • filter()
    • reduce(): 순서 의존이 없는 경우만
    • collect(): 순서 없는 자료형의 collecting
    • distinct()
  • 데이터 크기가 매우 클 때
  • 순서가 중요하지 않은 연산
  • 불변 데이터 사용 시

❌ 다음에는 사용하지 말자

  • findFirst()
  • forEachOrdered()
  • groupingBy()
  • 작은 데이터에 대한 모든 처리

Boxing / Unboxing 을 피하는 방법을 보자.

  • Stream<Integer> 보다는 IntStream, LongStream, DoubleStream 으로 primitive stream 사용

  • Stream<Integer> 로 되었을 때는 .mapToInt 와 같은 Unboxing 기능으로 primitive 변환 후에 병렬 처리 사용 이후에는 .boxed() 로 다시 Boxing 처리

  • 자바 표준에는 없지만, fastutil, Trove, HPPC 같은 외부 라이브러리는 IntList, IntSet, Int2IntMap 같은 primitive 컬렉션을 제공 → List보다 메모리 / 성능 효율

  • Integer.valueOf() 가 ~128 ~ 127 범위는 캐싱하는 것처럼 직접 캐시 테이블 구현 가능

  • Optionalprimitive 버전을 활용

    • OptionalInt, OptionalLong, OptionalDouble