[es6] 간단한 순차 처리기 만들기

개요

일반적으로 코딩 시 실행되는 최소의 단위는 문(statement)입니다만 문은 결국 중문의 형태로 그룹지을 수도 있고 더 나아가 함수에 넣으면 함수를 호출할 때까지 지연되는 하나의 묶음으로 만들 수도 있습니다.
이번에 작성할 순차처리기는 기본적으로 함수 단위의 묶음 순차대로 실행해주는 일종의 실행기 입니다.

기본적인 순차처리기(시퀀서)

아주 쉬운 시퀀서라면 그냥 배열을 루프 돌면서 배열 안의 함수를 실행해주는 형태를 생각해볼 수 있습니다.
 

const sequence =arr=>arr.forEach(f=>f());

별거 아니지만 순서대로 실행할 수 있는 함수 실행기를 만들었습니다. 이제 사용해보죠.

sequence([
  _=>console.log(1),
  _=>console.log(2)
]);

 
큰 무리 없이 1, 2가 찍힙니다.

빠져 나오기

시퀀스는 보통 제어 로직을 지원하게 되는데, 가장 간단한 제어 로직인 중간에 빠져나오기를 구현해보죠. 이를 위해서 포함되는 함수가 truthy를 반환하면 멈추는 걸로 하겠습니다(왜냐면 아무것도 안하면 그냥 진행되는 걸로 해야 특별히 멈추고 싶을 때만 return하면 되거든요)

const sequence=arr=>arr.some(f=>f());

고작 이 정도면 truthy를 반환하는 시점에 정지시킬 수 있겠죠.

 
sequence([
 _=>console.log(1),
 _=>(console.log(2), true),
 _=>console.log(3)
]);

이제 사용해보면 두 번째 함수에서 true를 반환하게 되어 3은 로그에 찍히지 않게 됩니다.

지금까지 구현된 건 마치 함수 몸체가 실행되다가 중간에 return으로 탈출하는 것 같은 효과를 내게 됩니다.
문을 기본 단위로 보지 말고, 여러 문을 묶어둔 함수를 실행의 기본 단위로 본다면 이 함수의 배열을 실행해가는 흐름 속에서의 제어도 가능하게 되는 식의 확장입니다.
 
이쯤에서 비동기적인 시퀀스로 확장해봅시다.

비동기 시퀀스

지금까지는 배열 안의 함수를 무조건 동기적으로 실행하기 때문에 기존 제어문과 동일하게 동기 처리합니다. 하지만 선택적으로 비동기가 가능한 형태를 중간에 삽입할 수 있게 개조해 보죠. 이를 위해 언어 표준인 프라미스를 이용해보겠습니다.
 

 
const sequence =arr=>{
  arr.some((f, idx)=>{
    if(f instanceof Promise){
      f.then(_=>sequence(arr.slice(idx + 1)), _=>_);
      return true;
    }else return f();
  });
};

이제 시퀀스는 배열 안의 요소가 프라미스인 경우 true를 반환하여 실행을 멈추고 대신 then을 대기합니다.
하지만 동기형 함수는 truthy를 반환하여 정지시켰던 것에 비해 프라미스의 경우는 resolve면 계속 전진하고 reject면 정지시키는 형태로 작성했습니다.

sequence([
  _=>console.log(1),
  new Promise(r=>setTimeout(_=>(console.log(2), r()), 500)),
  _=>console.log(3)
]);

이 예에서는 1은 즉시 출력되고, 2는 0.5초 후에 출력되며 이어서 곧장 3이 출력됩니다.

sequence([
  _=>console.log(1),
  new Promise((_, r)=>setTimeout(_=>(console.log(2), r()), 500)),
  _=>console.log(3)
]);

비슷해 보이지만 reject을 호출했기 때문에 2까지만 출력되고 3은 찍히지 않게 됩니다.

