https://helloworldjavascript.net/pages/285-async.html

위 링크를 참고하였습니다.

비동기 프로그래밍

Timer API

setTimeout(() => {
	console.log("2000 ms");
}, 2000);

setInterval(() => {
	console.log("3000 ms");
}, 3000);

함수를 특정 시간이 지난 뒤 실행 시키거나, 함수를 주기적으로 실행 시키는 작업을 할 수 있게 하는 내장 함수.

setTimeout과 setInterval은 각각 타이머 식별자를 반환합니다. 이 식별자를 가지고 실행 중인 타이머를 취소할 수 있습니다.

const timeoutId = setTimeout(() => {
  console.log('setTimeout이 실행된 지 2초가 지났습니다.');
}, 2000);
const intervalId = setInterval(() => {
  console.log('3초마다 출력됩니다.');
}, 3000);
clearTimeout(timeoutId);
clearInterval(intervalId);
// 아무것도 출력되지 않습니다.

타이머 사용 시 주의할 점

setTimeout과 setInterval은 정확한 지연시간을 보장해 주지 않습니다.

const start = new Date();

setTimeout(() => {
  console.log(new Date() - start);
}, 100);

// 실제 지연시간과 약간의 차이가 존재합니다.

또한 지연시간을 0으로 주었을 때는 코드가 기대한대로 동작하지 않습니다. setTimeout 호출 시 지연시간으로 0을 넘기면 어떻게 되는지 확인해보겠습니다.

setTimeout(() => {
  console.log('hello');
}, 0);

console.log('world');

// 출력 결과:
// world
// hello

브라우저의 Javascript 코드 실행 과정

호출 스택 (call stack)

콜스택은 스택 형태의 저장소로, 자바스크립트 엔진은 함수 호출과 관련된 정보를 이 곳에서 관리합니다.

아래의 코드에 대한 호출 스택을 그림으로 나타내보면 다음과 같습니다.

function add(x, y) {
  return x + y;
}

function add2(x) {
  return add(x, 2); // `add`를 호출
}

function add2AndPrint(x) {
  const result = add2(x); // `add2`를 호출
  console.log(result); // `console.log`를 호출
}

add2AndPrint(3); // `add2AndPrint`를 호출

아래를 간이 콜스택이라고 가정하겠습니다. 위의 코드는 콜스택에 다음과 같이 push되었다가 pop됩니다.

[add] ← 세번째로 쌓임, 가장 먼저 나감

[add2] ← 두번째로 쌓임, 두번째로 나감

[add2AndPrint] ← 가장 먼저 쌓임, 세번째로 나감

호출 스택에 저장되는 각 항목을 실행맥락(execution context)라고 부릅니다.

실행 맥락에는 아래와 같은 정보들이 저장됩니다.

  • 함수 내부에서 사용되는 변수 (variables)
  • 스코프 체인 (scope chain)
  • this가 가리키는 객체

브라우저가 자바스크립트 코드를 실행 시킬때, 호출 스택을 다음과 같이 조작합니다.

  • 스크립트를 불러 올 때, 전역 실행 맥락 (global execution context)을 호출 스택에 추가합니다
  • 함수가 호출되면, 해당 호출에 대한 실행 맥락을 생성해서 호출스택에 추가(push)합니다
  • 변수에 대입이 일어나면, 호출스택에 저장되어 있는 변수의 내용을 변경합니다
  • 함수의 실행이 끝나면 결과값을 반환하고 호출 스택 가장위에 있는 실행맥락을 제거(pop)합니다
  • 스크립트의 실행이 모두 끝나면, 전역 실행 맥락을 호출스택에서 제거(pop)합니다

이를 통해 변수에 값을 대입한다거나 함수가 여러번 중첩되어 호출되는 등의 복잡한 코드의 동작을 단순한 자료구조로 표현할 수 있게 됩니다.

