📂 목차
📚 본문
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)); // 10min
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)); // BobgroupingBy
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