- 데이터 소스를 일관되고 선언적인 방식으로 처리할 수 있게 해주는 API
- 스트림은 데이터의 흐름을 추상화한 개념으로, 데이터를 필터링, 매핑 등의 작업을 수행할 수 있게 해 준다.
- 데이터 처리에 대한 선언적이고 지연 평가된 접근법을 제공한다.
1. 반복을 통한 데이터 처리
- 모든 데이터 처리는 파이프라인 방식으로 작동하는데, DB에서 조회한 데이터를 컬렉션 자료 구조에 넣거나 필터링, 변환 같은 다양한 작업들을 거쳐 결과를 제공한다.
public static void main(String[] args) {
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
List<String> newList = new ArrayList<>();
for (String s : list) {
if (s.startsWith("a")) {
continue;
}
if (s.length() <= 5) {
continue;
}
newList.add(s);
}
System.out.println(newList); // [banana, orange]
}
- 위 코드의 단점은 반복 기반 루프에 필요한 보일러플레이트 코드의 양이다.
- 예제보다 복잡한 반복문의 경우 데이터 처리를 위한 코드의 양은 많아질 것이고, 가독성이 좋지 않을 것이다.
- 이렇게 사용자가 직접 명시적으로 요소를 반복하는 것을 외부 반복(External Iteration)이라 한다.
- 외부 반복의 단점을 해결하기 위해 자바에서는 스트림 API를 사용한 내부 반복(Internal Iteration)을 도입했다.
public static void main(String[] args) {
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
List<String> newList = list.stream()
.filter(s -> !s.startsWith("a"))
.filter(s -> s.length() > 5)
.collect(Collectors.toList());
System.out.println(newList); // [banana, orange]
}
- 내부 반복을 통해 개발자가 순회 과정을 직접 제어하지 않고, 데이터 소스 자체가 '어떻게 수행되는지'를 담당하도록 한다.
2. 함수형 데이터 파이프라인으로써의 스트림
- 스트림은 다른 데이터 처리 방식처럼 작업을 수행하지만 내부 반복자라는 장점이 있다.
선언적 접근법
- 단일 호출 체인을 통해 간결하고 명료한 다단계 데이터 파이프라인 구축이 가능하다.
조합성
- 데이터 처리 로직을 위한 고차 함수로 이루어져 있으며, 필요에 따라 조합하여 사용할 수 있다.
지연 처리
- 중간 연산이 즉시 수행되지 않고, 최종 연산이 호출될 때까지 지연되는 특성을 가진다.
- 필요하지 않은 연산은 건너뛰고, 필요한 연산만 수행하여 성능을 최적화할 수 있다.
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
Stream<String> stream = list.stream()
.filter(s -> {
System.out.println("filter: " + s);
return s.startsWith("a");
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("중간 연산은 실행되지 않는다.");
List<String> result = stream.collect(Collectors.toList());
System.out.println("최종 연산");
System.out.println(result);
// 중간 연산은 실행되지 않는다.
// filter: apple
// map: apple
// filter: banana
// filter: apricot
// map: apricot
// filter: orange
// 최종 연산
// [APPLE, APRICOT]
병렬 데이터 처리
- 스트림의 요소들을 병렬로 분할하여 처리함으로써, 대용량 데이터를 효율적으로 처리할 수 있다.
2-1. Stream의 특성
느긋한 계산법
- 결과가 실제로 필요한 시점까지 지연시키는 계산 전략
- 표현식을 어떻게 생성하는지와 해당 표현식을 언제 사용하는지에 대한 문제를 분리하는 개념
- 스트림에서 중간 연산을 수행할 때 즉각적으로 실행되지 않는다.
- 대신 해당 호출은 파이프라인을 확장하고 새롭게 지연 평가된 스트림을 반환한다.
- 스트림 요소의 흐름은 깊이 우선 방식을 따른다.
- 한 요소가 시작부터 끝까지 완전히 처리된 후에 다음 요소가 처리되는 방식
- 각 요소가 순차적으로 처리되므로, 메모리 사용이 효율적이다.
상태 및 간섭 없음
- 스트림의 중간 연산은 대부분 상태를 갖지 않고 파이프라인의 다른 부분과 독립적으로 작동하며 현재 처리 중인 요소에만 접근한다.
- 스트림은 간섭하지 않고 통과하는 파이프라인이다.
중간 연산의 상태를 가질 수 있지만 파이프라인에서 동작하는 인수들은 순수 함수로 설계하는 것이 좋다.
상태에 의존하는 것은 안전성과 성능에 영향을 주며, 의도치 않은 사이드 이펙트가 발생할 수 있다.
최적화
- 일반적인 for나 while 같은 루프로도 높은 최적화가 가능하다.
- 스트림 사용 시 파이트라인은 각 호출마다 새로운 스택 프레임을 필요로 하고 오버헤드의 단점이 있다.
- 그럼에도 스트림을 사용하는 것은 코드의 간결성, 가독성, 가변성 방지와 안전성 보장의 이유 때문이지 않을까.
재사용 불가능
- 스트림 파이프라인은 단 한 번만 사용할 수 있다. 종료 연산이 호출된 후 정확히 한 번만 전달된다.
- 스트림을 다시 사용하려 하면 IllegalStateException이 발생한다.
- 소스 데이터를 변경하거나 영향을 주지 않기 때문에 항상 동일한 소스 데이터로부터 다른 스트림을 생성할 수 있다.
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
Stream<String> originStream = list.stream();
List<String> newList = originStream
.filter(s -> !s.startsWith("a"))
.filter(s -> s.length() > 5)
.collect(Collectors.toList()); // 종료 연산 호출
System.out.println(newList); // [banana, orange]
// 재사용
List<String> recycleStream = originStream
.map(s -> s.toUpperCase())
.collect(Collectors.toList());
System.out.println(recycleStream);
// Exception in thread "main" java.lang.IllegalStateException:
// stream has already been operated upon or closed
병렬 처리
- 파이프라인 내에서 parallel 메서드를 호출하는 것만으로 가능하다.
- 하지만 스트림의 병렬 처리는 충분한 데이터(대용량)를 포함해야 하며, 연산이 여러 스레드의 오버헤드를 수용할 만큼의 비용을 감당할 수 있어야 한다.
2-2. Spliterator - 분할 반복자
- 일반적인 반복 루프는 Iterator 타입을 기반으로 요소들을 순회한다.
- 스트림은 자체 반복 인터페이스인 Spliterator를 사용한다.
- Spliterator는 자동으로 스트림을 분할하는 기법으로 Iterator처럼 소스의 요소 탐색 기능을 제공한다는 점은 같지만 Spliterator는 병렬 작업에 특화되어 있다.
Spliterator는 splitable iterator의 의미로 분할할 수 있는 반복자라는 의미
trySplit
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
// Spliterator 생성
Spliterator<String> spliterator1 = list.spliterator();
// Spliterator 분할
Spliterator<String> spliterator2 = spliterator1.trySplit();
// Spliterator 분할
Spliterator<String> spliterator3 = spliterator2.trySplit();
// Spliterator 분할 -> null
Spliterator<String> spliterator4 = spliterator3.trySplit();
spliterator1.forEachRemaining(System.out::println); // apricot, orange
spliterator2.forEachRemaining(System.out::println); // banana
spliterator3.forEachRemaining(System.out::println); // apple
spliterator4.forEachRemaining(System.out::println); // NullPointerException
- trySplit()은 Spliterator를 대략 절반으로 분할하여 새로운 Spliterator를 반환한다.
- Spliterator를 구현하고, trySplit()를 재정의하여 분할 기준을 나눌 수 있다.
- 예를 들어, ArrayList의 ArrayListSpliterator는 중간 지점을 기준으로 분할한다.
- 더 이상 분할이 불가능하다면 null을 반환한다.
tryAdvance
public static void main(String[] args) {
List<String> list = Arrays.asList("apple", "banana", "apricot", "orange");
// Spliterator 생성
Spliterator<String> spliterator1 = list.spliterator();
// Spliterator 분할
Spliterator<String> spliterator2 = spliterator1.trySplit();
// spliterator1 처리
processSpliterator(spliterator1);
System.out.println("=======");
if (spliterator2 != null) {
// spliterator2 처리
processSpliterator(spliterator2);
Spliterator<String> spliterator3 = spliterator2.trySplit();
if (spliterator3 != null) {
// spliterator3 처리
System.out.println("======");
processSpliterator(spliterator3);
}
}
}
// 파라미터 spliterator에 요소가 있다면 처리
private static void processSpliterator(Spliterator<String> spliterator) {
Consumer<String> action = System.out::println;
while (spliterator.tryAdvance(action));
}
- tryAdvance()는 요소를 하나씩 처리할 때 사용된다.
- spliterator에 다음 요소가 존재하면 해당 요소를 처리하고 true를 반환하고, 요소가 없다면 false를 반환한다.
Spliterator는 주로 대량의 데이터를 처리할 때 성능을 최적화하기 위해 스트림 내부적으로 사용된다.
3. 스트림 파이프라인 구축하기
3-1. map / filter / reduce
- map : 데이터 변환
- filter : 데이터 선택
- reduce : 결과 도출
위의 패턴은 요소의 연속을 하나의 단위로 취급한다.
중간 연산은 map / filter 단계를 나타내고, 최종 연산은 reduce 단계를 나타낸다.
예제에 사용할 Shape 클래스
public record Shape(int corners) implements Comparable<Shape> {
public boolean hasCorners() {
return corners() > 0;
}
public List<Shape> twice() {
return List.of(this, this);
}
@Override
public int compareTo(Shape o) {
return Integer.compare(corners(), o.corners());
}
public static Shape circle() {
return new Shape(0);
}
public static Shape triangle() {
return new Shape(3);
}
public static Shape square() {
return new Shape(4);
}
}
요소 선택
- 특정 조건에 따라 요소를 선택한다.
- 이는 Predicate를 이용한 필터링 혹은 요소의 개수를 기반으로 선택함으로써 이루어진다.
List<Shape> shapeList = Arrays.asList(Shape.square(), Shape.triangle(), Shape.triangle(), Shape.circle(), Shape.square(), Shape.circle());
// 스트림 생성
Stream<Shape> stream = shapeList.stream();
// filter -> 결과가 true인 해당 요소는 선택된다.
stream.filter(Shape::hasCorners).forEach(System.out::println);
// TODO dropWhile -> true가 될 때까지 통과하는 모든 요소를 폐기한다.
stream.dropWhile(Shape::hasCorners).forEach(System.out::println);
//takeWhile -> false가 될 때까지 요소를 선택한다.
stream.takeWhile(Shape::hasCorners).forEach(System.out::println);
// limit (long maxSize) -> 요소의 최대 개수를 maxSize로 제한한다.
stream.limt(2L).forEach(System.out::println);
// skip (long n) -> 앞에서부터 n개의 요소를 건너뛰고, 나머지 요소들을 다음 스트림 연산을 전달
stream.skip(2L).forEach(System.out::println);
// distinct - > 중복되지 않은 요소만 반환. 연산을 비교하기 위해 모든 요소를 버퍼에 저장
stream.distinct().forEach(System.out::println);
// sorted -> Comparable이 구현되어 있다면 자연스럽게 정렬된다. 안된 경우 ClasCastException이 발생한다.
stream.sorted().forEach(System.out::println);
요소 매핑
List<Shape> shapeList = Arrays.asList(Shape.square(), Shape.triangle(), Shape.triangle(), Shape.circle(), Shape.square(), Shape.circle());
// 스트림 생성
Stream<Shape> stream = shapeList.stream();
// map -> 함수가 요소에 적용되고 새로운 요소가 스트림으로 반환된다.
// map 함수의 반환 타입(Integer)으로 새로운 요소가 반환된다.
// Stream<Integer> integerStream = stream.map(Shape::corners);
stream.map(Shape::corners).forEach(System.out::println);
// flatMap -> 컬렉션, Optional 같은 컨테이너 형태의 요소를 펼쳐서 새로운 다중 요소로 포함하는 새로운 스트림을 반환
// Stream<List<Shape>> listStream = stream.map(Shape::twice);
// Stream<Shape> shapeStream = listStream.flatMap(List::stream);
stream.map(Shape::twice).flatMap(List::stream).forEach(System.out::println);
Peek
- 스트림은 많은 기능을 하나의 호출로 묶어 간결하게 코드를 작성할 수 있지만 디버깅의 단점을 가지고 있다.
- peek 연산은 주로 디버깅을 지원하기 위해 설게 되었는데, 스트림의 요소에 개입하지 않고 요소를 들여다볼 수 있다.
Stream.of(Shape.square(), Shape.triangle(), Shape.circle())
.map(Shape::twice)
.flatMap(List::stream)
.peek(shape -> System.out.println("current: " + shape))
.filter(shape -> shape.corners() < 4)
.collect(Collectors.toList());
// current: Shape[corners=4]
// current: Shape[corners=4]
// current: Shape[corners=3]
// current: Shape[corners=3]
// current: Shape[corners=0]
// current: Shape[corners=0]
요소 축소
- 누적 연산자를 반복적을 적용하여 스트림의 요소들을 하나의 결괏값을 만든다.
- 누적자는 중간 자료 구조를 필요로 하지 않으며 항상 새로운 값을 반환한다.
reduce
1. BinaryOperator만 사용하는 reduce
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
// 스트림 요소가 없는 경우 Optional을 반환한다.
Optional<Integer> result = list.stream().reduce((a, b) -> a + b);
result.ifPresent(System.out::println); // 15
2. Identity와 BinaryOperator를 사용한 reduce
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
// 초기값이 0으로 설정되어 결과는 항상 Integer로 반환된다.
Integer result = list.stream().reduce(0, (a, b) -> a + b);
System.out.println(result); // 15
3. Identity, BinaryOperator, combiner
- map과 reduce를 결합한 변형.
- 스트림에는 T 타입의 요소가 포함되어 있지만 원하는 결과가 U 타입인 경우 사용할 수 있다.
Integer result = list.stream().reduce(0, (a, b) -> a + b, Integer::sum);
System.out.println(result); // 15
List<String> list2 = Arrays.asList("apple", "banana", "cherry");
int result2 = list2.stream()
.mapToInt(String::length)
.reduce(0, (a, b) -> a + b);
System.out.println(result2); // 17
3-2. 연산 비용
- 연산 비용은 스트림의 각 연산 단계가 필요로 하는 자원에 따라 달라진다.
중간 연산 비용
List<String> list = Arrays.asList("apple", "banana", "cherry", "date", "elderberry");
List<String> result = list.stream()
.map(String::toUpperCase)
.filter(s -> s.length() > 5)
.collect(Collectors.toList());
- filter, map, sorted 같은 중간 연산자들은 스트림 파이프라인의 깊이를 증가시킨다.
- 위의 예제는 map, filter 순으로 연산을 진행하는데, 중간 연산 연산 호출 횟수는 총 10번이다.
- map 5번, filter 5번.
- 중간 연산의 순서를 변경하는 것만으로도 연산 호출 횟수를 줄여 비용을 낮출 수 있다.
- filter, map 순으로 변경한다면 5번, 3번.
- 총 8번의 연산이 실행된다.
메모리 비용
List<String> list = Arrays.asList("apple", "banana", "cherry", "date", "elderberry");
List<String> sortedList = list.stream()
.sorted()
.collect(Collectors.toList());
- 스트림은 일반적으로 메모리에 효울적이지만, 특정 연산에서는 추가 메모리 비용이 발생할 수 있다.
- filter, map 등은 요소의 상태를 유지하지 않기 때문에 메모리 사용이 적다.
- filter: Predicate을 통해 만족하는 요소만 통과
- map: 요소에 주어진 함수를 적용하여 새로운 요소로 변환
- sorted, distinct, limit 등은 내부적으로 상태를 유지해야 하므로 메모리 사용이 증가할 수 있다.
- sorted: 모든 요소를 수집, 저장하고 정렬 연산을 수행
- distinct: 중복 제거를 위해 요소를 추적하는 상태를 유지 -> HashSet 사용
- limit: 요소를 제한하기 위해 내부적을 요소의 수를 추적
4. 스트림 사용 여부 선택
작업의 복잡도 & 처리 요소의 수
- 단순하게 몇 줄로 이루어진 반복문은 스트림으로 변환했을 때 큰 이점을 얻기 어렵다.
- 작업의 내용을 쉽게 파악할 수 있다면 간단한 for-each 반복문을 사용하는 것이 더 나은 선택일 수 있다.
- 복잡한 로직을 가진 긴 루프라면 스트림 파이프라인으로 압축하여 코드 가독성과 유지보수성을 높일 수 있다.
함수형 접근 방식에 적합한지
- 로직이 함수형 접근 방식에 적합하지 않을 수 있다.
- 실제로 필요하지 않은 코드를 스트림 파이프라인에 맞게 강제하는 것은
문제의 본질을 이해하지 않은 상태에서 해결책만 찾아가는 것과 같다.
마무리
성능은 코드 설계와 도구 선택에 있어 가장 중요한 기준이 되어서는 안된다.
성능에 대한 고민만으로 적절한 도구를 선택하지 않는다면 문제에 대한 최적의 해결책을 놓칠 위험이 있다.
스트림의 핵심 목표는 데이터 처리에 있어서 보다 선언적이고 표현력 있는 방법을 제공하기 위한 것이다.
순수 함수와 불변성을 지닌 데이터의 조합은 자료 구조와 데이터 처리 로직 간의 연결을 더욱 유연하게 만든다.(코드의 느슨한 관계)
'함수형 프로그래밍' 카테고리의 다른 글
[ch.08] 스트림을 활용한 병렬 데이터 처리 (0) | 2024.06.20 |
---|---|
[ch.07] 스트림 사용 (0) | 2024.06.16 |
[ch.05] 레코드 (0) | 2024.06.01 |
[ch.04] 가변성 & 불변성 (0) | 2024.05.23 |
[ch.03] JDK 함수형 인터페이스 (0) | 2024.05.16 |