본문 바로가기

프로그래밍 언어/Javascript

자바스크립트의 비동기 처리 (async - await , promise , then ) - 1

비동기 관련한 글을 쓰겠다고 생각한지 정말 오래되었는데 이제야 쓰게 되었다. 부지런히 글을 쓰겠다고 계속 다짐하지만 실천이 참 힘들다.. 비동기 주제를 한 번에 다 정리할 순 없으니 계속 조금씩 추가 및 수정하도록 하겠다.

 

개발을 하다보면 데이터베이스를 다루게 되는데, 그때 필연적으로 비동기 처리와 만나게 된다. 이전 Node.js - MySQL 글에서 말했듯이, 보통 커넥션 풀 (pool)에서 커넥션을 가져와 처리 후에 반납하는 형태가 된다. 이 과정이 비동기이므로 callback(콜백)함수, async-await, promise, then 등을 활용하여야 한다.

그리고 동시에 let a=3, console.log('abc')이러한 명령들은 '동기 처리'라는 걸 알 수 있다.

 

일단 promise는 약속, 보증의 의미를 갖는데, 어떠한 비동기 처리 결과에 대한 약속이자 보증서 같은 느낌으로 이해하는 게 좋다. 단순하게 '시간이 걸리는 작업'으로 생각할 수도 있다. 코드의 내용이 순차적으로 진행되는 방식도 있지만, 자바스크립트의 특이한 사항은 1번 줄에 있다고 15번 줄에 있는 내용보다 먼저 처리된다는 보장이 없다. '시간이 걸리는 작업'이기에 그걸 계속 기다릴 순 없고, 다른 작업을 먼저 하는 것이다. 이 부분은 처음에는 불편할 수 있지만, 사고방식을 넓혀준다는 느낌으로 받아들이고 적응되면 또 나름의 노하우들이 쌓여서 다양한 기능을 구현할 수도 있다. 시간이 걸리는 작업을 내가 원하는 타이밍에 실행시켜줄 수 있기에 3차원의 코드 환경'시간'이란 (time dimension) 축이 추가된 느낌이다. 

하지만 어쨌든 이 '시간이 걸리는 작업'들이 확실하게 끝나는 것을 보아야 다른 작업을 이어서 할 수 있는 상황들이 많다. 그때가 바로 이 promise등의 비동기 처리가 필요한 곳이다.

 

Promise라는 객체가 있다. 이는 비동기 처리의 '완료 보증서' 또는 그 비동기 처리의 결과물 정도로 이해하면 좋다. 보통 비동기처리의 결과물을 어떤 변수에 담아 찍어보면 { <pending> }이라고 찍히게 된다. 

이 부분은 필자도 자세하게 알지 못해 확신할 순 없지만, 필자의 생각을 표현한다면 아래와 같다.

일단 pending이라는 표현에서 힌트를 얻을 수 있다. 왜 'processing'이라고 하지 않았을까?이다. 비동기처리라서 시간이 걸리기 때문에 '작업을 하고 있긴 한데 아직도 하고 있는 중'의 의미가 아니기 때문이다. 만약에 처리를 하고 있다면 'processing'이라고 했을 것이다. pending은 보통 작업 대기중일 때 쓰는 표현이다. 작업을 아직 하고 있진 않은데 작업이 곧 될 것 같은 상태일 때 주로 쓴다. 즉, 이 비동기처리는 시간이 걸려서 '아직 작업을 하고 있는' 상태가 아니라 '대기중'의 상태인 것이다. 필자가 이 포스팅을 하겠다고 마음을 먹었다면 pending이고, 글을 실제로 쓰고 있을 때는 processing인 것이다. 즉, promise객체는 아직 작업이 되고 있는 게 아니다! 

 

아래와 같이 코드를 썼다고 하자.

syncTaksk1()   // (동기 작업1)
asyncTask()    // (비동기 작업)
syncTask2()    // (동기 작업2)

