85 거침없는 자바스크립트 4회차 - 여러가지 시나리오의 비동기작업

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

이번 시간에는 순차적 비동기에 대해서 알아볼 것이다.

Sequential Async

비동기가 순차적으로 일어난다고 하면 의아할 사람들도 있을 것이다. 보통은 비동기를 병렬적으로 처리하는 것을 목표로 하기 때문이다.

async await 구문은 기본적으로 동기화에 대한 순차를 지정하게 된다. 따라서 우리는 병렬처리를 위해서 Promise.all이나 Promise.race를 사용하는게 일반적이다. 그런데 이러한 내장 메서드가 과연 정말 병렬적인지에 대해서는 생각해보아야 한다. 우리가 응답시간이 다른 여러개의 API의 호출을 한다고 할 때 Promise.all은 가장 응답시간이 느린 호출을 기준으로 Promise를 리턴한다. 그러나 이보다 더 좋은 방법이 있다. 바로 응답시간에 관계없이 응답되자 마자 원하는 동작을 동기적으로 실행하는 것이다.

Promise.all이나 Promise.race로는 이러한 순차처리와 병렬처리를 병합하여 처리하는 것이 불가능하다. 병렬처리를 최적화하기 위해서는 무언가 다른 구문이 필요하다.

다음과 같은 간단한 Promise.all 코드를 보자.

