Steam API에 대한 개념 잡기
Java를 통해 개발을 하다 보면 Stream API를 자주 사용하게 될 것입니다. Stream은 기본적으로 많은 기능을 제공해 주기 때문에, 적재적소에 사용하여 큰 도움을 얻을 수 있습니다. 최근 Java에 대한 강의를 보는 과정에서 제가 모르는 Stream 메서드들이 많이 등장해서, 이 기회에 이에 대해서 정리해보려고 합니다. 이 글을 읽는 분들이 이번 기회에 Stream API에 대한 이해 및 활용법에 대해 익혀 편하게 사용할 수 있기를 바랍니다.
Stream API란?
SteamAPI는 Java8부터 생긴 기능으로 `java.util.stream`에서 제공하는 다양한 기능들을 말합니다. Stream이라는 인터페이스에서 제공하는 기능들과 개발자가 작성한 람다 or 함수형 인터페이스 구현체를 이용하여 동작합니다. 공식 Docs에서는 다음과 같이 정의하고 있습니다. `A sequence of elements supporting sequential and parallel aggregate operations.` 순차적, 병렬적 집합 연산을 지원하는 요소들의 집합이라고 합니다. 이 말만 들으면 조금 이해가 쉽지 않네요. Docs를 조금 더 읽어보면 다음과 같이 말합니다.
Collections and streams, while bearing some superficial similarities, have different goals. Collections are primarily concerned with the efficient management of, and access to, their elements. By contrast, streams do not provide a means to directly access or manipulate their elements, and are instead concerned with declaratively describing their source and the computational operations which will be performed in aggregate on that source. However, if the provided stream operations do not offer the desired functionality, the iterator() and spliterator() operations can be used to perform a controlled traversal.
By contrast, streams do not provide a means to directly access or manipulate their elements, and are instead concerned with declaratively describing their source and the computational operations which will be performed in aggregate on that source. 이 부분을 해석해 보면 ‘반면, 스트림은 요소에 직접 접근하거나 조작할 수 있는 방법을 제공하지 않으며, 대신 스트림의 소스와 그 소스에 대해 집합적으로 수행될 계산 작업을 선언적으로 기술하는 데 중점을 둡니다.’ 이렇게 말합니다. 두 문장에서 중복적으로 나타나는 ‘집합적으로 수행될 작업’이라는 말이 여전히 어렵긴 하지만, 아무래도 Stream API를 사용해 보신 분들이라면 어렴풋이 감이 올 것이라고 생각합니다.
filter나 map을 떠올려보시죠. Stream에 저장된 요소들에게 조건에 맞는지를 체크하고, 다른 객체로 변환을 하는 등의 작업들입니다. Stream에 저장된 요소들에게가 ‘집합적’ 이라는 말로 생각하면, 이해가 되시겠죠?
장점과 단점
뭐.. 그래 StreamAPI가 뭔지는 알겠어. 그런데 왜 쓰는데? 그거 그냥 반복문으로도 다 가능한거 아니야?라는 의문이 드실 수 있습니다. 실제로 저도 Steam과 람다식을 짜는 방법을 제대로 모를 때엔 이런 생각이 들었고, 반복문을 짜는 게 더 범용적으로 이해가 쉽다고 생각했습니다. 하지만, 최근에는 많은 Java진영 개발자들이 Steam을 사용하는 이유가 있을 겁니다. Stream에 대한 어떤 장점이 있길래 이렇게 많이 사용할까.. 하는 이유와, Stream을 사용할 때의 주의점 및 단점도 같이 알아보도록 합시다.
장점
첫 번째로 생각하는 장점은 가독성입니다. 같은 작업을 반복문으로 하는 것 보다 Stream API를 이용하게 되면, 각 구분에 대한 의미를 이해하기 쉽습니다. 그리고 Stream API는 대다수의 Java 개발자가 그 내용을 얼핏 알기 때문에, 새로운 메서드를 만들어서 의미를 전달해 줄 필요가 적다는 장점이 있습니다.
아래 코드는 1부터 10까지의 수들 중 짝수들의 제곱의 합을 구하는 코드입니다.
크게 어렵지 않는 코드라서 사실 이해가 어렵지는 않겠지만, 무수히 많은 코드를 보고 이 코드를 보게 되어 정신이 피폐해진 상태라면, 반복문… 조건문.. 더하기.. 를 읽는 것보다 filter, map.. sum.. 을 읽는 게 조금이라도 더 눈에 들어오고 이해가 쉽지 않을까요??!!
public static void main(String[] args) {
int sum = 0;
List<Integer> numbers = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
for (int number : numbers) {
if (number % 2 == 0) {
sum += number * number;
}
}
System.out.println(sum);
}
public static void main(String[] args) {
int sum = 0;
List<Integer> numbers = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
sum = numbers.stream()
.filter(number -> number % 2 == 0)
.mapToInt(number -> number * number)
.sum();
System.out.println(sum);
}
두 번째 장점은 성능 최적화입니다. 이 부분은 Docs에도 살펴볼 수 있는데, 결과에 영향을 주지 않는 연산일 경우, 연산을 행하지 않습니다. 말로는 이해가 어려우니 바로 예제로 들어가겠습니다.
아래 코드는 1부터 10까지 가지고 있는 리스트를 원소의 개수만큼 1로 바꾸고 그 개수를 세는 코드입니다. 하지만 개수를 세는 연산을 할 때엔 1~10까지의 원소를 1로 바꾸는 행위는 그 결과에 영향을 주지 않습니다. 그렇기 때문에 Stream은 `mapToInt`를 실행시키기 않습니다. 그렇기 때문에 콘솔에는 `System.out.println("e : " + e);` 이 코드의 결과가 나타나지 않습니다.
List<Integer> numbers = List.of(1,2,3,4,5,6,7,8,9,10);
long count = numbers.stream()
.mapToInt(e -> { // 동작하지 않는다. mapToInt()연산을 하지 않아도 개수는 변하지 않기 때문
System.out.println("e : " + e);
return 1;
})
.count();
System.out.println(count); // 10
이는 장점이지만 반대로 단점이 될 수 있습니다. 그것에 대해서는 아래 단점 파트에서 다루도록 하겠습니다.
세 번째 장점은 손쉽게 병렬 처리가 가능하다는 점입니다.
Stream은 `parallel()` 혹은 `parallelStream()`을 이용해서 병렬 연산을 처리해줄 수 있습니다. 이때 내부에서 `ForkJoinPool.commonPool-worker`라는 스레드들이 병렬 계산을 도와줍니다.
예시를 보면 1부터 10억까지의 수를 합치는 코드가 있습니다. 이를 for-each 반복문을 사용하는 것과 병렬 스트림을 사용해서 계산을 하면 병렬 스트림이 더욱 빠른것을 확인할 수 있습니다. 아래 예제 코드는 연산 자체가 크게 시간이 걸리지 않아 단일 스레드에서도 비슷한 성능을 보이지만, 연산이 복잡하거나 IO로 인한 대기가 발생할 경우 그 차이는 더욱 쉬울 것입니다.
public static void main(String[] args) {
int[] numbers = new int[10_0000_0000];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = i;
}
long start = System.currentTimeMillis();
long sum = 0;
for (int number : numbers) {
sum += number;
}
System.out.println(sum);
long end = System.currentTimeMillis();
System.out.println(end-start); // 255
}
public static void main(String[] args) {
int[] numbers = new int[10_0000_0000];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = i;
}
long start = System.currentTimeMillis();
long sum = Arrays.stream(numbers).parallel()
.mapToLong(i -> i)
.sum();
System.out.println(sum);
long end = System.currentTimeMillis();
System.out.println(end-start); // 225
}
또한 제가 참고한 책인 Practical 모던자바에 의하면 저런 반복문의 경우 외부에서 발생할 수 있는 동시성 문제를 개발자가 고려해줘야 하지만, stream은 불변성을 띄고 있기 때문에, 안정적이라는 특징도 있습니다.
단점
첫 번째 단점은 Stream 객체를 생성하는데 드는 비용일 것입니다. 컬렉션을 스트림으로 변환하는 과정에서 사용되는 메서드를 보면 `ReferencePipeline` 혹은 `기본형 파이프라인(ex. IntPipeline)`등을 생성하는데, 스트림 API를 위해 사용하는 변수들의 개수가 굉장히 많고, 이는 곧 많은 자원이 사용된다는 것을 알 수 있습니다.
두 번째 단점은 앞선 장점에서 언급했던, 최적화의 신뢰성입니다. Stream의 최종 연산에 영향을 주지 않는다는 이유로 중간 연산을 동작하지 않게 하지만, 중간 연산 과정에서 유의미한 행위가 있다면, 이 마저도 동작하지 않는 문제가 있습니다. 아래 예시를 보시면, counter의 값을 증가시키는 `incrementAndGet()`가 동작하지 않아 counter의 값이 10이 아닌 0이 출력되는 것을 볼 수 있습니다. (물론 공식문서에서는 람다식에는 상태를 저장하지 말라는 이야기도 있습니다.)
public static void main(String[] args) {
List<Integer> numbers = List.of(1,2,3,4,5,6,7,8,9,10);
AtomicInteger counter = new AtomicInteger(0);
long count = numbers.stream()
.mapToInt(e -> { // 동작하지 않는다. mapToInt()연산을 하지 않아도 개수는 변하지 않기 때문
counter.incrementAndGet(); // 하지만 side-effect도 동작하지 않기 때문에, stream연산을 완전히 신뢰해서는 안된다.
System.out.println("e : " + e);
return 1;
})
.count();
System.out.println(count); // 10
System.out.println(counter.get()); // 0
}
마지막 단점은 chaining메서드를 지나치게 길게 사용한다거나, 람다식을 지나치게 복잡하게 작성하여 오히려 가독성이 떨어질 수 있다는 점입니다.이는 JS의 콜백지옥과 같은 느낌으로 이해하시면 될 것 같습니다.
특징
Lazy하다
Stream의 중간 연산과 최종 연산으로 구분되며 최종 연산이 실행되어야 비로소 중간 연산이 동작하게 됩니다. 여기서 중간 연산은 `filter`나 `map`과 같이 요소를 변환하는 연산이고 최종 연산은 `forEach`나 `collect`와 같은 연산을 말합니다.
public static void main(String[] args) {
List<Integer> numbers = List.of(1,2,3,4,5,6,7,8,9,10);
IntStream intStream = numbers.stream()
.mapToInt(e -> { // 동작하지 않는다. mapToInt()연산을 하지 않아도 개수는 변하지 않기 때문
System.out.println("e : " + e);
return 1;
});
// 아무 내용도 출력되지 않음.
/*
오후 12:50:38: Executing ':org.example.lazy.StreamIsLazyExample.main()'...
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :org.example.lazy.StreamIsLazyExample.main()
BUILD SUCCESSFUL in 532ms
2 actionable tasks: 2 executed
오후 12:50:39: Execution finished ':org.example.lazy.StreamIsLazyExample.main()'.
*/
}
한번 최종연산을 진행하면 다시 사용할 수 없다.
Stream에서는 앞서 언급한 최종연산을 한번 사용하면, 다시 사용할 수 없습니다. 이는 Stream의 최종 연산을 하는 메서드인 `evaluate`메서드의 구조를 보면 이해가 쉽습니다. 아래 코드를 보면 처음 호출이 될 때, `linkedOrConsumed`가 `true`가 되어, 이후에는 위의 조건문에 의해서 걸려 예외가 발생되는 것을 볼 수 있습니다.
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true; // <- 한번 사용하면 true가 되어서 다시 사용하게 되면 위의 조건문에 걸린다.
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
Stream 하다
이름에서 알 수 있듯이 모든 연산이 순차적으로 동작합니다. 저 개인적으로는 놀라웠던 기능입니다. 아래와 같은 코드가 있다면 모든 요소들에 대해 `filter`를 동작한 후, 그 후에 `map`, `forEach`를 동작하는 줄 알았습니다. 하지만 요소 1개마다 `filter`, `map`, `forEach`를 동작하고 그다음 요소를 동작합니다. 이 방법을 통해 중간 과정을 저장하지 않아도 되어서 메모리 낭비를 방지할 수 있다고 합니다.
List<Integer> numbers = List.of(1,2,3,4,5,6,7,8,9,10);
numbers.stream()
.filter(number -> {
if (number % 2 == 0) {
System.out.println(number + "는 짝수입니다.");
}else {
System.out.println(number + "는 홀수에요 ㅠ");
}
return number % 2 == 0;
})
.mapToInt(e -> { // 동작하지 않는다. mapToInt()연산을 하지 않아도 개수는 변하지 않기 때문
System.out.println("e : " + e);
return e;
}).forEach(System.out::println);
알고 보면 쓸모 있는 Stream API 모음
지금까지 Java의 Stream에 대해서 알아보았습니다. 이제는 어떻게 사용하는지, 그리고 어떤 유용한 기능들이 있을지에 대해서 알아봅시다.
중간 연산
대표적인 중간 연산들에 대해서 표를 통해서 대략적으로 알아보고, 특이한 메서드에 대해서는 추가적으로 설명해보겠습니다.
이름 | 설명 | 예시 |
`filter` | 주어진 조건에 해당하는 요소를 남깁니다. | `numbers.stream().filter(num -> num%2==0)` |
`map` | 요소를 다른 형태로 변환합니다. 기본형 전용 변환인 `mapToInt()`, `mapToLong()`, `mapToDouble()` 을 사용하면 각각 `IntStream`, `LongStream`, `DoubleStream`을 리턴합니다. | `students.stream().map(student -> student.getName()` // 이름만 가지고 있는 스트림으로 변환 |
`flatMap` | 각 요소에게 제공된 매핑 함수를 이용해 매핑 스트림을 만든 후, 현재 스트림의 요소를 대체합니다. | 하단 상세 설명 |
`distinct` | 중복된 요소를 삭제합니다. | 하단 상세 설명 |
flatMap
`flatMap()`은 위 설명처럼 새로운 매핑 스트림을 만든 후, 그 매핑 스트림의 요소들을 모아서 리턴합니다. `flatMap()`은 아래와 같은 리스트를 1~9까지 요소를 가지고 있는 스트림으로 변경해 줍니다.
List<int[]> deltas = Arrays.asList(
new int[]{1,2},
new int[]{3,4},
new int[]{5,6},
new int[]{7,8,9}
);
먼저 `flatMap()`을 사용하지 않은 경우부터 보겠습니다. 단순히 map을 이용하는 경우 `IntStream`들을 가지고 있는 Stream이 생성됩니다. 본래 의도는 배열 내의 원소들을 조작하는 스트림을 만들고 싶었는데 말이죠. 심지어 모든 내용을 출력하기 위해선 주석된 `forEach` 문처럼 내부에서 다시 한번 `forEach`를 호출하는 이상한 형태가 됩니다.
Stream<int[]> noFlatMapStream = deltas.stream();
Stream<IntStream> intStreams = noFlatMapStream.map(Arrays::stream);
intStreams.forEach(System.out::println);
// intStreams.forEach(ins ->ins.forEach(System.out::println));
/*
java.util.stream.IntPipeline$Head@7e9e5f8a
java.util.stream.IntPipeline$Head@8bcc55f
java.util.stream.IntPipeline$Head@58644d46
java.util.stream.IntPipeline$Head@14dad5dc
*/
하지만 `flatMap()`을 사용하면 다릅니다.
아래처럼 하나의 스트림에 배열의 모든 값이 들어간 것을 확인할 수 있고, 원하는 의도대로 동작하는 것을 확인할 수 있습니다. 이런 동작을 두고, 제가 참고한 서적에서는 평탄화 작업이라고도 부릅니다.
Stream<int[]> stream = deltas.stream();
IntStream intStream =
stream.flatMapToInt(Arrays::stream);
intStream.forEach(System.out::println);
출력 결과
1
2
3
4
5
6
7
8
9
distinct
dintinct는 중복된 요소를 제거하는 중간 연산입니다. 이를 사용하기 위해선 `equals()`와 `hashcode()`의 재정의가 필요합니다. 그리고 병렬 처리를 하는 과정에서는 의도한 대로 동작하지 않을 수 있기 때문에, 주의해서 사용해줘야 합니다.
사용 방법은 간단합니다. 아래처럼 이미 `equals()`, `hashcode()`가 재정의된 Integer의 경우 정상적으로 중복 제거가 완료된 것을 확인할 수 있습니다.
public static void main(String[] args) {
List<Integer> numbers = List.of(1,1,2,2,3,3,4,4,5,5);
numbers.stream().distinct()
.forEach(System.out::println); // 1 2 3 4 5
}
하지만 다음과 같은 경우는 어떨까요? 저는 학생이라는 클래스를 만들고 이름과 나이가 같으면 같은 객체로 판단하고 싶습니다. 하지만 `equals()`와 `hashcode()`를 재정의하지 않았어요. 그러니 실제로 주소값이 같은 맹구만 사라지고, 나이와 이름이 같은 맹구들은 그대로 유지가 되는 것을 확인할 수 있었습니다.
public static void main(String[] args{
Student mg = new Student("맹구", 5);
List<Student> students = List.of(
new Student("김철수", 5),
new Student("신짱구", 5),
new Student("맹구", 5),
new Student("맹구", 5),
mg, mg
);
students.stream().distinct()
.forEach(System.out::println);
/*
Student{name='김철수', age=5}
Student{name='신짱구', age=5}
Student{name='맹구', age=5}
Student{name='맹구', age=5}
Student{name='맹구', age=5}
*/
}
static class Student{
String name;
int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
최종 연산
최종 연산에 대해서는 크게 2가지로 나누어서 설명하겠습니다. 바로 검색과 변환입니다. 그 외에 해당하는 연산으로는 앞서서 자주 볼 수 있었던 `forEach()`가 있습니다. `forEach()`에 대한 설명은 추가로 진행하지 않겠습니다.
검색
최종 연산 중 검색을 진행하는 연산들은 조건에 해당하는 요소들이 있는지를 검색하여 `boolean`을 리턴하는 메서드들과 현재 있는 요소 중에서 데이터를 찾아 `Optional`로 감싸서 리턴하는 메서드들이 있습니다.
먼저 `boolean`을 리턴하는 메서드는 아래 3개입니다.
이름 | 설명 |
`anyMatch` | 조건을 만족하는 요소가 1개라도 있는 경우 `true`를 리턴한다 |
`allMatch` | 모든 요소들이 조건을 만족하는 경우 `true`를 리턴한다. |
`noneMatch` | 조건을 만족하는 요소가 1개도 없는 경우 `true`를 리턴한다. |
각 내용에 따라서 맞춰서 사용하면 되고, 사용 방법은 앞서 `filter`를 사용한 것처럼 사용하면 큰 문제없이 사용할 수 있습니다.
그다음으로는 `Optional <T>`를 리턴하는 메서드 2개입니다.
이름 | 설명 |
`findAny` | 요소 중에서 아무 요소 하나를 `Optional`로 감싸서 리턴합니다. |
`findFirst` | 요소 중 가장 첫번째 요소를 `Optional`로 감싸서 리턴합니다. |
위의 메서드를 이용하기 이전에 `filter`를 통해서 1개만 남겼다면, 별 차이는 없지만 요소가 여러 개일 때엔 차이가 발생한다. 아래 예제를 보면 `findAny()`의 결과와 `findFirst()`의 결과가 다른 것을 알 수 있다. 아쉽게도 병렬 스트림이 아닌 경우엔 0번 인덱스부터 순차적으로 동작해서 그런지 `findAny()`와 `findFirst()`의 경우가 동일하게 나타났다.
String[] names = {"신장구", "김철수", "유리", "훈이", "맹구", "흰둥이"};
List<Student> students = new ArrayList<>();
for (int i = 0; i < 100; i++) {
int nameIndex = new Random().nextInt(names.length);
students.add(new Student(names[nameIndex], i, i));
}
for (int i = 0; i < 100; i++) {
Optional<Student> student = students.parallelStream().findAny();
Optional<Student> student2 = students.parallelStream().findFirst();
System.out.println("any : " + student.get() + " first : " + student2.get());
}
/*
any : Student{name='흰둥이', age=65, studentNo=65} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='유리', age=32, studentNo=32} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='유리', age=32, studentNo=32} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='흰둥이', age=65, studentNo=65} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='유리', age=32, studentNo=32} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='흰둥이', age=65, studentNo=65} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='유리', age=32, studentNo=32} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='유리', age=32, studentNo=32} first : Student{name='신장구', age=0, studentNo=0}
any : Student{name='흰둥이', age=65, studentNo=65} first : Student{name='신장구', age=0, studentNo=0}
*/
변환
변환과 관련된 연산은 마지막에 `collect()` 메서드에서 동작이 이루어지며, 파라미터로 들어가는 `Collectors`의 메서드에 따라서 동작이 달리합니다. 변환 연산은 크게 3가지로 구분할 수 있습니다. 컬렉션으로의 변환, 그룹으로 변환, 연산한 수로 변환입니다. 마지막이 조금 이상한데, 총 합, 평균, 최댓값, 최솟값 등을 구하는 기능입니다.
첫 번째로 컬렉션 변환에 대해서 알아보면, 아마 가장 많이 사용하는 기능이라고 생각합니다.
`Collectors.toList`, `Collectors.toSet`, `Collectors.toMap`과 같은 메서드를 사용해서 각각 `ArrayList`, `HashSet`, `HashMap`을 리턴할 수 있고, `Collectors.toCollection()`의 파라미터로 원하는 구현체를 넣어서 사용이 가능합니다.
String[] names = {"신장구", "김철수", "유리", "훈이", "맹구", "흰둥이"};
List<Student> students = new ArrayList<>();
for (int i = 0; i < 100; i++) {
int nameIndex = new Random().nextInt(names.length);
int age = new Random().nextInt(100);
students.add(new Student(names[nameIndex], age, i));
}
students.stream()
// .collect(Collectors.toList())// ArrayList로 변환
// .collect(Collectors.toSet())// HashSet으로 변환
// .collect(Collectors.toMap(student -> student.studentNo, student -> student.age)); // HashMap으로 변환
.collect(Collectors.toCollection(TreeSet::new)); // 원하는 구현체 적용
두 번째로 살펴볼 기능은 그룹화 기능입니다. 이는 `Collectors.groupingBy`라는 메서드와 `Collectors.partitioningBy`를 사용합니다. 먼저 groupingBy는 key를 입력하면, 그 key와 key에 해당하는 요소들의 컬렉션 형태로 Map을 만들어냅니다. 그리고 별도의 설정을 통해서 key에 해당하는 value를 수정해 줄 수 있습니다. 아래 코드는 위의 학생들을 나이를 기준으로 그룹화한 코드와 이름을 기준으로 나이의 평균을 구하는 코드입니다.
// Map<Integer, List<Student>> ageCollector =
Map<Integer, Set<Student>> ageCollector = students.stream()
.collect(Collectors.groupingBy(Student::getAge, Collectors.toSet()));
Map<String, Double> nameAgeAvgCollector = students.stream()
.collect(Collectors.groupingBy(Student::getName, Collectors.averagingInt(Student::getAge)));
`groupingBy`와 유사하게 `partitioningBy`도 동작합니다.
// Map<Boolean, List<Student>> agePartitionList =
// Map<Boolean, Set<Student>> agePartitionSet =
Map<Boolean, Double> averageAge = students.stream()
// .collect(Collectors.partitioningBy(student -> student.age > 50));
// .collect(Collectors.partitioningBy(student -> student.age > 50, Collectors.toSet()));
.collect(Collectors.partitioningBy(student -> student.age > 50, Collectors.averagingInt(Student::getAge)));
마지막으로 살펴볼 메서드는 숫자 계산을 위한 메서드입니다. 메서드의 이름을 보면 쉽게 이해하실 수 있습니다.
이름 | 설명 |
`summingInt` | 주어진 요소들에 대하여 총 합을 구합니다. |
`averagingint` | 주어진 요소들에 대한 평균을 구합니다. |
`maxBy` | 주어진 요소들 중 최대값을 구합니다. |
`minBy` | 주어진 요소들 중 최소값을 구합니다. |
`counting` | 주어진 요소들의 개수를 구합니다. |
`summarizingInt` | 주어진 요소들에 대한 요약 정보를 제공합니다. 위에 있는 모든 값들을 지닌 객체 `IntSummaryStatistics`를 리턴합니다. |
`IntSummaryStatistics`에 대해서는 조금 생소하지만, 위의 설명처럼 모든 정보들을 가지고 있습니다. 아래 메서드를 통해서 수와 관련된 모든 정보를 획득할 수 있습니다.
아래 예제처럼 1부터 9까지의 스트림을 요약했을 때, 얻을 수 있는 모든 정보를 확인할 수 있습니다.
List<Integer> numbers = Arrays.asList(1,2,3,4,5,6,7,8,9);
IntSummaryStatistics intSummaryStatistics = numbers.stream().mapToInt(number -> number).summaryStatistics();
System.out.println(intSummaryStatistics); // IntSummaryStatistics{count=9, sum=45, min=1, average=5.000000, max=9}
마무리
지금까지 Java의 Stream API에 대해서 알아보았습니다. Stream API는 많은 장점을 제공하고 있기 때문에, 사용 방법에 대해서 익힌다면, 충분히 유용하게 사용할 수 있을 겁니다. 하지만 이 기능에서 제일 중요한 점은, 개발자가 작성하는 람다식입니다. Stream이 내부적으로 최적화를 해주고, 가독성을 올려준다고 하더라도 사용하는 사람이 이를 제대로 활용하지 못한다면 무의미해지니까, 이 점을 주의하면서 사용하면 충분히 생산성을 높이는데 도움이 될 것입니다.
이 글에서 사용된 예제는 아래 링크에서 확인할 수 있습니다.
https://github.com/kgh2120/study-example-code/tree/main/1.java-stream
참고 자료
Practical 모던 자바 어려워진 자바, 실무에 자신 있게 적용하기, 프로그래밍 인사이트, 장윤기