85 거침없는 자바스크립트 2회차

이누의 벨로그·2022년 3월 11일
0

85-2

동시성과 병렬성 Concurrency and Parallelism

동시성은 엄밀히 말하면 Concurrency를 직역한 오역에 가깝다. Concurrency는 마치 동시에 일어나는 것 같은 일 을 말하며 시분할 컴퓨터 시스템 등이 이에 해당된다.

반면 병행성이란 정말로 작업의 수만큼 프로세서가 존재하고, 각각 작업을 하나씩 맡아 여러개의 작업이 같이 수행하는 것을 말한다.

병행성에서는 메모리 공유의 문제가 일어난다. 각기 따로 할당된 프로세서가 서로 같은 메모리를 참조할 때 다른 프로세서가 좀유하고 난 후의 메모리 상태가 이전과 같다는 보장이 되지 않기 때문이다. 이 문제를 해결하기 위해서는 공유하는 메모리에 대해 한번에 하나의 프로세서만 사용할 수 있도록 blocking을 유발하는 방법밖에 없다. 그렇다면 메모리에 대한 접근이 blocking된 대상 프로세서는 어떤 행동을 할까?

  1. 좌절(그대로 접근을 포기)

  2. 탈취(메모리 점유하는 프로세서부터 권한을 강제로 넘겨받음)

  3. 대기 (자바의 Object.wait() 등)

등이 있다. 이 세가지 모두 유효한 방식이다.보통 병행성의 문제를 해결하기 위해서는 여러가지 도구와 수단을 활용하여 언어차원의 라이브러리를 제공한다.

자바스크립트는 어떨까? 자바스크립트에서는 ES 2018 까지 병행성이 없었다.ES2018에 shared memoryatomics 라는 병행성 스펙이 도입됨에 따라 자바스크립트는 병행성의 문제가 (다른 언어들처럼) 생기게 되었다.

이전에 자바스크립트는 동시성만 있었기 때문에, 메모리 공유로 인한 병행성의 문제가 없었다. 물론 동시성 또한 실제 세계에서는 수많은 작업과 메모리를 사용하는 복잡성을 가지기 때문에 주의를 기울여 통제해야 할 대상이다.

그러면 자바스크립트 엔진을 사용하여 동시성 모델을 알아보자. 크롬 브라우저의 V8 엔진은 브라우저를 완전히 통제하며 멀티 쓰레드로 렌더링을 포함한 각종 다양한 작업을 수행한다. 브라우저 엔진은 자바스크립트 코드를 실행하거나 코드가 참조하는 메모리 연산을 수행할 때만 싱글 스레드를 사용하고, 그 외 자바스크립트 코드나 메모리에 영향이 없는 작업은 멀티 스레드로 작업을 수행한다. 여기서는 브라우저 엔진이 백그라운드에서 수행하는 작업을 렌더링으로 표현하겠다.

브라우저엔진은 매번 콜백큐를 체크하면서 우리가 짠 코드가 콜백큐에 적재되어 있는지를 체크하고 비어있지 않다면 이를 가져와서 코드를 실행한다. 콜백큐가 비어있거나 실행이 끝나면 다시 렌더링 작업으로 돌아간다.

이 모든 작업은 하나의 스레드가 실행하는 동시성 모델이며 한번에 한가지 작업만 일어난다. 이 중 하나라도 blocking 이 일어난다면 바로 다음 작업에 영향을 준다. 브라우저 엔진에서 렌더링에 영향을 주지 않을만큼 빠른 시간안에 나머지 작업들이 실행되려면 우리가 작성한 자바스크립트 코드의 실행시간을 줄이는 것이 중요하다.