현상으로 보면 비동기를 완전히 구현한 것 같지만 실은 그렇지 않습니다. 왜냐면 두번째 요소인 프라미스는 엄밀히 말하면 두번째 요소를 실행하려는 시점에 프라미스가 활성화되어야 하는데 이 코드대로 라면 시퀀스에 인자로 할당하는 순간 이미 활성화되기 때문입니다. 이 문제를 해결하려면 프라미스 객체를 인자로 받으면 안되고 프라미스 객체를 만들어낼 함수를 인자로 받아야만 합니다. 즉 함수를 호출한 결과물이 프라미스인 경우 비동기 시퀀스가 되도록 수정해야 합니다.

 
const sequence =arr=>{
  arr.some((f, idx)=>{
    const result = f();
    if(!result) return;
    if(result instanceof Promise) result.then(_=>sequence(arr.slice(idx + 1)), _=>_);
    return true;
  });
};

위 코드에서는 f를 직접 프라미스로 평가하지 않고 우선 호출한 뒤 그 결과를 판단하는 구조로 되어있으므로 인자로 주어질 함수 안에 프라미스를 담으면 됩니다.

sequence([
  _=>console.log(1),
  _=>new Promise((_, r)=>setTimeout(_=>(console.log(2), r()), 500)),
  _=>console.log(3)
]);

이제 진정으로 두 번째가 되었을 때 프라미스가 발동되는 방식이 되었습니다. 기본적인 동기 비동기의 혼합 시퀀스를 작성했으므로 코루틴으로 발전시킬 수 있습니다.

코루틴

비동기 시퀀스를 만들었다고는 하나 시퀀스를 중단 시키는 제어 외에는 없는 상태입니다. 보통 함수가 진입하여 한 번 반환되는 경우 루틴이라고 부르는데, 하나의 함수에 여러 번 진입하고 여러 번 반환하는 경우 여러 번이라는 의미의 co가 붙어 코루틴이라고 합니다.
 
함수가 코루틴이 되려면 내부적으로 supend상태(일시정지)를 기록할 수 있는 능력이 필요하므로 함수 그 자체를 반환하는 것으로는 불충분하고 supend지점을 기억할 수 있는 형태의 객체가 되어야 합니다. 이를 언어 내부에서 문 단위로 처리할 수 있는 기능이 제네레이터(generator)입니다만, 이미 비동기를 포함하여 코루틴을 구현하고 있으므로 async generator에 가까운 스펙이라 할 수 있습니다. 우선 실행 지점을 저장한 상태를 갖도록 최초 호출을 통해 코루틴을 만들도록 해야 합니다.

제네레이터의 경우를 생각해보면 필요한 함수의 종류가 두 가지라는 점을 알 수 있습니다.

  1. 제네레이터 함수
  2. 제네레이터를 호출한 결과로 받게 되는 이터레이터 객체

헌데 시퀀스가 이미 배열로 함수들을 받아들이므로 이 시퀀스 함수 자체가 이터레이터 같은 역할을 하게 될 것입니다. 보다 상세한 코루틴의 정의를 해보죠.

  1. 이제 더 이상 truthy로 시퀀스를 중지시키는 것 외에도 yield라는 제어가 추가로 생겼음.
  2. 따라서 단지 truthy, falsy만으로는 진행, 중지, yield를 구분할 수 없음.
  3. 사전에 미리 Cosequence의 상수로 STOP과 YIELD를 정의하여 이 반환 값으로 대해 처리를 분기함.
  4. 프라미스의 경우는 STOP, YIELD 둘 다 reject에서 처리함.

이 정도를 정했으니 실제 코드로 옮겨봅시다.

const STOP = {}, YIELD = {};
const sequence =(arr, self)=>(self=_=>{//1
  if(!arr.length) throw 'is ended'; //2
  arr.some((f, idx)=>{ //3
    const result = f();
    if(result instanceof Promise){ //4
      arr = arr.slice(idx + 1);
      result.then(self, r=>{if(r == STOP) arr.length = 0;}); //5
      return true;
    }else if(result == STOP){ //6
      arr.length = 0;
      return true;
    }else if(result == YIELD){ //7
      arr = arr.slice(idx + 1);
      return true;
    }else{ //8
      return false;
    }
  });
});

