백엔드 개발자들이 알아야할 동시성 5 — Continuation Passing Style

Choi Geonu
9 min readAug 28, 2022

협력적 스케줄링을 구현하기 위해서는 다양한 방법을 사용할 수 있습니다. 다양한 프로그래밍 언어에서 지향하는 다양한 철학에 따라 여러가지 구현 방법이 있을 수 있죠. 오늘은 협력적 스케줄링을 구현하기 위한 첫번째 방법인 Continuation Passing Style, CPS 에 대해서 이야기 해보려 합니다.

Continuation Passing Style

Continuation Passing Style이라고 하면 뭔가 굉장히 어려운 이론적인 개념일 것 같습니다. 하지만 용어 하나를 많은 사람들에게 친숙한 단어로 바꾸면 단번에 무엇인지 알아 차릴 수 있죠. Continuation이라는 단어를 Callback으로 변경하면 어떨까요? Callback Passing Style, Callback 함수를 전달하는 방식의 스케줄링 기법을 의미하게 됩니다.

Continuation

어떤 프로시저(함수)가 수행되면 일반적으로 그 결과를 리턴하게 될것입니다. 예를 들어 Javascript에서 덧셈을 하는 함수를 만든다면 다음과 같이 만들겠지요.

function add(x, y) {
return x + y;
}

반면, Continuation Passing Style에서는 결과값을 리턴하지 않습니다. 그 대신 결과값을 다음으로 수행될 코드 조각인 Continuation에 전달하게 됩니다. Javascript에서는 Continuation을 Callback 함수로 표현할 수 있지요.

function add(x, y, next) {
next(x + y);
}

이러한 방식의 프로그래밍이 낯설다구요? Javascript 프로그래머라면 익숙할 다른 예제를 가져와 보겠습니다.

fetch('http://example.com/movies.json')
.then((response) => response.json())
.then((data) => console.log(data));

fetch() 함수의 리턴 타입인 Promise도 Continuation Passing Style을 응용한 구현입니다. HTTP 요청에 대한 응답을 받았을 때 다음에 어떤 작업을 수행할건지 then() 함수의 인자로 Continuation을 받아 결정하는 구조이죠.

코드 조각

이전 포스팅에서 우리는 스케줄링에 대해서 알아보았습니다. 위에서 Continuation은 코드 조각 이라고 표현했는데요. 이러한 코드 조각을 동시에 여러개를 실행해야 한다면, 이는 동시성 문제로 변하게 됩니다.
웹서버에서 동시에 1,000개의 HTTP 요청을 위와 같이 CPS로수행하게 된다면, 요청을 처리하기 위한 수많은 코드 조각이 동시에 수행되어야 하겠죠.

I/O Loop와 협력적 스케줄링

우리는 효율적인 동시성 처리를 위해서는 협력적 스케줄링을 사용해야 한다는 사실을 알고 있습니다. 단순히 협력적 스케줄링을 하는 것 뿐만 아니라, 작업을 I/O 대기가 일어나는 구간을 기준으로 나눠줘야 하죠.

여기서 CPS의 장점을 찾아볼 수 있습니다. 위의 fetch() 예제를 다시 보면 사실 이미 모든 Callback 구간이 I/O 대기를 피해서 나뉘어저 있는것을 알 수 있습니다.

fetch('http://example.com/movies.json')
// HTTP 응답을 받기 까지의 I/O 대기
.then((response) => response.json())
// HTTP 응답 Body를 읽어 JSON으로 변환하기 까지의 I/O 대기
.then((data) => console.log(data));

사실 내부에는 더 많은 동작을 기준으로 구간이 나뉘어저 있을 것 입니다.

/* Inside of fetch() */
.then(() => dnsLookup(...))
.then(() => tcpConnect(...))
.then(() => sendHeaderPackets(...))
.then(() => sendBodyPackets(...))
...

그렇지만 아무리 쪼개어도 결국 I/O 대기 구간은 Callback 사이에만 존재할 수 있는 구조이죠. 우리는 이 Callback함수, 즉 Continuation을 잘 스케줄링 하기만 하면 되는겁니다!

Node.js의 Event Loop

동시성에 있어서 Context Switch의 포인트로 적절한 부분이 I/O 대기만 있는것은 아닙니다. 하지만 이 연재 시리즈의 주제가 백엔드 개발자를 위한 동시성이니 I/O대기에서의 Context Switching에 대해서만 이야기 하겠습니다.

Node.js는 I/O대기를 처리하기 위한 런타임 레벨에서의 지원을 포함하고 있습니다. 모든 I/O 처리는 Event Loop(혹은 I/O Loop)라는 내부 프로시저에서 처리하고 있습니다.
Event Loop의 자세한 동작원리는 뒤로 해두고 몇가지 우리가 알아야할 핵심 포인트들만 짚어보겠습니다.

