CompletableFuture
현재 소프트웨어 개발 방법에서 두가지 유행이 있다.
- 멀티코어 프로세서의 등장 –> 병렬실행
- 독립 동작이 아닌 컨텐츠 제공 매시업 형태의 어플리케이션 대두 –> MSA
동시성 : 1 core multi thread
병렬성 : multi core multi thread
동시성 & 병렬성
동시성 & 병렬성을 만족하며 어플리케이션의 응답속도를 최대한 빠르게 구현할 수 있는지에 대한 것은 매우 중요하다.
모던 자바에서는 이 동시성&병렬성을 쉽게 구현하기 위해 CompletableFuture 라는 인터페이스를 추가하였다.
- 기존엔 Runnable, Thread를 사용하여 동기화를 시켜 로직을 수행하였다.
- 멀티코어 CPU가 많이 상용화 되면서 자바7은 포크/조인 프레임워크가 생겼고, 자바8에는 병렬 스트림이 추가되었다.
- 자바9에서는 비로소 분산 비동기 프로그래밍을 명시적으로 지원한다
- 발행(publish)-구독(subscribe) 프로토콜(pub&sub이라고도 함.) –> 리액티브 프로그래밍임
멀티스레드 프로그래밍의 추상화
0 ~ 1,000,000을 더하는 코드를 작성하는 코드를 아무런 추상화패턴 라이브러리를 사용하지 않으면
for (int i = 0 ; i < 250000; i++) {
sum0 += stats[i];
} // thread 0
for (int i = 250000 ; i < 500000; i++) {
sum1 += stats[i];
} // thread 1
for (int i = 500000 ; i < 750000; i++) {
sum2 += stats[i];
} // thread 2
for (int i = 750000 ; i < 1000000; i++) {
sum3 += stats[i];
} // thread 3
for (int i = 0 ; i < thread.size(); i++) {
thread[i].join();
}
result = sum0+sum1+sum2+sum3;
이와 비슷한 매우 추접추접한 코드가 작성될 것이다. 에러 발생비율이 높아지는건 덤..
하지만, 높은 수준으로 추상화된 라이브러리를 이용하면
sum = Arrays.stream(stats).parallel().sum();
요런 식으로 코드를 어어어어어어엄청 간결하게 할 수 있다.
하지만 이것도 코드상으로는 간단해 보이지만, 병렬 스트림에서는(기억이 잘 나지 않지만 ㅋ) 이를 제대로 활용하기 위해서 엄청난 제약조건이 많았고, 오히려 sequential로 처리할 떄보다 더 느린 케이스도 엄청 많았다… 결국 ‘잘 알고 사용해야 하는’ 건 변함이 없음..
이렇게 추상화를 해도
스레드를 어플리케이션 코드에서 ‘직접’ 만지는 것은 여러모로 위험하다… 위험해….
- OS가 지원하는 스레드 개수를 넘는 경우에 예상치 못한 상황 발생
- Race Condition 뭐 그런거..
- 동일한 소스인데 하드웨어 spec에 따라서도 성능이 결정됨
- 아무래도…. 스레드를 ‘직접’ 관리한다는 것 자체가 버그(장애)의 가능성이 높을 수 밖에 없음.
암튼 스레드를 직접 이용하는건 다양한 단점이 존재한다.
그래서 생긴 개념이 스레드 풀.
사용할 수 있는 워커 스레드를 적절히(직접이 아니라 라이브러리/API등이 관리하는 개념) 만들고, 필요할때마다 어플리케이션이 스레드풀에서 놀고 있는 스레드를 하나 꺼내와서 사용하는 개념…
이렇게 스레드 풀을 써도
모든 문제가 해결되는 것은 아니다.
- 스레드 풀을 사용하는 어플리케이션의 로직이 I/O wait이 걸리는, (CPU를 사용하지 않는) 로직이고, 수행해야 할 태스크 수가 스레드 풀의 수보다 많은 경우면 오히려 성능저하가 생길 수 있다.

어쨌든
스레드를 직접 이용하거나 스레드풀을 이용하지 않고, 동시성과 병렬성을 지원하는 API 에 대해 공부해 본다.
동기 API와 비동기 API
int값을 리턴하는 특정 메소드 f(x)와 g(x)의 값을 합치는 예제를 살펴보자.
int y = f(x); int z = g(x); result = y+z;
f와 g가 오래 걸리는 작업이라고 가정하자.
그럼 저 단순한 로직을 single-thread로 수행하면 result값은 f수행시간 + g수행시간 이 걸린다.
너무 오래 걸린다 && f 와 g가 서로 의존성이 없는 로직 이라면, 두개를 별도의 스레드로 병렬로 수행하면 된다.

뭐….보다시피 간단한 로직은 아니다.ㅋㅋ
그래서 Runnable 대신 스레드 풀을 이용하면 조금 더 간단하게 작성할 수 있다.

여전히 newFixedThreadPool로 스레드풀을 만들고, 또 start&join만 없다뿐이지 ExecutorService.submit을 통해 명시적으로 메소드 호출을 해야 하는 코드의 더러움은 계속 유지되고 있다.
이 더러움을 해결하기 위한 대표적인 두가지 방법은 다음과 같다
- Future형식 API
- 리액티브 형식 API
Future형식 API
Future<Integer> f(int x); Future<Integer> g(int x); Future<Integer> y = f(x); Future<Integer> z = g(x); result = y.get() + z.get();
4번째 줄을 실행하는 즉시, f메소드 바디를 평가하는 태스크가 포함된 Future가 리턴된다. 그리고 6번째 라인 수행을 통해서야 두 Future가 완료될때까지 기다린다.
리액티브 형식 API
f, g 메소드 자체를 바꿔서, 콜백을 이용하는 것이다.

근데 위 코드대로 그냥 콜백만 추가한다면 여러 잠재적인 문제가 발생되기는 한다
- 상황에 따라 먼저 계산된 결과가 출력됨
- 락을 사용하지 않으므로 한번이 아닌 두번의 출력문이 나올 수 있음
이런 경우는
- if – else 구문을 이용해 적절하게 락을 사용한다 (근데 이럴거면 Runnable이랑 다를게 없…)
- 리액티브는 사실 병렬성에 중점을 둔 API가 아니라, event-driven방식에 최적화된 API라서… 굳이 이 악물고 리액티브 API를 이용하지 말고 Future를 이용하는것이 더 좋음.
Blocking은 금물
속도제한을 하기 위해, 혹은 어떤 상호작용을 하기 위해 sleep()을 하는것은 반드시 그래야 하는 경우 아니면 쓰지 말자.
착각하기 쉬운게, sleep은 yield가 아니라 block 상태에 진입한다..
그래서 얼마간의 시간동안 특정 스레드를 멈추게 하고 싶으면, 단순히 sleep을 하지 말고 이런 식으로 yield가 들어가는 코드를 작성해야 할 것이다.
첫째,

둘째,

둘 다 동일한 동작이 수행되지만,sleep하는 순간 제 3의 스레드 내지는 제 3의 어플리케이션이 CPU자원을 점유하여 사용할 여지가 있는지 없는지에 대한 중요한 차이가 있다
- 첫번째코드 : work1 ~ work2가 다 수행될때까지 CPU 100%
- 두번째코드 : work1가 수행되고 10초동안 CPU 0%. work2가 수행되면서 종료
항상 코드를 작성할떄마다 이 점 잘 인지하고, 좀 더 복잡하더라고 두번째 코드처럼 개발을 하는 습관을 들이자.