[js-ex] 루프 추상화 #1

개요

함수형 언어가 강점으로 제시하는 많은 기능이 있습니다만 사실 현대화된 언어는 제어문도 충분한 기능이 갖춰져 있기 때문에 기법의 차이일 뿐 대부분 함수형의 강점을 흡수할 수 있습니다. 특히 모나드나 함수의 합성 등은 OOP기법을 통해서도 목적을 이룰 수 있기 때문에 상태를 배제하려는 방향으로만 생각할 필요는 없습니다. 이러한 보다 깊은 형태의 제어문을 다루기 위한 첫번째 시리즈로 루프에 대한 추상화 시리즈를 진행합니다. 이 글은 루프 추상화에 대한 첫 번째 글로 제네레이터나 async의 도움이 없는 상태에서 추상화하는 레벨을 먼저 다룹니다.
단순한 인덱스 루프보다 좀 더 복잡한 그래프구조의 루프를 기반으로 차근차근 생각을 전개해보죠.

  • [js-ex]는 js-expert 시리즈로 중급자를 대상으로 하고 있습니다.

그래프구조의 루프

json이나 js의 오브젝트는 복잡한 계층구조를 갖고 있어 깊은 복사나 각 원소 전체에 뭔가 적용할 때 그래프구조를 갖습니다.
그래프 구조를 루프돌 때 다양한 기법이 있지만 제어문과 스택을 사용하면 간단하면서도 고속으로 처리할 수 있습니다.
사실 꼬리물기 최적화의 원리를 이해한다면 다음 함수 호출 시 인자를 메모리처럼 사용한다는 사실로부터 매번 만들어지는 인자를 스택 구조에 저장하여 꺼내는 것으로 얼마든지 제어문의 루프로 바꿀 수 있다는 것을 알 수 있습니다.
 
꼬리물기 최적화를 지원하지 않는 기반 위의 함수형 언어들(스칼라, 클로저, 코틀린 등)이 바로 이 원리를 이용해 꼬리물기 최적화를 컴파일 시에 제어문의 루프로 고쳐쓰기 하는 것입니다.(함수 호출을 점프로 바꾸면서 인자 푸시를 인자 대입으로 바꾸는 방식으로 처리된다는 가르침을 받았습니다!)

말 만으로는 이해가 어려우니 간단히 json구조를 순회하면서 깊은 복사를 하는 함수를 하나 생각해보죠.

  1. json은 객체, 배열, 숫자, 문자, 불린이 올 수 있습니다.
  2. 간단히 객체와 배열이 아닌 값들은 즉시 반환합니다.
  3. 객체, 배열은 복제를 진행하면서 원소는 스택에 넣습니다.

이를 가볍게 구현하면 아래와 같이 될 것입니다. 

const clone = json=>{
  const result = [], stack = [{c:result, k:0, v:json}];//2,3
  let ctx;
  while(ctx = stack.pop()){//4
    let {c, k, v} = ctx;
    if(!v || typeof v != 'object') c[k] = v; //3
    else if(Array.isArray(v)){
      c = c[k] = [];
      v.forEach((v, k)=>stack.push({c,k,v}));//4
    }else{
      c = c[k] = Object.create(null);
      Object.entries(v).forEach(([k,v])=>stack.push({c,k,v}));//4
    }
  };
  return result[0];//1
};
const newjson = clone(json);

처음 등장하는 코드라 간단히 살펴보죠(번호에 맞게 주석이 달려있습니다)

  1. 결과는 최종적으로 result[0]이 될 예정입니다.
  2. stack에는 매 stack마다의 context를 저장하게 됩니다.
  3. context의 구조를 보면 {c:컨테이너, k:키, v:값} 형태로 되어있습니다. 결국 루프는 이 컨텍스트를 처리하여 c[k] = v 를 해주는 것으로 이해할 수 있습니다.
  4. stack은 루프를 진행하면서 재귀적인 작업이 필요해지면 다시 stack에 context가 추가되는 식으로 작동하므로 더 이상 stack에 추가되지 않아야 끝나게 됩니다.

