이번 글은 반복문에서의 비동기 처리에 관한 이야기이다.
이번 글에서 이해를 돕기 위해 MongoDB에서 겪은 사례를 설명하겠다.
이상형 월드컵을 위해 이런 식으로 설계했었다.
설명을 하자면
- players 는 게임에서 출전할 선수? 같은 느낌이다.
자신이 소속된 game의 _id를 gameId 라는 속성으로 보유하고 있다.- 선수별로 이미지를 여러 개 업로드 할 수 있게 했었다.
자신이 어느 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 는 이미지의 배열이 되겠지~ 싶었다. 그러나...
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 가 어떻게 구현되어 있는가도 궁금해졌다.