ES2015
2015년에 자바스크립트 문법에 매우 큰 변화가 있었다. 바로 ES2015이다.
ES2015 기본 문법에 대해 알아보자
보통 자바스크립트를 배울 때 var로 변수를 선언하는 법부터 배운다. 하지만 var은 이제 const와 let이 대체한다.
const와 let이 공통적으로 갖는 특징은 블록 스코프(범위)에 대해 알아보자
if (true) {
var x = 3;
}
console.log(x); // 3
if(true) {
const y = 3;
}
console.log(y); // Uncaught ReferenceError: y is not defined
코드는 크롬 개발자 도구(F12
)의 Console탭에 적으면 된다. Enter
를 누르면 코드가 실행되는데, 코드를 실행하지 않고 줄바꿈을 하려면 Shift
+Enter
를 입력한다.
x는 정상적으로 출력되는데 y는 에러가 발생한다. var을 const로 바꿨을 뿐인데 차이가 발생하는 것이디ㅏ. var은 함수 스코프를 가지므로 if문의 블록과 관계없이 접근할 수 있다.
하지만 const와 let은 블록 스코프를 가지므로 블록 밖에서는 변수에 접근할 수 없다. 블록의 범위는 if, while, for, function 등에서 볼 수 있는 중괄호이다. 함수 스코프 대신 블록 스코프를 사용함으로써 호이스팅 같은 문제도 해결되고 코드 관리도 수월해졌다.
📌 const, let과 var은 스코프 종류가 다르다.
그렇다면 const와 let은 무엇이 다를까? const는 한 번 값을 할당하면 다른 값을 할당할 수 없다. 또한, 초기화할 때 값을 할당하지 않으면 에러가 발생한다. 따라서 const로 선언한 변수를 상수라고 부르기도 한다.
const a = 0;
a = 1; // Uncaught TypeError: Assignment to constant variable
let b = 0;
b = 1; // 1
const c; // Uncaught SyntaxError: Missing initializer in const declaration
🔎 const와 let 중에 어느 것을 써야 할까?
자바스크립트를 사용할 때 한 번 초기화했던 변수에 다른 값을 할당하는 경우는 의외로 적다. 따라서 변수 선언 시에는 기본적으로 const를 사용하고, 다른 값을 할당해야 하는 상황이 생겼을 때 let을 사용하면 된다.
ES2015 문법에 새로운 문자열이 생겼다. 이 문자열은 큰따옴표나 작은따옴표로 감싸는 기존 문자열과 달리 백틱으로 감싼다(Tab
위에 있다). 특이한 점은 문자열 안에 변수를 넣을 수 있다는 것이다.
🔽 기존 ES5문법을 사용한 문자열
var num1 = 1;
var num2 = 2;
var result = 3;
var string1 = num1 + ' 더하기 ' + num2 + '는 \''+ reuslt + '\'';
console.log(string1); // 1 더하기 2는 '3'
문자열 string1은 띄어쓰기와 변수, 더하기 기호 때문에 가독성이 좋지 않다. 또한, 작은 따옴표를 이스케이프(escape)하느라 코드가 지저분하다.
🔽 ES2015부터는 다음과 같이 사용할 수 있다.
const num3 = 1;
const num4 = 2;
const result2 = 3;
const string2 = `${num3} 더하기 ${num4}는 '${result2}'`;
console.log(string2); // 1 더하기 2는 '3'
훨씬 깔끔해졌다. ${변수} 형식으로 변수를 더하기 기호 없이 문자열에 넣을 수 있다. 기존 따옴표 대신 백틱을 사용함으로 큰따옴표나 작은따옴표와 함께 사용할 수도 있다.
객체 리터럴에 편리한 기능들이 추가되었다.
다음 코드는 oldObject 객체에 동적으로 속성을 추가하고 있다.
var sayNode = function() {
console.log('Node');
};
var es = 'ES';
var oldObject = {
sayJS: function() {
console.log('JS');
},
sayNode: sayNode,
};
oldObject[es+6] = 'Fantastic';
oldObject.sayNode(); // Node
oldObject.sayJs(); // JS
console.log(oldObject.ES6); // Fantastic
이 코드를 다음과 같이 다시 쓸 수 있다.
const newObject = {
sayJS() {
console.log('JS');
},
sayNode,
[es+6]: 'Fantastic',
};
newObject.sayNode(); // Node
newObject.sayJS(); // JS
console.log(newObject.ES6); // Fantastic
}
oldeObject와 newObject를 비교해보자.
{ name: name, age: age } // ES5
{ anme, age } // ES2015
화살표 함수(arrow function)라는 새로운 함수가 추가되었으며, 기존의 function() {}도 그대로 사용할 수 있다.
function add1(x, y) {
return x + y;
}
const add2 = (x, y) => {
return x + y;
}
const add3 = (x, y) => x + y;
const add4 = (x, y) => (x + y);
function not1(x) {
return !x;
}
const not2 = x => !x;
add1, add2, add3, add4는 같은 기능을 사용하는 함수이다. 마찬가지로 not1, not2도 같은 기능을 한다.
화살표 함수에서는 function 선언 대신 => 기호로 함수를 선언한다.또한, 변수에 대입하면 나중에 재사용할 수 있다.
화살표 함수에서는 내부에 return문 밖에 없는 경우, return문을 줄일 수 있다.
중괄호 대신 add3과 add4 처럼 return할 식을 바로 적으면 된다. add4처럼 소괄호로 보기 좋게 감쌀 수도 있다. not2처럼 매개변수가 한 개이면 매개변수를 소괄호로 묶어주지 않아도 된다.
기존 function과 다른 점은 this 바인드 방식이다.
var relationship1 = {
name: 'zero',
friends: ['nero', 'hero', 'xero'],
logFriends: function() {
var that = this; // relationship1을 가리키는 this를 that에 저장
this.friends.forEach(function (friend) {
console.log(that.name, friend);
});
},
};
relationshipe1.logFriends();
const relationship2 = {
name: 'zero',
friends: ['nero', 'hero', 'xero'],
logFriends() {
this.friends.forEach(friend => {
console.log(this.name, friend);
});
},
};
relationsipe2.logFriends2();
relationship1.logFriends() 안의 forEach 문에서는 function 선언문을 사용했다.
각자 다른 함수 스코프의 this를 가지므로 that이라는 변수를 사용해서 relationship1에간접적으로 접근하고 있다.
💡 forEach 함수 안에서의 this는 forEach 함수 스코프
relationship2.logFriends() 안의 forEach 문에서는 화살표 함수를 사용했다.
따라서 바같 스코프인 logFriends()의 this를 그대로 사용할 수 있다. 상위 스코프의 this를 그대로 물려받는 것이다.
📍 즉 기본적으로 화살표 함수를 쓰되, this를 사용해야 하는 경우에는 화살표 함수와 함수 선언문(function) 둘 중 하나를 고르면 된다.
구조 분해 할당을 이용하면 객체와 배열로부터 속성이나 요소를 쉽게 꺼낼 수 있다.
🔽 다음은 객체의 속성을 같은 이름의 변수에 대입하는 코드이다.
var candyMachine = {
status: {
name: 'node',
count: 5,
},
getCandy: function () {
this.status.count--;
return this.status.count;
},
};
var getCandy = candyMachine.getCandy;
var count = candyMachine.status.count;
🔽 이 코드를 다음과 같이 바꿀 수 있다.
const candyMachine = {
status: {
name: 'node',
count: 5,
},
getCandy() {
this.status.count--;
return this.status.count;
},
};
const {getCandy, status : { count } } = candyMahince;
candyMachine 객체 안의 속성을 찾아서 변수와 매칭한다. count처럼 여러 단계 안의 속성도 찾을 수 있다. getCandy와 count 변수가 초기화된 것이다. 다만, 구조 분해 할당을 사용하면 함수의 this가 달라질 수 있다. 달라진 this를 원래대로 바꿔주려면 bind 함수를 따로 사용해야 한다.
🔽 배열에 대한 구조 분해 할당 문법도 존재한다.
var array = ['nodejs', {}, 10, true];
var node = array[0];
var obj = array[1];
var bool = array[3];
🔽 다음과 같이 바꿀 수 있다.
const array = ['nodejs', {}, 10, true];
const [node, obj, , bool] array;
node, obj와 boo의 위치를 보면 node는 배열의 첫 번째 요소, obj는 두 번째 요소, bool은 네 번째 요소라는 것을 알 수 있다. obj와 bool 사이의 요소인 10에는 변수명을 지어주지 않았으므로 10은 무시한다.
클래스 문법도 추가되었다. 하지만 다른 언어처럼 클래스 기반으로 동작하는 것이 아니라 여전히 프로토타입 기반으로 동작한다. 프로토타입 기반 문법을 보기 좋게 클래스로 바꾼 것이라고 이해하면 된다.
🔽 다음은 프로토타입 상속 예제 코드이다.
var Human = function(type) {
this.type = type || 'human';
};
Human.isHuman = function(human) {
return human instanceof Human;
}
Human.prototype.breathe = function() {
alert('h-a-a-a-m');
};
var Zero = function(type, firstName, lastName) {
Human.apply(this, arguments);
this.firstName = firstName;
this.lastName = lastName;
};
Zero.prototype = Object.create(Human.prototype);
Zero.prototype.constructor = Zero; // 상속하는 부분
Zero.prototype.sayName = function() {
alert(this.firstName + ' ' +this.lastName);
};
var oldZero = new Zero('human', 'Zero', 'Cho');
Human.isHuman(oldZero); // true
Human 생성자 함수가 있고, 그 함수를 Zero 생성자 함수가 상속한다. Zero 생성자 함수를 보면 상속받기 위한 코드가 상당히 난해함을 알 수 있다. Human.apply와 Object.create 부분이 상속받는 부분이다.
🔽 위 코드를 클래스 기반 코드로 바꾸었다.
class Human {
constructor(type = 'human') {
this.type = type;
}
static isHuman(human) {
return human instanceof Human;
}
breathe() {
alert('h-a-a-a-m');
}
}
class Zero extends Human {
constructor(type, firstName, lastName) {
super(type);
this.firstName = firstName;
this.lastName = lastName;
}
sayName() {
super.breathe();
alert('${this.firstName} ${this.lastName}');
}
}
const newZero = new Zero('human', 'Zero', 'Cho');
Human.isHuman(newZero); // true
전반적으로 class 안으로 그룹화 된 것을 볼 수 있다. 생성자 함수는 constructor 안으로 들어갔고, Human.isHuman 같은 클래스 함수는 static 키워드로 전환되었다. 프로토타입 함수들도 모두 class 블록 안에 포함돼서 어떤 함수가 어떤 클래스 소속인지 확인하기 쉽다. 상속도 간다해져서 extends 키워드로쉽게 상속할 수 있다. 다만, 이렇게 클래스 문법으로 바뀌었더라도 자바스크립트는 프로토타입 기반으로 동작한다는 것을 명심해야 한다.
자바스크립트와 노드에서는 주로 비동기를 접한다. 특히 이벤트 리스너를 사용할 때 콜백 함수를 자주 사용한다. ES2015 부터는 자바스크립트와 노드의 API들이 콜백 대신 프로미스(Promise) 기반으로 재구성되며, 악명 높은 콜백 지옥(callback hell) 현상을 극복했다는 평가를 받고 있다.
🔽 프로미스는 다음과 같은 규칙이 있다. 먼저 프로미스 객체를 생성해야 한다.
const condition = true; // true이면 resolve, false이면 reject
const promise = new Promise((resolve, reject) => {
if (condtion) {
resolve('성공');
} else {
reject('실패');
}
});
// 다른 코드가 들어갈 수 있음
promise
.then((message) => {
console.log(message); // 성공(resolve)한 경우 실행
})
.catch((error) => {
console.error(error); // 실패(reject)한 경우 실행
})
.finally(() => { // 끝나고 무조건 실행
console.log('무조건');
});
new Promise로 프로미스를 생성할 수 있으며, 안에 resolve와 reject를 매개변수로 갖는 콜백 함수를 넣는다. 이렇게 만든 promise 변수에 then과 catch 메서드를 붙일 수 있다. 프로미스 내부에서 resolve가 호출되면 then이실행되고, reject가 호출되면 catch가 실행된다. finally 부분은 성공/실패 여부와 상관 없이 실행된다.
resolve와 reject에 넣어준 인수는 각각 then과 catch의 매개변수에서 받을 수 있다. 즉, resolve('성공')
이 호출되면 then의 message가 '성공'이 된다. 만약 reject('실패')
가 호출되면 catch의 error가 '실패'가 되는 것이다. condition 변수를 false로 바꿔보면 catch에서 에러가 로깅된다.
프로미스를 쉽게 설명하자면, 실행은 바로 하되 결과값은 나중에 받는 객체이다. 결과값은 실행이 완료된 후 then이나 catch 메서드를 통해 받는다. 위 예제엣는 new Promise와 promise.then 사이에 다른 코드가 들어갈 수도 있다. new Promise
는 바로 실행되지만, 결과값은 then을 붙였을 때 받게 된다.
then이나 catch에서 다시 다른 then이나 catch를 붙일 수 있다. 이전 then의 return 값을 다음 then의 매개변수로 넘긴다. 프로미스를 return한 경우 프로미스가 수행된 후 다음 then이나 catch가 호출된다.
promise
.then((message) => {
return new Promise((resolve, reject) => {
resolve(message);
});
})
.then((message2) => {
console.log(message2);
return new Promise((resolve, reject) => {
resolve(message2);
});
})
.then((message3) => {
console.log(message3);
})
.catch((error) => {
console.error(error);
});
처음 then에서 message를 resolve하면 다음 then에서 message2로 받을 수 있다. 여기서 다시 message2를 resolve한 것을 다음 then에서 message3으로 받았다. 단, then에서 new Promise를 return해야 다음 then에서 받을 수 있다. 이를 활용해서 콜백을 프로미스로 바꿀 수 있다.
🔽 다음은 콜백을 쓰는 패턴 중 하나이다.
function findAndSaveUser(Users) {
Users.findOne({}, (err, user) => { // 첫 번째 콜백
if (err) {
return console.error(err);
}
user.name = 'zero';
user.save((err) => { // 두 번째 콜백
if(err) {
return console.error(err);
}
Users.findOne({gender:'m'}, (err, user) => { // 세 번째 콜백
// 생략
});
});
});
}
콜백 함수가 세 번 중첩되어 있다. 콜백 함수가 나올 때마다 코드의 깊이가 깊어진다. 각 콜백 함수마다 에러도 따로 처리해줘야 한다.
🔽 이를 프로미스로 바꿔보자.
function findAndSaveUser(Users) {
Users.findOne({})
.then((user) => {
user.name = 'zero';
return user.save();
})
.then((user) => {
return Users.findOne({gender:'m'});
})
.then((user) => {
// 생략
})
.catch(err => {
console.error(err);
});
}
코드의 깊이가 세 단계 이상 깊어지지 않는다. 위 코드에서 then 메서드들은 순차적으로 실행된다. 콜백에서 매번 따로 처리해야 했던 에러도 마지막 catch에서 한 번에 처리할 수 있다. 하지만 모든 콜백 함수를 위와 같이 바꿀 수 있는 것은 아니다. 메서드가 프로미스 방식을 지원해야 한다.
위 코드는 findOne
과 save
메서드가 내부적으로 프로미스 객체를 가지고 있다고 가정했기에 가능하다.
프로미스 여러 개를 한 번에 실행할 수 있는 방법이 있다. (기존의 콜백 패턴이었다면 콜백을 여러 번 중첩해서 사용해야 했다.) Promise.all
을 활용하면 간단히 할 수 있다.
const promise1 = Promise.resolve('성공1');
const promise2 = Promise.resolve('성공2');
Promise.all([promise1, promise2])
.then((result) => {
console.log(result); // ['성공1', '성공2'];
})
.catch((error) => {
console.error(error);
});
Promise.resolve
는 즉시 resolve
하는 프로미스를 하는 방법이다. 비슷한 것으로 즉시 reject
하는 Promise.reject
도 있다. 프로미스가 여러 개 있을 때 Promise.all
에 넣으면 모두 resolve
될 때까지 기다렸다가 then
으로 넘어간다. result
매개변수에 각각의 프로미스 결괏값이 배열로 ㄷㄹ어 있다. Promise
중 하나라도 reject
가 되면 catch
로 넘어간다.
프로미스가 콜백 지옥을 해결했지만, then
와 catch
가 계속 반복도기 때문에 여전히 코드가 장황하다. async/await
문법은 프로미스를 사용한 코드를 한 번 더 깔끔하게 줄인다.
위 findAndSaveUser
코드를 async/await
문법을 사용하여 바꿔보자.
async function findAndSaveUser(Users) {
let user = await Users.findOne({});
user.name = 'zero';
user = await user.save();
user = await Users.findOne({gender:'m'});
// 생략
}
함수 선언부를 일반 함수 대신 async function
으로 교체한 후, 프로미스 앞에 await
을 붙였다. 이제 함수는 해당 프로미스가 resolve
될 때까지 기다린 뒤 다음 로직으로 넘어간다. 예를 들어 await Users.findOne({})
이 resolve
될 때까지 기다린 다음에 user
변수를 초기화한다.
위 코드는 에러를 처리하는 부분(프로미스가 reject
된 경우)이 없으므로 다음과 같은 추가 작업이 필요하다.
async function findAndSaveUser(Users) {
try {
let user = await Users.findOne({});
user.name = 'zero';
user = await user.save();
user = await Users.findOne({ gender: 'm' });
// 생략
} catch (error) {
console.error(error);
}
}
try/catch
문으로 로직을 감쌌다. 프로미스의 catch
메서드처럼 try/catch
문의 catch
가 에러를 처리한다.
화살표 함수도 async
와 같이 사용할 수 있다.
for
문과 async/await
을 같이 써서 프로미스를 순차적으로 실행할 수 있다.
const promise1 = Promise.resolve('성공1');
const promise2 = Promise.resolve('성공2');
(async () => {
for await (promise of [promise1, promise2]) {
console.log(promise);
}
})();
for await of
문을 사용해서 프로미스 배열을 순회하는 모습이다. async
함수의 반환값은 항상 Promise
로 감싸진다. 따라서 실행 후 then
을 붙이거나 또 다른 async
함수 안에서 await
을 붙여서 처리할 수 잇다.
async function findAndSaveUser(Users) {
// 생략
}
findAndSaveUser().then(() => { /* 생략 */ });
// 또는
async function other() {
const result = await findAndSaveUser();
}
앞으로 중첩되는 콜백 함수가 있다면 프로미스를 거쳐 async/await
문법으로 바꾸는 연습을 해보자. 코드가 훨씬 간결해진다.