이제 루프 내부를 보죠.

  1. 최초의 if에서 기본 값인 경우를 찾아서 원래 기대했던 c[k] = v 를 수행합니다. 이 경우 더 이상 stack에 추가되지 않으므로 만약 이 경우로 context의 v가 수렴된다면 루프는 종료될 것입니다.
  2. 하지만 기본 값이 아닌 경우는 객체이므로 배열이거나 일반 오브젝트로 분리하여 처리하고 있습니다.
  3. 배열은 c[k]에 새로운 배열을 만들어 넣어주고 그 원소는 여기서 채우는게 아니라 다시 stack에 context를 추가하여 루프에게 위임해버립니다. 바로 여기서 재귀적인 호출을 대신하는 효과가 나게 되죠.
  4. 이제 새로 추가될 context에서는 c가 새로운 배열이 되고, k는 인덱스, v는 원래 있던 값이 될 것입니다. stack에 추가되고 있는 context는 그것을 나타냅니다.
  5. 일반 오브젝트는 배열과 대동소이하지만 새로운 오브젝트를 만들어 entries로 context를 stack에 추가하게 됩니다.

이러한 stack이 없어질 때까지 context처리를 해주는 루프를 통해 완전한 복제를 이뤄냅니다.

이를 좀 더 알기 쉽게 한 줄씩 보면 다음과 같습니다.

const clone = json=>{
  
  // 최종 결과는 result안에 들어오고 
  // stack의 최초 context에는 'result[0] = 결과'를 넣도록
  // container에 result, key에 0, value에 최초의 json을 넣었음
  const result = [], stack = [{c:result, k:0, v:json}];

  let ctx; //매 stack마다 현재의 컨텍스트를 가리킴

  // stack을 하나씩 까나가면서 처리, stack이 비워지면 종료
  while(ctx = stack.pop()){

    // 현재 stack을 해체하여 c(container), k(key), v(value)로 분리함
    let {c, k, v} = ctx; 

    // 최우선으로 v(값)가 기본형인 경우 더 이상 하위 탐색이 필요없으므로
    // c(컨테이너)에 미리 정해진 k(키)로 v를 넣어주고 추가적인 stack은 생성안함
    // 즉 이 경우로 v가 수렴하면 더 이상 stack은 생성되지 않고 소진만 되므로
    // 루프가 종료하는 방향이 됨
    if(!v || typeof v != 'object') c[k] = v;

    // 위에서 기본형을 제외하면 전부 오브젝트형이므로 보다 구상형부터 처리해야 함
    // 따라서 보다 구상형인 배열을 우선 처리함
    else if(Array.isArray(v)){

      // v가 배열이므로 컨테이너의 키에는 새로운 배열을 넣어주고
      // 이 새 배열을 c로 명명함
      c = c[k] = []; 

      // 원래 값인 v배열을 돌면서 stack에 원소수만큼 context를 생성하여 추가함
      // 컨테이너는 새로 만든 배열인 c가 되고, 키는 인덱스이며, 값은 기존 배열의 값
      // 직접 새 배열에 할당하지 않는 이유는 배열의 원소가 다시 배열이나 오브젝트가
      // 될 수 있기 때문에 다음 stack루프에서 다시 경우를 나눠서 처리해야 하기 때문임
      v.forEach((v, k)=>stack.push({c,k,v}));

    // 최종 오브젝트형의 처리
    }else{

      // 위의 배열 때처럼 새로운 오브젝트를 생성하고 이를 c라 함
      c = c[k] = Object.create(null);

      // v를 [[k,v],[k,v]..] 형태로 반환하는 entries의 배열로 바꿔
      // 순회하면서 stack에 추가해 줌. 각 원소가 다시 배열이나 오브젝트가 될 수 있음
      Object.entries(v).forEach(([k,v])=>stack.push({c,k,v}));//4
    }
  };

  //최종결과는 최초 stack에서 설정된데로 result의 0번 원소가 됨.
  return result[0];
};

고작 이 정도면 스택오버플로우 걱정없이 고속으로 깊은 복사를 처리할 수 있게 되는 거죠. 하지만 단순히 json의 깊은 복사를 얻고 싶다면 더욱 고속인

newjson = JSON.parse(JSON.stringify(json));

으로도 충분할 것입니다(내부의 c가 처리하므로 훨씬 빠릅니다)

map과 클래스화

하지만 위와 같은 복사 함수를 만들면

  1. 배열, 객체 외에도 보다 다양한 수준으로 값을 판정하여 복제 정책을 정할 수 있을 뿐만 아니라
  2. 원소를 그냥 복사하지 않고 함수를 적용할 수 있는 찬스도 갖게 됩니다.

