[새싹] 모던 자바 인 액션 Chapter07-(1)

채상엽·2022년 5월 10일
0

Sproutt 2nd - Spring Study

목록 보기
10/32

layout: post
title: "모던 자바 인 액션 스터디 - chapter7(1)"
date: 2022-04-25T00:00:00-00:00
author: sangyeop
categories: Sproutt-2nd


새싹 개발 서적 스터디 - 모던 자바 인 액션 Chapter7-(1)

병렬 데이터 처리와 성능

이 장의 내용

  • 병렬 스트림으로 데이터를 병렬 처리하기
  • 병렬 스트림의 성능 분석
  • 포크/조인 프레임워크
  • Spliterator로 스트림 데이터 쪼개기

병렬 스트림

컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.

병렬 스트림이란? : 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림

다음은 숫자 n을 인수로 받아서 1부터 n까지의 모든 숫자의 합계를 반환하는 메서드를 구현하는 코드이다

public long sequentialSum(long n) {
  return Stream.iterate(1L, i->i+1) // 무한 자연수 스트림 생성
    .limit(n) // n개 이하로 제한
    .reduce(0L, Long::sum); // 
}

이를 전통적인 자바로 구현하면 다음과 같다

public Long iterativeSum(long n) {
  long result = 0;
  for (long i = 1L; i<=n; i++) {
    result += i;
  }
  return result;
}

순차 스트림을 병렬 스트림으로 반환하기

여기서 parallel 메서드를 호출하면 기존 리듀스 연산이 병렬로 처리된다.

public long parallelSum(long n) {
  return Stream.iterate(1L, i -> i+1)
    .limit(n)
    .parallel() // 스트림 병렬로 변환
    .reduce(0L, Long::sum); 
}

여기선 스트림이 여러 청크로 분할되어있고, 이 분할된 여러 청크들에 연산을 병렬로 수행할 수 있다. 이후 리듀스 연산으로 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐서 전체 스트림의 리듀싱 결과를 도출한다.

사실 순차 스트림에 parallel을 호출해도 스트림 자체에는 아무 변화도 없지만, 내부적으로는 이후 연산이 병렬로 수행해야함을 의미하는 불리언 플래그가 설정된다. 반대로 sequential을 설정하면 순차 스트림으로 바꿀 수 있다.

스트림 성능 측정

성능 최적화를 위해서는 측정, 또 측정 하는 것이 매우 중요하다.측정을 위해서는 자바 마이크로벤치 마크 하니스(JMH) 라이브러리를 사용해 벤치마크를 구현한다.

for-loop vs stream

이를 이용해서 위 코드들을 측정하면 for-loop를 사용해 반복하는 것이 가장 저수준으로 동작할 뿐 아니라 기본값을 박싱하거나 언박싱할 필요가 없으므로 스트림을 사용했을 때에 비해 훨씬 빠르다.

순차 스트림 vs 병렬 스트림

또한 예상과는 다르게 병렬 스트림이 순차 스트림보다 훨씬 느린 결과를 보여주었는데 이는 두 가지 이유로 설명할 수 있다

  • 반복 결과로 박싱된 객체가 만들어지므로 숫자를 더하려면 언박싱을 해야 한다.
  • 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기가 어렵다.

iterate는 본질적으로 순차적이며, 리듀싱을 시작하는 과정에 전체 리스트가 준비되어 있지 않으므로 스트림을 병렬로 처리하도록 청크 분할을 할 수 없다. 이 때문에 병렬 처리가 지시 되었으나, 결국 순차 방식과 결과에 있어서는 차이가 없게되고 스레드를 할당하는 오버헤드만 증가하여 결과적으로 더 느린 처리시간을 보이게 된다.

이 때문에 내부 구조를 잘 이해하지 못하고 사용할 경우 오히려 성능을 더 악화 시킬수도 있게 된다.

더 특화된 메서드 사용