이처럼 자바스크립트 코드를 읽고 실행하는 이벤트 루프는 싱글 쓰레드로 동작한다. 따라서 멀티 쓰레드로 코드를 쓰더라도 이를 소비하는 이벤트 루프는 이를 순차적으로 읽기만 하면 되므로 병행성의 문제가 발생하지 않는다. 멀티 쓰레드 패턴에서는 이를 생산자-소비자 패턴, 특히 소비자가 싱글 쓰레드거나 하나인 경우를 파이프 패턴이라고 한다. 즉 백그라운드 태스크는 전부 멀티 쓰레드로 동작하더라도 이를 이벤트 루프라는 하나의 파이프에서 소비하는 한 병행성 문제가 일어나지 않는 것이다. GPU를 사용하는 DOM event, timer 또는 postmessage API나 네트워크 통신 작업 등은 모두 멀티 쓰레드로 실행되지만 이들이 콜백 큐에 할 일을 넣는 타이밍만 설정해준다면 싱글 쓰레드로 동작하는 이벤트 루프 덕분에 병행성의 문제는 해결된다.

SetTimeout

SetTimeout의 동작을 알아보기 위해 다음과 같은 코드를 보자. 우리는 코드를 작성할 때 Push 방식으로 생각하는 경향이 있다. 어떤 코드를 이벤트 중심의 어떤 사건에 자동으로 호출해달라는 요청을 주로 하는 것이다. 예를 들어 클릭 이벤트리스너를 걸면, 내가 클릭을 했을 때 이벤트가 동작하길 바라고, SetTimeout을 걸면 해당 타임아웃이 지났을 때 내 코드가 호출되길 바란다. 실제로 이를 어떻게 호출하거나 동작하는지는 관여하고 싶지 않은 것이 일반적이다.

const Item = class{
	#time;
	#block;
	constructor(block,time){
		this.#block=block;
		this.#time = time+ performance.now()
	}
}
const queue = new Set;

브라우저는 콜백 큐에 자바스크립트 코드를 그냥 삽입할 수 있지만, 우리의 예시에서 우리가 만든 큐에 삽입할 코드를 감싸주기 위해서 Item이라는 클래스를 만들었다. Item 클래스는 실행할 콜백인 block과, 언제 실행될지를 기록한 time을 갖는다. performance.now()는 브라우저가 시작 한 후 경과된 시간이다.

큐를 Set으로 만든 이유는 객체를 담기 위해서이다. 고유한 메모리 주소로 식별되는 객체를 담을 그릇은 중복을 허용하는 배열이여서는 안된다. 배열에 객체가 중복되어 있다면 그 자체로 이미 뭔가 오류가 있다고 할 수 있다. 따라서 우리는 중복을 허용하지 않는 Set을 객체의 컬렉션으로 사용하는 것이 적합하다.

const f = time>{
	queue.forEach(item=>{
			if(item.time>time) return;
			queue.delete(item);
			item.block();
	});
	requestAnimationFrame(f);
};
requestAnimationFrame(f);

그 다음으로는 이벤트 루프에서 큐를 체크하는 함수를 만들고 requestAnimationFrame으로 이를 실행해본다. requestAnimationFrame이 f 함수를 바로 실행할 수 있는 이유는 requestAnimationFrame의 첫번째 매개변수로 performance.now()가 주어지기 때문이다. 즉 requestAnimationFrame으로 위의 함수를 실행하면 매초마다 item에 기록된 time(time+performance.now()) 와 현재 performance.now()를 비교하여 같아졌을 때 큐에서 아이템을 삭제하게 된다.

한가지 주의할 점은 ES6 이후의 forEach 혹은 for of 문 등은 모두 루프의 복사본에 대해 루프를 도므로 루프 도중에 루프의 원소를 조작하거나 삭제하는 것이 아무 문제가 없다는 사실이다.

앞서 우리는 우리가 요청만 보내면 그 뒤에는 자동으로 요청에 따라 타임아웃 이후 콜백이 호출되는 Push 방식으로 생각했지만, 실제로 요청에 따라 동작하기 위해서는 위와 같은 Pull 방식으로 time을 지속적으로 체크하다 타임아웃이 되는 순간을 기다려야 한다. 이벤트 리스너도 이와 같은 pull 방식이다. 클릭 이벤트를 listen하기를 요청한다는 것은 실제로는 브라우저가 매번 루프를 돌며 사용자의 마우스 좌표와 클릭여부를 확인하고 있다. 마법은 존재하지 않는 것이다.

