[자바/Java] 스트림 Stream - 심화
스트림 Stream 심화
이전까지는 스트림의 생성 - 중간 연산 -최종 연산으로 이어지는 기본 개념 및 사용방법에 대해서 다루었다. 이번 글에서는 더 나아가 스트림의 동작 순서, 성능 향상 등 더 깊은 내용에 대해 다루겠다.
- 동작 순서
- 성능 향상
- 스트림 재사용
- 지연 처리
- Null-safe 스트림 생성
- 줄여쓰기
동작순서
스트림은 데이터의 흐름이다. 이때에 데이터의 흐름이 생성과 중간 연산, 최종 연산을 거쳐 흐르게 되는데, 구체적으로 어떻게 흐르는지에 대한 관찰이 필요하다.
다음 예제에서, 출력 결과는 어떻게 될까?
List<String> list = Arrays.asList("Eric", "Elena", "Era");
list.stream()
.filter(el -> {
System.out.println("filter() was called");
return el.contains("a");
})
.map(el -> {
System.out.println("map() was called.");
return el.toUpperCase();
})
.findFirst(); //strea에서 제일 첫번째 요소 반환
대부분의 사람이 filter가 3번 동작 -> map이 2번 동작 -> findFirst 의 순서로 동작한다고 생각할 것이다. 하지만 stream에서 연산은 모든 요소가 하나의 중간 연산을 수행하고 다음 연산으로 넘어가는 것이 아닌 하나의 요소가 모든 파이프라인을 통과해서 결과를 만들어 낸 뒤, 다음 요소로 넘어간다.
위의 예시를 단계별로 나타내면 아래와 같다.
- Eric -> filter 중간 연산 -> filter() was called 출력 -> "a"를 포함하지 않으므로 다음 연산 수행X
- Elena -> filter 중간 연산 -> filter() was called 출력 -> "a"를 포함하여 다음 연산 수행O
- Elena -> map 중간 연산 -> map() was called 출력 -> ELENA 를 다음 연산으로 넘김
- 최종 연산 stream에 ELENA가 들어감 -> findFirst -> 첫번째 요소인 ELENA반환 -> 종료
- Era에 대해서는 연산을 진행하지 않음
즉, filter -> filter -> map -> findFirst 순의 동작 순서를 가지는 것이다. 마지막 "Era"에 대한 연산은 이미 Elena에 대한 연산의 결과가 최종 연산의 결과이므로 더 이상 진행되지 않는 것이다.
성능 향상
위에서 살펴봤듯이 스트림은 한 요소씩 수직적으로 실행되는데, 여기에 스트림의 성능을 개선할 수 있는 힌트를 발견할 수 있다.
아래의 예제를 살펴보면,
List<String> list = Arrays.asList("Eric", "Elena", "Stream");
list.stream()
.map(el -> el.substring(0, 3)
.skip(2) //3번째 요소부터 끝까지
.collect(Collectors.toList());
동작 순서가 아래와 같다.
- Eric -> map -> skip
- Elena -> map -> skip
- Stream -> map -> skip -> collect
첫번째 요소인 Eric, Elena는 skip으로 인해서 어차피 최종 연산까지 도달하지 못한다. 사실상 의미없는 데이터인 것이다. 하지만 위의 예제에서는 의미없는 데이터에 map을 통해 substring 작업을 진행하는 것을 볼 수 있다.
이러한 불필요한 작업은 중간 연산의 순서를 변경함으로써 해결할 수 있다.
List<String> list = Arrays.asList("Eric", "Elena", "Stream");
list.stream()
.skip(2) //3번째 요소부터 끝까지
.map(el -> el.substring(0, 3)
.collect(Collectors.toList());
이렇게 변경할 경우, 다음과 같은 동작 순서를 가진다.
- Eric -> skip
- Elena -> skip
- Stream -> skip -> map -> collect
훨씬 적은 연산으로 위의 예제와 같은 결과가 나오는 것이다. 이렇듯, 스트림에서 성능을 향상시키기 위해서는 불필요한 연산을 줄여야 하고 이러한 방법 중 하나가 요소의 범위를 줄이는 작업을 먼저 진행한 것이다.
이러한 작업에는 skip, filter, distinct 등이 있다.
스트림 재사용
스트림은 종료 작업을 하지 않는 한 하나의 객체로서 계속해서 사용을 할 수 있다. 하지만 종료 연산을 하는 순간 스트림이 닫히기 때문에 재사용은 불가능하다.(스트림은 데이터를 저장하려는 용도X)
아래의 예제는 스트림이 닫혔는데, 스트림을 사용하는 경우이다.(에러 발생!!)
Stream<String> stream =
Stream.of("Eric", "Elena", "Java")
.filter(name -> name.contains("a"));
Optional<String> firstElement = stream.findFirst();
Optional<String> anyElement = stream.findAny(); // IllegalStateException: stream has already been operated upon or closed
이러한 상황이 발생하지 않게 하기 위해서는 데이터를 List에 저장하고 필요할 때마다 스트림을 생성하여 사용하면 된다.
List<String> names =
Stream.of("Eric", "Elena", "Java")
.filter(name -> name.contains("a"))
.collect(Collectors.toList());
Optional<String> firstElement = names.stream().findFirst();
Optional<String> anyElement = names.stream().findAny();
지연 처리 (Lazy Invocation)
스트림에서 최종 결과는 최종 연산이 이루어질 때 계산된다. 다음은 호출 횟수를 카운트 하는 예제이다.
private long counter;
private void wasCalled() { counter++; }
List<String> list = Arrays.asList("Eric", "Elena", "Java");
counter = 0;
Stream<String> stream = list.stream()
.filter(el -> {
wasCalled();
return el.contains("a");
});
System.out.println(counter); // 0 ??
위의 예제의 결과는 3이 나올거라는 예상과는 다르게 0이 나온다. 그 이유로는, 스트림의 최종 연산이 실행되지 않으면 스트림의 연산 또한 실행되지 않기 때문이다.
이러한 경우, 최종 연산을 수행을 해 주면 제대로 동작하는 것을 알 수 있다.
list.stream().filter(el -> {
wasCalled();
return el.contains("a");
}).collect(Collectors.toList());
System.out.println(counter); // 3
Null-sate 스트림 생성하기
NPE(NullPointerException)은 개발 시 흔히 발생하는 예외이다. Optional을 이용해서 null에 안전한(null-safe) 스트림을 생성할 수 있다.
public <T> Stream<T> collectionToStream(Collection<T> collection) {
return Optional
.ofNullable(collection)
.map(Collection::stream)
.orElseGet(Stream::empty);
}
위의 코드는 인자로 받은 컬렉션 객체를 이용해 옵셔널 객체를 생성하고 스트림을 생성한 후 리턴하는 메소드이다. 만약 컬렉션이 비어있는 경우 빈 스트림을 리턴한다.
List<Integer> intList = Arrays.asList(1, 2, 3);
List<String> strList = Arrays.asList("a", "b", "c");
//null이 아닐 때
Stream<Integer> intStream =
collectionToStream(intList); // [1, 2, 3]
Stream<String> strStream =
collectionToStream(strList); // [a, b, c]
//null인 경우, 위에서 만든 메소드 사용
List<String> nullList = null;
collectionToStream(nullList)
.filter(str -> str.contains("a"))
.map(String::length)
.forEach(System.out::println); // []
줄여 쓰기 (Simplified)
스트림 사용 시 다음과 같은 경우에는 내용을 좀 더 간결하게 줄여 쓸 수 있다. IntelliJ를 사용하면 줄여쓸 것을 제안해준다.
collection.stream().forEach()
→ collection.forEach()
collection.stream().toArray()
→ collection.toArray()
Arrays.asList().stream()
→ Arrays.stream() or Stream.of()
Collections.emptyList().stream()
→ Stream.empty()
stream.filter().findFirst().isPresent()
→ stream.anyMatch()
stream.collect(counting())
→ stream.count()
stream.collect(maxBy())
→ stream.max()
stream.collect(mapping())
→ stream.map().collect()
stream.collect(reducing())
→ stream.reduce()
stream.collect(summingInt())
→ stream.mapToInt().sum()
stream.map(x -> {...; return x;})
→ stream.peek(x -> ...)
!stream.anyMatch()
→ stream.noneMatch()
!stream.anyMatch(x -> !(...))
→ stream.allMatch()
stream.map().anyMatch(Boolean::booleanValue)
→ stream.anyMatch()
IntStream.range(expr1, expr2).mapToObj(x -> array[x])
→ Arrays.stream(array, expr1, expr2)
Collection.nCopies(count, ...)
→ Stream.generate().limit(count)
stream.sorted(comparator).findFirst()
→ Stream.min(comparator)
이러한 간략하게 표기하는 방법은 상황에 따라 다르게 동작할 수 있으니 주의하여야 한다.