자바 동시성 1 - 역사

Pull Request (opens in a new tab)

1. 기술의 발전과 App간 상호작용의 증가..

멀티 코어 프로세서가 발전하면서, 애플리케이션의 속도는 개발자가 얼마나 멀티 코어 프로세서를 잘 다루냐에 따라 크게 달라지게 되었다.
자바 7의 포크/조인 프레임워크와 자바 8의 병렬 스트림으로 인해 저수준인 스레드는 저리 치워 버리고, 단순하고 효과적으로 병렬을 달성할 수 있었다.

시간이 흐르면서 멀티 코어 프로세서만 발전한게 아니다. 인터넷, 모바일 서비스에서 사용되는 어플리케이션이 증가했다. 뭐 서비스를 사용하는 사람이 늘었냐? 그런 이야기 보다는, MSA 선택이 증가했다는 것을 이야기 하고 싶은 것이다.
이제 하나의 뚱뚱한 App이 아닌, 하나의 서비스임에도 작은 여러 App을 사용하는 서비스가 많아졌다. 서비스는 작아진 대신, 어플리케이션과 네트워크 통신은 많아졌다.
또한, 많은 기업들이 공개 API를 제공하면서, App끼리의 상호작용 또한 증가했다. 앞으로는 순수하게 자신의 소스로만 만들어진 서비스는 별로 남지 않을 것이고, 여러 서비스들이 제공해주는 기능들이 Mash Up된 서비스들이 주류를 차지할 것이다.

그래서 뻔한 얘기, 뭐가 문제냐?
바로 다른 서비스와의 소통 과정에서 응답을 기다리며 연산이 Blocking 되거나, CPU 클록 사이클이 낭비된다는 것이 문제이다.
에를 들어 우리 서비스 반디부디 (opens in a new tab)에서는 OAuth2를 통한 다양한 방식의 소셜 로그인을 제공해준다. 예를 들어서 애플 서버에 수상한 일이 생겨버려서 애플 로그인 요청에 대한 응답이 돌아오지 않는다고 해보자. TimeOut 설정을 짧게 잡지 않았더라면, 그동안 귀중한 스레드 하나가 볼모로 잡혀 있게 된다. 어휴 아까워.

2. 동시성과 자바의 역사 (~ java 9까지)

당신의 고민을 해결하기 위해 자바는 동시성과 관련된 두 가지 주요 도구를 제공해준다.

  1. 자바의 미래 - Future 인터페이스
  2. 자바 9 Flow API

이 책에서는 2가지만 언급하고 있지만, Java 19에 인큐베이터로 도입된 JDK Structured Concurrency (opens in a new tab)도 있다. Kotlin에서는 코루틴으로 동시성을 구조화 하는듯?하다.

(병렬성은 앞서 언급한 포크/조인, 병렬 스트림!)



2.1 빛이 있으라

자바의 동시성 프로그래밍에 대한 지원은 지난 20년간 시대의 변화와 기술의 발전에 맞춰 진화해왔다.
처음 자바는 Runnable과 Thread를 직접적으로 사용했다. Thread 객체는 현재 스레드 정보를 확인하고, 재우는(sleep) 등 스레드 자체를 직접 다룰 수 있게 해주었다.

너무 저수준! 단점이 많을 수 밖에 없다. Java 5에선 좀 더 표현력 있는 동시성을 위한 ExcutorService 인터페이스가 등장했다.
ExcutorService 인터페이스는 스레드의 실행과 테스크의 제출을 분리하였다.
개발자는 Runnable, Callable 작업을 등록할 수 있고, Service가 실행해준다. ExcutorService는 Executor를 상속 받아서 Runnable을 실행할 수 있는 것이다.

ExecutorService는 앞서 말한 Runnable이나 Thread 보다 높은 수준의 결과를 돌려주는데, 이들을 변형한 Callable, Future 등을 제공했다. 이러한 기능들 덕분에 Java 5가 등장한 2004년의 다음 해인 2005년도에 등장한 멀티 코어 CPU에서 쉽게 병렬 프로그래밍을 구현할 수 있었다.

대표적인 구현체로 ThreadPoolExecutor가 있는데, ThreadPoolExecutor 내부에 있는 Blocking Queue에 작업들을 등록해둔다. 이후 쓰레드 풀이 작업 수행을 완료하면, Blocking Queue에서 작업을 가져와 다음 작업을 수행한다. 더욱 자세한 설명 - 망나니 개발자 (opens in a new tab)

image



이후, 멀티코어 CPU에 대한 니즈가 커졌고, 자바는 그 니즈에 맞게 진화해왔다.
Java 7에서는 분할 정복 알고리즘을 활용한 포크/조인을 지원하는 RecursiveTask가 추가되었다. 작업을 분할하여 여러 병렬적으로 작업을 수행하고 작업을 합칠 수 있게 도와준다. Java 8에서는 병렬 스트림이 등장했다. 병렬 스트림은 메서드 하나 호출하는 것 만으로도 작업을 병렬로 수행하게 해준다.

너무 꿀인데요? -> 갈!!
자바 공부를 꽤 해본 사람은 알겠지만, 병렬 스트림은 막 쓰면 안된다. 조슈아 블로크 가라사대. Item 48. 스트림 병렬화는 주의해서 적용하라 (opens in a new tab)

2.2 어두운 미래와 밝은 내일