우선 2번 항목에서 언급한 원소에 적용할 함수를 인자로 받아보죠.

const clone =(json, f)=>{
  const result = [], stack = [{c:result, k:0, v:json}];
  let ctx;
  while(ctx = stack.pop()){
    let {c, k, v} = ctx;
    if(!v || typeof v != 'object') c[k] = f(v);
    else if(Array.isArray(v)){
      c = c[k] = [];
      v.forEach((v, k)=>stack.push({c,k,v}));
    }else{
      c = c[k] = Object.create(null);
      Object.entries(v).forEach(([k,v])=>stack.push({c,k,v}));
    }
  };
  return result[0];
};
const newjson = clone(json, v=>(console.log(v), v));

이 전과 달라진 점은
1. 두 번째 인자 f로 함수를 받아들이고
2. 원소가 기본 값인 경우 그냥 컨테이너에 넣지 않고 함수를 적용한 값을 넣는다는 것입니다.

단지 이것 만으로 모든 원소는 콘솔에 출력할 수도 있고 값을 변경할 찬스를 얻게 됩니다. 함수형에서 자주 등장하는 identity나 tab을 손쉽게 적용할 수 있죠.

좀 더 추상적인 레벨에서 생각해보면 이건 마치 배열의 map함수를 모든 구조의 객체에 사용할 수 있게 확장한 것이라 할 수 있습니다.

보다 일반화하여 map이라 함수의 이름을 바꿀 수 있을 것입니다. 여기서 한 단계 더 OOP적으로 사고하면 map을 아예 메소드로 갖는 클래스를 생각해볼 수 있습니다.
최초 불변 값을 받아 map메소드로 처리해 새로운 값을 반환하는 Wrapper클래스를 구현해보죠.

const Wrapper = class{
  constructor(v){
    this.v = v;
  }
  map(f){
    const result = [], stack = [{c:result, k:0, v:this.v}];
    let ctx;
    while(ctx = stack.pop()){
      let {c, k, v} = ctx;
      if(!v || typeof v != 'object') c[k] = f(v);
      else if(Array.isArray(v)){
        c = c[k] = [];
        v.forEach((v, k)=>stack.push({c,k,v}));
      }else{
        c = c[k] = Object.create(null);
        Object.entries(v).forEach(([k,v])=>stack.push({c,k,v}));
      }
    };
    return result[0];
  }
};
const wrap = new Wrapper({a:3, b:'b'});
const newValue = wrap.map(v=>v); //{a:3, b:'b'}

chaining, undo, redo

map메소드는 Wrapper 자신이 아니라 생성시 받은 v의 사본을 반환하기 때문에 메소드 체이닝(method chaining)이 불가능합니다.
이를 가능하게 하려면 다시 Wrapper로 감싸서 반환하거나 this.v를 갱신할 필요가 있습니다. 굳이 함수형을 지향하지는 않기 때문에 매번 새로운 Wrapper를 만들 생각은 없지만 생성되는 값은 항상 새로운데 이를 this.v에 두는 것은 미묘하므로 변경되어가는 값에 대한 스택을 쌓아두기로 하겠습니다. 이를 위해서는 다음과 코드가 추가로 필요합니다.

  1. 과거의 값을 기억할 stack배열
  2. 복사본의 값을 반환하지 않고 stack를 넣은 뒤 자신을 반환하는 새로운 map메소드
  3. 실제 값을 얻을 때 사용하는 getValue메소드
const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, f = identity)=>{
    const result = [], stack = [{c:result, k:0, v:base}];
    let ctx;
    while(ctx = stack.pop()){
      let {c, k, v} = ctx;
      if(!v || typeof v != 'object') c[k] = f(v);
      else if(Array.isArray(v)){
        c = c[k] = [];
        v.forEach((v, k)=>stack.push({c, k, v}));
      }else{
        c = c[k] = Object.create(null);
        Object.entries(v).forEach(([k, v])=>stack.push({c, k, v}));
      }
    };
    return result[0];
  };
  return class{
    constructor(v){this.stack = [v];}
    get value(){return map(this.stack[this.stack.length - 1]);}
    map(f){
      if(f) this.stack.push(map(this.stack[this.stack.length - 1], f));
      return this;
    }
  };
})();

