Callback Hell 벗어나서 비동기 코드 깔끔하게 처리하기
비동기 코드의 가독성과 유지보수성을 높여요
Table Of Contents
TL;DR
- Callback Hell은 비동기 작업을 순차적으로 처리하다 보면 콜백이 깊게 중첩되어 코드가 오른쪽으로 길게 늘어나는 구조예요.
- 코드 흐름을 따라가기 어렵고, 디버깅할 때 에러 위치를 찾기 힘들며, 에러 처리가 반복되고, 코드를 수정하기 어려워요.
- Promise는 콜백을 체인으로 연결해서 가독성을 높이고, async/await는 비동기 코드를 동기 코드처럼 작성할 수 있게 해줘요.
들어가기
(출처: https://jst.hashnode.dev/callback-hell)
코딩을 하다 보면, 함수가 계속 중첩되어 코드가 오른쪽으로 길게 늘어나는 경험을 해볼 수 있어요.
예를 들어서, 파일을 읽고, 그 결과를 처리하고, 처리된 데이터를 서버에 전송하고, 서버 응답을 다시 파일로 저장하는 식으로 비동기 작업이 이어지다 보면 함수 안에 함수가, 다시 그 안에 함수가 들어가는 구조가 만들어져요.
이런 구조를 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은 단순히 코드가 보기 안 좋기 때문에 피하는 게 아니에요. 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 벗어나기
같은 기능을 세 가지 방식으로 구현한 예시를 통해 차이를 살펴볼게요.
// 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로 에러를 한 곳에서 처리할 수 있어서, 여러 단계에서 에러가 발생할 수 있는 복잡한 로직에 적합해요.