Node.js의 Async/Await와 에러 처리

1. 개요

async/await를 사용하면 Node.js에서 비동기 코드를 동기 코드처럼 작성할 수 있어서 콜백이나 .then() 체인보다 훨씬 읽기 쉬워집니다. 하지만 내부 메커니즘을 제대로 이해하지 못하면 에러 처리 방식을 잘못 작성하기 쉽고, 이로 인해 UnhandledPromiseRejection이 발생해 애플리케이션 전체가 중단될 수 있습니다.

이 글에서는 async/await의 동작 원리부터 시작해서 올바른 에러 처리 패턴, 그리고 Promise.all, 반복문, Express 미들웨어와 함께 사용할 때 흔한 실수들을 다룹니다. 이 글을 읽고 나면 안전한 async 함수를 작성하는 방법과 비동기 에러를 빠르게 디버깅하는 방법을 알게 될 것입니다.

2. Async/Await는 어떻게 동작하나

async로 선언된 함수는 항상 Promise를 반환합니다. 함수 내부에서 어떤 값을 return하든 상관없습니다. await는 호출 지점에서만 “일시 중지”되며, 이벤트 루프에 제어권을 넘기고 스레드를 블로킹하지 않습니다.

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const user = await response.json();
  return user;
}

getUser(1).then(user => console.log(user.name));

await fetch(...) 줄은 fetch에서 반환한 Promise가 resolve될 때까지 기다렸다가 계속 진행됩니다. 만약 fetch가 reject된다면 (예: 네트워크 오류), 에러는 그 await 지점에서 throw됩니다. 에러를 잡지 않으면 getUser가 반환하는 Promise도 함께 reject됩니다.

주의: async function은 함수 내부에서 await를 사용하지 않더라도 항상 Promise를 반환합니다. 이 함수를 호출할 때 await나 .then()을 빠뜨리는 것이 “코드가 예상과 다른 순서로 실행된다”는 버그의 가장 흔한 원인입니다.

3. try/catch로 에러 처리하기

async 함수에서 에러를 처리하는 가장 표준적인 방법은 try/catch로 await 지점들을 감싸는 것입니다.

async function getOrder(orderId) {
  try {
    const order = await orderRepository.findById(orderId);
    if (!order) {
      throw new Error(`Order ${orderId} not found`);
    }
    return order;
  } catch (err) {
    console.error('Failed to get order:', err.message);
    throw err; // re-throw하면 호출자도 에러를 알 수 있습니다
  }
}
  • try 블록은 throw되거나 Promise가 reject될 수 있는 모든 로직을 감쌉니다.
  • catch (err)는 명시적으로 throw된 에러와 Promise reject 모두를 받습니다.
  • 끝에서 throw err를 해서 에러를 “삼키지” 않습니다. 이렇게 하면 호출자는 여전히 reject된 Promise를 받게 됩니다.

주의: 에러를 catch했지만 다시 throw하지 않고 올바른 값도 return하지 않으면, async 함수는 undefined로 resolve됩니다. 호출자는 모든 것이 정상이라고 생각하지만 데이터가 누락되어 있습니다. 이런 버그는 찾기가 매우 어렵습니다.

3.1. 커스텀 에러 클래스

에러를 명확히 분류하기 위해 (비즈니스 로직 에러, 시스템 에러 등), 커스텀 에러 클래스를 만드는 것이 좋습니다.

class PaymentError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'PaymentError';
    this.code = code;
  }
}

async function chargeCard(order) {
  const result = await paymentGateway.charge(order.total);
  if (!result.success) {
    throw new PaymentError('Card declined', 'CARD_DECLINED');
  }
  return result;
}

에러를 처리할 때 err instanceof PaymentError로 확인해서 에러 유형에 따라 다르게 처리할 수 있습니다. 에러 메시지 문자열을 비교하는 것보다 훨씬 안전합니다.

4. Promise.all과 Promise.allSettled의 에러 처리

Promise.all은 여러 Promise를 병렬로 실행하지만, 하나만 reject되어도 전체가 즉시 reject됩니다. 나머지 Promise들은 “백그라운드에서” 계속 실행되지만 그 결과는 무시됩니다.

async function notifyCustomers(customers) {
  const results = await Promise.all(
    customers.map(c => emailService.send(c.email))
  );
  return results;
}

두 번째 고객의 emailService.send가 실패하면 함수 전체가 즉시 reject됩니다. 첫 번째, 세 번째, 네 번째 고객의 이메일이 성공적으로 발송되었는지 알 수 없습니다.

모든 Promise의 결과를 알고 싶을 때는 (실패해도 상관없이) Promise.allSettled를 사용하세요:

async function notifyCustomersSafely(customers) {
  const results = await Promise.allSettled(
    customers.map(c => emailService.send(c.email))
  );

  const failed = results.filter(r => r.status === 'rejected');
  if (failed.length > 0) {
    console.warn(`${failed.length} email(s) failed to send`);
  }
  return results;
}

Promise.allSettled는 절대 reject되지 않습니다. results 배열의 각 요소는 { status: 'fulfilled', value } 또는 { status: 'rejected', reason } 형태입니다. 이후 어떻게 처리할지는 당신이 결정합니다.

[
  { status: 'fulfilled', value: true },
  { status: 'rejected', reason: Error: SMTP timeout },
  { status: 'fulfilled', value: true }
]

5. 반복문에서 Await 사용 시 흔한 실수

forEach 내에서 await를 사용하는 것은 매우 흔한 실수입니다. forEach의 콜백은 async를 기다리지 않습니다.

