병렬 어플리케이션의 성능과 응답성을 높일 수 있는 방법을 보자
병렬화 할 수 있는 작업을 구분하는 방법에 대해 알아보고
병렬화한 결과를 작업 실행 프레임웍에서 실행시키는 방법도 보자
대부분 병렬 어플리케이션은 task(업무의 단위)를 실행하는 구조가 효율적으로 되어있다.
어플리케이션이 해야 할 일을 task 단위로 분류하면 프로그램 구조도 간결하고 트랜잭션의 범위를 지점해 오류에 효과적인 대응이 가능하고 작업 실행의 병렬성을 극대화 할 수 있다.
스레드에서 작업 실행
가장 먼저 해야할 일은 작업의 범위를 어디까지 할 것인가다.
어플리케이션은 충분한 속도, 빠른 반응 속도, 가능한 많은 사용자 요청 처리와
부하가 가해져도 그냥 죽어버리지 않고 부하가 늘어남에 따라 점진적으로 속도가 느려져야 한다.
이걸 위해서는 작업 단위의 범위를 적절하게 설정해야 한다.
대부분의 어플리케이션에서는 클라이언트의 요청 하나를 작업 하나로 본다.
독립성을 보장 받으면서 작업의 크기를 적절하게 설정하는 것이라 볼 수 있다.
- 작업을 순차적으로 실행
어플리케이션에서 작업을 실행 시키는 순서를 지정하는 스케줄링 방법으로 여러가지 종류의 정책이 있다.
작업을 실행시키는 가장 간단한 방법은 단일 스레드에서 작업 목록을 순차적으로 실행하는 방법이다.
즉, 80포트에 접속한 클라이언트의 요청을 순차적으로 처리 한다.

한 번에 하나의 요청만 처리 할 수 있기 때문에 성능이 엄청 떨어진다.
기본 스레드는 네트웤 소켓 연결을 기다리고 있다가 클라이언트가 보내 온 요청을 처리하는 과정을 반복한다.
만약 웹서버가 이전 요청을 처리하고 있으면 다음 요청은 이전 요청이 끝날때까지 기다려야 한다.
웹서버의 요청을 처리하는 과정에는 대부분 약간의 연산과 I/O 연산이 대부분을 차지한다.
서버는 네트웤 소켓 I/O 를 통해 요청을 읽고 결과를 넘겨준다.
작업 도중에 파일을 읽거나 db 요청을 날려 대기 상태로 들어갈 수 있는데
순차적 실행은 이 대기 상태에서 다른 요청을 전혀 처리 하지 못한다는 문제가 있다.
단일 스레드에서 I/O 작업을 하는 동안 CPU 도 대기하고 있어서 하드웨어 자원을 제대로 활용하지 못한다.
- 작업마다 스레드를 직접 생성
반응 속도를 높일 수 있는 방법 중 하나가
요청이 들어올 때마다 스레드를 하나씩 만들어 실행 시키는 방법이다

