테크큐브 · IT테크

노드js 비동기 처리 방법: async/await 실전

✍ 테크큐브 편집팀 · 2026. 9. 19. · IT 트렌드·이슈
Node.js 이벤트 루프에서 파일 I/O와 Promise 작업이 처리되는 흐름
목차
  1. 노드js 비동기 처리, 무엇을 어떻게 써야 할까?
  2. 먼저 알아둘 이벤트 루프와 실행 순서
  3. 노드js 비동기 처리 방법 3가지 비교
  4. async/await로 구현하는 4단계
  5. 실무에서 자주 놓치는 오류 처리와 성능 문제
  6. 비동기 코드 작성 전 점검 목록
  7. 자주 묻는 질문
  8. 참고자료

노드js 비동기 처리, 무엇을 어떻게 써야 할까?

파일 읽기·데이터베이스 조회·외부 API 호출을 Promise와 async/await로 비동기 처리하는 Node.js 구조를 표현한 이미지

노드js 비동기 처리 방법의 실무 기본값은 Promise와 async/await입니다. 파일 읽기, 데이터베이스 조회, 외부 API 호출처럼 완료 시점을 바로 알 수 없는 I/O 작업을 기다리는 동안 Node.js가 다른 요청을 처리하도록 만들 수 있습니다. 다만 모든 작업을 무조건 병렬로 실행하면 안 됩니다. 앞선 결과가 다음 작업의 입력값이면 순차로, 서로 영향을 주지 않는 작업이면 병렬로 구성해야 합니다.

Node.js는 JavaScript를 서버 환경에서 실행하는 런타임입니다. JavaScript 실행 자체는 한 번에 하나의 호출 스택에서 진행되지만, 네트워크·파일·타이머 같은 비동기 작업의 완료 알림을 이벤트 루프(event loop)가 받아 후속 함수를 실행합니다. 따라서 비동기는 ‘코드가 동시에 여러 줄을 실행한다’는 뜻보다 대기 시간을 막지 않고 다음 일을 이어 가는 구조로 이해하는 편이 정확합니다.

핵심 기준: 이전 작업의 결과가 필요하면 await로 순차 실행하고, 결과가 서로 독립적이면 Promise.all로 병렬 실행하세요. CPU를 오래 점유하는 반복문은 async/await만으로 해결되지 않습니다.

먼저 알아둘 이벤트 루프와 실행 순서

호출 스택과 비블로킹 I/O

호출 스택(call stack)은 현재 실행 중인 함수가 쌓이는 공간입니다. 동기 코드가 오래 걸리면 스택이 비워지지 않아 다른 요청의 처리도 늦어집니다. 반면 fs.promises.readFile() 같은 비동기 I/O는 읽기 요청을 시작한 뒤 결과가 준비되었을 때 이어서 처리할 함수를 예약하므로, 파일을 기다리는 동안 서버는 다른 일을 받을 수 있습니다.

마이크로태스크와 타이머의 차이

Promise의 thenawait 이후 코드는 마이크로태스크 큐에서 처리됩니다. 일반적으로 현재 동기 코드가 끝난 뒤, 타이머 콜백보다 먼저 실행될 수 있습니다. 따라서 setTimeout(fn, 0)은 즉시 실행 예약이 아니라 ‘최소 지연 뒤 실행 가능한 시점에 예약’이라는 의미입니다. 실행 순서에 의존하는 코드는 로그 한두 줄로 추측하지 말고 테스트로 확인해야 합니다.

console.log('1: sync');

setTimeout(() => {
  console.log('3: timer');
}, 0);

Promise.resolve().then(() => {
  console.log('2: microtask');
});

console.log('1-2: sync end');
// 일반적으로: 1: sync → 1-2: sync end → 2: microtask → 3: timer

이벤트 루프의 단계와 process.nextTick()의 세부 동작은 Node.js 버전에 따라 점검할 부분이 있으므로, 지연 원인을 분석할 때는 Node.js 이벤트 루프 공식 안내를 기준으로 확인하는 편이 안전합니다.

노드js 비동기 처리 방법 3가지 비교

Node.js 비동기 처리 방식인 콜백, Promise, async/await의 코드 작성 방식을 나란히 비교한 이미지

콜백: 기존 API를 다룰 때 필요한 방식

콜백(callback)은 작업 완료 후 실행할 함수를 인수로 넘기는 방식입니다. Node.js의 전통적인 오류 우선(error-first) 콜백은 첫 번째 인수에 오류, 두 번째 인수에 성공 값을 전달합니다. 오래된 라이브러리나 일부 콜백 기반 API를 유지보수할 때 유용하지만, 여러 단계를 중첩하면 흐름과 오류 처리가 읽기 어려워집니다.

