매일 쏟아져 나오는 ES6에 대한 글을 보면 그저 한낱 먼 미래의 이야기라는 생각이 든다. 서비스를 ES6 로 개발하기에 버전 별 브라우저 사용률이 발목을 잡고 있기 때문이다. b2c는 그나마 좀 낫다. b2b는 여러 제약 때문에 windows xp를 사용하는 고객도 있고 (우리의 xp는 IE8 까지만 사용 가능하다) 심지어 최신 브라우저를 쓰면서도 '호환성 보기 모드' (IE8 시뮬레이션 모드)를 설정해놓고 사용하는 경우도 있다.
새로 시작하는 프로젝트가 IE8을 지원해야 하면 읽고 있던 ES6에 대한 글을 전부 닫고 나와 상관없는 이야기라며 멀리하게 된다. 하지만 조금 더 냉철하게 생각해 보자. ES6의 효율성을 포기하기에 너무 이르다.
// ES5
var tmp = getASTNode(),
op = tmp.op,
lhs = tmp.lhs,
rhs = tmp.rhs;
// ES6
var { op, lhs, rhs } = getASTNode();
// ----
// ES5
var html = '<span>' + item.name + '</span>' +
'<span class="' + item.cssClass + '">' + item.description + '</span>';
// ES6
const result = `<span>${item.name}</span>
<span class="${item.cssClass}">${item.description}</span>`;
// ----
// ES5
'hello'.indexOf('ell') > -1; // true
// ES6
'hello'.includes('ell'); // true
// ----
// ES5 의 배열 중복제거 함수
function dedup(arr) {
for(i) ...
for(j) ...
}
// ES6의 배열 중복제거 함수
const dedup = arr => [...new Set(arr)];
국내외 브라우저 사용률 자료가 뭐라고 하던 ES6지원 브라우저의 점유율은 오르고 있다. 벤더들은 하위 버전 지원에 칼을 빼 들었다. 지금 xp를 사용하려 하면 운영체제 자체에서 하위지원 종료에 관한 메시지가 뜨고, IE8을 쓰면 최신 브라우저의 안내 페이지가 수시로 열린다. 브라우저들이 점점 evergreen 정책으로 배포된다. 뒤에서 밀고 앞에서 잡아당기는 형상이니 언젠간 끌려가지 않을까?
다시 돌아와서 ES6를 사용하기 위해선 고객이 순순히 아래와 같이 따르면 된다.
하지만 브라우저가 뭔지 모른다거나 눈에 흙이 들어가기 전에 다른 브라우저는 안되거나 환경 때문에 어쩔 수 없다면? 그런 고객을 바꿀 순 없으니 우리가 바꾸면 된다. 적어도 IE8이상이면 말이다.
이 글은 위의 상황에서도 ES6를 사용하는 방법에 대해 다룬다. 먼저 ES5만 지원하는 '모던' 브라우저에서 ES6를 사용하는 방법을 알아보고 더 내려가 IE8도 사용할 수 있는 설정에 대해 알아본다. 참고로 NHN Entermainment 기술본부의 서비스 기본 브라우저 지원 범위에서 IE7은 '비즈니스와 관계 없을 경우 지원하지 않음' 이다. 사실 얼마전에 변경된 이 정책으로 인해 이 글을 쓸 수 있게 되었다.
transpiler는 compiler와 같이 코드를 무언가로 변환한다. compiler는 코드를 바이트 코드로 변환하지만, transpiler는 코드를 같은 레벨의 다른 언어로 변환한다.
JS transpiler는 coffeescript, typescript, babel, traceur 가 있다. 이 중 coffeescript, typescript는 고유의 문법을 JS로 변환하고, babel, traceur 는 JS 코드를 JS코드로 변환하는 transpiler이다. 왜 JS를 JS로 변환하는 transpiler가 있을까?
현재 ES6 스펙을 100%지원하는 브라우저는 없다. Chrome이 93%정도를 지원하고 다른 브라우저는 그 보다 낮다. ES6코드를 원본 그대로 실행시킬수 있는 브라우저가 아직 없다는 이야기이다.
// ES6
function foo(name = "john") {}
// ES5
function foo(name) {
name = typeof name === "undefined" ? "john" : name;
}
위의 코드는 ES6의 스펙인 default function parameter를 사용하고 있다. 이 문법은 방어 코드를 간결하게 만드는 주요 문법 중 하나다. (파라미터 갯수에 비례해 드라마틱하게 코드가 간결해진다)
비교적 최신 브라우저인 Edge13 (2015/12/05 출시) 버전 조차 이 문법을 지원하지 않는다. (실행하면 문법 자체가 없으니 Syntax Error를 발생한다) 또 IE11에서는 함수 중첩의 복잡함을 해소할 수 있는 Arrow function 스펙을 지원하지 않는다.
// ES6
$.ajax('/users', {
success: res => { console.log(res); },
error: err => { console.log(err.message); }
);
// ES5
$.ajax('/users', {
success: (function(res) {
console.log(res);
}).bind(this),
error: (function(err) {
console.log(err.message);
}).bind(this)
});
그럼 ES6버전의 코드를 미지원 브라우저에서 동작하게 하려면 어떻게 해야 할까? 결론은 하나하나 직접 수정해야 한다.
이때 babel 또는 traceur transpiler를 사용한다. ES6코드들을 전부 ES5코드로 변환하기 때문에 현재까지 출시된 모든 '모던' 브라우저에서 문제 없이 동작한다. (물론 '꼬리호출'과 같은 native단의 최적화 스펙은 적용되지 않지만 일반적인 문법은 전부 지원한다)
일반적으로 JS파일들은 성능 또는 보안 이슈 때문에 압축 후 배포한다. 압축 전에 transpiler가 ES5기반 코드로 변환하는 작업을 하도록 설정하면 된다. 듣기만 해도 복잡하고 어려울 것 같지만 변환과 압축을 한방에 해 주는 도구가 있다. 설정 파일을 만들고 명령어를 실행만 하면 된다.
Babel은 ES6코드를 ES5로 변환하는 transpiler다. 앞서 이야기했던 Syntax Error가 발생하는 코드 자체를 자동으로 ES5지원 코드로 변환한다. 그럼 class키워드는 어떻게 변환될까?
여러분도 알다시피 JS의 OOP는 시뮬레이팅 방식이기 때문에 헬퍼 함수 즉 클래스를 흉내내는 유틸리티 함수가 필요하다. Babel은 class키워드를 만났을 때 자동으로 Babel이 자체 구현한 클래스 시뮬레이션 함수를 포함시킨다.
// ORIGINAL
class User {
test() {}
}
class Admin extends User {}
// TRANSPILED
function _possibleConstructorReturn(self, call) {
/*생략*/
}
function _inherits(subClass, superClass) {
/*생략*/
}
function _classCallCheck(instance, Constructor) {
/*생략*/
}
var User = (function() {
function User() {
_classCallCheck(this, User);
}
User.prototype.test = function test() {};
return User;
})();
var Admin = (function(_User) {
_inherits(Admin, _User);
function Admin() {
_classCallCheck(this, Admin);
return _possibleConstructorReturn(this, _User.apply(this, arguments));
}
return Admin;
})(User);
_possibleConstructorReturn, _inherit, _classCallCheck은 babel이 가진 클래스 시뮬레이팅 유틸리티 함수다. transpiler가 class 키워드와 extends 키워드를 만났기 때문에 자동으로 소스에 추가된 것이다. 아래에는 User, Admin 함수가 일반적인 prototypal inheritance 패턴으로 변환되어 있는 것을 볼 수 있다.
변환 후 코드가 조금 깔끔하지 않지만 babel은 google의 traceur 에 비해 훨씬 보기좋게 변환된다. JS transpiler 중에 제일 보기 좋게 변환된다고 한다.
_callClassCheck은 생성되는 클래스가 new와 함께 쓰이지 않을 경우 오류를 내기 위한 함수이다. 즉 클래스를 클래스로 사용하는지 검사하는 것으로 볼 수 있는데 ES6명세에 맞게 쓰는지 검사하는 코드도 포함된다, 이는 옵션으로 조정 가능하다. 리얼 배포시엔 성능을 확보하기 위해 푼다.
이제 변환된 코드를 사용하면 '모던' 브라우저 대부분에서 ES6를 문제 없이 사용할 수 있게 된다. 하지만 IE8에서 동작하게 하려면 조금 더 설정이 필요하다.
Babel을 통해서 ES6 코드를 '모던' 브라우저에서도 동작할 수 있도록 했다. 그럼 IE8은 어떻게 할까? 다음의 코드를 변환했다고 가정해 보자.
// ORIGINAL
class UserList {
constructor() {
this.users = [];
}
contains(name) {
return this.users.indexOf(name);
}
}
// TRANSPILED
function _classCallCheck(instance, Constructor) {
/* 생략 */
}
var UserList = (function() {
function UserList() {
_classCallCheck(this, UserList);
this.users = [];
}
UserList.prototype.contains = function contains(name) {
return this.users.indexOf(name);
};
return UserList;
})();
UserList의 users 프로퍼티는 배열이고 contains() 메서드는 이 배열의 indexOf()로 포함 여부를 검사하고 있다. 이 코드가 IE8에서 동작할까? 답은 '아니오' 다.
['a', 'b', 'c'].indexOf('b');
는 IE8에서 오류가 발생한다. '없기' 때문이다. IE8이하는 배열의 요소를 검색할 수 있는 native 함수가 없다. 그래서 보통 같은 동작을 하는 함수를 만들어 프로젝트의 어딘가에 두고 쓴다.
function indexOf(arr, identity) {}
var arr = ["a", "b", "c"];
indexOf(arr, "c"); // 2
이런 패턴을 Fallback이라 한다. 없는 기능을 흉내내 쓰는 것이다. 하지만 이 코드를 쓰게 되면 indexOf를 native 에서 지원하는 브라우저에서도 Fallback을 쓰기 때문에 성능 저하의 원인이 된다. 그래서 다른 방법을 사용한다.
if (typeof Array.prototype.indexOf === "undefined") {
Array.prototype.indexOf = function() {};
}
Array 라는 native 객체에 indexOf 메서드가 없을 경우 아까 만들었던 Fallback으로 대체한다. 이 패턴을 Polyfill이라고 한다. 이로써 native에 있으면 native 구현을 사용하고 없으면 Polyfill을 쓰게 된다.
Babel은 ES6 코드를 'ES5를 지원하는 브라우저'에서 돌아가도록 하는 역할만 한다. 여기에 브라우저에 IE8은 포함되지 않는다. 그래서 IE8에서 빠진 ES5의 기능을 채울 수 있는 Polyfill 이 필요한 것이다. 노파심에 언급하지만 Babel과 Polyfill은 별개다. Polyfill만 추가해서 ES5의 기능을 IE8에서 쓰게 할 수도 있다.
Babel은 공식적으로 core-js를 Polyfill '옵션'으로 제공하고 있다. 때문에 babel-polyfill 패키지를 설치하고 코드에 포함시키는 것으로 요구사항을 만족시킬 수 있다. 그럼 이제 IE8에서 코드를 실행하면? 오류가 발생한다. 아직 한 가지 설정이 남아있다.
// ORIGINAL
class UserList {
constructor() {
this.users = new Set();
}
delete(name) {
return this.users.delete(name);
}
}
// TRANSPILED
function _classCallCheck(instance, Constructor) {
/* 생략 */
}
var UserList = (function() {
function UserList() {
_classCallCheck(this, UserList);
this.users = new Set();
}
UserList.prototype.delete = function _delete(name) {
// ERROR
return this.users.delete(name); // ERROR
};
return UserList;
})();
이번엔 users 프로퍼티를 ES6의 콜렉션인 Set 으로 변경했다. 그리고 delete
메서드를 통해 user를 제거할 수 있도록 했고 이를 babel을 통해 transpile 했다. 이 코드를 IE8에서 실행하면? Expected identifier
오류가 발생한다. 바로 delete
라는 키워드를 접근자로 사용했기 때문이다.
물론 이 코드는 ES5지원 브라우저에서는 유효하다. 때문에 transpile 중 접근자가 키워드일 때 obj['delete']();
형태로 바뀌어야 한다. Plugin을 통해 해결할 수 있다. member-expression-literals, property-literals, 플러그인은 transpile 중 이런 member literal 표기를 콤마로 감싸준다. 그래서 obj['delete']();
로 변환한다. 이제 IE8에서도 ES6문법을 사용할 수 있게 되었다! (확실히 더 해 줄건 없다. 실무에서 사용 중이고 문제가 발생한 적은 없다)
하위호환을 위한 Plugin뿐만 아니라 ES6 이후의 스펙을 위한 Plugin도 있다. async, await 스펙은 ES7의 스펙이지만 ES6의 generator를 응용하여 변환해주는 Plugin도 있다. 각 스펙별로 Plugin이 존재하니 필요하면 그때그때 설치해 사용하면 된다.
길고 긴 이론 설명이 끝났으니 이제 쓰는 방법이다. npm을 통해 설치한다.
$ npm i --save-dev babel-core babel-preset-es2015 babel-polyfill
babel-plugin-transform-es3-member-expression-literals
babel-plugin-transform-es3-property-literals
각 모듈을 설명한다
프로젝트 루트에 .babelrc
를 만들고 아래와 같이 설정한다.
{
"presets": ["es2015"],
"plugins": [
"transform-es3-property-literals",
"transform-es3-member-expression-literals"
]
}
그 후 babel
명령어와 함께 대상 파일을 glob 패턴으로 적어주면 변환된 결과가 나온다. 자세한 사항은 공식 문서를 참고하기 바란다.
지금까지의 과정을 요약하면 babel transpiler를 통해 ES6코드를 ES5에서 동작하게 할 수 있다. 이 때 IE8은 ES5 스펙 전체를 지원하지 않으므로 부족한 부분은 Polyfill을 사용한다. 추가적으로 이전 버전에서 키워드를 접근자로 사용할 때 오류가 나는 것을 방지하기 위해 Plugin으로 키워드는 문자로 감싸준다.
마지막으로 기타 팁들을 적어 본다
ES6코드의 효율성은 정말 엄청나다. 간결해지면서도 코드의 역할을 충실히 코딩으로 기술할 수 있다. JS는 '비교적' 최근들어 등장한 프로그래밍 패러다임을 소화할 수 있도록 변화하고 있다. 상황이 받쳐주지 않지만 근 시일 내에 변화가 올 것이라고 생각한다. 그 때가 왔을 때 새로 개발하지 말고 원본소스를 그대로 배포하자.
참고