바뀐 코드를 차근차근 살펴보죠.

  1. 이제 map은 Wrapper를 반환하므로 값을 얻으려면 반드시 get value를 통해야 합니다.
  2. 또한 기존의 map은 메소드에서 내부에서만 사용하도록 은닉된 함수로 변경되었습니다.
  3. get value는 stack의 마지막 값을 그대로 반환하지 않고 언제나 사본을 반환하게 됩니다.

내부의 값을 보호하기 위해 사본만 얻어가게 하는 것이죠. 이제 간단히 테스트해보죠.

const wrap = new Wrapper({a:3, b:'b'});
console.log(wrap
  .map(v=>typeof v == 'number' ? v * 2 : v) //숫자면 두배
  .map(v=>typeof v == 'string' ? '[' + v + ']' : v) //문자열이면 []로 감싼다
  .value
); 
//{a:6, b:'[b]'}

분명 내부 stack에는 차곡차곡 저장되어 있을텐데 undo, redo가 없으니 꺼내볼 수가 없습니다. undo는 스택을 까기만 하면 되기 때문에 괜찮지만 redo까지 구현하려면 cursor개념을 도입해야 합니다. 이미 stack이 있으므로 간단히 undo, redo와 cursor를 구현해보죠.

const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, f = identity)=>{...};
  return class{
    constructor(v){
      this.stack = [v];
      this.cursor = 0;
    }
    get value(){return map(this.stack[this.cursor]);}
    map(f){
      if(f){
        this.stack.length = this.cursor + 1;
        this.stack.push(map(this.stack[this.cursor], f));
        this.cursor = this.stack.length - 1;
      }
      return this;
    }
    undo(){
      if(this.cursor) this.cursor--;
      return this;
    }
    redo(){
      if(this.cursor < this.stack.length - 1) this.cursor++;
      return this;
    }
  };
}();

사실 undo, redo는 그저 cursor의 이동만 제어할 뿐입니다. 주의할 점은 map부분입니다. undo를 통해 커서가 뒤로 이동한 상태에서 새로운 값을 넣는 경우는 커서 뒤쪽을 날려주고 넣어야 일반적으로 유저가 기대하는 undo시스템이 됩니다.

간단히 전진 후진을 해보죠.

const wrap = new Wrapper({a:3, b:'b'});
console.log(wrap
.map(v=>typeof v == 'number' ? v * 2 : v)
.map(v=>typeof v == 'string' ? '[' + v + ']' : v)
.value
); //{a:6, b:'[b]'}
console.log(wrap.undo().value); //{a:6, b:'b'}
console.log(wrap.undo().value); //{a:3, b:'b'}
console.log(wrap.redo().value); //{a:6, b:'b'}
console.log(wrap.redo().value); //{a:6, b:'[b]'}

잘 되는 걸 확인했으면 다음 개념으로 가보죠.

transaction단위의 변화

대수적인 관점에서 map(f1).map(f2)는 map(f1, f2)와 같습니다. undo, redo를 작업 단위로 묶어 처리하기 위해서는 여러 개의 함수를 하나의 건으로 받을 수 있게 할 필요가 있습니다. 이러한 하나의 건수를 여기서는 간략히 transaction이라 하겠습니다.
코드의 변화는 고작 함수 하나 받던 걸 여러 개 받도록 고치면 됩니다.

const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, ...f)=>{
    const result = [], stack = [{c:result, k:0, v:base}];
    let ctx;
    if(!f.length) f.push(identity);
    while(ctx = stack.pop()){
      let {c, k, v} = ctx;
      if(!v || typeof v != 'object') c[k] = f.reduce((v, f)=>f(v), v);
      else if(Array.isArray(v)){
        c = c[k] = [];
        v.forEach((v, k)=>stack.push({c, k, v}));
      }else{
        c = c[k] = Object.create(null);
        Object.entries(v).forEach(([k,v])=>stack.push({c, k, v}));
      }
    };
    return result[0];
  };
  return class{
    constructor(v){Object.assign(this, {stack:[v], cursor:0});}
    get value(){return map(this.stack[this.cursor]);}
    map(...f){
      if(f.length){
        this.stack.length = this.cursor + 1;
        this.stack.push(map(this.stack[this.cursor], f));
        this.cursor = this.stack.length - 1;
      }
      return this;
    }
    undo(){
      if(this.cursor) this.cursor--;
      return this;
    }
    redo(){
      if(this.cursor < this.stack.length - 1) this.cursor++;
      return this;
    }
  };
})();