import fs from 'node:fs';

fs.readFile('./notice.txt', 'utf8', (error, text) => {
  if (error) {
    console.error('파일 읽기 실패:', error.message);
    return;
  }
  console.log(text);
});

Promise: 성공과 실패를 하나의 반환값으로 표현

Promise는 미래에 완료될 비동기 결과를 나타내는 객체입니다. 성공은 then(), 실패는 catch(), 성공·실패와 무관하게 정리할 일은 finally()에서 처리합니다. 콜백 중첩을 줄일 수 있지만 체인이 길어지면 반환값을 빠뜨리거나 오류를 삼키는 실수가 생길 수 있습니다.

import { readFile } from 'node:fs/promises';

readFile('./notice.txt', 'utf8')
  .then((text) => text.trim())
  .then((text) => console.log(text))
  .catch((error) => console.error('처리 실패:', error.message));

async/await: Promise를 읽기 쉬운 순서로 작성

async 함수는 항상 Promise를 반환하며, await는 Promise가 완료될 때까지 해당 async 함수의 진행만 멈춥니다. 서버 전체나 이벤트 루프를 멈추는 것이 아닙니다. try...catch와 결합하면 성공 경로와 실패 경로를 가까이 배치할 수 있어 API 핸들러, 서비스 계층, 배치 작업에 특히 적합합니다.

방식적합한 상황주의할 점
콜백기존 콜백 API 연동중첩과 오류 분기를 줄여야 합니다.
Promise 체인간단한 변환·연결 작업각 then에서 반환값을 놓치지 않아야 합니다.
async/await대부분의 새 애플리케이션 코드독립 작업까지 연속 await하지 않아야 합니다.

async/await로 구현하는 4단계

1단계: Promise 기반 API를 선택합니다

표준 모듈은 가능하면 Promise API를 사용합니다. 예를 들어 파일 작업은 node:fs/promises에서 가져오면 별도 래퍼 없이 await할 수 있습니다. 콜백 API만 제공되는 경우에는 node:utilpromisify로 변환할 수 있습니다.

2단계: 의존 관계를 먼저 구분합니다

사용자 정보를 조회한 뒤 그 사용자의 주문을 조회해야 한다면 두 작업은 의존 관계이므로 순서를 지켜야 합니다. 반대로 설정 파일과 공지 파일을 읽는 작업처럼 서로 결과를 필요로 하지 않는다면 동시에 시작하는 편이 대기 시간을 줄일 수 있습니다.

3단계: 순차 또는 병렬 코드를 작성합니다

  1. 입력값을 검증하고 필요한 비동기 작업을 정의합니다.
  2. 앞선 결과가 필요한 작업은 await로 완료 후 다음 작업을 호출합니다.
  3. 독립 작업은 Promise를 먼저 만든 뒤 Promise.all()로 한 번에 기다립니다.
  4. 성공 결과를 조합하고, 오류는 호출자에게 전달하거나 정책에 맞게 변환합니다.
import { readFile } from 'node:fs/promises';

async function loadPageData() {
  try {
    const profile = await readFile('./profile.json', 'utf8');

    const [notice, policy] = await Promise.all([
      readFile('./notice.txt', 'utf8'),
      readFile('./policy.txt', 'utf8')
    ]);

    return { profile: JSON.parse(profile), notice, policy };
  } catch (error) {
    throw new Error(`페이지 데이터 준비 실패: ${error.message}`);
  }
}

4단계: 실패와 취소 조건을 명시합니다

Promise.all()은 하나라도 거부되면 즉시 거부 결과를 반환합니다. 일부 작업의 실패 여부를 모두 수집해야 하는 경우에는 Promise.allSettled()가 알맞습니다. 외부 API처럼 무한정 기다리면 안 되는 작업에는 AbortController로 취소 신호와 시간 제한을 설계합니다.

실무에서 자주 놓치는 오류 처리와 성능 문제

Node.js 실무에서 async/await 오류 처리와 CPU 집약 작업으로 인한 성능 문제 및 대응 방법을 표현한 이미지

try...catch는 await 주변에 둡니다

await한 Promise가 거부되면 예외처럼 전달되므로 이를 처리하지 않으면 요청이 실패하거나 처리되지 않은 거부(unhandled rejection)가 발생할 수 있습니다. 단, 모든 오류를 같은 응답으로 바꾸지 말고 입력 오류, 외부 서비스 오류, 내부 오류를 구분해 로그와 HTTP 응답 정책을 정해야 합니다. 민감한 오류 객체 전체를 사용자 응답에 그대로 노출해서도 안 됩니다.

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 });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } finally {
    clearTimeout(timer);
  }
}