큐에 아이템을 삽입하는 다음과 같은 헬퍼함수를 만들 것이다.

const timeout = (block,item)=>queue.add(new Item(block, time));

이제 다음과 같이 SetTimeout과 똑같은 방식으로 사용할 수 잇다.

timeout(_=>console.log('hello'), 1000);

이제 우리가 수동으로 만든 이벤트 큐가 완성되었다. 이 이벤트 큐는 다음과 같이 동작한다.

실제 브라우저 엔진 상에서 RAF는 이벤트 루프 상에서 동작하는 최적화된 이벤트이다. 타이머 API는 별도 쓰레드로 동작하는 반면, RAF는 브라우저 엔진이 렌더링이 끝나면 직접 발생시키는, 렌더링과 정확하게 맞물리는 이벤트이기 때문에 브라우저의 렌더링 루프와 콜백을 동기화하는 데에 유용하게 쓰인다.

따라서 우리가 만든 이벤트 루프는 브라우저의 동시성 이벤트 루프 안에 RAF로 동작하는 작은 이벤트 루프를 하나 더 만든 셈이 된다. 실제 Promise의 MicroTask 큐도 이와 같이 자바 스크립트 이벤트 루프 내에 존재하는 또다른 이벤트 루프이다. 자바스크립트에는 이렇게 중첩된 이벤트 루프가 여러개 존재한다.

Non-blocking for

지금까지는 동시성을 이해하기 위한 예제를 알아봤다.

const working = _=>{};
for(let i=0;i<10000;i++)working();

이제 위와 같은 blocking을 일으키는 코드가 있다고 해보자. 10000번 동안 working을 할동안 싱글 쓰레드에서 브라우저의 렌더링이나 큐 확인 등등의 다른 작업은 전부 멈추게 될 것이다. 앞서 진정한 non-blocking은 존재하지 않고 단지 충분히 짧은시간 안에 blocking하는 flow가 종료되는 것을 non-blocking이라고 부를 뿐이라고 했었다. 그렇다면 이 함수의 for문이 이중으로 중첩된다거나, 내부에 실행하는 함수가 또다시 매우 긴 코드를 포함한다고 하면 어떻게 될까? 분명히 우리가 납득할 만한 시간 안에 종료되지 못할 것이다.

그렇다고 우리가 실제 도메인을 위한 로직에서 for문의 반복 횟수를 줄일 수 있을까? 아니다. 모든 제어문은 이유가 있기 때문에 태어난다고 앞서 얘기했었다. for문의 반복횟수가 많다면 그것은 실제로 그만큼의 횟수로 for블락을 실행해야하는 이유가 있기 때문에 도메인의 필요에 의해 결정되는 것이다. for문의 blocking을 해결하는 방법은, 별도의 타이밍을 빼고 다른 flow로 쓰레드를 넘겨주는 것 뿐이다. 이벤트 루프를 예로 들면, 한 번 루프가 돌 때 for문의 횟수 중 일부만을 실행하고 나머지 이벤트 루프의 작업을 수행한 후, 다음 이벤트 루프 때 다시 for문의 일부를 실행하는 식으로, for문의 blocking 시간을 우리가 납득할 수 있는 non-blocking이 될 때까지 조금씩 나누어 해소하는 것이다.

이렇게 반복문을 조금씩 나누어 해소하기 위해 다음과 같은 함수를 정의하자

const nbFor = (max, load, block)=>{
	let i=0;
	const f = time=>{
		let curr=load;
		while(curr-- && i<max){
			block();
			i++;
		}
		console.log(i);
		if(i<max-1)requestAnimationFrame(f);
	}
requestAnimationFrame(f);
}

