동기형 프로그램과 비동기형, 그리고 반응형 프로그램에 대해서
다시금 프로그램의 이론에 대해서 정리하고 포스트를 남겨본다. 프로그램의 구조적인 방법론(FP/OOP)와 프로그램의 스타일(명령형/선언형)에 이어 이번에는 프로그램의 실행 방법에 대한 비교 및 정리를 해보려 한다. 바로 동기형 vs 비동기형 프로그램이다. 한발 더 나아가 지금은 많이 사용하고 있는 반응형 프로그램에 대해서도 같이 정리해보려 한다.
동기형 프로그램 (Synchronous Program)
하나의 작업이 시작되면 그 작업이 끝날 때 까지 다른 작업을 처리하지 않는다.
작업이 호출되는 순간 처리가 시작되며, 모든 처리는 순차적으로 진행되며, caller는 함수를 호출하는 시점부터 종료 때 까지 호출한 함수의 작업이 끝날 때 까지 대기한다. 이를 blocking이라고 한다.
caller - (call) -> callee
|
(process)
|
caller <- (return) --┘
아래와 같은 동기형 프로그램을 보자
function a() {
console.log(`print A-1`);
b();
console.log(`print A-2`);
}
function b() {
console.log(`print B`);
}
a()
당연하게도 출력 순서는 아래와 같다.
print A-1
print B
print A-2
실행 순서가 완벽하게 정해져 있으며, 눈으로 따라가며 흐름을 파악하기도 매우 쉽다. 직관성이 매우 높기 때문에 분석, 디버깅, 동작과 실행 결과를 예측하는 것도 매우 쉽다.
비동기형 프로그램 (Asynchronous Program)
하나의 작업이 다른 작업을 실행할 때 자신이 아닌 다른 실행자에게 위임하고 자신은 자신의 작업을 계속하는 프로그램을 의미한다.
caller는 함수를 호출 하고 나서 처리 결과를 기다리지 않고 다시 자신의 작업을 실행한다. 이것을 non-blocking이라고 한다. 또한 실행했던 작업의 결과는 콜백 형태로 나중에 처리하게 된다. 물론 이 콜백을 처리하지 않아도 호출된 프로그램은 마지막까지 잘 처리된다.
caller - (call) -> callee
| |
(process) (process)
| |
| (callback) -> (void / caller)
(process)
|
(terminate)
예를 들어 아래와 같은 비동기 프로그램을 작성했다고 하자.
function a() {
console.log(`print A-1`);
setTimeout(b, 0);
console.log(`print A-2`);
}
function b() {
console.log(`print B`);
}
a()
여기서 보장되는건 print A-1이 가장 먼저 출력된다는 것이다. 그리고 두 번째로 찍힐 문자열은 실행되는 환경과 타이밍에 따라 다르다. 비동기로 실행되는 경우 두 개의 프로그램이 보통 병렬적으로 처리될 수 있다. 때문에 컴퓨터의 처리에 따라 print A-2가 먼저 나올 수도 있고, print B가 먼저 나올 수 있다.
하지만 본 예제는 NodeJS로 작성되었고, NodeJS의 비동기 프로그램은 아래와 같이 동작하므로, 결과를 예상할 수 있다.
a()함수가 이벤트루프의 큐에 등록된다.- 이벤트루프가 큐에 등록된 함수
a()를 꺼내 처리를 시작한다. a()함수가 실행되는 중에b()함수를 이벤트루프의 큐에 등록된다.a()함수의 마지막 라인까지 실행된 후 루프 1회가 종료된다.- 새 루프가 시작되면 현재 큐에 등록되어 있는 함수를 불러와 처리한다.
b()가 등록되었으므로 이어서b()의 내용을 처리한다.
따라서 출력 결과는 아래와 같다
print A-1
print A-2
print B
여기서 중요한 점은 a() 함수가 실행될 때 b()함수를 실행하지 않고 예약만 한다는 점과, a() 함수에서 b() 함수를 호출함에도 b() 함수의 처리 결과를 기다리지 않고 a() 함수의 흐름을 중단시키지 않고 끝까지 실행한다는 점이다.
이는 비동기 프로그램은 요청과 실행 사이가 완전히 분리되어 있다는 점이며, 이로 인하여 실행 순서가 완전히 달라질 수 있다는 점이다. 각 포인트마다 실행 순서와 결과가 다르기에 분석 및 디버깅도 동기형 프로그램에 비해 난도가 높다.
OOP에서 비동기 프로그램이 어려운 이유
- OOP는 객체의 상태에 집중하며 객체에 정의된 상태에 따른 동작을 프로그램의 흐름을 작성한다.
- 비동기 프로그램은 프로그램이 흐르는 순서를 예상하기가 어렵다. 잠깐의 시간 차이만으로도 객체의 상태가 변하기 때문에 같은 실행에 대해 같은 결과를 보장할 수가 없다. 동작이 실행되는 시점에 객체의 상태가 일관되지 않기 때문이다.
- 이러한 이유로 비동기 프로그램을 구현할 때 OOP의 상태에 의존하여 프로그램을 작성할 수 없다. 프로그램의 실행을 프로그램의 실행 흐름 자체를 제어할 수 있는 별도의 상태를 새로이 정의해야 한다.
- 하나의 흐름을 위해 하나의 상태 제어가 필요하며, 이 새로은 객체를 잘 관리해야 한다.
- 문제는 이러한 흐름이 하나가 아니라는 점이며, 상태가 계속 추가 될 때 마다 상태를 제어하는 부분이 점점 커지며 프로그램은 기하급수적으로 복잡해진다.
반응형 프로그램 (Reactive Program)
반응형 프로그램은 비동기 프로그램의 한 가지로서, 지속적으로 발생하는 데이터 스트림을 어떻게 비동기적으로 처리할지를 다룬다. 여기에 3가지 주요한 관점을 이해해야 한다.
- 데이터 스트림
- publisher와 subscriber에 의한 데이터의 생성과 소비, 그리고 그로 인한 역할의 분리와 변경사항 전파
- 비동기 프로그램과 동시성 제어
여기서 말하는 데이터 스트림이란 시간에 따라 순차적으로 지속적으로 들어오는 데이터를 의미한다. 이것은 지금 당장은 데이터가 없지만 앞으로 계속해서 입력될 수 있음을 의미한다. 그리고 이 데이터는 단순한 상태 변화를 의미하는 것이 아닌, 상태 변화가 있었고 어디론가로 이 내용이 전달될거야라는 의미이다. 이러한 데이터는 특정 모듈에서 발생하여 pipeline을 타고 전달되어 다른 모듈로 흘러들어가 소비된다.
이 데이터는 publisher(생산자)에 의해 생성되고 pipeline에 전달된다. subscriber는 pipeline을 관찰하고 있다가 자신에게 필요한 이벤트가 흘러가는 것을 보면 그 이벤트를 구독하여 코드를 실행하게 된다. 이로 인하여 publisher와 subscriber간의 책임과 역할이 분리되며, 각 모듈이 비동기적으로 처리될 수 있게 되며 이에 따라 동시성 제어가 필요해진다. 또한 publisher와 subscriber간의 결합성을 낮추어 유연한 구조를 가져갈 수 있게 한다.
[publisher] [pipeline] [subscriber]
|<------- (observe)
run() | |
(process) | |
| | |
(publsh event) ----->|
| |<------- (subscribe)
(return) | (process)
| |
|<------- (publish)
따라서 구조를 작성해 보면 주요한 위와 같다. 데이터의 흐름을 정의하고 어디서 어떠한 처리를 통해 이벤트를 발행하고 소비할지를 정한다. 실제 흐름은 파이프라인을 통해 이루어지며 publisher와 subscriber가 누구인지는 특정되지 않는다. 중요한건 누군가가 이벤트를 발행하면 누군가가 소비한다를 시작으로, 어떠한 이벤트가 어떤 방식으로 전파되어 또다른 이벤트의 흐름이 만들어지는가에 대한 부분을 기술한다. 이 구조는 각 모듈 간 결합도를 낮추고 모듈들이 분산된 채로 독립적으로 반응한다는 형태를 만들 수 있다. 이를 통해 비동기 처리가 가능해지며 이벤트의 흐름과 처리를 조율해야만 pipeline에 이벤트가 대량으로 쌓이는 Backpressure 현상을 막을 수 있다.