(책의 기준으로)최근 소프트웨어 개발 방법을 획기적으로 뒤집는 두 가지 추세
-
애플리케이션을 실행하는 하드웨어 관련
멀티코어 프로세서가 발전하면 애플리케이션의 속도는 멀티코어 프로세서를 얼마다 잘 활용할 수 있도록 소프트웨어를 개발하는가에 달라질 수 있다.
-
애플리케이션을 어떻게 구성하는가 관련
마이크로서비스 아키텍처 선택이 증가하게 되며 독립적으로만 동작하는 웹사이트가 아닌 다양한 소스의 콘텐츠를 가져와 합치는 메시업 형태를 띠게 된다. 이를 위해 여러 웹 서비스에 접근해야 하는 동시에 서비스의 응답을 기다리는 동안 연산이 블록 되거나 귀중한 CPU 클록 사이클 자원을 낭비하지 않아야 한다. 특히 스레드를 블록함으로 연산 자원을 낭비하는 일은 피해야 한다.
이를 위해 자바 8에서는 Future인터페이스를 구현한 CompletableFuture 클래스를 제공하며, 자바 9에서는 발행 구독 프로코톨에 기반한 리액티브 프로그래밍 개념을 따르는 플로 API를 제공한다.
동시성을 구현하는 자바 지원의 진화
- 초기 자바 : Runnable과 Thread
- 자바 5 : 스레드 실행과 태스트 제출을 분리하기 위한 ExecutorService, Runnable과 Thread의 변형을 반환하는 Callable<T>과 Future<T>, 제네릭
- 자바 7 : 분할 정복 알고리즘의 포크/조인 구현을 지원하는 java.util.concurrent.RecursiveTask
- 자바 8 : 스트림과 새로 추가된 람다 지원에 기반한 병렬 프로세싱. Future를 조합하는 기능을 추가하며 동시성을 강화한 CompletableFuture
- 자바 9 : 분산 비동기 프로그래밍을 지원. 리액티브 프로그래밍을 위한 Flow 인터페이스 추가
스레드와 높은 수준의 추상화
직접 Thread를 사용하지 않고 스트림을 이용해 스레드 사용 패턴을 추상화할 수 있다. 스트림으로 추상화하는것은 디자인 패턴을 적용하는 것과 비슷하지만 대신 쓸모없는 코드가 라이브러리 내부로 구현되면서 복잡성도 줄어든다는 장점이 더해진다.
Executor와 스레드 풀
자바 5는 Executor 프레임워크와 스레드 풀을 통해 자바 프로그래머가 태스크 제출과 실행을 분리할 수 있는 기능을 제공했다.
스레드의 문제
자바 스레드는 직접 운영체제 스레드에 접근한다. 운영체제 스레드를 만들고 종료하려면 비싼 비용을 치러야 하며 운영체제의 스레드 숫자는 제한되어있다. 운영체제가 지원하는 스레드 수를 초과해 사용하면 자바 애플리케이션이 예상치 못한 방식으로 크래시 될 수 있으므로 기존 스레드가 실행되는 상태에서 계속 새로운 스레드를 만드는 상황이 일어나지 않도록 주의해야 한다.
보통 운영체제와 자바의 스레드 수가 하드웨어 스레드 개수보다 많다. 일부 운영체제 스레드가 블록 되거나 자고 있는 상황에서 모든 하드웨어 스레드가 코드를 실행하도록 할당된 상황에 놓일 수 있다. 프로그램에서 사용할 최적의 자바 스레드 개수는 사용할 수 있는 하드웨어 코어의 개수에 따라 달라진다.
스레드 풀 그리고 스레드 풀이 더 좋은 이유
스레드 풀은 일정한 수의 워커 스레드를 가지고 있다. 스레드 풀에서 사용하지 않은 스레드로 제출된 태스크를 먼저 온 순서대로 실행한다. 이들 태스크 실행이 종료되면 스레드 풀로 반환한다. 이 방식의 장점은 하드웨어에 맞는 수의 태스크를 유지함과 동시에 수 천 개의 태스크를 스레드 풀에 아무 오버헤드 없이 제출할 수 있다는 점이다.
스레드 풀 그리고 스레드 풀이 나쁜 이유
거의 모든 관점에서 스레드를 직접 사용하는 것보다 스레드 풀을 이용하는 것이 바람직 하지만 두 가지 사항을 주의해야 한다.
- k 스레드를 가진 스레드 풀은 오직 k만큼의 스레드를 동시에 실행할 수 있다. 이때 잠을 자거나 I/O를 기다리거나 네트워크 연결을 기다리는 태스크가 있다면 주의해야 한다. 이런 상황에서 스레드는 블록 되며, 블록 상황에서 태스크가 워커 스레드에 할당된 상태를 유지하지만 아무 작업도 하지 않게 된다. 핵심은 블록 할 수 있는 태스크는 스레드 풀에 제출하지 말아야 한다는 것이지만 항상 이를 지킬 수 있는 것은 아니다
- 프로그램을 종료하기 전에 모든 스레드 풀을 종료하자. 자바는 이런 상황을 위해 Thread.setDaemon 메서드를 제공한다.
스레드의 다른 추상화
엄격한 포크/조인 방식이 아닌 비동기 메서드로 여유로운 포크/조인을 사용할 수 있다.
- 엄격한 포크/조인 : 스레드 생성과 join()이 한 쌍처럼 중첩된 메서드 호출 방식
- 여유로운 포크/조인 : 시작된 태스크를 내부 호출이 아니라 외부 호출에서 종료하도록 기다리는 방식
- 스레드 실행은 메서드를 호출한 다음의 코드와 동시에 실행되므로 데이터 경쟁 문제를 일으키지 않도록 주의해야 함
- 기존 실행 중이던 스레드가 종료되지 않은 상황에서 자바의 main() 메서드가 반환할 때 스레드의 행동
동기 API와 비동기 API
비동기 적용 방식
- Future 형식 API : 자바 5에서 소개된 Future를 이용한다. 일회성 값을 처리하는데 적합하다.
- 리액티브 형식 API : 콜백 형식으로 일련의 값을 처리하는데 적합하다.
잠자기(그리고 기타 블로킹 동작)는 해로운 것으로 간주
스레드는 잠들어도 여전히 시스템 자원을 점유한다. 스레드 풀에서 잠을 자는 태스크는 다른 태스크가 시작되지 못하게 막으므로 자원을 소비한다. 블록 동작도 이와 마찬가지다. 이런 상황을 방지하는 방법은 이상적으로 절대 태스크에서 기다리는 일을 만들지 않는 것과 코드에서 예외를 일으키는 방식이 존재한다.
비동기 API의 예외 처리 방법
Future나 리액티브 형식의 비동기 API에서 호출된 메서드의 실제 바디는 별도의 스레드에서 호출되며 이때 발생하는 어떤 에러는 이미 호출자의 실행 범위와는 관계가 없는 상황이 된다.
자바 9 플로 API에서는 Subscriber<T>클래스를 이용하며, 그렇지 않은 경우 예외가 발생했을 때 실행될 추가 콜백을 만들어 인터페이스를 구현해야 한다.
박스와 채널 모델
박스와 채널 모델(box-and-channel model)은 동시성 모델을 설계하고 개념화하기 위한 모델을 말한다.
int t = p(x) System.out.println( r(q1(t), q2(t)) );
박스와 채널 모델을 이용하면 생각과 코드를 구조화할 수 있다. 손으로 코딩한 결과보다 박스로 원하는 연산을 표현하면 더 효율적으로 시스템 구현의 추상화 수준을 높일 수 있다. 또한 박스와 채널 모델은 병렬성을 직접 프로그래밍하는 관점을 콤비네이터를 이용해 내부적으로 작업을 처리하는 관점으로 바꿔준다.
CompletableFuture와 콤비네이터를 이용한 동시성
Future는 실행해서 get()으로 결과를 얻을 수 있는 Callable로 만들어진다. 하지만 CompletableFuture는 실행할 코드 없이 Future를 만들 수 있도록 허용하며 complete() 메서드를 이용해 나중에 어떤 값을 이용해 다른 스레드가 이를 완료할 수 있고 get()으로 값을 얻을 수 있도록 허용한다.
콤비네이터를 이용한다면 get()에서 블록하지 않을 수 있고 그렇게 함으로 병렬 실행의 효율성은 높이고 데드락은 피할 수 있다.
발행-구독 그리고 리액티브 프로그래밍
리액티브 프로그래밍은 Future 같은 객체를 통해 한 번의 결과가 아니라 여러 번의 결과를 제공하는 모델이다. 또한 가장 최근의 결과에 대하여 반응(react)하는 부분이 존재한다.
자바 9에서는 java.util.concurrent.Flow의 인터페이스에 발행-구독 모델을 적용해 리액티브 프로그래밍을 제공한다.
자바 9 플로 API는 다음과 같이 세 가지로 정리할 수 있다.
- 구독자(Subscriber)가 구독할 수 있는 발행자(Publisher)
- 연결을 구독(subscription)이라한다.
- 이 연결을 이용해 메시지(또는 이벤트)를 전송한다.
발행-구독 모델에서의 컨테이너
- 여러 컴포넌트가 한 구독자로 구독할 수 있다.
- 한 컴포넌트는 여러 개별 스트림을 발행할 수 있다.
- 컴포넌트는 여러 구독자에 가입할 수 있다.
Publisher
Publisher는 발행자이며 subscribe를 통해 구독자를 등록한다.
public interface Publisher<T> {
public void subscribe(Subscriber<? super T> s);
}
Subscriber
Subscriber는 구독자이며, onNext()라는 정보를 전달할 단순 메서드를 포함하며, 구현자가 필요한대로 이 메서드를 구현할 수 있다.
public interface Subscriber<T> {
public void onNext(T t);
public void onError(Throwable t);
public void onComplete();
public void onSubscribe(Subscription s);
}
업스트림과 다운스트림
데이터가 발행자(생산자)에서 구독자(소비자)로 흐름에 착안하여 이를 업스트림(upstream) 또는 다운스트림(downstream)이라 부른다.
역압력
발행자가 매운 빠른 속도로 이벤트를 발행한다면 구독자가 아무 문제없이 처리할 수 있을까? 에서 역압력의 개념이 출발된다. 발행자가 구독자의 onNext를 호출하여 이벤트를 전달하는 것을 압력이라 부른다. 이런 상황에서는 수신할 이벤트의 숫자를 제한하는 역압력기법이 필요한다. 자바 9 플로 API에서는 발행자가 무한의 속도고 아이템을 방출하는 대신 요청했을 때만 다음 아이템을 보내도록 하는 request() 메서드를 제공한다.
Publisher와 Subscriber 사이에 채널이 연결되면 첫 이벤트로 Subscriber.onSubscribe(Subscription subscription)메서드가 호출된다. Subscription 객체는 다음처럼 Subscriber와 Publisher와 통신할 수 있는 메서드를 포함한다.
public interface Subscription {
void request(long n);
public void cancel();
}
Publisher는 Subscription 객체를 만들어 Subscriber로 전달하면 Subscriber는 이를 이용해 Publisher로 정보를 보낼 수 있다.
실제 역압력의 간단한 형태
한 번에 한 개의 이벤트를 처리하도록 발행-구독 연결을 구성하기 위해 다음과 같은 작업이 필요하다
- Subscriber가 OnSubscribe로 전달된 Subscription 객체를 필드로 저장.
- Subscriber가 수많은 이벤트를 받지 않도록 onSubscribe, onNext, onError의 마지막 동작에 channel.request(1)을 추가해 오직 한 이벤트만 요청한다.
- 요청을 보낸 채널에만 onNext, onError 이벤트를 보내도록 Publisher의 notifyAllSubscribers 코드를 바꾼다.
- 보통 여러 Subscriber가 자신만의 속도를 유지할 수 있도록 Publisher는 새 Subscription을 만들어 각 Subscriber와 연결한다.
역압력을 구현하려만 여러가지 장단점을 생각해야 한다.
- 여러 Subscriber가 있을 때 이벤트를 가장 느린 속도로 보낼 것인가? 아니면 각 Subscriber에게 보내지 않은 데이터를 저장할 별도의 큐를 가질 것인가?
- 큐가 너무 커지면 어떻게 해야할까?
- Subscriber가 준비가 안 되었다면 큐의 데이터를 폐기할 것인가?
이런 결정은 데이터의 성격에 따라 달라진다.
리액티브 시스템 vs 리액티브 프로그래밍
리액티브 시스템
- 런타입 환경이 변화에 대응하도록 전체 아키텍처가 설계된 프로그램.
- 반응성(responsive), 회복성(resilient), 탄력성(elastic)으로 세 가지 속성을 가진다.
- 반응성은 리액티브 시스템이 큰 작업을 처리하느라 간단한 질의의 응답을 지연하지 않고 실시간으로 입력에 반응하는 것이다.
- 회복성은 한 컴포넌트의 실패로 전체 시스템이 실패하지 않음을 의미한다.
- 탄력성은 시스템이 잣긴의 작업 부하에 맞게 적응하며 작업을 효율적으로 처리함을 의미
리액티브 프로그래밍
- 리액티브 시스템이 가지는 속성을 구현하기 위한 프로그래밍 형식을 의미한다.
- java.util.concurrent.Flow 관련된 자바 인터페이스에서 제공하는 리액티브 프로그래밍 형식.
- 이들 인터페이스 설계는 메시지 주도(message-driven) 속성을 반영한다.
정리하자면 리액티브 시스템은 전체적인 리액티브 환경 아키텍처를 의미하며 리액티브 프로그래밍은 리액티브 환경을 위해 사용하는 프로그래밍 기법이다. 리액티브 프로그래밍을 이용해 리액티브 시스템을 구현할 수 있다.