앞에서 설명하지 않은 Future 클래스는 비동기 작업(Callable)을 "들고" 있는 클래스이다. Future는 작업 결과를 Blocking하게 받아올 수도 있고, 작업이 완료되었는지 확인하거나, 취소할 수도 있다.
get() 메서드를 호출하면 응답을 기다려서 결과를 받아올 수 있다. 이러한 Future는 아주 많은 단점을 가지고 있는데 (어두운 미래 ㅠ)

  1. 외부에서 작업을 완료시킬 수 없다.
  2. 작업 완료는 get 호출로 할 수 있는데, Blocking하게 결과를 기다린다.
  3. 추가 작업을 하려면 그걸 다 기다리고 있어야 한다.
  4. 여러 Future를 조합할 수 없다.
  5. 예외 처리가 어렵다.

이 모든 단점을 해결한게?? CompletableFutre이다.
자바는 CompletableFutre를 통해 Future를 조합하는 기능을 제공하고, 위의 단점들을 모두! 싹! 해결해버렸다.

그것이 Java 8에 등장한 CompletableFutre이다. 이제 밝은 내일을 기대해도 좋을 것이다.

2.3 리액티브 프로그래밍

앞서 이야기했던 여러 웹 서비스들의 조합 이야기를 기억하는가? 다양한 웹 서비스를 이용하고, 이들이 제공해주는 정보들을 "실시간으로" 조합하는 프로그래밍을 리액티브 프로그래밍이라고 부른다.
자바 9에서는 밸행-구독 프로토콜 Flow 인터페이스로 이를 지원한다. CompletableFutre와 Flow의 궁극적인 목표는 동시에 가능한 많은 작업을 실행하는 것이다. 최대한 태스크들을 독립적으로 만들고, 멀티 코어 또는 여러 기술의 발전이 제공해주는! 병렬성을 쉽게 활용하는 것이 그 목표이다.
참고로 1도 모르지만 스프링에서도 WebFlux 라는 기술이 이런 리액티브 프로그래밍을 위해 사용된다. (opens in a new tab) 결국 내부적으로는 자바 기술을 활용할 것이 뻔하니, 자바 동시성 프로그래밍을 먼저 정복하는 것이 내 목표이다.


3. Box-And-Channel Model

동시성 모델을 개념화 하기 위한 모델로 박스와 채널 모델이 있다.

image


위 그림은 함수의 호출을 표현한 그림으로, 호출은 아래의 순서로 진행된다.

  1. p라는 함수에 인수 x가 들어갔다.
  2. 결과를 q1과 q2에 전달한다.
  3. q1과 q2의 결과로 r을 호출한다
  4. r의 결과를 출력한다

이를 원시인처럼 구현한다면, 아래와 같이 심플하게 구현할 수 있다.

int pResult = p(x);
 
int q1Result = q1(result1); 
int q2Result = q2(result1);
 
int rResult = r(q1Result, q2Result);
 
System.out.println(rResult);

우가우가!
현대 문명과는 거리가 먼 코드이다. 하드웨어의 병렬성이 없던 시절에는 좋은 코드였을지도 모른다. 하지만, 요즘 같이 좋은 시대에 병렬성을 버리는 것은 좋은 선택이 아니다.

Futre을 통해 개선해보자

int pResult = p(x);
 
Future<Integer> q1Result = executorService.submit(() -> q1(result1)); 
Future<Integer> q2Result = executorService.submit(() -> q2(result1)); 
 
int rResult = r(q1Result.get(), q2Result.get());
System.out.println(rResult);

음 조금 더 낫다. q1, q2가 병렬로 평가 되었다.
Q. p와 q는 왜 Future로 감싸지 않았나요? -> 갈!!
p는 모든 작업들 보다 우선시 되어야 하고, r은 모든 작업들이 끝난 후에 호출되어야 한다. 병렬성 보다는 작업 순서가 중요했다.

3.1 더 바빠지면 문제가 된다.

위 코드는 시스템이 바쁘지 않다면 잘 동작할 수도 있다.
하지만, 시스템이 커지고, 수많은 박스와 채널 다이어그램이 등장하고, 박스들은 내부적으로 또 박스와 채널을 사용하고 있다면 문제가 달라진다.

결국 Future가 Blocking이라서 문제! 이런 상황에선 많은 Task들이 get() 지옥에서 기다리는 상태에 놓이게 될 수도 있고, 하드웨어의 병렬성을 잘 활용하지 못하거나 Deadlock에 걸릴 수도 있다.
또한, 측정하기 어렵다. 얼마나 많은 get()을 감당할 수 있는지, 얼마나 많은 동시 작업이 가능한지 측정하기가 쉽지 않다.

이러한 문제를 CompletableFutre와 Combinator를 통해 해결할 수 있다!

그 방식은 다음 글인 자바 동시성 2에서 설명하겠다.

3.2 박스 채널 모델의 의의

그래서 박스 채널 모델은 왜 보여주었나..
박스 채널 모델은 병렬 진행 코드와 생각의 흐름을 그림으로 구조화 해줄 수 있는데에 의의가 있다!
병렬성이나 동시성은 머리로 상상하기 쉬운 개념이 아니다. 우리는 박스-채널 모델을 통해 쉽게 병렬성 코드를 그려볼 수 있다.
대규모 시스템 구현의 추상화 수준을 높일 수 있고, 박스로 원하는 연산을 표현하면, 손으로 계산하는 것 보다 낫다.
결국엔 우리는 코드를 짜야 한다. 이런 추상화를 통해 쉽게 이해함으로써, 나중에 알아볼 다양한 동시성 도구들로 코드를 짤 수 있는 것이다.