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)

세 가지를 주의해야 합니다:

  • 항상 await expect(...).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을 활성화하세요.

typescriptasyncpromisegenericsvitest