클래스 구조는 이전 버전과 다르지 않다.
메인 스레드는 여전히 접속을 대기하다가 요청이 들어오면 새로운 스레드를 생성해 실행한다.
이렇게 변경하면 3가지 결과를 얻는다
- 작업을 처리하는 기능이 메인 스레드에서 떨어져 나와서 서버의 응답 속도를 높여준다
- 동시에 여러 작업을 병렬로 처리 할 수 있기 때문에 두개 이상의 요청을 받아 동시에 처리 할 수 있다.
- 여러 클라이언트가 동시에 동작 할 가능성이 매우 높기 때문에 스레드 안전성을 확보해야한다
이 방법은 웬만한 부하까지는 견딜수있고 속도도 향상됐다.
하지만 클라이언트가 접속해 요청을 전송하는 속도에 비해 응답을 처리해서 넘겨주는 속도가 더 빨라야 한다는 제약이 있다.
- 스레드를 많이 생성할 때의 문제점
작업마다 스레드를 만들기에는 실제 서비스에서 사용하기 무리가 있다.
특정 상황에서 엄청 많은 스레드가 생성될 수 있는데
다음과 같은 문제점이 생긴다.
- 스레드 라이프 사이클 문제 : 스레드를 생성하고 제거하는 작업도 자원이 소모된다.
요청을 처리할때 기본적인 딜레이가 생기고 , 클라이언트 요청이 작업은 간단한데 자주 들어오는 요청이면
상대적으로 스레드를 생성하는 부분이 많은 부분을 차지 하게 된다.
- 자원 낭비 : 스레드는 메모리를 소모한다. 하드웨어에 실제 프로세서 보다 많은 수의 스레드가 만들어져 있으면
대부분의 스레드가 대기 상태가 된다. 대기 상태의 스레드가 많아 지면 메모리를 많이 쓰게 되고
JVM 의 가비지 콜렉터에 부하가 늘고 CPU 를 사용하기 위해 여러 스레드가 경쟁하면서 메모리 외에도 많은 자원이 소모된다.
- 안전성 문제: 모든 시스템에선 스레드 갯수가 제한되어있는데, 제한된 양을 쓰면 OOM 이 발생한다.
특정 수준의 스레드 개수를 넘어가면 성능이 떨어진다.
계속 생성이되면 결국 다운이 될것이다.
이전 방법들은 스레드의 개수를 제한 할 방법이 전혀 없다.
요청량을 늘려서 특정 수준이 넘어가면 웹 서버를 다운 시킬 수 있다.
Executor 프레임웍
스레드 풀은 스레드를 관리하는 측면에서 통제력을 갖출 수 있게 해주고
java.util.concurrent 패키지에 보면 Executor 프레임웍의 일부분으로 유연하게 사용 할 수 있는 스레드 풀이 만들어져 있다.
자바에서는 작업을 실행하고자 할때는 Thread 보다 Executor가 훨씬 추상화가 잘 되어 있다

Executor 는 작업등록과 작업 실행을 분리하는 표준적인 방법이다.
각 작업은 Runnable 의 형태로 정의 하고, 이 인터페이스를 구현한 클래스는
작업의 라이프 사이클을 관리하는 기능을 갖고 통계 값을 뽑거나 작업 실행 과정을 모니터링 하기 위한 기능도 있다.
Executor 는 프로듀서 - 컨슈머 패턴 을 하고 있고
작업을 생성해 등록하는 클래스가 프로듀서, 실제로 실행하는 스레드가 컨슈머 가 된다.
- 예제 Executor 를 사용한 웹서버
Executor 를 사용사면 작업이 간단해진다.
스레드를 직접 생성하는곳에서 Executor 를 사용하도록 변경했다.

