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 소요
  • 응답속도가 꽤 많이 개선되었다. 아마도 이번 로직 개선은 사용자가 몰릴 때 더 효과를 보일 것 같다.