TYPESCRIPT · 심층 가이드
TypeScript 비동기/동시성 완전 정리
async 함수는 호출되는 순간 이미 실행을 시작하므로, 같은 코드가 순차가 되느냐 병렬이 되느냐는 await를 어느 지점에 두느냐라는 한 가지 선택으로 갈립니다.
TypeScript가 도는 런타임은 대부분 단일 스레드이고, 동시성은 스레드가 아니라 이벤트 루프에 얹힌 대기로 구현됩니다. 여기서 모델이 깨지는 자리는 Promise의 생성 시점입니다. Promise는 만들어지는 즉시 작업을 시작하며 await는 결과를 기다릴 뿐입니다. 그래서 반복문 안에서 곧바로 await하면 전부 직렬로 늘어서고, 먼저 전부 만들어 두었다가 Promise.all로 모으면 같은 코드가 병렬이 됩니다.
비동기 함수 시작에서 반환 타입이 항상 Promise로 감싸진다는 사실을 확인한 뒤, 순차 작업 처리와 병렬 작업 처리를 나란히 놓고 코드 모양은 비슷한데 실행 시간이 왜 달라지는지를 봅니다. 그 위에 timeout 처리와 retry 로직이 얹히는데, 둘 다 원래 작업을 그대로 두고 바깥에서 감싸는 래퍼라는 점이 같습니다. 취소 신호까지 오면 시작·대기·중단이라는 세 축이 모두 갖춰지고, 나머지 주제는 이 조합의 변형으로 읽힙니다.
Promise.all은 하나가 거부되면 즉시 거부되지만 나머지 작업은 계속 돕니다. 중단된 것이 아니라 결과를 아무도 받지 않을 뿐이라, 뒤늦게 실패하면 처리되지 않은 거부가 되고 열어 둔 연결이나 파일이 그대로 남습니다. 전부의 성패를 알아야 한다면 Promise.allSettled를, 자원을 실제로 놓아야 한다면 AbortController를 함께 씁니다. 타임아웃 래퍼도 같은 이유로 원래 작업을 멈추지는 못합니다.
01비동기 함수 시작
async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.
TypeScript code
// 비동기 함수 시작
async function run(): Promise<void> {
const value = await Promise.resolve(1);
console.log(value);
}
run();await은 프로미스가 아닌 값에도 쓸 수 있고, 이때도 마이크로태스크 한 턴을 거친 뒤 다음 줄이 실행됩니다. 반환 타입을 Promise<void>로 적어 두면 값을 실수로 반환할 때 걸립니다.
run()의 반환값을 버리면 안에서 생긴 거부가 처리되지 않은 프로미스로 남아 Node가 프로세스를 종료시킬 수 있습니다. 최소한 .catch()는 붙여야 합니다.
02순차 작업 처리
이미 이행된 두 프로미스를 Promise.all에 넣고 결과 배열을 then으로 받습니다. 순서를 유지한 채 여러 작업 결과를 한 번에 모을 때 씁니다. 하나라도 거부되면 나머지 결과를 버리고 즉시 거부되므로, 실패까지 모두 보고 싶다면 Promise.allSettled가 맞습니다.
TypeScript code
// 순차 작업 처리
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);Promise.all에 배열 리터럴을 그대로 넘기면 결과가 튜플로 추론되어, 구조 분해로 받은 각 값의 타입이 인덱스별로 그대로 유지됩니다.
제목은 순차 처리지만 Promise.all에 넣는 순간 두 작업이 동시에 시작됩니다. 앞 결과를 뒤에서 써야 한다면 await을 줄줄이 쓰는 형태로 바꿔야 합니다.
03병렬 작업 처리
AbortController를 만들자마자 abort()를 호출하고 signal.aborted가 참으로 바뀐 것을 확인합니다. 취소 가능한 작업의 신호 전달 구조를 실험할 때 이 정도면 충분합니다. 신호는 한 번 중단되면 되돌릴 수 없어, 재시도할 때는 컨트롤러를 새로 만들어야 합니다.
TypeScript code
// 병렬 작업 처리
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);signal.aborted는 불리언 스냅숏일 뿐이라, 진행 중인 작업을 실제로 멈추려면 그 signal을 fetch나 이벤트 리스너의 옵션으로 넘겨 줘야 합니다.
컨트롤러를 모듈 최상단에 하나만 두고 재사용하면 첫 취소 이후 모든 요청이 시작하자마자 거부됩니다. 요청마다 새 컨트롤러를 만들어야 합니다.
04timeout 처리
setTimeout으로 10밀리초 뒤에 한 줄을 출력하도록 예약합니다. 시간 제한을 붙이려면 이 타이머와 실제 작업을 Promise.race로 겨루게 만드는 식으로 확장합니다. 지연 시간은 최소 대기 시간일 뿐이라 이벤트 루프가 바쁘면 10ms보다 늦게 호출됩니다.
TypeScript code
// timeout 처리
setTimeout(() => console.log("tick-4"), 10);브라우저는 중첩 깊이가 다섯을 넘어선 타이머의 최소 지연을 4밀리초로 올리기 때문에, 0을 지정해도 곧바로 실행되지는 않습니다.
타이머 콜백 안에서 던진 오류는 바깥 try/catch에 잡히지 않고 전역 예외로 빠져나갑니다. 콜백 안에서 직접 처리해야 합니다.
05retry 로직
재시도 자리에 놓인 코드는 await 한 번으로 값 5를 받아 출력하는 최소 형태입니다. 실제 재시도는 run 본문을 try로 감싸고 루프를 도는 방식으로 붙입니다. 실패마다 곧바로 다시 부르면 상대 서버를 더 밀어붙이므로 대기 간격을 늘려 가는 편이 안전합니다.
TypeScript code
// retry 로직
async function run(): Promise<void> {
const value = await Promise.resolve(5);
console.log(value);
}
run();재시도 간격을 두 배씩 늘리면서 약간의 무작위 값을 더하면, 여러 클라이언트가 같은 순간에 몰려 다시 실패하는 현상을 줄일 수 있습니다.
모든 실패를 재시도 대상으로 삼으면 400이나 401처럼 다시 보내도 결과가 같은 오류까지 반복 호출하게 되어 시간만 낭비합니다.
06스트림 또는 채널 소비
스트림을 다루는 대신, 문자열 두 개를 담은 프로미스 배열을 한꺼번에 기다려 결과를 찍습니다. 조각을 모두 모은 뒤 한 번에 처리하는 소비 방식에 해당합니다. 도착하는 대로 처리해야 한다면 for await...of로 비동기 이터러블을 도는 쪽이 메모리에 유리합니다.
TypeScript code
// 스트림 또는 채널 소비
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);for await...of는 조각을 하나 처리하고 버리는 방식이라, 전체를 배열에 모으는 형태와 달리 메모리 사용량이 조각 크기 수준에 머무릅니다.
조각을 모두 배열에 모은 뒤 처리하면 입력이 커질수록 메모리가 선형으로 늘어, 크기를 예측할 수 없는 로그 파일에서 프로세스가 죽습니다.
07producer consumer
생산자와 소비자를 잇는 큐 대신, 컨트롤러 하나를 만들어 곧바로 중단하고 그 상태를 읽습니다. 소비 쪽을 언제 멈출지 알려 주는 공유 신호를 어떻게 표현하는지 보여 줍니다. signal은 abort 이벤트도 발생시키므로, 대기 중인 소비자는 리스너로 깨울 수 있습니다.
TypeScript code
// producer consumer
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);signal.addEventListener('abort', ...)로 대기 중인 소비자를 깨울 수 있습니다. 다만 이미 중단된 신호에는 이벤트가 다시 나지 않아 aborted를 먼저 확인해야 합니다.
생산 속도가 소비 속도보다 빠른데 큐 길이에 상한을 두지 않으면, 중단 신호를 걸어 두어도 그 전에 큐가 메모리를 다 먹어 버립니다.
08취소 신호
여기서는 타이머 하나만 걸어 두고 10ms 뒤 한 줄을 출력합니다. 취소를 붙이려면 setTimeout이 돌려주는 핸들을 보관했다가 clearTimeout에 넘겨야 합니다. Node에서는 살아 있는 타이머가 프로세스 종료를 붙잡으므로, 정리하지 않으면 프로그램이 늦게 끝납니다.
TypeScript code
// 취소 신호
setTimeout(() => console.log("tick-8"), 10);Node의 setTimeout은 숫자가 아니라 Timeout 객체를 돌려주므로, 핸들 변수는 ReturnType<typeof setTimeout>으로 선언해야 타입이 맞습니다.
핸들을 보관하지 않으면 clearTimeout을 부를 수 없어, 이미 필요 없어진 콜백이 뒤늦게 실행되며 사라진 상태를 건드립니다.
09비동기 에러 처리
await 한 줄이 성공 경로만 밟는 코드라 에러 처리는 아직 어디에도 없습니다. 실제로는 await을 try/catch로 감싸야 거부된 프로미스를 잡을 수 있습니다. 현대 설정에서 catch가 받는 값의 타입은 unknown이라, message를 읽기 전에 좁혀야 합니다.
TypeScript code
// 비동기 에러 처리
async function run(): Promise<void> {
const value = await Promise.resolve(9);
console.log(value);
}
run();await을 감싼 try는 거부를 예외로 바꿔 잡아 주지만, await 없이 호출만 해 둔 프로미스의 거부는 같은 블록에서 잡히지 않습니다.
catch (error)의 error는 unknown이라 error.message를 바로 읽으면 컴파일 오류가 납니다. instanceof Error로 먼저 좁혀야 합니다.
10배치 처리
두 개짜리 배열을 통째로 Promise.all에 넘겨 한 배치로 처리합니다. 항목이 수천 개일 때 이 방식을 그대로 쓰면 동시 요청이 한꺼번에 터지므로, 배열을 잘라 배치마다 나눠 호출합니다. 결과는 입력 순서에 맞춰 튜플로 추론되어 인덱스별 타입이 유지됩니다.
TypeScript code
// 배치 처리
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);배치 크기는 상대 서버의 동시 연결 한도에 맞춰 정하고, 배치마다 완료를 기다리면 동시 요청 수를 그 크기 이하로 묶어 둘 수 있습니다.
수천 개 항목을 한 번에 Promise.all에 넣으면 소켓이 고갈되거나 상대가 429로 막아, 결과적으로 순차 처리보다 느려집니다.
11debounce
디바운스 대신 놓인 코드는 컨트롤러를 즉시 중단해 aborted가 참임을 보여 줍니다. 입력이 이어질 때 앞선 요청을 버리는 디바운스 구현에서 이 취소 신호가 그대로 쓰입니다. 취소된 fetch는 AbortError로 거부되므로, 일반 실패와 구분해 무시해야 합니다.
TypeScript code
// debounce
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);디바운스는 앞선 타이머를 clearTimeout으로 지우고 새로 거는 방식이라, 이미 나간 네트워크 요청까지 멈추려면 컨트롤러 취소를 함께 걸어야 합니다.
취소로 생긴 AbortError를 일반 실패로 처리해 화면에 띄우면, 사용자가 글자를 칠 때마다 오류 메시지가 깜빡이게 됩니다.
12throttle
10ms 타이머 한 개가 전부이고 콜백은 한 번만 실행됩니다. 스로틀은 여기에 마지막 실행 시각을 기억하는 변수를 더해 정해진 간격 안의 호출을 건너뛰는 식으로 만듭니다. 타이머 콜백이 화살표 함수라 바깥 this를 그대로 쓰고, 일반 함수로 바꾸면 this가 달라집니다.
TypeScript code
// throttle
setTimeout(() => console.log("tick-12"), 10);스로틀은 마지막 실행 시각을 Date.now()로 기억해 간격 안의 호출을 버리는데, 마지막 호출까지 반영하려면 끝에 타이머를 하나 남겨 둬야 합니다.
setTimeout 콜백을 일반 함수로 쓰면 this가 인스턴스가 아니게 되어, 객체 메서드를 스로틀로 감쌀 때 필드 접근이 undefined가 됩니다.
13파일 읽기 작업
파일 입출력 대신 Promise.resolve(13)을 기다려 값을 출력하는 자리표시 코드입니다. Node에서 실제로 읽을 때는 node:fs/promises의 readFile을 같은 자리에 넣습니다. 인코딩을 넘기지 않으면 반환 타입이 string이 아니라 Buffer가 됩니다.
TypeScript code
// 파일 읽기 작업
async function run(): Promise<void> {
const value = await Promise.resolve(13);
console.log(value);
}
run();readFile(path, 'utf8')처럼 인코딩을 주면 반환 타입이 Promise<string>으로 좁혀지고, 생략하면 Buffer가 나옵니다.
인코딩을 생략한 채 slice를 부르면 문자가 아니라 바이트 단위로 잘려, 한글이 중간에서 끊기고 깨진 글자가 남습니다.
14요청 작업 감싸기
요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.
TypeScript code
// 요청 작업 감싸기
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);요청 래퍼는 res.ok 확인과 상태 코드를 담은 오류 생성을 한곳에 모아 두는 것만으로도 호출부의 분기 코드를 크게 줄여 줍니다.
await res.json()의 반환 타입은 any라, 제네릭으로 타입을 붙여도 실제 응답 모양은 검사되지 않고 필드 오타가 런타임까지 살아남습니다.
15스케줄 작업
스케줄러 대신 컨트롤러를 만들어 바로 취소한 뒤 플래그를 읽습니다. 주기 작업을 돌릴 때는 이런 신호를 반복 조건에 걸어 다음 회차 진입을 막습니다. AbortSignal.timeout()을 쓰면 일정 시간 뒤 자동으로 중단되는 신호를 직접 타이머를 만들지 않고 얻습니다.
TypeScript code
// 스케줄 작업
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);AbortSignal.timeout(ms)은 정해진 시간이 지나면 스스로 중단되는 신호를 만들어 주어, 타이머와 컨트롤러를 직접 짝지어 관리하지 않아도 됩니다.
주기 작업에서 이전 회차가 끝나기 전에 다음 회차가 시작되면 실행이 겹쳐 쌓입니다. setInterval 대신 완료 후 다시 예약하는 방식이 안전합니다.
정리하며
- 반복문 안의
await는 직렬 실행이니 병렬이 필요하면 먼저 배열로 모읍니다. Promise.all은 거부 시 나머지를 멈추지 않으므로 취소 신호를 따로 전달합니다.- 타임아웃은 대기만 끊을 뿐이라 실제 중단은
AbortController로 처리합니다. - 재시도는 네트워크 오류와 검증 실패를 구분해 재시도 대상만 다시 던지게 만듭니다.
더 깊이 들어가고 싶다면 TypeScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.