요청 처리 작업을 등록하는 부분과 실제 처리 실행하는 부분이 Executor를 사이에 두고 분리 되어있다
하지만 Executor를 사용해 작업을 등록하는 코드는 전체 프로그램의 여기저기 퍼져 있는 경우가 많아서
한눈에 보기 어렵다.
Executor 를 수정하면 순차 처리나, 요청마다 스레드를 생성하는것도 구현할 수 있다.
- 스레드 풀
스레드 풀은 스레드를 풀의 형태로 관리한다.
스레드로 처리할 작업을 쌓아둬야 하기 때문에 작업 큐와 밀접한 관계가 있다.
작업 스레드는 아주 간단한 주기로 동작한다.
작업 큐에서 다음 실행할 작업을 가져오고 -> 작업을 실행 하고 -> 가져와 실행할 다음 작업이 있을때까지 대기한다.
스레드 풀을 사용하면 매번 스레드를 생설 할때보다 장점이 있다.
- 스레드를 재사용 하기 때문에 매번 스레드를 생성할 필요가 없어서 시스템 자원이 줄어드는효과가 있다.
- 클라이언트가 요청했을때 요청을 처리할 스레드가 이미 만들어져 있어서 딜레이가 발생하지 않는다.
자바 라이브러리에서는 몇가지 종류의 스레드풀을 제공하고 있다.
미리 정의 되있는 스레드풀을 사용하려면 Executors 클래스에 만들어져 있는 다음 메소드를 호출하자.
- newFixedThreadPool : 작업이 등록되면 실제 작업할 스레드를 하나씩 생성한다.
스레드의 최대 개수는 제한되어있다.
- newCachedThreadPool : 캐시 스레드풀은 현재 풀에 갖고 있는 스레드의 수가 처리 할 작업의 수보다 많아서
쉬는 스레드가 많으면 스레드를 자동으로 종료 시키고, 작업의 수가 많아지면 그만큼 스레드를 생성해준다.
최대 개수 제한이 없다.
- newSingleThreadExecutor : 단일 스레드로 동작하는 Executor 로 작업을 처리하는 스레드가 한개다.
순차적으로 처리된다.
-newScheduledThreadPool : 일정 시간 이후로 시작되거나 주기적으로 실행할수있다.
newFixedThreadPool랑 newCachedThreadPool 메소드는 ThreadPoolExecutor 클래스의 인스턴스를 생성한다.
작업마다 스레드를 생성할때보다 스레드 풀을 사용하면 엄청난 장점들을 얻을 수 있다.
웹서버에 부하가 가더라도 메모리 부족으로 죽는일이 발생하지 않는다.
또한 수천개의 스레드를 생성해 제한된 CPU와 메모리 로 사용해도 성능이 떨어지더라도 점진적으로 떨어지게 된다.
-Executor 동작 주기
Executor 를 종료하는 방법을 봐보자
JVM 은 모든 스레드가 종료되기 전에는 종료하지 않고 대기 하도록 되어있다.
Executor 를 제대로 종료시키지 않으면 JVM 자체가 종료되지 않고 대기 하기도 한다.
Executor 는 비동기로 작업하기 때문에 작업의 상태를 파악하기 어렵다
Executor 역시 안전한 방법이건 강제적이건 종료 절차를 밟아야 한다.
그리고 종료 되는 동안 작업을 맡긴 어프리케이션에 어떻게 처리했는지 알려줘야한다.

내부적으로 ExecutorService는 실행중 , 종료중, 종료 의 상태를 가지고 있고,
처음 생성하면 실행준 상태다.
shutdown 을 실행하면 종료 상태로 들어간다.
ExecutorService 의 하위 클래스인 ThreadPoolExecutor 는 종료 상태의 스레드에
작업이 들어가면 rejectedExecutionException 을 발생
- 지연작업, 주기적 작업
자바 라이브러리의 Timer 클래스 보다는
ScheduledThreadPoolExecutor 를 사용하는게 좋다.
Timer 는 스레드를 하나만 생성해 사용하기 때문에
한 작업이 너무 오래되면 다른 TimerTask 작업이 예정된 시간에 실행 못 할 수 있다.
만약 특별한 스케줄 방법을 지원하는 스케줄링 서비스를 구현해야 하면
BlockingQueue 를 구현하면서 ScheduledThreadPoolExecutor 와 비슷한 기능을 하는
DelayQueue 를 사용하는게 좋다.
병렬로 처리할 만한 작업
서버 어플리케이션은 작업의 범위를 일정하게 나눌 수 있는데
기본적으로는 클라이언트의 요청 한 건을 처리하는 작업이라고 볼 수 있다.
근데 이게 모든 어플리케이션에 적절한 건 아니다.
서버에서 클라이언트의 요청 한 건을 병렬 처리 하는 모습도 볼 수 있다.
특히 db 에서 그런 경우가 많다
병렬로 동작하는 몇가지 컴포넌트 예를 봐보자
- 예제 순차적 페이지 렌더링