내부의 map함수는 이전과 달리 rest인자로 f를 받게 되어 기본 값을 직접 적용할 수 없기 때문에 길이가 0인 경우 identity를 넣어주는 식으로 변경되었습니다. map메소드는 rest인자로 여러 개의 f를 받아 다시 내부의 map함수에게 spread로 풀어주는 정도입니다.
이제 여러 개의 함수를 한 건으로 처리할 수 있게 되었습니다. 함수를 두 개씩 보내는 예제를 작성해보죠.

const wrap = new Wrapper({a:3, b:'b'});
console.log(wrap
.map(v=>typeof v == 'number' ? v * 2 : v, v=>typeof v == 'string' ? '[' + v + ']' : v)
.map(v=>typeof v == 'number' ? v + 2 : v, v=>typeof v == 'string' ? '*' + v + '*' : v)
.value
); //{a:8, b:'*[b]*'}
console.log(wrap.undo().value); //{a:6, b:'[b]'}
console.log(wrap.undo().value); //{a:3, b:'b'}
console.log(wrap.redo().value); //{a:6, b:'[b]'}
console.log(wrap.redo().value); //{a:8, b:'*[b]*'}

이제 한 번에 여러 개의 함수를 적용하고 이를 한 단위로 undo, redo할 수 있게 되었습니다.

루프 내의 값 판정

여전히 내부 map함수에는 값을 판정하는 부분이 정적인 제어문으로 되어있습니다. 바로 아래 부분이죠.

if(!v || typeof v != 'object')...
else if(Array.isArray(v))...
else ...

루프를 돌면서 값의 형태를 판정하여 분기하는 구문이 위와 같이 정적이면 조금만 다른 데이터 구조가 와도 map함수를 완전히 다르게 작성해야 할 것입니다. 예를 들어 Map객체도 포함되어있는 경우는 추가적인 분기를 하도록 map함수를 개선해야 합니다.

const map =(base, ...f)=>{
  const result = [], stack = [{c:result, k:0, v:base}];
  let ctx;
  if(!f.length) f.push(identity);
  while(ctx = stack.pop()){
    let {c, k, v} = ctx;
    if(!v || typeof v != 'object'){ 
      v = f.reduce((v, f)=>f(v), v);
      if(c instanceof Map) c[k].set(k, v); //1
      else c[k] = v;
    }else if(Array.isArray(v)){
      const container = [];
      if(c instanceof Map) c[k].set(k, container);
      else c[k] = container;
      v.forEach((v, k)=>stack.push({c:container, k, v}));
    }else if(v instanceof Map){ //2
      const container = new Map();
      if(c instanceof Map) c[k].set(k, container); //1
      else c[k] = container;
      v.forEach((v, k)=>stack.push({c:container, k, v}));
    }else{
      const container = Object.create(null);
      if(c instanceof Map) c[k].set(k, container); //1
      else c[k] = container;
      Object.entries(v).forEach(([k,v])=>stack.push({c:container, k, v}));
    }
  };
  return result[0];
};

변경된 map함수를 살펴보면
1. 기본값으로 분기된 경우 컨테이너가 Map인 경우와 아닌 경우에 따라 다르게 처리됩니다.
2. 배열처리 후 Map을 별도로 처리하게 분기가 늘어났습니다.

결국 최초 받아들이는 컨테이너의 사양이 조금만 변경되면 매번 루프 내부에 추가적인 제어문이 발생하여 유지보수를 어렵게 합니다.
OOP에서 이를 해결하는 방법은 라우터와 전략 객체를 사용하는 것입니다.

문제는 이 컨테이너에서 전략 객체를 분리하는 기준입니다. 우선 눈에 띄는 두 가지 기준이 있습니다.
1. if로 분기되는 조건을 전략 객체 단위로 본다.
2. value의 타입을 전략 객체 단위로 본다.

현재 작성된 루프로는 1번도 괜찮을 듯하지만 실제 Map을 대입하여 수정해본 결과 하나의 타입이 영향을 끼치는 곳은 if로 분기될 때 뿐만 아니라 기본 값에서 컨테이너에 대입할 때도 관여된다는 사실을 확인할 수 있었습니다. 이를 해결하려면 stack 단순히 컨테이너와 k, v를 넣는 것이 아니라 직접 설정할 수 있는 수단을 함수로 제공해야 합니다.

