개요

Crash-Talk 서비스를 개발하는 과정에서, 말로만 듣던 요청과 응답에 관련된 코드들을 많이 다루기 시작했습니다. 그러나 작업 도중, 어떤 요청은 응답 이후의 컨텍스트를 가지고 처리하지만, 또 다른 성질의 요청은 응답 유무와 관계없이 비선형적으로 처리하는 것을 발견하게 됩니다. 이에 대한 원인을 추려보니, 본인이 자바스크립트가 비동기 동작을 처리하는 과정을 제대로 이해하지 못해서 발생하는 문제와, 비동기 자체에 대해 명확히 이해하지 못한 두 가지 문제점으로 좁혀지게 됩니다.

결국 추상적으로 생각해 오던 비동기와 동기의 차이점을 명확하게 이해하는 것이 요구되었으며, 자바스크립트에서는 이를 어떤방식으로 처리하며, 왜 그런 방식으로 처리하는지를 명확하게 알아야만 했습니다. 물론 이전에도 살펴봤던 개념이지만 명확하게 이해하고 응용하지 못한 이유가 있을 것이라 판단했고, 나름의 결론을 내린 결과, 동기와 비동기를 도대체 왜 구분지어 사용하며, 이러한 개념과 문법이 나오게 된 계기가 무엇인지를 이해하지 않고, 단순히 문법과 예시를 통해 어떤 상황에 써야 한다라는 지식 습득 중심의 학습이 가장 큰 문제였습니다.

그리하여 우테코 등 유튜브 영상과 나름의 구글링을 통해 알아낸 지식들을 바탕으로 다시 한 번 개념을 정리해보는 시간을 가지게 되었습니다. 이 글을 읽는 분들 중 저와 같은 실수를 하는 분들에게 도움이 되었으면 하는 바람입니다.

Block, Non-Block & Asynchronous, Synchronous

먼저 비동기 처리에 대해 다루기 이전에, 동기와 비동기는 도대체 무엇이며, 왜 이런 개념이 발생했는지를 살펴보면서 비동기 처리의 필요성에 대해 알 수 있습니다. 또한 현재의 자바스크립트가 비동기 작업을 처리하는 문법이 왜 그러한 방식으로 설계되었는지를 이해한다면, 각각의 해결책이 가지고 있는 한계점을 알게 되고, 어떤 상황에서 비동기 개념을 적용하고 다루어야 하는지 명확하게 이해할 수 있습니다. 때문에 Non-Block 과 Block 및 Asynchronous 와 Synchronous 개념을 간단히 짚어보도록 하겠습니다.

Block, Non-Block

먼저 Block의 개념에서 가장 중요하게 생각해야 할 부분은 바로 ‘제어권'입니다. Block과 Non-Block의 차이는 ‘제어권 반환'의 유무에 차이가 있습니다.

Async와 Sync의 개념에서 중요하게 생각해야 할 부분은 ‘데이터 전달 및 처리' 입니다. 어느 시기에 데이터를 전달하느냐에 따라 달라지게 됩니다.

이 두 가지 주요 개념을 가지고 설명해보겠습니다.

다음과 같은 caller 라는 호출자 함수가 있다고 생각 해 봅시다.

function caller(){

	called_A()

	called_B()

	called_C()

}

function called_A(){
	console.log('A')
}
function called_B(){
	console.log('B')
}
function called_C(){
	console.log('C')
}

caller();

위 코드를 실행하면 제일 먼저 caller 함수가 실행될 것입니다. 이때 ‘제어권'은 현재 실행되고 있는 함수인 caller가 가지고 있게 됩니다. caller 안의 구문이 순차적으로 실행되면서 제일 먼저 called_A 함수가 실행됩니다. 이때 caller가 가지고 있던 제어권이 called_A 함수로 전달이 됩니다(제어의 역전).

이 때, Non-Block과 Block의 차이점을 살펴볼 수 있습니다.