CPU 집약 작업은 이벤트 루프를 막습니다

대용량 암호화, 이미지 변환, 매우 큰 JSON 처리, 긴 반복 계산은 Promise로 감싸도 JavaScript 실행 시간을 줄이지 못합니다. 이런 작업이 호출 스택을 오래 점유하면 다른 요청의 응답이 지연됩니다. 작업 성격에 따라 worker_threads, 별도 작업 큐, 전용 서비스로 분리하는 방안을 검토해야 합니다. Node.js의 Worker Threads 공식 문서는 CPU 집약 작업 분리를 설명합니다.

주의: await를 연속으로 쓰는 코드가 항상 안전하거나 빠른 것은 아닙니다. 의존 관계가 없는 I/O를 직렬화하면 응답 시간이 불필요하게 늘고, 반대로 공유 자원을 동시에 갱신하면 경합과 데이터 불일치가 생길 수 있습니다.

반복문 안의 async 함수를 점검합니다

forEach(async () => {})는 배열 전체 완료를 await하지 않으므로, 완료 시점을 보장해야 하는 작업에 그대로 쓰면 문제가 됩니다. 순서가 필요하면 for...of와 await를 사용하고, 동시 실행 수를 제어해야 하면 한 번에 너무 많은 Promise를 만들지 않도록 배치 또는 동시성 제한 로직을 둡니다.

for (const fileName of fileNames) {
  await processFile(fileName); // 파일 처리 순서가 필요할 때
}

await Promise.all(fileNames.map((fileName) => processFile(fileName)));
// 파일들이 독립적이고 동시 실행 수가 감당 가능할 때

비동기 코드 작성 전 점검 목록

  • 이 작업이 파일·네트워크·DB처럼 기다림이 발생하는 I/O인지 확인합니다.
  • 다음 작업이 이전 결과에 의존하는지 먼저 표시합니다.
  • 독립 작업만 Promise.all()로 묶고, 실패 시 처리 정책을 정합니다.
  • 외부 요청에는 타임아웃, 재시도 기준, 취소 가능 여부를 설계합니다.
  • CPU 집약 작업은 이벤트 루프 지연을 측정한 뒤 분리 여부를 판단합니다.
  • 성공 경로뿐 아니라 권한 오류, 파일 없음, 네트워크 지연을 테스트합니다.

새 프로젝트라면 Promise 기반 표준 API와 async/await를 중심으로 시작하고, 콜백 기반 모듈을 만날 때만 경계를 명확히 해 변환하는 구성이 유지보수에 유리합니다. 특히 비동기 흐름은 문법보다 업무 규칙이 중요합니다. 어떤 결과가 반드시 먼저 확정되어야 하는지, 실패했을 때 무엇을 중단하거나 계속할지를 코드와 테스트에 드러내야 합니다.

자주 묻는 질문

Q. async/await를 쓰면 Node.js가 멀티스레드가 되나요?

아닙니다. async/await는 Promise를 읽기 쉽게 작성하는 문법입니다. I/O 대기 중 다른 작업을 처리하게 돕지만, JavaScript의 긴 CPU 계산을 자동으로 여러 코어에 분산하지는 않습니다.

Q. Promise.all과 Promise.allSettled는 언제 구분해서 쓰나요?

모든 작업이 성공해야 다음 단계로 갈 수 있으면 Promise.all을 사용합니다. 각 작업의 성공·실패 결과를 빠짐없이 수집해 일부 실패도 별도로 처리해야 하면 Promise.allSettled가 적합합니다.

Q. await를 쓰면 서버 요청 전체가 멈추나요?

일반적인 비동기 I/O를 await할 때는 해당 async 함수만 결과를 기다립니다. 다른 요청 처리는 계속될 수 있습니다. 다만 await 이전이나 이후에 CPU를 오래 쓰는 동기 코드가 있으면 이벤트 루프가 막힐 수 있습니다.

Q. setTimeout(fn, 0)은 즉시 실행되나요?

즉시 실행을 보장하지 않습니다. 현재 실행 중인 동기 코드와 우선 처리되는 큐가 끝난 뒤, 타이머 실행 조건이 충족된 시점에 호출됩니다. 정확한 순서가 필요한 업무 로직에 타이머 지연값만 의존하지 않는 편이 좋습니다.

참고자료

함께 보면 좋은 글