JS의 비동기 처리 메커니즘
- #JavaScript
동기(Synchronous)와 비동기(Asynchronous)
동기 작업과 비동기 작업은 호출된 함수의 작업 완료를 기다리는지 여부에 달려있다.
동기 방식의 경우, 호출된 함수의 작업이 완료되기까지 기다린 후 다음 작업은 진행한다. 때문에 설계가 직관적이고 작업 종료 시까지 다음 작업이 중단되므로 작업이 순차적으로 진행된다.
비동기 방식은, 호출된 함수의 작업 완료를 기다리지 않고 다음 코드를 계속 실행한다. 때문에 호출된 함수의 작업이 완료될 경우 콜백 함수 등을 통해 결과를 전달한다. 네트워크 요청과 같은 시간이 오래 걸리는 작업을 기다리는 동안 다른 작업을 수행할 수 있어 효율적인 자원 사용이 가능하다.
JS의 비동기 처리
JS는 싱글 스레드 언어이다. 싱글 스레드라는 것은 이 1개라는 뜻이다. 즉, 오직 한 번에 단 하나의 작업만을 수행할 수 있다.
그렇다면 JS는 어떻게 setTimeout과 같은 비동기 함수를 실행할 수 있을까? 이는 JS와 별개로 브라우저와 같은 실행 환경이 이벤트 루프, 태스크 큐와 같은 비동기 처리 매커니즘을 제공하기 때문이다.
이벤트 루프
이벤트 루프는 태스크 큐를 확인하고 이를 콜스택으로 옮겨 실행시키는 루프를 칭한다. 콜 스택과 태스크 큐 사이에서 작업을 중재하는 역할을 한다.
태스크 큐
태스크 큐는 비동기 작업이 완료된 후, 해당 작업의 결과가 실행되기 위해 순서대로 대기하는 공간이다. 태스크큐는 크게 2가지로 나뉘는데 마이크로태스크 큐(Microtask Queue)와 매크로태스크 큐(Macrotask Queue/Task Queue)로 나뉜다.
마이크로태스크 큐는 Promise, MutationObserver와 같이 중요도가 높은 비동기 작업의 콜백이 대기한다. 모든 큐 중에서 가장 높은 우선순위를 가지며, 콜 스택이 비는 즉시 마이크로태스크 큐에 있는 모든 작업을 큐가 완전히 비워질 때까지 가장 먼저 처리한다
매크로태스크 큐는 setTimeout, setInterval, I/O 작업, 이벤트 핸들러 등 일반적인 비동기 작업의 콜백이 대기한다. 우선순위가 마이크로태스크 큐보다 낮기 때문에 이벤트 루프틑 한 번의 반복 사이클마다 매크로태스크 큐에서 단 하나의 태스크만 꺼내어 실행하며, 이를 처리하기 전에 항상 마이크로태스크 큐가 비어있는지 확인한다.
한 가지 더 말해보자면 애니메이션 콜백 큐(Anotation Frames Queue)가 존재한다. 이 곳은 requestAnimationFrame()의 콜백 함수들이 관리되는 곳이며, 우선순위는 마이크로태스트큐와 매크로 태스크 큐 사이에 위치한다. 이 콜백들은 일반적인 태스크 큐나 마이크로태스크 큐와는 달리 별도의 공간에서 관리된다. 화면 갱신 시점에 큐에 존재하는 모든 작업을 실행하며, 실행 주기 역시 브라우저의 에 맞춰 와 가 일어나기 직전에 실행되는 독자적인 실행 주기를 가진다.
동작 과정
console.log("시작");
setTimeout(() => {
console.log("매크로태스크 (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("마이크로태스크 (Promise)");
});
requestAnimationFrame(() => {
console.log("애니메이션 콜백 (rAF)");
});
console.log("끝");
/*
시작
끝
마이크로태스크 (Promise)
애니메이션 콜백(rAF)
매크로태스크 (setTimeout)
*/- 동기 코드 실행:
console.log("시작")이 콜 스택에 쌓이고 즉시 실행됩니다.setTimeout이 호출되면 브라우저 API(Web API)가 타이머를 설정하고, 완료 시 콜백을 매크로태스크 큐에 넣습니다.Promise.resolve().then의 콜백은 마이크로태스크 큐에 담깁니다.requestAnimationFrame의 콜백은 애니메이션 콜백 큐에 담깁니다.console.log("끝")이 실행됩니다.
- 마이크로태스크 큐 확인
- 콜 스택이 비워지면 이벤트 루프는 가장 먼저 마이크로태스크 큐를 확인합니다.
- 큐에 있는 모든 작업이 완료될 때까지 실행합니다. 여기서는
Promise콜백이 출력됩니다.
- 애니메이션 콜백 큐 확인
- 브라우저가 화면을 갱신해야 하는 시점(렌더링 기회)이라면, 애니메이션 콜백 큐에 있는 작업을 실행합니다.
- 리페인트 직전에
rAF콜백이 출력됩니다.
- 매크로태스크 큐 확인
- 마이크로태스크 큐가 비어 있고 렌더링 작업이 정리되면, 이벤트 루프는 매크로태스크 큐에서 단 하나의 작업만 꺼내 콜 스택으로 옮깁니다.
setTimeout콜백이 마지막으로 출력됩니다.
이벤트 루프의 사이클은 매우 빠르게 도는 반면, 브라우저의 화면 갱신 주기는 보통 1초에 60번(약 16.7ms 간격)입니다. 이 갱신 시점이 끝나지 않았다면, 애니메이션 콜백 큐를 건너뛰고 매크로태스크 큐로 넘어갑니다. 다시 말해 애니메이션 콜백 큐는 화면을 다시 그려야겠다 판단할 때만 실행됩니다.
즉 여기서 말하는 우선순위란, 모든 콜백이 즉시 실행 가능한 시점에서의 우선순위입니다. 만약 모든 비동기 작업이 끝난 시점에, 화면의 갱신 시점이 일치한다면 애니메이션 콜백 큐의 작업이 먼저 실행됩니다. 그렇지 않고 아직 화면 갱신 시점이 다가오지 않았을 때 이벤트 루프가 먼저 실행된다면, 매크로태스크 큐의 작업이 먼저 실행됩니다.
Promise
프로미스(Promise)는 ES6에 추가된 자바스크립트 비동기 작업의 완료 또는 실패를 나타내는 객체이다. 복잡한 비동기 처리를 위해 콜백 함수를 중첩해서 사용할 때 발생하는 콜백 지옥을 해결하고, 비동기 작업의 흐름을 명확하게 관리하기 위해 도입되었다.
또한 프로미스의 후속 처리 콜백인 then, catch, finally는 일반적인 태스크보다 우선적으로 처리되는 마이크로태스크 큐에서 실행된다.
Promise의 3가지 상태
비동기 작업의 진행 상황에 따라 프로미스는 3가지 상태를 가지게 된다.
- Pending(대기) : 비동기 작업이 아직 완료되지 않은 초기 상태
- Fulfilled(이행) : 비동기 작업이 성공적으로 완료되어 결과 값을 반환한 상태
- Rejected(거부) : 비동기 작업이 실패하여 오류를 반환한 상태
Fulfilled와 Rejected 상태를 묶어서 Settled(처리 완료) 상태라고 부르기도 한다. 한 번 Settled 상태가 된 프로미스는 다시 다른 상태로 바뀌지 않는다.
사용법
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("작업 성공");
} else {
reject(new Error("작업 실패"));
}
});new Promise 생성자 함수를 사용해 생성할 수 있다. resolve(성공 시 호출)와 reject(실패 시 호출)라는 두 가지 콜백 함수를 인자로 받는 함수를 내부에서 실행한다.
프로미스의 결과는 후속 처리 메서드를 통해 전달받을 수 있으며, 체이닝을 통해 여러 비동기 작업을 순차적으로 연결할 수 있다.
.then(): 성공 시 결과 값을 인자로 받아 실행.catch(): 실패 시 발생한 에러를 인자로 받아 실행.finally(): 성공/실패 여부와 관계 없이 작업이 완료되면 항상 실행
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
console.log("작업 종료");
});한계
프로미스는 콜백 지옥을 개선하였지만 아래와 같은 한계를 가진다.
- 복잡한 에러 처리 : 비동기 흐름이 복잡해질 경우, 특정 단계에서 발생하는 에러를 세밀하게 추적하고 관리하는 데 있어 코드가 복잡해질 수 있다
- 가독성 저하 : 비동기 작업이 많아져 체인이 길어지면, 가독성이 떨어질 수 있다. 이는
async/await문법으로 보완할 수 있다.