본문 바로가기

프로그래밍 언어/Javascript

자바스크립트의 비동기 처리 (배열에서의 비동기 처리) - 2

 

앞선 포스팅에서 비동기 처리의 기본적인 부분을 살펴보았다. 이번에는 그것보다는 까다로운 케이스, 배열에서의 비동기처리를 알아보겠다. 쿼리의 결과로 리스트를 받으면 그것에 대한 추가 가공을 해야 하는 경우가 생기는데 그때 다시 필연적으로 배열에서의 비동기 처리가 필요하다. 이번에도 예제를 통해 작동 방식을 알아보자

b = [1, 3, 4]
const f = async arr => {
    return arr.map(e => e + 3)
}
console.log(f(b))

일단 위와 같이, 간단하게 1차원 배열 b부터 시작하자. f라는 함수에 배열을 넣으면 각 원소에 3을 더해서 리턴해준다.

근데 콘솔에 찍히는 건 프로미스가 붙어있다. 이제 이건 예상할 수 있어야 한다. async함수이기에 리턴에는 프로미스가 붙는다.

b = [1, 3, 4]
const f = async arr => {
    return await arr.map(e => e + 3)
}
console.log(f(b))

그럼 이번엔 위처럼 이렇게 써보자. 이렇게 했더니 결과가 더 이상해졌다. 이제 이전 포스팅에 이어 더욱 확실한 건 async비동기 함수의 리턴은 반드시 프로미스가 붙는다는 점이다. 비동기를 동기화하려면 async함수 안에서 결과를 봐야 하는 것이다. 그러면 비동기 함수 안에서 배열 동기화를 테스트해보기 위해 좀 더 복잡하게 2차원 배열을 만들어보자.

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    const res = arr.map(elem => lowerHandler(elem))
    console.log('upperRes', res)
    return res
}
upperHandler(a)

a라는 2차원 배열을 만들고, 그 안에 원소로 정수와 문자도 넣었다. 

lowerHandler는 1차원 배열을 받아 각 원소에 5를 더해주고,

upperHandler는 2차원 배열을 받아, 각 원소(1차원 배열)를 lowerHandler에 넘겨주는 역할을 한다.

여기서의 목표는 lowerRes를 쭈욱 다 찍고 마지막에 upperRes를 찍는 것이다.

위와 같이, 실행하면 다음과 같은 결과가 나온다.

upperRes [ Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> } ]
lowerRes [ 0, 7, 6 ]
lowerRes [ 8, 10 ]
lowerRes [ 2, 11, 'a5' ]

위를 분석해 보면, 일단 upperHandler가 실행되고 lowerHandler에서 결과를 받아와야 하는데, upperHandler내의 콘솔이 먼저 찍혔다! 물론 lowerHandler처리전이기 때문에 프로미스이고 내부는 심지어 pending이다. 그리고 사실상 upperHandler는 리턴까지 했는데, lowerHandler의 처리가 나중에 이루어져서 결과값이 콘솔에 찍힌 모습이다.

 

그렇다면 upperHandler내의 arr.map을 await하면 되겠구나! 라는 생각을 해볼 수 있다.

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    const res = await arr.map(elem => lowerHandler(elem))
    console.log('upperRes', res)
    return res
}
upperHandler(a)

그래서 위와 같이 쓰고나면, 아래와 같은 결과를 얻는다...

lowerRes [ 0, 7, 6 ]
lowerRes [ 8, 10 ]
lowerRes [ 2, 11, 'a5' ]
upperRes [ Promise { [ 0, 7, 6 ] },
  Promise { [ 8, 10 ] },
  Promise { [ 2, 11, 'a5' ] } ]

여전히 만족스럽지 않다. 일단 lowerRes가 먼저 찍히긴 한다. 하지만 upperRes의 결과 내에는 여전히 프로미스가 있다. 이것은 arr.map의 작업은 기다렸지만 그 내부의 연산을 일일이 대기해주진 않았기 때문이다. 말이 이상할 수 있다; 내부의 콜백 결과는 일단 가져와서 담고(여기서 프로미스가 벗겨지지 않음), 그 담아오는 과정만 대기해준 것이다.