그렇다면 실제 처리는 아래와 같다.

왼쪽에서 보듯이, 동기작업들이 연이어서 수행되고 비동기 작업은 대기상태가 된다. 이러한 트릭으로 마치 병행하여 동시에 수행되는 것처럼 보이는 것이다.

 

그럼 왜 병행하지 못할까. Node.js는 기본적으로는 싱글 스레드의 환경을 갖고 있다. 그래서 여러 개의 작업을 동시에 할 수 있는 여력이 없다. 대신에 비동기작업으로 인해 프로그램/시스템/서버가 다운되는 것을 막기 위해 그 비동기 작업의 처리의 타이밍을 결정할 수 있게 해준 것이다. 시간이 오래 걸리는 작업을 일일이 계속 기다려줄 수 없기 때문이다. 또한 그 작업들 중에 에러나 hang이 발생한다면 무한히 기다리게 된다.

왼쪽의 그림에서 오해하지 않아야 할 것은 비동기 작업이 무한히 대기하는 것은 아니라는 점이다. 일종의 작업 큐에 비동기작업들을 밀어 넣어두고, 그들에 대한 특별한 처리(Promise.resolve, await 등)가 없다면 모든 동기 작업들이 끝난 후에 작업을 마치긴 한다.

 

비동기 작업의 결과물은 Promise라는 객체로 리턴된다. 이제 언제 어디서 이러한 Promise를 사용할까?

 

예를 들면 대표적으로, 데이터베이스에 어떤 쿼리를 날리고, 그 결과를 express의 response에 실어서 보내고 싶은 경우들일 것이다.

일단은 필자의 Node.js에서 MySQL다루기 관련 포스팅에서 사용했던 dbPool을 재활용하여 dbPool을 얻어오는 예제를 통해 사용법을 이해해보자.

 

아래는 dbPoolCreator.js라는 파일이다. mysql세팅 관련한 내용은 모두 생략하였다. 싱글톤 패턴이며, getPool이라는 함수를 리턴하는 방식이다.

module.exports = (function () {
    let dbPool
    const initiate = async () => {
        return await mysql.createPool(settingObj)
    }
    return {
        getPool: async function () {
            if (!dbPool) {
                console.log('this instance creator must be called only once')
                dbPool = await initiate();
                console.log('[dbPoolCreator]: dbPool created')
            }
            return dbPool
        }
    }
})();

 

그리고 아래는 이제 dbPool을 얻어오려고 하는 test.js파일이다. 당연히 올바르지 않다.

const db = require('./models/dbPoolCreator')
let dbPool = await db.getPool() 또는 db.getPool()

위와 같이 쓰면 await에서 에러가 난다. await는 async하에서만 사용할 수 있기 때문이다. 

만약에 그냥 let dbPool = db.getPool()이라고 받으면 어떻게 될까? 그러면 dbPool에는 Promise { <pending> }이라는 프로미스 객체가 들어가게 된다. 이유는 위의 dbPoolCreator에 있는 것처럼 getPool함수가 async함수이기 때문이다. async함수는 promise를 리턴한다.

 

그렇다면 어떻게 해야 할까? 일단 2가지 방법(꼭 2가지만 있는 건 아니다 물론)을 생각해 볼 수 있다.

1번 방법은 .then을 사용하는 것이고,

2번 방법은 db.getPool()을 await할 수 있게 다른 async함수로 wrapping(랩핑, 포장)하는 것이다.

 

1번 방법은 다음과 같다.

db.getPool()은 promise객체를 리턴하고 그것을 .then을 통해 실행시킨다. 다른 동기 작업을 위해 잠시 미루어질 프로미스 객체가 then을 통해 실행되고 그 결과가 pool에 담기게 되는 것이다.

db.getPool().then(pool => console.log(pool))

 

그렇다면 아래처럼 쓴다면 어떻게 될까?

