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

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

코드스피츠 85 거침없는 자바스크립트 - 3회차

지난 시간에 Continuation Passing Style , 줄여서 CPS의 한가지 예시를 알아보았다. CPS는 프로그래밍 패러다임보다는 조금은 좁은 프로그래밍 스타일을 일컫는 말이다. 이번 강의에서는 CPS의 여러 방식들을 배워볼 것이다.

Context & Switch

다음과 같은 간단한 제네레이터 함수를 보자.

const gene = function*(a){
	let b;
	yield a;
	b=a;
	yield b;
}

우리는 앞서 제네레이터의 yield 구문은 코드 내부의 sync flow 도중에 suspend를 걸어 제어를 위임할 수 있다고 배웠다. 그런데, 한 가지 궁금한 점이 생긴다. 바벨은 제네레이터 또한 ES5로 트랜스파일해준다. 그렇다면 제네레이터는 ES6 구문인데, 바벨은 이러한 구문이 없는 ES5에서 어떻게 sync flow가 suspend하고 제어를 위임하는 동작을 구현할 수 있는가?

그 전에, 제네레이터의 시그니쳐를 좀 더 알아보자. 제네레이터는 어떻게 사용하는가?

const iter = gene(3);
for(const i of iter) console.log(i);

위와 같이 인자를 주고 제네레이터를 호출하여 얻은 제네레이터 객체 iter에 대해 for of루프를 수행한다. . gene 제네레이터는 인자로 부여한 a를 바로 한번 yield하고 b=a로 a를 할당한 b를 다시한번 yield했다. 따라서 루프의 결과는 같은 값이 2번 출력되어야 한다.

이를 바벨로 번역한 함수는 다음과 같다.

var gene = regeneratorRuntime.mark(function gene(a){
	var b;
	return regeneratorRuntime.wrap(function gene$(_context){
		while(1){
			switch(_context.prev= _context.next){
			case 0:
				_context.next = 2;
			case 2:
				b=a;	
				_context.next=5;
				return b;
			case 5:
				case 'end':
				return _context.stop();
			}
		}
	}, gene);
});

이터러블은 [Symbol.iterator()] 메소드를, 제네레이터는 제네레이터 함수(function*)을 호출하면 이터러블 이면서 동시에 이터레이터 객체를 리턴한다고 했었다. 무슨 의미인지 도무지 전혀 감이 오지 않는 switch 구문은 잠시 놔두고, 함수 시그니처를 중심으로 살펴보자. gene제네레이터는 호출하여 제네레이터 객체를 리턴하는 함수다. 따라서 regeneratorRuntime.mark 라는 저 길어보이는 이름은 결국 호출할 수 있는 함수 여야 한다. 또한, gene 제네레이터가 호출 했을 때 제네레이터 객체를 반환해야 된다는 사실을 생각해보면 mark 함수 내부에서 리턴하는 wrap 함수는 제네레이터 객체라는 것을 알 수 있다.

인자로 받은 a와 클로져 변수로 함수 외부에서 인식한 b는 wrap함수가 여러번 호출되어도 초기화되지 않고 유지되는 컨텍스트가 된다. 그렇다면 wrap함수의 역할은 gene$ 라는 내부의 함수를 _context라는 객체를 인자로 계속해서 호출하는 것이 된다.

그 다음 switch에 오는 할당식은 항상 _context.next의 값에 따라 발동된다. 자바스크립트의 할당식은 언제나 뒤에오는 할당하는 값으로 평가되므로 a=b 라는 할당식은 값이 b로 평가된다. 처음 wrap함수를 실행하면 _context.next값은 0이며, 따라서 case 0으로 분기하여 2를 할당한 후 a를 리턴하고 종료한다. 그러면 그 다음 호출에는 _context.next는 값이 2이므로 case 5로 분기하여. 5를 할당하고, 또 그 다음 호출에는 case 5로 분기하여 _context.stop()라는 메서드를 실행되면 마침내 더 전진할 분기문이 없어서 _context.stop()을 계속 실행하게 되는 것이다. 이처럼 wrap함수를 계속 호출할 때마다 한단계씩 분기문을 전진하게 된다.

이런 아무 상관 없는 무작위 값으로 된 case 분기문이 무엇을 의미하는지 궁금할 것이다. 이 값들은 바벨 엔진이 제네레이터의 yield구문을 평가하여 적절한 숫자를 부여한 것이다. yield * 등으로 연쇄적으로 위임할 경우를 대비하여 수와 수 사이의 간격을 둔다. 물론, 상당히 랜덤한, 별다른 의미가 없는 숫자일 뿐이며, 중요한 것은 switch-case 구문으로 단계적으로 분기문을 전진할 수 있도록 한다는 것이다.

그럼 이제 조금은 이해가 안가는 바벨의 구문들을 제외하고 직접 gene함수를 구현해보자.

const gene2 = a=>{
	let b;
	return wrap(_context=>{
		while(1){
			switch(_context.prev=_context.next){
			case 0:
				_context.next = 2;
			case 2:
				b=a;	
				_context.next=5;
				return b;
			case 5:
				case 'end':
				return _context.stop();
			}
		}
	});
};

