Stream 은 왜 만들어졌나?
Java , Kotlin 은 List, Set, Array 등 다양한 Collection 을 제공한다.
Collection API 와 Iterate 방식은 표준화 되었지만
각 클래스는 같은 기능을 하는 메소드가 중복 정의되어 있다.
List 를 정렬하는 방법만 해도 쉽게 2가지를 떠올릴 수 있다.
- List.sort
- Collection.sort
리스트 정렬
public class JavaStreamTest {
@DisplayName("Sort list with two way ")
@Test
void forLoop() {
List<Integer> nums = Arrays.asList(5, 4, 3, 2, 1);
nums.sort(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1.compareTo(o2);
}
});
for (Integer num : nums) {
System.out.print(num);
}
System.out.println();
Collections.sort(nums, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});
for (Integer num : nums) {
System.out.print(num);
}
}
}
배열은 또 Arrays.sort() 를 사용한다.
배열 정렬
public class JavaStreamTest {
@DisplayName("Sort array using Arrays.sort")
@Test
void arraySort() {
int[] arr = new int[]{1,2,3,4,5};
Arrays.sort(arr);
}
}
정렬 API가 중복 정의되어 있다.
Arrays.sort(), Collections.sort(), List.sort()
스트림이 필요한 이유
List.sort, Collection.sort, Arrays.sort() 모두 같은 기능을 하는 메소드가 중복 정의되어있다.
여러 메소드를 산발적으로 사용하면, 코드를 읽는 개발자 입장에서 의미를 파악하는데 인지 부하가 증가한다.
스트림은 이 문제를 해결하기 위해 등장했다.
스트림은 데이터 소스와 관계 없이 같은 방식으로 API 를 표준화했다.
이로써 코드 가독성 및 재사용성을 높인다.
public class JavaStreamTest {
@DisplayName("Sort list using stream")
@Test
void streamSort() {
int[] arr = new int[]{5,4,3,2,1};
List<Integer> numList = Arrays.asList(5, 4, 3, 2, 1);
Arrays.stream(arr).sorted(); // array , list 모두 stream화 후 같은 API 사용
numList.stream().sorted();
}
}
Stream 핵심 특징
- 최종 연산 전까지 중간 연산이 실행되지 않는다. - Lazy Evaluation
- 데이터 소스 (원본)를 수정하지 않는다. - Immutable
- 일회용이다.
- 병렬 처리를 지원한다. - parallel()
Lazy Evaluation vs Eager Evaluation
Lazy Evaluation
지연 평가(Lazy Evaluation) 은 실제 계산이 필요한 시점까지 계산을 미루는 것을 의미한다.
값을 계산하는 시점을 늦추어 불필요한 계산을 방지하여 성능 향상을 도모한다.
class EvaluationTest {
@DisplayName("Java Stream evaluate lazy")
@Test
fun lazyEval() {
// Java Stream doesn't return stream result until final operation
// given
val intStream = IntStream.rangeClosed(1, 9) // [1,2,3,4,5,6,7,8,9]
// when
val doubledStream = intStream
.filter { // 중간 연산
println("filter: $it")
it <= 3
}
.map { // 중간 연산
println("map: $it")
it * 2
}
// 최종 연산 실행 전에 메시지가 출력된다.
println("Not yet execute final operation")
// anyMatch meets '4' not filter and map operations execute '3 ~ 9'
val result = doubledStream.anyMatch { it >= 4 } // 최종 연산
// then
assertThat(result).isTrue()
}
}
anyMatch 조건에 해당하는 숫자 2가 나오자 이후 숫자 연산은 생략했다.
filter, map 연산이 '2'까지만 실행된다.
3~9 등 불필요한 연산을 생략하는 이 전략을 바로 Lazy Evaluation 이라한다.
Eager Evaluation
조급한 평가 (Eager Evaluation)은 모든 계산을 바로 실행하겨 결과 값을 도출하고 다음 계산으로 넘아가는 방식을 의미한다.
즉, 각 중간 연산을 실행하고 다음 중간 연산으로 넘어간다.
Kotlin Collection 은 기본적으로 eager evaluation 방식으로 동작한다.
class EvaluationTest {
@DisplayName("Kotlin Collections evaluate eager")
@Test
fun eagerEval() {
// Kotlin Collections return collection each step (eager evaluation)
// given
val intArray = IntArray(9){it + 1}
// when
val doubledCollection = intArray
.filter { // 중간 연산
println("filter: $it")
it <= 3
}
.map { // 중간 연산
println("map: $it")
it * 2
}
println("Not yet execute final operation")
val result = doubledCollection.any { it >= 4 } // 최종 연산
// then
assertThat(result).isTrue()
}
}
최종 연산 도달 전에 모든 중간 연산을 바로 실행한다.
모든 원소에 대해 연산이 실행되기 때문에 데이터 양이 많아지면 Lazy Evaluation 에 비해 성능 저하가 있을 수 있다.
중간 연산 vs 최종 연산
중간 연산
연산 결과가 스트림인 연산.
체이닝 기법으로 메소드 연쇄 호출로 자주 사용된다.
- map
- flatMap
- distinct
- skip
- peek
- sorted
최종 연산
연산 결과가 스트림이 아닌 연산.
스트림 요소를 소모하여 단 한번만 가능하다.
- forEach
- count
- findAny, findFirst
- allMatch, anyMatch, noneMatch
- toArray, toList
- reduce
- collect
class StreamIntermediateOperation {
@DisplayName("Streams can extract or transform using map and filter")
@Test
fun mapFilter() {
// given
val fileStream = Stream.of(
File("Ex1.jpg"),
File("Ex2.jpg"),
File("Ex3.png"),
File("Ex4")
)
// when
val fileNameList = fileStream
.map { it.name } // 중간 연산
.filter { it.indexOf(".") != -1 } // 중간 연산
.map { it.uppercase(Locale.getDefault()) } // 중간 연산
.toList() // 최종 연산
// then
assertThat(fileNameList).isEqualTo(listOf(
"EX1.JPG",
"EX2.JPG",
"EX3.PNG")
)
}
}
스트림은 일회용이다.
스트림은 한번 소모(Consume) 되면 재사용이 불가능하다.
class StreamConstruction {
@DisplayName("Stream is allowed to use once")
@Test
fun streamOnlyOnce() {
val list = listOf(1,2,3,4,5)
val intStream: Stream<Int> = list.stream()
// when then
intStream.forEach(System.out::print)
// then
val errMessage = assertThrows<IllegalStateException> {
intStream.forEach(System.out::print)
}.message
assertThat(errMessage).isEqualTo("stream has already been operated upon or closed")
}
}
forEach() 는 최종 연산으로써 스트림을 소모한다.
이미 소모된 스트림을 재사용하려하자 'IllegalStateException' 이 발생한다.
'JVM > Java' 카테고리의 다른 글
[객체지향] 잘못된 DRY 원칙 적용 (0) | 2023.12.16 |
---|---|
[Java] Enum 에는 equals 대신 == 을 써라 (0) | 2023.11.22 |
[Java] SpringBoot 없이 Yaml config 로드하기 (feat.SnakeYaml) (0) | 2023.09.15 |
Presentation - Business DTO를 분리시켜라 (0) | 2023.08.06 |
[Java] Data Transfer Object (DTO) (0) | 2023.01.27 |