이전 시퀀스함수와 가장 다른 점은 실행되는 게 아니라 실행할 함수를 반환한다는 점입니다. 이 구조를 차근차근 살펴보죠.(위 코드의 주석번호와 일치합니다)

  1. 새로 생성될 함수 내부에서 자신을 참조할 필요가 있어 쓰지 않는 인자를 하나 생성하여(self) 여기에 할당해둡니다.
  2. arr의 길이가 0인데 호출하려고 하면 예외를 발생시킵니다.
  3. 기존처럼 arr을 some으로 순회합니다.
  4. 요소함수를 호출한 결과가 프라미스라면, 배열을 현 시점 이후만 남기도록 우선 끊습니다.
  5. resolve의 경우는 자신을 다시 호출하여 이후 요소를 진행하게 됩니다. reject인 경우 STOP이라면 완전히 arr를 비워서 더 이상 호출할 수 없게 만들면 됩니다.
  6. 동기 함수는 STOP인 경우 마찬가지로 arr를 비웁니다.
  7. YIELD라면 다시 현 커서까지만 지우고 다시 호출될 수 있는 여지를 남겨둡니다.
  8. 그 외의 경우는 전부 동기요소를 계속 진행합니다.

이제 이를 사용해보죠.

const co = sequence([
  _=>console.log(1),
  _=>(console.log(2), YIELD),
  _=>new Promise(r=>setTimeout(_=>(console.log(3), r()), 500)),
  _=>(console.log(4), STOP),
  _=>console.log(5)
]);

co(); //1, 2까지 출력
co(); //0.5초 후에 3,4출력
co(); //????

헌데 이 시나리오에서 두 번째 co()를 한 시점에서 이미 시퀀스는 비동기로 접어들게 됩니다.
이 때 다시 세번째 co()하면 0.5초가 지나기 전이므로 3이 먼저 출력되면서 엉망이 되고 말 것입니다.
이를 해결하려면 co내부에서 이미 시퀀스가 비동기로 진입한 경우 이후의 호출을 비동기 이후의 호출로 미룰 수 있게 내부에서 처리해줘야합니다. 이러한 처리를 포함하면 co가 유지해야 하는 상태가 많아져 지금처럼 간단하게는 힘들 것입니다. 하부 코드는 정리하고 co의 생성 부분은 확장하여 여러 상태를 갖도록 하겠습니다.

