많은 언어에서는 동기 및 비동기 코드를 효과적으로 사용해 여러분의 코드를 더욱 단순화하고 효율적으로 동작할 수 있도록 돕고 있습니다. 이 글에서는 어떻게 JavaScript에서 효율적인 비동기 플로우를 구성할 수 있는지 설명합니다.


"비동기적"

기본적인 동기 시스템과 비동기 시스템의 가장 큰 차이는 프로그램(혹은 시스템)이 특정 작업이 끝날 때까지 기다릴 필요성이 있는가 입니다. 그렇기 때문에 비동기적으로 작동하는 프로그램을 만들 경우에는 오래 걸리는 작업을 작업 큐에 추가하고 한 번에 여러 작업을 처리하도록 만드는 등 동기적으로 동작하는 것보다 훨씬 효율적인 작업 순서로 프로그램이 작동하도록 만들 수 있습니다.

동기적 프로그램의 워크플로우

동기적 프로그램의 경우 한 가지 작업이 끝나고 또 다음 작업을 실행하는데 간단히 한 번에 하나씩만 한다고 볼 수 있습니다. 예를 들어서 아래와 같은 코드가 있다면 calcA 함수가 끝나기 전까지는 calcB 함수가 실행될 수 없는 것이죠.

calcA()
calcB()

비동기적 프로그램의 워크플로우

반면 위 프로그램에서 calcAcalcB 함수 간의 상관관계가 없고 calcB 함수가 calcA 함수의 결과값을 필요로 하지 않을 경우 2개의 함수를 한 번에 실행하면 훨씬 더 빠르게 작업을 완료할 수 있을 것입니다. 이 때 저희는 calcA 함수의 결과를 반환했음을 확실히 하기 위해서 callback이라는 개념을 사용합니다. 아래 코드와 같이 특정 함수가 끝나고 실행할 함수를 Callback function이라고 합니다.

const calcA = callbackFunction => {
    setTimeout(() => callbackFunction(), 500)
}
const calcB = callbackFunction => {
    setTimeout(() => callbackFunction(), 1000)
}

calcA(() => console.log('Function `calcA` finished'))
calcB(() => console.log('Function `calcB` finished'))

비동기적으로 동작하는 형태

특정 프로그램이 비동기적으로 동작하는 경우는 기본적으로 3가지의 경우가 있습니다.

  • Callback
  • Promise (async + await)
  • Event

Promise

Promise는 기본적으로 위에서 다루었던 Callback과는 다르게 언어 스펙에 포함되어 있습니다. 대부분의 경우 변수나 함수 이름에 알기 쉬운 용어를 사용하는데 여기에서 Promise는 약속하다라는 동사의 뜻과 같이 '특정 함수가 종료'될 때 약속을 잡는다는 의미로 보면 됩니다. '특정 함수가 종료'된다는 것은 함수가 처리를 완료하여 처리한 결과값 혹은 오류를 반환하는 경우를 뜻합니다.

const makePromise = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(Date.now()), 1000 * 2)
    })
}
                       
makePromise()
    .then(time => console.log('Current timestamp: ' + time))
    .catch(error => console.error('Error!: ' + error))

위와 같이 Promise 객체를 반환하면 저희는 then 함수를 통해 핸들러 함수를 제공하고 catch 함수를 통해 오류를 핸들링할 수 있게 됩니다.

then 함수에서의 반환

또, Promise 환경에서 여러개의 Promise를 이어서 사용할 수도 있습니다. 아래는 Fetch API를 사용한 경우입니다. fetch 함수에서 먼저 res 객체를 받고 res.text()라는 비동기 함수를 실행하여 Promise를 반환하면 다시 then 함수로 Promise가 끝날 때까지 기다리고 반환값인 text를 받아 콘솔에 기록합니다.

const fetch = require('isomorphic-unfetch')

fetch('https://google.com')
	.then(res => res.text())
	.then(text => console.log(text))

async, await 키워드 사용하기

JavaScript ES6에서는 이 Promise를 조금 더 쉽게 사용하기 위해 asyncawait 키워드를 만들었습니다. 이 키워드들을 사용하면 훨씬 단순하게 코드를 작성할 수 있습니다. 가장 상단의 코드는 무조건 동기적으로 실행되니 main이라는 비동기 함수를 대신 생성합니다. 아래와 같이 async 키워드를 통해 비동기 함수를 생성할 수 있습니다.

// ES5 or lower
async function main () {
    console.log('hello world')
}

// ES6 or higher
const main = async () => {
    console.log('hello world')
}

위와 같이 Fetch API를 통해 await 키워드의 사용을 알아보겠습니다. 비동기 함수 앞에 await 키워드를 붙여줄 경우에는 해당 함수가 끝날 때까지 기다리고 다음 코드는 무조건 해당 함수가 끝난 후에 실행되게 됩니다. 단, 동기적인 코드는 async 범위 안에서도 무조건 동기적으로 실행되니 굳이 await을 붙일 필요는 없습니다.

const fetch = require('isomorphic-unfetch')

const main = async url => {
    const res = await fetch(url)
    const text = await res.text()
    
    return text
}

여러개의 작업 동시에 처리하기

단, await 키워드를 사용할 때 주의할 점이 하나 있습니다. 여러분은 동기식으로 코드를 작성하는 동안 상관관계가 없는 함수의 실행을 늦추어 비동기 프로그래밍의 장점을 무시하게 되는 경우입니다. 이 때 저희는 여러개의 Promise를 한 번에 실행하기 위해서 Promise.all 메서드를 사용할 수 있습니다.

const fetch = require('isomorphic-unfetch')

const getContext = async url => {
    const res = await fetch(url)
    const text = await res.text()
    
    return text
}

Promise.all([
    getContext('https://google.com'),
    getContext('https://bing.com')
])
	.then(results => {
		for (let i = 0, l = results.length; i < l; i++) {
			console.log(`${i} of ${l}: ${results[i]}`)
		}
	})

위와 같이 Promise.all 메서드에 Promise로 구성된 배열을 넘겨주면 모든 Promise가 완료된 후에 then의 핸들러를 결과의 배열과 함께 호출합니다.

Event

마지막으로 비동기적으로 작동하는 코드의 형태에는 이벤트가 있습니다. 이벤트 안의 함수는 동기로 작동할지라도 이벤트가 발생하는 경우는 정해지지 않고 한 번에 한 이벤트만 처리하는 것이 아니기 때문입니다. 이벤트를 통해 비동기적으로 핸들링하는 경우를 Event-driven이라고 하며 대표적으로는 웹 서버이 Event-driven 형태입니다.

용어 선택하기

동기 그리고 비동기와 함께 사람들이 많이 사용하는 용어로 Blocking과 Non-blocking I/O가 있습니다. 기본적으로 동기는 Blocking I/O 그리고 비동기는 Non-Blocking I/O라고도 부릅니다. 하지만 특정한 상황에서는 2개는 정말로 다른 것을 뜻할 수 있기 때문에 이 글에서는 많은 언급을 피하였습니다.