JavaScript & Node.js
[Node.js 비동기 답게 쓰기] await => Promise.allSettled
Haksae
2022. 4. 17. 06:05
1. Node.js 싱글 스레드? 논 블로킹? : async/await의 함정
1) Node.js는 사실 싱글 스레드 방식으로 일하지 않는다.
- 모두 잘 알듯이, Node.js는 싱글 스레드, 비동기식 이벤트 기반, 논 블로킹 I/O 라는 특징을 가지고 있다.
- 싱글 스레드임에도 Node.js가 빠르게 작업을 처리할 수 있는 이유는, 바로 논 블로킹 I/O이기 때문이다.
- 논 블로킹이란 이전 작업이 완료될 때까지 기다리지 않고 다음 작업을 수행하는 것을 의미한다.
- Node.js가 작업을 처리 안하고 가버리면 그러면 누가 그 작업을 처리하는가? 백그라운드(libuv)가 한다.
- 비동기적으로 처리할 수 있는 이벤트를 백그라운드에서 처리해주기 때문에, Node.js가 싱글 스레드임에도 빠른 작업 속도를 낼 수 있는 것이다. (사실 이런 배경을 알면 Node.js가 사실은 싱글 스레드 방식으로 일하지 않는다는 것을 알게된다.)
2) async, await의 함정
- 그러나 코드를 짜다보면 우리는 모든 작업을 비동기적으로 처리할 수 없음을 깨닫는다.
- 순서가 중요한 로직을 짤 때면 논 블로킹으로 처리할 수 있는 이벤트임에도 불구하고, 블로킹 처리를 하여 동기적으로 작업을 해야하는 상황이 펼쳐진다.
- 이전에는 이 때문에 소위 콜백 지옥과 프로미스 지옥이 만들어졌고, 그나마 ES8부터 도입된 async/await로 인해 개선되었다.
- 그리고 이러한 개선은 더 이상 콜백과 프로미스 지옥에 갇힐 염려를 하지 않고, 자연스럽게? await를 쓰게 만드는 것 같다.
- 바로 함정이 여기에 있다. 인간의 뇌는 동기적이기 때문에, 어느순간 자바스크립트로 코드를 짜면서 await를 남발한 탓에, Node.js를 블로킹 I/O로 사용하고 있는 것이다...!
2. async / await의 함정 예시
- 아래의 코드는 필자가 실전 프로젝트에서 짰던 코드 중 하나다.
- 대기실에서 유저가 퇴장할 시, Room 컬렉션에서 해당 대기실 방 도큐멘트를 찾아 유저의 롤에 해당하는 정보를 업데이트 해줘야하는 로직이었다.
- 결과나 순서가 중요한 로직이 아니기에, 동기적으로 로직을 짤 상황이 아닌데도, 너무 익숙하게도 await를 남발하며 동기적으로 코드를 작성하였다.
if (!gameInfo) {
const roomInfo = await Rooms.findOne(
{ roomNum },
{
_id: 0,
blackTeamPlayer: 1,
whiteTeamPlayer: 1,
blackTeamObserver: 1,
whiteTeamObserver: 1,
}
);
if (roomInfo.blackTeamPlayer === id) {
await Rooms.updateOne({ roomNum }, { $set: { blackTeamPlayer: null } });
}
if (roomInfo.whiteTeamPlayer === id) {
await Rooms.updateOne({ roomNum }, { $set: { whiteTeamPlayer: null } });
}
if (roomInfo.blackTeamObserver.includes(id)){
await Rooms.updateOne(
{ roomNum },
{ $pull: { blackTeamObserver: id } });
}
if (roomInfo.whiteTeamObserver.includes(id)){
await Rooms.updateOne(
{ roomNum },
{ $pull: { whiteTeamObserver: id } });
}
}
3. Refactor) Promise.allSettled
*Promise.allSettled
- Promise.allSettled() 메서드는 주어진 모든 프로미스를 이행하거나 거부한 후, 각 프로미스에 대한 결과를 나타내는 객체 배열을 반환합니다.
- 일반적으로 서로의 성공 여부에 관련 없는 여러 비동기 작업을 수행해야 하거나, 항상 각 프로미스의 실행 결과를 알고 싶을 때 사용합니다.
출처 : MDN
- 위에서 설명한대로 해당 로직은 성공 여부도 순서도 상관없는 로직이었다.
- 이에 필자는 위의 코드에 대하여 아래와 같이 Promise.allSettled를 사용하여 리팩토링을 하였다.
- 코드는 다소 길어졌지만, Node.js를 논 블로킹 답게 쓰기 위한 선택이었다.
if (!gameInfo) {
const isBlackPlayer = Rooms.findOne({ roomNum })
.then((result) => {
if (result.blackTeamPlayer === id)
return Rooms.updateOne(
{ roomNum },
{ $set: { blackTeamPlayer: null } }
);
})
.then((updateResult) => {
})
.catch((err) => {
console.log(err);
});
const isWhitePlayer = Rooms.findOne({ roomNum })
.then((result) => {
if (result.whiteTeamPlayer === id)
return Rooms.updateOne(
{ roomNum },
{ $set: { whiteTeamPlayer: null } }
);
})
.then((updateResult) => {
})
.catch((err) => {
console.log(err);
});
const isBlackObserver = Rooms.updateOne(
{ roomNum },
{ $pull: { blackTeamObserver: id } }
);
const isWhiteObserver = Rooms.updateOne(
{ roomNum },
{ $pull: { whiteTeamObserver: id } }
);
await Promise.allSettled([
isBlackPlayer,
isWhitePlayer,
isBlackObserver,
isWhiteObserver,
]).then((results) => {
});
}
};
4. 결과 : 성능 개선
- 성능은 어떻게 되었을까?
리팩토링 전 : 퇴장시 await로 처리하여 평균 20ms, 사용자 많을 시 최대 60ms 소요
리팩토링 후 : Promise.allSettled를 사용하여 비동기적으로 처리하는 것으로 로직 개선
=> 결과 : 평균 13ms, 사용자 많을 시 최대 35ms 소요됨
- 응답속도가 꽤 많이 개선되었다. 아마도 이번 로직 개선은 사용자가 몰릴 때 더 효과를 보일 것 같다.