[JAVA] 스트림과 병렬스트림 (parallelStream)

2022. 6. 8. 22:42[ 백엔드 개발 ]/[ Java,Kotlin ]

최종 연산과 중간 연산

스트림이란 일련의 데이터의 흐름이다. 실질적으로는 스트림은 인스턴스로 존재하고 여러 개의 파이프를 통과함으로써 알련의 작업이 처리된다. 여기서 파이프는 메소드 호출을 통해 이루어진다. 최종 연산을 담당하는 파이프는 가장 마지막에 위치해야 한다. 최종 연산이 아닌 그 외 연산은 모두 중간 연산이라고 부른다.

 

- 최종 연산(Terminal Operation) : 스트림을 연산하기 위한 파이프 중 가장 마지막의 연산을 담당하는 파이프

- 중간 연산(Intermediate Operation) : 최종 연산이 아닌 파이프에서 처리되는 연산들

 

예를 들어 홀수만 찾아내고 이들의 합을 구하는 연산이 있다고 가정하면 총 2가지 연산(홀수 찾기, 더하기)이 존재하고 이들 간의 순서는 홀수를 찾는 것이 먼저 진행되어야 하고 그 이후에 더하기 연산이 진행되어야 한다. 여기서 중간 연산은 홀수 찾기이며 최종 연산은 더하기이다.

 

        int[] arr = {1, 2, 3, 4, 5};
        int sum = Arrays.stream(arr)
                .filter(n -> n % 2 == 1)
                .sum();

 

위 예제에서 중간연산은 filter이며 해당 메소드 호출은 관련 연산을 담당하는 파이프로 스트림이 연결된다. 최종 연산은 sum 메소드이며 마지막 파이프를 담당한다. 중간 연산과 최종 연산을 담당하는 메소드의 반환형은 각각 다르다는 점을 기억해야 한다. 중간 연산을 담당하는 메소드는 스트림을 반환하고 최종 연산을 담당하는 메소드는 최종 연산에 맞는 데이터 타입을 반환한다.

 

cf. 중간 연산에서 반환하는 스트림 형태

- IntStream, DoubleStream, LongStream, Stream<T> 등

 

cf. 최종 연산의 종류

- 요소 출력 또는 반환이 없는 추가 작업: forEach

- 통계: sum, count, max, min

- 수집: toList, toSet, toMap, toCollection, toArray

- 조건검사: allMatch, anyMatch, noneMatch, findFirst, findAny

- 두 요소를 연산하면서 줄여나가는 과정 반복: reduce

- 그 외: joining, groupingBy, partitioningBy

 

* 최종 연산은 반드시 1번만 진행되어야 한다.

 


 

필터링과 매핑

1. Filtering

조건을 주고 해당 조건에 해당하는 데이터만 파이프를 통과할 수 있도록 한다.

 

2. Mapping

- 파이프를 통과하면서 데이터의 형태가 바뀐다.

 

        List<String> list = Arrays.asList("A", "B", "C");
        list.stream()
                .map(String::length)
                .forEach(System.out::println);

 

map 메소드에서 담당하는 파이프를 지나면, 문자열이 문자열의 길이로 바뀐 형태로 최종 연산(forEach)을 통과하게 된다.

 

여기서 일반적인 map 메소드를 활용하면, 제네릭 타입을 반환하기 때문에 primitive type이 사용될 수 없다. 즉, 위 예시에서 map을 통과하면 int가 아닌 Integer가 반환된다. 여기서 primitive type을 반환하여 사용하고 싶다면 mapToInt를 사용해야 한다.

 

 

cf. Primitive Type으로 반환받기 위한 매핑 메소드

- mapToInt, mapToDouble, mapToLong

- 위와 같은 메소드는 일반적인 map과 다르게 오토박싱 및 오토언박싱 과정을 생략할 수 있다는 장점이 있다. (오토 방식/언박싱을 쉽게 생각할 수 있으나 코드 사이사이에 숨어있는 이런 과정은 매우 큰 성능차이를 보인다.)

 

 


 

리덕션과 병렬스트림

1. 리덕션(Reduction)

https://kouzie.github.io/java/java-8-%EB%9E%8C%EB%8B%A4,-%EC%8A%A4%ED%8A%B8%EB%A6%BC/#reduce

 

특정 조건에 의해 파이프로 들어오는 다수의 데이터를 한 개로 줄이는 연산을 의미한다. 이때 데이터가 파이프로 들어오는 순서대로 2개씩 연산하여 결과를 도출하고 그 결과를 다음에 들어오는 데이터와 연산하여 결과를 도출하는 형태이다.

 

예를 들어 sum 연산 같은 경우에도 리덕션 연산에 포함된다. 하지만 사용자가 직접 reduction 연산을 정의하고 싶다면 reduce 메소드를 사용할 수 있다.

 