웹 브라우저느 호출스택에 실행맥락이 존재하는 동안, 즉 실행주인 함수가 존재하는 동안에는 먹통이 되어버립니다. 브라우저는 대게 60fps로 동작하기 때문에, 대략 16ms 안에 코드 실행을 완료하지 못하면 브라우저 내에서의 애니메이션이 뚝뚝 끊기는 현상이 나타납니다. 이는 사용자 경험에 악영향을 미칠 수 있습니다.

// 특정 시간동안 계속 루프를 도는 코드
function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}

sleep(5000);
// 5초 동안 while 루프가 실행되므로, 호출 스택이 비워지지 않고 브라우저는 먹통이 됩니다.

따라서 브라우저에서 동작하는 자바스크립트 코드, 특히 사용자와의 상호작용을 위한 코드를 작성 할 때에는 코드의 실행시간이 얼마나 될 지를 항상 염두에 두어야 합니다.

작업 큐 (Task Queue)

모든 작업을 16ms안에 처리 할 수는 없습니다.

어떤 사건(event)이 일어날 때까지 기다리거나, 혹은 큰 데이터에 대한 계산이 완료 될 때까지 기다리는데에는 시간이 오래걸리기 마련입니다.

이런 경우, 브라우저에서는 다음과 같은 절차를 통해 오래 기다려야 하는 일을 처리할 수 있습니다.

  • 기다려야 하는 일을 자바스크립트 엔진에서 직접 처리하는 것이 아니라, API를 통해 브라우저에게 위임합니다. 이 때, 일이 끝나면 실행시킬 콜백을 같이 등록합니다.
  • 위임된 일이 끝나면, 그 결과와 콜백을 작업 큐(task queue)에 추가합니다.
  • 브라우저는 호출스택이 비워질때 마다 작업 큐에서 가장 오래된 작업을 꺼내와서 해당 작업에 대한 콜백을 실행 시킵니다.브라우저는 이 과정을 끊임없이 반복하는데, 이를 이벤트 루프(event loop)라고 부릅니다.

Call Stack → Web APIs → Task Queue → Call Stack (loop)

  • 각 작업은 큐에 쌓인 순서대로 진행됩니다.
  • 이미 작업 큐에 작업이 쌓여있다면, 뒤늦게 추가된 작업은 앞서 추가된 작업이 모두 실행된 다음에, 호출 스택이 비워진 다음에야 실행됩니다.
  • 호출 스택이 비워지지 않는다면, 작업 큐에 쌓여있는 작업을 처리 할 수 없습니다
  • 각 작업 사이에 브라우저는 화면을 새로 그릴 수 있습니다. 즉, 호출스택이 비워지지 않는다면 화면을 새로 그릴 수 없습니다.

앞서 지연시간으로 0을 넘겨준 setTimout 예제를 다시 한 번 보겠습니다. 지연시간을 0으로 주면, 브라우저는 setTimeout에 넘겨진 콜백을 바로 실행하는 것이 아니라 그 콜백을 작업 큐에 등록합니다. 호출 스택이 비워지면, 그제서야 작업 큐에 들어있는 콜백을 가져와서 실행시킵니다. 이 때문에 hello가 나중에 출력되는 것입니다.

setTimeout(() => {
  console.log('hello');
}, 0); // 작업 큐에 콜백이 추가됨

console.log('world');

비동기 프로그래밍 (Asynchronous Programming)

어떤 일이 완료되기를 기다리지 않고, 다음 코드를 실행해 나가는 프로그래밍 방식을 비동기 프로그래밍 이라고 합니다. 반대로 어떤일이 완료될 때까지 코드의 실행을 멈추고 기다리는 프로그래밍 방식을 동기식 프로그래밍 이라고 합니다.

브라우저에서 비동기 프로그래밍은 주로 통신과 같이 오래걸리는 작업들을 브라우저에게 위임할 때 이루어집니다.