우선 max는 총 실행해야하는 횟수, load는 한 번에 실행하는 횟수, block은 반복문으로 실행할 함수를 말한다. 우리는 앞서 Timer의 예제에서도 한 번에 실행할 transaction을 f라는 함수로 정의하여 requestAnimationFrame에 넘김으로써 다음 프레임에 이를 다시 재귀적으로 넘기게 하였다. 여기서도 마찬가지다. 우리는 load라는 한번에 실행할 횟수만큼만 block이 실행되도록 조건문을 설정하였다. f함수는 i 라는 변수를 클로저로 안고 태어남으로써 함수가 몇번을 호출되어 실행되더라도 i의 상태가 계속해서 단조증가 하도록 만들었다. 따라서 최대로 설정된 max에 도달하기 전까지, 매번 프레임에 f함수가 실행될 때마다 load로 설정된 횟수만큼만 block함수가 호출된다. 그리고, i가 max에 도달하기 전에는 항상 다음프레임에 한 번 더 f함수를 호출하고 있다.(i<max-1) 위 nbFor 함수를 실행하면, 한 번에 정해진 load 횟수만큼만 반복문을 실행하고 flow 제어권을 다시 브라우저 엔진에 돌려주게 되므로, 브라우저 엔진이 blocking 없이 이벤트 루프를 돌며 렌더링 및 수많은 백그라운드 작업을 정상적으로 할 수 있게 된다. 이는 마치 자바에서 thread.sleep()를 거는 것과 동일한 효과를 가진다. 스레드의 제어권을 넘겨주는 것이다.

이 코드를 통해 자바스크립트에서 핵심적인 한 가지 교훈을 얻을 수 있다. 어떤 코드가 스레드를 점유하지 않도록 주의를 기울여야 한다는 것이다. 코드가 동기적으로 수행할 작업이 많거나 많은 횟수의 반복을 시행해야 할 때도 스레드의 제어권을 넘겨줄 수 있도록 한 번에 조금씩 나누어서 실행야 한다. 만약 제어권을 넘겨주지 않으면 실행해야할 코드가 데드락에 빠지는 경우도 있다. 코드가 실행되기 위해 다른 작업이 수행되어야 하는데 코드가 스레드에 blocking을 걸고 있어서 다른 코드도 수행되지 못하고 이 코드도 blocking이 해소되지 못하는 것이다. 어떠한 경우에도 무거운 부하는 조금씩 나누어 실행하고 제어권을 빨리 돌려줄 것을 명심해라.

위 함수는 함수 바깥에 함수 내부 변수의 상태를 보전해주는 클로져를 이용해서 함수를 조금씩 나누어 실행해도 함수를 다음 프레임에 계속해서 호출할 수 있게 한 것이다. 우리는 함수를 캡슐화하고 함수를 여러번 호출하여 그 때마다 전진시키려 하는데, 호출할 때마다 초기화되는 인자나 지역변수만으로는 이를 구현할 수 없기 때문에 함수 바깥에 함수를 여러 번 호출해도 일관성을 보장하는 클로져 변수 i를 만든 것이다. 이를 클로저 패턴 이라고 한다. 그러나 함수의 코드에서는 클로저 변수의 상태나 역할이 명확하게 드러나지 않기 때문에 우리의 머릿속에서 서로 다른 층에 있는 변수의 상태를 관리해야 하는 어려움이 있다. 물론 이 또한 반복적인 훈련을 통해 숙달할 수 있는 부분이다.

raF를 앞서 만든 timeout 함수로 변경하면 우리가 앞서 만든 이벤트 큐로 이를 처리할 수 있다.

제네레이터 Generator

이터러블과 이터레이터는 자바개발자에게 매우 익숙한 용어이다. 자바스크립트에서 이터러블과 이터레이터는 자바스크립트 표준에 규정되어있는 인터페이스이다. 인터페이스란 자바의 인터페이스와 무관한, 자바스크립트의 고유명사이다. 자바스크립트에서 인터페이스란 특정 객체특정 키특정 값이 들어 있는 형태를 규정한 것을 말한다. 즉 자바스크립트에서 인터페이스란 오브젝트의 형태를 말한다. 그러면 이터러블과 이터레이터가 어떠한 오브젝트의 형태를 정의하고 있는지 알아보자.

  • 이터러블[Symbol.Iterator] 라는 메소드를 갖고 있다.
  • [Symbol.Iterator] 메소드는 이터레이터 객체를 리턴한다.
  • 이터레이터next() 메소드를 가지고 있고 이 메소드는 valuedone 이라는 키가 있는 오브젝트를 리턴한다. done에는 다음 번 next 호출을 할 지를 결정하는 boolean값이 들어오며, value에는 이 번 next() 호출에서 받게 되는 값이 들어있다. done이 true면 value는 undefined 이다.