const STOP = {}, YIELD = {};
const sequence =arr=>{
  let isHold; //1
  const self=_=>{
    if(isHold) throw 'is holded(use then)';
    if(!arr.length) throw 'is ended';
    let promise;
    arr.some((f, idx)=>{
      const result = f();
      if(!result) return;
      if(result instanceof Promise){
        arr = arr.slice(idx + 1);
        isHold = true;
        promise = new Promise(res=>{ //2
          result.then(
            _=>{
              isHold = false;
              if(arr.length) self();
              res();
            },
            r=>{
              isHold = false;
              if(r == STOP) arr.length = 0;
              res();
            }
          );
        );
      }else if(result == STOP) arr.length = 0;
      else if(result == YIELD) arr = arr.slice(idx + 1);
      return true;
    });
    if(promise) return promise; //3
  };
  return self;
};

앞의 코드에 비해 다음과 같은 점이 변경되었습니다(주석과 일치)

  1. isHold라는 상태가 추가되고
  2. result가 프라미스인 경우 resolve시점에 다음 co를 호출할 수 있도록 Promise로 감싸 안게 되었습니다.
  3. 이런 경우 co함수는 반환값으로 프라미스를 주게 됩니다.

isHold의 경우 프라미스 상황에서 묶인 뒤 해당 프라미스가 해소되는 시점에 풀리게 됩니다. 이러한 이유로 co는 더 이상 처리할 배열이 없을 때도 예외 상황이지만 isHold인 경우도 예외를 처리할 수 있게 되었습니다.
이제 다음과 같이 실행해볼 수 있습니다.

const co = sequence([
  _=>console.log(1),
  _=>(console.log(2), YIELD),
  _=>new Promise(r=>setTimeout((_, r)=>(console.log(3), r(YIELD)), 500)),
  _=>(console.log(4), STOP),
  _=>console.log(5)
]);

co(); //1, 2까지 출력
co().then(co); //0.5초 후에 3출력 후 4출력
//5는 출력되지 않음

값의 전달

다음과 같이 연속된 fetch를 사용하는 경우를 생각해보죠.

const co = sequence([
  _=>fetch('test.json'),
  response=>response.json(),
  json=>console.log(json)
]);

아마 당연하게도 이렇게 사용하고 싶을 것입니다. 여기에는 사소한 문제가 몇 가지 있습니다.

  1. 동기 함수의 경우 이미 반환 값을 제어 처리에 사용하고 있다.
  2. 비동기 함수의 경우도 제어 처리를 하는 경우는 이미 reject의 인자를 소비하고 있다.

가장 자연스러운 흐름은 함수의 반환 값이나 resolve의 인자를 통해 다음 함수에게 전달하는 것이겠지만 반환 값을 제어에 사용하고 있으므로 용이하지 않습니다. 이는 YIELD상황에서 중간에 값을 주는 문제로도 연결되며 더 나아가 YIELD후 재게될 때 값을 받아들이는 문제도 한꺼번에 해결하는 편이 낫습니다.
이 해결 방법은 다양한 디자인이 존재할 수 있습니다만 이 포스팅에서는 STOP과 YIELD값 안에 반환 값을 넣는 것으로 해결하겠습니다.
따라서 다음과 같이 해소됩니다.

동기함수
1. 일반적인 값 반환 – return 값
2. 정지 시키면서 값 반환 – return STOP(값)
3. YIELD하면서 값 반환 – return YIELD(값)

프라미스
1. 일반적인 값의 반환 – resolve(값)
2. 정지 시키면서 값 반환 – reject(STOP(값))
3. YIELD하면서 값 반환 – reject(YIELD(값))

흐름 제어 시 약간 불편하긴 하지만 대신 정상적인 프라미스 resolve나 일반적인 동기함수의 값 반환이 훨씬 더 많이 사용된다는 점을 고려해서 결정해봤습니다. 이제 이 디자인에 맞춰 기존 함수를 수정합니다.

이 디자인을 실제로 구현하는 것은 굉장히 복잡한 비동기 흐름에 대한 이해를 필요로 합니다. 특히 프라미스의 resolve를 홀딩해두고, 동기 비동기에 따라 반환값이 달라지는 등의 복잡한 처리를 요구하게 됩니다. 차근차근 구성 요소별로 설명하겠습니다.

1. STOP, YIELD

이제 STOP과 YIELD는 단순한 제어 마커가 아니라 값을 담을 수 있는 래퍼 역할을 수행해야 합니다. 오브젝트를 사용할 수도 있지만 여기서는 타입을 이용하여 보다 안전하게 디자인 하겠습니다.

const StopV = class{constructor(v){this.v = v;}};
const YieldV = class{constructor(v){this.v = v;}};
const STOP =v=>new StopV(v);
const YIELD =v=>new YieldV(v);

이제 STOP(3) 이라고 사용하면 실제로는 3을 담고 있는 StopV의 인스턴스가 반환될 것입니다.

2. 내부 상태 추가

우선 코드를 보죠.

const sequence =(...arr)=>{

  const INSIDE = {};
  let holder, resolver;

  const co = (v, inside)=>{

    ..여러가지 처리

    return holder || v;
  };
  return co;
};

이제 작성하게 될 시퀀스는 위와 같은 모양을 갖습니다.

  1. INSIDE – 내부에서 생성된 co함수는 두 번째 인자로 특별한 inside객체가 오는 경우와 아닌 것으로 내부 호출과 외부 호출을 구분하게 됩니다. 내부 호출에서 co를 사용하는 경우는 co(값, INSIDE) 형태로 사용하고 외부에서 호출할 때는 co(값) 형태로 사용됩니다. 내 외부의 호출 구분이 필요한 이유는 co함수 내부 구현에서 설명합니다.
  2. isHold – 만약 현재 실행 중인 함수가 프라미스라면 값을 반환하지 않고 그 프라미스를 기다리는 프라미스를 반환하게 됩니다. 문제는 스퀀스 상에 프라미스가 여러 개 있다면 외부에서는 then이 호출되는 시점이 이번 한 개의 프라미스가 해소될 때가 아니라 여러 개의 시퀀스 프라미스가 해결된 후에 최종 값을 받기를 바란다는 것입니다. 따라서 최초 외부에 프라미스를 반환한 경우 이 프라미스를 마지막까지 기억하여 해소해야 하므로 이것을 기억하는 내부 변수가 holder가 됩니다.
  3. resolver – 2번에서 설명한 holder가 진행 중인 상태라면 이 holder를 해소하기 위한 리졸버를 co스코프 밖에서 잡아두어야 할 것입니다. 그것을 위한 변수입니다.

이 외에도 co함수가 첫 번째 인자로 값을 받을 수 있다는 것을 볼 수 있습니다. 이제 코루틴의 각 진입과 반환에서 상호작용이 양방향으로 가능해졌습니다.

3. co함수의 큰 틀

이번에도 코드를 먼저 보고 나서 설명하겠습니다.

const sequence =(...arr)=>{
  const INSIDE = {};
  let holder, resolver;
  const co = (v, inside)=>{

    //1
    if(holder && inside != INSIDE) throw 'is holded(use then)';
    if(!arr.length) throw 'is ended';

    let promise;
    arr.some((f, idx)=>{

      //2
      v = f(v);

      //3
      let promise;
      if(v instanceof Promise){
        //프라미스처리
        return true;
      }else if(v instanceof StopV){
        arr.length = 0, v = v.v;
        return true;
      }else if(v instanceof YieldV){
        arr = arr.slice(idx + 1), v = v.v;
        return true;
      }
    });

    //4
    if(INSIDE && !promise) resolve(v);
    else return holder || v
  };
  return co;
};

각 주석에 번호에 맞춰 설명합니다.

  1. 기존에는 남은 배열 요소가 있는지만 검사하는 수준이었지만 지금은 holder가 이미 존재하는 상태에서 내부 호출이 아닌 경우도 예외로 처리합니다. 이미 holder가 존재한다면 외부에서는 then으로만 접근해야 하기 때문에 기본으로 막지만, 내부에서는 시퀀스가 계속 진행될 수 있어야 하므로 허용합니다.
  2. 이제 함수를 호출하면 그 값을 다시 v에 넣어 다음 함수의 인자로 다시 전달해줍니다. some을 돌면서 계속 이번 함수의 반환 값 v가 다음 함수의 인자로 전달되는 구조입니다.
  3. v값에 따라 특수한 경우에 대한 처리를 합니다. 비동기인 프라미스, STOP, YIELD의 호출 결과에 따른 제어처리가 발동하면 return true로 some을 끊어줍니다. STOP, YIELD는 내부에서 값을 꺼내는 과정만 추가되었을 뿐 기존과 동일하게 배열을 정리해주는 수준입니다.
  4. 마지막으로 some이 끝나면 이번 호출의 co처리를 종결하고 최종 값을 반환합니다. 만약 3번에서 프라미스가 되었다면 holder가 반환될 것이고 아니라면 함수의 최종 결과인 v가 반환됩니다. 하지만 내부의 비동기 시퀀스가 진행되다가 다시 동기함수로 시퀀스가 전환되는 경우는 resolve로 보고해야 할 것입니다. 이 점은 다음 섹션의 프라미스 구현에서 보다 자세히 다룹니다.

이 방법을 통해 시퀀스 중 비동기프라미스가 최초로 등장하는 시점에는 무조건 holder가 반환되어 프라미스가 나오므로 외부에서는 반드시 then을 통해서만 값을 받을 수 있게 됩니다. 그에 비해 전부 동기함수라면 즉시 값을 받을 수 있을 것입니다.
다음은 다소 복잡한 프라미스 부분의 처리를 보겠습니다.

4. 프라미스 부분의 구현

코드로 들어가기 전에 아이디어를 정리해보죠.

  1. 최초로 비동기가 시작되면 외부에 반환할 프라미스를 만들어야 한다.
  2. 그 프라미스는 시퀀스 상의 프라미스가 전부 해소된 시점이나 중간에 STOP, YIELD등이 발생한 시점에 해소되어야 한다.
  3. 일단 연속된 프라미스가 해소되거나 시퀀스 상의 프라미스들이 해소된 후에 동기형 함수가 쭉 전개되고 난 뒤라면 resolve시켜야 할 것입니다.
  4. 결국 holder가 만들어지는 시점과 해소되는 시점은 굉장한 시간 차이가 날 수 있으므로 사전에 holder의 리졸버를 미리 resolver라는 스코프에 잡아두었다가 필요할 때 해소하는 전략인 셈입니다.

자세한 내용은 코드에 직접 주석으로 설명하면서 전개하죠.

const sequence =(...arr)=>{
  const INSIDE = {};
  let holder, resolver;
  const co = (v, inside)=>{
    if(holder && inside != INSIDE) throw 'is holded(use then)';
    if(!arr.length) throw 'is ended';
    let promise;
    arr.some((f, idx)=>{
      v = f(v);
      
      if(v instanceof Promise){

        arr = arr.slice(idx + 1);
        //holder가 있든 없든 일단 promise를 만들고 보자.
        promise = new Promise(res=>{
          //이전 홀더가 없는 상태라면 지금의 res를 resolve로 잡아둔다.
          if(!holder) resolve = res;

          //이제 원래 v로 들어온 프라미스를 해소해간다.
          v.then(
            v=>{
              if(arr.length) co(v, INSIDE); //내부 호출은 INSIDE를 첨부함!
              else holder = null, resolve(v); //홀더를 풀고 해소한다
            },
            v=>{
              if(v instanceof StopV) arr.length = 0, v = v.v;
              else if(v instanceof YieldV) v = v.v;
              holder = null, resolve(v); //마찬가지로 해소한다.
            }
          );
        });
        //홀더가 없는 상황이면 새로운 홀더로 만든다.
        if(!holder) holder = promise;

        return true;
      }else if(v instanceof StopV){
        arr.length = 0, v = v.v;
        return true;
      }else if(v instanceof YieldV){
        arr = arr.slice(idx + 1), v = v.v;
        return true;
      }
    });
    if(INSIDE && !promise) resolve(v);
    else return holder || v;
  };
  return co;
};

이제 모든 구성요소가 완성되었으니 실제로 사용해보죠.

const co = sequence(
  v=>new Promise((r, _)=>setTimeout(_=>(console.log(v), r(1)), 500)),
  v=>new Promise((r, e)=>setTimeout(_=>(console.log(v), e(YIELD(2))), 500)),
  v=>(console.log(v), 3),
  v=>new Promise((r, _)=>setTimeout(_=>(console.log(v), r(4)), 500))
);
co(0).then(v=>{
  console.log('a:', v)
  co(v).then(v=>{console.log('b:',v)});
});
//0, 1가 차례로 0.5초마다 등장
//a:2 등장
//2, 3 등장
//b:4 등장

결론

간단한 함수 시퀀스 처리기를 만들어보았습니다. 언어 차원의 기능은 아니기 때문에 문이나 식 단위로 처리되지는 않습니다만 연속된 함수를 인자로 받아 순차적으로 실행하되, 그 함수 내부에 프라미스가 있는 경우도 부드럽게 비동기를 시퀀스에 포함시킬 수 있게 만들었습니다. 이러한 비동기를 포함하는 시퀀스는 async나 async generator의 기능을 대체할 수 있으므로 범용적으로 사용할 수 있는 기법입니다.
최종적으로 정리된 코드는 다음과 같습니다.

const StopV = class{constructor(v){this.v = v;}}; //1
const YieldV = class{constructor(v){this.v = v;}}; //1
const STOP =v=>new StopV(v), YIELD =v=>new YieldV(v); //1
const sequence =(...arr)=>{
  const INSIDE = {};
  let holder, resolver;
  const co = (v, inside)=>{
    if(holder && inside != INSIDE) throw 'is holded(use then)';
    if(!arr.length) throw 'is ended';
    let promise;
    arr.some((f, idx)=>{
      v = f(v);
      if(v instanceof Promise){
        arr = arr.slice(idx + 1);
        promise = new Promise(res=>{
          if(!holder) resolve = res;
          v.then(
            v=>{
              if(arr.length) co(v, INSIDE);
              else holder = null, resolve(v);
            },
            v=>{
              if(v instanceof StopV) arr.length = 0, v = v.v;
              else if(v instanceof YieldV) v = v.v;
              holder = null, resolve(v);
            }
          );
        });
        if(!holder) holder = promise;
      }else if(v instanceof StopV) arr.length = 0, v = v.v;
      else if(v instanceof YieldV) arr = arr.slice(idx + 1), v = v.v;
      else return;
      return true;
    });
    if(INSIDE && !promise) resolve(v);
    else return holder || v;
  };
  return co;
};
%d 블로거가 이것을 좋아합니다: