[javascript] Promise, async, await

javascript, promise, async, await

이번 포스트는 javascript의 비동기 프로그래밍을 동기 프로그래밍으로 할 수 있게 해주는 promise에 대해 다뤄보도록 하겠습니다.

  1. 비동기 프로그래밍
  2. Promise
  3. async/await
  4. Promise 응용



비동기 프로그래밍

javascript는 Event Driven으로 실행되는 구조를 가지고 있습니다. 사용자의 행동에 대해 반응하는 방식으로 작동하는 것입니다.

window.onload = e => {
  console.log(`window가 load 되는 시점에 실행됩니다.`)
}
document.querySelector('#target1').onclick = e => {
  console.log(`#target1을 클릭했을 때 실행됩니다.`)
}
document.querySelector('#input').onkeyup = e => {
  console.log(`#input에 키보드 입력을 했을 때 실행됩니다.`)
}

위와 같이, site가 load 되는 시점, 마우스 클릭을 했을 때, 키보드를 눌렀을 때 등의 event를 감지하여 function을 실행하는 구조입니다. 이러한 형태가 바로 Event Drive 입니다. 이런 event driven의 특징을 살펴보겠습니다.

window.onload = e => {
  setTimeout(_ => {
    console.log('1초 후에 실행됩니다.')  // 1번
  }, 1000)
  console.log('바로 실행됩니다.') // 2번
}

위의 코드는 2번이 먼저 실행 된 다음에 1번이 실행됩니다. 즉, 코드가 순차적으로 실행되는 것이 아닌 Event 기반으로 실행 되는 것입니다. 그리고, 이러한 이벤트가 많아질 경우 callback 지옥에 빠지게 됩니다.

var num = 1
setTimeout(_ => {
  console.log(`${++num} 번째 setTimeout`)
  setTimeout(_ => {
    console.log(`${++num} 번째 setTimeout`)
    setTimeout(_ => {
      console.log(`${++num} 번째 setTimeout`)
      setTimeout(_ => {
        console.log(`${++num} 번째 setTimeout`)
        setTimeout(_ => {
          console.log(`${++num} 번째 setTimeout`)
          setTimeout(_ => {
            console.log(`${++num} 번째 setTimeout`)
            setTimeout(_ => {
              console.log(`${++num} 번째 setTimeout`)
            }, 1000)
          }, 1000)
        }, 1000)
      }, 1000)
    }, 1000)
  }, 1000)
}, 1000)

코드를 순차적으로 실행 시키기 위해서 이러한 행위를 반복합니다.
callback이 끝난 후 callbak,
다시 callback이 끝난 후 callback ... 반복
그러다 보면 굉장히 보기 좋지 않은 코드가 되는 것이죠. 이것을 해결하기 위한 것이 Promise입니다.


Promise

Promise의 문법은 다음과 같습니다.

// ES5
var num = 0
var f = function () {
  return new Promise(function (resolve) { // Promise 객체를 반환합니다.
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      // setTimeout이 끝나는 시점에서 resolve() 실행
      // resolve는 then에서 인자로 넘겨진 callback 함수
      resolve()
    }, 1000)
  })
}
const callback = function () {
  console.log(`then 구문에서 반환하는 function은 resolve가 됩니다.`)
}

f().then(callback)

Promise의 실행 순서는 다음과 같습니다.

  1. Promise 객체를 반환합니다.
  2. Promise 객체의 인자에는 resolve function과 reject function이 있습니다.
  3. Promise 객체에는 then 이라는 method가 있습니다.
  4. then은 function을 인자로 받습니다.
  5. then에서 넘겨진 function은 promise의 resolve 에서 실행됩니다.

핵심은, then에서 function을 넘겨주고, 해당 function이 resolve입니다. then을 계속 사용하기 위해서는 new Promise를 계속 반환해줘야 합니다.

// ES5
var num = 0;
(function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })  
})().then(function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })
})
.then(function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })
})
.then(function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })
})
.then(function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })
})

보여지는 것 처럼, then을 계속 사용하기 위해 then의 인자 functino에서 항상 new Promise를 반환합니다. 그래야 then을 이어서 사용할 수 있습니다.

그리고 위의 코드는 다음과 같이 추상화할 수 있습니다.

// ES5
var num = 0
var f = function () {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`${++num} 번째 실행`)
      resolve()
    }, 100)
  })
}
f().then(f).then(f).then(f).then(f).then(f).then(f).then(f).then(f)

그리고 es6 문법을 사용하면 더욱 더 깔끔해집니다.

// ES6
let num = 0
const f = _ => new Promise(resolve => {
  setTimeout(_ => {
    console.log(`${++num} 번째 실행`)
    resolve()
  }, 500)
})
f().then(f).then(f).then(f).then(f).then(f).then(f).then(f).then(f)

훨씬 보기 편해졌죠? 하지만 이런 then 구문에도 문제점이 존재합니다.

// ES6
let num = 0
const f = _ => new Promise(resolve => {
  setTimeout(_ => {
    console.log(`${++num} 번째 실행`)
    resolve()
  }, 500)
})
f().then(f).then(f).then(f).then(f).then(f).then(f).then(f).then(f)
console.log('이게 제일 먼저 실행됩니다.')

동기 프로그래밍을 하기 위해서 항상 then을 사용해야 한다는 것입니다. then 바깥에서는 여전히 비동기 프로그래밍 방식으로 실행됩니다. 그래서 async/awiat이라는 개념을 사용하여 훨씬 간단하게 proimse를 사용할 수 있습니다.


Async/Await

async : 비동기

await : 기다리다

async/await에 대한 해석입니다. 즉 비동기로 실행되는 것들을 끝날 때 까지 기다리는 형태를 의미합니다. aysnc/await을 사용하기 위해선 일단 Promise를 사용해야합니다. 코드를 통해 살펴봅시다.

// ES6
let num = 0
const f = _ => new Promise(resolve => {
  setTimeout(_ => {
    console.log(`${++num} 번째 실행`)
    resolve()
  }, 500)
});
// async/await 구문은 function에서 사용됩니다.
// 그래서 즉시 실행 함수를 사용하였습니다.
(async _ => { // 함수 앞에 async 라는 키워드를 붙입니다.
  await f() // promise를 반환하는 함수 앞에 await을 붙입니다.
  console.log('test1') // await 구문이 완료될 때 까지 기다린 후 실행됩니다.
  await f()
  console.log('test2')
  await f()
  console.log('test3')
})();

async/await의 핵심 문법은 다음과 같습니다.

  1. function 앞에 async라는 키워드를 붙인다.
  2. promise로 반환하는 것들 앞에 await을 붙인다.

사실 javascript의 코드는 거의 대부분 함수 내부에서 작성됩니다. 그렇기 때문에 async/await 문법은 매우 편리하게 사용될 수 있습니다.

그리고 async/await에서 resolve에다 인자를 넣어주면 해당 값이 await 구문에 반환됩니다.

// ES6
let num = 0
const f = _ => new Promise(resolve => {
  setTimeout(_ => {
    console.log(`${++num} 번째 실행`)
    resolve(num * 5)
  }, 500)
});
(async _ => {
  const num1 = await f()
  console.log(num1) // 5
  const num2 = await f()
  console.log(num2) // 10
})();

이 처럼 resolve(num * 5)를 하였고, const num1 = await f() 에는 num * 5의 결과가 반환되었습니다. then 구문 보다 훨씬 폭 넓고 쉽게 사용할 수 있습니다.



Promise 응용

Promise가 무엇인지는 알았으나, 이것을 어떻게 사용해야할지는 막막하리라 생각됩니다. 그래서 응용 방법에 대해 알아보도록 하겠습니다.

node.js에서 mysql은 callback 형식으로 작동합니다. 쿼리문을 수행 후, callback을 통해 처리하는 방식입니다.

예를들어 회원가입을 할 때, 먼저 중복된 정보가 있는 지 검사를 해야 하며, 중복 된 정보가 없다면 데이터베이스에 회원 정보를 추가합니다.

const mysql = require('mysql')

// variable setting
const con = mysql.createConnection({ /* DB info */ })

// mysql 실행구문
const sql1 = `SELECT count(*) cnt FROM member where id = 'test' and pw = '1234'`
const sql2 = `INSERT INTO member SET id = 'test', pw = '1234'`
con.query(sql1, (err, res) => {
  if (res.rows[0] === 0) {
      con.query(sql2, (err, res) => {
        if (err) console.log(err)
        else console.log('회원정보가 추가되었습니다.')
      })
  } else {
    console.log('이미 중복된 회원 정보가 존재합니다.')
  }
})

위의 사례는 쿼리문을 2개 중복시켰을 경우 실행되는 예제 입니다. 하지만 쿼리문이 2개가 아니라 3개, 4개, 5개 이상이며 심지어 else 구문에도 쿼리문이 존재한다면 어떤 일이 발생할까요?

con.query(sql1, (err1, res1) => {
  if (!err1) {
    con.query(sql2, (err2, res2) => {
      if (!err2) {
        con.query(sql3, (err3, res3) => {
          if (!err3) {
            con.query(sql4, (err4, res4) => {
              if (!err4) {
                con.query(sql5, (err4, res4) => { /* ... */ })
              }
            })
          }
        })
      }
    })
  }
})

코드를 이해하는 것은 둘째 치고, 코드를 보고만 있어도 가슴이 답답해집니다. 도대체 이런걸 어떻게 알아볼 수 있을까요?
하지만 promise를 사용하면, 특히 async/await을 사용하면 매우 깔끔하게 표현할 수 있습니다.

const queryExec = sql => new Promise ((resolve, reject) => {
  con.query(sql, function (err, res) => {
    err ? reject(err) : resolve(res) // reject는 예외 처리를 할 때 사용합니다.
  })
});

(async () => {
  try {
    const res1 = await queryExec(sql1)
    const res2 = await queryExec(sql2)
    const res3 = await queryExec(sql3)
    const res4 = await queryExec(sql4)
    const res5 = await queryExec(sql5)
    /* ... 최종 처리 코드 작성 ... */
  } catch (err) {
    console.log(err)
  }
})()

훨씬 직관적으로 변했습니다. 이게 바로 Promise를 사용하는 이유입니다. 그러나, 이 코드에도 문제가 있습니다. 여기서 필요한 것은 res1 ~ res5 까지의 데이터입니다. 하지만 res1을 가져온 다음에 res2를 가져오고, res3을 가져오고 등의 방식으로 실행되기 때문에 시간이 낭비됩니다.

즉, 최종 코드가 처리 되기 까지 걸리는 시간은 모든 쿼리문이 실행 시간의 합과 동일합니다.

Query Time 1 + Query Time 2 + ... + Query Time n = 최종 실행 까지 걸리는 시간

이 때 Promise.All을 사용하면 좋습니다.

(async () => {
  try {
    Promise
      .all([queryExec(sql1), queryExec(sql2), queryExec(sql3), queryExec(sql4), queryExec(sql5)])
      .then(values => {
        const [res1, res2, res3, res4, res5] = values
        /* ... 최종 처리 코드 작성 ... */
      })
  } catch (err) {
    console.log(err)
  }
})()

Promise.all은 모든 Promise 객체들 중 가장 마지막으로 끝나는 것을 기준으로 then 구문을 처리합니다. 따라서 쿼리문 5개 중 가장 늦게 끝나는 것을 기준으로 실행 되게 됩니다.

Max(QueryTime1, QueryTime2, ... , QueryTime5) = 최종 실행 가지 걸리는 시간