const render = function(...urls){
	Promise.all(urls.map(url=>fetch(url,{method:'GET'}).then(res=>res.json())))
		.then(arr=>arr.forEach(console.log)
}
redner('1.json','2.json','3.json');

단순히 urls 배열을 각각 fetch하여 Promise 배열로 바꾼 뒤 이를 json()으로 만드는 체인 까지의 프로미스를 리턴했다. 이 Promise.all이 리턴한 결과 Promise는 배열이므로 각각을 console.log로 출력해본다. 이 함수는 셋 중에 가장 늦게 응답이 오는 Promise를 기준으로 then이 발동하여 출력된다.

이 Promise.all 문법은 async await의 순차처리와는 잘 맞지 않는 방식이다. 이를 조금 바꿔보자.

const render=function(...urls){
	const loop=_=>{
		if(urls.length){
			fetch(urls.shift(),{method:'GET'}).then(res=>res.json())
				.then(json=>{
					console.log(json);
					loop();
				});
			}
		};
	loop();
};
redner('1.json','2.json','3.json');

urls을 배열을 하나씩 소진하면서 순차적으로 비동기 호출을 하게 된다. 앞으로 우리가 함수에서 마주칠 console.log는 실제로는 우리에게 있어서 데이터를 렌더링 해주는 함수라고 보면 될 것이다. 이 함수는 데이터를 비동기로 처리하고, 렌더링하고 나서 다시 재귀로 이를 되돌려주는 제어까지 한 번에 들어있기 때문에 관심사 분리가 전혀되어 있지 않다. 셋 중 하나만 바뀌어도 이 함수는 깨진다. 따라서 이 함수를 조금 더 분리할 필요가 있다.

루프를 전진하며 외부에 위임해주는 제네레이터와 이를 실행해주는 함수를 분리할 것이다. 마치 리덕스 사가와 비슷하게 말이다.

const dataLoader = function* (f, ...urls) {
  for (const url of urls) {
    const json = yield fetch(url, { method: "GET" }).then((res) => res.json()); //json은 제네레이터 바깥에서 next함수의 인자로 전달해주는 값으로, 제네레이터가 추가적인 동작을 할 수 있게 해준다.
    f(json); // 렌더링 로직
  }
}; //그러나 여전히 데이터 처리와 렌더링을 같이 갖고 있어 유지보수 부담이 생김
const generender = function (...urls) {
  const iter = dataLoader(console.log, ...urls);
  const next = ({ value, done }) => {
    if (!done) value.then((v) => next(iter.next(v))); //next에 넘겨주는 동시에 다음 yield값을 받아올 수도 있음. 실행은 제네레이터에서 해주게 된다. 즉, 제네레이터 내부에서 데이터 처리도 가능하다.
  };
  next(iter.next());
};

yield를 할당하는 json 이라는 변수는 제네레이터의 객체의 next() 메서드에 매개변수로 전달할 수 있는 인자다. 자바스크립트의 제네레이터는 yield로 외부에 상태를 위임할 수도 있지만 외부에서 전달할 수도 있는데, yield 구문을 변수에 할당하면 외부에서 next()로 전달한 값이 변수에 할당된다. 따라서 next()안에 들어온 인자를 제네레이터 내부에서 f()함수를 통해 처리하게 된다. yield는 Promise를 리턴하고 이를 외부에서 제어해서 다시 제네레이터로 돌려주는 형식이다. 제네레이터를 사용하면 이처럼 제네레이터가 위임하거나 또는 제네레이터에 전달한 형태를 포로토콜로써 정의할 수 있고 외부에서 이를 사용할 실행기를 얼마든지 그에 따라 만들어낼 수 있다. Redux Saga 등 많은 Co 함수 등은 제네레이터가 Promise를 외부에 위임하고 외부에서는 제네레이터에 Promise를 해소한 값을 전달하는 프로토콜을 사용한다.

이러한 형태를 Generator-Executor라고 한다. 제네레이터는 제어포인트를 외부에 위임하고 외부함수가 제어의 형태를 결정하도록 만들어준다. 위 함수의 비동기가 성립하는 이유는 제네레이터 때문이 아니라 외부 함수가 Promise의 반제어역전을 이용해서 then으로 원하는 시점에 호출할 수 있기 때문이다. 즉 Promise는 그 자체가 비동기가 아니라 비동기 제어를 외부에 then 호출에 위임하는 성질을 가지고 있기 때문에 외부함수가 이를 비동기적으로 제어할 수 있는 것이다.

따라서 Promise를 사용할 때 생성한 Promise에 바로 then을 호출하여 사용하는 경우에는 결국 콜백함수와 다를 바가 없으며, Promise의 생성과 then 호출을 분리하여 제어를 위임해야 Promise의 제어역전을 이룰 수가 있다는 점에 주의해야 한다.

이제 dataLoader는 제어구조에 관여하지 않는다. 그러나 우리는 여전히 데이터 처리 로직과 렌더링 로직을 dataLoader 함수 안에서 한번에 가지고 있다. 그렇다면 이를 분리하기 위해 다음과 단순히 yield를 두번 하는 함수를 생각해볼 수 있다.

const dataLoader2 = function* (...urls) {
  for (const url of urls) {
    const json = yield fetch(url, { method: "GET" }).then((res) => res.json()); //next함수에 인자가 없는 경우
    yield json; //데이터를 받는 경우
  }
};
const render2 = function (...urls) {
  const iter = dataLoader2(...urls);
  const next = ({ value, done }) => {
    if (!done) {
      if (value instanceof Promise) value.then((v) => next(iter.next(v)));
      else {
        console.log(value); // 렌더링을 위임받게
        next(iter.next()); //yield의 두가지 경우에 대응
      }
    }
  };
  next(iter.next());
};

Promise를 yield하고 이를 then으로 실행한 결과를 제네레이터에 전달하는 부분은 동일하지만 렌더링 로직을 분리하기 위해 단순히 한 번 더 yield하였고 그를 위한 분기문이 하나 더 추가되었다.

Promise 객체는 어떻게 콜백을 바로 실행하지 않고 then으로 나중에 실행할 수 있게 해주는 걸까? Promise 내부에 일종의 버퍼를 두어서 비동기 데이터를 버퍼에 담는데, then의 호출 시점에 데이터에 존재 유무에 따라 없다면 기다렸다가 then을 실행시켜주는 것이다. 이처럼 Promise는 비동기에 대한 이후 진행을 외부에 반제어역전으로 위임할 수 있고, 제네레이터는 Yield를 통해 제어를 도중에 중단하고 진행을 외부에 위임할 수 있는 기능을 가지고 있기 때문에 두 가지를 합쳐서 외부에 비동기에 대한 제어를 위임할 수 있게 된다. 하지만 이러한 Generator Executor 구문 또한 2017에 async await 가 도입된 이후에는 더 이상 필요가 없어졌다.


Async Iterator

async iterator에 대해 알아보기 전에 async에 대해 알아보자. async 에는 yield처럼 외부에 위임해주는 구문이 없다. 따라서 제네레이터 처럼 제어를 도중에 중단하여 위임할 수 없고 내부에서 제어가 완결되어야 하기 때문에, 앞서 제네레이터의 예시처럼 렌더링과 데이터 처리를 분리할 수 없고 다음과 같이 순차적으로 전부 처리해야 한다.

const render = async function(...urls){
	for(const url of urls){
		console.log(await(await fetch(url,{method:'GET'})).json());
	}		
}

await 키워드 뒤에 와야하는 값은 Promise 여야 한다. 또한 await 의 결과로 반환되는 값은 then에 전달되는 값이다. 따라서 await 키워드는 Promise-then의 단축 표현이라고 할 수 있다.

이 함수는 제어를 넘겨줄 수 없기 때문에 처음에 봤던 함수와 마찬가지로 데이터 처리/ 렌더링 / 제어를 분리할 수 없어 모두 같이 가지고 있다.

이를 async iterator/제네레이터를 사용하면 다음과 같이 분리할 수 있다.


const dataLoader3 = async function* (...urls) {
  for (const url of urls) {
    yield await (await fetch(url, { method: "GET" })).json();
  }
};
const render4 = async function (...urls) {
  for await (const json of dataLoader3(...urls)) {
    console.log(json);
  }
};
		

async 제네레이터를 사용하면 비동기 호출을 await로 대기한 결과를 yield로 위임할 수 있다. 따라서 제어를 내부에서 완결할 필요가 없어졌으므로 렌더링 로직을 다시 분리할 수 있게 됐다. async 제네레이터에서 yield한 값은 Promise 이므로, 이를 사용하는 함수도 async여야 이를 사용할 수 있다. 단 이때, 제네레이터 객체에 대한 개별 next() 호출은 await dataLoader3().next() 와 같이 익숙한 문법으로 사용할 수 있지만, 제네레이터 객체의 for-of 사용을 위해서 새롭게 도입된 문법이 for await of이다. for await of는 Promise로 이루어진 async 이터러블을 루프로 소비할 수 있다.

async 이터러블은 일반 이터러블과 별개의 인터페이스로 정의되어 있으며, Promise 배열을 반환하는[Symbol.asyncIterator] 메소드를 가진다. 즉, Symbol.asyncIterator는 async인 next() 메서드를 가지는(Promise를 반환하는) 이터레이터 객체를 반환한다. 따라서 우리는 이제 제어권을 위임하면서 관심사를 분리하는 데 성공했다.

Yield *

Yield 는 yield하는 값으로 재차 이터러블이나 제네레이터가 오게할 수 있는 구문이다. 제네레이터 안에서 제네레이터를 부르고, 제어권을 다시 또다른 제네레이터에게 위임하고 돌려받는 것이다. 제네레이터 안에서 제네레이터를 호출하면 어떤 일이 일어날까? yield 로 위임하는 제네레이터를 메인 제네레이터, yield 뒤에 오는 제네레이터를 서브 제네레이터라고 해보자. 메인 제네레이터가 yield하는 순간 suspend하고 제어를 넘기는데, 서브 제네레이터 또한 내부에서 연쇄적으로 suspend가 일어날 것이다. 메인 제네레이터에서 서브 제네레이터로 위임되고, 다시 서브 제네레이터에서부터 제어를 돌려받아서 메인 제네레이터도 제어를 돌려받는 위임과 제어복구의 연쇄가 일어나는 것이다. 따라서 yield 를 사용하면 얼마든지 원하는 depth로 제네레이터를 사용할 수 있게 된다. 함수형 프로그래밍의 지연 평가 (*Lazy Evaluation) *또한 이러한 제네레이터의 연쇄적인 위임을 이용한다. 제네레이터를 사용하지 않는 일반적인 메소드들은 내부에서 루프가 완결되야하기 때문에, 매번 filter, map, reduce나 for문 등으로 루프를 돌 때마다 배열전체의 루프를 돌고 그 다음 배열 메소드가 실행되면 또다시 루프를 전부 도는 일이 일어난다. 반면 yield를 사용하면, 메인 제네레이터의 평가를 지연하고 yield한 서브 제네레이터의 위임 연쇄가 완료되고 나서야 값을 평가한다.

const urlLoader = async function* (url) {
  yield await (await fetch(url, { method: "GET" })).json();
};

const dataLoader4 = async function* (...urls) {
  for (const url of urls) yield* urlLoader(url);
};
const render= function(...urls) {
  for await (const json of dataLoader4(...urls)) {
    console.log(json);
  }

yield 를 사용해서 여러개의 url을 받는 dataLoader와 하나의 url에 대한 처리만을 담당하는 urlLoader를 분리해주었다. 이제 하나의 url에 대한 처리를 담당하는 urlLoader를 분리함으로써, url 여러개를 처리하는 경우와 하나만을 처리하는 경우를 분리할 수 있다. 또한, await 구문이 나오지 않았다고 async 함수가 아니어도 된다고 착각할 수가 있지만, yield 뒤에 오는 값이 async 제네레이터라면 yield가 있는 메인 제네레이터도 async 제네레이터여야 한다. 즉 async 제네레이터를 yield로 위임할 수 있는 것은 async 제네레이터 뿐이다. 하지만 async 제네레이터는 async가 아닌 일반 제네레이터/이터러블도 위임할 수 있다.(이에 대해서는 뒤에서 좀 더 다룬다.)

단, 이 예제에서는 위임하는 서브 제네레이터의 yield가 단 한 번에 해소되기 때문에 사실 yield 로 위임하지 않는 것과 별다른 차이는 없으나, 서브 제네레이터가 yield를 여러번에 걸쳐 해소한다면 메인 제네레이터의 yield는 서브 제네레이터의 yield가 모두 해소되기 전까지는 다시 제어를 돌려받지 못한다. 즉 dataLoader4의 for루프는 yield*로 위임한 각각의 urlLoader의 yield가 끝나기 전에는 suspend되어 진행되지 않는다.

yield* 를 사용해서 우리는 여러 depth에 걸친 이터러블을 위임할 수 있게 되었다. 위의 예제에서는 2단계에 걸쳐 urlLoader라는 async 제네레이터를 가장 외부의 렌더링 함수로 위임할 수 있었다.

Async Group

그럼 이제 비동기의 순서를 정해주는, Grouping을 구현해보자.

const url = async function* (url) {
  yield await (await fetch(url, { method: "GET" })).json();
};
const urls = async function* (...urls) {
  const r = [];
  for (const u of urls.map(url)) r.push((await u.next()).value); 
  yield r;
};
const dataLoader5 = async function* (...aIters) {
  for (const iter of aIters) yield* iter; 
};
const render6 = async function(...aIters) {
  for await (const json of dataLoader5(...aIters)) console.log(json);
};
render6(urls('1.json','2.json'), url('3.json'));
render6(url('1.json'), urls('2.json','3.json'));

앞서서는 url 문자열을 매개변수로 넣어주었다면, 이제는 최종 렌더링 코드에서 완결 되어있는 async 제네레이터를 인자로 받을 것이다. 즉 인자로 받은 제네레이터들이 순차적으로 해소되어야만 다음 제네레이터가 해소된다. 첫번째 render6는 urls 제네레이터가 해소되고 나서야 url 제네레이터가 해소될 것이고, 두번째는 그 반대가 될 것이다. 앞서서는 async 이터러블을 직접 생성하여 yield*로 위임 하였지만, 이제는 외부에서 전달하는 async 이터러블에 대해 그대로 위임하고 있다.

urls 함수는 인자로 받는 urls 배열에 대해 각각 map으로 url 제네레이터의 인자로 전달하여 async 제네레이터를 생성하고, 각각에 대해 await 한 값을 배열로 yield한다. 다시한번 async iterable의 인터페이스를 명시하자면, 앞서 async 제네레이터/ 이터러블은 [Symbol.asyncIterator] 메서드를 가지며, 이 때의 이터레이터 객체는 async 인 next() 메서드를 가지므로, next()의 결과값인 {value,done}은 Promise 라고 했다. 라서 우리는 async 제네레이터/이터러블을 소비하기 위해서 for...of 문으로는 for await of를, next()를 사용할 때는 await next() 와 같은 형태로 사용해야 한다.

따라서 urls의 결과값은 배열이 되며, url의 결과값은 단일 json이 된다. 이쯤에서, 앞서 우리가 작성했던 generator-executor 패턴의 코드를 기억하는가? generator-executor 코드에서 우리는 제네레이터 yield를 통해 제네레이터에 전달하거나 제네레이터가 위임할 값의 형태를 마치 프로토콜처럼 정의하여 이를 실행할 executor를 외부에서 만들어낼 수 있었다. async 제네레이터에서는, 자바스크립트가 문법적으로 우리를 위해서 2가지를 해결해준다.

  1. yield 구문에 해당하는 suspend 블록 구간을 제공한다.
  2. async 제네레이터 구문을 사용하면 우리가 yield할 모든 값을 Promise로 감싸주고, 호출하면 이터러블 객체(제네레이터 객체)로 바꿔준다.

따라서 generator-executor 패턴에서는 우리가 직접 주고받을 값의 형태를 정의했다면, async generator 문법을 사용하면서 우리는 자바스크립트가 미리 만들어준 Promise로 랩핑된 값을 리턴하는 이터레이터 객체를 얻게 된다. 문법적으로 많은 것이 해결되지만, 반대로 async 구문을 사용하면 무조건 Promise로 랩핑된 이터레이터 객체로 타입이 확정된다는 제한사항이 있다. 이처럼 CPS 구문에 대한 확정적인 타입을 가지는 문법은 C# 언어의 영향을 받은 것이다. 반면 프로그래머가 원하는 타입을 인터페이스로 설정할 수 있는 언어들도 있으며, 이 경우 지난시간에 우리가 직접 구현해보았던 SeqIterable과 Continuation 같은 복잡한 구문을 통해 구현된다.

async 이터러블을 통해 언어적으로 타입이 확정되어 간결한 구문으로 CPS를 사용할 수 있다는 건 큰 장점이지만, 반면 async 이터러블 문법적으로 무엇을 해결하는지, 어떤 타입이 확정되는지를 알아야 이를 정확히 사용할 수 있다. 또한 yield나 for await of 같은 특정 키워드에 대한 지식도 요구된다.

이제 우리는 타입이 asnyc Iterable이기만 하면 render6 함수의 인자로 사용될 수 있다는 것을 알았으니, 다음과 같은 일을 할 수도 있다.

const start = function*(){yield 'start';}
const end = function*(){yield 'end';}
render6(start(), urls('1.json', '2.json'), url('3.json'),end())

문자열 외엔 아무값도 전달하지 않는 함수이지만 iterable이기 때문에 urls와 url async 이터러블과 순서를 지켜서 출력된다. 그런데, start와 end는 async 이터러블이 아닌데 어떻게 yield를 하지? 라는 의문이 드실 것이다. 자바스크립트에 async iterable 표준이 도입될 때, async 이터러블/제네레이터의 await나 yield 구문은 뒤에 async 이터러블이 아닌 일반 이터러블/제네레이터도 올 수 있도록 했다. 따라서 for await of나 yield, await 구문 모두 일반 이터러블 / 동기 객체가 뒤에 올 수 있다. 따라서 우리는 async 제네레이터 하나만으로 동기/비동기 제네레이터를 섞어서 yield 할 수 있다.

const dataLoader5 = async function* (...aIters) {
  for (const iter of aIters) yield* iter; 
};
const render6 = async function(...aIters) {
  for await (const json of dataLoader5(...aIters)) console.log(json);
};

이제 우리는 console.log() 제어문 단 하나만으로 단순한 문자열부터 비동기 동작과 배열까지 전부 제어할 수 있게 되었다. 제어의 추상화가 이루어진 것이며, 이는 외부에 위임한 루프를 suspend하는 제네레이터가 아니라면 불가능한 일이다. console.log가 있는 render는 for 루프를 돌지만 실제로는 루프를 도는 것이 아니라 dataLoader5함수가 yield하는 것을 기다릴 뿐이다. dataLoader5는 각기 다른 제어를 가지는 비동기/동기 제네레이터를 yield로 위임받을 뿐이다. 이렇게 제네레이터는 위임받는 외부 함수의 루프를 suspend할 수 있는 능력이 있기 때문에 각기 다른 제어를 제네레이터가 가져가고 외부함수의 제어를 추상화할 수 있는 것이다. 이러한 CPS의 suspend 동작은 언어차원에서 문(statement*)로 지원하느냐의 차이만 있을 뿐 함수로 전부 구현 가능하다.(앞서 3장에서 구현해보았다). 제어의 추상화가 이루어졌다면, 각각의 로직이 변경되더라도 render6의 변화는 일어나지 않는다.

Pass Param

그런데 뭣하러 Async를 그룹화를 하는거지? 이라는 질문이 떠오른다면 맥을 짚은 것이다.. 우리는 비동기작업을 그룹화하여 순서만 만들어줬지 정작 순서를 이용해서 아무 것도 하지 않았다. 비동기 작업에 순서가 필요한 경우는, 앞서 처리가 끝난 데이터를 가지고 그 뒤의 비동기 작업을 하는 경우가 될 것이다. 즉 그룹화를 통해 데이터를 뒤의 순서에 넘겨줄 수 있을 때 순차적 비동기 Sequential Async가 의미있어 질 것이다. 그것도 앞서 본 제어의 추상화와 일반화를 유지하면서 말이다.

우선, 제네레이터가 참고할 수 있는 어휘를 생각해보자. 제네레이터가 이터레이터로 만들어졌을 때 확정되는 어휘는 오직 제네레이터 함수의 인자와 지역변수 뿐이다. 이것이 함수형 제네레이터의 한계점이다. 따라서 함수형 제네레이터에서 변경된 어휘공간을 참고하려면 매번 새로운 제네레이터를 생성하는 수밖에 없다. 이 한계점을 극복하기 위해서는 this라는 컨텍스트를 추가적으로 가지는 클래스 메소드로 제네레이터를 만들 수 있다. a라는 제네레이터와 b라는 제네레이터를 각각 호출해서 이터러블 인자로 넘겨줄 때, 제네레이터를 이미 생성했지만 this라는 인스턴스의 컨텍스트를 사용해서 좀 더 늦게 lazy한 binding을 유도할 수 있다.

다음 코드를 보자

const url = (url,opt={method='GET'})=>new URL(url,opt);
const URL = class{
	#url; #opt;
	constructor(url,opt){
		this.#url= url;
		this.#opt = opt;
	}
	async *load(){
		yield await (await fetch(this.#url,this.#opt)).json();
	}
}

클래스 메소드로 제네레이터를 사용하는 것은 ES2015부터 가능했지만 앞서 async Iterable의 도입과 같이 async 제네레이터도 메서드로 사용이 가능해졌다. load 제네레이터는 어떤 인자도 없이 인스턴스의 this 컨텍스트를 통해 값을 yield한다. 이제 url은 클래스를 대신 생성해주는 팩토리함수가 됐다. 하지만 아직까지도 생성시점의 인자만을 이용해 이터러블 객체를 생성하고 있다. 생성 이후에 바꿀 수 있도록 변경해볼 것이다.

그 전에 async iterable 객체대신 URL 클래스 인스턴스를 인자를 받도록 앞선 코드의 함수를 살짝만 바꿔보자

const urls = async function* (...urls) {
  const r = [];
  for (const url of urls) r.push((await url.load().next()).value); 
  yield r;
};

그러면 이제 Async Iterable 객체를 생성하는 AIter라는 클래스를 정의해보자.

class AIter {
  //base async 이터러블 클래스(앞으로 어싱크 이터러블은 모두 이를 사용함)
  update(v) {} //override 안해도 상관없음
  async *load(): any {
    throw "override";
  } //override 해야 함.
}

typescript나 자바라면 존재하는 abstract 키워드가 없기 때문에 자바스크립트는 클래스 메서드의 상속 여부를 다음과 같이 throw 문을 사용해서 표현해야 한다. update 메서드는 AIter 클래스의 자식클래스라면 모두 가지게 될 외부에 노출된 인터페이스이므로 public 메서드지만 구현은 자식클래스가 담당하게 된다. 반면 load 메서드는 throw 구문으로 오버라이드 하지 않을씨 에러를 강제함으로써 abstract 추상 메서드 라는 것을 표현한다. 메서드명으로 짐작할 수 있겠지만, update 메서드는 이터러블 객체를 생성하는 컨텍스트를 변경할 것이고, load 메서드는 자식 클래스가 스스로 반환할 이터레이터 객체를 결정하고 반환하게 될 것이다. 우리가 앞으로 만들 모든 Async Iterable 객체는 AIter를 상속받아 생성하게 된다.

이제 URL 클래스를 만들어보자.

const Url = class extends AIter {
  #url; #opt;
	constructor(url,opt){
		super();
		this.#url= url;
		this.#opt = opt;
	}
  update(json) {
    if (json) this.opt.body = JSON.stringify(json);
  }
  async *load() {
    console.log("body", this.opt.body);
    yield await (await fetch(this.url, this.opt)).json();
  }
};

update 함수는 fetch 통신시 body에 json을 통째로 보내게 된다. 따라서 update 이후에 생성한 load() async 이터러블은 fetch option의 body가 달라진다.따라서 객체를 미리 생성해 놓더라도 load()를 호출한 시점이 update 이전이냐 이후냐에 다라서 다른 이터러블이 생성된다. this 컨텍스트가 lazy binding 하기 때문에 가능해진 일이다.

다수의 url로 이터러블 배열을 생성해주는 urls 클래스를 만들어보자.

const Urls = class extends AIter {
  #urls;
  #body;
  constructor(urls,body) {
    super();
    this.#urls = urls;
  }
  update(json) {
    this.#body = json;
  }
  async *load() {
    const r = [];
    for (const url of this.#urls) {
      url.update(this.#body);
      r.push((await url.load().next()).value);
    }
    yield r;
  }
};
const urls = (...urls)=>new Urls(...urls.map(u=>url(u)));
const Start = class extends AIter{
 *load(){ yield 'start';}
}
const End = class extends AIter{
  *load(){yield 'end';}
}

Urls 객체는 인자로 문자열 주소의 배열을 받는것이 아니라 앞서 만든 Url 클래스의 배열을 받는다. 타입스크립트라면 타입을 컴파일 차원에서 확정할 수 있겠지만, 자바스크립트는 별도의 타입 검증함수를 만들어 런타임에 에러를 내는 것이 최선이다. 다만 이번 강의의 코드에서는 이를 사용하지 않는다. 타입 검증함수는 86 객체지향 자바스크립트의 2강에 나오는 코드를 통해 확인할 수 있다. 필자의 86 객체지향 자바스크립트 강의를 정리한 포스트에도 코드가 올라와있으니 참고하시려면 해당 글을 보시면 된다. Urls 클래스도 update 오퍼레이션을 가지지만 하는 일은 Url 클래스와 다른데, 단순하게 json값을 저장해주기만 한뒤 이를 load 시에 각각의 Url 인스턴스의 update 메서드를 호출하여 업데이트하는 방식이다. 그 후 load() async 제네레이터는 마찬가지로 배열을 yield한다. 팩토리 함수로 문자열 배열을 Url 객체로 바꿔 인자로 전달한 뒤 생성한다. Start와 End도 load 메서드를 오버라이드 하는 AIter 클래스의 자식클래스로 만들어주면 된다. 자바스크립트는 클래스 메서드 오버라이드에서 async가 있고 없고를 구분하지 않고 이름을 기준으로 오버라이드 하기 때문에 일반 제네레이터 메서드로 상속할 수 있다.

그럼 이제 정말로 인자를 넘기는 pass params를 구현해 보자.

const dataLoader7 = async function* (...aIters) {
  let prev;
  for (const iter of aIters) {
    iter.update(prev); //이전 값으로 업데이트 한 뒤 API 호출
    prev = (await iter.load().next()).value;
    yield prev;
  }
};

초기화 하지 않는 prev 변수를 선언해서 AIter 인스턴스들의 load() 제네레이터를 한번 호출하고 next()를 할 때마다 갱신하고 있다. 따라서 루프가 진행되면 AIter 인스턴스들에게 update로 주는 json값이 계속해서 갱신된다.

앞서 실제로 body가 바뀌는지 확인하기 위해서 Url 클래스의 load 메서드에서 console.log 했으므로, 콘솔 출력 결과는 다음과 같다.

처음엔 undefined였던 body가 순차적으로 변화하고 있는 것을 볼 수 있다.

우리는 사용하는 코드를 단순화하고 싶어한다. 우리는 하고 싶은 작업을 먼저 기술한 뒤에, 이를 토대로 함수나 변수 등의 어휘를 구성한다. 중요한 것은, 어휘를 더 단순하고 보기 쉽게 구성하면 구성할 수록, 이를 소비하는 제어문또한 단순해져야 한다는 것이다. 만약 복잡한 제어문을 통해 단순한 어휘를 소비한다면, 우리가 단순화한 어휘가 아주 조금만 바뀌어도 우리는 수십줄, 아니 수백줄의 코드를 수정해야 할 수도 있다. 따라서 우리는 단순한 어휘를 사용하기 위해서는 제어를 위임해야 한다.

const render6 = async function(...aIters) {
  for await (const json of dataLoader5(...aIters)) console.log(json);
};

우리의 사용코드가 어떻게 제어를 위임했는지를 살펴보자. 제어를 위임하기 위해서는 각각의 객체에 제어를 메소드 등으로 제어를 위임해야 하지만 현재 우리는 async 표준 프로토콜을 사용하는 render라는 함수에게 이를 위임하고 있다. 따라서 우리는 언어적으로 Promise의 이터러블을 사용하는 것이 확정된 형태를 그대로 사용하여 제어만 위임하면 되는 것이다. 이렇게 언어적으로 확정된 형태를 사용하면 복잡한 제어는 위임한 채로, 단순하게 json 데이터에 따라 뷰를 렌더링 하는 일종의 로직만 작성하게 된다. 또한, 받아들이는 인자의 클래스 타입에 대한 의존성이 하나도 없이 오직 언어 표준 프로토콜인 async, await, iterator 만 남고 나머지 타입에 대한 의존성은 모두 dataLoader7가 가져갔다. 순수하게 값만 다루는 사용코드가 되었다.

const dataLoader7 = async function* (...aIters) {
  let prev;
  for (const iter of aIters) {
    iter.update(prev); //이전 값으로 업데이트 한 뒤 API 호출
    prev = (await iter.load().next()).value;
    yield prev;
  }
};

그렇다면 이제 클래스 타입에 대한 지식은 dataLoader7 함수만 가져가게 되었다. AIter 클래스가 아무리 변화해도, render함수는 변하지 않고 dataLoader7 함수만 변한다. 이로써 우리는 지식을 분리하고 있다. dataLoader7은 render 함수가 소비할 prev에 대한 정책과 AIter 클래스의 인터페이스를 사용하는 지식만을 가지고 있다. 최종적으로, 실제 load() 제네레이터의 제어는 각각의 구상 AIter 클래스들이 담당한다.

이러한 제어위임을 통해 우리는 render함수의 거대한 3중 If 문으로부터 탈출할 수 있었다. 대신 각각의 클래스 타입으로 복잡한 제어를 밀어넣었다. 디자인 패턴의 전략패턴에 해당된다고 할 수 있다. 어떠한 문제를 추상적인 수준에서 범용적으로 정의하고 도메인/지식에 따라 변경되는 부분을 전략(Strategy)로 외제화 하여 외부에서 주입받는 것이 전략패턴이다. 이 때 외부에서 주입받는 전략은 코드를 객체로 만드는 Composition을 통해 OCP를 준수할 수 있게 된다. 우리는 AIter 클래스라는 클래스 타입을 만들고 각각의 구상 클래스 객체를 dataloader 함수에 주입함으로써 전략패턴을 사용했고, 제네레이터 메소드를 통해 루프의 비효율성을 제거하고 지연된 평가를 할 수 있었다. 이렇게 순차적으로 async를 처리하면서 그룹을 만들고, 인자를 전달할 수 있게 되었다.

profile
inudevlog.com으로 이전해용

0개의 댓글