JAVASCRIPT · 심층 가이드
JavaScript 비동기/동시성 완전 정리
순차와 병렬을 나누는 기준부터 타임아웃, 재시도, 취소 신호, debounce와 throttle까지 비동기 코드에서 반복되는 15가지 형태를 정리했습니다.
JavaScript의 동시성은 스레드가 아니라 이벤트 루프 위에서 돌아갑니다. 그래서 "동시에 실행"이라는 말은 CPU가 나뉘는 것이 아니라 대기 시간이 겹친다는 뜻입니다. 이 차이를 모르면 for 루프 안에서 await을 걸어 놓고 왜 느린지 의아해하거나, 반대로 CPU를 쓰는 계산을 async로 감싸 놓고 응답성이 개선되기를 기대하게 됩니다. 비동기는 대기를 겹치는 도구지 계산을 나누는 도구가 아닙니다.
순차 작업 처리와 병렬 작업 처리를 나란히 읽는 것부터 시작하시기 바랍니다. 앞 결과가 뒤 요청의 입력이면 순차, 서로 독립이면 Promise.all입니다. 여기에 timeout 처리와 retry 로직을 얹으면 실제 네트워크 코드의 골격이 완성되고, 취소 신호는 그 둘을 AbortController로 묶어 줍니다. 배치 처리는 병렬을 무제한으로 풀지 않기 위한 제어 장치로 읽으면 됩니다.
Promise.all은 하나가 실패하면 즉시 거부되지만, 나머지 작업은 취소되지 않고 계속 돕니다. 여기서 처리되지 않은 거부가 뒤늦게 터지면 Node에서는 기본적으로 프로세스가 종료됩니다. 전부의 결과가 필요하면 Promise.allSettled를 쓰고, 부분 실패를 허용하려면 각 작업을 개별적으로 감싸야 합니다. debounce와 throttle에서는 남아 있는 타이머를 정리하지 않아 컴포넌트가 사라진 뒤 콜백이 실행되는 사고가 잦습니다.
01비동기 함수 시작
async function으로 선언한 run이 이미 이행된 Promise.resolve(1)을 await해 값 1을 꺼내 출력합니다. 콜백 중첩 없이 비동기 흐름을 위에서 아래로 읽고 싶을 때 이 형태로 시작합니다. 이미 완료된 프로미스라도 await는 마이크로태스크 큐를 한 번 거치므로 run() 다음 줄이 먼저 실행됩니다.
JavaScript code
// 비동기 함수 시작
async function run() {
const value = await Promise.resolve(1);
console.log(value);
}
run();async 함수는 무엇을 반환하든 프로미스로 감싸 돌려주므로, run()의 반환값에 .then이나 await를 붙여 완료 시점을 잡을 수 있습니다.
await를 async가 아닌 함수 안에 쓰면 문법 오류가 납니다. 최상위에서 쓰려면 ESM 모듈이어야 하고 CommonJS 파일에서는 여전히 함수로 감싸야 합니다.
02순차 작업 처리
두 개의 이행된 프로미스를 Promise.all에 넘겨 ['a', 'b'] 배열 하나로 모은 뒤 then으로 출력합니다. 제목은 순차 처리지만 실제 동작은 두 작업을 함께 기다렸다가 입력 순서대로 결과를 돌려주는 쪽입니다. 하나라도 거부되면 나머지 결과를 버리고 즉시 거부되니, 부분 실패를 살리려면 Promise.allSettled를 씁니다.
JavaScript code
// 순차 작업 처리
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);Promise.all은 완료 순서와 무관하게 입력 배열의 순서대로 결과를 채워 주므로, 인덱스만으로 어떤 요청의 응답인지 안전하게 되짚을 수 있습니다.
정말 순차 실행이 필요한데 Promise.all을 쓰면 요청이 동시에 나갑니다. 앞 결과가 뒤 입력이 되는 작업이라면 for...of 안에서 하나씩 기다려야 합니다.
03병렬 작업 처리
AbortController를 만들자마자 abort()를 호출하고 signal.aborted가 true로 바뀐 것을 확인합니다. 여러 요청을 한꺼번에 띄운 뒤 한 번에 중단시키는 구조에서 이 신호 객체를 공유합니다. 컨트롤러 하나는 재사용할 수 없어서, 중단 이후 다시 시작하려면 새 인스턴스를 만들어야 합니다.
JavaScript code
// 병렬 작업 처리
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);signal.reason에는 중단 사유가 담기고, abort(new Error("타임아웃"))처럼 값을 직접 넘기면 그것이 그대로 거부 사유로 전달됩니다.
컨트롤러를 요청마다 새로 만들지 않고 하나를 재사용하면, 한 번 abort()된 뒤로는 이후 요청이 시작하자마자 전부 취소된 상태로 실패합니다.
04timeout 처리
setTimeout으로 10밀리초 뒤에 tick-4를 출력하도록 예약만 걸어 둡니다. 지연 실행이나 제한 시간을 재는 타이머의 최소 형태입니다. 10ms는 최소 대기 시간일 뿐이라 콜 스택이 비어야 실행되고, 반환된 타이머 id를 잡아 두지 않으면 clearTimeout으로 취소할 방법이 없습니다.
JavaScript code
// timeout 처리
setTimeout(() => console.log("tick-4"), 10);setTimeout은 매크로태스크라 이미 대기 중인 프로미스 콜백보다 항상 뒤에 실행됩니다. 지연을 0으로 줘도 마이크로태스크가 먼저 비워집니다.
Node에서는 예약된 타이머가 살아 있는 동안 프로세스가 종료되지 않습니다. 긴 지연을 걸어 두고 잊으면 스크립트가 끝나지 않은 채 멈춰 있게 됩니다.
05retry 로직
코드는 await Promise.resolve(5)로 값 하나를 받아 출력하는 async 함수 호출이 전부이며, 재시도 반복이나 실패 분기는 들어 있지 않습니다. 재시도를 붙인다면 이 await 줄을 try로 감싸고 루프를 도는 형태가 됩니다. run()은 프로미스를 반환하므로 반환값을 무시하면 내부 예외가 처리되지 않은 거부로 새어 나갑니다.
JavaScript code
// retry 로직
async function run() {
const value = await Promise.resolve(5);
console.log(value);
}
run();재시도를 붙일 때는 성공하면 즉시 반환하고 마지막 시도에서만 오류를 다시 던지도록 해야, 실패 원인이 남으면서도 중복 요청이 쌓이지 않습니다.
run()이 돌려준 프로미스를 무시하면 내부 오류가 처리되지 않은 거부로만 새어 나가고, 최신 Node에서는 그대로 프로세스가 종료됩니다.
06스트림 또는 채널 소비
Promise.all이 배열 안 두 프로미스를 모두 기다린 다음 결과 배열을 한 번에 콘솔로 넘깁니다. 스트림처럼 값이 하나씩 도착하는 소비가 아니라, 전부 모인 뒤 한 번 처리하는 방식입니다. 값이 도착하는 대로 다뤄야 한다면 for await...of로 비동기 이터러블을 도는 편이 메모리 면에서 유리합니다.
JavaScript code
// 스트림 또는 채널 소비
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);for await...of는 비동기 이터러블뿐 아니라 프로미스가 담긴 평범한 배열에도 쓸 수 있어, 값이 하나씩 필요한 자리에서 Promise.all을 대신합니다.
결과 배열 전체를 한 번에 로그로 넘기면 항목이 많을 때 콘솔이 뒤쪽을 잘라 냅니다. 스트림 소비라면 도착할 때마다 한 건씩 찍는 편이 정확합니다.
07producer consumer
생산자와 소비자를 잇는 큐 대신, 여기서는 중단 신호 하나만 다룹니다. controller.abort() 호출 뒤 signal.aborted를 읽어 상태가 즉시 반영됨을 보여 줍니다. signal에는 abort 이벤트 리스너를 붙일 수 있는데, 이미 중단된 신호에 리스너를 나중에 달면 다시 호출되지 않는 점에 주의합니다.
JavaScript code
// producer consumer
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);signal.addEventListener("abort", ...)로 정리 작업을 걸어 두면, 소비자가 멈출 때 열려 있던 자원을 한 곳에서 모아 닫을 수 있습니다.
이미 aborted가 참인 신호에 리스너를 붙이면 영영 호출되지 않습니다. 등록 전에 aborted를 먼저 확인하지 않으면 정리 코드가 통째로 누락됩니다.
08취소 신호
타이머 예약 한 줄로, 10ms 뒤 tick-8이 찍힙니다. 취소 신호라는 제목과 달리 취소 경로는 없고 예약만 존재합니다. 실제로 끊으려면 const id = setTimeout(...)처럼 id를 보관했다가 clearTimeout(id)를 부르거나, AbortSignal의 abort 이벤트에서 정리해야 합니다.
JavaScript code
// 취소 신호
setTimeout(() => console.log("tick-8"), 10);clearTimeout은 이미 실행됐거나 존재하지 않는 id를 넘겨도 오류 없이 무시되므로, 취소 여부를 따로 추적하지 않고 그냥 불러도 안전합니다.
타이머 id를 지역 변수에만 두면 다른 함수에서 취소할 수 없습니다. 취소가 필요한 타이머는 id를 바깥 범위나 객체 필드에 보관해야 합니다.
09비동기 에러 처리
run 안에서 await가 이행된 프로미스의 값 9를 받아 출력하며, 거부 경로는 등장하지 않습니다. 오류를 다루려면 await를 try/catch로 감싸거나 호출부에 run().catch(...)를 붙입니다. await한 프로미스가 거부되면 그 자리에서 throw와 같아져, 함수가 반환한 프로미스도 함께 거부됩니다.
JavaScript code
// 비동기 에러 처리
async function run() {
const value = await Promise.resolve(9);
console.log(value);
}
run();try/catch는 await한 프로미스의 거부만 잡습니다. 기다리지 않고 호출만 해 둔 프로미스의 실패는 같은 블록 안이어도 잡히지 않습니다.
catch에서 로그만 남기고 넘어가면 호출부는 성공한 줄 알고 다음 단계를 진행합니다. 복구할 수 없는 오류는 다시 던지거나 실패 값을 반환해야 합니다.
10배치 처리
배치 처리의 골격은 Promise.all에 넘긴 배열이며, 두 작업이 끝나면 결과가 순서대로 담긴 배열이 then으로 전달됩니다. 요청 여러 건을 묶어 한 번에 보내고 결과를 한꺼번에 받을 때 이 구조를 씁니다. 배열이 커지면 동시 실행 수를 제한하지 않는 한 연결이 한꺼번에 열리므로 묶음 크기를 나눠야 합니다.
JavaScript code
// 배치 처리
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);동시 실행 수를 제한할 때는 배열을 조각내 Promise.all을 차례로 부르면 되는데, 한 조각이 늦으면 다음 조각 전체가 함께 밀린다는 점을 감안해야 합니다.
Promise.all은 하나가 거부되면 즉시 거부되지만 나머지 작업이 취소되지는 않습니다. 이미 나간 요청은 계속 돌면서 결과만 버려집니다.
11debounce
new AbortController()를 만들고 곧바로 중단시킨 뒤 aborted 플래그를 확인하는 세 줄입니다. 디바운스에 필요한 타이머 재설정 로직은 없지만, 앞선 호출을 무효로 만드는 부분은 이 신호로 구현할 수 있습니다. fetch에 signal을 넘겨 두면 중단 시 프로미스가 AbortError로 거부됩니다.
JavaScript code
// debounce
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);디바운스에서는 새 호출마다 이전 컨트롤러를 중단시키고 새로 만들어 두면, 늦게 도착한 예전 응답이 최신 결과를 덮어쓰는 일을 막을 수 있습니다.
AbortError를 일반 실패와 같이 취급해 오류 화면을 띄우면, 사용자가 빠르게 입력할 때마다 정상 취소가 오류로 보입니다. error.name으로 갈라야 합니다.
12throttle
출력 예약 한 건, 즉 10ms 뒤 tick-12를 찍는 setTimeout만 있습니다. 스로틀은 보통 마지막 실행 시각을 기억하거나 대기 중인 타이머 유무를 확인해 만드는데, 이 코드에는 그 상태 변수가 없습니다. 브라우저에서는 중첩이 깊어진 타이머의 최소 지연이 4ms로 올라가는 규칙도 있습니다.
JavaScript code
// throttle
setTimeout(() => console.log("tick-12"), 10);스로틀은 마지막 실행 시각을 기억해 간격이 지나야 통과시키는 방식과, 대기 중인 타이머가 없을 때만 새로 예약하는 방식으로 구현이 갈립니다.
스크롤이나 입력 이벤트마다 setTimeout을 새로 걸면서 이전 타이머를 지우지 않으면, 예약이 겹겹이 쌓여 핸들러가 뒤늦게 수십 번 몰려 실행됩니다.
13파일 읽기 작업
파일 입출력 API는 없고, await Promise.resolve(13)이 값을 돌려주는 자리를 대신합니다. Node에서 실제로 읽는다면 fs/promises의 readFile을 같은 자리에 놓으면 됩니다. 콜백형 fs.readFile과 달리 프로미스형은 await과 바로 맞물리고 오류도 try/catch로 잡힙니다.
JavaScript code
// 파일 읽기 작업
async function run() {
const value = await Promise.resolve(13);
console.log(value);
}
run();fs/promises의 readFile은 기다리는 동안 이벤트 루프를 막지 않아, 요청을 처리하는 도중에도 안전하게 부를 수 있습니다.
서로 무관한 파일 여러 개를 루프 안에서 하나씩 기다리면 디스크가 놀고 있는데도 순서대로 대기합니다. 이런 읽기는 한꺼번에 묶는 편이 훨씬 빠릅니다.
14요청 작업 감싸기
요청 두 건을 흉내 낸 프로미스를 배열로 묶어 Promise.all에 넘기고, 모인 결과 배열을 그대로 로그로 보냅니다. 응답을 함께 기다렸다가 한 덩어리로 다루고 싶을 때의 기본형입니다. then(console.log)처럼 메서드를 그대로 넘기면 this가 console에서 떨어져 나오는데, 모든 메서드가 이 상태에서 동작하지는 않습니다.
JavaScript code
// 요청 작업 감싸기
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);fetch는 404나 500 응답도 거부가 아니라 정상 이행으로 처리하므로, 감싸는 함수 안에서 res.ok를 확인해 직접 오류로 바꿔 줘야 합니다.
메서드를 떼어 넘기는 습관을 다른 객체에도 적용하면 this가 끊겨 TypeError가 납니다. 필요하면 화살표 함수로 감싸 호출해야 합니다.
15스케줄 작업
스케줄러 대신 중단 컨트롤러가 놓여 있습니다. abort() 뒤 signal.aborted가 true이므로, 예약된 작업을 시작 전에 무효로 만드는 판단 기준으로 쓸 수 있습니다. AbortSignal.timeout(ms)처럼 시간이 지나면 스스로 중단되는 신호를 쓰면 타이머와 취소를 한 객체로 합칠 수 있습니다.
JavaScript code
// 스케줄 작업
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);AbortSignal.any([a, b])로 여러 신호를 합치면 사용자 취소와 제한 시간 초과 중 어느 쪽이 먼저 오더라도 같은 중단 경로를 타게 만들 수 있습니다.
반복 스케줄에 setInterval을 쓰면 콜백이 간격보다 오래 걸릴 때 실행이 밀려 겹칩니다. 작업이 끝난 뒤 다시 예약하는 방식이 안전합니다.
정리하며
- 독립 작업만 Promise.all로 묶고 의존 관계가 있으면 순차로 둡니다
- Promise.all의 실패는 다른 작업을 멈추지 않으므로 allSettled를 고려합니다
- 타임아웃과 재시도는 AbortController로 취소까지 함께 설계합니다
- debounce·throttle은 정리 함수로 남은 타이머를 반드시 해제합니다
더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.