HTML 내용을 순차적으로 그리는 방법이 있다.
텍스트를 그리다 이미지 를 만나면 네트웍으로 다운받아서 채워 넣는다.
아주 간단 하지만 전부 표시될때까지 많은 시간이 걸린다.
똑같이 순차적이지만 덜 짜증나는 방법이 있다.
텍스트를 전부 처리하다가 이미지를 만나면 네모난 박스만 남겨두고
전부 그린 후에 이미지를 차례로 다운받아 공간에 넣는다.
이미지 다운은 I/O 작업이고 대기 시간이 길지만 CPU 가 하는일이 거의 없다.
작업을 작은 단위의 작업으로 쪼개서 동시에 실행하면 CPU도 잘 활용 할 수 있고, 처리 속도도 개선할 수 있다.
- 결과가 나올때 까지 대기: Callable 과 Future
Executor 프레임웍에서 작업을 표현하는 방법으로 Runnable 인터페이스를 사용한다.
근데 이 Runnable 은 run 메소드가 실행이 끝난 다음 뭔가 결과 값을 리턴해 줄 수 도 없고,
예외가 발생할 수 있다고 throws 로 표현할 수 없다.
-> 결과 값을 db 든 어디든 저장을 해야하고 오류가 발생하면 로그 파일에 기록하는 정도만 가능하다
결과를 받을 때까지 시간이 걸리는 작업들이 많다
ex.) db 쿼리, 네트웍 작업 등
이렇게 결과를 얻는데 오래 걸리면 Runnable 보다 Callable 을 쓰는게 좋다.
Callable 은 핵심 메소드인 call 을 실행 시켜 결과 값을 돌려 받을 수 있고 Exception 도 발생시킬 수 있다.
Executor 에서 실행한 작업은 생성, 등록, 실행 종료 의 네가지 상태를 갖고
작업은 상당한 시간 동안 실행 될 수 있으므로 중간에 취소 할 수 있어야 하고
시작 되지 않은 작업은 취소 될 수 있어야 한다
Future 는 특정 작업이 완료됐는지, 취소됐는지에 대한 정보를 가지고 있다..
Future 는 한번 완료된 상태는 되돌릴수없다.

- 예제. Future 를 사용해 페이지 렌더링
앞서 본 HTML 렌더링 프로그램의 병렬성을 높여 동작하게
프로그램 내부에서 진행되는 작업을 둘로 나눠보자
첫번째 작업은 텍스트를 이미지로 그려내는 작업이고
두번째는 HTML 페이지에서 사용한 이미지 파일을 다운로드 받는 작업
Callable 과 Future 를 사용하면 HTML 페이지를 렌더링 하는 프로그램과 같이
여러 스레드가 서로 상대방을 살펴가며 동작하는 구조를 쉽게 설계할 수 있다.

먼저 이미지 다운 받는 기능의 Callable 을 만들어 ExecutorService 에 등록하고
Callable 이 등록되는 즉시 해당 작업에 대한 Future 인스턴스를 받을 수 있다.
메인작업이 실행되는 과정에서 이미지 파일을 표현해야 하는 시점이 오면
future.get 을 통해 이미지 파일을 확보한다.
get 은 메소드 진행 상태(시작되지 않은 상태, 시작한 상태, 완료 상태)에 따라
다르게 동작하는데 완료 상태라면 결과를 보여 주던가 Exception을 발생시키고
시작하지 않았거나 실행중이라면 완료 때까지 기다리고
작업 실행이 끝났는데 Exception이 발생하면 ExecutionException 예외를 던진다.
위 예제에서는 첫번째는 Exception이 발생하는 경우고
두번째는 결과 값을 얻기전에 메인스레드가 인터럽트 되는 경우다.
예제에서는 이미지 파일을 다운로드 하면서 텍스트 본문을 이미지로 그려 넣는다.
이렇게만 구성해도 사용자는 페이지가 훨씬 빠르게 그려진다고 느낀다.
개선할 점이 아직 있다. 예를들면 이미지를 전부 다운로드 받을때까지 기다린다는점이다.
여러개의 이미지 파일 가운데 다운로드 받는 순서대로 화면에서 보는게 좋다.
- 다양한 형태의 작업을 병렬로 처리하는 경우의 단점
스레드 A 와 B가 있을때 A 작업이 10배 정도의 시간이 걸리면
전체 실행 시간에 9% 정도만 이득이 생기는 것이다.
결과적으로 여러개의 스레드가 하나의 작업을 나눠 실행시킬때
작업 스레드 간에 필요한 내용을 조율해야 하는데 자원을 소모하게 된다.
따라서 병렬로 성능상 이득이 생기려면 해당 부하보다 훨씬 이득이 있어야 한다.
- CompletionService : Executor 와 BlockingQueue 의 연합
작업을 Executor에 등록해 실행시키고
각 작업에서 결과가 나오는 즉시 그 값을 사용하려면
각 작업 별로 Future 객체를 정리해두고 , get 을 호출해 결과가 나왔는지를 폴링으로 결과를 가져올 수 있다.
하지만 이건 깔끔한 방법은 아니다.
다행히 이걸 하기 위해 미리 만들어져 있는게 있다.
Completion service 다
Completion service는 Executor 의 기능과 BlockingQueue 의 기능을 하나로 모은 인터페이스다.
필요한 Callable 작업을 등록해 실행 할 수 있고, take 와 poll 과 같은 큐 메소드를 사용해
작업이 완료되는 순간 완료된 작업의 Future 를 받아 올 수 있다.
Completion service 를 구현한 클래스로는 ExecutorCompletionService 가 있다.
등록된작업은 Executor 를 통해 실행한다.

