본문 바로가기
Programming/Java

[자바/Java] 스트림 Stream - 심화

by 코딩하는 랄로 2023. 10. 15.
728x90

스트림 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)

 

이러한 간략하게 표기하는 방법은 상황에 따라 다르게 동작할 수 있으니 주의하여야 한다.

728x90