이터러블 인터페이스를 구현하는 것들로는 문자열, Array, Set 등이 있다.

그렇다면 제네레이터가 뭔지는 코드로 알아보자

const infinity = (function*(){
	let i=0;
	while(true)yield i++;
})();
console.log(infinity.next());	

제네레이터는 유사 이터러블 이라고 부른다. 그 자체는 이터러블 객체가 아니기 때문이다. 이터러블 인터페이스에 규정된 [Symbol.iterator] 메소드를 가지고 있지 않다. 하지만, 이 메소드를 호출하지 않고 제네레이터를 그냥 호출하는 것만으로도, 이터레이터 객체 이면서 동시에 이터러블인 제네레이터 객체 를 반환한다. 따라서 위의 코드에서 즉시실행함수로 실행한 익명 제네레이터함수는 제네레이터 객체를 반환할 것이고, 따라서 next() 메소드를 호출할 수 있는 것이다. 제네레이터에서 yield가 일어날 때 마다, 반환할 이터레이터 객체의 next()가 반환할 value를 주게 된다. 따라서 위 코드는 inifinity.next()를 호출할 때마다 0부터 무한히 증가하는 i값을 출려한다.

그런데 왜, 제네레이터 내부에서 무한 루프에 빠져서 yield를 반환하는데 이 코드는 무한루프에 빠져서 blocking으로 죽는 증상이 나타나지 않는걸까?

제네레이터 의 함수 표현(function*)은 특수한 문법으로, 내부적으로 suspend라는 구간을 생성한다. 우리는 첫 시간에 동기명령은 순차적으로 전부 해소되기 전까지 개입하거나 멈출 수 없다고 배웠다. 하지만 제네레이터는 가능하다. 자바스크립트 엔진을 모든 명령을 Record 라는 일종의 객체로 래핑하여 적재한다. 그리고, 이를 순차적으로 해소할 때 이 Record 를 하나하나 풀어서 내부의 명령을 실행한다. 이 때 이 Record에 suspend 가 걸려있다면, 더 이상 sync flow는 진행되지 않는다. 앞서 클로져를 사용하여 여러 층의 변수로 구현한 쓰레드를 다른 flow에게 위임하는 행위 가 제네레이터의 yield 사용하면 자바스크립트 엔진 수준에서 가능해진다. yield를 호출하자마자 sync flow의 명령 Record는 suspend되어 더 이상 flow가 순차적으로 실행되지 않는다. 제네레이터 함수가 반환한 제네레이터 객체가 다음번 next()를 호출해야만, 앞서 suspend 한 동기 명령이 마저 실행된다. 이를 resume이라고 한다. 따라서 제네레이터에서 무한루프로 실행한 yield는 동기 blocking을 일으키지 않는다.

무한 루프를 돌면서 blocking을 일으키는게 아니라, yield 할 때마다 flow를 suspend하고 외부에 쓰레드를 위임하기 때문이다. 우리는 이를 통해 무한히 계속된 value가 나오는 배열을 미리 메모리를 확보하지 않고도 만들 수 있다. 단 여기서 무한이란 우리가 원하는 만큼(next()를 호출한 만큼) 전진할 수 있는 것을 말한다.

const nbFor = (max, load, block)=>{
	let i=0;
	const f = time=>{
		let curr=load;
		while(curr-- && i<max){
			block();
			i++;
		}
		console.log(i);
		if(i<max-1)requestAnimationFrame(f);
	}
requestAnimationFrame(f);
}