db.getPool().then(pool => console.log(1, pool))
console.log(2)

이 경우, 2가 먼저 찍히고, 1이 찍히게 된다.

db.getPool()이 비동기함수이므로 그 처리가 끝나길 기다리지 않고, 바로 다음 줄로 넘어간 것이다. 그래서 2가 먼저 찍히고, db연결 등의 과정을 거쳐야 했던 윗 라인이 상대적으로 늦어서 1번이 그 다음에 찍히게 된다.

pool을 가져오는 dbPoolCreator파일에서도 콘솔을 2줄을 찍는다.

필자의 화면에는 'this instance creator must be called only once'가 가장 먼저 찍혔고, 2가 찍혔고, [dbPoolCreator]: dbPool created가 다음으로 찍혔고, 마지막에 1번과 pool에 대한 정보가 출력되었다.

2를 미처 찍기도 전에 this instance creator must be called only once까지는 수행이 되었고, initiate를 하는 부분이 실제 db풀을 생성하는 부분이어서 그게 끝나기전에 당연히 2가 먼저 찍힌 것이다. 그러고 나서는 dbPool created로그가 찍히고, 1이 가장 마지막에 찍히게 된다.

 

2번 방법은 다음과 같다.

(async function () {console.log(await db.getPool())})()

위와 같이 쓰면, db.getPool()이 리턴하는 promise를 처리할 수 있는 await를 붙일 수 있다. 그게 가능하도록 async 무기명 함수가 정의되고 그게 즉각 실행된다. 즉시실행 무기명 함수의 형태이다.

이렇게 하면 바로 pool이 콘솔에 찍히게 된다.

위는 사실 아이디어만 담기위해 축약하였고, 가독성을 위해 다음을 보자.

 

let dbPool
const f = async function () {
    dbPool = await db.getPool()
    console.log(dbPool)
}
f()

이렇게 쓰면 f라는 비동기 랩핑 함수를 정의하여 그 안에서 await를 쓸 수 있게 된다. 그리고 그 함수 외부에 dbPool이라는 변수(또는 객체)를 두어 f() 실행 후 사용할 수 있게 했다.

 

let dbPool
const f = async function () {
    dbPool = await db.getPool()
    console.log(1, dbPool)
}
f()
console.log(2)

위와 같이 쓰면 어떻게 될까? f()가 뭔가 이제 동기적으로 처리될 것 같은 기대감에 1이 찍히고 2가 찍힐 지도 모른다는 생각이 든다. 하지만 가차 없이 2가 먼저 찍히고 1이 나중에 찍힌다. f함수는 그 내부적으로 비동기 처리를 동기적으로 할 수 있게 async-await가 도입된 것이다. 즉, 그 내부의 scope에서만 동기적 처리가 가능한 것이고, 외부에서는 여전히 비동기 함수일 뿐이다. 그래서 동기적으로 처리될 부분, 비동기적으로 처리될 부분을 정확히 알고 설계를 해야 한다.

이번에는 다음을 보자

const f = async function () {
    dbPool = await db.getPool()
    const p1 = db.getPool()
    console.log(1, p1)
    console.log(2, dbPool)
}
f()

이렇게 하면 1에서 Promise { Pool }이 찍히고, 2에서 Pool정보가 찍힌다. 당연하다. p1은 await하지 않았기에 promise를 받았고, dbPool은 처리가 되어서 Pool을 갖고 있다. 이 예제를 통해 보고 싶었던 것은 왜 p1이 Promise { <pending> }이 아니라 Pool정보가 들어있는가이다. 그것은 await db.getPool()을 통해 pool이 만들어졌기에 싱글톤으로 만들어진 db자체는 내부에 이제 풀을 갖고 있는 상태(initiate가 실행됨)인 것이다. 그래서 p1에는 Promise로 감싸진 풀 정보가 오게 되는 것이다. 

 