비동기 프로그래밍 방식은 대개 프로그램의 성능과 응답성을 높이는 데에 도움을 줍니다. 하지만 코드가 실제로 실행되는 순서가 뒤죽박죽이 되므로, 코드의 가독성을 해치고 디버깅을 어렵게 만든다는 비판을 받아왔습니다. 이런 문제를 해결하기 위해 비동기 프로그래밍을 위한 여러 기법이 생겨났고, 또 어떤 것들은 JavaScript 언어 자체에 포함되기도 했습니다. 여기에서는 근래 JavaScript 생태계에서 자주 사용되는 몇 가지 비동기 프로그래밍 기법들을 살펴 보겠습니다.

콜백 (Callback)

다른 함수의 인수로 넘기는 함수. 이 콜백으로 비동기 프로그래밍을 할 수 있습니다.

const $ = require('jquery');
const API_URL = 'https://api.github.com/repos/facebookincubator/create-react-app/issues?per_page=10';

$.ajaxSetup({
  dataType: 'json'
});

$.get(API_URL, issues => {
  console.log('최근 10개의 이슈:');
  issues
    .map(issue => issue.title)
    .forEach(title => console.log(title));
  console.log('출력이 끝났습니다.');
});

console.log('받아오는 중...');

위 예제에서 $.get 메소드의 두 번째 인수로 콜백을 넘겨주었습니다. $.get 메소드는 비동기식으로 동작하며, Github API 서버와 통신하는 일을 브라우저에 위임한 후 바로 종료됩니다. 통신이 끝나면, 그 결과를 첫 번째 인수로 해서 콜백을 호출하게 됩니다.

여기서 주의할 것이 있습니다. 콜백을 인수로 받는 함수가 항상 비동기식으로 동작하는 것은 아닙니다. 위 예제의 mapforEach의 인수로 넘겨준 것 역시 콜백이지만, 이 때에는 콜백이 동기식으로 호출됩니다.

콜백은 JavaScript가 고차함수를 잘 지원한다는 특징 때문에 가장 많이 사용되는 비동기 프로그래밍 양식이었습니다. 하지만 콜백만으로는 복잡한 비동기 데이터 흐름를 표현하기가 어려워서 많은 프로그래머들이 힘들어했고, 결국 콜백 지옥(callback hell)이라는 용어까지 생겨났습니다.

Promise

위에서 설명한 콜백의 문제를 해결하기 위해 여러 라이브러리들이 등장했고, 그 중에서 개발자들에게 널리 선택받은 것이 바로 Promise 패턴을 사용한 라이브러리들(jQuery DefferedQBluebird)이었습니다. 이 라이브러리들이 표준화되어, 결국 ES2015에 이르러 JavaScript 언어 자체에 포함되게 되었습니다.

Promise는 '언젠가 끝나는 작업'의 결과값을 담는 통과 같은 객체입니다. Promise 객체가 만들어지는 시점에는 그 통 안에 무엇이 들어갈지 모를 수도 있습니다. 대신 then 메소드를 통해 콜백을 등록해서, 작업이 끝났을 때 결과값을 가지고 추가 작업을 할 수 있습니다.

Promise 객체를 생성하는 가장 쉬운 방법은 Promise.resolve 정적 메소드를 사용하는 것입니다.

const p = Promise.resolve(1);

또한, then 메소드에 넘겨준 콜백에서 Promise 객체를 반환하면, then 메소드가 반환한 Promise 객체는 앞의 Promise 객체의 결과를 따르게 됩니다. 아래 예제를 직접 실행하고, 어떻게 출력이 되는지 확인해보세요.

// Promise 객체를 반환하는 함수
function delay(ms) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`${ms} 밀리초가 지났습니다.`);
      resolve();
    }, ms);
  });
}

delay(1000)
  .then(() => delay(2000))
  .then(() => Promise.resolve('끝'))
  .then(console.log);

console.log('시작');

비동기 함수 (Async function)

Promise를 사용하는 비동기 프로그래밍 방식은 이전의 방식과 비교하면 여러 가지 장점을 갖지만, 여전히 콜백을 사용한다는 점 때문에 '불편하다', '가독성이 좋지 않다'는 비판을 받아왔습니다.