Non-Block : called_A는 가져간 제어권을 통해 자기가 가진 구문들에게 실행 명령을 내린 이후 다시 자신을 호출한 함수인 caller에게로 제어권을 바로 넘겨주게 됩니다. 이때 called_A의 실행 결과로 나온 결과값들이 caller 함수에게 다시 전달되는 시점은 알 수 없게 됩니다. 논블락 상황에서는 결과값의 전달과 제어권의 반환이 반드시 동시에 이루어져야 하는 것이 아니기 때문입니다.

Block : called_A는 가져간 제어권과 함께 자신의 구문들을 모두 실행시킵니다. 이때 called_A를 호출한 caller 함수는 제어권을 빼앗긴 상태이니 아무 행동도 할 수 없습니다. 이후 called_A의 실행이 완료되고, 결과로 나온 결과값들과 제어권을 함께 caller 함수에게 다시 전달 해 주게 됩니다. 즉 결과값의 전달과 제어권의 반환이 반드시 동시에 이루어지게 됩니다.

Sync, Async

Sync와 Async도 한 번 알아봅시다. 프로그래밍 과정에서 함수의 동작의 시점에서 벗어나 조금 포괄적인 시선에서 보았을 때 Sync와 Async는 사건의 시작과 끝을 맞추는 것에서 차이가 난다고 볼 수 있습니다. 조금 추상적일 수도 있지만 위 코드를 예시로 보았을 때, Sync는 called_A 가 끝나는 시점의 직후는 called_B의 시작이라고 볼 수 있고, called_B가 끝나는 시점은 called_C가 시작하는 시점이라고 볼 수 있습니다. 혹은 ‘제어권의 반환과 결과값 전달의 시점이 일치되어야 한다’와 같은 것들도 Sync 의 개념이라고 볼 수 있습니다. ‘시점을 일치시킨다'라는 맥락에서 보았을 때 둘 다 해당되는 내용이니까요.

여기까지 잘 따라오셨다면 드는 의문이 있으실 겁니다. 분명 Async Sync와 Non-Block Block은 다른 개념이라고 했는데 동의어와 같다는 느낌을 받으실 겁니다. 왜냐하면 관점에 따라 달라질 수 있는 추상적인 개념이기 때문입니다.

Deep dive

Async와 Sync를 조금 더 살펴봅시다.

포괄적인 개념에서 둘은, 시점을 다루는 개념입니다. 그러나 우리가 실제로 코딩을 하는 상황에서 어떻게 Sync 와 Block 개념을 구분지을 수 있을까요? 바로 호출된 함수의 결과 처리에 대한 주도권이 누구한테 있냐로 볼 수 있습니다. 여러 예시들을 통해 Sync와 Async라는 개념이 지엽적인 개념이 아니라 포괄적으로 생각해 볼 수 있는 개념이라는 것을 느껴봅시다.

function caller(){
	console.log(called_A());
}

function called_A(){
	const a = 3;
	return a;
}

caller()

위 코드가 작동되는 과정을 Block & Synchronous 관점에서 살펴봅시다.

caller가 호출되면 caller가 called_A 함수를 호출하게 됩니다. 이때 called_A에게 제어권이 전달되고, called_A 는 수행에 대한 결과 값인 a를 전달함과 동시에 제어권을 caller에게 반환하게 됩니다. 제어권과 결과 값을 돌려받은 caller는 돌려받은 시점에서 console.log((called_A 수행 결과 값))을 실행하게 됩니다. 즉 결과 값에 대한 처리를 called_A를 호출한 caller 함수가 하게 됩니다. 시점이라는 개념에서 보았을 때 Sync 이니 결과 값 전달과 제어권 반환의 시점을 일치 및 console.log() 구문이 실행되는 시점이 순차적으로 진행된다는 부분도 성립하게 됩니다.

즉 호출에 대한 반환값을 호출자가 처리하고(Sync), 데이터 전달이 제어권 반환의 시점과 일치합니다(Block, Sync)

function caller(){
	console.log(called_A());
}

function called_A(){
	const a = 3;
	return a;
}

