자바스크립트는 본질적으로 단일 스레드(Single-threaded) 기반 언어입니다. 이는 한 번에 하나의 작업만 처리할 수 있다는 의미입니다. 만약 동기적(Synchronous)으로 모든 코드가 실행된다면, 네트워크 요청이나 대용량 파일 처리와 같은 시간이 오래 걸리는 작업이 실행될 때 애플리케이션 전체가 멈춰버리는 '블로킹(Blocking)' 현상이 발생할 것입니다. 이는 사용자 경험에 치명적인 영향을 미칩니다. 이러한 문제를 해결하기 위해 자바스크립트는 비동기(Asynchronous) 처리 모델을 도입했으며, 이 모델은 웹 개발의 핵심적인 패러다임으로 자리 잡았습니다. 이 글에서는 자바스크립트 비동기 처리의 역사적 흐름을 따라가며 콜백(Callback) 패턴부터 프로미스(Promise), 그리고 현대적인 Async/Await 문법까지 각 방식의 동작 원리, 장단점, 그리고 실용적인 활용법을 심도 있게 탐구합니다.
1. 비동기 프로그래밍의 근간: 이벤트 루프와 실행 모델
자바스크립트의 비동기 동작을 제대로 이해하려면, 먼저 자바스크립트 런타임 환경이 코드를 어떻게 처리하는지 알아야 합니다. 핵심에는 호출 스택(Call Stack), 웹 API(Web APIs), 태스크 큐(Task Queue), 그리고 이 모든 것을 조율하는 이벤트 루프(Event Loop)가 있습니다.
- 호출 스택 (Call Stack): 현재 실행 중인 함수의 목록을 관리하는 LIFO(Last-In, First-Out) 구조의 자료구조입니다. 함수가 호출되면 스택에 추가(push)되고, 함수의 실행이 끝나면 스택에서 제거(pop)됩니다. 자바스크립트는 단 하나의 호출 스택을 가지므로, 스택이 비어있지 않으면 다른 작업을 처리할 수 없습니다.
- 웹 API (Web APIs): 브라우저에서 제공하는 비동기 기능들입니다.
setTimeout
,fetch
,DOM 이벤트 리스너
등이 여기에 속합니다. 이러한 API들은 호출 스택에서 즉시 실행되지 않고, 브라우저의 별도 스레드에서 처리됩니다. - 태스크 큐 (Task Queue) / 콜백 큐 (Callback Queue): 웹 API에서 처리된 비동기 작업의 콜백 함수들이 대기하는 FIFO(First-In, First-Out) 구조의 큐입니다. 예를 들어,
setTimeout(callback, 1000)
이 호출되면, 브라우저는 1초를 센 후callback
함수를 이 큐에 넣습니다. - 이벤트 루프 (Event Loop): 호출 스택과 태스크 큐를 지속적으로 감시하는 역할을 합니다. 이벤트 루프의 임무는 단 하나, "호출 스택이 비어있을 때, 태스크 큐에서 가장 오래된 작업을 가져와 호출 스택에 추가하는 것"입니다. 이 과정을 통해 비동기 작업이 순차적으로 실행될 수 있습니다.
이러한 구조 덕분에 자바스크립트는 단일 스레드임에도 불구하고 블로킹 없이 여러 작업을 효율적으로 처리할 수 있습니다. 예를 들어 fetch
요청을 보내면, 이 작업은 웹 API로 넘어가 백그라운드에서 처리되고, 자바스크립트 엔진은 즉시 다음 코드를 실행합니다. 네트워크 응답이 도착하면, 응답을 처리할 콜백 함수가 태스크 큐에 추가되고, 이벤트 루프는 적절한 시점에 이 함수를 호출 스택으로 옮겨 실행시킵니다. 이것이 바로 자바스크립트의 비동기 처리의 핵심 원리입니다.
2. 비동기 처리의 시작: 콜백(Callback) 패턴
콜백 함수는 자바스크립트 비동기 프로그래밍의 가장 원초적인 형태입니다. 콜백은 간단히 말해 '나중에 호출될 함수'로, 다른 함수의 인자로 전달되어 특정 작업이 완료된 후에 실행되는 함수를 의미합니다. 이는 이벤트 처리, 타이머 설정, 데이터 요청 등 다양한 비동기 상황에서 널리 사용되었습니다.
2.1. 콜백 패턴의 기본 구조
가장 고전적인 예시는 setTimeout
입니다. 특정 시간 이후에 코드를 실행하고 싶을 때 사용합니다.
console.log('작업 시작');
setTimeout(function() {
console.log('1초 뒤에 실행되는 작업');
}, 1000);
console.log('작업 종료');
// 출력 순서:
// 작업 시작
// 작업 종료
// 1초 뒤에 실행되는 작업
위 코드에서 setTimeout
에 전달된 익명 함수가 바로 콜백 함수입니다. setTimeout
은 즉시 호출 스택에서 빠져나가고, 웹 API가 타이머를 작동시킵니다. 그동안 자바스크립트는 다음 코드인 console.log('작업 종료')
를 실행합니다. 1초가 지나면 타이머가 완료되고, 콜백 함수는 태스크 큐로 이동했다가 이벤트 루프에 의해 호출 스택으로 옮겨져 실행됩니다.
서버에서 데이터를 가져오는 가상의 함수를 콜백 패턴으로 구현하면 다음과 같습니다.
function getUser(id, callback) {
console.log(`사용자 ID ${id} 조회 중...`);
// 네트워크 요청을 시뮬레이션하기 위해 setTimeout 사용
setTimeout(() => {
if (id === 1) {
const user = { id: 1, name: 'John Doe' };
callback(null, user); // 성공: 첫 번째 인자는 에러(null), 두 번째는 결과
} else {
callback(new Error('사용자를 찾을 수 없습니다.'), null); // 실패: 첫 번째 인자에 에러 객체 전달
}
}, 1500);
}
// 함수 사용
getUser(1, (error, user) => {
if (error) {
console.error('오류 발생:', error.message);
} else {
console.log('사용자 정보:', user);
}
});
getUser(2, (error, user) => {
if (error) {
console.error('오류 발생:', error.message);
} else {
console.log('사용자 정보:', user);
}
});
Node.js에서는 이러한 '에러 우선 콜백(Error-first Callback)' 스타일이 표준처럼 사용되었습니다. 콜백 함수의 첫 번째 인자는 항상 에러 객체를 받고, 에러가 없으면 `null`을 전달하는 방식입니다. 이를 통해 개발자는 콜백 함수 내부에서 `if (error)` 구문을 통해 에러 처리를 먼저 수행할 수 있었습니다.
2.2. 콜백 지옥 (Callback Hell)과 그 문제점
콜백 패턴은 단순한 비동기 작업을 처리하는 데는 효과적이지만, 여러 개의 비동기 작업이 순차적으로 의존성을 가질 때 심각한 문제를 드러냅니다. 예를 들어, 사용자 정보를 가져오고, 그 정보로 사용자의 게시글 목록을 가져오고, 첫 번째 게시글의 댓글을 가져오는 시나리오를 생각해 봅시다.
// 가상의 데이터베이스 조회 함수들
function getUser(id, callback) { /* ... */ }
function getPosts(userId, callback) { /* ... */ }
function getComments(postId, callback) { /* ... */ }
// 비동기 작업의 중첩
getUser(1, (err, user) => {
if (err) {
console.error('사용자 조회 오류:', err);
return;
}
console.log('사용자:', user.name);
getPosts(user.id, (err, posts) => {
if (err) {
console.error('게시글 조회 오류:', err);
return;
}
console.log('게시글 수:', posts.length);
const firstPost = posts[0];
getComments(firstPost.id, (err, comments) => {
if (err) {
console.error('댓글 조회 오류:', err);
return;
}
console.log('첫 게시글의 댓글 수:', comments.length);
// 또 다른 비동기 작업이 필요하다면...
// getLikes(comments[0].id, (err, likes) => {
// ... 계속 깊어짐
// });
});
});
});
위 코드처럼 콜백 함수가 계속해서 중첩되는 구조는 코드의 가독성을 심각하게 해칩니다. 들여쓰기가 깊어지며 옆으로 길어지는 모양 때문에 이를 콜백 지옥(Callback Hell) 또는 멸망의 피라미드(Pyramid of Doom)라고 부릅니다. 콜백 지옥은 다음과 같은 구체적인 문제점을 야기합니다.
- 가독성 저하: 코드의 논리적 흐름을 파악하기가 매우 어렵습니다. 각 단계가 어디서 시작하고 끝나는지 추적하기 힘듭니다.
- 에러 처리의 복잡성: 각 콜백 단계마다 동일한 `if (err)` 블록을 반복적으로 작성해야 합니다. 이는 코드를 장황하게 만들고, 에러 처리를 누락할 가능성을 높입니다. 중앙 집중식 에러 처리가 불가능합니다.
- 제어 흐름의 어려움: 조건부 분기나 반복문과 같은 제어 흐름을 비동기 작업과 결합하기가 매우 까다롭습니다. 예를 들어, 여러 게시글의 댓글을 병렬로 가져온 후 모든 작업이 완료되었을 때 특정 동작을 수행하는 로직을 구현하기가 복잡합니다.
이러한 문제들은 더 크고 복잡한 애플리케이션을 개발하는 데 큰 걸림돌이 되었고, 자바스크립트 커뮤니티는 더 나은 비동기 처리 방식을 모색하게 되었습니다.
3. 새로운 희망: 프로미스(Promise)의 등장
콜백 지옥의 문제를 해결하기 위해 ES6(ECMAScript 2015) 표준에 프로미스(Promise)가 도입되었습니다. 프로미스는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 이름 그대로, 당장은 결과를 알 수 없지만 '결과를 언젠가 알려주겠다'는 약속과 같습니다.
3.1. 프로미스의 세 가지 상태
프로미스는 항상 다음 세 가지 상태 중 하나를 가집니다.
- 대기 (Pending): 초기 상태. 비동기 작업이 아직 완료되지 않았습니다.
- 이행 (Fulfilled): 비동기 작업이 성공적으로 완료되었습니다. 결과 값을 가집니다.
- 거부 (Rejected): 비동기 작업이 실패했습니다. 실패 원인(에러)을 가집니다.
프로미스는 한 번 이행(fulfilled)되거나 거부(rejected)되면 그 상태가 변하지 않는(immutable) 특징을 가집니다. 즉, 대기 상태에서 이행 또는 거부 상태로만 변경될 수 있으며, 그 이후에는 다른 상태로 바뀔 수 없습니다. 이를 'settled' 상태라고 합니다.
3.2. 프로미스 생성과 사용
프로미스는 `new Promise()` 생성자를 통해 만듭니다. 생성자는 두 개의 함수(`resolve`, `reject`)를 인자로 받는 실행 함수(executor)를 받습니다.
function getUserPromise(id) {
return new Promise((resolve, reject) => {
console.log(`사용자 ID ${id} 조회 중...`);
setTimeout(() => {
if (id === 1) {
const user = { id: 1, name: 'John Doe' };
resolve(user); // 성공 시 resolve 호출, user 객체를 결과로 전달
} else {
reject(new Error('사용자를 찾을 수 없습니다.')); // 실패 시 reject 호출, 에러 객체를 원인으로 전달
}
}, 1500);
});
}
생성된 프로미스는 .then()
, .catch()
, .finally()
메소드를 사용하여 결과를 처리합니다.
.then(onFulfilled, onRejected)
: 프로미스가 이행(fulfilled)되었을 때 `onFulfilled` 함수가, 거부(rejected)되었을 때 `onRejected` 함수가 호출됩니다. 보통 `onRejected`는 `catch`를 사용하므로 첫 번째 인자만 주로 사용합니다..catch(onRejected)
: 프로미스가 거부(rejected)되었을 때만 `onRejected` 함수가 호출됩니다. 에러 처리를 명시적으로 분리할 수 있어 가독성이 좋습니다..finally(onFinally)
: 프로미스의 성공/실패 여부와 관계없이 작업이 완료(settled)되면 항상 `onFinally` 함수가 호출됩니다. 로딩 스피너를 숨기는 등 마무리 작업에 유용합니다.
const userPromise = getUserPromise(1);
userPromise
.then(user => {
console.log('성공:', user);
})
.catch(error => {
console.error('실패:', error.message);
})
.finally(() => {
console.log('조회 작업 완료.');
});
3.3. 프로미스 체이닝(Promise Chaining)으로 콜백 지옥 탈출
프로미스의 가장 강력한 기능은 체이닝(Chaining)입니다. .then()
메소드는 새로운 프로미스를 반환하므로, 여러 개의 .then()
을 연결하여 비동기 작업을 순차적으로 실행할 수 있습니다. 이를 통해 콜백 지옥의 중첩 구조를 평평하게 펼칠 수 있습니다.
앞서 콜백 지옥 예제를 프로미스 체이닝으로 리팩토링해 보겠습니다. 먼저 각 함수가 프로미스를 반환하도록 수정합니다.
function getUser(id) {
return new Promise((resolve, reject) => { /* ... */ });
}
function getPosts(userId) {
return new Promise((resolve, reject) => { /* ... */ });
}
function getComments(postId) {
return new Promise((resolve, reject) => { /* ... */ });
}
이제 이 함수들을 체인으로 연결합니다.
getUser(1)
.then(user => {
console.log('사용자:', user.name);
return getPosts(user.id); // 다음 then으로 결과를 전달하기 위해 새로운 프로미스를 반환
})
.then(posts => {
console.log('게시글 수:', posts.length);
const firstPost = posts[0];
return getComments(firstPost.id); // 또 다른 프로미스 반환
})
.then(comments => {
console.log('첫 게시글의 댓글 수:', comments.length);
})
.catch(error => {
// 체인 중간 어디에서든 에러가 발생하면 여기서 잡힘
console.error('오류 발생:', error);
})
.finally(() => {
console.log('모든 데이터 조회 작업 완료.');
});
콜백 패턴과 비교했을 때, 코드가 위에서 아래로 순차적으로 읽히며 논리적 흐름을 파악하기 훨씬 쉬워졌습니다. 또한, .catch()
하나로 체인 전체의 에러를 한 곳에서 처리할 수 있어 에러 핸들링이 매우 간결하고 강력해졌습니다. 이것이 프로미스가 가져온 혁신입니다.
3.4. 여러 프로미스 동시 처리하기
프로미스는 여러 비동기 작업을 병렬로 처리하고 그 결과를 조합하는 데 유용한 정적 메소드들을 제공합니다.
Promise.all(iterable)
: 배열(또는 이터러블)에 담긴 모든 프로미스가 이행(fulfilled)될 때까지 기다렸다가, 모든 프로미스의 결과 값을 담은 배열을 반환하는 새로운 프로미스를 반환합니다. 만약 프로미스 중 하나라도 거부(rejected)되면, 즉시 그 에러를 담아 거부됩니다. 여러 API를 동시에 호출하고 모든 응답이 필요할 때 유용합니다.
const promise1 = Promise.resolve('첫 번째 성공');
const promise2 = new Promise(resolve => setTimeout(() => resolve('두 번째 성공'), 100));
const promise3 = fetch('https://api.example.com/data');
Promise.all([promise1, promise2, promise3])
.then(results => {
console.log(results); // ['첫 번째 성공', '두 번째 성공', Response 객체]
})
.catch(error => {
console.error('하나 이상의 프로미스가 실패했습니다:', error);
});
Promise.race(iterable)
: 배열에 담긴 프로미스 중 가장 먼저 완료(이행 또는 거부)되는 것의 결과/에러를 그대로 반환합니다. 여러 엔드포인트 중 가장 빠른 응답을 주는 곳의 데이터를 사용하거나, 타임아웃을 구현할 때 유용합니다.
const promiseA = new Promise(resolve => setTimeout(() => resolve('A가 승리'), 100));
const promiseB = new Promise(resolve => setTimeout(() => resolve('B가 승리'), 200));
Promise.race([promiseA, promiseB])
.then(winner => {
console.log(winner); // 'A가 승리'
});
Promise.allSettled(iterable)
: Promise.all
과 유사하지만, 프로미스 중 하나가 실패하더라도 멈추지 않고 모든 프로미스가 완료(settled)될 때까지 기다립니다. 결과는 각 프로미스의 상태(status
: 'fulfilled' 또는 'rejected')와 값(value
) 또는 이유(reason
)를 담은 객체의 배열로 반환됩니다. 여러 작업의 성공 여부와 관계없이 모든 결과를 확인해야 할 때 사용합니다.Promise.any(iterable)
: 배열에 담긴 프로미스 중 가장 먼저 이행(fulfilled)되는 것의 결과를 반환합니다. 모든 프로미스가 거부될 경우에만 AggregateError
와 함께 거부됩니다. 여러 미러 서버 중 가장 먼저 응답하는 서버의 데이터를 사용하고 싶을 때 적합합니다.4. 현대적 비동기 처리: Async/Await
프로미스는 콜백 지옥을 해결했지만, .then()
과 .catch()
를 사용하는 체이닝 문법은 여전히 비동기 코드임을 명확하게 드러냅니다. 코드가 길어지면 여전히 복잡해 보일 수 있습니다. ES2017(ES8)에서는 이러한 프로미스를 더욱 쉽게 사용할 수 있도록 Async/Await라는 새로운 문법이 도입되었습니다. Async/Await는 프로미스 위에 구축된 '문법적 설탕(Syntactic Sugar)'으로, 비동기 코드를 마치 동기 코드처럼 보이게 만들어 가독성을 극대화합니다.
4.1. `async`와 `await`의 기본 원리
async
: 함수 선언 앞에 `async` 키워드를 붙이면, 해당 함수는 항상 프로미스를 반환합니다. 함수가 명시적으로 값을 반환하면, 그 값으로 이행(fulfilled)되는 프로미스가 반환됩니다. 함수 내에서 에러가 발생하거나 프로미스가 거부되면, 그 에러를 담아 거부(rejected)되는 프로미스가 반환됩니다.await
: `await` 키워드는 `async` 함수 내에서만 사용할 수 있습니다. `await`는 프로미스 바로 앞에 위치하며, 해당 프로미스가 완료(settled)될 때까지 함수의 실행을 일시 중지합니다. 프로미스가 이행되면, `await` 표현식은 그 결과 값을 반환합니다. 만약 프로미스가 거부되면, 에러를 던집니다(throw).
이 두 키워드를 조합하면 비동기 작업의 결과를 변수에 직접 할당하고, 동기적인 에러 처리 방식인 `try...catch` 구문을 사용할 수 있게 됩니다.
4.2. 프로미스 체이닝을 Async/Await로 리팩토링하기
앞서 살펴본 프로미스 체이닝 예제를 `async/await`를 사용하여 다시 작성해 보겠습니다. 그 차이는 극명합니다.
async function fetchUserData() {
try {
const user = await getUser(1); // 프로미스가 완료될 때까지 기다렸다가 결과를 user에 할당
console.log('사용자:', user.name);
const posts = await getPosts(user.id); // 이전 작업이 끝나야 실행됨
console.log('게시글 수:', posts.length);
const firstPost = posts[0];
const comments = await getComments(firstPost.id);
console.log('첫 게시글의 댓글 수:', comments.length);
} catch (error) {
// try 블록 내의 어떤 await에서든 에러가 발생하면 여기서 잡힘
console.error('오류 발생:', error);
} finally {
console.log('모든 데이터 조회 작업 완료.');
}
}
fetchUserData();
코드가 어떻게 변했는지 주목해 보세요. .then()
콜백의 중첩이나 체인이 완전히 사라지고, 마치 일반적인 동기 코드를 작성하는 것처럼 위에서 아래로 순차적으로 코드를 읽고 이해할 수 있습니다. 비동기 작업의 결과가 일반 변수처럼 다뤄지며, 에러 처리는 익숙한 `try...catch` 구문으로 통합되어 매우 직관적입니다.
4.3. Async/Await와 병렬 처리
Async/Await를 사용할 때 흔히 저지르는 실수 중 하나는 병렬로 처리할 수 있는 작업을 순차적으로 실행하는 것입니다. 예를 들어, 서로 의존성이 없는 두 개의 데이터를 가져오는 경우를 생각해 봅시다.
// 나쁜 예: 순차적 실행 (불필요하게 오래 걸림)
async function getTwoThingsSlowly() {
const result1 = await fetch('api/data1'); // data1 요청이 끝날 때까지 기다림
const result2 = await fetch('api/data2'); // 그 후에야 data2 요청 시작
// 총 소요 시간 = (data1 요청 시간) + (data2 요청 시간)
}
// 좋은 예: 병렬 실행
async function getTwoThingsFast() {
try {
// 두 프로미스를 동시에 시작시키고, Promise.all로 두 결과가 모두 올 때까지 기다림
const [result1, result2] = await Promise.all([
fetch('api/data1'),
fetch('api/data2')
]);
// 총 소요 시간 = max(data1 요청 시간, data2 요청 시간)
} catch (error) {
console.error('데이터를 가져오는 중 오류 발생:', error);
}
}
위의 '좋은 예'에서처럼, `await`를 `Promise.all`과 함께 사용하면 여러 비동기 작업을 효율적으로 병렬 처리할 수 있습니다. 먼저 프로미스들을 생성하여 동시에 실행을 시작시킨 후, `Promise.all`로 모든 작업이 완료되기를 기다리는 패턴은 `async/await` 코드에서 성능을 최적화하는 핵심적인 기법입니다.
5. 종합 비교: 콜백 vs 프로미스 vs Async/Await
지금까지 자바스크립트 비동기 처리 방식의 진화를 살펴보았습니다. 각 방식의 특징을 한눈에 비교하면 다음과 같습니다.
항목 | 콜백 (Callbacks) | 프로미스 (Promises) | Async/Await |
---|---|---|---|
코드 구조 | 중첩된 구조 (피라미드) | 체이닝 구조 (.then().then()...) | 선형적, 동기적 구조 |
가독성 | 낮음 (흐름 추적 어려움) | 중간 (콜백보다 개선됨) | 매우 높음 (동기 코드와 유사) |
에러 처리 | 각 콜백마다 분산 처리 (if err) | 중앙 집중식 처리 가능 (.catch) | 동기적 처리 방식 (try...catch) |
값 반환 | 콜백 함수를 통해 비동기적으로 전달 | .then() 콜백으로 결과 전달 | await 표현식이 직접 값 반환 (변수 할당 가능) |
디버깅 | 콜 스택 추적이 어려워 복잡함 | 콜백보다 용이하지만 여전히 비동기적 | 동기 코드와 유사하여 디버깅이 용이함 |
기반 | 기본 함수 전달 패턴 | ES6 표준 객체 | 프로미스 기반의 문법적 설탕 |
결론: 어떤 방식을 선택해야 하는가?
자바스크립트 비동기 프로그래밍은 콜백의 혼란스러움에서 시작하여, 프로미스를 통해 구조화된 패턴을 정립했고, 마침내 Async/Await를 통해 개발자 친화적인 형태로 발전했습니다. 현대 자바스크립트 개발 환경에서는 대부분의 경우 Async/Await를 사용하는 것이 최선의 선택입니다.
Async/Await는 코드의 가독성을 비약적으로 향상시키고, 유지보수를 용이하게 하며, 실수를 줄여줍니다. 비동기 로직을 마치 동기 로직처럼 생각하고 작성할 수 있게 해주는 것은 개발 생산성에 엄청난 이점을 제공합니다.
하지만 이것이 콜백과 프로미스를 배울 필요가 없다는 의미는 아닙니다.
- 콜백은 여전히 DOM 이벤트 리스너나 일부 오래된 라이브러리, Node.js의 특정 모듈에서 사용되고 있습니다. 그 기본적인 개념을 이해하는 것은 자바스크립트의 근본적인 동작 방식을 이해하는 데 도움이 됩니다.
- 프로미스는 Async/Await의 기반 기술입니다. `Promise.all`이나 `Promise.race`와 같은 강력한 동시성 제어 패턴을 사용하려면 프로미스 객체 자체와 그 메소드들에 대한 깊은 이해가 필수적입니다. Async/Await를 사용하더라도, 그 내부에서는 프로미스가 동작하고 있음을 항상 인지해야 합니다.
결론적으로, 비동기 코드를 작성할 때는 Async/Await를 주력으로 사용하되, 필요에 따라 프로미스의 강력한 기능들을 조합하여 활용하는 것이 가장 이상적인 접근 방식입니다. 이 세 가지 패러다임의 역사와 각각의 장단점을 이해함으로써, 여러분은 어떤 비동기적 상황에서도 가장 깔끔하고 효율적이며 견고한 코드를 작성할 수 있는 능력을 갖추게 될 것입니다.
0 개의 댓글:
Post a Comment