반복문에서 비동기 처리하기 (for, map, async, await)

Posted by , May 23, 2023
Typescript
Series ofJS_TS

무더운 여름(?)엔 스파클링 와인과 개발을...

반복문에서 비동기를...

최근 개발 일정 및 개인 사정으로 인해 포스팅을 전혀 하지 못했다.
네이버 블로그는 일상이나 가벼운 소재라서 막 쓸수 있지만 개발 블로그는...

아무렇게나 막 싸지르는 공간도 아니고, 다른 예제 복붙하는거는 정말 싫어해서...
그래서 더 미루지 않았나 싶다. ㅎㅎ

흑우집합소 패치 내역이나 개발한 걸 TIL 쪽에 올리려다가 이제는 따로 카테고리를 빼야 할 듯 싶다.
이것도 많이 밀렸는데 언제 적는지...

무튼...
평화롭게(?) 개발을 하다가 반복문을 사용하면서 비동기 처리하다가 조금 막힌 부분이 있었다.
이번 포스팅에서는 그 문제, 그리고 조사, 해결방법을 공유하고자 포스팅을 남겨본다.

반복문에서 비동기를 쓸 때

일반적으로 반복문 내에서 비동기 처리를 하는 경우는 드물게 있다.
예를 들어 특정 파라메터를 가지는 url의 데이터를 크롤링하거나,
데이터베이스에서 순차적으로 뽑아오는등... 몇 가지 케이스가 있다.

근데 while이나 for 등과 같은 고전파 방식인 반복 순회에서는 비동기가 잘 동작한다.
하지만 **고차함수(高次函數)**의 map, filter, foreach등에서는 이 비동기가 생각한 대로 흘러가지 않는다.

왜 그런걸까?


간단한 예제부터 시작

그냥 급조한 예제 코드다...
와인의 재고를 찾는 코드가 있다고 해보자.
(여담인데 떼땅져... 6만원 밖에 안하는데 정말 맛있다.)

그리고 아래 코드는 타입스크립트 플레이그라운드에서 동작하는 코드다.
만약 직접 typescript 코드를 작성한다면 일부 코드에 await키워드를 붙여야 한다.

async function Run() {
  //와인 수량 저장 스토어
  const wineStore: { [key: string]: number } = {
    taittinger: 10, //떼땅져는 10개
    piper_heidsieck: 12, //파이퍼 하이직은 12개
    andre_clouet: 21, //앙드레 끌루에는 21개
    opus_one: 5, //오퍼스원은 5개
    pounamu: 31, //푸나무는 31개
  }

  //찾는 시간
  const findTime = (ms: number) => {
    return new Promise(resolve => setTimeout(resolve, ms))
  }

  //와인 검사
  const checkWineCnt = async (wineName: string): Promise<number> => {
    const ms = Math.floor(Math.random() * (3 - 1 + 1) + 1) * 1000
    console.log("find start... during ms = ", ms)
    const cnt = await findTime(ms).then(v => wineStore[wineName])
    console.log("[" + wineName + "] count = " + cnt)
    console.log("find end... ")
    return cnt
  }

  checkWineCnt("opus_one")
}

Run()

findTime 함수는 1~3초 랜덤으로 찾는 시간을 밀리세컨으로 생성한다.
checkWineCnt 함수는 전달받은 와인 이름으로 몇개가 있는지 조회를 한다.

현실은 그냥 와인셀러 열고 꺼내면 되지만 -_-;;
뭐 그냥 샘플 예제니까...넘 억까라 해도 양해 바란다.

무튼 이렇게 반복문을 안쓰는 예제에서 시작해보겠다.
만약 스토어에 있는 모든 와인을 조회해보고 싶다면 아래처럼 하단에 코드를 변경할 수 있다.

async function Run() {
  const wineList: string[] = Object.keys(wineStore)

  const runFunc = async () => {
    for (let i = 0; i < wineList.length; i++) {
      await checkWineCnt(wineList[i])
    }
  }

  searchAllFunc()
}

Run()

이렇게 할 경우 우리가 생각한 대로 동작을 한다.


ForEach

근데 for를 사용하면 우리가 생각한 대로 잘 동작하지만, 고차함수를 통해서 작업을 하면 다르게 처리된다.
위와 같은 작업을 고차함수인 foreach로 바꾸면 아래와 같다.

async function Run() {
  const wineList: string[] = Object.keys(wineStore)

  wineList.forEach(async (item: string) => await checkWineCnt(item))
}