ExecutorCompletionService 에서는 메소드 완료된 결과 값을 쌓아둘 BlockingQueue 를 생성한다.
FutureTask 에는 done 메소드가 있는데 FutureTask 작업이 모두 완료되면
done 메소드가 한번씩 호출된다.
done 메소드는 BlockingQueue 에 추가하고 take 와 poll 메소드를 호출하면 스대로 BlockingQueue 의 해당 메소드로 넘겨 처리한다.
- CompletionService 를 활용한 페이지 렌더링

앞서 소개했던 HTML 을 두가지 측면에서 훨씬 개선 할 수 있다.
전체 실행 시간을 줄일 수 있고, 응답 속도도 높일 수 있다.
각각의 이미지 파일을 다운로드 받는 작업을 생성하고,
Executor를 활용해 다운로드 실행한다.
이렇게하면 다운로드 하던 부분을 병렬화 시키고
전부 다운로드 받는데 걸리는 전체 시간을 줄일 수 있다.
그리고 다운로드 받은 이미지는 CompletionService 를 통해 찾아가면 다운로드 받아지는 순간
이미지를 그릴것이다
- 작업 실행 시간 제한
일정 시간이 지나도 종료되지 않고 결과를 못 받았다면
더이상 작업의 의미가 없을 수 도 있다.
이럴때는 결과를 버릴 수 밖에 없다.
타임아웃을 지정할 수 있는 Future.get 메소드를 사용하면 이와 같은 시간 제한 요구사항을 만족할 수 있다.
어플리케이션을 작업이라는 단위로 구분해 실행할 수 있는 구조를 잡으면
개발 과정을 간소화 하고 병렬성을 확보해 병렬성을 높일 수 있다.
Executor를 사용하면 작업을 생성하는 부분과 실행하는 부분을 분리해 원하는 형태의
실행 정책을 쉽게 만들어 사용할 수 있다.
스레드를 직접 사용하기보단 Executor 를 사용해보자
어플리케이션이 하는일을 개별 작업으로 구분해 처리 할 때는 작업의 범위를 적절하게 잡아야 한다.
웬만한 어플리케이션은 일반적으로 작업 범위는 잘 적용하지만 ,
일부 어플리케이션에서는 병렬로 처리해 이득을 보려면 분석을 통해 병렬로 처리할 작업을 찾아내야 할 수도 있다.
'Java > 병렬 프로그래밍' 카테고리의 다른 글
자바 병렬 프로그래밍 - 스레드 풀 활용 (0) | 2021.01.11 |
---|---|
자바 병렬 프로그래밍 - 중단 및 종료 (0) | 2021.01.08 |
자바 병렬 프로그래밍 - 구성 단위 (0) | 2021.01.05 |
자바 병렬 프로그래밍 - 객체 구성 (0) | 2021.01.04 |
자바 병렬 프로그래밍 - 객체 공유 (0) | 2020.12.19 |