그럼 아래처럼 써보자. 각 1차원 배열의 결과를 온전히 받기 위해 async elem => await lowerHandler(elem)으로 변경했다.

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    const res = await arr.map(async elem => await lowerHandler(elem))
    console.log('upperRes', res)
    return res
}
upperHandler(a)

하지만 위처럼 하면, 아래와 같이 결과가 더 이상하다.

lowerRes [ 0, 7, 6 ]
lowerRes [ 8, 10 ]
lowerRes [ 2, 11, 'a5' ]
upperRes [ Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> } ]

여기서 중요한 점! arr.map처럼 자체 병렬 작업이 있는 함수의 경우는 await로 비동기를 동기화하지 않는다는 것이다!

그냥 단순 await로는 전체 작업을 대기해주지 않은 것이다.

병렬 작업은 각 작업을 대기하기 위해 Promise.all이란 것을 활용해야 한다! 이제 아래처럼 써보자. lowerHandler는 사실상 비동기를 동기화한 결과를 내려주므로 async-await할 필요없이 바로 받아도 된다. 그래서 다시 수정했다. (elem=> lowerHandler(elem))

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    const res = Promise.all(arr.map(elem => lowerHandler(elem)))
    console.log('upperRes', res)
    return res
}
upperHandler(a)

이렇게 하면 결과에 upperRes가 먼저 찍히고, pending이 들어있게 된다. 그렇다. Promise.all도 결국 프로미스 리턴이다. 그러므로 await를 해주어야 하는 것이다!

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    const res = await Promise.all(arr.map(elem => lowerHandler(elem)))
    console.log('upperRes', res)
    return res
}
upperHandler(a)

위와 같이 하고 실행하면 드디어 올바른 결과를 얻을 수 있다.

lowerRes [ 0, 7, 6 ]
lowerRes [ 8, 10 ]
lowerRes [ 2, 11, 'a5' ]
upperRes [ [ 0, 7, 6 ], [ 8, 10 ], [ 2, 11, 'a5' ] ]

이제 Promise.all을 쓰지 않고 같은 결과를 얻는 방법을 생각해보자. 지금이야 배열에 단순한 연산을 가하기에 잘 보이진 않지만, 사실 map과 for-loop사이에는 중요한 차이점이 있다. 아래 코드는 map을 쓰지 않고 루프를 썼다.

a = [[-5, 2, 1], [3, 5], [-3, 6, 'a']]
const lowerHandler = async arr => {
    const res = await arr.map(e => e + 5)
    console.log('lowerRes', res)
    return res
}
const upperHandler = async arr => {
    for (let i = 0; i < arr.length; ++i)arr[i] = await lowerHandler(arr[i])
    console.log('upperRes', arr)
}
upperHandler(a)

위처럼 하면 보다 직관적일 수 있다. 2차원 배열이 들어왔고, 이에 대해 루프를 돌았으며, await를 통해 각 1차원 배열에 대한 lowerHandler의 처리 결과로 치환한 것이다. 

 

이제 for-loop와 map의 중요한 차이를 살펴보자. 이 차이를 보기 위해선 단순 연산이 아닌 실제 비동기 연산을 쓰는게 좋다. 그래야 제대로 이해할 수 있다. 

일단 데이터베이스에는 아래와 같은 데이터가 들어있다.

mysql> select id,title from article;
+----+-----------+
| id | title     |
+----+-----------+
|  3 | article 1 |
|  4 | article 2 |
|  5 | article 5 |
|  6 | article 6 |
+----+-----------+
const f1 = async () => {
    const p = await db.getPool()
    return new Promise((resolve, reject) => {
        p.getConnection((err, conn) => {
            return resolve(conn)
        })
    })
}
const f2 = async pConn => await pConn

const query = async (conn, id) => {
    const sql = `SELECT id, title FROM article where id='${id}';`
    console.log(id)
    return new Promise((resolve, reject) => {
        conn.query(sql, (err, rows, fields) => {
            if (err) return reject(err)
            console.log(rows)
            return resolve(rows)
        })
    })
}

const f = async _ => {
    const conn = await f2(await f1())
    const b = [3, 4, 5, 6]
    await Promise.all(b.map(e => query(conn, e)))
}
f()