아까 봤었던 non-blocking for에는 한가지 문제가 있다. 우리는 순수한 반복문에 대한 로직인 f함수를 작성해서 이를 네이티브 이벤트인 requestAnimationFrame에 넘겼다.

이는 제어문은 루프구조가 같더라도 내부요소가 바뀌면 재활용이 불가능하고 새로운 제어문을 작성해야 한다는 한계점이 있기 때문이다. 사실 이는 제어문만의 한계가 아니라 모든 식(expression)이 아닌 문(statement)의 특징이기도 하다. 재활용을 위해서는 식이나 값으로 바꿔야 하는 것이다. 왜냐하면 문(statement)는 한 번 실행되고 나면 실제 메모리상에서 사라지기 때문이다.

우리가 CPU에 명령을 적재한다고 할 때 명령으로 적재되는 것은 모두 문(statement)으로 한번 실행되면 사라져 재활용할 수 없는 것들이다. 반면 값과 식은 메모리에 저장되기 때문에 CPU가 언제든지 재활용할 수 있다. 우리는 문을 f라는 함수안에 가두는 행위를 통해 문을 식으로 바꾸어 재활용한 것이다. 또는 문을 sub flow 혹은 sub routine으로 만들어 재활용한 것이라고 볼 수 있다. 서브루틴이 만들어질 때 nbFor 함수 내부의 루틴을 확정 지었으므로 이 루틴의 모양이 조금이라도 변한다면 - 우리의 경우에는 requestAnimationFrame이 timeout으로 변하거나 아니면 다른 네이티브 이벤트로 변한다면 - 우리는 nbFor 함수를 재활용할 수 없고 nbFor함수를 복사하여 완전히 다른 제어문을 새로 만들어야 하는 것이다. 물론 지금은 일반화하여 인자로 해당 부분을 받는 것이 가능하겠지만 일반화가 불가능한 로직이라면 얄짤없이 새로운 제어문을 생성하는 방법밖에 없다.

따라서 제어구문과 네이티브 의존성을 가진 코드를 분리하지 못하면 우리는 코드의 반복을 막을 수 없다. 그리고 제네레이터 는 이것을 해소할 수 있다. 왜일까? 제네레이터는 스스로의 실행을 suspend하고 제어를 외부에 위임할 수 있기 때문이다.

일반적인 for 제어문은 sync flow를 멈출 수 없기 때문에 자신이 자기자신의 sync flow에 대한 완전한 제어구조를 내부에 가지고 있게 된다.하지만 제네레이터는 스스로를 suspend하면 외부에서 제네레이터가 리턴한 iterator 의 next() 메소드를 호출함으로써 제네레이터의 sync flow를 제어할 수 있다. 제어 권한을 외부에 위임할 수 있는 것이다.

const gene = function*(max,load,block){
	let i =0, curr=load;
	while(i<max){
		if(curr--){
			block();
				i++;
		}else{
			curr=load;
			console.log(i);
			yield;
		}
	}
}

제네레이터 함수가 앞선 nbFor와 다른 점은

  1. 클로저 변수를 쓰지 않고 지역변수를 사용한다.
  2. 제어구조를 코드에 전부 내재하지 않고 yield로 외부에 위임한다. 여기서는 frame을 건너뛰는 requestAnimationFrame 부분을 yield로 위임했다.

nbFor에선 함수 외부에서 참조하는 클로져변수를 사용해서 루프를 멈추었다. 하지만 제네레이터 함수에서는 그렇게 하지않고 전체 루프를 다 실행하고 있다. 다만 루프를 멈추고 싶은 시점에 외부에 yield했다. 즉 yield로 함수 내부의 sync flow가 중단 되기 때문에, 그냥 함수 내부의 지역변수를 사용하는 것 만으로도 전체 루프의 제어가 가능한 것이다.

따라서 우리는 제네레이터를 사용한다면 반복문의 로직에서 언제든지 네이티브 의존 코드를 외제화 할 수 있으며 새로운 도메인에 대해 반복문을 재활용할 수 있다. 제네레이터를 쓰려면, 제어구조에서 공통된 부분을 제네레이터에 내재한 뒤 변화할 부분(상태)를 yield로 외부에 위임해야 한다.