이번에는 풀에서 conneciton객체를 가져오는 과정을 살펴봄으로써, 프로미스를 리턴하지 않는 비동기 빌트인(built-in) 함수에서 프로미스를 리턴하도록 강제하는 방법까지 알아보자.

const f = async function () {
    const p = await db.getPool()
    return await p.getConnection()
}
console.log(f())

위의 코드에서의 의도는 pool을 가져오고 거기서 connection객체를 받아 리턴하려고 한 것이었다. pool에는 getConnection이라는 빌트인 메서드를 갖고 있으며 이는 커넥션 객체를 리턴해준다. 그 커넥션 객체에서 쿼리를 날릴 수 있기 때문에 중요하다. 하지만 위처럼 쓰면 에러가 발생한다. getConnection은 프로미스를 리턴하는 형태가 아니기 때문이다. getConneciton은 콜백의 형태로 작동한다. 그러면 어떻게 connection객체를 밖에서 받을 수 있을까? (사실 밖으로 리턴하지 않아도 된다. 콜백 내부에서 처리하고 반납하면 그만 이기 때문이다. 하지만 여기서는 프로미스를 이리저리 활용하기 위해서 억지로 하고 있다.)

new Promise라고 프로미스 객체를 생성하여 리턴하면 된다.

new Promise객체를 생성하면 내부 콜백에는 resolve와 reject를 넣어주어야 하는데, resolve를 리턴이라고 보면 되고, reject는 에러를 핸들링하는 부분이라고 보면 된다 (에러 처리용이며, 외부에서 catch로 받을 수 있다!). 

다음을 보자.

const f = async function () {
    const p = await db.getPool()
    return new Promise(async (resolve, reject) => {
        p.getConnection((err, conn) => {
            if (err) {
                conn.release()
                return reject(err)
            }
            return resolve(conn)
        })
    })
}
console.log(f())

참고로 conn.release()는 중요하다. 풀에 커넥션을 반환해주어야 하기 때문이다.

이제 위처럼 쓰면, conn객체 정보를 프로미스로 감싸서 리턴해줄 수 있다. 하지만 이제 눈치채셨겠지만, 콘솔에는 커넥션 객체 정보가 나오지 않는다. 프로미스가 리턴되었기에 pending상태가 된다!

 

그럼 앞서 살펴본 대로 then을 사용하자

const f = async function () {
    const p = await db.getPool()
    return new Promise(async (resolve, reject) => {
        p.getConnection((err, conn) => {
            if (err) {
                conn.release()
                return reject(err)
            }
            return resolve(conn)
        })
    })
}
f().then(conn => console.log(conn))

위처럼 then을 사용하면 프로미스를 벗길 수 있고, PoolConnection 객체 정보를 볼 수 있다. 편의상 콘솔에 찍고 말았지만, 정확히는 conn.release()를 추가하여 반납해야 함을 잊지 않아야 한다!) 물론, then이 아니라 await를 통해 conn을 받을 수 있다. 하지만 그러려면 또다시 다른 async 랩핑 함수가 필요해진다. 그 내부에서 await로 f()가 실행되도록 해야 하기 때문이다.

 

그러면 이제 자연스럽게 프로미스 체인이 필요해진다

프로미스 체인은 then안에 앞선 프로미스의 실행 결과물이 들어간다는 점을 활용한 것이다. 

비동기함수1().then(비동기함수1의 결과물=>...)의 형태가 된다. 비동기 함수가 promise를 리턴하면 then을 통해 실행이 완료된다. 그리고 프로미스가 벗겨진 결과물이 then의 인자로 바로 들어간다. 즉 then안에 함수를 넣으면 그 함수의 인자로 들어가게 되는 것이다. 갑자기 프로미스 체인을 처음 본다면 어색할 수도 있지만, 위에서 모두 살펴본 내용이니 찬찬히 보면 이해할 수 있다.

아래를 보자.

