콜백 함수 — 동기 콜백과 비동기 콜백의 차이
콜백은 자바스크립트에서 가장 기본적인 비동기 패턴이자, 함수를 인자로 전달하는 고차 함수의 핵심 개념입니다. "콜백이 뭔가요?"라는 질문에 "다른 함수에 인자로 전달되어 나중에 호출되는 함수"라고 간결하게 답할 수 있어야 합니다.
콜백 함수란?
다른 함수에 인자로 전달되어, 특정 시점에 호출되는 함수입니다.
// greet이 콜백 함수
function greet(name) {
console.log(`안녕, ${name}!`);
}
function processUser(callback) {
const name = "정훈";
callback(name); // 콜백 호출
}
processUser(greet); // "안녕, 정훈!"
동기 콜백
즉시 실행되는 콜백입니다. 배열 메서드들이 대표적입니다.
// map의 콜백은 동기적으로 즉시 실행됨
const result = [1, 2, 3].map((n) => {
console.log(`처리 중: ${n}`);
return n * 2;
});
// "처리 중: 1"
// "처리 중: 2"
// "처리 중: 3"
console.log(result); // [2, 4, 6]
// forEach도 동기 콜백
[1, 2, 3].forEach((n) => console.log(n));
console.log("forEach 끝"); // forEach가 다 끝난 후 실행
// sort의 비교 함수도 동기 콜백
[3, 1, 2].sort((a, b) => a - b);
동기 콜백의 특징은 콜백이 완료된 후에 다음 코드가 실행된다는 점입니다.
비동기 콜백
나중에 실행되는 콜백입니다. 이벤트 핸들러, 타이머, API 호출 등에 사용됩니다.
// setTimeout — 지정 시간 후 실행
console.log("1");
setTimeout(() => {
console.log("2 — 비동기 콜백");
}, 1000);
console.log("3");
// 출력: "1" → "3" → "2 — 비동기 콜백" (1초 후)
// 이벤트 리스너 — 이벤트 발생 시 실행
button.addEventListener("click", () => {
console.log("클릭됨!"); // 언제 실행될지 모름
});
// 파일 읽기 (Node.js)
const fs = require("fs");
fs.readFile("data.txt", "utf-8", (err, data) => {
if (err) {
console.error("에러:", err);
return;
}
console.log("파일 내용:", data);
});
console.log("파일 읽기 요청 완료"); // 이게 먼저 출력됨
비동기 콜백은 ** 현재 실행 중인 코드가 모두 끝난 후** 이벤트 루프에 의해 실행됩니다.
에러 우선 콜백 (Error-First Callback)
Node.js에서 표준으로 사용되는 패턴입니다. 콜백의 첫 번째 인자가 에러입니다.
function fetchData(callback) {
// 비동기 작업 시뮬레이션
setTimeout(() => {
const error = Math.random() > 0.5 ? new Error("실패!") : null;
const data = error ? null : { id: 1, name: "정훈" };
callback(error, data); // 에러가 첫 번째 인자
}, 1000);
}
fetchData((err, data) => {
if (err) {
console.error("에러 발생:", err.message);
return;
}
console.log("성공:", data);
});
콜백 지옥 (Callback Hell)
비동기 콜백이 중첩되면 가독성이 크게 떨어집니다.
// 순차적으로 3개의 API를 호출해야 하는 상황
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
getShipping(details.shippingId, (err, shipping) => {
if (err) return handleError(err);
console.log("배송 정보:", shipping);
// 들여쓰기 지옥...
});
});
});
});
콜백 지옥의 문제점
- 가독성이 떨어짐 (오른쪽으로 끝없이 들여쓰기)
- 에러 처리가 반복적이고 누락되기 쉬움
- 디버깅이 어려움
- 병렬 실행이 복잡함
콜백 지옥 탈출 1: 함수 분리
function handleShipping(err, shipping) {
if (err) return handleError(err);
console.log("배송 정보:", shipping);
}
function handleDetails(err, details) {
if (err) return handleError(err);
getShipping(details.shippingId, handleShipping);
}
function handleOrders(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, handleDetails);
}
function handleUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);
중첩은 해소되지만, 흐름을 파악하려면 여러 함수를 봐야 하는 단점이 있습니다.
콜백 지옥 탈출 2: Promise
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetails(orders[0].id))
.then((details) => getShipping(details.shippingId))
.then((shipping) => console.log("배송 정보:", shipping))
.catch((err) => handleError(err));
콜백 지옥 탈출 3: async/await
async function getShippingInfo(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
const shipping = await getShipping(details.shippingId);
console.log("배송 정보:", shipping);
} catch (err) {
handleError(err);
}
}
콜백을 Promise로 변환
기존 콜백 기반 API를 Promise로 감싸는 패턴입니다.
// 수동 변환
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf-8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Node.js 내장 util.promisify 사용
const { promisify } = require("util");
const readFile = promisify(fs.readFile);
// 사용
const data = await readFile("data.txt", "utf-8");
콜백 패턴이 여전히 유용한 경우
// 1. 이벤트 리스너 — 여러 번 호출되므로 Promise보다 콜백이 적합
element.addEventListener("scroll", handleScroll);
// 2. 배열 메서드 — 동기 콜백
const filtered = data.filter(isValid);
// 3. 옵저버 패턴
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 가시 영역에 들어옴
}
});
});
** 기억하기 **: 동기 콜백은 즉시 실행되고(map, filter), 비동기 콜백은 나중에 실행됩니다(setTimeout, fetch). 콜백 지옥을 피하려면 Promise/async-await를 사용하되, 이벤트 리스너나 배열 메서드에서는 콜백이 여전히 자연스러운 선택입니다.
댓글 로딩 중...