개요
현재 tc39 제안에 보면 Stage1에서 계류 중인 제안 중 옵셔널 채이닝(optinal chaining)이 있습니다.
https://github.com/tc39/proposal-optional-chaining
M$를 거쳐 Godaddy에 가 있는 가브리엘 챔피온 주도 하에 진행 중인 제안인데, 형태를 보면 다분히 스위프트의 옵셔널이 채이닝하는 방식과 흡사합니다. 이 문법의 형태는 현재 ES기조와 약간 다른 노선이라 과연 표준으로 채택될 지 미지수지만, 이 제안에 영감을 얻어 나름대로의 옵셔널을 구현해보기로 했습니다.
옵셔널 기초
옵셔널(optional)에 대한 기초적인 개념부터 생각해보겠습니다. 보통 변수가 갖는 대표적인 속성은 다음과 같은 것들이 있습니다.
- 실제 메모리의 주소 – 값을 쓸 메모리의 위치죠.
- 메모리에서 차지할 크기 – 얼마나 큰 메모리 공간을 할당해줘야하는가를 의미합니다.
- 값인지 참조인지 – 값이면 메모리에서 전체를 복사를 통해 할당되고, 참조면 주소만 복사하게 됩니다.
헌데 옵셔널이란 개념은 객체 참조형 변수에 한 가지를 속성을 더해줍니다. 그 객체 참조형 변수에 null을 할당이 가능한가? 를 정의하는 거죠(숫자나 불린형의 경우는 원래 null을 할당할 수 없으니까요)
어떤 변수가 옵셔널 타입이라면 null을 할당할 수 있고, 아니라면 null을 할당할 수 없게 처음부터 막아버리는 것입니다.
실행 시점에 발생하는 에러 대부분이 null point 참조 에러인 점을 감안해보면, 다소 귀찮더라도 null이 처음부터 할당 불가능한 변수는 충분한 가치가 있을 것입니다. 이러한 아이디어는 코틀린, 스위프트 같은 신생 언어는 물론 기존 자바나 c#에도 반영되어 직접적인 옵셔널이 지원되던가 간접적으로 지원됩니다.
이 포스팅에서는 스위프트식 옵셔널을 구현해볼 건데, 스위프트에서는 변수를 선언하는 시점부터 변수형에 ?를 붙여 옵셔널 변수를 별도 선언하는 식으로 사용됩니다. 이를 간단히 js식으로 표현해보죠.
//일반 변수는 null할당 불가 let name0 = 'hika'; name0 = null; //즉시 에러! //?를 붙여 옵셔널 변수 선언 let name1? = 'hika'; name1 = null; //괜찮음!
또한 스위프트의 옵셔널 변수는 즉시 값을 꺼낼 수 없고 항상 추출 절차를 통해서 값을 얻게 합니다. 이것도 간단히 js식으로 예를 보죠.
//옵셔널 변수 let nameOpt? = 'hika'; //옵셔널 값을 추출하여 할당할 일반 변수 let name; //옵셔널을 추출하여 일반 변수에 할당 if(name = nameOpt.extract()){ //추출 성공! 값을 담은 일반 변수 사용. console.log(name); }else{ //null인 경우 추출 실패! console.log('fail to extract'); }
고작 값을 얻기 위해 저렇게나 귀찮게 작성해야 하는 코드는 향후 null값들이 일으킬 수 많은 버그를 생각해보면 오히려 최소한이라고 할 수도 있습니다. 결국 옵셔널 + 추출 문법은 결국 런타임에 null체크를 하는 밸리데이션 코드와 비슷하기 때문에 밸리데이션을 생략할 수 없는 장치라고 생각할 수도 있습니다.
하지만..귀찮은 건 귀찮은 것입니다 ^^;
옵셔널 채이닝(optional chaining)
옵셔널은 귀찮지만 편리한 면도 있습니다. 바로 옵셔널의 채이닝 상황입니다. 이 상황은 다음과 같은 코드로 생각해볼 수 있습니다.
console.log(a.b.c);
만약 a의 b속성에 값이 있을 수도 있고 null일 수도 있다고 생각해보세요. null인 경우는 c속성을 참조하려는 순간 null point참조 에러로 죽어버립니다.
바로 이게 가장 흔하게 발생하는 오류로 크롬에서는 Cannot read property ‘c’ of undefined 또는 Cannot read property ‘c’ of null 로 나타나는 오류입니다.
이 오류는 어떤 변수(또는 속성)가 처음에는 null이 아닐지라도 실행 중에 얼마든지 null이 될 수 있기 때문에 발생됩니다.
//a.b.c출력기 const log =_=>console.log(a.b.c); //이 시점에는 a.b.c가 있다 const a = {b:{c:3}}; log(); //3 - 문제없음 //실행 중에 b가 사라짐 a.b = null; log(); //Cannot read property 'c' of null
결국 특별하게 null을 막는 장치가 없는 이상 정상적으로 보이거나 컴파일되는 코드라도 실행 중 null이 되는 순간 잠재적인 오류의 대상이 될 수 있는 것이죠. 이는 다중으로 중첩 할수록 더 큰 문제가 됩니다.
const value = a.b.c.d.e.f;
각 구성 부분에 하나라도 undefined나 null이 할당되면 에러로 죽어버립니다. 이 에러는 JS실행 자체를 완전히 죽이는 에러로 치명적입니다.
실행 시 굉장히 위험한 상황이라 이를 처리하는 안전한 방법이 필요한데 크게 두 가지가 있습니다. 우선은 예외로 막는 방법이 있죠.
let temp = null; try{ temp = a.b.c.d.e.f; }catch(e){} const value = temp;
예외처리를 매번하기는 귀찮지만 try블록 안의 내용을 크게 신경쓰지 않는다는 점에서는 오히려 덜 귀찮은 편입니다. 단지 예외는 전체적인 실행을 느리게 만듭니다(뭐 그렇다고 다른 방법이 try보다 그렇게 빠른 것은 아닙니다 ^^)
두 번째는 런타임 검사를 직접 하는 방법입니다.
let temp = null; if( a && a.b && a.b.c && a.b.c.d && a.b.c.d.e ) temp = a.b.c.d.e.f; const value = temp;
직접 검사는 귀찮고 일반화시키기 까다롭습니다. 그래서 이런 상세한 절차를 생략하고 무조건 할당하다가 null point참조 에러를 양산하게 되죠 ^^;
JS의 키는 문자열로 참조할 수 있으므로 중첩키 상황만 놓고 보면 문자열 참조키를 받아 일반화 시키는 도우미 함수를 만들 수 있습니다.
과거 많은 라이브러리에서도 제각각의 형태로 제공되어 왔습니다. es6에서는 내장 every를 이용해 간단히 구현할 수 있습니다. 안전한 중첩 속성 추출기(extract)를 작성해보죠.
const extract =(target, keys)=>{ keys.split('.').every(v=>target = target[v]); return target; }; const value = extract(a, 'b.c.d.e.f');
이 해법은 실행 시점의 null 조사를 일반화 시킵니다. 실무적으로 편하고 코드도 짧기 때문에 자주 사용하는 기법입니다. 속성처럼 사용하려면 좀 위험하지만 Object.prototype에 추가해주면 됩니다.
Object.prototype.extract = function(keys){ let target = this; keys.split('.').every(v=>target = target[v])); return target; }; const value = a.extract('b.c.d.e.f');
이러한 과거의 기법을 아예 JS문법에 포함하자고 제안한 게 바로 “옵셔널 채이닝” 입니다.
const value = a.b?.c?.d?.e?.f;
이렇게 쓰면 ?가 붙은 속성은 null의 가능성을 내포하고 있다고 미리 JS엔진에게 힌트를 주는 것입니다.
그럼 JS는 b.c.d.e 구간을 조회할 때 중간에 null이 나와도 에러를 만들지 않고 부드럽게 그 뒤를 진행하지 않은 체 null을 반환하게 됩니다.
나쁜 생각은 아니지만 이는 단지 중첩 속성 참조 시에 에러를 억제하는 용도입니다. 아마 그렇기 때문에 제안 자체도 “옵셔널”이 아니라 “옵셔널 채이닝”이라고 한정된 이름이 된 것이라 예상됩니다.
스위프트에서는 옵셔널 변수와 일반 변수의 구분이 있어 null 할당을 방지하는 기능을 기본으로 제공한 상태에서 채이닝을 제공하는데 반해 옵셔널 채이닝은 실제 채택 되도 null point참조의 문제를 일부 억제할 뿐 해결안이 되기는 힘들 것입니다.
그렇기 때문에 나름대로의 옵셔널을 구현하여 적용해보려는 것입니다.
엄격하게 스위프트식으로 구현해보기
스위프트는 값 타입을 중요하게 여기는 언어로 Structure와 Enum을 지원합니다. 스위프트에서 옵셔널의 정체는 Enum으로 Optional.none(null인 경우)과 Optional.some(값이 있는 경우)으로 처리됩니다.
하지만 JS에서는 일반 변수와 옵셔널 변수의 구분도 없고 위와 같은 특별한 생성자를 지원하지 않습니다.
따라서 최대한 개념을 이어 받으면서 JS의 특성에 맞춰 나름대로의 형태로 구현해보죠.
- 우선 null값을 허용하지 않는 Val타입과
- null을 허용하는 Opt타입이 있다 하죠.
Opt의 내부 상태는 none이거나 some이 되게 할 수도 있는데 큰 쓸모가 없어서 생략하겠습니다.
- 또한 내부의 값을 추출할 때는 짧게 v라는 getter를 이용하기로 하겠습니다.
- 매번 new하기는 귀찮으니 간단한 팩토리 함수를 만들어 노출하기로 합니다.
이를 간단히 스케치하면 다음과 같은 코드가 됩니다.
//팩토리함수를 얻는다 const {opt, val} =(_=>{ //private속성을 위한 심볼 const value = Symbol(); //null불가 타입 const Val = class{ constructor(v){this.v = v;} get v(){return this[value];} set v(v = null){ //undefined, null 통합! //null을 여기서 막음! if(v === null) throw 'null!'; this[value] = v; } }; //옵셔널 타입 const Opt = class{ constructor(v){this.v = v;} get v(){return this[value];} set v(v = null){ this[value] = v; } }; //팩토리함수 const val =v=>new Val(v); const opt =v=>new Opt(v); return {val, opt}; })();
이제 다음과 같이 사용할 수 있을 것 입니다.
//null불가 const name1 = val('hika'); //v속성으로 추출 console.log(name1.v); //hika //null을 할당하려고 하면 죽는다! name1.v = null; //throw!! //옵셔널로 선언 const name2 = opt('hika'); //null을 할당해도 괜찮음 name2.v = null;
이제 가장 기본이 되는 옵셔널과 null을 허용하지 않는 방식의 변수를 선언할 수 있게 되었습니다.
옵셔널 체이닝의 구현
v속성이 값을 추출하는데 사용한다면 옵셔널을 채이닝할 때는 c(chaining)속성으로 연결해볼 수 있을 것입니다.
그럼 채이닝을 반환한다는 의미는 뭘까요?
- 채이닝한 대상이 null이 아니라면 그 값을 반환하면 됩니다.
- 하지만 null인 경우 실제 엔진이 제공한다면 null을 반환하면서 이후 뒤에 있는 코드를 무시하면 되겠죠.
- 우리는 엔진이 아니니 null일 때도 뭔가 반환하고 그 객체가 다른 속성으로 연결할 수 있게 해줘야 합니다.
어떤 옵셔널이 c속성으로 반환 채이닝 객체에게 계속 다른 중첩 속성을 물어보게 됩니다.
이 무한한 속성명에 대응하려면 Proxy를 이용할 수 밖에 없습니다. 즉 채이닝 반환 객체의 정체는 특별한 프록시 객체입니다.
혹시 프록시가 뭔지 잘 모르시는 분들은 mdn을 참고하세요
이 프록시 객체의 get트랩을 생각해보면
- v속성으로 값을 최종 추출하면 null을 반환하지만
- 그 외의 나머지 속성은 또 다시 프록시 객체인 자신을 반환하는 것
이라 정의할 수 있습니다. 즉 일단 체이닝이 반환되고 나면 v를 호출하기 전까지는 무한히 채이닝 자신이 반환되는 것이죠.
말 그대로를 프록시로 구현하면 다음과 같을 것입니다.
const chain = new Proxy({}, { //v키는 null, 그 외에는 무한 채이닝 get(_, key){return key === 'v' ? null : chain;} });
이제 옵셔널에서는 c속성을 구현할 때
- 값이 null이 아니라면 그 값을 반환하고
- null인 경우는 위에서 정의한 chain객체를 반환해주면 됩니다.
const chain = new Proxy({}, {get(_, key){return key === 'v' ? null : chain;}}); const value = Symbol(); const Opt = class{ constructor(v){this.v = v;} get v(){return this[value];} set v(v = null){this[value] = v;} //값이 null이면 chain객체를 보낸다 get c(){return this.v === null ? chain : this.v;} };
이제 옵셔널 채이닝이 c속성을 통해 가능해졌습니다. 제가 필력에 감동한 야곰님의 스위프트 프로그래밍에 나온 클래스명을 그대로 재현해보겠습니다.
const Room = class{ constructor(num){this.num = val(num);} }; const Building = class{ constructor(name){ this.name = val(name); this.room = opt(null); } }; const Address = class{ constructor( province, city, street, building, detailAddress){ this.province = val(province); this.city = val(city); this.street = val(street); this.building = opt(building); this.detailAddress = opt(detailAddress); } }; const Person = class{ constructor(name){ this.name = val(name); this.address = opt(null); } };
위에 등장하는 클래스는 4가지로
- Person이 Address를 옵셔널로 소유하고
- Address는 Building을 옵셔널로 소유하며
- Building은 Room을 옵셔널로 소유합니다.
- 최종 Room에 num은 null을 허용하지 않는 Val입니다.
이제 다음과 같은 코드를 기대할 수 있을 것입니다.
const person = val(new Person('hika')); const number = person.v.address.c.building.c.room.c.num.v;
- 최초 생성한 person은 address가 opt(null)인 상태이므로 address.c는 이미 chain프록시를 반환했을 것입니다.
- 이후 모든 체인은 전부 chain객체가 반환되며 최종 v속성에서 null이 반환되어 number에는 null이 들어옵니다.
즉 이미 person.v.address.c 시점에 chain프록시 객체가 반환된 상태이므로 그 이후 마지막에 num.v의 v가 호출되기 전까지 쭉 chain객체가 반환되다가 마지막에 v에서 null이 반환된거죠.
반면 각 속성에 값을 넣어주면 제대로 작동하게 됩니다.
const person = val(new Person('hika')); //person에게 address입력 person.v.address.v = new Address('province', 'city', 'street', null, null); //address에게 building입력 person.v.address.c.building.v = new Building('building'); //building에게 room입력 person.v.address.c.building.c.room.v = new Room(17); const number = person.v.address.c.building.c.room.c.num.v; //17
굉장히 옵셔널체인 문법과 흡사해졌습니다만 v, c등의 속성명이 엇갈리기도 하고 보기에도 안좋아보입니다.
이를 개선하기 위해 또 다른 방법을 동원할텐데 바로 es6부터 도입되어있는 코어객체 상속입니다.
Function상속과 apply트랩으로 개선
코어객체 중 Function을 상속받으면 인스턴스 자체가 callable이 되므로 new로 생성된 인스턴스를 함수처럼 호출할 수 있게 됩니다.
실제 호출 시의 작동은 프록시를 이용해 apply트랩을 걸어주면 되죠(apply트랩 정보)
우선 쉬운 Val부터 처리해보죠.
//value값 반환 const trapV = {apply(t){return t[value];}}; const value = Symbol(); const Val = class extends Function{ constructor(v){ super(); this.v = v; return new Proxy(this, trapV); } set v(v = null){ if(v === null) throw 'null!'; this[value] = v; } };
v에 대한 게터가 완전히 제거되고 오직 apply트랩으로만 실제 값을 추출할 수 있게 되었습니다.
이제 val형은 호출을 통해 값을 추출합니다..
const person = val(new Person('hika')); //호출로 값 추출 console.log(person()); // Person
이제 좀 더 복잡한 옵셔널에 적용해보죠.
Opt형은 호출 시엔 c속성이 대응하지만 값을 얻을 때는 v속성이 대응해야 하는 복잡함이 있습니다. 하지만 함수 호출로 추출과 체이닝을 구분해야하니 인자를 보내는 수 밖에 없겠죠.
옵셔널은 체이닝 상황이 더 많고 추출은 마지막에만 일어나므로 다음과 같이 정했습니다.
- true를 보내면 추출하고 안보내면 체이닝으로 작동
const trapO = { apply(t, _, a){ //인자에 참이 들어오면 추출 if(a[0]) return t[value]; //아니면 기존 채이닝 로직! return t[value] === null ? chain : t[value]; } }; const value = Symbol(); const Opt = class extends Function{ constructor(v){ super(); this[value] = v; return new Proxy(this, trapO); } };
이를 실제로 사용해보면 다음과 같을 것입니다.
const person = opt(new Person('hika')); //값을 추출하려면 true를 인자로 보낸다 console.log(person(true)); // Person //체이닝 하려면 인자 없이 호출한다. console.log(person()); //chain객체 반환
이제 모든 재료가 갖춰졌으니 위의 예제는 다음과 같은 코드로 바꿔 쓸 수 있습니다.
const person = val(new Person('hika')); person().address.v = new Address('province', 'city', 'street', null, null); person().address().building.v = new Building('building'); person().address().building().room.v = new Room(17); const number = person().address().building().room().num(); //17
아까의 .c .v의 조합보다는 훨씬 나아진 모습으로 체이닝하게 되었습니다.
하지만 짜피 Val과 Opt 모두 프록시를 이용하는 마당에 apply트랩만 이용할 필요는 없습니다.
현재 추출과 채이닝을 구분하기 위해 인자를 사용하고 있는데 인자를 제거하고
- 일반적인 속성을 부르면 채이닝으로 동작하고
- 호출 시에는 오직 추출로만 동작하게
개선할 수 있을 것입니다. 이럴려면 get트랩과 apply트랩을 동시에 사용하면 됩니다.
마지막으로 더 나아진 프록시 트랩을 보죠.
get트랩과 apply트랩을 동시에 이용한 옵셔널
이 아이디어는 Val, Opt가 호출을 추출 시에만 쓰고 속성을 사용하면 채이닝으로 작동하게 하는데 있습니다.
단지 호출 시에 인자를 보내면 오히려 setter로 사용할 수 있을 것입니다. 이걸 통해 값을 재설정하는데 사용하던 v속성도 파기할 수 있습니다.
완성되고 난 후 최종적인 사용 형태는 다음과 같을 것입니다.
const person = val(new Person('hika')); //v세터 대신 괄호에 값을 보내면 갱신된다. person.address(new Address('province', 'city', 'street', null, null)); const number = person.address.building.room.num();
중간 채이닝에서 괄호도 없어졌기 때문에 최종 값만 추출하는 형태가 되어 더욱 명확하고 간단한 표현이 가능해집니다.
이러한 채이닝에는 Val도 참가하기 때문에 Val도 마찬가지로 get 트랩을 구현해야 합니다.
get트랩에서 대해서 아이디어를 정리해보면
- person.address는 실제로는 person[value]에 있는 객체를 찾아 이 객체의 address를 반환해야 합니다.
- 즉 person[value].address를 반환해야 하죠.
- Val은 [value]가 null일 가능성은 없습니다만 그 안에 있는 값은 얼마든지 null일 수 있습니다.
- 하지만 Val입장에선 [value]만 보장하면 되기 때문에 그저 중계만 할 뿐입니다.
이를 트랩으로 표현하면 다음과 같습니다.
const trapV = { apply:(t, _, a)=>{ //인자가 들어오면 할당 if(a.length){ if(a[0] === null) throw 'null!'; t[value] = a[0]; } //기본은 추출 return t[value]; }, //그저 [value]안에 있는 값의 속성을 중계해줄 뿐이다. get:(t, k)=>t[value][k] };
따라서 Opt용 트랩은 오히려 간단합니다. 이미 인자에 따라 추출과 채이닝을 분리해뒀기 때문에 그저 채이닝 부분만 get트랩으로 옮겨주면 됩니다.
즉 기존의 트랩을 보면
const trapO = { apply(t, _, a){ //1. 인자에 참이 들어오면 추출 if(a[0]) return t[value]; //2. 아니면 기존 채이닝 로직! return t[value] === null ? chain : t[value]; } };
1, 2번을 인자로 분리해서 처리했으나 이를 apply와 get트랩으로 나눠주면 됩니다.
const trapO = { //1. apply는 추출과 할당만 apply:(t, _, a)=>a.length ? (t[value] = a[0]) : t[value], //2. get은 채이닝만! get:(t, k)=>t[value] === null ? chain : t[value][k] };
전체 예제는 완전히 바꿔쓸 수 있게 되었습니다.
const person = val(new Person('hika')); //address부터 없으니 null console.log(person.address.building.room.num()); //person의 address할당 person.address(new Address('province', 'city', 'street', null, null)); //하지만 building부터 없으니 null console.log(person.address.building.room.num()); //address의 building할당 person.address.building(new Building('building')); //하지만 room이 없으니 null console.log(3,person.address.building.room.num()); //building의 room할당! person.address.building.room(new Room(17)); //드디어 17이 출력! console.log(person.address.building.room.num());
값의 추출 및 재할당은 Val과 Opt 둘 다 호출을 통해서만 가능하고 자연스럽게 속성을 체이닝하는 형태가 된거죠.
결론
기존 extract방법론에서 스위프트의 옵셔널, ecmascript 제안 stage1에 등록된 옵셔널체이닝까지를 살펴보며 다양한 구현을 해봤습니다.
옵셔널을 도입하면 수 많은 버그를 예방할 수 있고 특히 크롬의 경우 63이후에 프록시의 성능이 비약적으로 높아졌습니다.
https://v8project.blogspot.kr/2017/10/optimizing-proxies.html
설령 옵셔널체이닝이 표준이 된다고 해도 널값에 대한 문제가 해결되는 것은 아니므로 프로젝트에 실제로 val과 opt를 적용해 다양한 시도를 해보고 있습니다. 최종 코드는 다음과 같습니다.
const [op, val] =(_=>{ const $ = Symbol(), isN =v=>v === null, err =v=>{throw v;}; const trapV = { apply:(t,_,a)=>(a.length ? (t[$] = isN(a[0]) ? err('null!') : a[0]) : t[$]), get:(t, k)=>t[$][k] }; const Val = class extends Function{ constructor(v){ super(); this[$] = isN(v) ? err('null!') : v; return new Proxy(this, trapV); } }; const chain = new Proxy(_=>_, {apply:_=>null, get:_=>chain}); const trapO = { apply:(t, _, a)=>a.length ? (t[$] = a[0]) : t[$], get:(t, k)=>t[$] === null ? chain : t[$][k] }; const Opt = class extends Function{ constructor(v){ super(); this[$] = v; return new Proxy(this, trapO); } }; return [v=>new Opt(v), v=>new Val(v)]; })();
예제코드의 최종버전은 아래와 같습니다.
const Room = class{ constructor(num){this.num = val(num);} }; const Building = class{ constructor(name){ this.name = val(name); this.room = op(null); } }; const Address = class{ constructor(province, city, street, building, detailAddress){ this.province = val(province); this.city = val(city); this.street = val(street); this.building = op(building); this.detailAddress = op(detailAddress); } }; const Person = class{ constructor(name){ this.name = val(name); this.address = op(null); } }; const person = val(new Person('hika')); console.log(1,person.address.building.room.num()); //1, null person.address(new Address('province', 'city', 'street', null, null)); console.log(2,person.address.building.room.num()); //2, null person.address.building(new Building('building')); console.log(3,person.address.building.room.num()); //3, null person.address.building.room(new Room(17)); console.log(4,person.address.building.room.num()); //4, 17
recent comment