• JavaScript

비동기 자바스크립트 소개

콜백부터 Promise, Async/Await까지 이해하는 JavaScript 비동기 프로그래밍

2017. 7. 25

비동기 함수

비동기란 동기의 반대이다. 동기는 함수를 호출했을 때 그 실행이 완료될 때까지 다른 작업의 실행을 막는 방식이다.

브라우저는 동기함수를 한 번에 하나씩만 처리하며 자바스크립트 프로그램을 실행한다. 동기함수가 실행되는 중간에는 다른 작업이 개입하지 못한다. 심지어는 마우스 이벤트와 같은 입력도 모두 막혀 동작하지 않는다. 하지만 함수 하나하나가 너무 빠르게 실행되기 때문에 마치 동시에 실행되는 것처럼 보인다.

그런데 만약 아래와 같은 코드가 실행되면 어떨까?

while(true) {}

콘솔에 실행해 보면, 탭을 종료하려고 해도 잘 안 되고, 웹 페이지를 클릭해도 반응이 없다. 잘못 프로그래밍된 웹사이트에서는 굉장히 긴 동기함수를 사용자 브라우저에서 실행할 수도 있다. 이런 경우에 대비하여 브라우저는 어떤 함수가 끝나지 않고 오랜 시간 계속되는 경우에 강제로 중단하도록 되어 있다. 보통 함수 실행이 그렇게 오래 걸리는 것은 프로그래머의 잘못이다.

비동기 함수는 호출될 때 작업을 시작하는 것까지는 같다. 하지만 실행한 작업이 진행되는 동안 프로그램이 다른 일을 하며 기다릴 수 있다는 차이가 있다.

외부 서버에서 이미지를 다운로드하는 작업은 몇 초가 걸릴 수도 있다. 이미지를 다운로드하는 동안 브라우저가 완전히 멈추어 데이터를 다 받을 때까지 기다린다고 가정해 보자. 사용자는 답답할 것이고 컴퓨터의 입장에서도 시간을 낭비하게 된다.

이런 경우가 바로 비동기 프로그래밍이 필요한 경우이다.

setTimeout

자바스크립트에는 setTimeout(func, ms)이라는 함수가 있다. 이 함수는 ms 밀리초 이후에 func 함수를 실행한다.

setTimeout( () => console.log('TIME!'), 3000 );

setTimeout 함수를 우리가 직접 구현해 볼 수 있을까?

function mySetTimeout(func, ms) {
  let now = new Date().getTime();
  for(;now + ms > new Date().getTime();) {
    // do nothing and wait
  }
  func();
}
mySetTimeout( () => console.log('TIME!'), 3000 );

분명 ms 밀리초 정도 이후에 func 함수가 실행된다. 그러나 mySetTimeoutsetTimeout은 대기 시간을 길게 할수록 차이가 명확히 드러나게 된다.

setTimeout과는 다르게 mySetTimeout은 기다리는 동안 다른 작업을 할 수 없다! 즉 이런 방식으로 프로그래밍하면 안된다.

blocking과 non-blocking

사실 setTimeout 함수는 이런 방식으로는 구현할 수 없다.

setTimeout에 인자로 넘겨진 함수는 ms 밀리초 후에 자바스크립트 실행기가 가지고 있는 ‘나중에 할 일 목록’, 즉 작업 큐에 추가된다. 현재 실행 중인 동기함수를 모두 실행하면 이제 자바스크립트 실행기는 작업 큐 맨 앞에 있는 함수를 또 실행해 나간다.

눈으로 확인해 보기 위해 아래처럼 준비한 후에 간단한 실험을 해보자.

function doA() {
  console.log('Do A');
}
function doB() {
  console.log('Do B');
}

아래의 코드를 실행했을 때 ‘나중에 할 일 목록’, 즉 작업 큐의 상태가 어떻게 바뀌어가는지 살펴 보자.

// 예제 1. setTimeout 하는 경우
setTimeout(doA, 1000);
// 예제 2. mySetTimeout 하는 경우
mySetTimeout(doB, 2000);

위의 예제 1에서는 1000 밀리초 후에 doA가 작업 큐에 추가되며 실행된다. 반면 2에서는 작업 큐와는 상관 없이 시간을 확인하며 while 루프를 돌다가 시간이 되면 주어진 함수 doB를 동기적으로 실행한다.

