티스토리 뷰

ParallelStream

java8에 추가된 parallelStream 은 멀티스레드 프로그래밍을 매우 쉽게 해준다.

개발자가 직접 스레드 혹은 스레드풀을 생성하거나 관리할 필요없이 parallelStream(), parallel() 만 사용하면 알아서 ForkJoinFramework 를 이용하여 작업들을 분할하고, 병렬적으로 처리하게된다.

public static void main(String[] args) {
        parallel();
        single();
    }

    private static void parallel() {
        long start = System.currentTimeMillis();
        long sum = LongStream.range(0, 1_000_000_000).parallel()
            .sum();
        System.out.println(sum);
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    private static void single() {
        long start = System.currentTimeMillis();
        long sum = LongStream.range(0, 1_000_000_000)
            .sum();
        System.out.println(sum);
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

(내 컴퓨터에선 parallel이 약 2배 빠르다.)

이를 이용하면 매우 쉽게 병렬 프로그래밍을 할 수 있기때문에 현재 내가 일하고있는 일터의 내부 코드엔 parallelStream 을 사용한곳이 몇군데 존재한다. 그런데 이 코드로인해 준장애상황을 맞이했는데 그 이유를 알아보도록하자.

Thread pool

parallelStream 은 개발자 모르게 내부적으로 스레드 풀을 만들어서 작업을 병렬화시킨다. 여기서 중요한 점은 parallelStream 별로 스레드풀을 만드는게 아니라는 점이다. 별도의 설정이 없다면 하나의 스레드 풀을 모든 parallelStream이 공유하게된다.

Blocking IO

parallelStream 을 이용해 멀티 스레드를 이용하고, 그 코드 내부엔 db 호출과 rest template 을 이용한 동기 http 호출이 있었다.

장애발생

API 응답이 상당히 느려지는 경우가 발생했다. 기준치를 넘어 알람이 오기시작했고, 이슈를 확인해봤다. 사실 잘 돌아가던 서비스고, 근래에 수정사항이 있었던적도 아니어서 왜 발생했는지 추적하기가 꽤 힘들었다. 이리 저리 추적하다가 쓰레드덤프를 생성하게됐고, 쓰레드 덤프를 생성해서 분석해보니 상당수의 쓰레드가 block 혹은 wait 상태인걸 발견했다.

장애원인

리스트를 돌면서 순차적으로 http 호출을 해야한다고 생각보자. 리스트의 크기가 5개라면 parallelStream 을 이용해 5개의 스레드를 만들어 병렬로 호출하면 더 빨라질것 같다. 그리고 아마도 이런 의도로 코드 내부에 parallelStream 을 쓴곳들이 있는것으로 보인다. 하지만 정말 parallelStream 으로 동작했을때 더 빠른지, 그리고 일반 stream이 조금 더 느릴지라도 서비스 스펙을 위반할정도로 느린지는 테스트를 통해 확인해봐야한다. 일단 내가 맞은 준장애상태의 원인은 parallelStream 을 통해 io 작업을 하는 코드 때문이었다. parallelStream 은 위에서 말한대로 스레드 풀을 공유한다. 그래서 parallelStream 으로 blocking io 가 발생하는 작업을 하게되면 스레드풀 내부의 스레드들은 block 되게된다. 이때 http 호출 코드에서 block 되는건 문제가 안되는데 이 스레드 풀을 사용하는 다른쪽의 parallelStream 이 문제가 되는것이다. 스레드 풀을 공유하기때문에 http 호출로 인해 block 된 스레드들을 다른 parallelStream 코드에서도 사용하지못하게되고 스레드를 얻을때까지 기다리게된다. 이렇게되어 전체적인 API 처리 자체에 문제가 생긴것이다.

장애해결

io 작업이 있는 parallelStream 을 전부 일반 stream 으로 변경해서 배포했다. API의 응답속도는 이전과 같아졌고, 우리가 맞이한 준장애사태는 다행히 마무리될 수 있었다.

이 이슈는 지금처럼 일반 stream 으로 변경해서 해결할 수도있고, 혹여라도 꼭 병렬로 처리되어야 한다면 스레드 풀을 default 로 사용하지않고 parallelStream 마다 각각 커스텀하게 지정해주면 된다. 다만 parallelStream을 이용할때 커스텀 스레드 풀을 예쁘게 전달할 수 있는 API를 제공하지않아 코드가 좀 지저분해진다는 문제는 있다.

ForkJoinPool pool = new ForkJoinPool(4);
long sum = pool.submit(() -> LongStream.range(0, 1_000_000_000).parallel()
            .sum()).get();

(스레드 4개짜리 커스텀 스레드풀을 이용하는 코드)

다만 우리는 일반 stream 으로도 충분히 커버할 수 있어 좀 더 쉬운쪽으로 이슈를 해결했다.

정리

사실 이슈처리의 가장큰 어려움은 기존에 잘 돌고있던 API 라는 점이다. 신규로 오픈한 API라던가, 근래에 코드를 변경해서 배포한적이 있는 API라면 원인을 좀 더 빠르게 찾아낼 수 있었을텐데 몇년간 잘 돌고있던 API에서 갑자기 문제가 발생하니 원인을 찾는게 꽤 힘들었다. 이미 다 조치한 상태에서 글로 정리하다보니 과정의 어려움이 잘 전달되지않을것같은게 좀 아쉽다. 그럼 몇년간 잘 돌던 API가 왜 갑자기 문제를 일으켰나? 를 추적해보니 장애가 발생하는 시점에 클라이언트 쪽 변경사항이 있었다. 이전에 비해 API를 더 많이 호출하도록 클라이언트가 변경되어 배포됐는데 하필 그 API 가 parallelStream 으로 http io 를 하고있던 API 였던것이다. 장애를 맞아 며칠간 고통스러웠지만 이렇게 또 하나 이슈를 해결하니 뿌듯하다.

마지막으로 한마디만 더하자면, 가장 위에있는 sum 예제에서 0부터 더해야할 숫자의 크기를 줄여보자. 내가 저 예제에서 더하는 크기를 왜 저런 어마어마한 숫자까지 만들었는지 아는가? 어지간하면 parallel 보다 single 이 더 빠르다. parallel은 위에서 말한 문제도 있지만 작업들을 분할하고 다시 합치는 비용, 스레드들간의 컨텍스트 스위치 비용도 포함된다. 정말 parallelStream 을 써야할지는 테스트를 통해 확인해보고 적용토록 하자.

이펙티브 자바 3판을 아직 안봤는데 3판에선 이런 parallelStream 에 대해서도 다룬다고하니 올해 안에 꼭 봐야겠다.

참고

https://dzone.com/articles/think-twice-using-java-8

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함