ES2017에서 도입된 비동기 함수(async function)를 사용하면, 동기식 코드와 거의 같은 구조를 갖는 비동기식 코드를 짤 수 있습니다.

함수 앞에 async 키워드를 붙이면, 이 함수는 비동기 함수가 됩니다.

// 비동기 함수
async function func1() {
  // ...
}

// 비동기 화살표 함수
const func2 = async () => {
  // ...
}

// 비동기 메소드
class MyClass {
  async myMethod() {
    // ...
  }
}

비동기 함수는 항상 Promise 객체를 반환한다는 특징을 갖습니다. 이 Promise의 결과값은 비동기 함수 내에서 무엇을 반환하느냐에 따라 결정되며, then 메소드와 똑같은 방식으로 동작합니다.

async function func1() {
  return 1;
}

async function func2() {
  return Promise.resolve(2);
}

func1().then(console.log); // 1
func2().then(console.log); // 2

또 하나의 중요한 특징은 비동기 함수 내에서 await 키워드를 쓸 수 있다는 것입니다. await는 Promise의 then 메소드와 유사한 기능을 하는데, await 키워드 뒤에 오는 Promise가 결과값을 가질 때까지 비동기 함수의 실행을 중단시킵니다. 여기서의 '중단'은 비동기식이며, 브라우저는 Promise가 완료될 때까지 다른 작업을 처리할 수 있습니다.

await는 연산자이기도 하며, await 연산의 결과값은 뒤에 오는 Promise 객체의 결과값이 됩니다.

// Promise 객체를 반환하는 함수.
function delay(ms) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`${ms} 밀리초가 지났습니다.`);
      resolve()
    }, ms);
  });
}

async function main() {
  await delay(1000);
  await delay(2000);
  const result = await Promise.resolve('끝');
  console.log(result);
}

main();

비동기 함수의 가장 큰 장점은 동기식 코드를 짜듯이 비동기식 코드를 짤 수 있다는 것입니다.

Generator

generator 함수는 '함수를 잠시 멈춰둘 수 있다'는 특징을 갖고 있습니다.

const co = require('co');
const axios = require('axios');
const API_URL = 'https://api.github.com';

function* fetchStarCount() {
  const starCount = {};

  // 1. Github에 공개되어있는 저장소 중, 언어가 JavaScript이고 별표를 가장 많이 받은 저장소를 불러온다.
  const topRepoRes = yield axios.get(`${API_URL}/search/repositories?q=language:javascript&sort=stars&per_page=1`);

  // 2. 위 저장소에 가장 많이 기여한 기여자 5명의 정보를 불러온다.
  const topMemberRes = yield axios.get(`${API_URL}/repos/${topRepoRes.data.items[0].full_name}/contributors?per_page=5`);

  // 3. 해당 기여자들이 최근에 Github에서 별표를 한 저장소를 각각 10개씩 불러온다.
  const ps = topMemberRes.data.map(user => axios.get(`${API_URL}/users/${user.login}/starred?per_page=10`));
  const starredReposRess = yield Promise.all(ps);
  const starredReposData = starredReposRess.map(r => r.data)

  // 4. 불러온 저장소를 모두 모아, 개수를 센 후 저장소의 이름을 개수와 함께 출력한다.
  for (let repoArr of starredReposData) {
    for (let repo of repoArr) {
      if (repo.full_name in starCount) {
        starCount[repo.full_name]++;
      } else {
        starCount[repo.full_name] = 1;
      }
    }
  }
  return starCount;
}

co(fetchStarCount).then(console.log);

다만 generator는 함수의 재개를 프로그래머가 직접 제어할 수 있다는 장점을 갖고 있기 때문에, 일부러 비동기 함수 대신 generator를 사용하는 경우도 있습니다. React에서 비동기 프로그래밍을 하기 위해 널리 사용되는 라이브러리인 redux-saga 역시 generator를 활용하고 있습니다.

← Go home