그럼 이제 실제로 non-blocking for를 구현하기 위해 제네레이터를 사용해보자.

const nbFor = (max, load, block)=>{
	const iterator = gene(max,load,block);
	const f =_=>iterator.next().done || timeout(f); //done이 false면 timeout을 호출
	timeout(f,0)
}

timeout을 사용하는 네이티브 코드는 제네레이터로 만든 반복문 코드로부터 완전히 외제화 되었다. 제네레이터 기반으로 이터레이터를 얻어서, next()를 통해서 외부에서 반복문을 제어하게 되었다. 즉 제네레이터로 제어 역전을 달성 하였다.

이렇게 우리는 Continuation Passing Style 코드를 한 가지 알아보았다. 앞서 yield나 await 등에서 호출시점의 변수나 상태들을 sub flow에 공급하는 기능을 continuation, 이를 활용하는 프로그래밍 스타일을 Continuation Passing Style이라고 했다. 앞으로 우리는 이를 CPS라는 이름으로 부를 것이다.

앞서 제네레이터를 사용한 코드는 네이티브 이벤트를 순수 로직으로 분리했기 때문에 유지보수성이나 재활용 측면에서 앞선 nbFor보다 더 좋은 코드라고 할 수 있다. 그러나, 루프를 나누어 관리했던 전의 코드가 제어문의 책임 측면에 있어서는 책임을 조금씩 나누었기 때문에 더 가벼운 책임을 가진다고 볼 수 있다.


그런데 만약, 비동기 코드에 대한 제어 역전을 이루려면 어떻게 해야할까?

Promise

일반 적인 비동기 호출을 생각해보자. ajax나 fetch로 콜백을 호출했다고 생각해보면, 우리는 fetch 호출에 대한 제어를 통해 콜백을 호출하는 데는 성공했다. 하지만 비동기인 콜백이 언제 응답이 오는지 알 수 있을까? 없다. 우리는 비동기 동작에 대해서는 제어권이 없는 것이다.

콜백을 사용하게 되면 프로그래머에게는 콜백에 대한 제어권이 없다. 일반 콜백이 실행되는 것을 대기하기 위해 setTimeout이나 raF 등으로 이벤트 루프를 활용해 코드 실행을 늦춰주는 정도의 방법을 사용하지만, 이 또한 콜백을 제어하는 것이 아니며 우리는 단지 콜백이 실행되는 것을 대기하기 위해서 코드를 추가할 뿐이다. 메인 쓰레드를 넘겨주기 위해 비동기 코드를 사용하지만 정작 메인 쓰레드에서는 비동기 코드를 대기하는 모순이 발생하는 것이다.

메인 쓰레드에서 단지 대기하는 것이 아니라 우리가 원하는 시점에 비동기 콜백이 오도록 제어하기 위해서는 어떻게 해야하는가? 우선 가능한 일인가? fetch로 서버의 응답을 기다리는 비동기 코드를 짰는데, 서버가 우리가 원하는 시점에 응답하도록 조종한다는 일은 불가능 것이다. 서버의 응답시간, 또는 실제 비동기 로직을 위해 외부에서 소요되는 시간은 제어할 수 없는 부분이다.하지만 응답이 왔을 때, 해당 콜백이 바로 자동으로 호출되는 것이 아니라 우리가 원하는 시점에 호출되도록 하는 것은 우리가 제어가 가능한 부분이고 이것이 바로 Promise의 반제어역전이다.

따라서 Promise를 사용할 때는 then을 어느 시점에 사용할 건지에 대한 고민이 항상 수반되어야 한다. Promise를 생성하자마자 바로 then을 사용해서 콜백을 호출한다는 것은 곧 Promise의 반제어역전이 필요없다는 의미이며 과거의 중첩된 콜백 형태와 다를바가 없는 것이기 때문이다. 제어권을 행사하기 위해서는 Promise 객체에 대한 then을 우리가 원하는 시점에 행사해야하는 것이다. new Promise().then이 나쁘다는 얘기는 아니다. 다만 콜백과 아무 다를바가 없다는 것을 인지해야 한다.’

