티스토리 뷰

반응형

목차

    배경

    java 8부터는 반복문을 더 효율적으로 처리할 수 있는 Streams API를 제공한다. 하지만 프로젝트와 실무 등에 streams API를 적용하여 코드를 작성하다 보면 의도와 다르게 동작하거나 오히려 가독성이 떨어지고 사용하기 불편하다는 느낌을 받은 적이 있다. 이번에는 Streams API의 장점과 동작 원리 그리고 (오히려 가독성이 떨어지고 불편함을 초래하는 상황을 방지하기 위해) 사용 시 주의사항에 대한 내용을 찾아보고 내가 이해한 내용과 함께 이를 정리해 보있다.

    Streams API의 장점

    Streams API의 장점에 대해 소개하기 전에 어떤 경우에 Stream을 활용하는게 좋을지 그리고 그때의 장점은 무엇인지 알아보기 위해 기본적인 for loop부터 정리해 보았다.
    우선 다음과 같은 배열 array를 조회하는 가장 기본적인 for문은 다음과 같이 배열의 길이와 인덱스를 활용하는 것이다.

    int[] array = {1, 2, 3, ...};
    for(int i = 0; i < array.length; i++){
    	int element = array[i]; // 배열 array의 각 원소에 대해 순차적으로 인덱스로 접근
    }

     
    JDK 1.5부터는 배열의 길이와 인덱스를 직접 다루지 않는 향상된 for문을 제공한다.

    for(int element : array){
    	// 각 요소에 대한 처리
    }

     
     
    하지만 기본적인 for문과 향상된 for문 모두, 배열의 각 원소에 대해 접근하는 코드와 각 요소들을 처리하는 코드를 개발자가 직접 작성해주어야 한다.
    이처럼 매번 개발자가 배열의 각 요소에 접근하는 코드를 반복하여 작성하는 불편함을 해소하기 위해, java 8부터는 개발자가 배열(을 비롯한 컬렉션)의 각 요소에 대한 처리 방법에만 집중할 수 있도록 streams api를 제공한다.
     
    배열 array를 streams api를 활용하여 각 요소를 다루는 코드는 다음과 같다. 이 코드에서 array 배열의 각 요소에 접근하는 코드를 매번 직접 작성할 필요 없이 처리할 작업만을 명시하면 된다.

    Arrays.stream(array)
    	.map(element -> {
    	      // 각 요소에 대한 작업 수행
            System.out.println("Element: " + element);
            return String.valueOf(element);
        });

     
    이처럼 Streams API를 사용하면 배열의 각 요소를 개발자가 직접 컬렉션 외부로 꺼내올 필요 없이, 컬렉션 내부에서 어떻게 처리할지만 기술하면 된다. 이러한 특성으로 인해 Streams API는 내부 반복자라고 부른다.
     

    https://ddoongmause.blogspot.com/2021/01/chapter16.html

    컬렉션 내부에서 어떻게 처리할지 그 작업만 기술하면 되기 때문에 람다식으로 간결하게 작성할 수 있고 이를 통해 가독성을 향상할 수 있다. 또한 Streams API는 병렬 처리를 지원하기 때문에 병렬 작업이 필요한 상황이라면 멀티 스레드를 활용하여 성능을 향상할 수 있다. (물론 병렬 처리를 진행한다면 그렇지 않을 때보다 더 많은 부분을 신경 써야 할 것이다.)
     
    따라서 구성원들이 Stream API 문법에 익숙하다는 전제하에, Streams API은 간결한 코드 작성으로 가독성과 때때로 성능 향상이라는 이점을 얻을 수 있다. 만일 Stream API 문법이 어색하다면 의도치 않은 실수가 발생할 것이고 Streams API의 특성상 그 원인을 파악하고 해결하기에는 조금 더 많은 비용이 소요될 것이다.
     
    이처럼 간편하다는 장점으로 많이 사용되고 있으나, Streams API를 사용할 때 주의해야 할 점도 있다.
     

    Streams API 사용 시 주의사항

    1. 스트림을 소비(consume)하는 것을 깜빡함(스트림 파이프라인의 최종 연산 누락)

    스트림의 모든 요소에 최종 연산을 통해 필요한 모든 작업을 처리하고 나면 그 스트림을 소비(consume)했다고 한다.
    이때 스트림은 호출되는 즉시 모든 연산들을 수행하는 것이 아니라 최종 결과를 얻기 전까지 필요한 중간 연산만 실제로 수행하며 불필요한 계산이 발생하지 않도록, 효율적인 계산을 위해 지연 연산(lazy evaluation) 방식으로 연산을 수행한다. 이러한 연산 방식으로 여러 개의 메서드로 연결된 스트림 파이프라인을 구성하는 경우 첫 번째 연산(스트림 생성)만 즉시 동작하고 최종 연산이 수행되기 전까지 두 번째 이후로 제공된 중간 연산들은 바로 동작하지 않는다. 따라서 만일 최종연산을 누락하는 경우 그 스트림은 소비(comsume) 하지 않고 그대로 무시된다.
     
    예를 들어 다음과 같은 코드는 최종연산을 누락하여 중간연산인 peek()는 실행되지 않는다.

    // 중간 연산만을 수행하는 스트림 파이프라인
    IntStream.range(1, 6)
    	.peek(System.out::println)
    	.peek(i -> {
        			if (i == 5)
                    	throw new RuntimeException("bang");
                    });

     
    만일 IDE를 사용한다면 다음과 같이 마지막 연산의 결과는 무시된다는 경고를 출력한다.
     

     
    실제로 위 코드를 수행해 보면 아무것도 출력되지 않으며 예외도 발생하지 않는다.
     

     
    최종 연산인 forEach()를 추가하면 의도한 대로 스트림 파이프라인이 동작하며 다섯 번째 요소에서 RuntimeException이 발생한다. 

    // 최종 연산을 수행하는 스트림 파이프라인
    IntStream.range(1, 6)
    	.peek(System.out::println)
    	.peek(i -> {
        			if (i == 5)
                    	throw new RuntimeException("bang");
                    })
    	.forEach(i -> System.out.println(i + "번째 원소 호출"));

     
    실행 결과는 다음과 같다.

     첫 번째 peek() 메서드에 의해 각 요소에 순차적으로 접근하며 콘솔에 출력하고, 5번째 요소가 아니라면 forEach() 최종 연산에 의해서도 콘솔에 출력된다. 첫번째 peek() 메서드가 접근 중인 스트림 요소를 콘솔에 출력하고 나서 5번째 요소라면 RuntimeException이 발생한다.

    스트림을 소비하기 위한 최종연산으로는 forEach() 외에도 sum(), collect(), count(), reduce(), min(), max(), anyMatch(), allMatch(), noneMatch(), findFirst(), findAny() 등이 있다.
     

    2. 재사용 스트림 문제

    Streams API를 사용한다면 누구나 한 번쯤 발생하는 상황이라고 생각한다. 스트림은 오직 한 번만 소비(consume)할 수 있으며, 스트림을 한 번 소비하면 그 이후에 해당 스트림은 닫히기 때문에 다음 코드에서 이미 사용한 스트림을 사용하려고 하면 이는 작동하지 않는다.
     
    예를 들어 다음과 같은 코드는 java.lang.IllegalStateException: stream has already been operated upon or closed 에러가 발생한다.

    public static void main(String[] args) {
    		int[] intArray = {1, 2, 3, 4, 5};
    
    		/* int 배열을 IntStream으로 변환 */
    		IntStream intStream = Arrays.stream(intArray);
    		intStream.forEach(value -> System.out.println(value));
    		System.out.println();
    
    		/* IntStream을 Double Stream으로 변환 */
    		// 이미 소비한 intStream은 다음 코드에서 다시 사용할 수 없다.
    		DoubleStream doubleStream = intStream.asDoubleStream(); 
    		doubleStream.forEach(value -> System.out.println(value));
    		System.out.println();
    	}

     
    이미 소비한 스트림은 더 이상 사용할 수 없으므로 doubleStream을 생성할 때 필요한 intStream은 다음과 같이 새로 생성해야 한다.해야한다.

    public static void main(String[] args) {
    		int[] intArray = {1, 2, 3, 4, 5};
    
    		/* int 배열을 IntStream으로 변환 */
    		IntStream intStream = Arrays.stream(intArray);
    		intStream.forEach(value -> System.out.println(value));
    		System.out.println();
    
    		/* IntStream을 Double Stream으로 변환 */
    		// intStream을 다시 얻는다.
    		DoubleStream doubleStream = Arrays.stream(intArray).asDoubleStream(); 
    		doubleStream.forEach(value -> System.out.println(value));
    		System.out.println();
    	}

     

    3. 무한 스트림 생성

    Stream의 iterate() 연산을 사용하는 경우 무한 스트림은 의외로 쉽게 생성될 수 있어 주의가 필요하다.
    다음과 같은 IntStream은 무한히 생성되어 0 ~ ∞ 까지 그 값을 출력하려고 한다.

    IntStream.iterate(0, i -> i + 1)
    			   .forEach(System.out::println);

     
    인텔리제이와 같이 IDE를 사용한다면 Non-short-circuit operation consumes infinite stream 경고를 출력한다.

     
    따라서 iterate()로 스트림을 생성하는 경우 limit() 중간 연산을 통해 범위를 지정해 준다면 무한 스트림을 방지할 수 있을 것이다.

    IntStream.iterate(0, i -> i + 1)
             .limit(10) // 0 ~ 9 까지 IntStream 생성
             .forEach(System.out::println);

     
    java 9부터는 iterate에 람다식(lambda expression)으로 범위를 지정할 수 있어 limit()를 사용하지 않고 무한 스트림을 방지할 수 있다. iterate에 두 번째 값으로 범위를 지정하면 된다.

    IntStream.iterate(0, i -> i < 10, i -> i + 1)
    				 .forEach(System.out::println);

     

    4. 감지하기 힘든(subtle) 무한 스트림 생성

    앞서 설명했듯이 무한 스트림을 방지하기 위해 명시적으로 limit() 메서드를 호출하여 그 범위를 제한하였으나, 무한 스트림이 생성되는 경우이다. 다음과 같은 코드는 결국 무한 스트림을 생성하고 콘솔에 "complete"를 출력하지 않는다.

    IntStream.iterate(0, i -> ( i + 1 ) % 2)
             .distinct()
             .limit(10)
             .forEach(System.out::println);
    
    System.out.println("complete");

     
    iterate() 연산의 수행 결과로 0과 1이 무한히 반복해서 나타나는 0, 1, 0, 1, 0, 1, 0, 1, … 형태의 무한 스트림이 생성될 것이다.
    이후 바로 distinct()와 limit() 연산이 수행되는 것이 아니라 최종 연산인 forEach()가 동작하면서 순차적으로 스트림의 모든 요소에 대해 distinct()와 limit() 연산을 수행한다.(지연 연산, lazy evaluation)
    iterate()에 의해 생성된 스트림의 각 요소에 대해 distinct()와 limit() 연산은 다음과 같이 연산을 수행한다. 
     

    1. 첫 번째 요소 0
      1. distinct() 연산은 처음으로 등장한 0은 중복되지 않으므로 limit()로 전달한다.
      2. limit()도 아직 제한 범위인 10에 도달하지 않았으므로 0을 forEach()에 전달하고 콘솔에 출력된다.
    2. 두 번째 요소 1
      1. distinct() 연산은 처음으로 등장한 1도 중복되지 않으므로 limit()로 전달한다.
      2. limit()도 아직 제한 범위인 10에 도달하지 않았으므로 1을 forEach()에 전달하고 콘솔에 출력된다.
    3. 세 번째 요소 0, 네 번째 요소 1, 이후 등장하는 모든 요소들
      1. distinct()는 0과 1이 아닌 새로운 요소가 나올 때까지 이후로 등장한 0과 1을 필터링하여 limit()로 전달하지 않는다.
      2. iterate()는 반복해서 0과 1이 무한 반복되는 스트림을 생성하는데,distinct()는 중복 여부를 판별하기 위해 iterate()가 생성하는 스트림의 모든 요소에 접근하려고 한다.
      3. 따라서 distinct()가 계속해서 모든 요소에 접근하여 필터링을 하면서 무한 루프에 빠지게 된다.

     
    위 코드는 첫 0과 1만을 콘솔에 출력하고 complete는 화면에 출력되지 않으며 프로세스도 종료되지 않는다.
     

     
    순서를 바꿔서 limit() 연산을 먼저 적용하여 범위를 제한하고 나서 그 이후에 distinct()로 중복을 제거하도록 작성한다면 의도대로 동작한다.

    IntStream.iterate(0, i -> ( i + 1 ) % 2)
    			.limit(10)
    			.distinct()
    			.forEach(System.out::println);
    
    System.out.println("compelete");

     

    5. 감지하기 힘든(subtle) 병렬 무한 스트림 생성

    앞서, 아래와 같은 코드는 무한 스트림을 생성한다고 소개하였다.

    IntStream.iterate(0, i -> ( i + 1 ) % 2)
             .distinct()
             .limit(10)
             .forEach(System.out::println);
    
    System.out.prinln("complete");

     
    만일 개발자가 위 코드가 무한 스트림을 생성한다는 것을 인지하지 못하는 상황에서 스트림을 병렬로 처리하면, 이는 병렬 무한 스트림에 빠지게 될 것이다.

    IntStream.iterate(0, i -> ( i + 1 ) % 2)
    		.parallel() // 병렬 스트림
            .distinct()
            .limit(10)
            .forEach(System.out::println);
    
    System.out.prinln("complete");

     
    병렬 처리 환경에서 무한 스트림은 단순히 프로세스가 종료되지 않는다는 것 외에도 상황이 달라질 수 있다. 예를 들어 4. 감지하기 힘든(subtle) 무한 스트림 생성에서는 하나의 CPU만을 사용했으나 병렬 처리 환경에서는 병렬 처리를 위해 더 많은 자원들이 무한스트림을 소비하기 위해 할당되어 CPU 사용량이 매우 증가할 것이다.

    6. Stream의 Backing Collection 수정

    배열이나 컬렉션 등에서 실제 데이터를 저장하고 관리하는 부분 즉, 개발자가 직접적으로 다루지 않고 내부에서만 사용되는 데이터 구조backing collection이라고 한다. 꼭 Streams에서만 적용되는 내용은 아니라, 단순 list를 다룰 때에도 데이터를 처리하면서 그 리스트를 수정하면 예상치 못한 사이드 이펙트를 유발한다는 것은 잘 알고 있다. 하지만 데이터 처리의 높은 추상화를 제공하는 java 8 streams 특성상 이러한 부분은 더 쉽게 놓칠 수 있다.
     
    다음과 같은 코드는 0부터 10까지 ArrayList를 생성한다.

    // boxed() : // 기본 타입인 int를 Wrapper 클래스(Integer) 로 변환
    List<Integer> list = IntStream.range(0, 10)
    				.boxed()
    				.collect(toCollection(ArrayList::new));

     
    이때 중간연산으로 루핑(looping, 요소를 하나씩 반복해서 가져와 처리하는 것)을 위해 peek() 연산을 추가하고, 이 때 peek() 연산은 실행 중간에 스트림의 원소를 제거한다면 이는 Backing collection을 수행하여 예상치 못한 사이드 이펙트를 유발할 것이다.

    list.stream()
        .peek(list::remove)
        .forEach(System.out::println);

     
    위 코드는 실행을 실행해 보면 콘솔에 0 2 4 6 8 null null null null null이 출력되고 Exception in thread "main" java.util.ConcurrentModificationException 예외가 발생한다.
     
    이는 1. 스트림을 소비(consume)하는 것을 깜빡함(스트림 파이프라인의 최종 연산 누락) 소개했던 지연연산 때문인데, peek(list::remove)는 중간 연산으로서 각 요소에 대해 주어진 동작을 수행하지만 최종 연산이 호출되기 전까지는 실제로 실행되지 않는다. forEach(System.out::println)이 호출될 때 peek(list::remove) 이 실행되면서 리스트의 각 요소가 제거되는데 이처럼 최종 연산 이전에 중간 연산으로 인해 리스트가 수정되며 ConcurrentModificationException 가 발생한다.
     
    구체적으로 위 코드의 동작 순서는 다음과 같다.
     

    1. 0부터 10까지 값을 요소로 갖는 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 리스트를 stream()으로 변환
    2. peek() 중간 연산은 forEach() 최종 연산이 실행(= 스트림 소비)될 때까지 실제로 동작하지 않는다. (지연연산)
    3. forEach() 최종 연산이 호출되면 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 리스트의 각 요소에 대해 두 번째 중간원소인 peek()과 forEach()에 기술한 작업(콘솔 출력)을 수행한다.
      1. 첫 번째 요소
        1. peek() 중간연산은 리스트의 첫번째 요소인 0을 스트림에서 제거한다.
          1. 이때 스트림은 다음과 같이 변환된다 : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
        2. forEach()는 소비 중이던 첫 번째 요소 0을 처리(= 콘솔 출력)한다.
      2. 두 번째 요소
        1. peek() 중간연산은 스트림의 두번째 요소를 스트림에서 제거한다.
        2. 첫 번째 요소 처리 단계에서 스트림이 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]로 변환되었으므로 두 번째 원소는 2가 된다.
          1. 이때 스트림은 다음과 같이 변환된다 : [1, 3, 4, 5, 6, 7, 8, 9, 10]
        3. forEach() 스트림의 두번째 요소인 2를 처리(= 콘솔 출력)한다
      3. 세 번째 요소
        1. peek() 중간연산은 스트림의 세번째 원소를 스트림에서 제거한다.
        2. 두 번째 요소 처리 단계에서 스트림이 [1, 3, 4, 5, 6, 7, 8, 9, 10]로 변환되었으므로 세 번째 원소는 4가 된다.
          1. 이때 스트림은 다음과 같이 변환된다 : [1, 3, 5, 6, 7, 8, 9, 10]
        3. forEach() 스트림의 세번째 요소인 4를 처리(= 콘솔 출력)한다
      4. 이후 마지막 10번째 원소까지 위 과정을 반복한다.
        1. stream이 가리키는 원소가 없다면 의도와 다르게 null이 출력되며, 이후 ConcurrentModificationException 가 발생한다.

    다음과 같이 수행해 보면 스트림의 동작이 조금 더 쉽게 이해가 될 것이다.

    list.stream()
    			.peek(element -> {
    				System.out.println("1. 리스트에서 " + element + " 제거");
    				list.remove(element);
    			})
    			.peek(element -> {
    				System.out.println("2. remove(" + element + ") 호출 이후 list : " + list);
    			})
    			.forEach(element -> {
    				System.out.println("3. 스트림이 처리중인 원소 값 : " + element);
    				System.out.println();
    			});

     
    따라서 스트림을 중간에 수정하면 코드가 의도대로 동작하지 않아 ConcurrentModificationException 예외가 발생할 가능성이 높고, 이처럼 스트림을 소비하는 도중에 변경하는 상황은 가능한 삼가야 한다.


    참고 문서 및 출처

    반응형
    Comments