비동기 처리를 위한 문법 (Promise)
Synchronous
자바스크립트 엔진은 기본적으로 동기(Synchronous)로 동작합니다.
console.log(1);
console.log(2);
console.log(3);
런타임 시 코드를 위에서부터 아래로 읽으며 실행하기 때문에, 우리는 [ 1 2 3 ]이라는 결과를 얻을 수 있습니다. 만약 비동기로 동작하기를 원한다 하면 중간에 setTimeout, setInterval과 같은 Host API의 도움을 받아야 합니다. 그렇게 되면 태스크 큐와 이벤트 루프에 의해 비동기로 동작하는 것을 확인할 수 있습니다. 태스크 큐와 이벤트 루프에 대해 익숙하지 않으시면, 이전 글인 자바스크립트에 동시성을 부여하는 이벤트 루프를 확인해 주세요!
Host API란 웹 브라우저 또는 Node 환경에서 추가적으로 제공하는 메서드입니다.
비동기 문법이 필요한 이유
비동기 코드를 작성할 때는 보통 매개변수로 콜백 함수를 전달하여 후속 처리를 구현합니다. 그리고 비동기 코드의 후속 처리로 작성한 콜백 함수가 처리 결과를 가지고 또다시 비동기 함수를 호출하는 경우도 있고요. 이런 과정이 계속 이어지면 자연스레 중첩이 깊어지게 되는데, 이를 콜백 지옥(Callback Hell)이라 합니다. 콜백 지옥에 빠지게 되면 코드의 복잡도가 높아지고, 가독성이 떨어져 유지보수가 어려워집니다.
다음 예제는 콜백 지옥에 빠진 코드입니다.
class UserStorage {
loginUser(id, password, onSuccess, onError) {
setTimeout(() => {
if (id === 'nohack' && password === '123123') {
onSuccess(id);
} else {
onError(new Error('not found'));
}
}, 2000);
}
getRoles(user, onSuccess, onError) {
setTimeout(() => {
if (user === 'nohack') {
onSuccess({ name: 'nohack', role: 'admin' });
} else {
onError(new Error('no access'));
}
}, 1000);
}
}
const userStorage = new UserStorage();
userStorage.loginUser(
'nohack',
'123123',
(userId) => {
userStorage.getRoles(
userId,
(userWithRole) => {
console.log(userWithRole);
},
(error) => console.error(error)
);
},
(error) => console.error(error)
);
딱 보기에도 코드가 복잡해 보이죠? 게다가 콜백 지옥이 발생하면, 에러를 핸들링하는 부분에서 어려움이 있기도 합니다. 일반적으로 에러가 발생하면 호출자(Caller)를 타고 올라가, catch 블록을 만나면서 에러를 잡습니다. 하지만 비동기 코드의 콜백으로 쓰인 함수는 콜 스택(Call Stack)이 비어있을 때, 비로소 스택에 등록되어 실행되기 때문에 상위 스택이 비어있게 됩니다.
따라서 아래 코드는 에러를 잡을 것 같지만 에러를 출력합니다.
try {
setTimeout(() => { throw new Error('Error'); }, 1000);
} catch (e) {
console.error('Catch Error', e);
}
이처럼 전통적인 콜백 패턴의 단점들을 극복하기 위해 ES6에서 프로미스(Promise)라는 문법을 도입했습니다.
프로미스 (Promise)
프로미스는 비동기 처리 시점을 명확하게 할 수 있으며 다음의 형태로 사용합니다.
const promise = new Promise((resolve, reject) => {
if (/* 조건 */) {
resolve('success');
} else {
reject('failure');
}
});
일반적으로 프로미스는 Promise 생성자 함수로 객체를 만들어 사용합니다. Promise 함수는 작업 성공 시 호출할 함수(Resolve)와 실패 시 호출할 함수(Reject), 이렇게 두 가지 콜백 함수를 매개변수로 받습니다. 한 생성자 내에서 resolve나 reject는 각각 최대 1번씩만 사용 가능합니다.
이렇게 생성자를 통해 프로미스 객체를 만들게 되면 객체의 첫 상태는 pending입니다. 그리고 이어서 비동기 코드가 실행되어 처리에 성공하면 fullfilled 상태, 실패하면 rejected 상태가 됩니다. 마지막으로 비동기 작업이 완전히 끝나면 settled 상태가 되면서, 프로미스의 상태가 더 이상 변화할 수 없음을 알립니다. 아래 그림은 프로미스 객체의 상태 프로세스를 나타낸 그림입니다.

