반복문에서의 비동기 처리

GeeU·2021년 12월 27일
0

이번 글은 반복문에서의 비동기 처리에 관한 이야기이다.

배경 설명

이번 글에서 이해를 돕기 위해 MongoDB에서 겪은 사례를 설명하겠다.
이상형 월드컵을 위해 이런 식으로 설계했었다.

설명을 하자면

  1. players 는 게임에서 출전할 선수? 같은 느낌이다.
    자신이 소속된 game의 _id를 gameId 라는 속성으로 보유하고 있다.
  2. 선수별로 이미지를 여러 개 업로드 할 수 있게 했었다.
    자신이 어느 player의 이미지인지 player의 _id 를 playerId 로 알 수 있다.

이게 과연 올바른 설계인지는 차치하고... 아무튼 이런 형태의 db 가 있다고 치고 얘기를 시작하겠다.

시행착오

첫 번째 시행착오

// 배열에서 하나를 랜덤으로 선택함.
const getRandomIndex = (arr) => Math.floor(Math.random() * arr.length);

// targetPlayers 에서 랜덤한 player 를 골라 길이가 round인 배열을 반환.
const selectRandomPlayers = (players = [], round = 0) => {
  let selectedPlayers = [];
  let temp = [...players];

  while (selectedPlayers.length < round) {
    selectedPlayers.push(...temp.splice(getRandomIndex(temp), 1));
  };

  return selectedPlayers;
};

// 특정 게임에서 사용될 이미지들을 불러옴
const getImages = async (ctx) => {
  const { params: { gameId, round } } = ctx;

  const targetPlayers = await Player.find({ gameId });
  const selectedPlayers = selectRandomPlayers(targetPlayers, round);
  
  // !이 부분 주목!
  const selectedImages = await selectedPlayers.map(async ({ _id }) => {
    const targetImages = await Image.find({ playerId: _id });
    const selectedImage = targetImages[getRandomIndex(targetImages)];

    return selectedImage;
  });

  ctx.body = selectedImages;
};

결과부터 말하자면 위 코드는 의도대로 동작하진 않는다.
요청에 대한 반환으로 Promise 의 배열을 냅다 던지게 되고 오류를 뱉는다.

!이 부분 주목! 을 보면, map 안에서 비동기 처리를 하기 위해 async 로 callback 을 감싼걸 볼 수 있다. 일단 await 를 앞에다 갖다 붙히기만 하면 비동기 처리가 되어 selectedImages 는 이미지의 배열이 되겠지~ 싶었다. 그러나...

async Function 의 return 은 Promise 다!

return selectedImage 를 하고 있지만, 결국 async 로 감싸져 있는 callback 함수는 Promise 를 리턴하고, 그 Promise 가 모여 Promise 의 배열이 생길 뿐이다.

mozilla 에서 그 설명을 볼 수 있다.

암시적으로 Promise 를 반환하는 거였구나... 를 깨닫고 그럼 어떻게 해야하는가를 고민,

Promise 를 반환하는게 문제인가?
아니 그렇다고 비동기를 위해 map 이나 forEach 를 피하는 것도 아닌거 같은데...
일단 Promise 를 반환하는 걸 회피하는 식으로 해볼까?

같은 생각을 하며 리서칭을 했고, 찾은게 for await ... of? for ... of?

두 번째 시행착오

const getImages = async (ctx) => {

  ...
  
  let selectedImages = [];
  
  for await (const { _id } of selectedPlayers) {
    const targetImages = await Image.find({ playerId: _id });
    const seletedImage = targetImages[getRandomIndex(targetImages)];

    selectedImages.push(seletedImage);
  };
  
  ctx.body = selectedImages;
};

요로코롬 쓰면 잘 굴러간다! 예시의 경우엔 순서가 크게 상관없지만 과거에 순서 보장도 필요했던 케이스가 있었기에 우선 만족했다! 만...
문제는 너ㅓㅓㅓㅓㅓ무 느리다.. 요구하는 이미지의 갯수가 엄청나지거나 하는 경우에도 모든 await 를 기다려줘야 하는가? 라는 생각이 문득 들었다.

추가로 이건 궁금한 점인데, for await ... of 나 for ... of 나 둘 다 잘 되고, 둘 다 거론되던데 딱히 둘의 차이점이나 비교를 하는 글은 본 적이 없는 것 같다. 혹시 누군가 아시는 분 계시다면 댓글 부탁드리겠습니다.

아무튼 돌아와서 리서칭할때 같이 거론되던 Promise.all 을 사용해봤다.

해결!