이를 실현하기 위해 context에 c, k를 보내서 값을 넣던 기존의 방식을 버리고
1. 직접 컨테이너의 타입별로 set하는 방법을 캡슐화하여 set이라는 함수로 stack에 넣고
2. 실제 값을 설정하는 경우는 직접 이 set함수에 값을 보내서 넣는 방식으로 처리합니다.

이를 map함수에 반영해보죠.

const map =(base, ...f)=>{
  let result, ctx;
  const stack = [{v:base, set:v=>result=v}];
  if(!f.length) f.push(identity);
  while(ctx = stack.pop()){
    let {v, set} = ctx;
    if(!v || typeof v != 'object'){ 
      set(f.reduce((v, f)=>f(v), v)); //1
    }else if(Array.isArray(v)){
      const c = [];
      set(c); //2
      v.forEach((v, k)=>stack.push({v, set:v=>c[k] = v})); //3
    }else if(v instanceof Map){
      const c = new Map();
      set(c); //2
      v.forEach((v, k)=>stack.push({v, set:v=>c.set(k, v)})); //3
    }else{
      const c = Object.create(null);
      set(c); //2
      Object.entries(v).forEach(([k,v])=>stack.push({v, set:v=>c[k] = v})); //3
    }
  };
  return result; //4
};
  1. 우선 값을 직접 할당하는 경우도 stack의 set함수를 통해 처리할 수 있고
  2. 새로운 컨테이너를 만드는 경우도 간단히 set에 넣어서 해결할 수 있습니다.
  3. 각 컨테이너 타입은 스스로 set하는 방법을 정의하고 있으므로 stack의 set은 각 컨테이너 사정에 맞게 처리됩니다.
  4. 결과적으로 쓸데없이 시작점의 배열이 필요 없어져 그대로 result를 반환할 수 있게 되었습니다.

전략 객체의 도입

우선 전략 객체의 모습을 생각해보죠.

const Condition = class{
  isCheck(stack, ctx, f){}
};
const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, ...f)=>{...};
  return class{
    constructor(v, ...cond){Object.assign(this, {stack:[v], cursor:0, cond});}
    ...
  };
})();

만약 전략 객체라는 것이 있다면 이를 사용하기 위해 Wrapper의 생성자에서 여러 개를 받아들여야 하므로 생성자의 인자가 rest로 추가될 것입니다. 물론 속성에도 cond로 잡았습니다. 헌데 어짜피 전략 객체라는 게 제공할 메소드가 isCheck하나 뿐이라 객체가 아닌 함수로 대체해도 충분할 것입니다. 따라서 Condition클래스는 생략할 수 있습니다.

const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, ...f)=>{...};
  return class{
    constructor(v, ...cond){Object.assign(this, {stack:[v], cursor:0, cond});}
    ...
  };
})();

생성자는 rest로 여러 전략 객체용 함수를 받아들이게 된 셈입니다.

결국 이 cond의 최종 소비자는 내부의 map함수가 될 것입니다. 그 map함수를 간단히 스케치해보면 다음과 같은 모양이 될 것입니다.

const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, cond, ...f)=>{
    let ctx, result;
    const stack = [{v:base, set:v=>result = v}];
    if(!f.length) f.push(identity);
    while(ctx = stack.pop()) cond.some(cond=>cond(ctx, stack, f));
    return result;
  };
  return class{
    constructor(v, ...cond){Object.assign(this, {stack:[v], cursor:0, cond});}
    ...
  };
})();

내부 map함수가 놀랍도록 줄어든 걸 볼 수 있습니다. 이제 map 전략 객체를 some로 소비하는 invoker일 뿐입니다. 개별 전략은 외부에서 공급될 것이기 때문에 some을 통해 간단한 흐름 제어만 해줍니다. 이를 반대편인 전략 객체(함수)입장에서 말하자면

자신의 조건에 부합하는 경우는 truthy를 반환하도록 map함수와 계약을 맺은 셈입니다.

예를 들어 기존에 배열을 처리하던 제어문은 전략 객체화 된 함수로 바꾸면 다음과 같이 될 것입니다.