프로미스 객체를 생성하게 되면 pending 상태를 거쳐, fullfill 또는 reject 상태를 갖은 채로 대기하게 됩니다. 프로미스는 이미 성공 또는 실패에 대한 결과가 정해져 있으므로, 이제는 개발자가 원하는 시점에 처리만 하면 됩니다. 결괏값을 확인할 때는 프로미스 객체의 then 또는 catch 메서드를 사용합니다.
const checkUser = (userId) =>
new Promise((resolve, reject) => {
if (userId === 'nohack') {
resolve('success');
} else {
reject('failure');
}
});
// checkUser는 프로미스를 반환한다.
// 반환된 프로미스는 pending -> fullfilled 상태이다.
// then 메서드에서 결괏값을 받아 출력한다.
checkUser('nohack')
.then((result) => console.log(result))
.catch((error) => console.error(error));
프로미스의 resolve와 reject는 그저 후속 처리를 위한 콜백 함수를 받는 매개변수이고, then과 catch는 반환된 프로미스의 상태를 보면서 처리하는 메서드입니다. 별로 어렵지 않죠? 이렇게 프로미스를 사용하면 비동기 처리와 관련된 코드를 알아보기 쉽습니다.
한 번 위에서 만들었던 UserStorage 코드에도 프로미스를 적용해 볼게요.
class UserStorage {
loginUser(id, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 'nohack' && password === '123123') {
resolve(id);
} else {
reject(new Error('not found'));
}
}, 2000);
});
}
getRoles(user) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (user === 'nohack') {
resolve({ name: 'nohack', role: 'admin' });
} else {
reject(new Error('no access'));
}
}, 1000);
});
}
}
// { name: 'nohack', role: 'admin' }
const userStorage = new UserStorage();
userStorage
.loginUser('nohack', '123123')
.then((id) => userStorage.getRoles(id))
.then((result) => console.log(result))
.catch((err) => console.error(err));
userStorage의 loginUser 메서드는 프로미스를 반환하고, 인자를 올바르게 보냈기 때문에 fullfilled 상태입니다. 그렇기 때문에 then 메서드에서 결과를 받았고, 그 결과를 userStorage.getRoles의 인자로 보내 호출하면서 다시 한 번 프로미스를 반환 받습니다. 이렇게 프로미스를 반환하는 함수를 연속으로 호출하며 처리하는 동작을 프로미스 체이닝(Promise Chaining)이라 합니다.
프로미스를 마치기 전에 한 가지 팁! 만약 결괏값이 하나라면 다음 코드처럼 then 또는 catch 메서드 안에 함수만 적어줘도 됩니다. 그러면 resolve나 reject에 의해 반환된 값이, 적혀있는 함수의 매개변수로 알아서 전달되어 실행됩니다.
userStorage
.loginUser('nohack', '123123')
.then(userStorage.getRoles)
.then(console.log)
.catch(console.error);
비동기 코드를 동기적으로 짜는 방법
프로미스만으로도 비동기를 처리하는 코드가 상당히 깔끔해진 것을 확인했습니다. 그런데 이보다 더 가독성 좋은 코드를 짜는 방법이 있는데, 바로 async와 await이라는 키워드를 사용하는 것입니다. 이 둘은 프로미스를 기반으로 하면서, 비동기 코드를 동기적으로 작성할 수 있게 도와주는 문법적 설탕(Syntatic Sugar)입니다.
문법적 설탕이란, 동일한 내부 동작을 더 간단하게 사용할 수 있도록 만들어진 문법입니다. 👏
사용 방법은 정말 간단한데요. 함수의 키워드인 function 앞에 async를 붙여주면 되고, 내부에서 비동기 처리의 결과를 받는 부분의 앞에 await을 붙여주기만 하면 됩니다. 이렇게 하면 비동기를 수행하는 코드지만, 동기적인 코드처럼 보이게 하여 가독성이 향상됩니다.
const foo = async () => {
const result = await new Promise((resolve) =>
setTimeout(() => resolve(12345), 1000)
);
// 12345
console.log(result);
};
foo();
await은 함수의 실행을 멈추고 프로미스의 상태가 fulfilled 또는 rejected가 되면 결괏값을 받습니다. 그렇기 때문에 바로 아래에서 출력해 결과를 볼 수 있었고, async 함수는 값을 반환하면 프로미스를 생성하며 반환하기 때문에 외부에서 then 메서드로 처리할 수도 있습니다. 그리고 async 함수 안에서 에러가 발생하면, 동기적으로 에러 핸들링을 하듯 내부에 try-catch 구문을 사용하면 됩니다.
마지막으로 UserStorage 코드를 한 번 더 수정하면서 마치겠습니다.
// Class 코드는 동일합니다.
const userStorage = new UserStorage();
try {
const userId = await userStorage.loginUser('nohack', '123123');
const userWithRole = await userStorage.getRoles(userId);
// { name: 'nohack', role: 'admin' }
console.log(userWithRole);
} catch (error) {
console.error(error);
}
동기 처리를 하듯 코드를 작성할 수 있기 때문에 프로미스보다 가독성도 좋아 코드를 이해하기 쉽습니다.
사용하기 쉽다 해도, 프로미스가 기반이므로 프로미스를 잘 익혀두도록 합시다! 😆
References
'🌈 기술스택 > JavaScript' 카테고리의 다른 글
Intersection Observer API로 무한 스크롤 구현하기 (0) | 2022.01.04 |
---|---|
마우스로 터치 스크롤 구현하기 (1) | 2022.01.04 |
자바스크립트에 동시성을 부여하는 이벤트 루프 (0) | 2021.10.07 |
연속으로 발생하는 이벤트를 제어하는 방법 (0) | 2021.10.07 |
코드 실행에 필요한 정보를 담은 실행 컨텍스트 (0) | 2021.10.05 |
댓글
이 글 공유하기
다른 글
-
Intersection Observer API로 무한 스크롤 구현하기
Intersection Observer API로 무한 스크롤 구현하기
2022.01.04 -
마우스로 터치 스크롤 구현하기
마우스로 터치 스크롤 구현하기
2022.01.04 -
자바스크립트에 동시성을 부여하는 이벤트 루프
자바스크립트에 동시성을 부여하는 이벤트 루프
2021.10.07 -
연속으로 발생하는 이벤트를 제어하는 방법
연속으로 발생하는 이벤트를 제어하는 방법
2021.10.07
댓글을 사용할 수 없습니다.