Run()

이렇게 하고 결과를 보면 아래와 같다.

전혀 비동기로 동작하지 않는다.
이유는 간단하다.

고차함수인 ForEach는 비동기를 기다리지 않는다.
좀 더 정확히 이야기 하면 ForEach는 Promise를 인지하지 못한다.


How to solve?

뭐 간단하다.

  1. For loop를 사용한다.
  2. For of를 사용한다.

1번은 맨 처음 예제에서 사용한 방식이고, 2번은 아래와 같다.

async function Run() {
  const forOfLoop = async () => {
    for (const [value] of wineList) {
      await checkWineCnt(value)
    }
  }
  forOfLoop()
}

Run()

뭐 좀 억지스럽긴 한 코드인데, 이해를 돕는 코드이니 넘어가자.
사실 제일 좋은 건 1번인 것 같다.

굳이 비동기 루프 코드를 따로 작성하지 않고, 그냥 그 자리에서 For를 시전하면 되니까?
취향껏 쓰자.


Map

이번엔 와인의 이름과 수량을 문자열로 합쳐서 만드는 것을 해야 한다고 가정하자.
결과물을 배열이고, 안에는 와인명-수량 이라는 형태로 간다고 하자.

예를 들면 [taittinger-10, "piper_heidsieck-12"...] 이렇게 말이다.

그럼 고차함수인 Map을 활용해서 변환할 수 있다.
아래의 코드를 참고하자.

async function Run() {
  const wineList: string[] = Object.keys(wineStore)

  const result = wineList.map(async (item: string, idx: number) => {
    const cnt = await checkWineCnt(wineList[idx])
    return item + "-" + cnt
  })

  console.log("result = ", result)
}

Run()

우리가 원하는 결과는 아마 checkWineCnt함수를 수행하면서 find start end 맨트가 각각 뜨고,
그 다음 마지막으로 result의 콘솔 결과가 출력되길 원할 것이다.

하지만 결과는...


그리고 중간에 로그를 보면 다음과 같이 되어 있는 것을 확인할 수 있다.

"result = ",  [Promise: {}, Promise: {}, Promise: {}, Promise: {}]

그렇다.
전부 Promise형태로 된 배열이 있다.
Map도 비동기를 처리하지 못한다는 뜻이다.

물론 얘는 Promise가 왔으니 해결할 방법은 있다.


How to solve?

async function Run() {
  const wineStore: { [key: string]: number } = {
    taittinger: 10,
    piper_heidsieck: 12,
    andre_clouet: 21,
    opus_one: 5,
  }

  const findTime = (ms: number) => {
    return new Promise(resolve => setTimeout(resolve, ms))
  }

  const checkWineCnt = async (wineName: string): Promise<number> => {
    const ms = Math.floor(Math.random() * (3 - 1 + 1) + 1) * 1000
    console.log("find start... during ms = ", ms)
    const cnt = await findTime(ms).then(v => wineStore[wineName])
    console.log("[" + wineName + "] count = " + cnt)
    console.log("find end... ")
    return cnt
  }

  const wineList: string[] = Object.keys(wineStore)

  const mapResult = wineList.map(async (item: string, idx: number) => {
    const cnt = await checkWineCnt(wineList[idx])
    return item + "-" + cnt
  })

  const callPromiseAll = async () => {
    const data = await Promise.all(mapResult)
    console.log("data = ", data)
    return data
  }

  callPromiseAll()
}

Run()

위 코드를 수행하면 아래처럼 확인할 수 있다.

보면 checkWineCnt함수의 실행은 비동기로 동작하지 않지만, 결과는 제대로 나온다.


Filter

이번엔 수량이 20개 이상인 와인만 가져오고 싶다고 가정해보자.

async function Run() {
  const wineStore: { [key: string]: number } = {
    taittinger: 10,
    piper_heidsieck: 12,
    andre_clouet: 21,
    opus_one: 5,
  }

  const findTime = (ms: number) => {
    return new Promise(resolve => setTimeout(resolve, ms))
  }

  const checkWineCnt = async (wineName: string): Promise<number> => {
    const ms = Math.floor(Math.random() * (3 - 1 + 1) + 1) * 1000
    console.log("find start... during ms = ", ms)
    const cnt = await findTime(ms).then(v => wineStore[wineName])
    console.log("[" + wineName + "] count = " + cnt)
    console.log("find end... ")
    return cnt
  }

  const wineList: string[] = Object.keys(wineStore)

  const filterResult = wineList.filter(async (item: string, index: number) => {
    const cnt = await checkWineCnt(wineList[index])
    return cnt >= 20
  })

  console.log("filterResult = ", filterResult)
}
Run()

