동기와 비동기

Stackoverflow 의 비유를 보자.

동기

영화티켓을 사려고 줄을 서고 있다. 당신은 앞의 사람들이 모두 티켓을 사기 전까지는 티켓을 살 수 없고 이 논리는 당신의 뒤에 있는 사람들에게도 적용된다.

비동기

당신은 레스토랑에 많은 사람들과 있다. 음식을 주문한다. 다른 사람들도 음식을 주문할 수 있지만, 그들이 당신의 음식이 요리되고 서빙될 때까지 기다릴 필요는 없다. 주방에서 요리사들이 계속해서 요리하고, 서빙하고 주문을 받기 때문이다. 사람들은 요리되자마자 음식을 받을 수 있다.

즉, 동기란 앞의 작업의 실행시간에 상관없이 끝날 때까지 기다리는 것이며 비동기란 앞의 작업이 오래 걸린다면 그걸 하는 동안 그 다음 작업을 수행하는 것이다.


비동기 함수

모두가 가장 잘 아는 대표적인 비동기 함수는 역시 Web API에서 제공하는 setTimeout() 이다.

console.log('first');
setTimeout(() => console.log('third'), 3000);
console.log('second');
first
second
third

짜여진 코드의 순서대로 동작하지 않고 그 다음인 second 부터 출력함을 알 수 있다. 하지만 여기서 setTimeout() 의 실행이 끝나고 1초 뒤에 콘솔에 출력하고 싶다면 어떻게 해야 할까?

console.log('first');
setTimeout(() => console.log('third'), 3000);
setTimeout(() => console.log('second'), 4000);

이런 식으로 시간을 계산해서 지정할 수 있다. 하지만 직관적이지 않기 때문에 로직이 복잡하게 되면 이해하기 힘들 수 있다. 따라서 이런 로직을 좀 더 명확하게 구현하는 방법을 알아보자.


1. 콜백함수

콜백함수를 연쇄적으로 활용하면 된다.

console.log('first');
setTimeout(() => {
  console.log('second');
  setTimeout(() => {
    console.log('third');
  }, 1000);
}, 3000);

순서가 보장되기는 하지만 콜백함수가 많아지게 되면 흔히 말하는 “콜백 지옥(Callback Hell)” 이 형성되기 때문에 가독성이 현저히 떨어진다.


2. Promise

Promise는 비동기 처리에 사용되는 객체로 미래에 완성될 작업에 대한 것을 표현한다. ES6(ES2015)부터 도입되었으며 then() 을 통해 작업이 완료되었을 때를 처리하고 catch() 를 통해 작업에서 에러가 발생한 경우를 처리한다.

const getPromise = () => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(xhr.status);
    xhr.send();
  });
};

getPromise()
  .then((result) => console.log(result))
  .catch((error) => console.log(error));

XHR 객체를 활용하여 Ajax 통신을 실행한 경우 비동기로 동작하기 때문에 그걸 Promise 객체로 만들어서 리턴한다. 리턴된 Promise 객체를 활용하여 성공/실패에 대한 처리를 한다. 여기서 resolve()then() 에 전달되는 함수를 가리키며 reject()catch() 에 전달되는 함수를 가리킨다.


3. Async / Await

ES8(ES2017)부터 도입된 키워드로 비동기적으로 생각하기가 낯선 우리들의 사고방식을 조금 더 직관적이고 편하게 만들어주는 방식이다. Promise 예시를 한번 바꿔보면 다음과 같다.

const getPromise = () => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(xhr.status);
    xhr.send();
  });
};

const execute = async () => {
  const result = await getPromise();
  console.log(result);
};
execute();

결과는 같으며 훨씬 직관적이라는 것을 알 수 있다. await 은 해당 작업을 기다리는 것이며 완료된 작업을 result 로 받아 console.log() 로 resolve 시키는 방식이다. 하지만 여기선 reject가 보이지 않는데, 예외처리를 Promise와는 다르게 try~catch 를 활용하기 때문이다. 에러 핸들링을 보기 위해선 새로운 코드를 보도록 하자.

const getPromise = () => {
  return new Promise((resolve, reject) => {
    reject();
  });
};

const execute = async () => {
  try {
    const result = await getPromise();
    console.log(result);
  } catch(error) {
    console.log('Wow error!!!!');
  }
};
Wow error!!!!!

getPromise() 내에서 바로 reject() 를 실행시키니 catch() 문의 코드가 실행되는 것을 볼 수 있다. 이로써 이 키워드들 또한 에러 핸들링을 할 수 있다는 것을 알았다.


마무리

이렇게 비동기 작업을 처리하는 방법들에 대해 배워봤는데, 상황에 따라서 각각 다른 방법을 사용하는 것이 맞다고 본다. 제일 편한 것은 async/await 이지만 ES8이기 때문에 지원하는 브라우저가 많지 않다. 따라서, 모든 방법들에 대해 알고는 있자.


참조