스트림(Stream)이란?
스트림은 '데이터의 흐름'입니다. 배열 또는 컬렉션 인스턴스에 함수 여러 개를 조합해서 원하는 결과를 필터링하고 가공된 결과를 얻을 수 있습니다. 또한 이전에 배웠던 람다를 이용하여 코드의 양을 줄여 간결하게 표현할 수 있습니다.
즉, 배열과 컬렉션을 함수형으로 처리할 수 있습니다.
또한 간단하게 병렬처리(multi-threading)가 가능하다는 장점이 있습니다.
이 말은 즉, 스레드를 이용해 많은 요소들을 빠르게 처리할 수 있습니다.
스트림에 대한 내용은 크게 3가지로 나눌 수 있습니다.
이번 글은 이 세 가지 내용 중 최종 연산에 대한 내용을 다뤄보고자 합니다.
최종 연산
우리는 스트림의 생성과 중간 연산에 대해 공부하였습니다. 이제 마지막으로 가공한 스트림을 이용해 결괏값으로 만들어내는 단계입니다. 결괏값을 만들어내기 위해 스트림 API는 다양한 종료작업을 제공하며 이를 스트림을 끝내는 최종 작업(terminal operations)이라 합니다.
Calculating
최소, 최대, 합, 평균 등 기본형 타입으로 결과를 만들어낼 수 있습니다.
import java.util.stream.IntStream;
public class Main {
public static void main(String[] args) {
long count = IntStream.of(1, 3, 5, 7, 9).count();
long sum = IntStream.of(1, 3, 5, 7, 9).sum();
System.out.println(count);
System.out.println(sum);
}
}
// 출력 결과
5
25
만약 스트림이 비어 있는 경우 count와 sum은 0을 출력합니다.
하지만 평균, 최소, 최대의 경우에는 표현할 수 없기에 Optional을 이용해 리턴합니다.
import java.util.OptionalInt;
import java.util.stream.IntStream;
public class Main {
public static void main(String[] args) {
OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min();
OptionalInt max = IntStream.of(1, 3, 5, 7, 9).max();
System.out.println(min);
System.out.println(max);
}
}
// 출력 결과
OptionalInt[1]
OptionalInt[9]
스트림에서 바로 ifPresent 메서드를 이용해서 Optional을 처리할 수 있습니다.
import java.util.stream.DoubleStream;
public class Main {
public static void main(String[] args) {
DoubleStream.of(1.1, 2.2, 3.3, 4.4, 5.5)
.average()
.ifPresent(System.out::println);
}
}
//출력 결과
3.3
이 외에도 사용자가 원하는대로 결과를 만들어내기 위해 reduce와 collect 메서드를 제공합니다.
Reduction
스트림은 reduce 라는 메서드를 이용해서 결과를 만들어냅니다.
reduce 메서드는 세가지의 파라미터를 받을 수 있습니다.
- accumulator: 각 요소를 처리하는 계산 로직. 각 요소가 올 때마다 중간 결과를 생성하는 로직.
- identity: 계산을 위한 초기값으로 스트림이 비어서 계산할 내용이 없더라도 이 값은 리턴.
- combiner: qudfuf(parallel) 스트림에서 나눠 계산한 결과를 하나로 합치는 동작의 로직.
// 1 개 (accumulator)
Optional<T> reduce(BinaryOperator<T> accumulator);
// 2 개 (identity)
T reduce(T identity, BinaryOperaotr<T> accumulator);
// 3 개 (combiner)
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
- 인자가 1개인 경우
import java.util.OptionalInt;
import java.util.stream.IntStream;
public class Main {
public static void main(String[] args) {
OptionalInt reduce = IntStream.range(1, 4) // [1, 2, 3]
.reduce((a, b) -> {
return Integer.sum(a, b);
});
System.out.println(reduce);
}
}
//출력 결과
OptionalInt[6]
인자가 한개인 경우입니다. BinaryOperator<T>는 같은 타입의 인자 두 개를 받아 같은 타입의 결과를 반환하는 함수형 인터페이스입니다. 위의 예제에서는 두 값을 더하는 람다를 넘겨주고 있습니다. 따라서 결과는 6이 됩니다.
- 인자가 2개인 경우
import java.util.stream.IntStream;
public class Main {
public static void main(String[] args) {
int reducedTwoPrams =
IntStream.range(1, 4) // [1, 2, 3]
.reduce(10, Integer::sum);
System.out.println(reducedTwoPrams);
}
}
인자가 두 개인 경우입니다. 여기서 10은 초기값이고, 스트림 내 값을 더해서 결과는 16입니다.
여기서 람다는 메서드 참조(method reference)를 이용해서 넘길 수 있습니다.
- 인자가 3개인 경우
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Integer reducedParams = Stream.of(1, 2, 3)
.reduce(10, // identity
Integer::sum, // accumulator
(a, b) -> {
System.out.println("combiner was called");
return a+b;
});
System.out.println(reducedParams);
}
}
//출력 결과
16
인자가 세 개인 경우입니다. 위의 코드를 실행해 보면 마지막 인자인 combiner는 실행되지 않습니다.
Combiner는 병렬 처리 시 각자 다른 스레드에서 실행한 결과를 마지막에 합치는 단계입니다. 따라서 병렬 스트림에서만 동작합니다.
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Integer reducedParams = Stream.of(1, 2, 3)
.parallel()
.reduce(10, // identity
Integer::sum, // accumulator
(a, b) -> {
System.out.println("combiner was called");
return a+b;
});
System.out.println(reducedParams);
}
}
//출력 결과
combiner was called
combiner was called
36
병렬 스트림에서 동작하는 모습입니다. 결과는 다음과 같이 36을 확인할 수 있습니다.
먼저 accumulator는 총 세 번 동작합니다.
초기값 10에 각 스트림값을 더한 세 개의 값(10 + 1 = 11, 10 + 2 = 12, 10 + 3 = 13)을 계산합니다. Combiner는 identity와 accumulator를 가지고 여러 스레드에서 나눠 계산한 결과를 합치는 역할입니다. 12+ 13 = 25, 25 + 11 = 36 이렇게 두 번 호출됩니다.
병렬 스트림이 무조건 시퀀셜보다 좋은 것은 아닙니다. 오히려 간단한 경우에는 이렇게 부가적인 처리가 필요하기 때문에 오히려 느릴 수도 있습니다.
Collecting
collect 메서드는 또 다른 종료 작업입니다. Collector 타입의 인자를 받아서 처리를 합니다.
자주 사용하는 작업은 Collectors 객체에서 제공합니다.
Collecting을 수행하기 위해 아래와 같은 간단한 리스트를 작성하였습니다. Product 객체는 수량(amount)과 이름(name)을 가지고 있습니다.
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
}
}
Product class
public class Product {
private String name;
private int amount;
public Product(int amount, String name) {
this.amount= amount;
this.name = name;
}
public void setAmount(int amount) {
this.amount = amount;
}
public void setName(String name) {
this.name = name;
}
public int getAmount() {
return amount;
}
public String getName() {
return name;
}
}
Collectors.toList()
스트림에서 작업한 결과를 담은 리스트로 반환합니다. 다음 예제에서는 map으로 각 요소의 이름을 가져온 후 Collectors.toList를 이용해서 리스트로 결과를 가져옵니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
List<String> collectorCollection =
productList.stream()
.map(Product::getName)
.collect(Collectors.toList());
System.out.println(collectorCollection);
}
}
//출력 결과
[potatoes, orange, lemon, bread, sugar]
Collectors.joining()
스트림에서 작업한 결과를 하나의 스트링으로 이어 붙일 수 있습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
String listToString = productList.stream()
.map(Product::getName)
.collect(Collectors.joining());
System.out.println(listToString);
}
}
//출력 결과
potatoesorangelemonbreadsugar
Collectors.joining은 세 개의 인자를 받을 수 있습니다. 이를 이용하면 간단하게 스트링을 조합할 수 있습니다.
- delimiter: 각 요소 중간에 들어가 요소를 구분시켜 주는 구분자
- prefix: 결과 맨 앞에 붙는 문자
- suffix: 결과 맨 뒤에 붙는 문자
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
String listToString = productList.stream()
.map(Product::getName)
.collect(Collectors.joining(", ","<",">"));
System.out.println(listToString);
}
}
//출력 결과
<potatoes, orange, lemon, bread, sugar>
Collectors.averageingInt()
숫자 값(Integer value)의 평균(arithmetic mean)을 냅니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Double averageAmount =
productList.stream()
.collect(Collectors.averagingInt(Product::getAmount));
System.out.println(averageAmount);
}
}
// 출력 결과
17.2
Collectors.summingInt()
숫자값의 합(sum)을 냅니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Integer summingAmount =
productList.stream()
.collect(Collectors.summingInt(Product::getAmount));
System.out.println(summingAmount);
}
}
//출력 결과
86
IntStream으로 바꿔주는 mapToInt 메서드를 사용해서 좀 더 간단하게 표현할 수 있습니다.
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Integer summingAmount =
productList.stream()
.mapToInt(Product::getAmount)
.sum();
System.out.println(summingAmount);
}
}
// 출력 결과
86
Collectors.summarizingInt()
만약 합계와 평균 모두 필요하다면 스트림을 두 번 생성해야 할까요? 이런 정보를 한 번에 얻을 수 있는 방법으로는 summarizingInt 메서드가 있습니다.
import java.util.Arrays;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
IntSummaryStatistics statistics =
productList.stream()
.collect(Collectors.summarizingInt(Product::getAmount));
System.out.println(statistics);
}
}
//출력 결과
IntSummaryStatistics{count=5, sum=86, min=13, average=17.200000, max=23}
- count: getCount()
- sum: getSum()
- min: getMin()
- max: getMax()
- average: getAverage()
이를 이용하면 map을 호출할 필요가 없게 됩니다. 위에서 살펴본 averaging, summing, summarizing 메서드는 각 기본 타입(int, long, double) 별로 제공됩니다.
Collectors.groupingBy()
특정 조건으로 요소들을 그룹 지을 수 있습니다. 수량을 기준으로 grouping 하여 보겠습니다. 여기서 받는 인자는 함수형 인터페이스입니다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Map<Integer, List<Product>> collectorMapOfLists =
productList.stream()
.collect(Collectors.groupingBy(Product::getAmount));
}
}
결과는 Map 타입으로 나오며, 같은 수량이면 리스트로 묶어서 보여줍니다
{23=[Product{amount=23, name="potatoes"},
Product{amount=23, name="bread"}],
13=[Product{amount=13, name="lemon"},
Product{amount=13, name="sugar"}],
14=[Product{amount=14, name="orange"}]}
Coleectors.partitioningBy()
위의 groupingBy 함수형 인터페이스 function을 이용해서 특정 값을 기준으로 스트림 내 요소들을 묶었다면, partitioningBy는 함수형 인터페이스 Predicate를 받습니다. Predicate는 인자를 받아서 boolean 값을 리턴합니다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Map<Boolean, List<Product>> mapPartitioned =
productList.stream()
.collect(Collectors.partitioningBy(el -> el.getAmount() > 15));
}
}
따라서 평가를 하는 함수를 통해서 스트림 내 요소들을 true와 false 두 가지로 나눌 수 있습니다.
{false=[Product{amount=14, name='orange'},
Product{amount=13, name='lemon'},
Product{amount=13, name='sugar'}],
true=[Product{amount=23, name='potatoes'},
Product{amount=23, name='bread'}]}
Collectors.collectingAndThen()
특정 타입으로 결과를 collet 한 이후에 추가 작업이 필요한 경우에 사용할 수 있습니다. 이 메서드의 시그니쳐는 다음과 같습니다. finisher가 추가된 모양인데, 이 피니셔는 collect를 한 후에 실행할 작업을 의미합니다.
public static<T,A,R,RR> Collector<T,A,RR> collectingAndThen(
Collector<T,A,R> downstream,
Function<R,RR> finisher) { ... }
아래의 예제는 Collectors.toSet을 이용해서 결과를 Set으로 collect 한 후 수정불가한 Set으로 변환하는 작업을 추가로 실행하는 코드입니다.
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Set<Product> unmodifiableSet =
productList.stream()
.collect(Collectors.collectingAndThen(Collectors.toSet(),
Collections::unmodifiableSet));
}
}
Collector.of()
필요한 로직이 있다면 직접 collector를 만들 수 있습니다. accumulator와 combiner는 reduce에서 살펴본 내용과 동일합니다.
public static<T, R> Collector<T, R, R> of(
Supplier<R> supplier, // new collector 생성
BiConsumer<R, T> accumulator, // 두 값을 가지고 계산
BinaryOperator<R> combiner, // 계산한 결과를 수집하는 함수.
Characteristics... characteristics) { ... }
코드 예제를 보며 이해해 보도록 하겠습니다
import java.util.*;
import java.util.stream.Collector;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Collector<Product, ?, LinkedList<Product>> toLinkedList =
Collector.of(LinkedList::new,
LinkedList::add,
(first, second) -> {
first.addAll(second);
return first
});
}
}
위의 예제 코드를 보면 collector를 하나 생성합니다. 컬렉터를 생성하는 supplier에 LinkedList의 생성자를 넘겨줍니다. 그리고 accumulator에는 리스트에 추가하는 add 메서드를 넘겨주고 있습니다. 따라서 이 컬렉터는 스트림의 각 요소에 대해서 LinkedList를 만들고 요소를 추가하게 됩니다. 마지막으로 combiner를 이용해 결과를 조합하는데, 생성된 리스트들을 하나의 리스트로 합치고 있습니다.
따라서 다음과 같이 collect 메서드에 우리가 만든 커스텀 컬렉터를 넘겨줄 수 있고, 결과가 담긴 LinkedList가 반환됩니다.
import java.util.*;
import java.util.stream.Collector;
public class Main {
public static void main(String[] args) {
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
Collector<Product, ?, LinkedList<Product>> toLinkedList =
Collector.of(LinkedList::new,
LinkedList::add,
(first, second) -> {
first.addAll(second);
return first
});
LinkedList<Product> linkedListOfPersons =
productList.stream()
.collect(toLinkedList);
}
}
Matching
매칭은 조건식 람다 Predicate를 받아서 해당 조건을 만족하는 요소가 있는지 체크한 결과를 반환합니다.
다음과 같은 세 가지 메서드가 있습니다.
- 하나라도 조건을 만족하는 요소가 있는지(anyMatch)
- 모두 조건을 만족하는지(allMatch)
- 모두 조건을 만족하지 않는지(noneMatch)
간단한 예제코드입니다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> names = Arrays.asList("apple","Java","angel");
boolean anyMatch = names.stream()
.anyMatch(name -> name.contains("a"));
boolean allMatch = names.stream()
.allMatch(name -> name.length() >3);
boolean noneMatch = names.stream()
.noneMatch(name -> name.endsWith("s"));
System.out.println(anyMatch);
System.out.println(allMatch);
System.out.println(noneMatch);
}
}
//출력 결과
true
true
true
Iterating
foreach는 요소를 돌면서 실행되는 최종 작업입니다.
앞서 살펴본 peek와는 중간 작업과 최종 작업의 차이가 있습니다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> names = Arrays.asList("apple","Java","angel");
names.stream().forEach(System.out::println);
}
}
//출력 결과
apple
java
angel
Reference
'JAVA' 카테고리의 다른 글
[JAVA] 파일 입출력 (I/O) (0) | 2023.03.20 |
---|---|
[JAVA] 스트림 (Stream) - 중간 연산 (0) | 2023.03.18 |
[JAVA] 스트림 (Stream) - 생성 (0) | 2023.03.17 |
[JAVA] 람다 (Lambda) (0) | 2023.03.16 |
[JAVA] 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy) (0) | 2023.03.15 |