📂 목차
📚 본문
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()
peek
는 forEach
와 비슷하지만 forEach
는 최종 연산자로서 작용되고, peek
는 중간연산자로 작동한다. 중간에 데이터가 어떻게 처리되는지 보고 싶거나 중간데이터를 써야 할 경우에 사용한다.
stream.peek(Consumer<? super T> action)
내부적으로 Consumer
를 넣어야 하며, stream 내의 데이터에 대해 소비를 하는 functional interface 가 들어가면 된다.
reduce()
reduce
는 stream
의 데이터를 축소, 합치는 연산을 수행한다. 첫 번째 인자는 초기값인데 선택이다. 두 번째 인자는 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()
이렇게 생성된 스트림들은 연산들이 병렬적으로 실행되게 되며 다음 주의사항을 포함한다:
- 작업 단위가 충분히 크지 않으면 성능 저하가 됨
- 내부적으로
ForkJoinPool
이라는 스레드 풀을 사용하는데,
스레드를 쪼개고 합치는 오버헤드가 있기에, 요소 수가 적어 연산이 가벼울 시 직렬 스트림보다 더 느리다.
- 내부적으로
- 공유 상태(Shared State) 접근 시 주의
- 병렬 스트림은 여러 스레드가 동시에 작업하기 때문에, 외부에서 공유되는 가변 상태(mutable state)(자바는 PF 가 아니기 때문)를 건드리면 동기화 문제가 발생한다.
- Race Condition 을 막기 위해 동기화 메커니즘이 반드시 필요하다.
- 순서가 중요한 연산에서 주의
- 병렬 처리는 순서를 보장하지 않기에 결과 처리를 주의하자.
- 스레드 풀 제약
parallelStream
은 기본적으로ForkJoinPool
을 사용한다- 다른 병렬 작업과 공유될 수 있기 때문에, 예기치 않게 성능이 저하될 수 있으며, 직접 커스텀 ForkJoinPool 을 쓰고 싶다면
stream().parallel().collect(...)
대신poolsubmit(() -> stream.parallel()...)
과 같은 패턴을 사용해야 한다.
- Boxing / Unboxing 비용
- 내부적으로 Stream
이라면 **Auto Boxing** 에서 (**-128 ~ 127** 는 객체 생성이 일어나지 않음, 이미 생성되어 있기 때문) 괜찮지만 큰 값은 매번 객체를 생성하여 Heap 을 사용하게 된다. - 이 말은 기본형은 스택/레지스터 수준에서 처리되지만, 래퍼 타입은 힙 객체로 참조된다는 의미인데, int 의 primitive 는 기본적으로 4 byte 인데 반해
Integer
는 8 byte 의 레퍼런스형 이므로 힙에 생성되는게 당연하다. 하지만Integer
에서도 작은 값(-128 ~ 127) 에 대해서는 캐싱이 되어서 힙을 사용하지 않아 비용이 그렇게 크지 않다라는 의미이다. - 위와 같은 상황이 되면 GC 부하가 증가가되며, 불필요한 Boxing 으로 객체가 남발되어서 GC 부담이 커지게 된다.
- 기본형은 CPU 명령어 하나로 계산이 가능하고, 래퍼 타입은 Unboxing 후 계산을 하기 때문에 다시 Boxing 을 또 해야하므로 추가 오버헤드가 발생한다.
- 내부적으로 Stream
사실 Integer 에 헤더 자체가 보통 12 ~ 16 byte 가 될 수도 있고, 최소 16 byte 이상 차지할 수도 있다(JVM 구현에 따라 다르다).
언제 사용하면 좋을까?
⭕️ 기본적으로 다음을 전부 만족할 때 사용하면 좋다.
- CPU Bound 연산
map()
filter()
reduce()
: 순서 의존이 없는 경우만collect()
: 순서 없는 자료형의 collectingdistinct()
- 데이터 크기가 매우 클 때
- 순서가 중요하지 않은 연산
- 불변 데이터 사용 시
❌ 다음에는 사용하지 말자
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 범위는 캐싱하는 것처럼 직접 캐시 테이블 구현 가능 -
Optional
도primitive
버전을 활용OptionalInt
,OptionalLong
,OptionalDouble