caller()

같은 코드를 Asynchronous & NonBlock 관점에서 살펴봅시다.

caller가 호출되면 called_A() 구문을 만나는 순간 called_A를 호출하고 제어권을 called_A에게 저달합니다. 이후 called_A는 자신의 구문을 실행시키도록 명령만 내린 직후 제어권을 다시 caller에게 전달하게 됩니다. 제어권을 전달받은 caller는 called_A가 결과값을 전달 했는지의 유무와 상관 없이 자신의 구문인 console.log를 실행하게 됩니다. 이때 콘솔에 출력되는 값은 ‘undefined’라고 추측할 수 있겠지요? 또한 caller가 제어권을 전달받은 시점에 called_A는 호출자의 동작에 상관없이 자신의 구문을 실행합니다. 결국 제어권 반환과 결과값의 전달 시점이 일치하지 않으므로 Async 개념이 성립하게 되고, called_A가 끝나는 시점과 caller가 다시 실행되는 시점이 선형적이지 않으므로 또한 Async에 부합하게 됩니다. 또한 호출자인 caller와 called_A가 서로의 작업이 상호 연관되지 않으므로 NonBlock에도 부합합니다.

즉 호출에 대한 반환값을 호출자가 반드시 처리하는 것이 아니고(Async), 데이터 전달이 제어권 반환의 시점과 일치하지 않습니다(NonBlock, Async)

Asynchronous & Block 은 필자가 명확하게 이해 하지 못했으나, 커널과 어플리케이션의 동작 과정에서 추론한 개념을 설명 해 보겠습니다. 아래 코드를 살펴봅시다.

function caller(){
	const a = called_A();
	const res = a + 5;
	res = res+ 5;
	console.log(res);
}

function called_A(){
	const a = 3;
	called_B();
	return a;
}

function called_B(){
	console.log('B')
}

caller()

caller를 호출하면 called_A를 만나는 순간 called_A를 호출함과 동시에 제어권을 called_A에게 넘겨주게 됩니다. 이때 caller는 정지 상태가 되겠죠?(Block) called_A는 자신의 구문을 실행시키는 과정에서 called_B를 실행시키고, caller에게 결과 값 전달과 제어권을 반환합니다.(Sync) caller는 전달된 제어권을 바탕으로 결과 값을 처리합니다. 그러나 caller가 결과값을 처리하는 시점에서, called_B 가 실행되고 있는 상태라고 가정한다면, caller 와 called_A 의 입장에서는 Sync이지만 caller와 called_B의 입장에서는 Async입니다. 결국 보는 시점에 따라서 Block과 Async가 동시에 지켜지고 있다고 볼 수 있습니다.

Synchronous & Non-Block은 다음 그림으로 설명하겠습니다.

Sync이니 어쨌든 결과 값 전달 및 제어권 반환이 동시에 이루어지는 시점에서 값에 대한 처리가 이루어져야 합니다. 그러나 Non-Block 개념에 부합하기 위해선 결과 값 전달이 이루어지는 시점까지 호출자가 다른 동작을 수행 해야 합니다. 바로 이 부분이 핵심입니다. 호출자가 기다리는 동한 수행하는 여러 동작들 중 호출 된 함수에 제어권을 넘겨 결과 값을 넘길 준비가 됐냐고 물어보는 동작이 있으면 됩니다. 즉 할일을 계속 하는 와중에 지속적으로 “야, 작업 끝났어?” 라고 물어보는 것입니다. 이에 대한 응답으로 호출된 함수가 “작업 끝났어, 여기 결과값이랑 제어권 다시 줄게" 라고 오는 순간 해당 결과값을 호출자가 처리하게 되는 것입니다.

자바스크립트에서 비동기 처리를 하는 과정

자바스크립트에서의 비동기 처리를 알아보기 위한 기본적인 sync와 block 개념에 대해 알아보았습니다. 그렇다면 이젠 자바스크립트가 어떻게 비동기 작업을 처리하는지를 살펴봐야 겠지요?