위와 같이, id가 3, 4, 5, 6인 데이터가 들어있고, 이를 하나씩 접근하려고 한다. query함수는 인자로 들어온 id를 출력하고 실제 쿼리를 수행하여 결과를 콘솔에 찍는다. 그리고 함수 f에서는 article id가 담긴 배열[3, 4, 5, 6]을 map으로 돌면서 query함수를 각각 실행한다.

그러면 

3이 먼저 찍히고,

3에 상응하는 쿼리 결과

4가 다음에 찍히고,

4에 상응하는 쿼리 결과가 찍히는 식으로 진행되어야 할 것 같다.

하지만 결과는 다음과 같다.

this instance creator must be called only once
[dbPoolCreator]: dbPool created
3
4
5
6
[ RowDataPacket { id: 3, title: 'article 1' } ]
[ RowDataPacket { id: 4, title: 'article 2' } ]
[ RowDataPacket { id: 5, title: 'article 5' } ]
[ RowDataPacket { id: 6, title: 'article 6' } ]

이 부분이 바로 map과 for-loop의 큰 차이점인데, 일단 map은 병렬적으로 수행된다. 즉 하나의 원소에 대한 처리가 끝날 때까지 다음 원소가 대기하지 않는다. 한 원소가 출발하면 그 원소의 처리 결과 여부와 상관없이 바로 다음 원소가 출발한다. 그래서 쿼리 결과가 나오기 전에 3, 4, 5, 6의 4개 원소가 모두 query함수로 진입하여 첫번째 콘솔을 찍은 상태가 되었고, 뒤이어 비동기 처리가 하나씩 완료되면서 쿼리 결과가 찍혔다.

그렇다면 이번에는 for-loop를 살펴보자.

const f = async _ => {
    const conn = await f2(await f1())
    const b = [3, 4, 5, 6]
    // await Promise.all(b.map(e => query(conn, e)))
    for (const e of b) await query(conn, e)
}
f()

바뀐 부분만 추가하였다. for(const e of b)를 통해 루프로 각 원소를 처리한다. 이렇게 하면 아래와 같이 결과가 나온다.

this instance creator must be called only once
[dbPoolCreator]: dbPool created
3
[ RowDataPacket { id: 3, title: 'article 1' } ]
4
[ RowDataPacket { id: 4, title: 'article 2' } ]
5
[ RowDataPacket { id: 5, title: 'article 5' } ]
6
[ RowDataPacket { id: 6, title: 'article 6' } ]

for-loop는 하나의 처리가 끝나야만 다음 처리로 가기 때문에 위와 같이 결과가 찍히게 된다.

 

여기서 잘 생각해본다면 transaction처리인지 아닌지에 따라 어떤 것을 사용하면 좋은지에 대한 답도 나오게 된다. 데이터베이스에서 선처리가 발생하고 처리결과에 의존하여 다른 처리가 일어나야한다면 병렬적이 아닌 동기적인 처리가 일어나도록 코드를 짜야할 것이고, 각 쿼리들이 서로 독립적이라면 map등을 통해 순서에 구애받지 않고 처리를 해도 될 것이다.

 

한편 위에 for-loop에서도 await를 빼면 map과 같은 결과를 낼 수 있다. 하지만 반대로 아래와 같이 썼다고 해도 for-loop의 결과가 나오진 않는다. 궁금하신 분들은 한번 해보길 권장드린다.

const f = async _ => {
    const conn = await f2(await f1())
    const b = [3, 4, 5, 6]
    // 이렇게까지 써도 for-loop처럼 결과가 나오진 않는다. query함수 내의 conn.query가
    // 콜백형태로 써야하는 함수이기 때문이다.
    await Promise.all(b.map(async e => await query(conn, e)))
}
f()

 

이처럼 비동기로 배열을 다루는 것까지 살펴보았다. 배열 원소에 대한 순차적인 작업이 중요하지 않다면 내부를 비동기로 단순하게 처리하고 마지막에 await Promise.all(그 배열)로 마무리하는게 간편하긴 하다. 하지만 그것보다도 각 원리를 확실히 이해하는 게 더 중요하며, 그래야만 어떠한 상황과 어떠한 경우에라도 응용할 수 있게 된다.