// 잘못된 방법: forEach는 async를 기다리지 않습니다
async function processOrders(orders) {
  orders.forEach(async order => {
    await orderService.process(order);
  });
  console.log('Done'); // 주문이 처리되기 전에 즉시 실행됩니다
}

forEach는 async 콜백을 호출한 후 반환된 Promise를 무시하고 기다리지 않습니다. console.log('Done')은 주문이 처리되기 전에 실행됩니다. 만약 process가 에러를 throw하면, 그 에러를 아무데서도 잡지 않아 UnhandledPromiseRejection이 발생합니다.

순차적으로 처리해야 한다면 for...of를 사용하세요:

async function processOrdersSequentially(orders) {
  for (const order of orders) {
    await orderService.process(order);
  }
  console.log('Done');
}

또는 병렬로 처리하고 싶으면 Promise.all과 map을 사용하세요:

async function processOrdersParallel(orders) {
  await Promise.all(orders.map(order => orderService.process(order)));
  console.log('Done');
}

6. Express 미들웨어에서의 에러 처리

Express는 기본적으로 라우트 핸들러의 async 함수에서 발생하는 에러를 잡지 않습니다. 함수 내에서 throw하면 요청이 멈추거나 프로세스가 중단될 수 있습니다 (Express 4는 Promise rejection을 자동으로 처리하지 않습니다).

// 위험: async 에러를 Express가 자동으로 잡지 않습니다
app.get('/orders/:id', async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  res.json(order);
});

안전한 방법은 에러를 next()로 전달하는 래퍼를 작성하는 것입니다:

function asyncHandler(fn) {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
}

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  if (!order) {
    const err = new Error('Order not found');
    err.statusCode = 404;
    throw err;
  }
  res.json(order);
}));

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
});

asyncHandler는 라우트 핸들러를 감싸고, 반환된 Promise가 reject되면 .catch(next)가 에러를 Express의 에러 처리 미들웨어로 전달합니다. 에러가 방치되지 않도록 합니다.

주의: Express 5 (이 글을 쓸 당시에는 베타 버전)는 async 핸들러의 rejection을 자동으로 처리하지만, 현재 널리 사용되는 Express 4에서는 이 래퍼가 여전히 필요합니다.

7. Timeout과 AbortController

쉽게 간과할 수 있는 문제: await로 기다리는 Promise가 절대 resolve되지 않으면 함수가 무한정 멈춥니다. AbortController를 사용해서 대기 시간을 제한하세요.

async function fetchWithTimeout(url, timeoutMs = 3000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

setTimeout은 timeoutMs 후에 controller.abort()를 호출해서 fetch가 AbortError 이름의 에러로 reject되도록 합니다. finally 블록은 성공 또는 실패 여부에 관계없이 항상 타이머를 정리해서 메모리 누수를 방지합니다.

8. Async 함수 테스트하기

Jest를 사용해서 성공 경우와 실패 경우를 모두 테스트합니다.

<!-- package.json (의존성 부분, Maven이 아님) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
  const order = await repository.findById(orderId);
  if (!order) {
    throw new Error(`Order ${orderId} not found`);
  }
  return order;
}

module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');

describe('getOrder', () => {
  it('주문을 찾으면 반환', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };

    const order = await getOrder(1, mockRepo);

    expect(order).toEqual({ id: 1, total: 100 });
  });

  it('주문을 찾지 못하면 에러 throw', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue(null) };

    await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
  });
});

mockResolvedValue는 주어진 값으로 resolve되는 Promise를 시뮬레이션합니다. rejects.toThrow는 Promise가 예상하는 메시지로 reject되는지 확인합니다. 이것이 테스트에서 try/catch를 직접 사용하는 대신 비동기 에러를 테스트하는 표준 방법입니다.

PASS  ./orderService.test.js
  getOrder
    ✓ 주문을 찾으면 반환 (3 ms)
    ✓ 주문을 찾지 못하면 에러 throw (2 ms)

9. 피해야 할 흔한 실수들

  • await 빠뜨리기: async 함수를 호출하지만 await를 하지 않으면 코드가 예상과 다른 순서로 실행되고, 에러가 조용히 무시됩니다.
  • .then()과 await 섞어 쓰기: 혼동하기 쉬우므로 한 함수 내에서는 하나의 스타일만 사용하세요.
  • 너무 넓은 try/catch: 비동기와 무관한 로직까지 감싸면 에러의 출처를 파악하기 어렵습니다.
  • catch에서 re-throw하지 않기: 호출자가 에러 대신 undefined를 받게 되어 상위 계층에서 은폐된 버그를 유발합니다.
  • async 콜백과 함께 forEach 사용: 기다리지 않으므로 for...of 또는 Promise.all을 사용하세요.

주의: 로직에 I/O나 비동기 작업이 없다면 함수를 async로 표시할 필요가 없습니다. 불필요한 Promise 생성 오버헤드만 증가시킵니다.

10. 결론

async/await는 Node.js에서 비동기 코드를 읽기 쉽게 만들지만, 올바른 에러 처리가 애플리케이션을 안정적으로 유지하는 핵심입니다. 항상 await 지점을 try/catch로 감싸고, 필요할 때 에러를 re-throw하고, 병렬 작업의 모든 결과를 알고 싶을 때는 Promise.allSettled를 사용하세요. Express 라우트 핸들러를 위한 래퍼도 잊지 마세요. 성공 경우와 실패 경우 모두에 대한 테스트를 작성하면 나중에 비동기 로직을 리팩토링할 때 훨씬 더 자신감 있게 진행할 수 있습니다.

nodejsasync-awaitjavascripterror-handlingpromise