콜백 지옥에서 Async-Await까지: 비동기 JavaScript 마스터하기
Emily Parker
Product Engineer · Leapcell

소개
JavaScript가 단순한 클라이언트 측 스크립팅 언어에서 복잡한 웹 애플리케이션과 강력한 서버 측 인프라를 구동할 수 있는 다재다능한 강국으로 발전함에 따라, 효과적인 비동기 프로그래밍의 필요성이 매우 중요해졌습니다. 서버에서 데이터를 가져오거나, 파일을 읽거나, 사용자 입력을 처리하는 것과 같은 작업들은 즉시 발생하지 않습니다. 이러한 "오래 실행되는" 작업들이 메인 스레드를 차단한다면, 우리의 애플리케이션은 멈춰버려 사용자에게 좌절스러운 경험을 제공할 것입니다. 수년 동안 이러한 비동기 작업을 처리하는 주요 메커니즘은 콜백이었습니다. 기능적이긴 했지만, 광범위한 중첩 콜백 사용은 개발자들이 애정(또는 고통스럽게) "콜백 지옥"이라고 부르는 것으로 자주 이어졌습니다. 이는 깊숙이 들여쓰기된, 읽기 어렵고 관리하기 어려운 코드 덩어리였습니다. 이 글은 이 문제를 해결하는 여정을 추적하며, Promises와 우아한 async/await 구문이 어떻게 강력한 솔루션으로 등장하여 비동기 JavaScript를 작성하고 이해하는 방식을 변화시켰는지 보여줄 것입니다.
비동기 JavaScript의 진화 이해하기
솔루션에 대해 자세히 알아보기 전에, 이 논의를 정의하는 핵심 개념에 대한 공통된 이해를 구축해 보겠습니다.
핵심 용어
- 비동기 JavaScript: 메인 프로그램 스레드의 실행을 차단하지 않고 "백그라운드"에서 실행될 수 있는 코드입니다. 비동기 작업이 완료되면, 종종 미리 정의된 함수(콜백)를 실행하여 완료를 신호합니다.
- 콜백 함수: 다른 함수의 인수로 전달되는 함수로, 그런 다음 루틴이나 작업을 완료하기 위해 외부 함수 내에서 호출됩니다. 비동기 프로그래밍에서 콜백은 종종 비동기 작업이 완료된 후에 실행됩니다.
- 콜백 지옥 (또는 피라미드의 죽음): 여러 개의 중첩된 비동기 함수가 각각 이전 함수의 완료에 의존할 때 발생하는 현상으로, 과도한 들여쓰기와 복잡한 제어 흐름으로 인해 코드를 읽고 이해하고 디버그하기 어렵게 만듭니다.
- Promise: 비동기 작업의 최종 완료(또는 실패)와 그 결과 값을 나타내는 객체입니다. Promise는 pending, fulfilled(성공), 또는 rejected(실패)의 세 가지 상태 중 하나일 수 있습니다. Promise는
.then()및.catch()메서드를 연결함으로써 비동기 작업을 처리하는 더 깔끔한 방법을 제공합니다. - Async/Await: Promise 위에 구축된 구문 설탕으로, 비동기 코드가 동기 코드처럼 보이게 하고 작동하게 합니다.
async키워드는 비동기 함수를 나타내고,await키워드는 Promise가 안정될 때까지(해결되거나 거부될 때까지)async함수의 실행을 일시 중지한 다음, Promise의 해결된 값으로 실행을 재개합니다.
콜백 지옥 경험
고전적인 "콜백 지옥" 시나리오를 예시로 설명하면서 문제를 시작해 보겠습니다. 사용자 데이터를 가져온 다음, 해당 게시물을 가져오고, 마지막으로 특정 게시물에 대한 댓글을 가져오는 세 가지 순차적인 비동기 작업을 수행해야 한다고 가정해 보겠습니다.
// --- 콜백 지옥 시나리오 --- function fetchUserData(userId, callback) { setTimeout(() => { console.log(`Fetching user data for UserId: ${userId}`); const userData = { id: userId, name: "Alice" }; callback(null, userData); // err, data }, 1000); } function fetchUserPosts(userId, callback) { setTimeout(() => { console.log(`Fetching posts for UserId: ${userId}`); const posts = [ { id: 101, title: "Post One", userId: userId }, { id: 102, title: "Post Two", userId: userId } ]; callback(null, posts); }, 800); } function fetchPostComments(postId, callback) { setTimeout(() => { console.log(`Fetching comments for PostId: ${postId}`); const comments = [ { id: 201, text: "Great post!", postId: postId }, { id: 202, text: "Very insightful.", postId: postId } ]; callback(null, comments); }, 700); } // 시퀀스 실행 fetchUserData(123, (error, userData) => { if (error) { console.error("Error fetching user data:", error); return; } console.log("User Data:", userData); fetchUserPosts(userData.id, (error, userPosts) => { if (error) { console.error("Error fetching user posts:", error); return; } console.log("User Posts:", userPosts); if (userPosts.length > 0) { fetchPostComments(userPosts[0].id, (error, postComments) => { if (error) { console.error("Error fetching post comments:", error); return; } console.log("Comments for first post:", postComments); console.log("All data fetched successfully!"); }); } else { console.log("No posts found for this user."); } }); });
깊은 들여쓰기와 반복적인 오류 처리에 주목하십시오. 더 많은 단계나 조건부 논리를 추가하면 이 코드를 관리하기 어려워질 것입니다. 이것이 콜백 지옥의 본질입니다.
Promise를 이용한 구출
Promise는 콜백이 많은 비동기 코드의 가독성과 구조 문제를 해결하기 위해 도입되었습니다. 비동기 작업을 처리하는 표준화된 방법을 제공하며, 연결 및 더 나은 오류 전파를 가능하게 합니다.
먼저, 콜백 기반 함수를 Promise를 반환하도록 리팩토링해 보겠습니다.
// --- Promise로 리팩토링 --- function fetchUserDataPromise(userId) { return new Promise((resolve, reject) => { setTimeout(() => { console.log(`[Promise] Fetching user data for UserId: ${userId}`); const userData = { id: userId, name: "Alice" }; // 가끔 오류 시뮬레이션 if (userId === 999) { reject("User not found!"); } else { resolve(userData); } }, 1000); }); } function fetchUserPostsPromise(userId) { return new Promise((resolve, reject) => { setTimeout(() => { console.log(`[Promise] Fetching posts for UserId: ${userId}`); const posts = [ { id: 101, title: "Post One", userId: userId }, { id: 102, title: "Post Two", userId: userId } ]; resolve(posts); }, 800); }); } function fetchPostCommentsPromise(postId) { return new Promise((resolve, reject) => { setTimeout(() => { console.log(`[Promise] Fetching comments for PostId: ${postId}`); const comments = [ { id: 201, text: "Great post!", postId: postId }, { id: 202, text: "Very insightful.", postId: postId } ]; resolve(comments); }, 700); }); } // Promise를 이용한 시퀀스 실행 fetchUserDataPromise(123) .then(userData => { console.log("[Promise] User Data:", userData); return fetchUserPostsPromise(userData.id); // 다음 Promise 연결 }) .then(userPosts => { console.log("[Promise] User Posts:", userPosts); if (userPosts.length > 0) { return fetchPostCommentsPromise(userPosts[0].id); } else { console.log("[Promise] No posts found for this user."); // 추가 연결을 막기 위해 빈 배열로 해결된 Promise를 반환하거나 오류를 발생시킬 수 있습니다. return Promise.resolve([]); } }) .then(postComments => { console.log("[Promise] Comments for first post:", postComments); console.log("[Promise] All data fetched successfully!"); }) .catch(error => { // 중앙 집중식 오류 처리 console.error("[Promise] An error occurred:", error); }); // 오류 예제 fetchUserDataPromise(999) .then(userData => { /* 실행되지 않습니다 */ }) .catch(error => { console.error("[Promise Error Example] An error occurred:", error); });
보시다시피 .then() 체인은 코드를 상당히 평탄화하고 .catch() 블록은 전체 비동기 시퀀스에 대한 깔끔한 오류 처리를 제공합니다. 이는 중첩된 콜백보다 상당한 개선입니다.
Async/Await의 우아함
async/await는 ES2017과 함께 도입되었으며, Promise 위에 더 동기적으로 보이는 구문을 제공합니다. 이를 통해 비동기 코드를 더욱 쉽게 읽고 쓸 수 있으며, 특히 순차적인 작업의 경우 더욱 그렇습니다.
// --- Async/Await 활용 --- // Promise를 반환하는 함수는 그대로 유지됩니다! // 위에서 fetchUserDataPromise, fetchUserPostsPromise, fetchPostCommentsPromise async function fetchDataWithAsyncAwait(userId) { try { console.log("\n[Async/Await] Starting data fetching..."); const userData = await fetchUserDataPromise(userId); console.log("[Async/Await] User Data:", userData); const userPosts = await fetchUserPostsPromise(userData.id); console.log("[Async/Await] User Posts:", userPosts); if (userPosts.length > 0) { const postComments = await fetchPostCommentsPromise(userPosts[0].id); console.log("[Async/Await] Comments for first post:", postComments); } else { console.log("[Async/Await] No posts found for this user."); } console.log("[Async/Await] All data fetched successfully!"); } catch (error) { // try-catch 블록은 동기 코드처럼 오류를 처리합니다. console.error("[Async/Await] An error occurred:", error); } } // Async/Await를 이용한 시퀀스 실행 fetchDataWithAsyncAwait(123); // 오류 예제 fetchDataWithAsyncAwait(999);
async/await 버전은 동기 코드와 매우 유사하게 보입니다. await 키워드는 본질적으로 async 함수의 실행을 자신이 기다리는 Promise가 해결될 때까지 일시 중지한 다음, 해결된 값을 언래핑합니다. 오류 처리는 표준 try...catch 블록으로 수행되어 매우 직관적입니다.
언제 무엇을 사용할까?
- 콜백: 복잡한 시퀀싱이나 오류 처리가 포함되지 않은 간단한 단일 비동기 작업에 주로 유용합니다. 깊이 중첩된 작업에는 피하십시오. 많은 오래된 Node.js API에서는 여전히 콜백을 사용합니다(예:
fs모듈). 하지만 이제는 Promise 기반 대안이 있는 경우가 많습니다. - Promise: 체인(
.then()) 및 중앙 집중식 오류 처리(.catch())가 필요한 작업에 탁월합니다. 비동기 흐름을 관리하기 위한 깔끔한 API를 제공하며async/await의 기반입니다. 여러 동시 Promise를 처리해야 할 때 이상적입니다(예:Promise.all(),Promise.race()). - Async/Await: 가독성과 유지보수성이 중요한 순차적인 비동기 작업에 선호되는 선택입니다. 비동기 코드를 동기 코드처럼 보이게 하고 느끼게 하여 인지 부하를 크게 줄입니다. Promise 위에 구축되었으므로 Promise를 이해하는 것이 여전히 중요합니다.
결론
콜백으로 가득 찬 JavaScript에서 async/await의 우아함으로의 여정은 비동기 작업을 관리하는 방식에서 중요한 발전을 나타냅니다. 콜백이 비차단 I/O의 개념을 도입하긴 했지만, 복잡한 시나리오에서의 한계는 "콜백 지옥"의 곤경을 초래했습니다. Promise는 비동기 작업을 관리하고 작업을 연결하기 위한 구조화된 API를 제공하여 코드 가독성과 오류 처리를 크게 개선했습니다. 마지막으로, async/await는 Promise 위에 구문 계층을 제공하여 동기적인 가독성과 오류 처리를 비동기 패러다임으로 가져왔습니다. Promise와 async/await를 채택하는 것은 현대적이고 유지보수 가능하며 강력한 JavaScript 애플리케이션을 작성하는 데 중요합니다. 이러한 도구를 이해하고 적용함으로써 개발자는 콜백 지옥의 깊은 곳에서 벗어나 강력하면서도 즐겁게 작업할 수 있는 비동기 코드를 작성할 수 있습니다.