Promise를 사용하여 제네레이터를 만들어보자.

const gene2 = function*(max,load,block){
	let i =0;
	while(i<max){
		yield new Promise(res=>{
			let curr=load;
			while(curr-- && i<max){
				block();
				i++;
				}
			console.log(i);
			timeout(res,0);
		});
	}
}

똑같은 yield 구문을 사용하지만 Promise로 감싸서 양도하고 있다. 제어권을 Promise로 감싸서 위임하는 것이다. 이렇게 하는 이유는 Promise 내부의 작업을 캡슐화하여 제네레이터 함수가 결정하고, 외부에서는 Promise 내부 작업이 끝났을 때만 제어권을 then으로써 위임받을 수 있게끔 하는 것이다. Promise의 반제어역전을 사용하여 제네레이터가 제어에 관여했다고 볼 수 있다. 이제 timeout이라는 외부의 네이티브 이벤트를 제네레이터의 루프 내부에서 다시 소유하여, Promise의 then이 외부에서 실행될 타이밍에 대한 제어권도 Promise 내부에서 가지게 된 것이다.

이렇게 우리는 Promise를 통해 제어권을 돌려받는데 성공했다. 이제 이를 사용하는 코드는 다음과 같다.

const nbFor = (max,load,block)=>{
	const iterator = gene2(max,load,block);
	const next = ({value, done})=>done || value.then(v=>next(iterator.next()));
	next(iterator.next());
};	

next 함수의 매개변수에서 볼 수 있듯이 next함수는 iterator.next() 메서드가 반환하는 객체를 인자로 받는다. 만약 이터레이터의 진행이 끝났으면 (done이 true면) 그대로 진행을 멈추고, 진행이 끝나지 않았으면 우리가 Promise로 감싸 yield한 value를 then으로 실행한다. 따라서 우리는 value가 언제 done이 될지 결정할 수 없고 제네레이터 내부의(Promise로 감싼) 비동기 코드가 결정하므로 then의 대한 제어권은 Promise에 있다고 할 수 있다.(비동기 코드에). 외부에서는 단지 next 함수의 진행에 대한 제어권만을 가지게 되었다. 제네레이터로 외부에 위임한 제어를 다시 Promise로써 돌려받았으니, 역전에 역전을 이뤄냈다고 할 수 있다. 이렇게 제어를 위임하거나 다시 회수할 수 있는 Promise와 제네레이터의 복합적인 작용으로, 우리는 원하는 제어의 책임을 어느 코드에 둘지를 마음대로 결정할 수 있다. 이미 안정된 제어를 외부에 위임하기 위해서는 단순 제네레이터를 사용할 수도 있을 것이고, 복잡한 제어를 원하는 대로 조정하기 위해서는 Promise를 사용하여 내부에서 원하는대로 결정할 수도 있을 것이다.

next(iterator(next())의 제어를 비동기인 Promise가 내부에서 timeout으로 resolve한 시점에 위임했으니 사실상 nbFor은 제어를 Promise에 넘겼다고 보아도 된다. 이를 Co-Pattern 이라고 하며 Redux Saga의 제네레이터가 이에 해당된다. Promise가 담당하는 개별적인 루프에 대해 언제 전진할지, 얼마나 전진할지를 결정해 줌으로써 개별적인 루프의 합으로 비동기가 점진적으로 전진되게 하는 이러한 코딩 스타일을 Continuation Passing Style이라고 한다.

우리는 이렇게 여러가지 방법으로 Sync flow를 분할하여 스레드에 대한 제어권을 위임하는 방법을 알아보았다. 또한 제네레이터와 Promise를 사용하여 분할한 개별적 Sync Flow에 대해 제어를 외부에 위임하거나, Async Flow 이후에 동작에 대한 제어를 다시 내부에서 수행하는 반제어역전을 할 수 있다는 것을 알게 되었다.

profile
inudevlog.com으로 이전해용

0개의 댓글