JavaScript 비동기 처리
비동기 방식이란 무엇을 의미하며, 어떻게 처리하는 것일까요?
이번 글에서는 비동기란 무엇을 의미하며, 비동기 프로그래밍은 어떻게 이루어지는지에 대해 알아보겠습니다.
동기 vs 비동기
먼저 동기와 비동기의 차이점에 대해 간단하게 알아보겠습니다.

동기 (Synchronous)
동기란 시작된 하나의 작업이 끝날 때까지 다른 작업을 시작하지 않고 기다렸다가 다 끝나면 새로운 작업을 시작하는 방식입니다.
위 그림의 Synchronous와 같이 작업이 직렬로 배치되어 실행되며 작업 실행의 순서가 확실히 정해져 있는 것을 동기식 처리라 부릅니다.
이러한 동기식 방식은 실행 순서가 보장된다는 장점이 있지만, 앞선 태스크가 종료할 때까지 이후 태스크들이 블로킹되는 단점이 있습니다.
비동기 (Asynchronous)
비동기란 동기식 방식과는 다르게 먼저 시작된 작업의 완료 여부와는 상관없이 새로운 작업을 시작하는 방식입니다.
위 그림의 Asynchronous와 같이 작업이 병렬로 배치되어 실행되며 작업의 순서가 확실하지 않아 나중에 시작된 작업이 먼저 끝나는 경우도 발생합니다.
이러한 비동기식 방식은 현재 실행 중인 태스크가 종료되지 않은 상태라 하더라도 다음 태스크를 곧바로 실행합니다.
하지만 태스크 실행 순서가 보장되지 않습니다.
또한 비동기 함수는 전통적으로 콜백 패턴을 사용하는데 이는 콜백 지옥을 발생시켜 가독성을 나쁘게 하고, 비동기 처리 중 발생한 에러의 예외처리가 곤란하며, 여러 개의 비동기 처리를 한 번에 처리하는데 한계가 있습니다.
비동기 처리가 필요한 이유
그렇다면 비동기 처리가 왜 필요한 것일까요?
자바스크립트는 기본적으로 싱글쓰레드 방식으로 동작하기 때문에 한 번에 한 가지 일만 수행할 수 있습니다. 만약 시간이 오래 걸리는 작업을 동기적으로 처리한다면, 그 작업이 끝날 때까지 다른 모든 작업이 멈춰버리는 문제가 발생합니다. 이것이 바로 비동기 프로그래밍이 필요한 이유입니다.
비동기 처리를 사용하면 시간이 오래 걸리는 작업을 백그라운드에서 처리하고, 그 사이에 다른 작업을 계속 수행할 수 있습니다. 물론 싱글쓰레드이기 때문에 멀티쓰레드처럼 진짜 동시에 실행되는 것은 아니지만, 매우 빠른 속도로 작업을 전환하며 순차적으로 실행하기 때문에 사용자 입장에서는 동시에 실행되는 것처럼 느껴지게 됩니다.
과거에는 웹 페이지가 단순했기 때문에 동기적 프로그래밍만으로도 충분했습니다. 하지만 현대의 웹은 API 호출, 파일 업로드, 실시간 데이터 업데이트 등 다양한 작업을 동시에 처리해야 하기 때문에 비동기 프로그래밍이 필수가 되었습니다.
비동기 처리 방법
비동기 처리의 필요에 대해 알아봤으니, 비동기를 어떻게 처리할 수 있는지에 대해서 알아보겠습니다.
비동기 요청시에는 Callback 방식과 Promise 방식이 사용될 수 있습니다.
Callback 기반 처리 방법
먼저 callback 함수가 무엇인지 알아보겠습니다.
callback 함수는 아래와 같이 설명할 수 있습니다.
다른 함수의 인자로써 이용되는 함수. 어떤 이벤트에 의해 호출되어지는 함수.
콜백함수란 다른 함수의 인자로써 이용되는 함수로 제어권도 함께 위임한 함수입니다. 위임받은 코드는 자체적인 내부 로직에 의해 콜백함수를 적절히 실행합니다. callback 함수는 비동기 처리시에 사용되며, 콜백지옥에 빠지면 들여쓰기 수준이 높아지며 가독성이 떨어진다는 특징이 있습니다.
callback 함수의 예로는 자바스크립트에 있는 setTimeout 함수를 예로 들 수 있습니다. setTimeout 함수는 두 개의 매개 변수를 받는데, 첫번째는 실행할 작업 내용을 담은 콜백 함수이고, 두 번째는 이 콜백 함수를 수행하기 전에 기다리는 밀리초 단위 시간입니다. 즉, setTimeout 함수는 두번째 인자로 들어온 시간만큼 기다린 후에 첫 번째 인자로 들어온 콜백 함수를 실행합니다.
아래 코드의 실행 결과는 "one", "two", "waited 1 sec."가 출력됩니다.
function async(callback) {
callback('one');
setTimeout(() => {
callback('waited 1 sec.');
}, 1000);
callback('two');
}
async(function (msg) {
console.log(msg);
});
이렇게 어떠한 비동기 로직이 완료되었을 때 callback 함수를 실행시킴으로써 callback에서 작성한 어떠한 행동을 실행할 수 있게 됩니다.
하지만 이러한 callback 함수의 단점으로는 아래 코드의 예와 같이 비동기 로직의 결과를 다음 비동기로 전달해 실행해야 할 때, 가독성이 매우 안 좋아지고 코드 작성도 힘들어진다는 점이 있습니다. 이를 콜백지옥이라고 부릅니다.
function async(result, callback) {
setTimeout(() => {
callback(result, function (result) {
console.log(result);
});
}, 1000);
}
async(0, function (res, callback) {
callback(res);
async(res + 1, function (res, callback) {
callback(res);
async(res + 1, function (res, callback) {
callback(res);
});
});
});
Promise 기반 처리 방법
위에서 살펴본 콜백지옥에서 벗어나고자 Promise 방법을 사용할 수 있습니다.
Promise 방법을 통해서는 아래와 같이 비동기 처리를 할 수 있습니다. Promise 객체는 new 키워드와 함수를 인자로 받는 생성자를 통해서 생성할 수 있으며, 여기서 인자로 받는 함수는 resolve와 reject라는 2개의 파라미터를 가집니다.
아래 코드의 실행결과 "waited 1 sec."가 출력됩니다.
function async(key) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (key === true) resolve('waited 1 sec.');
else reject(new Error('Error!'));
}, 1000);
});
}
async(true)
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error);
});
그렇다면 위에서 callback 함수를 사용할 때 확인해본 2번째 예시를 Promise를 사용해 바꿔보겠습니다.
function async(result) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(result);
resolve(result);
}, 1000);
});
}
async(0)
.then((res) => {
return async(res + 1);
})
.then((res) => {
return async(res + 1);
});
Callback과 Promise의 차이점
Callback을 사용하면 비동기 로직의 결과값을 처리하기 위해서는 callback 안에서만 처리를 해야하고 콜백 밖에서는 비동기에서 온 값을 알 수가 없습니다. 따라서 비동기 로직의 결과를 다음 비동기로 전달해서 실행해야 할 때 콜백은 점점 깊어져서 가독성이 매우 나쁘게 됩니다.
하지만 Promise를 사용하면 비동기에서 온 값이 Promise 객체에 저장되기 때문에 코드 작성이 용이합니다.
Promise는 .then 메소드를 통해서 저장되어 있는 값을 원하는 때에 사용할 수 있으며 이를 통해 코드의 깊이가 깊어지지 않고 이해하기 쉽습니다.
async/await
앞서 Promise 방식으로 비동기 처리하는 방법에 대해 알아보았습니다. Promise 방식의 경우 Callback 방식에 비해 코드 작성에 용이하며, then을 통해 코드의 깊이가 깊어지는 것을 막을 수 있다는 특징이 존재했습니다.
하지만 Promise의 경우에도 마찬가지로 then 지옥(여러 개의 .then이 나타날 수 있음)이 있을 수 있으며, Promise 방식의 경우에는 then()을 통해 입력 인수로 값을 받을 수 있지만 해당 값을 내부에서 처리해야 한다는 단점이 있습니다.
따라서 이러한 단점을 극복하고 조금 더 직관적으로 코드를 확인하고자 async/await가 나오게 되었습니다.
async와 await는 자바스크립트의 비동기 처리 패턴 중 가장 최근에 나온 문법으로, 기존의 비동기 처리 방식인 콜백 함수와 Promise의 단점을 보완하고 가독성이 좋은 코드를 작성할 수 있게 도와줍니다.
async/await의 기본 문법은 아래와 같습니다.
async function 함수명() {
await 비동기처리_함수명();
}
async function 함수명() {
const response = await 비동기처리_함수명();
}
아래의 코드를 통해 async/await에 대해 더 알아보겠습니다.
async function getUserName(id) {
const res = await fetch(`https://api.github.com/users/${id}`);
const { name } = await res.json();
return name;
}
위 과정을 살펴보면, await는 fetch로 넘긴 API 호출의 응답이 올 때까지 기다리다가 resolve한 결과를 res로 넘깁니다. 그 후 res를 json 형태로 변환하여 name 값을 받아오는 것을 알 수 있습니다.
만약 Promise로 사용하였다면, then을 활용하여 resolve, reject 관련 후속 메서드를 활용하는 등 여러 가지 처리를 통해서 해당 코드보다 훨씬 길어질 것입니다. 그에 반해 async/await는 짧게 처리될 수 있습니다.
async/await에서의 에러 처리
async/await의 경우, Callback 함수의 setTimeout의 경우에서 적용할 수 없었던 try, catch를 활용해서 일반 동기 코드에서처럼 에러 처리를 할 수 있습니다.
또한 async/await는 Promise 방식과는 달리 resolve와 reject 호출을 보장해줍니다. 그래서 async 함수 내에서 catch문을 사용하여 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject하는 프로미스를 반환합니다.
async와 await의 에러 처리하는 방식은 아래와 같습니다.
async function getUserName(id) {
try {
const res = await fetch(`https://api.github.com/users/${id}`);
const { name } = await res.json();
return name;
} catch (error) {
console.log(error);
}
}
마무리
자바스크립트의 비동기 처리는 Callback에서 시작해 Promise로 발전했고, 최종적으로 async/await 문법으로 더욱 간결하고 읽기 쉬운 코드를 작성할 수 있게 되었습니다.
Callback은 콜백 지옥이라는 가독성 문제가 있었고, Promise는 이를 체이닝으로 해결했지만 여전히 .then()이 반복되는 구조였습니다. 또, async/await는 비동기 코드를 마치 동기 코드처럼 작성할 수 있게 해주어 가독성을 향상시켰습니다.
하지만 async/await가 항상 최선의 선택은 아니라고 생각합니다. 여러 비동기 작업을 동시에 처리해야 할 때는 Promise.all()을 사용하는 것이 더 효율적일 수도 있고, 간단한 이벤트 핸들러에서는 Callback이 더 직관적일 수도 있다고 생각합니다.
따라서 각 방법의 특성을 이해하고 상황에 맞게 적절히 선택하는게 좋을 것 같습니다.