이렇게 하면 앙드레 끌루에만 배열로 반환되어야 한다.
근데 결과를 보면 전체 다 반환된다.

 "filterResult = ",  ["taittinger", "piper_heidsieck", "andre_clouet", "opus_one"]

이유는 이것도 Map과 비슷한데filter의 콜백은 Promise를 반환하고, 이는 언제나 true로 인정된다.
그래서 전체 다 반환되는 것이다.

얘는 해결하려면 약간 코드가 너저분해지는데 map을 사용해서 처리할 수 있다.


How to solve?

아래는 해결 코드다.

async function Run() {
  const wineStore: { [key: string]: number } = {
    taittinger: 10,
    piper_heidsieck: 12,
    andre_clouet: 21,
    opus_one: 5,
  }

  const findTime = (ms: number) => {
    return new Promise(resolve => setTimeout(resolve, ms))
  }

  const checkWineCnt = async (wineName: string): Promise<number> => {
    const ms = Math.floor(Math.random() * (3 - 1 + 1) + 1) * 1000
    console.log("find start... during ms = ", ms)
    const cnt = await findTime(ms).then(v => wineStore[wineName])
    console.log("[" + wineName + "] count = " + cnt)
    console.log("find end... ")
    return cnt
  }

  const wineList: string[] = Object.keys(wineStore)

  const promiseWineList = wineList.map(
    async (item: string, index: number) => await checkWineCnt(wineList[index])
  )
  const cntWineList = await Promise.all(promiseWineList) //[10, 12, 21, 5]

  const filterResult = wineList.filter((item: string, index: number) => {
    const cnt = cntWineList[index]
    return cnt >= 20
  })

  console.log("filterResult = ", filterResult)
}

Run()

그럼 아래와 같이 출력된다.

코드가 약간 지저분해지는데 처리는 된다.


reduce

이번엔 저 와인들의 수량을 모두 합치는 것을 해보려 한다고 가정하자.
뭐 그냥 for문 사용해서 할 수 있지만, 고차함수 중 reduce라는 것을 쓰면 더 편하게 할 수 있다.
아래는 와인 총합을 구하는 reduce를 사용한 코드다.

const sum0 = await wineList.reduce(async (sum: number, item: string) => {
  const cnt = await checkWineCnt(item)
  return (await sum) + cnt
}, 0)

근데 이렇게 작성하면 typescript 에러가 발생할 것이다.

thumbnail


뭔가 장황한 에러가 있는데 그냥 쉽게 생각하면 sum : number라는 것에 있다.
async 함수가 되는 순간 반환은 Promise값으로 온다.

저기 reduce 함수의 첫 인자는 반환 값인데 보면 쎙으로 number로 되어 있다.
또한 초기 값도 Promise가 아닌 0으로 되어 있다.
그래서 저 부분을 고치면...

const sum = await wineList.reduce(
  async (sum: Promise<number>, item: string) => {
    const cnt = await checkWineCnt(item)
    return (await sum) + cnt
  },
  Promise.resolve(0)
)

이렇게 바꾸면 이제 정상적으로 출력이 된다.

위 코드가 typescript라서 미연에 방지가 된거다.
만약 javascript를 사용하고 있었더라면 이상한 Promise랑 겹쳤을 것이다.

정리

지금까지 고차함수의 일부에서 비동기를 사용할 때 문제점과 해결방법을 알아봤다.
약간 억까 예제가 좀 있었지만... 문제의 본질과 해결 방법은 충분히 전달되었을거라 믿는다.

다시한번 정리하면 다음과 같다.

  1. foreach는 비동기를 할 수 없기에 다른 반복문을 사용하자.
  2. map은 Promise 배열을 반환하기에 **Promise.all()**을 사용하여 처리하자.
  3. filter는 map 등으로 처리 후 Promise.all()로 값을 변환한 후 사용하자.
  4. reduce는 typescript 사용으로 미연에 방지할 수 있으며, 반환 값의 Promise를 잘 사용하자.

이정도로만 알고 있으면 잘 쓸수 있다.
근데 모르면 그냥 GPT한테 예제를 보여달라고 하자.
그렇다고 운영코드를 올리진 말고...