// f1은 dbPoolCreator를 받아서 getPool을 실행하고 프로미스를 리턴한다.
const f1 = db => {
    return new Promise((resolve, reject) => {
        resolve(db.getPool())
    })
}

// f2는 f1에서 then을 통해 실행된 결과(pool)를 받아서 커넥션 객체를 내어준다.
// f2에 들어온 것은 f1에서 리턴된 프로미스(풀)이 아니다!
// then을 통해 실행 완료된 온전한 풀을 받는다.
const f2 = pool => {
    return new Promise((resolve, reject) => {
        pool.getConnection((err, conn) => {
            if (err) {
                conn.release()
                return reject(err)
            }
            return resolve(conn)
        })
    })
}

// f3도 프로미스의 실행결과를 받는다. 프로미스를 받는 게 아니다!
const f3 = conn => {
    console.log(conn)
    conn.release()
}

f1(db)
    .then(f2)
    .then(f3)
    .catch(e => console.error(e))

위의 코드 블럭에서 코멘트도 달았지만, then을 통해 promise가 실행된 후의 결과를 다음 스텝으로 넘겨준다는 것이 중요하다! 그리고 위의 프로미스 체인은 async-await를 단 한번도 쓰지 않았다! 이렇게 then과 체인을 통해 promise를 순차적으로 다룰 수 있다는 점을 정리했다. 그리고 마지막에 .catch는 이전 단계 어디에서든 발생하는 모든 에러를 담는다. f1, f2, f3 어디서라도 에러가 발생하면 가장 마지막 catch로 직행한다. 물론 f1, f2, f3 내부에서 에러를 핸들하면 단계적으로 다르게 처리할 수도 있겠다. 하지만 모아서 동일하게 처리하려면 위처럼 하면 된다. 참고로 f1, f2, f3등의 함수 내부에서 return new Error()의 형식으로 에러를 내보내면 catch로 가지 않는다! 정상적으로 리턴된 경로를 따르기 때문이다.

 

이제 마지막으로 최신문법인 async-await를 사용해서 동일한 작업을 수행하자. 앞서 말한바와 같이 await를 쓰려면 async스코프를 만들어야 한다.

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 f = async _ => {
    const conn = await f2(await f1())
    console.log(conn)
    conn.release()
}
f()

위와 같이, f1에서는 promise로 포장된 커넥션 객체가 리턴되고 f2에서는 그것을 중화(?)하여, 즉 프로미스를 해제하여 connection객체를 리턴해준다. 그리고 이 두 함수를 모두 포함하는 또다른 async f함수에서 await를 통해 모든 작업을 동기적으로 처리한다. async-await를 쓰면 .then에서처럼 자꾸 인덴트가 지속적으로 생기는 문제를 해결할 수 있다. 그리고 더 최신 문법이어서 더 권장되고 있는 방식이다. await를 쓰면 보다 직관적으로 동기화시킬 수 있는데 async로 감싸야 한다는 점은 주의하자.

 

그리고 추가로 

다음과 같은 케이스에 주의해야 한다.

if(...){
    someAsyncFunc()
    return
}

anotherFunc()  // <--- if로 들어갔다면 절대 수행되지 않아야 할 함수

위와 같은 형식일 때, 비동기 처리가 바로 끝나지 않아서 return이 되지 않고, 절대 수행되지 않아야 하는 함수가 실행되게 된다...

즉, 비동기가 분기문 내에 포함된 경우도 각별히 주의해야 한다.

 

 

 

벼르고 벼르던 비동기 처리 내용을 이제서야 썼다.

거의 1년(?!)을 미룬 것 같다...다른 할 일들이 많지만 게으름일 수도 있다 분명히. 더 자주 더 꾸준히 쓰려면 더 노력해야한다.

나름대로 열심히 썼는데, 읽는 사람의 입장에서 좋은 글일지 의문이다...이후에는 배열에서의 비동기 처리에 대해 다루어보겠다.