자바스크립트뿐만 아니라 특정 언어가 작동하는 방식을 구체적으로 이해하고 싶으면 런타임 환경을 먼저 살펴보는 것이 중요하다고 생각합니다.

자바스크립트는 싱글 쓰레드 언어입니다. 즉 한 번에 하나의 작업만 수행할 수 있다는 말입니다. 자바스크립트가 싱글 쓰레드 언어라고 불리는 이유는 메인쓰레드인 이벤트 루프가 싱글 쓰레드이기 때문입니다. 즉 자바스크립트 그 자체로는 싱글쓰레드 언어이기 때문에 동기적으로 작업을 실행한다고 볼 수 있습니다. 그러나 자바스크립트는 독립적으로 실행되는 것이 아니라 웹 브라우저나 NodeJS와 같은 멀티 쓰레드 환경에서 실행되기 때문에 런타임 자체는 싱글 쓰레드가 아니라고 볼 수 있습니다. 즉 동기적 특성을 가지고 있지만, 런타임 환경을 통해 그 한계를 극복하여 비동기 방식으로 작동하도록 사용할 수 있다는 말입니다. 그렇다면 이런 비동기 환경을 지원하게 된 계기가 무엇일까요?

이전과 같은 정적 웹에서 벗어나 현대에는 다양한 웹 어플리케이션이 존재합니다. 그리고 이들은 한 번에 여러 요청을 받는 경우가 다반사입니다. 그러나 모든 요청을 동기식으로 처리한다면 분명 순차적으로 작업을 처리하는 동기적 특성 상 매우 느린 속도를 보여주게 될 것입니다. 실제로 ‘Blocking’(콜 스택에 오랜 수행시간이 걸리는 작업이 할당됨을 의미)이라는 현상이 많이 발생하였습니다. 이런 블로킹 현상을 극복하고 다양한 요청을 한 번에 처리하기 위해 비동기로 작업을 수행할 방법을 갈구하게 됩니다. 그 결과 현재 자바스크립트를 구동하는 런타임 환경은 언어 자체의 동기적 특성을 기반으로 비동기 작업을 가능하도록 하는 환경을 만들게 됩니다.

비동기 처리 런타임 환경을 살펴보기 이전에 콜백 함수의 개념에 대해 간단히 이해하고 넘어가도록 합시다.

콜백함수

1. 다른 함수에 매개변수로 넘겨준 함수
2. 어떤 이벤트가 발생한 후, 수행될 함수
3. 어떤 함수를 실행시키고 난 이후에 결과를 받을 함수 혹은 그 다음에 실행될 함수

C와 같은 다른 언어를 접하신 분들은 이해가 안되는 부분이 계실겁니다. 아니 함수를 어떻게 매개변수로 넘겨줄 수 있는거지? 와 같은 의문 말입니다. 왜냐하면 자바스크립트에서 함수는 일급 객체(시민)이기 때문입니다. 그렇다면 일급 객체란 무엇을 뜻하는 걸까요? 다음과 같은 조건을 만족하면 해당 엔티티를 일급 객체라고 할 수 있습니다.

일급객체

  • 변수나 데이터 구조안에 담을 수 있어야 한다.
  • 파라미터로 전달 할 수 있다.
  • return 이 가능하다.

즉 자바스크립트에서 함수는 변수에 바인딩이 가능하고, 다른 객체에 포함시킬 수도 있으며, 함수의 매개 변수로서 전달도 가능하고, 함수의 반환값으로 함수를 전달할 수도 있다는 말이 됩니다. 이러한 구조상 클로저를 쉽게 구현할 수 있겠지요? 그리고 이런 특성을 가진 자바스크립트 함수를 다양한 역할로 사용 가능하도록 하는것이 콜백 함수라는 개념이 됩니다. 비동기 처리의 문제점을 해결하는 것도 콜백 함수의 개념에서 시작하게 되는 것입니다.

이제 본격적으로 비동기 처리를 해주는 자바스크립트 런타임 환경에 대해 알아봅시다.