Event Loop의 도식화
  • Event Loop는 I/O 작업을 받아 처리하는 반복문입니다.
  • I/O 작업을 논-블로킹 방식으로 처리하여 컨텍스트 스위칭 없이 순차적으로 처리합니다.
  • I/O 작업이 끝나면 결과를 콜백 함수를 호출하여 전달합니다.

Node.js에서는 이 Event Loop를 기반에 두고 모든 작업을 처리하게 됩니다. I/O 작업이 완료 되었을 때 Callback이 실행되는 것을 Event(I/O Event)가 발생했다 라고 표현하여 Event Loop라고 말하기도 하구요. 이 때문에 이러한 방식을 Event-Driven 이라고 표현하기도 합니다.

Event Loop를 이용한 협력적 스케줄링

이제 원래 주제로 돌아와서 Event Loop로 어떻게 협력적 스케줄링이 일어나는지 알아보겠습니다.

CPS 기반의 웹서버에서 다수의 HTTP 요청을 받는다면 다음과 같이 여러개의 동시 작업(Concurrent Task)이 생성될 것 입니다.

각 작업은 Continuation, 즉 Callback 함수로 연결이 되어있을 것입니다. Task에서 다음 Task로 넘어가는것은 함수의 호출로 이루어지는 것 입니다.
이러한 상황에서 Task를 수행하던 도중 I/O 작업을 만나 대기해야하는 상황이 오면 어떻게 해야할까요?

I/O 작업을 만났을 때 Node.js의 동작

Node.js에서는 Event Loop로 I/O 작업을던져버리고 다른 작업이 수행되도록 합니다. 즉, 다음 작업을 실행하는 Callback 함수를 Event Loop로 던지고 실행 권한을 넘긴다는 것 입니다.

Event Loop는 이렇게 작업을 받고 다음 작업으로 스위칭하다가 완료된 I/O 작업을 발견하면 Callback을 통해 처리를 이어가게 됩니다.

첫번째 도식에서 던진 I/O 작업이 끝났을 때 Event Loop의 동작

이런식으로 I/O 작업때만 Context Switching이 발생하게 되니, 이는 일종의 협력적 스케줄링의 구현이라 할 수 있습니다.
협력적 스케줄링을 통해서 우리는 불필요한 Context Switching을 최소화하여 자원의 낭비 없이 많은 I/O를 처리할 수 있게 되는겁니다.
또한, Event Loop는 논-블로킹으로 구현되기에 멀티 쓰레드로 동작할 필요도 없습니다. 즉, 여러 작업을 동시에(Concurrently) 처리하기 위해 많은 쓰레드를 생성하여 메모리를 차지할 필요도 없는 것 입니다.

이것이 CPS를 이용한 효율적인 동시성 처리이고 Node.js 기반 웹서버가 많은 요청을 동시에 처리할 수 있는 이유입니다.

Node.js의 Event Loop 내부는 싱글 쓰레드로 동작하지만 사실 Event Loop 자체가 멀티 쓰레드로 여러개 실행되는 구조입니다. 이 포스팅에선 스케줄링에 대한 이해를 위해 구현에 관련한 많은 부분을 생략했습니다.

잠깐, 여기서 이해가 되지 않는 부분이 하나 있습니다. 두번째 도식에서 I/O 작업을 던지고 다음 작업을 실행한다는데, Event Loop가 다음 작업을 어떻게 안다는 것일까요?

두번째 도식

사실 HTTP 요청을 통해 여러 동시 작업을 수행할 때 생략한 파트가 있습니다. 사실 모든 요청의 시작 또한 I/O 작업을 통해 시작된다는 점이죠.

수정된 Task의 전환

그림을 위와 같이 다시 그리면 작업의 전환 이라는것이 세번째 도식과 차이가 없는 Callback 호출이라는것을 알 수 있게 되지요.

CPS의 단점

CPS는 이처럼 훌륭한 스케줄링을 가능하게 만들지만 명확한 단점도 존재합니다.

출처: https://blog.devgenius.io/javascript-promise-chaining-avoid-callback-hell-6e04818d4464

바로 반복되는 Callback으로 인한 스트레스가 있습니다. 물론 Javascript에서는 Promise와 같은 함수 Pipelining을 통해 이러한 스트레스를 줄이기도 했습니다.

이런식으로 함수를 Pipelining해서 함수의 조합을 간편하게 만들어 주는 방식은 다른 중요한 개념으로 확장되는데, 이는 이후 포스팅에서 다루어 보도록 하겠습니다.

물론 동시성을 해결하는 방법에는 CPS만 있는것이 아닙니다. 이후 포스팅에서 더욱 다양한 방법을 소개 해보도록 하겠습니다.

--

--