https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#reduce-T-java.util.function.BinaryOperator-

 

reduce 메소드의 두번째 인자에 람다식을 전달하는데 apply 메소드를 갖는 함수형 인터페이스이다. apply 메소드는 인자 두개를 받고 해당 인자를 통해 하나의 값을 반환해야하는 형태이다.

 

reduce 메소드의 첫 번째 인자는 스트림이 비었을 경우 default 값으로 반환할 데이터를 지정한다. 이 값은 스트림의 첫 번째 데이터로 간주하기 때문에 주의해서 사용해야 한다. 만약 스트림이 비어있지 않더라도 해당 값은 가장 첫 번째로 파이프에 통과하는 값으로 간주되기 때문이다.

 

 

2. 병렬스트림

병렬은 하나의 task를 여러 개의 코어에서 나눠서 동시에 처리하는 것을 의미한다. 병렬처리를 한다고 해서 무조건 빠른 응답을 기대할 순 없다. task를 여러개로 나누는 작업자체도 시간이 소요되고 어떻게 task를 나눌지 결정하는 것 자체도 시간이 소요된다. 물론, 나눠진 task를 처리하는 것 자체는 빠른 응답을 기대할 수 있다. 즉, 병렬처리를 위한 전, 후의 작업이 추가적으로 필요하다. 

 

다행히 자바에서는 task를 어떻게 나누고 어떻게 다시 합칠지에 대한 방법을 지원한다. 따라서 개발자는 병렬처리를 위한 task 분리 및 취합에 대해 고민하지 않아도 된다. 다만, 스트림 생성 시 기본 스트림이 아닌 병렬스트림을 위한 스트림을 생성해야 한다.

 

List에서는 parallelStream()이라는 메소드를 지원하며 이를 통해 병렬스트림 기반의 데이터를 처리할 수 있다. 또는 기존에 사용하던 스트림에서 parallel() 메소드를 사용할 수도 있다. 병렬스트림은 CPU 코어의 자원 상태에 따라 코어를 몇개 쓸지 결정된다. 사용가능한 프로세서 갯수(Runtime.getRuntime().availableProcessors())에서 리턴된 값을 기반으로 쓰레드 풀을 구성한다.

 

병렬스트림은 'ForkJoinPool' 기반으로 동작하는데, reduce 연산을 사용하면 다음과 같이 join 될 때 두 코어의 처리 결과에서 다시 한번 reduce 연산이 수행되는 절차를 거친다.

 

모던 자바 인 액션

 

* 주의: 병렬스트림을 사용한다고 해서 무조건 작업이 빨라지는 것은 아니다. 병렬처리를 사용했을 때와 그렇지 않았을 경우의 시간을 비교해서 적절하게 사용해야 한다.

 

 


 

 

스트림의 장단점

- 지연 연산(Lazy Evaluation)으로 인해 '디버깅'이 어렵다.

- 복잡한 nested 반복문으로 구성하는 것보다 가독성이 좋다. 하지만 남용하면 더 안좋아진다.

- 병렬처리를 개발자가 직접 구현하지 않고도 병렬스트림을 활용할 수 있다.

- 스트림을 활용하기 위한 객체를 생성하고 준비과정을 거치는데 오버헤드가 존재한다.

 

* 스트림은 절대적으로 사용해야하는 건 아니다. 스트림의 장단점을 고려해서 필요한 부분에만 적용해야 한다.

 

 

 

 

cf. 조급한 연산(Eager Evaluation) vs 지연 연산(Lazy Evaluation)

예시) 스트림의 구조가 다음과 같다고 가정하면

 

.filter(1번조건)

.filter(2번조건)

.map(변형식)

.collect(Collectors.toList())

 

1) 조급한 연산(Eager Evaluation)

스트림으로 흘려보내는 '모든 데이터'에 대해 첫 번째 filter를 통과시킨 결과를 한 번에 모아서 두 번째 filter 조건으로 흘려 보낸다.

 

2) 지연 연산(Lazy Evaluation)

스트림으로 흘려보내는 '개별 데이터'에 대해 첫 번째 filter조건이 통과하면 해당 개별 데이터를 두 번째 filter 조건으로 흘려 보낸다.

 

* 지연 연산의 연산 순서를 보고싶다면 조건 람다식에 출력문을 넣어 확인해보자

 

 

 

cf. 향상된 for문과 일반 for문은 가독성 외 차이점이 없다. 향상된 for문의 바이트코드를 보면 일반 for문으로 변환된다. 즉, iterator를 사용하지 않는다.

 

 

Reference

- https://kouzie.github.io/java/java-8-%EB%9E%8C%EB%8B%A4,-%EC%8A%A4%ED%8A%B8%EB%A6%BC/