Callback Hell 벗어나서 비동기 코드 깔끔하게 처리하기
비동기 코드의 가독성과 유지보수성을 높여요
Table Of Contents
TL;DR
- Callback Hell은 비동기 작업을 순차적으로 처리하다 보면 콜백이 깊게 중첩되어 코드가 오른쪽으로 길게 늘어나는 구조예요.
- 코드 흐름을 따라가기 어렵고, 디버깅할 때 에러 위치를 찾기 힘들며, 에러 처리가 반복되고, 코드를 수정하기 어려워요.
- Promise는 콜백을 체인으로 연결해서 가독성을 높이고, async/await는 비동기 코드를 동기 코드처럼 작성할 수 있게 해줘요.
들어가기
이 글에서는 Callback Hell의 정의, 발생 이유, Promise와 async/await로 해결하는 방법을 다뤄요.
(출처: https://jst.hashnode.dev/callback-hell)
코딩을 하다 보면, 함수가 계속 중첩되어 코드가 오른쪽으로 길게 늘어나는 경험을 해볼 수 있어요.
예를 들어서, 파일을 읽고, 그 결과를 처리하고, 처리된 데이터를 서버에 전송하고, 서버 응답을 다시 파일로 저장하는 식으로 비동기 작업이 이어지다 보면 함수 안에 함수가, 다시 그 안에 함수가 들어가는 구조가 만들어져요.
이런 구조를 Callback Hell(콜백 지옥)이라고 불러요. Callback Hell은 코드의 가독성을 떨어뜨리고, 에러 처리를 어렵게 만들며, 유지보수를 힘들게 만들어요.
Callback이란
콜백(callback)은 비동기 작업이 완료되었을 때 호출되는 함수예요. 비동기 처리를 사용하면 파일 읽기나 네트워크 요청 같은 느린 작업을 기다리는 동안에도 다른 작업을 계속할 수 있어요. 하지만 여러 비동기 작업을 순차적으로 처리하다 보면, 콜백 안에 콜백이 들어가는 Callback Hell 구조가 만들어져요.
초기 프로그래밍에서는 작업을 순차적으로 처리하는 동기(synchronous) 방식이 당연하게 여겨졌어요. 동기 방식에서는 한 작업이 끝나야 다음 작업을 시작할 수 있어요. 예를 들어 여러 파일을 읽어야 한다면, 첫 번째 파일 읽기가 완전히 끝난 후에야 두 번째 파일 읽기를 시작할 수 있어요.
// 동기 방식의 여러 파일 읽기 const fileA = readFileSync("fileA.txt"); // 파일 A 읽기가 끝날 때까지 대기 const fileB = readFileSync("fileB.txt"); // A가 끝난 후 파일 B 읽기 시작 const fileC = readFileSync("fileC.txt"); // B가 끝난 후 파일 C 읽기 시작 // 총 소요 시간: fileA 시간 + fileB 시간 + fileC 시간
동기 방식은 코드가 위에서 아래로 읽히기 때문에 순서를 파악하기 쉬워요. 위 코드는 파일 A, B, C를 순서대로 읽는다는 걸 한눈에 보여줘요.
하지만 파일 읽기나 네트워크 요청같이 시간이 오래 걸리는 작업을 동기 방식으로 처리하면, 그 작업이 끝날 때까지 프로그램 전체가 멈춰버리는 문제가 생겨요.
예를 들어서 사용자가 버튼을 클릭했는데, 서버 요청이 끝날 때까지 화면이 완전히 멈춘다면 사용자 경험이 매우 나빠질 거예요.
이런 문제를 해결하기 위해 비동기(asynchronous) 처리 방식이 등장했어요. 비동기 방식에서는 작업을 시작한 후, 그 작업이 끝나기를 기다리지 않고 다른 작업을 계속 진행할 수 있어요.
예를 들어 여러 파일 읽기 작업을 비동기로 처리하면, 세 파일을 동시에 읽을 수 있어요.
// 비동기 방식의 여러 파일 읽기 Promise.all([ readFile("fileA.txt"), // 파일 A, B, C를 동시에 읽기 시작 readFile("fileB.txt"), readFile("fileC.txt"), ]).then(([fileA, fileB, fileC]) => { // 모든 파일 읽기가 완료되면 실행 // 총 소요 시간: 가장 오래 걸리는 파일의 시간만큼 });
비동기 작업에는 종료 후 호출할 함수를 등록하고, 작업이 완료될 때 해당 함수를 호출해서 결과를 처리해요. 이렇게 작업 완료 시 호출되는 함수를 콜백(callback) 함수라고 불러요.
// 비동기 방식의 파일 처리 readFile("input.txt", (error, data) => { // 파일 읽기가 끝나면 이 콜백 함수가 호출돼요 if (error) { console.error("파일 읽기 실패:", error); return; } const processed = processData(data); writeFile("output.txt", processed, (error) => { // 파일 저장이 끝나면 이 콜백 함수가 호출돼요 if (error) { console.error("파일 저장 실패:", error); return; } console.log("처리 완료!"); }); }); // readFile 호출 후 즉시 다음 코드로 진행돼요 console.log("파일 읽기 시작됨");
비동기 방식을 사용하면 파일 읽기나 네트워크 요청 같은 느린 작업을 기다리는 동안에도 다른 작업을 계속할 수 있어요. 웹 서비스에서 서버 요청을 보낸 후에도 사용자가 화면을 계속 조작할 수 있는 것도 비동기 처리 덕분이에요.
웹 브라우저는 비동기 처리를 기본으로 해요. 사용자가 버튼을 클릭해서 API를 호출하는 동안에도 화면을 스크롤하거나 다른 버튼을 누를 수 있어요. 그래서 메인 스레드 하나로도 여러 작업을 동시에 하는 것처럼 보이게 할 수 있어요.
하지만 여러 비동기 작업을 순차적으로 처리해야 할 때는 문제가 생겨요. 첫 번째 작업이 끝나면 그 결과를 사용해서 두 번째 작업을 시작하고, 두 번째 작업이 끝나면 세 번째 작업을 시작하는 식으로 이어져야 하기 때문이에요.
이런 순차적 처리를 콜백으로 구현하면, 콜백 안에 콜백이 들어가는 구조가 만들어져요.
// 세 개의 비동기 작업을 순차적으로 처리 readFile("config.json", (error1, config) => { if (error1) { console.error("설정 파일 읽기 실패:", error1); return; } // 첫 번째 작업이 끝나면 두 번째 작업 시작 fetchData(config.apiUrl, (error2, data) => { if (error2) { console.error("데이터 가져오기 실패:", error2); return; } // 두 번째 작업이 끝나면 세 번째 작업 시작 processAndSave(data, (error3) => { if (error3) { console.error("처리 및 저장 실패:", error3); return; } console.log("모든 작업 완료!"); }); }); });
이렇게 콜백이 계속 중첩되면서 코드가 오른쪽으로 길게 늘어나는 현상이 바로 Callback Hell이에요. 코드가 피라미드처럼 보인다고 해서 Pyramid of Doom(파멸의 피라미드)이라고도 불러요.
Callback Hell이 나쁜 이유
Callback Hell을 피하는 이유는 보기 안 좋아서가 아니에요. 실제 제품 개발에서 여러 문제를 일으켜요.
코드 흐름을 따라가기 어려워요
콜백이 깊게 중첩되면 코드가 오른쪽으로 계속 들어가면서, 어떤 순서로 실행되는지 파악하기 어려워져요.
예: 사용자 대시보드를 로딩할 때요. 사용자 정보 → 알림 목록 → 최근 활동 → 추천 콘텐츠 순서로 데이터를 가져와야 해요.
// 4단계만 중첩되어도 코드가 오른쪽으로 깊게 들어가요 fetchUserProfile(userId, (error1, profile) => { if (error1) return showError("프로필을 불러올 수 없습니다"); fetchNotifications(userId, (error2, notifications) => { if (error2) return showError("알림을 불러올 수 없습니다"); fetchRecentActivity(userId, (error3, activities) => { if (error3) return showError("활동 내역을 불러올 수 없습니다"); fetchRecommendations(profile.interests, (error4, recommendations) => { if (error4) return showError("추천을 불러올 수 없습니다"); // 여기까지 와야 화면을 그릴 수 있어요 renderDashboard({ profile, notifications, activities, recommendations, }); }); }); }); });
코드를 위에서 아래로 읽다가, 계속 오른쪽으로 들어가야 해요.
실제 로직(renderDashboard)은 가장 깊은 곳에 숨어 있어서 찾기 어려워요.
디버깅할 때 에러 위치를 찾기 어려워요
콜백 방식에서는 에러가 발생해도 스택 트레이스에 원래 호출 경로가 남아있지 않아요.
// 콜백 방식: 스택 트레이스가 끊겨요 fetchUserProfile(userId, (error, profile) => { if (error) { console.error(error); // Error: Network request failed // at XMLHttpRequest.onError (...) // ❌ 누가 fetchUserProfile을 호출했는지 알 수 없어요 } });
각 콜백이 비동기로 실행되기 때문에, 에러가 발생한 시점에는 원래의 호출 맥락이 이미 사라져요. 버그를 찾으려면 6~7개의 중첩된 콜백을 하나씩 열어보면서 추적해야 해요.
에러 처리가 일관되지 않아요
콜백이 중첩되면 각 단계마다 에러를 처리해야 하는데, 이 과정이 반복되고 복잡해져요.
예를 들어 사용자가 파일을 업로드하는 기능을 만든다고 해볼게요. 파일 검증 → 서버 업로드 → 썸네일 생성 → DB 저장 순서로 이어지고, 각 단계마다 다른 에러가 날 수 있어요.
// 각 단계마다 다른 에러 처리가 필요해요 validateFile(file, (validationError) => { if (validationError) { // "파일 형식이 올바르지 않습니다"를 보여줘야 해요 showError("파일 형식 오류"); return; } uploadToServer(file, (uploadError, fileUrl) => { if (uploadError) { // "네트워크 오류가 발생했습니다"를 보여줘야 해요 showError("업로드 실패"); return; } generateThumbnail(fileUrl, (thumbnailError, thumbnail) => { if (thumbnailError) { // 썸네일은 실패해도 계속 진행할까요? 아니면 중단할까요? // 이런 결정이 콜백 안에 섞여요 } saveToDatabase(fileUrl, thumbnail, (dbError) => { if (dbError) { // DB 저장 실패 시 이미 업로드된 파일은 어떻게 처리해야 할까요? // 롤백 로직이 여러 콜백에 흩어져요 } }); }); }); });
각 에러마다 사용자에게 다른 메시지를 보여줘야 하는데, 에러 처리 로직이 여러 콜백에 흩어져 있어요. 또한 중간 단계가 실패했을 때 이전 단계를 롤백해야 한다면, 그 로직을 관리하기가 더 복잡해져요.
코드를 수정하기 어려워요
중첩된 콜백 구조는 나중에 기능을 추가하거나 수정할 때 구조 전체를 다시 짜야 할 수도 있어요.
위 파일 업로드에 "진행률 표시"를 넣는다면요. 각 콜백 안에 진행률 코드를 넣고, 콜백 체인 전체를 수정해야 해요.
또한 여러 명이 같은 코드를 동시에 수정하면 충돌이 발생하기 쉬워요. 한 사람이 중간 단계를 추가하면, 다른 사람이 작업하던 콜백 구조가 깨지기 때문이에요.
성능과 메모리 문제가 발생해요
비동기 작업을 순차적으로만 처리하면, 실제로는 병렬로 처리할 수 있는 작업도 기다리게 돼요. 예를 들어 여러 API를 호출해야 할 때, 하나씩 기다리면 전체 시간이 길어져요.
// 순차 처리 (느림) const user = await fetchUser(userId); // 100ms const posts = await fetchPosts(userId); // 100ms const comments = await fetchComments(userId); // 100ms // 총 300ms // 병렬 처리 (빠름) const [user, posts, comments] = await Promise.all([ fetchUser(userId), // 100ms fetchPosts(userId), // 100ms (동시 실행) fetchComments(userId), // 100ms (동시 실행) ]); // 총 100ms
Callback Hell 구조에서는 이런 최적화를 하기 어려워요.
중첩된 콜백은 클로저를 통해 외부 변수를 계속 참조하기 때문에, 메모리 누수가 발생할 수 있어요.
function loadUserData(userId) { // 큰 데이터를 로드해요 const largeDataSet = fetchLargeData(); // 10MB fetchUserProfile(userId, (error, profile) => { if (error) return; fetchUserPosts(userId, (error, posts) => { if (error) return; // 여기서 largeDataSet을 사용하지 않아도 // 클로저 때문에 메모리에 계속 남아있어요 renderUserPage(profile, posts); }); }); // largeDataSet은 // 모든 콜백이 완료될 때까지 메모리에 남아요 }
사용자가 페이지를 여러 번 이동할 때마다 loadUserData가 호출되면, 이전 데이터가 제대로 정리되지 않아서 메모리 사용량이 계속 증가해요.
Promise, async/await으로 Callback Hell 벗어나기
아래는 인증 → 프로필 조회 → 설정 검증 → 저장 순서를 콜백, Promise, async/await으로 각각 구현한 코드예요.
// 1. 콜백 방식 (Callback Hell) function updateUserSettings(userId, newSettings, callback) { authenticateUser(userId, (authError, token) => { if (authError) return callback(authError); fetchUserProfile(token, (profileError, profile) => { if (profileError) return callback(profileError); validateSettings(profile, newSettings, (validationError, validated) => { if (validationError) return callback(validationError); saveSettings(userId, validated, (saveError, result) => { if (saveError) return callback(saveError); callback(null, result); }); }); }); }); } // 2. Promise 방식 - 체인으로 연결 function updateUserSettings(userId, newSettings) { return authenticateUser(userId) .then((token) => fetchUserProfile(token)) .then((profile) => validateSettings(profile, newSettings)) .then((validated) => saveSettings(userId, validated)) .catch((error) => handleError(error)); } // 3. async/await 방식 - 동기 코드처럼 작성 async function updateUserSettings(userId, newSettings) { try { const token = await authenticateUser(userId); const profile = await fetchUserProfile(token); const validated = await validateSettings(profile, newSettings); return await saveSettings(userId, validated); } catch (error) { handleError(error); } }
Promise를 사용하면 중첩된 콜백을 체인으로 연결해서 코드가 위에서 아래로 읽히게 만들 수 있어요.
에러도 .catch()로 한 곳에서 처리할 수 있어요.
async/await를 사용하면 비동기 코드를 동기 코드처럼 작성할 수 있어요. 코드 흐름을 이해하기 가장 쉽고, try-catch로 에러 처리를 일관되게 할 수 있어요.
언제 어떤 방법을 선택할지
콜백을 선택하는 경우
기존 라이브러리가 콜백만 지원하면 그대로 쓰는 게 낫고, setTimeout처럼 한 번만 쓰는 작업은 Promise로 감쌀 필요 없어요.
// 간단한 일회성 작업 setTimeout(() => { console.log("3초 후 실행"); }, 3000);
Promise를 선택하는 경우
여러 비동기 작업을 동시에 실행하고 모든 결과를 기다려야 할 때는 Promise.all이 가장 명확해요.
// 3개 API를 동시에 호출하고 모두 완료될 때까지 기다려요 const [users, posts, comments] = await Promise.all([ fetchUsers(), fetchPosts(), fetchComments(), ]);
가장 먼저 끝난 결과만 필요하면 Promise.race를 쓰면 돼요.
async/await를 선택하는 경우
순차적으로 여러 단계가 이어지고, 각 단계 결과가 다음 단계에 필요하면 async/await가 가장 읽기 쉬워요.
// 각 단계의 결과가 다음 단계에 필요해요 async function processOrder(orderId) { const order = await fetchOrder(orderId); const payment = await processPayment(order.amount); const receipt = await generateReceipt(payment.id); return receipt; }
try-catch로 에러를 한 곳에서 처리할 수 있어서, 여러 단계에서 에러가 날 수 있는 복잡한 로직에 맞아요.
마치며
순차적인 비동기 작업이 늘어나면 콜백만으로는 흐름 파악과 에러 처리, 수정이 어려워져요. Promise 체인과 async/await를 쓰면 같은 로직을 위에서 아래로 읽기 쉽게 쓸 수 있고, 실무에서는 "이 구간은 병렬로 돌릴 수 있는지", "에러 시 어디서 멈출지"를 먼저 정한 뒤 방식만 골라 쓰면 돼요.