비동기 처리 과정

  • Call Stack: 자바스크립트에서 수행해야 할 함수들을 순차적으로 스택에 담아 처리
  • Web API: 웹 브라우저에서 제공하는 API로 AJAX나 Timeout등의 비동기 작업을 실행
  • Task Queue: Callback Queue라고도 하며 Web API에서 넘겨받은 Callback함수를 저장
  • Event Loop: Call Stack이 비어있다면 Task Queue의 작업을 Call Stack으로 옮김
  • Render Queue : 16ms 마다 렌더링 요청을 렌더 큐에 전달하고 Call Stack이 비었다면 마찬가지로 렌더링 요청에 대한 렌더링 작업을 수행하게 됩니다.

위 예시를 통해 알 수 있는 부분을 정리해보면 다음과 같습니다. 원래 자바스크립트는 싱글 스레드 기반으로 동기적으로 작동합니다. 그러나 setTimeOut과 같은 외부 Api를 통해 비동기 방식으로 작업을 처리할 수 있게 됩니다. 작업의 처리에서 콜 스택이 최우선 순위를 가지고, 콜 스택이 비었을 떄, web API를 통해 Callback Queue 로 전달된 콜백 작업들을 이벤트 루프가 순차적으로 콜스택으로 옮겨 수행한다는 것을 알 수 있습니다.

해당 작동방식을 이해한다면, 싱글 스레드에 동기적 작동방식을 가진 자바스크립트가 어떻게 비동기를 자연스럽게 처리할 수 있는지 알 수 있게 됩니다.

위 코드의 setTimeOut 딜레이 시간을 0 으로 바꾼다면 어떤 상황이 발생할까요?

만약 출력 결과는 동일하다고 생각하셨다면 잘 이해하셨다고 생각합니다. 런타임 환경에 대한 지식을 배우기 이전의 상황을 가정해 본다면, 우리는 어쨌든 딜레이는 0 이니 다른 코드들과 함께 동기적으로 작업이 이루어 진다고 생각해 출력 결과의 순서도 ‘hi there bye’ 순으로 나타난다고 생각했겠지만, 런타임 환경에서 Web API를 처리하는 방법을 학습한 지금, 딜레이가 0 임과 상관없이 결국 작업에 대한 순서를 setTimeOut이 보장하기 떄문에 딜레이 값과 상관 없이 위 그림의 콘솔 출력과 동일한 결과를 내 놓을 것입니다.

결국 setTimeOut의 인자로 전달하는 딜레이 값은 비동기 작업이 수행되는 순서를 지정하기 위한 최소한의 시간 값을 지정한다고 볼 수 있습니다. 5000으로 인자를 전달한다고 반드시 5초 뒤에 실행되는 것이 아니라, 최소 5초뒤에는 이 작업이 실행될 수 있도록 보장하겠다 라는 의미를 가진다는 것이죠. 실행을 보장한다는 말은 결국 해당하는 작업이 비동기로 처리되도록 보장한다와 동일한 의미를 가진다고 볼 수 있습니다.

콜백 함수를 통한 비동기 처리

이제 자바스크립트 코드가 비동기 처리를 하는 방식의 발전과정을 한번 살펴봅시다.

다음은 콜백 함수를 통해 비동기 작업을 처리하는 코드입니다.

function caller(){ // 콜러
	console.log('sync task');
	asyncRequest(asyncCallBack);
}