멀티코어 프로세스를 이용한 병렬 연산을 효과적으로 활용하기 위해서는 어떻게 해야할까?

  • LongStream.rangeClosed는 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.
  • LongStream.rangeClosed는 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다. 예를 들어 1-20 범위의 숫자를 각각 1-5, 6-10, 11-15, 16-20 범위로 분할할 수 있다.

위와 같은 LongStream 또는 IntStream등 기본형 특화 스트림을 사용하여 오버헤드를 없앨 수 있으며, 숫자 범위의 제한을 함으로써 for-loop 보다 더 빠른 처리 속도를 기대해 볼 수 있게된다.

병렬 스트림의 올바른 사용법

많은 문제가 공유된 상태를 바꾸는 알고리즘을 사용하기때문에 일어난다. 다음은 n까지 자연수를 더하면서 공유된 누적자를 바꾸는 프로그램을 구현한 코드이다

public Long sideEffectSum(long n) {
  Accumulator accumulator = new Accumulator();
  LongStream.rangeClosed(1, n).forEach(accumulator::add);
  return accumulator.total;
}

public class Accumulator {
  public long total = 0;
  public void add(long value) {
    total += value;
  }
}

위와 같은 코드는 본질적으로 순차 실행하도록 구현되어 있기 때문에, total에 접근할때마다 데이터 레이스가 일어날 것이다.

데이터 레이스란? : 여러 쓰레드/프로세스가 공유자원에 동시에 접근하려 할 때 일어나는 경쟁 상황

이처럼 실행하게 되면 올바른 결과값이 도출되지 않는다. 이 예제를 통해서 병렬 스트림과 병렬 계산에서는 공유된 가변 상태를 피해야 한다는 사실을 확인할 수 있었다.

병렬 스트림 효과적으로 사용하기

병렬 스트림을 어떤 경우에 사용해야하는지 어떤 기준을 정하는 것은 매우 어렵다.

  • 순차스트림과 병렬스트림 중 어떤 것이 좋을지 애매하다면 일단 측정하라

  • 박싱을 주의하라. 되도록이면 기본형 특화 스트림을 사용하라

  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산들이 있다. ex) limt, findFirst 등등

  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라. 처리해야할 요소가 N개이고 처리하는데 드는 비용이 Q라고하면 전체 비용은 N*Q가 된다. Q가 높아진다는 것은 병렬 스트림으로 성능을 개선할 여지가 있음을 의미한다.

  • 소량의 데이터에서는 병렬 스트림은 도움이 되지 못할 가능성이 크다.

  • 스트림을 구성하는 자료구조가 적절한지 확인하라. 예를 들어 ArrayListLinkedList 와는 다르게 모든 요소를 탐색하지 않고도 리스트를 분할할 수 있기 때문에 병렬 처리에 적합하다.

    ArrayList : index를 가지고 있다. index를 가지고 있고 무작위 접근이 가능하기 때문에, 해당 index의 데이터를 한번에 가져올 수 있다.

    LinkedList : 각 원소마다 앞, 뒤 원소의 주소값을 가지고 있다. 순차 접근이기 때문에 검색 속도가 느리다.

  • 스트림의 특성과 파이프라인 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라서 분해 과정의 성능이 달라질 수 있다. SIZED 스트림의 경우에는 정확히 같은 크기의 두 스트림으로 분할할 수있으므로 효과적인 병렬 처리가 가능하다. 반면에 필터 연산이 있을 경우에는 스트림 길이를 예측할 수 없으므로 효과적인 처리가 어렵다.

  • 최종 연산의 병합 과정 비용을 살펴보자. 병합 비용이 비싸면 병렬 스트림의 이득이 상쇄될 수 있다.

소스분해성
ArrayList훌륭함
LinkedList나쁨
IntStream.range훌륭함
Stream.iterate나쁨
HashSet좋음
TreeSet좋음

순차적이지 않아 요소 탐색 없이 분해할 수 있으면 분해성이 좋고 그렇지 않다면 나쁘다고 하는 것 같다.

profile
프로게이머 연습생 출신 주니어 서버 개발자 채상엽입니다.

0개의 댓글