mark함수는 그대로 함수를 노출할 뿐이므로 생략이 가능하고, 내부의 함수를 람다로 바꿔줬다. 이제 우리가 구현해야 할건, _context를 인자로 받아 switch 분기문을 전진시키는 앞서 본 형태의 함수를 넘겨주면, 이를 통해 제네레이터 같은 CPS를 구현할 수 있는 wrap 함수이다.

우리가 사용할 wrap함수의 인터페이스를 다음과 같이 규정하자.

const wrap = block=>new SwitchIterable(block);

wrap함수는 콜백인 block을 인자로 받아서, 제네레이터 객체를 리턴하여야 한다는 점을 앞서 살펴봤다. 따라서 wrap은 제네레이터를 리턴해주는 일종의 팩토리 함수로써 역할한다. 지난 강의에서 제네레이터는 이터러블과 이터레이터 인터페이스를 동시에 구현한 객체라고 했다. 이터러블 인터페이스를 구현하는 객체는 [Symbol.iterator] 라는 메소드를 가져야 하며, 이 메소드는 이터레이터 객체를 반환해야 한다. 제네레이터의 경우에는 두 인터페이스를 모두 구현하였기 때문에 Symbol.Iterator가 this를 반환하도록 하고, 다시 next() 메소드를 가지도록 구현할 수 있지만, 여기서는 이터러블과 이터레이터 클래스의 계층을 나누어 구현할 것이다. 그럼 우선 Switch 형식에 대응하는 Iterable 인스턴스를 반환하는 SwitchIterable 클래스를 구현해보자.