const getImages = async (ctx) => {

  ...
  
  const selectedImages = await Promise.all(selectedPlayers.map(async ({ _id }) => {
    const targetImages = await Image.find({ playerId: _id });
    const selectedImage = targetImages[getRandomIndex(targetImages)];

    return selectedImage;
  }));
  
  ctx.body = selectedImages;
};

그냥 첫 번째 시행착오 때 쓴 것을 Promise.all 로 뒤덮으면 끝이다.
Promise.all 은 Promise 의 배열을 받아 새로운 Promise 를 반환하는데(정확히는 요소가 Promise인 순회 가능한 객체이지만 편의 상 Promise 의 배열이라고 하겠다),
새로 반환한 Promise 의 결과는 처음에 넘긴 Promise 의 배열을 모두 이행한 후, 그 결과를 담은 배열이다.

일단 속도는 빠르다! 새로운 고민이라면 위에서도 말했듯 순서 보장까지 필요했기 때문이다.
급해서 그랬는지 Promise.all 은 순서가 보장되지 않는다는 말을 대충 봐서 오해가 생겼다.
어떤 오해였나... 하면 Promise.all 에서 헷갈렸던건 이런 것이였다.

길이가 3 인 Promise 의 배열 promises 와 Promise.all 의 결과인 results 가 있다고 치자.
예를 들어, 이행하는데 걸리는 시간이
promises[0]은 2초, promises[1]은 3초, promises[2]은 1초라면, 순서대로
results[0] 에 promises[2] 의 실행 결과,
results[1] 에 promises[0] 의 실행 결과,
results[2] 에 promises[1] 의 실행 결과 가 들어가는 줄 알았다.

허나 그게 아니라,
results[0] 에 promises[0] 의 실행 결과,
results[1] 에 promises[1] 의 실행 결과,
results[2] 에 promises[2] 의 실행 결과 가 제대로 들어간다!

내가 원한 순서 보장은 실행 순서에 대한 순서 보장이 아닌 후자의 순서 보장이였기에 Promise.all 이 가장 적합한 해결 방안이였다.

단지 병렬로 실행하다보니 내부에서 로그를 찍는다거나 하면 매번 다를 수도 있다.

이렇게!

이 부분을 모르고 순서 보장이 안 된다는 말에 느려도 for ... of 를 써야하나..? 다른 방법 없나..? 고민하다 여러 번의 console.log 실험과 공부를 통해 Promise.all 이여도 문제 없다는 걸 알고 해결했다.

여담으로 Promise.all 을 사용할 때, forEach 에 사용하고 싶다면 forEach 만 map 으로 바꿔주면 된다.

const getImages = async (ctx) => {

  ...
  
  let selectedImages = [];
  
  // forEach -> map 으로 바꾸면 해결!
  await Promise.all(selectedPlayers.forEach(async ({ _id }, i) => {
    const targetImages = await Image.find({ playerId: _id });
    const selectedImage = targetImages[getRandomIndex(targetImages)];
    
    // 대신 이러면 push 순서는 보장되지 않는다!
    // selectedImages.push(selectedImage);
    
    // 요렇게하면 해결!
    selectedImages[i] = selectedImage;
  }));
  
  ctx.body = selectedImages;
};

forEach 는 딱히 무언갈 반환하지 않으므로, Promise.all(undefined) 가 되어
undefined is not iterable 오류를 뱉는다.
그러므로 forEach 를 map 으로 바꿔서 Promise 를 반환하게 해주면 해결!

좋았던 점

이 때를 기점으로 점점 Promise 나 async/await 에 대한 눈이 뜨이기 시작한 것 같다.
async 를 쓰면 Promise 로 포장... await 는 그걸 풀어주는 느낌... 인건가..? 라던가.
forEach 나 map 이 이전 loop 의 실행 완료를 딱히 기다려주지 않는 점이라던가..?
async/await 가 어디까지 기다려주고 어디부터 기다려주지 않는다거나.
이 전까지 모호했던 개념들이 점점 퍼즐처럼 맞춰지는 기분이 들었다.

아쉬웠던 점 등등...

앞으론 리서칭도 좀 더 꼼꼼하게. 보면 직접 쳐보고. 궁금한건 여러 방면으로 더 실험해보고.
그래야겠다. 결국 이번에도 고민하고 앓던 이유도 급해서 대충 찾아봐서 그런 것도 있으니 말이다.

그리고 Promise 에 대해 좀 더 공부할 필요성을 느꼈고, 개인적으론 async/await 가 어떻게 구현되어 있는가도 궁금해졌다.

profile
개발자라 불러도 될진 모르겠지만 아무튼 응애 개발자

0개의 댓글