Menu

TypeScript async/await: 타입, 에러 처리, 병렬 호출

TypeScript에서 async와 await의 타입을 다룹니다. async 함수는 Promise<T>를 반환하고, await는 이를 풀며, 에러는 try/catch로 잡고, 최상위 await에는 ES 모듈이 필요합니다. 하나씩 await하기와 병렬로 await하기의 차이, forEach 함정도 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

TypeScript에서 async 함수는 항상 Promise를 반환합니다. 본문이 string을 반환하면 타입은 Promise<string>입니다. 그 안에서 await는 Promise<T>를 T로 풉니다.

런타임 동작(await가 무엇을 멈추는지, 이벤트 루프)은 JavaScript의 영역이며 JavaScript async/await에서 다룹니다. TypeScript가 맡는 것은 들어가고 나오는 타입입니다.

async 함수의 반환 타입

async 함수의 선언된 반환 타입은 본문이 평범한 값을 반환하더라도 Promise<...>여야 합니다. 생략하면 TypeScript가 추론합니다.

async function count(): number라고 쓰면 TS1064 오류(The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<number>'?)가 납니다. 유틸리티 타입 Awaited<T>는 풀린 타입을 줍니다. Awaited<ReturnType<typeof count>>는 number입니다.

await를 빠뜨렸을 때

await를 빠뜨리면 T 대신 Promise<T>를 들고 있게 됩니다. 타입이 더 이상 맞지 않으므로 TypeScript가 대부분 잡아냅니다.

if (ok) 버그는 실제로 일어납니다. Promise 객체는 항상 truthy이므로 접근을 허용해 버렸습니다. TypeScript는 이를 TS2801로 보고합니다. Promise를 boolean 변수에 대입하거나 Promise에 없는 속성을 읽어도 컴파일에 실패합니다. 결과를 무시하는 호출(save(user);)은 잡히지 않으며, typescript-eslint의 no-floating-promises 규칙이 이를 담당합니다.

try/catch로 에러 처리하기

거부된 Promise는 await에서 예외를 던지므로 평범한 try/catch가 동작합니다. strict에서 catch의 변수는 unknown이므로 .message를 읽기 전에 좁혀야 합니다.

async 함수 안의 throw는 호출한 곳에서 예외를 던지는 것이 아니라 그 함수의 Promise를 거부합니다. 사용자 정의 에러 클래스, 예외 대신 결과 반환하기 등 더 많은 패턴은 에러 처리 페이지에 있습니다.

최상위 await

함수 밖의 await는 ES 모듈에서만 동작합니다. CommonJS로 컴파일되는 파일(이 예제들, 그리고 "type": "module"이 없는 Node 프로젝트가 해당합니다)에서는 거부됩니다.

index.ts(2,14): error TS1309: The current file is a CommonJS module and cannot use 'await' at the top level.

이 페이지의 모든 예제처럼 코드를 async main 함수로 감싸서 호출하세요. ES 모듈 프로젝트(package.json에 "type": "module"이 있고 module이 node16이나 nodenext이거나, 번들러용으로 module: "esnext"인 경우)에서는 최상위 await가 허용됩니다.

순차 실행 vs 병렬 실행

각 await는 다음 줄이 시작되기 전에 자신의 Promise를 기다립니다. 서로 독립적인 호출이라면 모두 먼저 시작하고 Promise.all로 한꺼번에 await하세요.

순차 실행 부분에서 b는 아직 시작하지도 않았으므로 a보다 먼저 끝날 수 없습니다. 병렬 실행 부분에서는 d가 먼저 끝나고, 전체 시간은 합계가 아니라 가장 긴 호출 정도입니다. 그래도 Promise.all은 결과를 입력 순서대로, 튜플 타입으로 반환합니다.

forEach 함정

forEach는 async 콜백이 반환하는 Promise를 무시하므로 아무것도 작업을 기다리지 않습니다.

await와 함께 쓰는 for...of는 항목을 하나씩 처리하고, map과 함께 쓰는 Promise.all은 병렬로 실행한 뒤 모두 기다립니다. forEach는 어느 쪽도 하지 않으며, void를 반환하도록 타입 지정된 콜백은 Promise를 반환하는 콜백도 받아들이므로 TypeScript는 경고하지 않습니다.

for await로 비동기 순회하기

for await...of는 async 제너레이터 같은 비동기 이터러블을 순회하며 각 값을 await합니다.

async function* pages(total: number): AsyncGenerator<string[]> {
    for (let page = 1; page <= total; page++) {
        await new Promise((r) => setTimeout(r, 10));
        yield [`item ${page}a`, `item ${page}b`];
    }
}

async function main() {
    for await (const batch of pages(3)) {
        console.log(batch.join(", ")); // batch: string[]
    }
}
main();

요소 타입은 제너레이터의 AsyncGenerator<T> 타입 표기에서 오며, 생략하면 추론됩니다.

자주 묻는 질문

TypeScript에서 async 함수의 반환 타입은 무엇인가요?

항상 Promise입니다. number를 반환하는 async 함수의 반환 타입은 Promise<number>이고, 아무것도 반환하지 않으면 Promise<void>입니다. async function f(): number라고 쓰면 Promise<number>를 제안하는 TS1064 오류가 납니다.

TypeScript에서 최상위 await는 어떻게 쓰나요?

최상위 await는 ES 모듈에서만 동작합니다. module이 es2022, esnext, system, preserve이거나, Node가 ESM으로 다루는 파일에서 node16/node18/node20/nodenext여야 하고, target은 es2017 이상이어야 합니다. CommonJS 파일에서는 TS1309 오류입니다. 어디서나 통하는 해결책은 async main 함수입니다: async function main() { ... } main();.

TypeScript에서 async/await의 에러는 어떻게 처리하나요?

await를 try/catch로 감쌉니다. strict에서는 잡힌 값이 unknown으로 타입 지정되므로 먼저 좁히세요: if (e instanceof Error) console.log(e.message). await하지도 catch하지도 않은 거부는 처리되지 않은 거부가 됩니다.

TypeScript에서 비동기 호출을 병렬로 실행하려면 어떻게 하나요?

Promise를 모두 먼저 시작한 뒤 한꺼번에 await합니다: const [a, b] = await Promise.all([loadA(), loadB()]). await loadA(); await loadB();라고 쓰면 하나씩 차례로 실행됩니다. Promise.all은 결과 튜플에서 각 결과의 타입을 유지합니다.

forEach 안에서 async가 동작하지 않는 이유는 무엇인가요?

forEach는 콜백을 호출하고 반환값을 무시하므로 async 콜백이 만든 Promise는 await되지 않습니다. 루프는 즉시 끝나고, 그 뒤의 코드가 작업이 끝나기 전에 실행됩니다. 하나씩 처리하려면 await와 함께 for...of를, 병렬로 처리하려면 await Promise.all(items.map(async (x) => ...))를 쓰세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기