const SwitchIterable = class{
	#block;
	constructor(block){this.#block = block;}
	[Symbol.iterator](){return new SwitchIterator(this.#block);}	
}

[Symbol.iterator]가 이터레이터 객체를 반환하고, 생성자로 block 콜백을 넘겨주었다. 우리가 for of 루프에 대해서 비용이 비싸다 라고 말하는 이유는, for of 구문이 이 코드에 나온 것처럼 새로운 이터레이터 객체를 생성해서 루프를 돌기 때문이다

이터레이터 객체의 구현은 다음과 같다.

const SwitchIterator = class{
	static done = {done:true}
	#block;
	#context = new Context;
	constructor(block){this.#block=block;}
	next(){
		const value = this.#block(this.#context);
		return value===Context.stop? SwitchIterator.done :{value, done:false}
	}
}

SwitchIterator는 block을 매개변수로 생성되어 next()를 호출하면 이터레이터 인터페이스에 정의된대로 {done:true} 이거나 {value, done:false} 인 두 객체 중 하나를 리턴한다. 이 때 이터레이터 객체는 새롭게 context를 안고 태어나서, 그 컨텍스트를 next()메소드가 호출될 때마다 block 콜백에 전달한다. 그 결과로 인해 일어나는 context의 변화가 자신의 필드에 그대로 유지되기 때문에, 우리는 next() 마다 변화하는 Context 상태에 따라 루프를 전진하는 CPS를 구현할 수 있는 것이다.

이 때 호출 결과가 Context 클래스의 스태틱 필드인 stop (고유값이여야 하므로 Symbol() 이 된다)으로 루프를 더 이상 전진할 수 없다는 신호를 보내오면 우리는 {done:true}를 리턴할 것이고, 아니라면 계속해서 전진할 수 있다는 의미이므로 {value, done:false}를 리턴할 것이다. 이 모든 구현은 단지 자바스크립트 표준 이터레이터 인터페이스를 그대로 따른 것 뿐이다.

그렇다면 인터페이스가 규정되지 않는 Context 객체는 어떻게 구현하느냐 하면, 우리가 앞서 사용한 그대로 구현하면 된다.

const Context = class{
	static stop = Symbol();
	prev = 0;
	next= 0;
	stop(){return Context.stop;}
}

next 필드와 그를 할당할 prev 필드, 그리고 고유한 심볼 값인 Context.stop 필드와 이를 리턴하는 stop() 메소드만 있으면 끝이다. 어떤 코드의 구성내용을 구현하기 전에 그 코드를 어떻게 사용할 것인지를 먼저 명시하고 구현하는 것은 우리가 원하는 목표를 미리 세워놓고 그것을 실천하는 것과 같은 좋은 습관이다. 앞서 우리가 switch 분기문 내부에서 Context 클래스에서 사용할 프로퍼티들을 미리 명시했기 때문에, 실제로 클래스의 청사진을 그리는 것은 별다른 노력이 필요 없는 것이다.

이제 우리는 gene2 함수를 호출하면 이터러블 객체를 얻을 수 있게 되었다. 이 객체는 [Symbol.iterator] 메소드를 실행하면 새로운 Context를 안고 태어나는 이터레이터를 반환하며, 이터레이터는 next() 호출마다 변화하는 Context 상태에를 이용하여 루프를 전진할 수 있게 되었다. 이제 우리는 gene2를 실제 제네레이터와 동일하게 사용할 수 있다.

const iter = gene(4); // 제네레이터 객체
const iter2 = gene2(4); // SwitchIterable 객체
for(const i of iter) console.log(i);
for(const i oof iter2) console.log(i);

앞서 우리가 살펴본 바벨 플러그인 구현체는 TC39에서 자바스크립트의 새로운 제안들이 Stage3 이상 단계로 올라가기 위해서 가장 표준적으로 많이 작성되는 테스트 코드들이다. 우리는 이를 토대로 역으로 이터러블과 이터레이터 인터페이스 스펙을 구현하여 바벨처럼 제네레이터를 제네레이터 없이 성립시킬 수 있게 되었다. 우리는 yield 구문 대신 조금 더 길고 무작위한 값으로 분기되는 switch 구문을 사용하게 되었지만, 제네레이터가 하려는 일을 일련의 연속으로 보고 이를 직접 구현해보았다. 이를 통해 알 수 있는 건, 내가 외부에 제네레이터로 위임하려는 복잡한 제어는 사실은 수동으로 조금씩 작게 분할할 수 있다는 것이다. 이렇게 우리는 공유 컨텍스트와 switch 구문을 사용해서 상태를 변경하여 CPS를 달성하였다.

자바스크립트 외의 다른 언어은 이러한 수동으로 직접 나누어 전진하는 방식의 CPS를 사용한다. 단지 앞서 본 switch 구문처럼 코드가 거대해지고 하나의 거대한 sync flow 로직이 생길 뿐이다.그럼 이를 보다 작은 단위로 나누어서 관리하는 방법은 없을까?

Continuation & Resume

지금까지 알 수 있었던사실은 CPS를 달성하기 위해서는 작업을 잘게 나누어 외부에 제어를 위임해야 한다는 것이었다. 그러면 외부에서 루프를 다시 재개했을 때 우리는 작업했던 바로 그 부분부터 루프를 다시 전진시키게 될 것이다. 이러한 전진을 Continuation이라고 부른다. 결국 CPS의 핵심은, 우리가 앞서 첫번째 강의에 말했었던 non-blocking을 달성하기 위해 현재 나의 sync flow를 조금씩 나누어 blocking을 줄이는 데 있다.

앞서 작성 switch는 sync flow 전체를 하나의 switch 분기문으로 제어해야 하므로 코드가 길어지고 관리가 힘들었다. 바벨 플러그인이 알아서 이런 코드를 작성하고 우리는 몰라도 된다면 별로 문제가 없겠지만, 직접 구현하는 입장에서 중복되는 긴 코드를 그대로 사용하는 것은 어려운 일이다. case 분기문을 사용하지 않는 방법은 없을까?

다음과 같이 case 분기문이 아니라 한번에 처리해야할 내용 들을 연속적으로 받아들이는 SeqIterable 클래스를 정의해보자.

const gene4 = a=>{
	let b;
	return new SeqIterable(
		cont=>{
			cont.resume(a)
		},
		cont=>{
		b=a;
		cont.resume(b);
		}
	}
};

한번에 처리해야하는 하나의 단계가 하나의 함수가 되어 여러 단계를 일련의 함수로써 받아들이고 있다. 각 단계는 아까와 마찬가지로 context를 인자로 받아 내가 전진시키고 싶을 때 직접 resume을 통해서 전진하며 바뀐 context를 리턴한다. 또한 전진하는 메소드 resume을 사용함으로써 실제 제네레이터의 yield구문과 같이 전진하는 컨텍스트를 한 줄로 표현할 수 있게 됐다. 앞서 기계만이 짤 수 있을 것 같은 switch 구문과 비교하면 어떤가? 프로그래머가 직접 사용할 수 있을 것 같은 구조를 가지게 되었다. 그럼 이런 일련의 함수를 Iterable로 만들어주는 SeqIterable 클래스를 구현해보자.

💡 코드의 일반 개발론을 깊게 다루진 않겠지만, 코드를 전개할 때는 항상 실제 사용해야할 코드를 먼저 구현하여 코드의 스펙이나, 함수의 시그니처를 먼저 확정하는 것이 좋다. 확정된 코드의 스펙, 함수의 시그니처를 가지고 코드를 짜는 것은 마치 달리기를 하는데 목적지의 방향을 먼저 아는 것과도 같다. 확정되지 않은 스펙 위에 코드를 쌓아가는 것은 어려운 일이다. 즉 원하는 기능을 먼저 정의하고 이를 제공하는 코드나 클래스를 작성해야 한다. 테스트 주도 개발도 결국에는 이러한 사용 주도 개발 방법의 확장이다.
const SeqIterable = class{
	#blocks;
	constructor(...blocks){this.#blocks=blocks;}
  [Symbol.iterator](){return new SeqIterator(this.#blocks.slice(0);}
} 

SeqIterable 클래스는 다음과 같다. 매우 간단하다. 앞서 우리가 사용할 코드에 명시한 스펙만 그대로 구현하면 된다. 연속된 단계, 즉 Continuation을 인자로 받는다고 했으니 이를 복수의 blocks로 받으면 된다. 아까와 마찬가지로 [Symbol.iterator]로 SeqIterator를 리턴하고 blocks를 전달하면 된다. 객체지향의 역할모델은 객체의 책임 이외에는 전부 다른 객체에 위임하는 것을 원칙으로 한다. 우리는 Iterable의 책임을 자바스크립트 Iterable 인터페이스에 명시된 그대로 Iterator를 리턴하는 것으로 제한하고 나머지 이터러블의 책임이 아닌 것은 전부 위임할 것이다. 물론, block의 컬렉션을 전달하게 되었으므로 SwitchIterable보다는 조금 책임이 무거워졌다. block의 컬렉션을 그대로 전달하지 않고 복제본을 전달하는 이유는 SeqIterator가 이를 소모 하여 변형시키게 때문이다. 이에 대해서는 다음 코드를 보자.

const SeqIterator = class{
	#blocks;
	static done={done:true}
	#cont = new Continuation;
	constructor(...blocks){this.#blocks=blocks;}
	next(){
		if(!this.#blocks.length)return SeqIterator.done;
		const cont = this.#cont;
		cont.stop()
		this.#blocks.shift()(cont);
			return cont.isStop()? SeqIterator.done :{value:cont.value(), done:false}
	}
}

앞서 SwitchIterator에서 context가 현재 switch 구문의 현재 단계를 next, prev, stop 이라는 필드의 값으로 알고 있었다면, 여기에서 Continuation은 각각의 단계에서 실행되야 하는 코드를 갖고 있다. 따라서 next()를 호출하면, 앞서 전달한 코드를 하나씩 shift해서 순서대로 없애면서 인자로 필드에 있는 Continuation 객체를 전달한다. 그런데 이때, 코드를 실행하기 전에 Continuation을 stop하는 것을 볼 수 있다. 왜 Stop을 하는 것일까?

이는 마치 제네레이터에서, yield를 호출하지 않으면 return undefined가 되어 그대로 종료되는 것과 같은 원리이다. 앞서 우리는 제네레이터가 yield 구문을 사용하면 코드를 감싸는 Record를 그대로 suspend 시킴으로써 Sync flow를 멈추고 제어를 외부에 위임한다고 했다. 만약 더 이상 suspend할 yield 구문이 없다면 당연히 재개할 sync flow도 없으므로 제네레이터 함수는 그대로 종료된다. 따라서 우리는 모든 단계에서 미리 Continuation을 stop으로 중지시키고 resume 메서드를 명시적으로 호출해주지 않으면 이를 재개시키지 않고 그대로 종료시킬 것이다.

따라서 우리는 Continuation 클래스가 stop이라는 상태를 확인하기 위한 isStop() 메서드나, Continuation의 현재 값을 확인하기 위한 value() 등의 명세를 가질 것이라고 명시하고, next() 호출마다 이를 활용해 결과 객체를 리턴할 것이다.

그럼 이를 그대로 Continuation 클래스로 바꿔주자

const Continuation = class{
	static #stop = Symbol();
	#value;	
	resume(v){this.#value=v;}
	value(){return this.#value;}
	stop(){this.#value=Continuation.#stop;}
	isStop(){return this.#value===Continuation.#stop}
}

ES6 이후로는 상수값은 Symbol로 만들면 된다. static private 필드는 클래스 내부에서만 쓸 수 있는 필드이므로, stop이라는 상수는 변경 불가능하며 오직 stop() 및 isStop() 메서드로만 접근할 수 있다. 따라서 우리가 resume을 호출했다면, SeqIterator 객체가 가지고 있는 Continuation 인스턴스는 isStop 메서드의 리턴값으로 false를 가지게 되므로, next() 의 리턴 객체는 {value:cont.value(), done:false}가 된다.

따라서 이렇게 만들어진 SeqIterator는 next() 메서드를 호출할 때마다 단 하나의 block 씩만을 실행한다. 연속된 코드를 SeqIterable로 넘겨주어도 단 하나만 실행하고 쓰레드는 다른 flow에게 넘겨주는 것이다. 이렇게 우리는 sync flow를 blocking 하지않고 원하는 만큼만 전진시키고 외부에 위임하는 것을 반복할 수 있다. Continuation 클래스는 이렇게 flow를 여러 번으로 나누어 전진해도 실행 컨텍스트의 연속성이 확보될 수 있도록 하는 역할을 한다.

하지만 이렇게 작성한 코드에도 한가지 불만사항이 남아있다. 바로 blocks의 상태를 외부의 SeqIterator 클래스에서 컬렉션으로 관리하고 있다는 점이다. 그게 왜 문제가 되는지는 다음 제네레이터 코드를 통해 살펴보자.

const gene = function*(a){
	let b;
	while(1){
		a++;
		b=a;
		yield b;
	}
}

이 구문은 무한루프를 돌기 때문에 무한대로 yield 할 수 있다. 그렇다면 이를 switch 구문으로 만들면 case가 무한대로 생성되야 할까? 아닐 것이다. 우리는 단지 초기화되었을 때 context가 무한루프의 true 조건이 되게하는 초기 케이스 1개와, true 조건일 때 도는 무한루프 case 1개와, falsy를 전달했을 때 탈출하는 case 1개로 총 3개의 case만 있으면 된다. 즉 우리는 모든 case를 초기화/루프/탈출의 3가지 case로 일반화 할 수 있다. 탈출하지 않고 다시 초기화 조건으로 돌아가면 영원히 무한루프를 돌게 된다. 그런데 우리의 SeqIterable이 무한루프를 구현할 수 있는가? 아니다. SeqIterable은 배열을 사용하고 있으므로 순차적으로밖에 전진할 수 없으므로 루프조건에서 계속해서 초기화 조건으로 돌아가는 동작을 할 수 없다.

💡 CPS는 재귀함수와는 매우 다른 접근이다. 재귀함수를 사용할 우리는 콜스택이 계속해서 쌓이므로 스택 오버플로우를 방지하도록 주의해야 한다. 그런데 루프 제어문을 사용할 때, 분명히 루프 제어문도 함수와 마찬가지로 Subflow인데 왜 루프를 돌 때는 스택이 쌓이지 않는 걸까? 그 이유는, 현대 언어의 모태가 된 C언어, 또 그 모태인 ABC언어는 루프 제어 블록이 끝나는 순간 스스로를 스택에서 클리어할 수 있기 때문이다. ABC 언어를 계승하는 현대 언어들은 while이나 for문을 쓰면 매 제어 블록마다 스스로 스택클리어를 하게 되어 스택 오버플로우가 일어나지 않는다.

따라서 SeqIterable가 무한루프를 구현하기 위해서는 아래와 같은 모습이 되야한다.

const gene=a=>{
	let b;
	return new SeqIterable(
		cont=>{
			cont.resume();
		},
		cont=>{
			cont.resume(a);
		}
	);
}

이 코드는 다음의 조건을 만족하는 코드이다.

  1. 특별히 지정하지 않으면 순차적으로 내려옴

    Continuation 사이에 특정한 조건으로 이동하도록 지정하지 않는다면 그냥 순차적으로 Continuation을 전진하면 된다.

  2. 첫번째 함수는 값을 반환하지 않음

    바벨의 switch 구문에서 살펴보았듯이, 첫번째 함수는 단지 Continuation이 루프로 진입하기 위한 초기화 조건만을 확인하고, 루프하는 함수로 Continuation을 이어주기만 하면 된다. 따라서 별다른 값을 리턴할 필요가 없다. 물론, 이 때 우리는 앞서 디폴트로 Continuation을 무조건 stop했고, 이를 재개하려면 resume() 메서드를 무조건 호출해야 하기 때문에, resume 메서드에 인자를 넣지 않았을 경우 값을 반환하지 않도록 기본값 인자로 만들어줘야 한다.

  3. 두번째 함수는 첫번째 함수를 알아야 함.

두번째 함수가 바로 루프에서 값을 외부로 yield하는 함수이므로, 무한루프를 돈다면 값을 반환한 뒤에 다시 첫번째 함수의 초기화 조건으로 돌아가야 한다. 그러려면 두번째 함수가 첫번째 함수를 알아야 한다. 어떻게? 함수의 고유한 이름으로 찾을 수 있어야 한다.

  1. 외부 제어를 피하고 내부에서 결정함

따라서 이를 실현하려면 Continuation은 외부에서 제어하지 않고 내부적으로 전진할 수 있어야 한다.

여기에 명시한 스펙을 바탕으로 실제 코드를 구현해보자.

1번과 2번에 나온 요구사항에 따라 값을 리턴하지 않고도 순차적으로 진행하는 것을 가능하게 하려면 stop되어 iterator.done 처리를 하지 않으면서도, value를 리턴하지도 않는 또 하나의 상태를 표현하기 위한 메서드가 필요하다. 이를 위해 Continuation 클래스에 isPass 메서드를 추가해주자. 또한, 함수가 다른 함수를 알 수 있도록 고유한 이름을 가져야 하기 때문에, 각각의 continuation에 key 속성을 부여하고 이를 저장할 map 저장소를 생성할 것이다. 또한 마지막으로, 내부적으로 전진을 결정할 수 있도록 Continuation의 구조를 링크드 리스트로 만들고 다음 노드에 대한 next 포인터를 가질 것이다.

요구사항에 따라 실제 구현내용을 결정하는 것을 모델링이라고 한다. 이제 모델링을 끝냈으면 코드를 작성하는 단계만 남았다. 실제 사용코드를 스케치해보자.

const gene = a=>{
	let b;
	return new Sequence(
		new Continuation(0, cont=>{
			if(!1)cont.stop();
			cont.resume();
		})
	).next(
		new Continuation(1,  cont=>{
		a++;
		b=a;
		cont.resume(b,0);
	})
);
};

어떻게 이런 코드가 나왔는지 천천히 살펴보자. 우선, 우리는 내부적으로 노드를 가리키는 포인터도 가져야 하기 때문에, 더 이상 함수로 사용하지 않고 이를 Continuation 객체로 감쌌다. 감싼 객체는 첫번째 인자로 자신의 고유한 키값을 가지고, 두번째로는 스스로를 실행할 함수 block을 가진다. 그리고, Sequence 수준에서 현재 Continuation의 다음 번 Continuation을 next() 메서드로 붙여준다. 첫번째 Continuation에서는 무한루프의 조건문을 그대로 가져와서 continuation의 stop() 여부를 결정하고, 무한루프 Continuation으로 전진시키기 위해 인자가 없이 resume() 메서드를 호출하면, 값을 반환하지 않으므로 순차적으로 다음 continuation으로 전진한다.

다음 Continuation에서는, resume 메서드로 특정 값을 반환하면서 0이라는 특정 키를 가지는 Continuation으로 이동하도록 한다. 따라서 이 Continuation의 전진 결과는 0이라는 키를 가진 첫번째 Continuation으로 돌아가는 것이 될 것이다.

요구사항을 도출해서 어떠한 기능을 가질지 결정한 다음, 사용할 코드를 먼저 스케치 하며, 사용할 코드를 먼저 작성한 뒤에 바탕이 될 클래스 코드를 작성하는 이러한 개발 프로세스는 애자일 방법론과도 맥을 같이 한다. 그럼 이제 클래스 청사진을 작성해보자.

const Continuation = class{
	#key; #block; #value; #seq;
	static #pass = Symbol();
	static #stop= Symbol();
	constructor(key,block){this.#key=key; this.#block=block;}
	setSequence(seq){this.#seq = seq; this.#seq.setCont(this.#key,this);}
	setNext(cont){
        this.next = cont;
  }
	resume(v=Continuation.pass, next=undefined){
		this.#value=v;
		if(next) this.#next = this.#seq.getCont(next)
	}

}

const Sequence = class{
	#table = new Map;
	#cont;
	#end;
	constructor(cont){this.#cont = this.#end= cont; cont.setSequence(this);}
	next(cont){
        this.#end.setNext(cont);
        this.#end = cont;
        cont.setSequence(this);
        return this;
  }
	[Symbol.iterator](){return new Iterator(this.#cont);}
	getCont(key){if(!this.#table.has(key)throw `no key:${key}`; return this.#table.get(key)}
	setCont(key,cont){if(this.#table.has(key)throw `exist key:${key}`; return this.#table.set(key, cont)}
}

복잡해 보이겠지만 단지 앞서 명시한 명세서를 그대로 구현한 것에 지나지 않는다. resume 메서드는 값과 다음 Continuaion의 key 2개를 인자로 받아서 값은 Continuation.#pass 라는 고유 키를, next는 자신이 속한 Sequence 클래스의 map에서 키값으로 받아온 Continuation을 디폴트 값으로 지정해준다.

Sequence 클래스는 생성자에서 Continuation 클래스 타입을 인자로 받아 이를 진입점으로 설정한다. 즉 생성자에서는 단 하나의 Continuation만을 받는다. 그리고, 내부에서 Continuation을 저장하는 #table 속성을 가지고 이를 외부에 setCont와 getCont로 노출한다. 우리는 어떤 Continuation이 어떤 Sequence에 있는지도 알 수 없고, 각자의 key값이 고유한지도 알 수 없기 때문에, 이를 Continuation의 static 필드 또는 전역 Map 으로 만들 수 없다. Sequnce는 Continuation을 소유하는 주체가 되며 Continuation들 사이에 공유하는 컨텍스트가 바로 Sequence이기 때문에, Continuation은 키값으로 다음 Continuation을 찾을 때 자신이 속한 Sequence에게 이를 물어보고 찾게 되는 것이다. 이는 제네레이터 인터페이스로 생각해보아도 명백하다. 제네레이터는 매번 호출될 때마다 새로운 이터러블을 만들고, 이터러블은 매번 새로운 이터레이터 객체를 만들어서 리턴한다. 따라서 Sequence는 새로운 Continuation요소들을 매번 생성해서 관리하는 역할에 가장 적합하다. 그러므로 우리는 Continuation을 생성하고 나서 사후적으로 Sequence를 지정해주고 있다.

이 때 Continuation은 자신이 속할 Sequence가 나에게 지정이 되면 (setSequence) 자신을 해당 Sequence의 map에 set 해준다. 서로간의 계약을 생성시에 맺는 것이 아니라 Lazy하게 오퍼레이션을 통해 메세지를 보냄으로서 맺고 있다. 또한 Sequece의 next 메소드를 통해 continuation의 다음 continuation 순서를 지정해주고 공유 컨텍스트를 자신으로 지정해주는 일련의 트랜잭션을 실행한다. Sequence는 이터레이터 객체가 아닌 이터러블 객체이므로 next()가 이터레이터의 인터페이스를 말하는 것이 아니라 링크드 리스트를 위한 메서드임을 알아야 한다.

이를 바탕으로 Iterator 클래스를 구현하자.

const Iterator = class{
    static done = {dont:true}
    #target;
    constructor(cont) {
        this.target = cont;
    }
    next(){
        const target = this.#target;
        if(target===undefined) return Iterator.done;
        target.suspend(); //cont를 suspend 하고 시작함
        if(target.isStop()) return Iterator.done; // resume하지 않으면 끝낸다.
        if(target.isPass()){
            this.target = target.getNext(); //isPass면 다음 continuation으로 넘어감
            return this.next(); //바로 다음 next를 호출해서 그 다음 continuation의 값을 리턴하도록 한다.
        }else{
            const result = {value:target.value(), done:false}
            this.target = target.getNext(); //resume할 때 지정한 next로 타겟을 변경. 무한 루프 가
            return result;
        }
    }
}
const Continuation = class{
		suspend(){
        this.value = Continuation.stop
        this.block(this);
    }
		getNext(){return this.#next;}
    value(){return this.value;}
    isStop(){
        return this.value ===Continuation.stop;
    }
    isPass(){
        return this.value ===Continuation.pass;
    }
}

Continuation 코드가 너무 길어져 따로 분리하였다. 앞의 코드와 연결된 하나의 클래스이다. Iterator 클래스는 stop 메서드가 suspend로 바뀐 것을 제외하면 똑같다. target은 Continuation으로 다음 Continuation이 없으면 종료한다. 이전의 코드와 마찬가지로 디폴트로 suspend() 메서드를 실행하며 suspend() 메서드는 Continuation 스스로의 block을 실행한다. 만약 실행한 결과가 Continuation.stop이라면 iterator.done을 리턴하고, Continuation,pass라면 그냥 Continuation을 한번 더 전진시키고 이터레이터의 next() 메서드를 한번 더 호출해주면 된다. 다음 번 Continuation의 리턴값으로 전진시켜주는 것이다. isStop도, isPass도 아니라면 그때는 Continuation의 value를 얻어와서 리턴하고, Continuation을 다음 노드로 한 칸 전진 시키면 된다.

이렇게 우리는 무한루프를 CPS로 구현해보았다. 현대 언어들의 모태인 ABC언어의 컴파일러는 for문을 사용하면 함수와 똑같은 서브루틴을 생성하지만 스택에서 빠져나오기 전에 스택메모리를 해제하지 않는 함수와는 달리 for문은 구간을 jump 할 때마다 스택을 해제하는 기능을 가지고 있다. CPS는 바로 이러한 스택을 해제하는 기능을 흉내낸 것이다. 따라서 CPS로 구현한 함수는 아무리 루프를 도는 횟수가 많아져도 스택오버플로우가 일어나지 않는다. (스크립트 타임아웃은 걸릴 수 있다.)

이제 우리는 무작위 값을 계산하여 인과를 알기 힘들었던 switch-case 문에서 벗어나 어느정도 사람이 이해할 수 있는 방식으로 Continuation을 작성할 수 있게 되었다. 사실 이 코드는 코틀린의 CoRoutine 및 여타 low-level CPS 구현 코드와 거의 유사하다. 또한 크롬에서 async iterator /generator 등을 사용했을 시 크롬 내부 엔진이 변환하는 코드와 유사하기도 하다. 요점은 바로 이러한 루프의 분할과 외부 제어 위임이 CPS의 요체라는 것이다.


번외 - 공유 컨텍스트(Context)

그러나 여전히, 우리에게는 한가지 걸리는 점이 있다. 바로 Continuation의 컨텍스트를 a,b라는 클로져와 스코프가 보장하고 있다는 것이다. 클로져에 의존하지 않고 객체지향적으로 이를 보장할 수는 없는걸까? 자바같은 언어는 클로져가 없는데 어떻게 CPS의 컨텍스트를 보장할 수 있을까?

우리는 객체 내부에 a,b 등의 컨텍스트를 공유해주어야 하며 이를 어휘 공간(Lexical Environment) 이라고 한다. 우리가 프로그래밍에서 사용할 수 있는 어휘는 키워드를 제외하면 메모리에 저장된 변수명 밖에 없다. 어휘 공간이란 어떠한 변수명이 인식되는 함수 블록이나 제어 블록 내에서 인식할 수 있는 어휘 환경을 제약한 것을 말한다. 이를 통해 우리는 전역으로 어휘공간을 통일한 경우에 일어날 수 있는 어휘의 중복이나 혼동을 방지한다. 현재 우리는 언어 차원에서 제공하는 스코프를 사용하고 있는데, 이를 객체지향적으로 해결하기 위해서는 클로져 변수를 컨텍스트 객체로 만들어서 컨텍스트 객체를 공유함으로써 이 문제를 해결할 수 있다. 우리는 이를 Map으로 만들 것이다.

간단하게 사용 코드를 스케치 해보자.

const noScopegene = a=>{
    return new Context().set('a', a).set('b', undefined).next(new Continuation(0,cont=>{
        if(!1)cont.stop();
        cont.resume();
    })).next(new Continuation(1,cont=>{
        cont.context.set('a', cont.context.get('a')+1);
        cont.context.set('b', cont.context.get('a'));
        cont.resume(cont.context.get('b'), 0)
    }))
}

Sequence가 아닌 Context 객체로 이름을 달리해보자. 이제 클로져를 사용하지 직접 어휘공간으로 a와 b를 설정해주는 것을 볼 수 있다. 또한, 스코프에 의존하여 단순히 값을 더해주었떤 것을 모두 map의 값을 바꿔주는 방식으로 변경해야 한다.

자바스크립트의 Map의 set 메소드는 기본적으로 this를 리턴함으로써 체이닝이 가능하도록 되어있다. 그렇다면 우리의 Context 객체는 Map을 상속받아야 한다. 이는 ES6의 class 문법에서만 가능한 프로토타입에서는 불가능한 부분이다. 프로토타입에서는 마지막에 무조건 Object 생성자로 객체를 생성한 후에 프로토타입체인으로 상위 객체에 연결하므로 Object가 Array나 Function 등의 하위 객체를 상속받을 수 없기 때문이다. 하지만 class 구문을 사용하면 체이닝 되어있는 생성자를 끝까지 탐색하여 가장 상위의 생성자로 객체를 생성한 뒤 상속받는 객체를 하나씩 꾸미는 방식을 사용하기 때문에, 우리는 원하는 내장 객체를 상속받을 수 있다.

const Context= class extends Map{
    #table = new Map;
    #start;
    #end;
    next(cont){
        if(this.#start===undefined)this.#start=this.#end=cont
        else this.#end = this.end.#next= cont
        cont.#context= this;
        return this;
    }
}

따라서 사용코드에 나온 명세대로 Context를 구현해보면 다음과 같다. 기존 Sequence에서 다른 부분만을 적었다. Context는 이미 Map 내장객체를 상속받음으로써 스스로가 하나의 Map이므로, 기존의 Continuation의 키를 저장하던 Map과는 구분되어야 한다. 앞서 cont라는 필드를 start라는 이름으로 고쳐주면 좀 더 의미가 명확하다. 우리는 생성자에서 Continuation을 받지 않을 것이기 때문에, 첫번째 호출 때 링크드 리스트의 노드가 비어있는 경우를 예외처리 해주면 나머지는 전부 동일하다.

다만 코드적으로 context의 getter와 setter를 사용하기 위해 cont.context.set 으로 길어지던 부분을 Continuation의 메소드로 추가하여 좀 더 중복을 줄여보자.

const Continuation = class{
    #key; #block; #value; #context;#next;
    static #pass = Symbol();
    static #stop= Symbol();
    constructor(key,block){this.#key=key; this.#block=block;}
    setSequence(seq){this.#context = seq; this.#context.setCont(this.#key,this);}
    set next(cont){this.#next = cont;}
    get next(){return this.#next; }
    get(key){return this.#context.get(key);}
    set(key, value){return this.#context.set(key,value);}
    resume(v=Continuation.#pass, next=undefined){
        this.#value=v;
        if(next) this.#next = this.#context.getCont(next)
    }
    suspend(){
        this.#value = Continuation.#stop
        this.#block(this);
    }
    get next(){return this.#next;}
    get value(){return this.#value;}
    get isStop(){
        return this.value ===Continuation.#stop;
    }
    get isPass(){
        return this.value ===Continuation.#pass;
    }
}

const noScopegene = a=>{
    return new Context().set('a', a).set('b', undefined).next(new Continuation(0,cont=>{
        if(!1)cont.stop();
        cont.resume();
    })).next(new Continuation(1,cont=>{
        cont.set('a', cont.get('a')+1);
        cont.set('b', cont.get('a'));
        cont.resume(cont.get('b'), 0)
    }))
}

context를 get하지 않고 바로 Continuation에서 변수를 저장할 수 있다. 그 외의 메서드는 setter와 getter로 바꾸어주었다. 이제 우리는 함수의 클로져에 의존하지 않고 객체지향적으로 어휘공간을 만들어서 소통할 수 있게 되었다.


번외 - 객체지향의 데코레이터 패턴

객체지향에서 객체는 스스로 자신의 상태를 관리할 책임을 가진다. 우리는 객체지향의 객체를 ‘역할' 로써 바라보아야 한다. 따라서, 역할이 역할에게 할당된 상태를 관리할 책임을 가지게 된다. 따라서 객체지향에서 책임은 자신이 가진 메소드로부터 나오고, 권한은 자신의 상태로부터 나온다.

객체지향에서 컬렉션을 사용할 때는 해당 컬렉션을 사용하는 소유자 객체가 컬렉션에 대한 책임을 가지는게 맞는지, 컬렉션의 구조를 결정할 책임이 소유자에게 있는지 고민해야 한다. 우리의 코드에서 block은 스스로가 다음 block을 진행할지 말지를 resume 메서드로 결정하고 있다. 따라서 자신의 구조를 자신이 결정하는 노드의 링크드 리스트 가 바로 blocks의 구조여야 하며, 이를 객체지향에서는 Decorator Pattern 이라고 한다.

💡 객체지향의 Decorator Pattern은 컬렉션을 사용하는 것을 지양하고 대신 링크드 리스트를 사용한다. 그 이유는 무엇일까? 컬렉션을 사용한다는 것은 그 자체로 이미 컬렉션을 소유하는 객체에게 무거운 책임을 넘기는 것이기 때문이다. 또한, 컬렉션의 루프를 수행할 때 컬렉션 객체의 통일된 함수 시그니처를 사용하는 일반적인 정책을 사용할 수 밖에 없으므로, 정책이 한정되기 때문이다. 링크드 리스트를 사용한다는 것은 각각의 객체들에게 루프로 시행할 자신의 행위에 대한 정책을 결정할 수 있게 하는 것이며 이로 인해 훨씬 더 많은 복잡도를 다룰 수 있게 된다. 자신의 구조를 동적으로 추가하는 것을 Decorator 패턴이라 한다면, 명령에 대한 책임을 작게 나누어 외부에 연쇄적으로 위임하는 것은 Chainable Responsibility 패턴이라고 한다.
profile
inudevlog.com으로 이전해용

0개의 댓글