JavaScript 비동기 처리 이해하기: Callback, Promise, async/await

JavaScript 비동기 처리 이해하기: Callback, Promise, async/await
인턴십을 진행하며 백엔드 어플리케이션을 개발하던 도중, 비동기 함수 호출에 대해 await 키워드가 누락된 곳을 보았고 해당 부분에서 버그가 발생하고 있음을 발견했다.
이러한 실수를 방지하기 위해 ESLint의 require-await 룰을 적용하였고, 이 과정 중에 no-return-await 이라는 룰 또한 존재한다는 것을 알게 되었다.
'return 문에 await 키워드를 사용하면 안되는 룰이 존재한다고? 비동기 함수의 응답 값은 Promise<T>의 형태이고 이 함수를 다른 비동기 함수에서 호출하고 await 없이 바로 반환하면 Promise<Promise<T>> 형태의 객체가 반환되는 것이 아닌가?' 하는 의문이 들었다.
이 질문에 대한 답을 하기 위해 JavaScript의 비동기 처리 메커니즘에 대해 깊이 있게 탐구해보았고, 이 글에서는 그 내용을 정리해보고자 한다.
목차
비동기 처리란?
비동기 처리란, 어떤 작업이 완료될 때까지 기다리지 않고 다음 작업을 계속 진행하는 방식을 의미한다. JavaScript는 싱글 스레드 언어로 한 번에 하나의 작업만 처리할 수 있다. 동기 방식의 경우 긴 작업이 실행되는 동안 다른 작업이 차단(block)될 수 있다.
예를 들어 서버에서 데이터를 받아오는 네트워크 요청(보통 DBMS와의 통신)이나 파일 입출력 작업 등은 시간이 오래 걸릴 수 있다. 이러한 작업들을 동기 방식으로 처리한다면, 그 결과를 기다리는 동안 사용자는 스크린이 멈춘 것처럼 느끼게 된다. 클릭, 스크롤 등의 사용자 인터랙션이 지연되고 브라우저가 고장난 듯한 경험을 하게 된다.
웹 서비스에서 사용자 경험(UX)은 매우 중요하다. 사용자는 빠르고 반응성이 좋은 인터페이스를 기대하며, 느린 시스템에 대해 관대하고 자비로운 마음으로 가지고 기다려주거나 응원해주지 않는다. 사용자는 즉시 이탈하여 경쟁 서비스로 이동할 가능성이 높다. 비동기 처리는 시간이 오래 걸리는 작업을 백그라운드에 잠시 맡겨두고 다른 작업을 계속 진행할 수 있게 해준다. 이렇게 하면 애플리케이션이 멈추지 않고 계속 반응할 수 있어 사용자 경험이 크게 향상된다.
JavaScript는 비동기 처리를 제공하기 위해 여러 메커니즘을 제공한다. 그 중 가장 기본적인 것이 Callback 함수이며, 이후 Promise와 async/await가 등장하면서 비동기 처리가 더욱 직관적이고 관리하기 쉬워졌다.
Callback 함수
비동기 처리가 처음 도입되었을 때, JavaScript는 Callback 함수를 사용하여 비동기 작업의 완료를 처리했다. Callback이란 다른 함수에 인자로 전달되어 특정 작업이 완료된 후 호출되는 함수이다.
예를 들어, setTimeout 함수는 일정 시간이 지난 후에 Callback 함수를 실행한다.
console.log("작업 시작");
setTimeout(() => {
console.log("비동기 작업 완료");
}, 2000);
console.log("다음 작업 진행");
비동기 작업이 완료되면 Callback 함수가 호출되어 결과를 처리하기 때문에 코드의 흐름이 막히지 않는다. 하지만 네트워크 통신 혹은 파일 입출력과 같은 작업을 위해 비동기 처리 방식을 사용해야하지만 비지니스 로직 상 동기적으로 여러 흐름을 제어해야할 때 Callback 함수를 중첩해서 사용해야하는 상황이 발생한다.
예를 들어 1. 사용자 정보를 받아오고 2. 해당 사용자의 포인트를 조회하고 3. 포인트를 기반으로 쿠폰을 발급하여 4. 쿠폰 발급 결과를 저장하는 작업이 있다고 가정해보자. 이를 Callback 함수만으로 처리한다면 다음과 같이 된다.
getUserInfo(
userId,
(user) => {
getUserPoints(
user,
(points) => {
issueCoupon(
points,
(coupon) => {
saveCoupon(
coupon,
(result) => {
console.log("쿠폰 저장 성공:", result);
},
(error) => {
console.error("쿠폰 저장 실패:", error);
},
);
},
(error) => {
console.error("쿠폰 발급 실패:", error);
},
);
},
(error) => {
console.error("포인트 조회 실패:", error);
},
);
},
(error) => {
console.error("사용자 정보 조회 실패:", error);
},
);
Indent가 깊어지고, 에러 핸들링 코드가 중복되어 코드의 가독성이 매우 떨어진다. 이를 흔히 'Callback Hell' 또는 'Pyramid of Doom'이라고 부른다.
이 문제를 해결하기 위해 등장한 것이 Promise이다.
Promise
앞서 보았듯이 Callback 함수는 간단한 비동기 작업에는 적합하지만, 복잡한 비동기 흐름을 관리하기에는 한계가 있다. 이러한 문제를 해결하기 위해 ES6(ECMAScript 2015)에서 Promise가 도입되었다.
Promise는 말 그대로 '약속'을 의미한다. 비동기 작업이 완료되었을 때 값을 반환하겠다는 약속이다. 비동기 작업이 언젠가는 완료된다는 약속을 표현하는 객체이며 작업 결과(성공 또는 실패)를 담아 후속 작업을 처리할 수 있게 해준다.
Promise는 다음과 같은 세 가지 상태(state)를 가진다:
- 대기(pending): 초기 상태, 비동기 작업이 아직 완료되지 않은 상태
- 이행(fulfilled): 비동기 작업이 성공적으로 완료된 상태
- 거부(rejected): 비동기 작업이 실패한 상태
Promise 객체는 then(), catch(), finally() 메서드를 제공하여 비동기 작업의 결과를 처리할 수 있다.
console.log("작업 시작");
new Promise((resolve, reject) => {
setTimeout(() => {
resolve("비동기 작업 완료");
}, 2000);
})
.then((message) => {
console.log(message);
})
.catch((error) => {
console.error("에러 발생:", error);
})
.finally(() => {
console.log("작업 종료");
});
console.log("다음 작업 진행");
then() 메서드는 Promise가 이행되었을 때 호출되며, catch() 메서드는 거부되었을 때 호출된다. finally() 메서드는 성공 여부와 상관없이 작업이 완료된 후에 호출된다.
Promise는 아래와 같은 장점이 있다.
- 가독성 향상: Callback 함수에 비해 코드가 더 직관적이고 읽기 쉽다.
- 에러 처리 용이:
catch()메서드를 통해 에러를 한 곳에서 처리할 수 있다. - 비동기 흐름 제어 개선:
Promise.all(),Promise.race()등을 통해 여러 순차, 병렬 비동기 작업을 효율적으로 관리할 수 있다. - 상태 기반 관리: Promise의 상태 변화를 더욱 명확하게 표현할 수 있다.
앞선 Callback Hell 문제를 Promise로 해결하면 다음과 같이 된다.
getUserInfo(userId)
.then((user) => getUserPoints(user))
.then((points) => issueCoupon(points))
.then((coupon) => saveCoupon(coupon))
.then((result) => {
console.log("쿠폰 저장 성공:", result);
})
.catch((error) => {
console.error("에러 발생:", error);
});
비로소 코드가 위에서 아래로 자연스러운 흐름을 따라 읽힐 수 있게 되었다.
하지만 여전히 then() 체이닝이 길어질 수 있고, 비동기 작업 간의 의존성이 복잡해질 경우 가독성이 떨어질 수 있다.
이러한 아쉬움을 해결하기 위해 등장한 다음 세대 비동기 처리 메커니즘이 async/await이다.
async/await
Promise는 Callback 함수의 단점을 상당히 많이 보완했지만, 여전히 then() 체이닝이 길어질 수 있고, 비동기 작업 간의 의존성이 복잡해질 경우 가독성이 떨어질 수 있다.
이런 아쉬움 속에 개발자들은 코드는 위에서 아래 방향으로 자연스럽게 읽히고, 에러는 try/catch 처럼 한 번에 처리되며, 반복문/조건문도 일반 코드처럼 자유롭게 사용될 수 있는 비동기 처리 방식을 원했다.
이 요구를 충족시키기 위해 ES2017(ECMAScript 2017)에서 async/await가 도입되었다. async/await는 Promise 기반의 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 문법적 설탕(syntactic sugar)이다.
async 키워드는 함수 선언 앞에 붙여 해당 함수가 비동기 함수임을 나타낸다. async 함수는 함수 안에서 어떤 값을 반환하더라도 자동으로 Promise로 감싸서 반환한다.
async function fetchData() {
return "데이터";
}
fetchData().then((data) => {
console.log(data); // "데이터"
});
겉으로 보기에는 단순히 literal 문자열을 반환하는 것 같지만, 실제 동작은 다음과 같다.
function fetchData() {
return Promise.resolve("데이터");
}
await 키워드는 async 함수 내부에서만 사용할 수 있으며, Promise가 이행될 때까지 함수의 실행을 일시 중지시킨다. await는 Promise가 이행되면 그 결과 값을 반환하고, 거부되면 에러를 throw한다.
async function getData() {
try {
const data = await fetchDataFromServer();
console.log("데이터 받음:", data);
} catch (error) {
console.error("에러 발생:", error);
}
}
비동기 처리 관점에서 보면 await는 해당 함수의 실행을 일시 중지시키며 다른 작업이 실행될 수 있도록 이벤트 루프에 제어를 넘긴다. Promise가 이행되면 다시 함수 실행이 재개된다. 즉, JavaScript의 싱글 스레드 특성을 유지한 채, 비동기 처리를 가능하게 해주는 장치이다.
헷갈리는 개념 정리
Promise 객체를 반환하는 async 함수
async 함수는 항상 Promise 객체를 반환한다. 함수 내부에서 명시적으로 Promise를 반환하지 않더라도, 반환 값은 자동으로 Promise.resolve()로 감싸진다.
그렇다면 Promise 객체를 반환하는 비동기 함수의 반환 타입은 어떻게 될까?
단순히 생각하면 Promise<T>를 반환하는 Promise 객체, Promise<Promise<T>> 형태가 될 것 같지만, JavaScript 엔진은 이러한 중첩된 Promise를 자동으로 평탄화(flatten)한다.
따라서 async 함수가 Promise<Promise<T>>를 반환하더라도 실제로는 Promise<T> 형태로 처리된다.
이 때문에 async 함수 내부에서 다른 비동기 함수를 호출하고 그 결과를 바로 반환할 때는 굳이 await 키워드를 사용하지 않아도 된다.
오히려 await를 사용하면 약간의 대기 시간이 추가될 수 있다. 하지만 await를 사용하면 코드의 의도가 명확해지고, 디버깅 시점에서 해당 Promise가 언제 이행되는지 쉽게 알 수 있다는 장점이 있다.
async function updateUser(userId, data) {
const user = await getUserInfo(userId); // await 사용
return saveUserInfo(user, data); // await 미사용
}
위 예시에서 getUserInfo 함수는 await를 사용하여 호출하였지만, saveUserInfo 함수는 await 없이 바로 반환하였다.
saveUserInfo 함수의 반환 값인 Promise를 자동으로 평탄화하기 때문에 별도로 await를 붙이지 않아도 문제 없이 처리된다.
async function updateUser(userId, data) {
const user = await getUserInfo(userId); // await 사용
return await saveUserInfo(user, data); // await 사용
}
위와 같이 saveUserInfo 함수 호출에 await를 추가할 수도 있다. 이는 코드의 의도를 명확하게 하고, 디버깅 시점에서 해당 Promise가 이행될 때까지 기다리도록 할 수 있다.
약간의 대기 시간이 추가될 수 있지만, 다른 여러 주요 성능 병목 요소에 비하면 미미한 수준이기에 디버깅 이점을 위해 사용하는 것이 권장되는 경우도 있다.
결론적으로, async 함수 내부에서 다른 비동기 함수를 호출하고 그 결과를 바로 반환할 때는 await 키워드를 사용하지 않아도 되며, 이는 no-return-await 룰이 권장하는 바이다.
async 함수 안에서 동기 작업만 이루어진다면?
JavaScript는 싱글 스레드 언어이기 때문에, 여러 작업을 비동기적으로 처리하기 위해 메인 스레드와 이벤트 루프(event loop)라는 개념을 사용한다. 메인 스레드는 실제로 코드를 실행하는 단일 스레드이며, 이벤트 루프는 비동기 작업이 완료되었는지 감지하고, 완료된 작업의 콜백 함수를 메인 스레드에서 실행할 수 있도록 스케줄링하는 역할을 한다.
비동기 작업 중에서 시간이 오래 걸리는 Macro task는 보통 다른 백그라운드 프로세스나 스레드에서 실행되며, 메인 스레드가 차단되지 않도록 한다. 반면, 비교적 짧은 시간에 완료되는 Micro task는 메인 스레드에서 직접 실행된다. 따라서 비동기 함수 안에 Micro task만 존재한다면, 그 작업들은 메인 스레드에서 바로 실행되기 때문에 async/await를 사용하더라도 사실상 동기 작업처럼 동작한다.
결국 함수 내부에서 순수하게 동기 작업만 이루어진다면 async 키워드를 붙이는 것은 불필요하다.
async function calculateSum(a, b) {
return a + b; // 동기 작업
}
위 코드에서 calculateSum은 동기 작업만 수행하므로 async를 붙일 필요가 없다. 이 경우 함수는 단순히 값을 반환할 뿐이며, 비동기 처리가 필요하지 않다.
function calculateSum(a, b) {
return a + b; // 동기 작업
}
위와 같이 async 키워드를 제거하여 동기 함수로 작성하는 것이 더 적절하다.
마치며
Promise의 Promise, 비동기 함수 속 동기 작업 등의 질문들로부터 출발하여 JavaScript의 비동기 처리 메커니즘에 대해 살펴보았다. JavaScript의 비동기 처리는 싱글 스레드 언어의 한계를 극복하고, 사용자 경험을 향상시키기 위한 핵심적인 개념이다. Callback 함수, Promise, async/await 등 다양한 메커니즘을 통해 비동기 작업을 효율적으로 관리할 수 있다. 각 메커니즘의 장단점을 이해하고 적절히 활용하는 것이 중요하다. 이를 통해 더 나은 코드 품질과 사용자 경험을 제공할 수 있을 것이다.