2025-09-13 12:34
-
자바스크립트는 한 번에 하나의 작업만 처리하는 싱글 스레드 언어이며, 이로 인해 발생하는 ‘블로킹’ 문제를 해결하기 위해 비동기 처리 방식이 도입됨.
-
비동기 처리 방식은 콜백 함수에서 시작하여, ‘콜백 지옥’ 문제를 해결하기 위한 프로미스(Promise), 그리고 프로미스를 더 읽기 쉽게 만든 async/await 문법으로 발전해 옴.
-
자바스크립트의 비동기 동작 핵심에는 이벤트 루프, 태스크 큐, 마이크로태스크 큐가 있으며, 이를 이해하는 것이 비동기 코드의 실행 순서를 예측하는 데 중요함.
자바스크립트 동기와 비동기 완벽 정복 핸드북
자바스크립트 개발자라면 누구나 ‘동기’와 ‘비동기’라는 단어를 마주하게 된다. 이 두 개념은 자바스크립트의 핵심 동작 원리를 이해하고, 효율적인 코드를 작성하기 위해 반드시 넘어야 할 산이다. 특히 웹 브라우저와 Node.js 환경에서 사용자 경험을 해치지 않는 빠르고 부드러운 애플리케이션을 만들기 위해서는 비동기 처리에 대한 깊은 이해가 필수적이다.
이 핸드북은 자바스크립트의 동기적 특성과 그 한계에서부터 비동기 처리가 왜 필요한지, 그리고 콜백(Callback)에서 프로미스(Promise), 최신 async/await 문법에 이르기까지 비동기 프로그래밍의 전체적인 진화 과정을 상세하게 다룬다. 단순히 문법을 나열하는 것을 넘어, 내부 동작 원리인 ‘이벤트 루프’까지 파헤쳐 자바스크립트의 비동기 세계를 완벽하게 정복할 수 있도록 안내할 것이다.
1. 모든 것의 시작 동기 프로그래밍
동기(Synchronous)란 무엇인가
‘동기’는 ‘동시에 일어난다’는 뜻이지만, 프로그래밍에서는 순서대로 하나씩 실행된다는 의미로 사용된다. 코드가 작성된 순서에 따라 작업이 하나씩 처리되고, 앞선 작업이 완전히 끝나야만 다음 작업이 시작될 수 있다.
이는 마치 패스트푸드점의 한 줄짜리 주문 라인과 같다.
-
첫 번째 손님이 주문을 하고 음식을 받을 때까지, 뒤에 있는 손님들은 아무것도 하지 못하고 기다려야 한다.
-
첫 번째 손님의 주문 처리가 모두 끝나야 비로소 두 번째 손님의 주문을 받기 시작한다.
자바스크립트는 기본적으로 이런 동기 방식으로 동작하는 싱글 스레드(Single Thread) 언어다. 즉, 코드를 실행하는 일꾼이 단 한 명뿐이라 한 번에 하나의 작업만 처리할 수 있다.
function taskA() {
console.log('A 작업 시작');
// 시간이 걸리는 작업 (예: 복잡한 연산)
for (let i = 0; i < 1e9; i++) {}
console.log('A 작업 끝');
}
function taskB() {
console.log('B 작업 시작');
console.log('B 작업 끝');
}
console.log('전체 작업 시작');
taskA();
taskB();
console.log('전체 작업 끝');
위 코드를 실행하면 콘솔에는 다음과 같은 순서로 출력된다.
전체 작업 시작
A 작업 시작
(몇 초 후)
A 작업 끝
B 작업 시작
B 작업 끝
전체 작업 끝
taskA
내부의 복잡한 연산이 끝날 때까지 taskB
는 하염없이 기다려야만 한다. 이것이 바로 동기 처리의 가장 큰 특징이자 문제점인 **블로킹(Blocking)**이다.
동기 방식의 문제점 블로킹
만약 시간이 오래 걸리는 작업이 웹 브라우저에서 동기적으로 처리된다면 어떻게 될까? 사용자가 버튼을 클릭했는데, 서버에서 대용량 데이터를 가져오는 작업(수 초 소요)이 동기적으로 실행된다고 상상해 보자. 데이터를 모두 가져올 때까지 브라우저는 아무것도 하지 못하고 그대로 멈춰버릴 것이다. 사용자는 스크롤, 다른 버튼 클릭 등 어떤 상호작용도 할 수 없게 되며, 이는 최악의 사용자 경험으로 이어진다.
이러한 ‘블로킹’ 문제를 해결하기 위해 자바스크립트는 ‘비동기’라는 처리 방식을 도입했다.
2. 기다림의 미학 비동기 프로그래밍
비동기(Asynchronous)란 무엇인가
‘비동기’는 동기와 반대되는 개념으로, 작업이 끝나는 것을 기다리지 않고 다음 작업을 바로 시작하는 방식을 말한다. 시간이 오래 걸리는 작업은 일단 다른 곳에 요청만 해두고, 그 작업이 언젠가 끝나면 결과를 알려달라고 약속한 뒤, 곧바로 다음 코드를 실행한다.
이는 마치 카페의 진동벨 시스템과 같다.
-
손님은 카운터에서 커피를 주문하고 돈을 낸 뒤 진동벨을 받아 자리로 간다.
-
카운터는 다음 손님의 주문을 바로 받기 시작한다. (블로킹되지 않음)
-
손님은 자리에서 다른 일을 하다가(다른 코드 실행), 커피가 완성되면 울리는 진동벨을 보고 커피를 받아온다. (결과 처리)
자바스크립트는 setTimeout
, fetch
(서버 통신), 파일 읽기(Node.js) 등 시간이 걸릴 수 있는 작업들을 비동기적으로 처리할 수 있는 기능(Web API)들을 제공한다.
function taskA() {
console.log('A 작업 시작');
// 1초가 걸리는 작업을 요청만 하고 바로 다음으로 넘어감
setTimeout(() => {
console.log('A 작업의 결과가 나왔습니다!');
}, 1000); // 1000ms = 1초
console.log('A 작업 끝');
}
function taskB() {
console.log('B 작업 시작');
console.log('B 작업 끝');
}
console.log('전체 작업 시작');
taskA();
taskB();
console.log('전체 작업 끝');
위 코드를 실행하면 결과는 동기 방식과 완전히 다르게 나타난다.
전체 작업 시작
A 작업 시작
A 작업 끝 // setTimeout을 기다리지 않고 바로 실행됨
B 작업 시작
B 작업 끝
전체 작업 끝
(1초 후)
A 작업의 결과가 나왔습니다!
setTimeout
이라는 비동기 함수는 “1초 뒤에 이 함수를 실행해 줘”라고 브라우저에게 요청만 하고 즉시 종료된다. 자바스크립트는 setTimeout
이 끝나기를 기다리지 않고 바로 console.log('A 작업 끝')
과 taskB()
를 순서대로 실행한다. 그리고 1초가 지나면, 브라우저는 약속했던 함수를 실행하여 결과를 보여준다.
이처럼 비동기 처리는 **논블로킹(Non-blocking)**을 가능하게 하여, 오래 걸리는 작업 중에도 다른 작업을 수행할 수 있게 해준다.
3. 비동기 처리의 진화 과정
자바스크립트에서 비동기 코드를 작성하는 방식은 시간이 흐르면서 더 편리하고 읽기 쉬운 형태로 발전해왔다.
1단계: 콜백 함수 (Callback Function)
가장 전통적인 비동기 처리 방식이다. 특정 작업이 끝난 뒤에 실행될 함수(콜백 함수)를 다른 함수의 인자로 넘겨주는 형태다. 위의 setTimeout
예제가 바로 콜백 함수를 사용한 것이다.
// 서버에서 사용자 정보를 가져오는 가상 함수
function getUser(id, callback) {
console.log(`${id} 사용자 정보를 요청합니다.`);
setTimeout(() => {
const user = { id: id, name: 'Alice' };
callback(user); // 작업이 끝나면 콜백 함수를 실행
}, 1500);
}
getUser(1, (user) => {
console.log('받은 사용자 정보:', user);
});
콜백 방식은 직관적이지만, 여러 개의 비동기 작업을 순차적으로 처리해야 할 때 문제가 발생한다.
콜백 지옥 (Callback Hell)
사용자 정보를 가져온 뒤, 그 정보로 게시글 목록을 가져오고, 또 그 목록의 첫 번째 게시글의 댓글을 가져와야 한다면 어떻게 될까?
getUser(1, (user) => {
// 1. 사용자 정보 가져오기 성공
getPosts(user.id, (posts) => {
// 2. 게시글 목록 가져오기 성공
getComments(posts[0].id, (comments) => {
// 3. 댓글 가져오기 성공
console.log(comments);
}, (err) => {
console.error('댓글 가져오기 실패:', err);
});
}, (err) => {
console.error('게시글 가져오기 실패:', err);
});
}, (err) => {
console.error('사용자 가져오기 실패:', err);
});
콜백 함수가 계속해서 중첩되어 코드의 깊이가 깊어지고, 가독성이 극도로 나빠지는 현상을 콜백 지옥이라고 부른다. 이는 코드 이해와 유지보수를 매우 어렵게 만든다.
2단계: 프로미스 (Promise)
콜백 지옥 문제를 해결하기 위해 ES6(2015)에서 도입된 객체다. 프로미스는 비동기 작업의 ‘미래 결과 값(성공 또는 실패)‘을 담는 그릇이라고 할 수 있다.
프로미스는 세 가지 상태를 가진다.
-
대기 (Pending): 비동기 작업이 아직 끝나지 않은 상태
-
이행 (Fulfilled): 비동기 작업이 성공적으로 끝난 상태 (결과 값을 가짐)
-
거부 (Rejected): 비동기 작업이 실패한 상태 (에러를 가짐)
// Promise를 반환하는 함수
function getUserPromise(id) {
return new Promise((resolve, reject) => {
console.log(`${id} 사용자 정보를 요청합니다.`);
setTimeout(() => {
const user = { id: id, name: 'Alice' };
if (user) {
resolve(user); // 성공 시 resolve 호출
} else {
reject(new Error('사용자를 찾을 수 없습니다.')); // 실패 시 reject 호출
}
}, 1500);
});
}
getUserPromise(1)
.then((user) => {
// 성공(Fulfilled)했을 때 실행되는 부분
console.log('성공:', user);
})
.catch((error) => {
// 실패(Rejected)했을 때 실행되는 부분
console.error('실패:', error);
})
.finally(() => {
// 성공/실패 여부와 상관없이 무조건 실행
console.log('요청 처리 완료');
});
프로미스의 .then()
메서드를 이용하면 콜백 지옥을 해결할 수 있다. .then()
은 새로운 프로미스를 반환하므로, 이를 연결하여(Chaining) 코드를 평평하게 만들 수 있다.
getUserPromise(1)
.then(user => getPostsPromise(user.id)) // getPostsPromise도 Promise를 반환
.then(posts => getCommentsPromise(posts[0].id)) // getCommentsPromise도 Promise를 반환
.then(comments => console.log(comments))
.catch(error => console.error('어딘가에서 에러 발생:', error));
들여쓰기가 깊어지지 않고, 위에서 아래로 흐르는 자연스러운 코드 구조가 되었다. 에러 처리도 마지막 .catch()
에서 한 번에 할 수 있어 훨씬 깔끔하다.
3단계: async/await
ES8(2017)에서 도입된 문법으로, 프로미스를 더욱 동기 코드처럼 보이게 만드는 **Syntactic Sugar(문법적 설탕)**이다. async/await
는 내부적으로 프로미스를 기반으로 동작한다.
-
async
: 함수 앞에 붙이며, 이 함수는 항상 프로미스를 반환한다. -
await
:async
함수 안에서만 사용할 수 있으며, 프로미스가 처리될 때까지(settled) 함수의 실행을 일시 중지하고 기다린다.
// 위의 Promise Chaining 코드를 async/await로 변환
async function fetchUserFlow() {
try {
console.log('사용자 정보를 가져옵니다...');
const user = await getUserPromise(1); // Promise가 끝날 때까지 기다림
console.log('게시글 목록을 가져옵니다...');
const posts = await getPostsPromise(user.id);
console.log('댓글을 가져옵니다...');
const comments = await getCommentsPromise(posts[0].id);
console.log('최종 결과:', comments);
} catch (error) {
// 중간에 발생한 어떤 에러든 여기서 잡힘
console.error('작업 흐름 중 에러 발생:', error);
}
}
fetchUserFlow();
코드가 마치 동기적으로 순서대로 실행되는 것처럼 읽힌다. 비동기 작업의 결과를 변수에 바로 할당할 수 있고, 에러 처리도 익숙한 try...catch
구문을 사용할 수 있어 가독성과 생산성이 크게 향상된다.
구분 | 콜백 함수 | 프로미스 | async/await |
장점 | 전통적이고 간단한 비동기 구현 | 콜백 지옥 해결, 체이닝 가능 | 동기 코드와 유사한 최고의 가독성 |
단점 | 콜백 지옥 발생, 에러 처리 복잡 | then 체이닝이 길어지면 복잡 | 함수에 async 를 계속 붙여야 함 |
사용 | 간단한 비동기 처리 | 복잡한 비동기 흐름 제어 | 대부분의 최신 비동기 코드 작성 |
4. 심화: 자바스크립트 비동기 동작의 비밀, 이벤트 루프
자바스크립트는 싱글 스레드인데 어떻게 비동기 작업이 동시에 처리되는 것처럼 보일까? 그 비밀은 자바스크립트 엔진이 아닌, 자바스크립트가 실행되는 환경(브라우저, Node.js)에 있다.
자바스크립트 실행 환경은 다음과 같은 요소들로 구성된다.
-
콜 스택 (Call Stack): 코드가 실행될 때 함수 호출을 기록하는 곳. 후입선출(LIFO) 구조로, 함수가 호출되면 스택에 쌓이고(push), 실행이 끝나면 빠져나온다(pop). 자바스크립트는 콜 스택에 있는 하나의 작업만 처리할 수 있다.
-
Web API (또는 Background):
setTimeout
,fetch
, DOM 이벤트 등 비동기 작업을 처리하는 곳. 브라우저가 별도의 스레드에서 이 작업들을 처리한다. 자바스크립트 엔진의 일부가 아니다. -
태스크 큐 (Task Queue / Macrotask Queue): Web API에서 처리된 비동기 작업의 콜백 함수들이 대기하는 곳. 선입선출(FIFO) 구조의 대기열이다.
setTimeout
,setInterval
등의 콜백이 여기에 들어간다. -
마이크로태스크 큐 (Microtask Queue): 태스크 큐와 유사하지만, 더 높은 우선순위를 갖는 작업들이 대기하는 곳. 프로미스의
then
,catch
,finally
콜백과MutationObserver
가 여기에 들어간다. -
이벤트 루프 (Event Loop): 콜 스택과 태스크 큐, 마이크로태스크 큐를 감시하는 역할.
-
콜 스택이 비어 있을 때만 큐에서 작업을 가져와 콜 스택으로 옮긴다.
-
마이크로태스크 큐에 작업이 있다면, 모두 비워질 때까지 작업을 꺼내 실행한다.
-
마이크로태스크 큐가 비면, 태스크 큐에서 작업 하나를 꺼내 실행한다.
-
이 과정을 계속 반복한다.
-
이벤트 루프 동작 예시
console.log('1. 시작');
setTimeout(() => {
console.log('4. setTimeout 콜백'); // 태스크 큐로 이동
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise.then 콜백'); // 마이크로태스크 큐로 이동
});
console.log('2. 끝');
위 코드의 실행 순서는 다음과 같다.
-
console.log('1. 시작')
이 콜 스택에 들어가 실행되고, 콘솔에 ‘1. 시작’이 출력된다. -
setTimeout
이 콜 스택에 들어가 실행된다. 브라우저 Web API에 타이머 설정을 요청하고, 콜백 함수는 0초 뒤 태스크 큐로 이동한다. -
Promise.resolve().then()
이 콜 스택에 들어가 실행된다.then
의 콜백 함수는 즉시 마이크로태스크 큐로 이동한다. -
console.log('2. 끝')
이 콜 스택에 들어가 실행되고, 콘솔에 ‘2. 끝’이 출력된다. -
이제 동기 코드가 모두 실행되어 콜 스택이 비었다. 이벤트 루프가 작동한다.
-
이벤트 루프는 우선순위가 높은 마이크로태스크 큐를 먼저 확인한다.
Promise.then
의 콜백이 있으므로 이를 콜 스택으로 옮겨 실행한다. 콘솔에 ‘3. Promise.then 콜백’이 출력된다. -
마이크로태스크 큐가 비었다. 이제 이벤트 루프는 태스크 큐를 확인한다.
-
setTimeout
의 콜백이 있으므로 이를 콜 스택으로 옮겨 실행한다. 콘솔에 ‘4. setTimeout 콜백’이 출력된다.
최종 출력 순서: 1, 2, 3, 4
setTimeout의 대기 시간이 0초임에도 불구하고 프로미스보다 늦게 실행되는 이유는 바로 마이크로태스크 큐가 태스크 큐보다 높은 우선순위를 갖기 때문이다.
결론
자바스크립트의 동기와 비동기는 단순히 코드 작성법의 차이가 아니다. 싱글 스레드라는 언어적 한계를 극복하고, 사용자에게 쾌적한 인터랙션을 제공하기 위한 핵심적인 동작 원리다.
-
동기는 코드의 흐름이 단순하고 예측 가능하지만, 오래 걸리는 작업에서 블로킹을 유발한다.
-
비동기는 논블로킹을 통해 애플리케이션의 반응성을 유지하며, 콜백 함수, 프로미스, async/await로 진화하며 가독성과 생산성을 높여왔다.
-
이 모든 비동기 동작의 중심에는 이벤트 루프가 있으며, 이를 이해하는 것이 자바스크립트의 동작을 깊이 있게 파악하는 열쇠다.
이제 당신은 비동기 코드를 마주했을 때 더 이상 두려워할 필요가 없다. 이 핸드북의 지식을 바탕으로 콜백 지옥을 탈출하고, 프로미스와 async/await를 자유자재로 사용하여 더욱 효율적이고 강력한 자바스크립트 애플리케이션을 만들어 나가길 바란다.