이것은 아래의 예제들을 실행해 보았을 때 명확히 알 수 있다.

// 예제 3. 연달아 하는 경우
setTimeout(doB, 2000);
setTimeout(doA, 1000);
console.log('Timers set up');

이 코드는 무슨 뜻일까? doA는 1초 후에, doB는 2초 후에 실행하라는 뜻이다. doAdoB가 실행되고 2초 후에 실행될까? 아니다. 콘솔에 실행해보면 setTimeout은 시간이 지날때까지 다음 코드의 실행을 기다리지 않고 바로 넘어간다. 따라서 아래처럼 출력된다.

Timers set up
Do A
Do B

doB 를 실행하는 mySetTimeout은 다르다.

mySetTimeout(doB, 2000);
setTimeout(doA, 1000);
console.log('Timers set up');

여기에서는 1. 2초 후에 doB가 실행되고, 2. doB의 실행이 끝났을 때 기준으로 1초 후에 doA를 실행하라고 한 후 3. 'Timer set up' 메시지를 출력하고 4. 2에서 예약한 대로, 1초 후 doA가 실행된다.

즉, mySetTimeout은 코드의 실행을 막지만(blocking) setTimeout은 막지 않는다(non-blocking).

이런 경우도 생각해 볼 수 있다.

setTimeout( doA, 1000 );
mySetTimeout( doB, 2000 );
// 500 ms에 사용자가 <button onClick={doC} />을 클릭했다.

작업 큐와 이벤트 루프

setTimeout은 타이머가 끝나면 함수를 작업 큐에 넣는다. ‘타이머가 끝나면’ 같은 것을 이벤트라고 한다. 비동기 프로그래밍은 이벤트를 기반으로 한다. ‘즉, 서버에서 사용자 데이터를 가져왔으면 프로필 사진 URL이 있을 테니 그 URL에서 사진을 다운로드하고 그 사진 다운로드가 끝나면 무엇을 하고…‘와 같은 식으로 프로그램을 작성하게 된다.

위에서 예로 든, ‘사용자 데이터 응답 받음’, ‘사진 데이터 응답 받음’과 같은 것은 ‘타이머가 끝남’과 같이 이벤트들이다. 다른 함수로 인해 이벤트가 발생할 수도 있지만 onClick이 지정된 HTML 요소를 클릭할 때에도 이벤트는 발생한다. 이벤트가 발생하면 만약 그 이벤트를 듣고 있던(listen) 함수가 작업 큐에 들어간다.

자바스크립트 실행기는 작업 큐의 작업 목록을 차례대로 실행한다. “하나 처리하고 -> 큐에 할 일이 있으면 -> 또 가져와서 실행하고 -> (계속)” 이런 방식으로 작동하는 것을 이벤트 루프라고 한다.

비동기 콜백(async callbacks)

setTimeout은 이벤트가 발생할 경우에 실행될 함수를 위에서 본 것 같이 전달한다. 이런 방식을 비동기 콜백 이라고 부른다. 자바스크립트에 비동기 콜백을 사용하는 함수들은 많다.

모든 콜백이 비동기 콜백일까? map 함수에 전달되는 함수 역시 callback이라고 불린다. 하지만 map 함수는 전달한 콜백 함수를 비동기적으로 실행하지 않는다. 즉 map의 콜백은 동기 콜백이다.

[1,2,3].map(item => console.log(item));
console.log('map done');
// 1
// 2
// 3
// map done

다음에 예시로 들 함수는 아마 처음 보는 함수일 수도 있다. fs는 NodeJS 환경에서 파일을 읽고 쓰는 데 사용하는 모듈이다. 브라우저에서는 fs 모듈을 제공하지 않으므로 혼동하지 않기를 바란다.

터미널에서 node 명령을 사용해 직접 실습해 볼 수 있다.

const fs = require('fs');
// (이 구문은 import fs from 'fs'; 와 같은 의미입니다.)

fs.readdir('.', (err, files) => {
  console.log(files)
})
// 현재 폴더의 내용을 읽고, 읽기가 끝났으면 그 결과를 두번째 인자로 주어진 함수에게 넘겨 호출.