arrayST = (ctx, stack, f){
  if(Array.isArray(ctx.v)){
    const c = [];
    ctx.set(c);
    ctx.v.forEach((v, k)=>stack.push({v, set:v=>c[k] = v}));
    return true;
  }
};
/*이전 제어문
else if(Array.isArray(v)){
  c = c[k] = [];
  v.forEach((v, k)=>stack.push({c, k, v}));
}
*/

이전 제어문과 비교하여 큰 변화는 없지만 지역변수가 연동되는 제어문이 아니다 보니 인자로 처리에 필요한 변수를 주입받고 외부에는 boolean으로 통보하게 됩니다. 또한 앞 서 언급한 대로 기존에 stack에 넣는 context는 c, k, v로 되어있었는데 v, set으로 변경되었습니다. 자신의 타입에 맞춰 어떻게 set해야 하는 지 직접 지정해주고 있습니다.

하지만 위에서 제안한 (ctx, stack, f) 라는 인자는 전략 객체에게 너무 많은 제어권을 위임하고 있습니다. 보다 권한을 축소할 필요가 있습니다.

  1. context를 전체로 공개하지 않고 v와 set을 추출하여 전달하고
  2. 범용적인 stack의 권한을 축소하여 정해진 형태로 push만 할 수 있게 하며
  3. f배열 전체를 공개하지 않고 특정 값을 보내면 함수전체가 적용된 값만 얻을 수 있게 제약하죠.

위의 제약 조건을 전략 객체에게 건다면 map함수는 다음과 같이 될 것입니다.

const map =(base, cond, ...f)=>{
  let ctx, result;
  const stack = [{v:base, set:v=>result = v}];
  const push =(v, set)=>stack.push({v, set});
  const fn =v=>f.reduce((v, f)=>f(v), v);
  if(!f.length) f.push(identity);
  while(ctx = stack.pop()) cond.some(cond=>cond(ctx.v, ctx.set, push, fn));
  return result;
};

push와 fn을 추가로 설정하여 전략 객체는 훨씬 제한된 권한을 갖게 되었습니다. 이에 따라 위에서 작성했던 배열 처리 함수는 다음과 같이 변경됩니다.

arrayST = (v, set, push, fn){
  if(Array.isArray(v)){
    const c = [];
    set(c);
    v.forEach((v, k)=>push(v, v=>c[k] = v));
    return true;
  }
};

이제 전략 객체는 훨씬 권한이 축소되어 보다 안전해졌습니다. 여기까지의 내용을 Wrapper에 전부 반영해보죠.

const Wrapper = (()=>{
  const identity=v=>v;
  const map =(base, cond,...f)=>{
    let ctx, result;
    const stack = [{v:base, set:v=>result = v}];
    const push =(v, set)=>stack.push({v, set});
    const fn =v=>f.reduce((v, f)=>f(v), v);
    if(!f.length) f.push(identity);
    while(ctx = stack.pop()) cond.some(c=>c(ctx.v, ctx.set, push, fn));
    return result;
  };
  return class{
    constructor(v, ...cond){Object.assign(this, {stack:[v], cursor:0, cond});}
    get value(){return map(this.stack[this.cursor], this.cond);}
    map(...f){
      if(f.length){
        this.stack.length = this.cursor + 1;
        this.stack.push(map(this.stack[this.cursor], this.cond, f));
        this.cursor = this.stack.length - 1;
      }
      return this;
    }
    undo(){
      if(this.cursor) this.cursor--;
      return this;
    }
    redo(){
      if(this.cursor < this.stack.length - 1) this.cursor++;
      return this;
    }
  };
})();

내부 map함수 루프 안에는 이제 아무런 if문도 남지 않게 되었습니다. 이전에 있던 조건문을 전부 외재화 된 함수로 바꿔보죠.