function asyncRequest(callBackFn){ //리퀘스터
	ajax('ENDPOINT',function(data){
		callBackFn(data);
	}
}

function asyncCallBack(data){ // 콜백
	console.log(data);
}

caller();

해당 코드가 작동하는 과정을 살펴보기 이전에 우리는 앞서 배웠던 Sync 와 Async의 개념을 다시 한 번 살펴볼 필요가 있습니다. ‘시점'이라는 관점을 취할 수도 있지만, ‘결과 데이터 처리' 라는 관점에서 해당 개념을 살펴볼 수 있다고 배웠었지요? 결과 데이터 처리를 누가 하냐에 집중을 하며 한 번 살펴봅시다.

caller가 호출되면 caller는 console.log 를 수행한 뒤 asyncRequest(asyncCallBack)을 호출합니다. asyncRequest는 외부 api인 ajax를 통해 ‘ENDPOINT’에 위치한 데이터를 비동기적인 방식으로 요청합니다. 여기서 caller의 관점에서 한 번 살펴봅시다.

caller가 제어권을 가지고 호출한 console.log는 호출 시점에서 caller가 제어권을 가지고 있기 때문에 해당 코드를 제어할 수 있습니다. 그러나 비동기 방식으로 호출된 asyncRequest는 자신의 코드를 실행시키는 명령을 내린 이후, 다시 제어권을 caller에게 전달하게 됩니다. 그러나 결국 asyncRequest 입장에서는 외부 라이브러리인 ajax를 통해 요청과 콜백에 대한 제어를 수행하게 되겠지요? 때문에 리퀘스터 내부에서는 해당 데이터에 대한 처리를 외부 라이브러리가 소유한 제어권을 통해 자유자재로 할 수 있습니다.

그러나 caller의 입장에서는 제어권을 가지고 있음에도 불구하고, 자신이 호출한 리퀘스터 함수가 데이터를 처리하는 과정을 제어하지 못하고 외부 라이브러리에게 그 권한이 반강제적으로 위임된 꼴이 된 셈입니다. 결국 실질적인 제어권을 비동기 리퀘스터에게 뺏긴 셈이지요. 이를 ‘제어의 역전'이라고 합니다. 만약 리퀘스터가 데이터를 불러오는 도중 문제가 생겨 콜백함수에 원하는 데이터를 전달하지 못하는 상황이 벌어지면 매우 난감하겠지요?

콜백 방식으로 구현한 비동기 처리는 ‘비동기'의 ‘데이터 처리'관점에는 부합하지만, 실질적인 프로그래밍의 관점에서는 다양한 변수가 발생할 수 있는 요인을 통제하지 못하는 모순적인 상황이 발생하게 되었다는 것입니다.

이렇듯 자기가 짠 코드가 불러오는 데이터를 자유자재로 통제할 수 없다면 개발자 입장에선 매우 난감하고 찝찝한 상황이 아닐 수가 없습니다. 실제로 제어의 역전으로 인해 발생하는 난감한 상황들이 매우 많았던 것도 사실이구요. 이런 불가항력적인 상황이 비동기로 처리되는 작업이라도, 해당 작업의 데이터 처리에 대한 통제권을 호출자가 소유해야 하는 방식이 필요하다라는 요구를 만들게 됩니다.

그리고 그러한 제어권을 호출자가 되찾기 위해 탄생한 것이 바로 Promise인 것이죠.

Promise의 등장

먼저 Promise의 특징을 살펴봅시다.

  • 미래의 값을 반환할 수도 있는 함수를 캡슐화한 객체
  • 제어의 재역전
  • 비동기 요청 수행에 대한 상태를 가진다.(성공 실패 대기)
  • 호출자에 의해 실행된 리퀘스터의 비동기 요청이 끝나고 나면, 결과 값을 연결된 콜백으로 전달한다.

Promise 3가지 상태

new Promise() 와 같이 새로운 Promise 객체가 생성되었을 때 해당 객체는 다음과 같은 상태중 하나를 가질 수 있습니다.

  • Pending(대기) : 비동기 처리 로직이 아직 완료되지 않은 상태
  • Fulfilled(이행) : 비동기 처리가 완료되어 프로미스가 결과 값을 반환해준 상태
  • Rejected(실패) : 비동기 처리가 실패하거나 오류가 발생한 상태

new Promise() 메서드를 호출하게 되면 대기상태인 promise 객체가 생성 되며,해당 메서드를 호출할때, 콜백 함수를 선언할수 있고 이때 콜백함수의 인자는 resolve와, reject를 줄 수 있습니다.

이후 요청에 대한 응답이 제대로 도착되었는지, 아닌지에 따라 Promise 객체는 이행 혹은 실패의 상태를 가지게 됩니다. resolve 는 이행 상태의 프로미스 객체를 처리하고, reject는 실패 상태의 프로미스 객체를 처리하게 됩니다.

function requester(){
	return new Promise((resolve, reject)=> { //=> 이 시점에서는 대기 상태
		ajax('ENDPOINT', (data)=>{
			if(data) resolove(data) //유효한 데이터면 data 반환 => 이행
			else reject('error') // 예외 발생시 에러 반환 => 실패
		})
	}
}

function asyncTask(){
	const promise = requester();

	promise
		.then((data) =>{//작업 수행})
		.catch((error) => {// 에러 처리})
}

asyncTask();

분명 비동기 요청작업을 requester가 수행하지만 해당 요청 작업에서의 데이터 처리를 requester를 호출한 asyncTask 가 then 과 catch를 통해 제어하고 있지요? 이렇듯 Promise 개념의 도입을 통해 제어의 역전으로 발생하는 데이터 처리에 대한 신뢰성 문제를 대처할 수 있게 됩니다. 또한 then 이나 catch의 체이닝을 통해 구조화된 콜백을 작성할 수도 있겠지요?

그러나 전달하는 값의 종류에 상관없이 항상 일관된 객체로 전달해야하는 부분이나 외부 호출자가 Promise 내의 흐름에 대한 예외 처리가 어렵다는 부분에서 또 다른 단점들을 야기했습니다. 또한 비동기 작업이 처리되는 과정을 코드 구조를 보고 예측하기에는 인간의 사고방식과 동떨어져 유지 보수나 디버깅, 로직의 작성과정에서 신경써야할 부분이 많아 비용이 컸습니다. 때문에 이런 동떨어진 사고방식을 극복하기 위해 접목한 개념이 generator와 promise를 함께 사용하는 것이었습니다.

function *asyncTask(){
	const data = yield request();
	yield () => console.log(data);
}

function request(){
	ajax('ENDPOINT', function(data){
		gen.next(data)
	})
}

const gen = asyncTask();
gen.next();

우리의 목표는 비동기 처리 방식을 이해하는 것 이기 때문에 제너레이터 문법에 대한 자세한 설명은 생략하겠습니다. 위 코드가 작동되는 과정을 보면 asyncTask()는 제너레이터 함수로써 자신의 구문을 실행하는 것이라 제너레이터 객체를 반환합니다. 반환된 제너레이터 객체는 gen 변수가 바인딩 하게됩니다.

이후 gen.next() 메소드가 실행되면 gen이 바인딩하는 asyncTask 제너레이터 함수의 yield 키워드를 찾아 해당 구문을 실행하게 됩니다. 따라서 request() 메서드가 실행되고 리퀘스트 함수 내부에서 또 다시 gen.next(data)를 실행하게 되지요. 결국 gen.next()가 수행할 작업은 request()이므로 data 변수에는 request()가 실행된 결과 값을 전달 받아야 합니다. 이런 결과 값의 전달을 해주는 메소드가 위 예시에서는 gen.next(data) 에서 파라미터로 전달된 data가 되겠지요. 그렇다면 ajax 구문을 통해 불러온 data를 request를 호출한 asyncTask의 ‘data’ 변수가 바인딩 하게 되고, next()의 성질에 따라 제너레이터 내부의 다음 yield키워드를 찾아 수행하게 됩니다.

이렇듯 제너레이터와 프라미스의 결합을 통해 로직 자체는 비동기 코드이지만 읽을 때 동기적인 코드처럼 읽을 수 있게 됩니다. 이러한 방식을 통해 콜백 방식이 가지고 있던 신뢰성 문제를 개선하고, 코드 작성시 비동기적인 사고를 기반한 코드 작성에서 오는 능률 저하에 대처할 수 있게 됩니다.

그러나 문법적인 측면에서 더욱 편리하게 제너레이터와 프라미스를 함께 사용하는 방식의 필요성을 느끼게 된 많은 개발자들의 수요에 따라 해당 구조의 syntax sugar로써 async await 방식이 등장하게 됩니다.

Async / Await 의 등장

이제 Async Function의 특징을 알아보도록 합시다.

  • Syntatic Sugar
  • ES2017
  • 함수 내에서 await을 만나면 해당 키워드가 포함된 구문 이외의 실행을 일시 중지
  • await 의 프로미스 수행 결과 값을 전달 받은 시점에서 함수 작동을 재진행

generator & promise 를 이용해 만들었던 위 예시를 async 함수로 리팩터링 해 봅시다.

async function asyncTask(){
	const data = await request();
}

function request(){
	return new Promise((resolve)=>{ajax('ENDPOINT', function(data){
		resolve(data);
	})
}

asyncTask();

위 코드에서 의문이 드는 부분이 생기실 수 있습니다. “Promise 객체를 새로 생성하면 콜백 함수의 인자로 반드시 resolve, reject 둘 다 전달 해 주어야 하는 것이 아닌가요?”

⇒ 우리가 async function을 사용하는 이유를 곱씹어 봅시다.

제너레이터와 프라미스를 활용한 비동기 처리 방식이나 async function을 활용한 방식이나 공동으로 추구하는 목적은 바로 ‘데이터 처리의 제어권 소유를 호출자가 가질 수 있도록 하자.’입니다. 결국 asyncTask 의 실행결과로 반환된 프라미스 객체는 실패 혹은 이행의 상태를 가지게 되고, 이 객체는 request를 호출한 호출자 함수의 data가 바인딩 하게 되겠지요? 그리고 해당 data 변수로 이리저리 조작하는 주체는 asyncTask가 되겠네요. 여기서 await은 비동기 작업의 처리 시점을 일치 시키기 위한 키워드라고 자연스럽게 파악할 수 있습니다.

정리해보자면, async function이 실행되었을 때 await키워드를 만난다면 해당 키워드 뒤의 비동기 구문이 비동기 처리가 끝나고, 데이터와 제어권을 함께 전달받은 시점까지 아무런 동작이 할 수 없도록 동기적인 구조로 변환해줍니다. 또 이런 async await 방식을 통해 비동기 작업의 결과 데이터를 처리하는 주체를 호출된 함수(여기서는 request)가 아닌 호출자 함수(asyncTask)가 담당하게 되며 제어의 역전으로 인한 신뢰성 문제도 극복할 수 있게 되는 것입니다. 제너레이터 방식 대비 높은 가독성 또한 장점으로 가져갈 수 있는 부분이죠.

그러나 다수의 Promise를 병렬적으로 처리할 수 없다는 문제점과, 경우에 따라 async키워드를 관련 함수 모두에 선언해야 하는 상황도 발생할 수 있게 됩니다.

이번 학습을 통해 자바스크립트의 비동기 처리 방식의 의문점에 대한 결론을 어느정도는 내릴 수 있을 것 같습니다. 결국 promise나 async await을 사용하는 이유는 데이터 처리 주체에 대한 컨트롤을 확보해 신뢰성 문제를 해결하고, 기존 비동기 코드를 동기적 과정으로 수행할 때 발생하는 가독성의 문제를 처리하는 것이 주된 목적이라고 볼 수 있겠습니다.

그러나 각각의 처리방법을 살펴보았을 때 무조건 최신 문법만 사용하고 더 효율적이다 라는 절대적인 원칙은 존재하지 않는다는 것을 알 수 있습니다. 오히려 Promise 객체를 병렬적으로 처리해야 하는 상황에서는 async function을 지양하는 것이 맞는 선택이 될 수도 있겠지요. 결국 문제 상황에 맞게 적절한 솔루션을 선택하여야 하며 완벽한 비동기 처리 방식은 없다고 결론지을 수 있겠습니다.

0개의 댓글