예를 들어, 요리를 하면서 물을 끓이는 작업을 동기로 처리하면 물이 끓을 때까지 아무 일도 하지 못하지만, 비동기로 처리하면 물을 끓이는 동안 재료를 손질할 수 있는 것과 같다.
여기서 처음에 동기와 비동기를 구분하는 기준과 블로킹과 논블로킹을 구분하는 기준에 무슨 차이가 있는지 애매모호했다. 내가 이를 정리한 방법은 지체되지 않고 다음 작업이 실행 되는지 여부를 중점적으로 따지는 것이 동기와 비동기의 구분의 핵심인 느낌이었고, 기존 작업의 제어권을 넘김으로써 기존 작업이 멈추는지 여부를 중점적으로 따지는 것이 블로킹과 논블로킹을 구분하는 핵심이라 느꼈다.
이처럼 동기와 비동기, 블로킹과 논블로킹은 별도의 개념이고, 결과적으로 함수의 실행 방식에는 총 4가지의 방식이 존재한다.
여기서 우리가 익숙한 구조는 '동기 + 블로킹', '비동기 + 논블로킹'이고, 일반적으로 동기 작업은 '동기 + 블로킹', 비동기 작업은 '비동기 + 논블로킹' 구조로 구현되는 경우가 많기 때문에, 이렇게 이해해도 큰 무리는 없다. 4가지 방식의 차이점에 대해서는 추후 새로운 포스팅에서 더 자세히 알아보도록 하자.
자바스크립트는 브라우저에서 실행되는 대부분의 코드(이벤트 처리, API 호출, 파일 읽기 등)를 처리하기 위해 비동기 방식을 채택하고 있다. 자바스크립트는 싱글 스레드 언어이기 때문에, 만약 모든 작업을 동기적으로 처리한다면 사용자는 웹페이지가 멈춘 것처럼 느낄 수 있다.
자바스크립트 초창기에는 비동기 작업이 끝난 후 실행될 작업을 관리할 수단이 콜백함수 밖에 없었기 때문에, 콜백함수를 인자로 전달하는 방식이 주로 사용되었다.
function getData(callback) {
setTimeout(() => {
callback('데이터 도착');
}, 1000);
}
getData(function (result) {
console.log(result); // 1초 후 '데이터 도착' 출력
});
예를들어 특정 유저의 특정 게시글에 달린 댓글들을 조회하는 상황이라고 가정했을 때 유저를 조회하는 비동기 작업에 해당 유저의 게시글을 조회하는 함수를 콜백 함수로 전달해야 하고, 해당 함수에 또 다시 해당 게시글에 달린 댓글을 조회하는 함수를 콜백 함수로 전달해야 한다. 이런 식으로 콜백 함수를 중첩해서 사용하는 구조를 콜백 지옥(callback hell)이라고 부른다. 콜백 함수를 중첩해서 사용하게 되면 코드에 들여쓰기가 계단처럼 들어가는 구조를 띄게 되기 때문에 중첩 횟수가 늘어남에 따라 가독성이 매우 떨어지게 된다.
getUser(userId, (user) => {
getPostsByUser(user.id, (posts) => {
getCommentsByPost(posts[0].id, (comments) => {
console.log('댓글 목록:', comments);
});
});
});
ES6 문법 이후에 Promise가 등장하면서 비동기 작업의 결과에 따라 then이나 catch 체이닝을 통해 콜백 함수를 처리할 수 있게 되면서 콜백 지옥을 해결할 수 있게 되었다.
ES6부터는 Promise 객체를 통해 비동기 작업의 성공(resolve) 또는 실패(reject)를 표현할 수 있게 되었다. 이로써 작업의 결과에 따른 동작을 체이닝을 통해 좀 더 직관적인 형태로 관리가 가능해졌다.
function getData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('데이터 도착');
}, 1000);
});
}
getData()
.then((result) => console.log(result)) // 1초 후 '데이터 도착'
.catch((error) => console.error(error));
.then().then()
과 같은 체이닝이 길어지면 가독성이 떨어질 수 있다.ES2017(ES8)에서는 async/await 문법이 도입되어 Promise를 더 직관적으로 사용할 수 있게 되었다.
function getData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('데이터 도착');
}, 1000);
});
}
async function showData() {
const result = await getData();
console.log(result); // 1초 후 '데이터 도착'
}
showData();
await
을 사용하게 되면 해당 Promise 객체를 반환할 때까지 이후의 작업이 멈추게 된다. 이 부분에서 await
을 blocking라고 생각할 수도 있지만, await
은 non-blocking이다. 함수 흐름에서는 일시 정지처럼 보이지만, 전체 실행 환경(이벤트 루프)은 멈추지 않는다.
async function foo() {
console.log('1');
await new Promise((res) => setTimeout(res, 1000));
console.log('2');
}
foo();
console.log('3');
1
3
(1초 뒤)
2
1
은 foo()
함수 내의 동기 코드이므로 즉시 실행된다. await
는 Promise가 완료될 때까지 함수 흐름을 일시 정지시킨다. console.log('3')
은 먼저 실행된다. console.log('2')
가 실행된다.foo()
함수는 await
을 만나면 콜스택에서 빠지는데, 그렇다면 await
이후의 코드는 언제, 어떻게 다시 실행되는 것일까?
이 과정을 아래와 같이 정리할 수 있다.
foo()
가 호출되면 콜스택에 올라가고, 함수 내부의 동기 코드인 console.log('1')
이 실행되어 1
이 출력된다.await new Promise(...)
를 만나면, 해당 Promise
가 처리(resolve)될 때까지 foo()
함수의 실행은 일시적으로 중단된다.await
이후 코드(console.log('2')
)는 자동으로 .then()
콜백 형태로 래핑되어 마이크로태스크 큐에 등록될 준비 상태가 된다.foo()
함수는 실행을 멈추고 콜스택에서 제거된다.console.log('3')
이 콜스택에 올라가고 실행되어 3
이 출력된다.setTimeout
콜백을 실행하고, 이때 Promise
가 resolve되면서 console.log('2')
가 마이크로태스크 큐에 등록된다.console.log('2')
를 꺼내 콜스택에 push하여 실행한다.결국 await
은 비동기 함수 내부에서 흐름을 잠시 멈출 뿐, 전체 자바스크립트 실행 환경이나 이벤트 루프 자체는 계속 작동하고 있다. 이러한 구조 덕분에 await
이후 코드가 나중에, 정확한 타이밍에 맞춰 실행될 수 있는 것이다.
방식 | 도입 시기 | 장점 | 단점 |
---|---|---|---|
Callback | 초기 | 간단하고 빠르다 | 콜백 지옥, 유지보수 어려움 |
Promise | ES6 | 체이닝 가능, 에러 처리 용이 | 복잡한 체이닝은 가독성이 떨어진다 |
async/await | ES2017 | 직관적이고 가독성이 좋다 | 병렬 처리 시 성능 저하 우려 |
이처럼 자바스크립트의 비동기 처리 방식은 점차 가독성과 유지보수성을 높이는 방향으로 발전해 왔다. 현대 개발에서는 async/await 방식이 가장 권장되는 방식이지만, 콜백과 프로미스 또한 여전히 많이 사용되고 있다. 특히 async/await은 Promise를 기반으로 한 문법적 설탕(syntactic sugar)이기 때문에, 실제 개발에서는 콜백, Promise, async/await이 함께 쓰이는 경우가 많다.