fs를 활용해 콜백 연습을 조금 더 해보자. fs.writeFile 함수로 문자열을 파일로 적을 수 있다. 역시 파일 쓰기가 완료되는 경우에 콜백을 실행한다.

두 개를 조합해서 위에서 얻은 파일 목록을 JSON 파일로 적고, 완료되면 완료 메시지를 콘솔에 띄워 보자.

const fs = require('fs');

fs.readdir('.', (err, files) => {
  // 여기서는 파일을 만듭니다. 주의!
  fs.writeFile('ls.json', JSON.stringify(files), (err) => {
    console.log("File's been written!");
  });
})

한 단계 더 나가서, 파일 적기가 끝나면 그 파일을 다시 읽어서 콘솔에 띄워 보자. readFile이라는 함수가 있다.

const fs = require('fs');

fs.readdir('.', (err, files) => {
  fs.writeFile('ls.json', JSON.stringify(files), (err) => {
    fs.readFile('ls.json', (err, data) => {
      console.log(data.toString());
    })
  });
})

위처럼 비동기 콜백 방식을 쓰면 다음에 실행할 함수를 전 함수에 인자로 넣는 방식으로 비동기 작업을 연달아 실행할 수 있다.

그러나 너무 많아진다면 어떨까?

한 두 개 호출이라면 모를까, 열 개 이상으로 비동기 콜백을 연달아 넣는다면 그야말로 지옥과 같다. 그래서 이런 문제를 Callback Hell이라고 부른다. Callback Hell

Promise

자바스크립트 프로그래머들은 Callback Hell이 드러내는 불편한 비동기 프로그래밍 문제를 오래 전부터 겪어왔던 사람들이다. 발전을 거듭한 끝에 지금 자바스크립트는 비동기 프로그래밍을 세련된 방법으로 처리하는 언어가 되었다.

그 핵심에는 Promise가 있다. Promise 기반 비동기 함수는 호출할 때 callback을 받는 대신에, Promise 객체를 반환한다. 반환된 Promise 객체를 통해 그 다음에 실행할 함수를 전달할 수 있다.

fetch

브라우저의 fetch 함수를 살펴보자. fetch 함수는 인자로 주어진 URL에서 데이터를 가져오는 일을 한다. Promise 기반 비동기 함수이며 아래와 같이 사용한다.

fetch('https://api.github.com/users/hanpama')

fetch 함수는 여러 방법으로 사용할 수 있지만 가장 단순한 것은 위의 예시처럼, 특정 주소를 인자로 넣어서 HTTP 요청을 보내는 것이다. 원격 서버가 데이터를 다 보내줄 때까지 다른 작업이 모두 멈춰있어선 안 되므로 당연히 비동기 함수로 만들어져 있다.

브라우저 자바스크립트 콘솔에 아래의 코드를 실행해 보자.

const p = fetch('https://api.github.com/users/hanpama');

fetch의 반환값 pPromise객체라고 콘솔에 출력될 것이다. fetch함수는 Promise를 반환하며 이렇게 말하는 셈이다.

“지금 내가 하는 작업은 오래 걸리니까 조금만 기다려주면 값을 돌려줄게. 이 Promise 객체를 가지고 있으면 값이 돌아올거야.”

예시의 p 값인 Promise는 말 그대로 긴 작업을 하러 떠난 함수가 남기고 간 약속이다. 이런 속성 때문에 미래에 올 값의 대리인(proxy)이라 말하기도 한다.

          // "지금 내가 하는 작업은 오래 걸리니까 조금만 기다려주면 값을 돌려줄게.
          // 이 Promise 객체를 가지고 있으면 여기에 값이 들어올거야."
const p = fetch('https://api.github.com/users/hanpama');
  // fetch가 돌려준 Promise 객체를 p에 보관했다.

프로그래머는 이제 fetch가 돌려준 약속을 가지고 다음 작업을 지시할 수 있다.

p.then( response => console.log(response) );

모든 Promise에는 then이라는 함수가 있다. then 함수는 인자로 받은 함수를 Promise가 완료되면 실행하도록 지시한다.

  // 그럼 이 Promise(fetch)가 완료됐을 때 그 결과값인 response를 console.log 하자