const Cond = {
  primitive(v, set, push, fn){
    if(!v || typeof v != 'object'){
      set(fn(v));
      return true;
    }
  },
  array(v, set, push, fn){
    if(Array.isArray(v)){
      const c = [];
      set(c);
      v.forEach((v, k)=>push(v, v=>c[k] = v);
      return true;
    }
  },
  object(v, set, push, fn){
    if(typeof v == 'object'){
      const c = Object.create(null);
      set(c);
      Object.entries(v).forEach(([k, v])=>push(v, v=>c[k] = v));
      return true;
    }
  }
};
/*기존 제어문
if(!v || typeof v != 'object') c[k] = f.reduce((v, f)=>f(v), v);
else if(Array.isArray(v)){
  c = c[k] = [];
  v.forEach((v, k)=>stack.push({c, k, v}));
}else{
   c = c[k] = Object.create(null);
   Object.entries(v).forEach(([k,v])=>stack.push({c, k, v}));
}
*/

기존의 제어문은 이제 외재화된 전략 객체가 되어 Wrapper가 적재할 수 있게 되었습니다. 이제 실제 사용해보죠.

const wrap = new Wrapper({a:3, b:'b'}, Cond.primitive, Cond.array, Cond.object);
console.log(wrap
.map(v=>typeof v == 'number' ? v * 2 : v, v=>typeof v == 'string' ? '[' + v + ']' : v)
.map(v=>typeof v == 'number' ? v + 2 : v, v=>typeof v == 'string' ? '*' + v + '*' : v)
.value
); 

이전과 다르게 Wrapper를 생성할 때 처리할 값 뒤에 추가적으로 루프 내부에서 작동할 조건 분기를 적재해주고 있습니다.
이젠 완전히 외재화되었으므로 Wrapper와 내부의 map함수를 전혀 건드리지 않고 Cond에 Map처리기를 추가하여 처리할 수 있게 됩니다.

Cond.map = (v, set, push, fn)=>{
  if(v instanceof Map){
    const c = new Map;
    set(c);
    v.forEach((v, k)=>push(v, v=>c.set(k, v)));
    return true;
  }
};
const wrap = new Wrapper(
  {a:3, b:'b', map:new Map([['a', 3], ['b', 'b']])}, 
  Cond.primitive, Cond.array, Cond.map, Cond.object
);
console.log(wrap
.map(v=>typeof v == 'number' ? v + 2 : v, v=>typeof v == 'string' ? '*' + v + '*' : v)
.value
);
// {a:5, b:'*b*', map:Map(2){a=>5, b=>'*b*'}}

위의 예제에서 map안에 있는 a, b도 +2와 *감싸기가 제대로 적용된 것을 볼 수 있습니다.

결론

결국 내부의 map함수는 개별 값에 대해서는 변화되는 함수배열을 적용하면서 루프 내부의 로직은 별도의 전략 객체를 회전시켜 원하는 결과를 만들어주는 실행기 입니다.
다른 면으로 보자면 실행기가 꼭 처리해야 할 코드 외에는 전부 외부에서 함수로 주입 받습니다.

결국 전략 객체만 바꿔주면 어떠한 객체 구조도 재귀호출 없는 빠른 루프로 탐색하면서 원하는 변형을 수행할 수 있습니다. 예를 들어 어떤 객체의 순환 참조를 사전에 제거하고 싶다면 다음과 같이 사용하면 될 것입니다.

Cond.circularRef =()=>{
  const check = new Set();
  return (v, set, push, fn)=>{
    if(v && typeof v == 'object'){
      if(check.has(v)) throw new ReferenceError(v + ' is Circular referenced');
      check.add(v);
    }
  };
};

이 전략 객체는 언제나 false를 반환하므로 나머지 흐름에 영향을 주지 않으므로 끼워 넣으면 손쉽게 순환 참조를 걸러낼 수 있습니다.

const ref = {toString(){return '{ref}'}};
const wrapper2 = new Wrapper(
  {a:ref, b:{otherRef:ref}}, 
  Cond.circularRef(), Cond.primitive, Cond.array, Cond.map, Cond.object
);
try{
  wrapper2.value;
}catch(e){
  console.log('순환참조 ' + e);
}
//순환참조 ReferenceError: {ref} is Circular referenced
  1. 루프의 구조를 추상화하고
  2. 값을 변형하는 함수를 외부에서 주입받고,
  3. 루프 내부의 분기 로직을 전략 객체로 외재화하여

유연한 추상 루프를 만들 수 있습니다.

  1. 전략 객체가 루프에게 제어플래그로 boolean보다 더 다양한 값을 주도록 개선하면 조기 탐색 종료, 지연 평가 및 비동기 평가도 가능해질 것입니다.
  2. 더 나아가 루프 컨테이너 자체가 제네레이터인 경우는 루프의 결과 뿐만 아니라 중간 중간 탐색 결과를 보고할 수도 있을 것입니다.

보다 발전된 추상 루프 컨테이너는 다음 포스팅에서 다루도록 하죠 ^^;