TypeScript async 함수와 Promise 타입 지정하기
1. 개요
async 함수는 현대 TypeScript 코드의 대부분을 차지합니다: API 호출, 데이터베이스 읽기, 큐 처리. async 함수를 작성하기는 쉽지만, 타입을 선언해서 컴파일러가 에러를 잡도록 하는 것은 더 어렵습니다.
이 글은 async 함수와 Promise에 타입을 지정하는 방법을 소개합니다. 주요 내용은 Promise<T>, Awaited<T>, Promise.all과 Promise.allSettled의 타입, 타입 안전한 에러 처리, 그리고 timeout 같은 제네릭 헬퍼입니다. 이 글을 다 읽으면 await을 빼먹거나 forEach에서 async를 잘못 사용하는 것 같은 실수를 피할 수 있습니다.
2. 환경 설정
예제는 TypeScript의 strict 모드와 Vitest를 사용합니다:
npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext
--strict 플래그는 useUnknownInCatchVariables도 함께 활성화합니다. 이 플래그는 5번 섹션의 에러 처리에서 중요합니다.
모든 예제는 공통된 간단한 모델과 네트워크 지연을 시뮬레이션하는 함수를 사용합니다:
// models.ts
export interface Order {
id: string;
customerId: string;
total: number;
status: 'PENDING' | 'PAID';
}
export interface Customer {
id: string;
name: string;
}
export const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
delay는 Promise<void>를 반환합니다. 이것은 “완료됨”을 나타내지만 값을 담지 않는 Promise의 타입입니다. 여기서 new Promise<void>로 명시해야 합니다. 생략하면 TypeScript는 Promise<unknown>으로 추론합니다.
3. async 함수의 반환 타입
3.1. 항상 Promise
async 함수는 항상 Promise를 반환합니다. 함수 내부에서 반환하는 값이 그 안에 포장됩니다:
// order-service.ts
import { Order, Customer, delay } from './models';
const orders = new Map<string, Order>([
['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);
export async function fetchOrder(id: string): Promise<Order> {
await delay(50);
const order = orders.get(id);
if (!order) {
throw new Error(`Order ${id} not found`);
}
return order; // Order 타입, Promise<Order>로 포장됨
}
export async function fetchCustomer(id: string): Promise<Customer> {
await delay(30);
return { id, name: 'Nguyễn Văn An' };
}
타입 선언은 Promise<Order>이지 Order가 아닙니다. 만약 async function fetchOrder(id: string): Order라고 쓰면 컴파일러가 에러를 냅니다:
error TS1064: The return type of an async function or method must be the global Promise<T> type.
함수 내부의 throw는 반환 타입을 바꾸지 않습니다. 에러는 rejected Promise로 변환됩니다. 호출하는 쪽에서는 await 주변의 try/catch로 또는 .catch()로 잡을 수 있습니다.
3.2. 추론할까, 명시할까?
타입 주석을 생략하면 TypeScript이 반환 타입을 추론합니다. 하지만 public 함수의 경우 타입을 명시하는 것이 좋습니다. 다음 예제를 보세요:
async function findOrder(id: string) {
const order = orders.get(id);
if (!order) return null;
return order;
}
// 추론된 타입: Promise<Order | null>
이 타입은 맞지만, 함수 본문에 의존합니다. 누군가 첫 번째 분기를 return undefined로 바꾸면, 타입은 몰래 Promise<Order | undefined>로 바뀌고, === null을 확인하는 모든 코드가 깨져도 경고가 없습니다. Promise<Order | null>을 명시하면, 컴파일러가 함수 자체에서 바로 에러를 냅니다.
4. Awaited로 Promise “내부” 타입 얻기
때로 async 함수가 있고 await 후의 값 타입이 필요할 수 있습니다. ReturnType은 충분하지 않은데, 이것이 Promise 자체를 반환하기 때문입니다:
import { fetchOrder } from './order-service';
type R1 = ReturnType<typeof fetchOrder>; // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order
type Nested = Awaited<Promise<Promise<number>>>; // number
Awaited<T>는 await의 동작을 정확히 모방합니다. Promise를 재귀적으로 “풀어내며, 중첩된 Promise도 처리합니다. 이 타입은 제네릭을 작성할 때나 라이브러리가 결과 타입을 export하지 않을 때 매우 유용합니다:
type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }
5. 에러의 타입: Promise는 에러 파라미터가 없습니다
Promise<T>는 오직 하나의 타입 파라미터만 가지며, 성공 시의 값용입니다. Promise<Order, NotFoundError> 같은 문법은 없습니다. 따라서 catch의 변수는 unknown 타입입니다:
async function printOrder(id: string): Promise<void> {
try {
const order = await fetchOrder(id);
console.log(order.total);
} catch (err) {
// err: unknown
if (err instanceof Error) {
console.error('에러:', err.message);
} else {
console.error('알 수 없는 에러:', err);
}
}
}
await printOrder('o-404');
에러: Order o-404 not found
JavaScript는 어떤 값이든 throw할 수 있으므로: 문자열, 숫자, 객체. 따라서 unknown이 err의 유일하게 정직한 타입입니다. err.message에 접근하기 전에 instanceof로 타입을 좁혀야 합니다.
에러를 타입의 일부로 만들고 싶으면, Result 패턴을 사용할 수 있습니다:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
try {
return { ok: true, value: await promise };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
return { ok: false, error };
}
}
const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
console.log(result.value.status); // result.value: Order
} else {
console.log(result.error.message);
}
PENDING
ok 속성이 discriminant입니다. if (result.ok)를 확인한 후, TypeScript는 result.value가 존재하고 Order 타입임을 알 수 있습니다. 호출 측이 에러 처리를 “빼먹을” 수 없는데, value를 얻으려면 먼저 ok를 확인해야 하기 때문입니다.
참고: 모든 함수를
Result로 감싸지 마세요. 이 패턴은 예상된 비즈니스 에러(예: “찾을 수 없음”, “재고 부족”)에 잘 어울립니다. DB 연결 끊김 같은 인프라 에러는 상위 계층의 공통 에러 처리로 올려보내세요.
6. Promise.all, allSettled, race의 타입
6.1. Promise.all은 tuple 타입을 보존합니다
배열 리터럴을 전달하면, TypeScript은 각 위치마다 타입을 추론합니다:
const [order, customer] = await Promise.all([
fetchOrder('o-1'),
fetchCustomer('c-1'),
]);
// order: Order, customer: Customer
console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000
두 요청이 병렬로 실행됩니다. 전체 시간은 약 50ms이지 80ms가 아닙니다. 결과 타입은 [Order, Customer]이지 (Order | Customer)[]가 아닙니다. 덕분에 타입 강제 없이 destructure할 수 있습니다.
이것은 배열을 직접 전달할 때만 해당합니다. 먼저 배열을 만들고 전달하면 tuple 타입이 손실됩니다:
const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[] -- 위치 정보 손실
const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer
as const를 추가하면 tuple 타입이 유지됩니다.
6.2. Promise.allSettled와 타입 좁히기
Promise.allSettled는 reject하지 않습니다. 결과의 각 요소는 PromiseSettledResult<T> 타입입니다:
const settled = await Promise.allSettled([
fetchOrder('o-1'),
fetchOrder('o-404'),
]);
for (const r of settled) {
if (r.status === 'fulfilled') {
console.log('OK', r.value.id); // r.value: Order
} else {
console.log('FAIL', r.reason); // r.reason: any
}
}
OK o-1
FAIL Error: Order o-404 not found
r.reason은 any 타입이지 unknown이 아닙니다. 이것은 표준 라이브러리의 작은 허점이라, 사용하기 전에 instanceof Error를 확인해야 합니다.
6.3. Promise.race와 타입 보존하는 generic timeout 헬퍼
Promise.race는 Promise들의 합 타입을 반환합니다. Promise<never>와 결합하면, 타입을 보존하는 timeout 헬퍼를 만들 수 있습니다:
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`${ms}ms 후 타임아웃`)), ms),
);
return Promise.race([promise, timeout]);
}
const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order
timeout의 타입이 Promise<never>이므로, race 결과는 Promise<T | never>인데, 이는 Promise<T>입니다. never는 union 타입에서 사라집니다. 이것이 never의 의미입니다: “이 분기는 절대 값을 반환하지 않습니다”.
참고: 위 예제는
promise가 먼저 끝났을 때setTimeout을 취소하지 않습니다. 프로덕션 코드에서는 timer id를 저장했다가finally에서clearTimeout을 호출해야 합니다. 또 다른 방법은 하위 API가signal을 지원하면AbortSignal.timeout(ms)을 사용하는 것입니다.
7. async 콜백의 타입
7.1. () => void 함정
TypeScript는 Promise<void>를 반환하는 함수를 () => void 타입에 할당하도록 허용합니다. 해롭지 않아 보이지만 에러를 숨길 수 있습니다:
function onOrderPaid(handler: (order: Order) => void) {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
handler(order); // 반환된 Promise가 무시됨
console.log('핸들러 호출 완료');
}
onOrderPaid(async (order) => {
await delay(10);
throw new Error('이메일 전송 실패'); // unhandled rejection!
});
핸들러 호출 완료
Error: 이메일 전송 실패 (처리되지 않은 rejection, Node process를 crash할 수 있음)
onOrderPaid는 handler가 async임을 모릅니다. await하지 않으므로 에러를 아무도 잡지 못합니다. 수정 방법은 handler가 async일 수 있음을 명시한 후 await 하는 것입니다:
type OrderHandler = (order: Order) => void | Promise<void>;
async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
await handler(order); // Promise가 아닌 값에 await 하는 것도 합법
}
void | Promise<void> 타입은 동기 핸들러와 비동기 핸들러 모두를 받습니다. 동기 값에 await 하는 것은 그냥 그 값을 반환할 뿐입니다.
7.2. forEach의 async
가장 흔한 실수입니다. forEach는 (item) => void를 받으므로 Promise를 무시합니다:
async function payAll(list: Order[]): Promise<void> {
list.forEach(async (o) => {
await delay(20);
o.status = 'PAID';
});
console.log(list.map((o) => o.status)); // 여전히 ['PENDING', ...]
}
필요에 따라 두 가지 방법으로 고칠 수 있습니다:
// 순차적: 하나씩
for (const o of list) {
await delay(20);
o.status = 'PAID';
}
// 병렬: 반환 타입은 Promise<void[]>
await Promise.all(
list.map(async (o) => {
await delay(20);
o.status = 'PAID';
}),
);
이런 타입 에러를 자동으로 잡으려면, typescript-eslint에서 @typescript-eslint/no-floating-promises와 @typescript-eslint/no-misused-promises rule을 활성화하세요. TypeScript 컴파일러만으로는 이런 경우를 보고하지 않습니다.
8. Vitest로 동작과 타입 테스트하기
Vitest를 사용하면 런타임 값과 컴파일 타임 타입을 모두 테스트할 수 있습니다:
// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';
describe('order-service', () => {
it('주문이 존재하면 반환', async () => {
await expect(fetchOrder('o-1')).resolves.toMatchObject({
id: 'o-1',
status: 'PENDING',
});
});
it('주문이 없으면 reject', async () => {
await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
});
it('promise가 너무 오래 걸리면 withTimeout reject', async () => {
const slow = delay(200).then(() => '완료');
await expect(withTimeout(slow, 50)).rejects.toThrow('50ms 후 타임아웃');
});
it('Promise.all과 withTimeout의 타입 유지', () => {
expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
expectTypeOf(
Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
).resolves.toMatchTypeOf<[Order, { name: string }]>();
});
});
테스트 실행:
npx vitest run --typecheck
✓ order-service.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
세 가지를 주의해야 합니다:
- 항상
awaitexpect(...).resolves/.rejects전에. 이를 빼먹으면 Promise가 완료되기 전에 테스트가 끝나서 항상 통과합니다. .rejects.toThrow(...)는 reject된 에러의 메시지를 확인합니다.expectTypeOf는 런타임에 로직을 실행하지 않습니다.--typecheck플래그는 Vitest가tsc를 실행해서 타입을 확인하도록 합니다. 누군가withTimeout을Promise<unknown>을 반환하도록 바꾸면, 이 테스트는 실패합니다.
9. 흔한 실수
| 실수 | 결과 | 해결책 |
|---|---|---|
await 빼먹기 |
변수 타입이 Promise<Order> 대신 Order; 에러가 무시됨 |
no-floating-promises 활성화; 타입 호버링 주의깊게 읽기 |
if (fetchOrder(id)) |
항상 true (Promise는 object) |
TypeScript 5.x가 항상 참인 조건 보고; 먼저 await 하기 |
forEach의 async |
대기하지 않음, 에러가 잡히지 않음 | for...of 또는 Promise.all(map) |
catch에서 err.message 접근 |
strict 모드에서 컴파일 에러 |
instanceof Error로 좁히기 |
외부 데이터에 as Promise<Order> 강제 |
타입이 “거짓말”, 런타임 에러 발생 | schema로 validate한 후 타입 할당 |
마지막 실수를 더 설명하겠습니다. await response.json()은 Promise<any>를 반환합니다. const order: Order = await res.json()이라고 쓰면 컴파일되지만, TypeScript은 아무것도 확인하지 않습니다. 외부에서 오는 데이터는 항상 validate이 필요합니다.
참고: Promise를 반환하는 모든 함수가
async키워드를 필요로 하는 것은 아닙니다. 함수가 Promise를 그냥 전달할 뿐이면 (예:return repository.find(id)),async를 생략할 수 있습니다.await를 포함하는try/catch가 있을 때만async를 유지하세요.async를 빼고await가 있으면,catch가 reject를 잡지 못합니다.
10. 결론
async 함수는 항상 Promise<T>를 반환합니다. public API에서는 이 타입을 명시하세요. Promise를 “풀어야” 할 때는 Awaited<T>를 사용하세요. Promise.all은 배열 리터럴을 전달하거나 as const를 사용하면 tuple 타입을 유지합니다. Promise.race를 Promise<never>와 결합하면 타입을 보존하는 generic 헬퍼를 작성할 수 있습니다.
에러에 관해, Promise는 에러의 타입을 담지 않습니다. catch의 변수는 unknown이고, 비즈니스 에러가 타입에 드러나야 하면 Result 패턴을 사용하세요. 마지막으로, () => void 콜백과 forEach에 주의하세요. TypeScript 컴파일러가 놓치는 문제를 자동으로 잡으려면 ESLint의 Promise 관련 rule을 활성화하세요.