p.then( response => console.log(response) );

만약 fetch가 비동기 콜백을 사용했다면 우리는 아래와 같이 프로그래밍했을 것이다.

// 실제 코드가 아님!
fetch('https://api.github.com/users/hanpama', response => {
  console.log(response);
})

위의 response에는 HTTP 요청의 본문 텍스트를 얻을 수 있는 text()라는 비동기 함수가 들어있다. 역시 Promise를 반환하는 함수인데 만약 콜백 방식으로 텍스트를 콘솔에 출력해야 했다면 아래와 같을 것이다.

// 실제 코드가 아님!
fetch('https://api.github.com/users/hanpama', response => {
  response.text(text => {
    console.log(text);
  })
})

다행히 Promise를 사용한 실제 fetch 함수는 이렇게 사용한다. 더 이상 함수를 중첩해서 넣을 필요가 없다.

const firstPromise = fetch('https://api.github.com/users/hanpama');
const secondPromise = fetchPromise.then(res => res.text());
const finalPromise = secondPromise.then(text => console.log(text));

모든 Promise 객체를 const에 대입할 필요는 없으므로 아래처럼 간결하게 정리할 수 있다.

const finalPromise = fetch('https://api.github.com/users/hanpama')
  .then(res => res.text())
  .then(text => console.log(text))
/*
  1: 'https://api.github.com/users/hanpama'에서 데이터를 가져와
  2: '그게 끝나면' 거기에서 텍스트를 추려내
  3: '그게 끝나면' 결과 텍스트를 콘솔에 출력해
*/

혹시 fetch, text 함수가 모두 동기 함수였다면 아래처럼 프로그래밍 하지 않았을까?

const res = fetch('https://api.github.com/users/hanpama');
const text = res.text();
console.log(text);

위에처럼 프로그래밍하면 정말 편할텐데, 비동기 함수라는 이유로 더 복잡하게 해야만 한다. (콜백을 쌓는 방법보다는 Promise가 훨씬 나아 보이지만)

Async/Await

귀찮은 것은 절대 무슨 일이 있어도 버틸 수 없다. async/await 문법은 이런 욕망에 의해 만들어졌다.

await

const res = await fetch('https://api.github.com/users/hanpama');
const text = await res.text();
console.log(text);

await 키워드를 Promise 앞에 사용하면 Promise의 결과값을 그 자리에서 얻을 수 있다! 알아두어야 할 한 가지는

  • await은 async 함수 안에서만 사용할 수 있다는 것이다.

async

async 함수에 대해 알아야 하는 두 가지가 있다.

  • 반환값이 Promise이다
  • 내부에서 await을 쓸 수 있다

async 함수는 이렇게 작성한다.

async function addNumber(a, b) {
  return a + b;
}

저 addNumber 함수는, addNumber(1, 2) 라고 실행해도 3이 아니라 Promise가 반환된다. 3은 어떻게 얻을까?

addNumber(1, 2).then(sum => console.log(sum))

즉 async 함수가 안에서 반환하는 값은 Promise에 감싸여 나온다. 그러므로 then을 쓰면 된다.

그런데 느낌이 온다. async/await을 섞으면 then이라는 것은 쓸 필요가 없다는 느낌이.

const three = await addNumber(1, 2)

브라우저 콘솔에서 실행해 보자!

짠! 여기에서 Promise, then, callback 같은 것은 이제 눈에 보이지도 않는다. 비동기 함수인데도 마치 동기함수처럼 보인다.

물론 이런 덧셈함수를 비동기로 만들어서는 좋은 점이 없다. 비동기 함수는 fetch 같은 네트워킹, 또는 입출력 함수에 많이 쓰인다. 연산은 적은데 기다리는 시간이 많은 작업들이다.

const res = await fetch('https://api.github.com/users/hanpama');
const text = await res.text();
console.log(text);

이쯤 되면 프로그래머들은 마지못해 비동기 프로그래밍을 하지만 기회만 되면 동기 프로그래밍처럼 하고 싶어하는 것 같다. 동시에 일어날 여러 작업을 계획하는 것은 정말 어렵기 때문이다.

더 읽기

관련 명세