🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Node.js 비동기 프로그래밍 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 10. 29. · 20 Views

Node.js 비동기 프로그래밍 완벽 가이드

비동기 처리는 Node.js의 핵심입니다. 콜백부터 Promise, async/await까지 실무에서 바로 활용할 수 있는 비동기 프로그래밍 패턴을 완벽하게 정리했습니다. 초급 개발자도 쉽게 이해할 수 있도록 실전 예제와 함께 설명합니다.


목차

  1. 동기vs비동기
  2. 콜백함수
  3. 콜백지옥
  4. Promise
  5. async/await
  6. Promise.all
  7. Promise.race
  8. 에러처리
  9. EventEmitter
  10. setTimeout/setInterval

1. 동기vs비동기

시작하며

여러분이 레스토랑에서 음식을 주문할 때를 생각해보세요. 주문한 음식이 나올 때까지 카운터 앞에서 꼼짝 않고 기다린다면 어떨까요?

그 사이에 전화도 못 받고, 친구와 대화도 못하고, 아무것도 할 수 없을 겁니다. 프로그래밍에서도 똑같은 상황이 발생합니다.

파일을 읽거나, 데이터베이스에서 정보를 가져오거나, 외부 API를 호출하는 작업은 시간이 걸립니다. 이런 작업이 끝날 때까지 프로그램이 멈춰있다면 사용자는 답답함을 느낄 수밖에 없죠.

바로 이럴 때 필요한 것이 비동기 프로그래밍입니다. 시간이 걸리는 작업을 백그라운드에서 처리하고, 그 사이에 다른 일을 계속할 수 있게 해줍니다.

Node.js는 태생부터 비동기로 설계되었습니다. 이것이 Node.js가 적은 자원으로도 많은 요청을 동시에 처리할 수 있는 비결입니다.

개요

간단히 말해서, 동기는 순차적으로 한 번에 하나씩 처리하는 방식이고, 비동기는 여러 작업을 동시에 진행하는 방식입니다. 동기 방식에서는 코드가 위에서 아래로 순서대로 실행되며, 한 작업이 끝나야 다음 작업이 시작됩니다.

파일 읽기 같은 작업이 5초 걸린다면, 그 5초 동안 프로그램은 완전히 멈춰있게 됩니다. 사용자 인터페이스가 있다면 화면이 얼어붙은 것처럼 보일 것입니다.

반면 비동기 방식은 시간이 걸리는 작업을 시작한 후, 그 작업이 끝나기를 기다리지 않고 바로 다음 코드를 실행합니다. 작업이 완료되면 미리 등록해둔 콜백 함수나 Promise를 통해 결과를 받아 처리합니다.

웹 서버를 예로 들면, 동기 방식에서는 사용자 A의 요청을 처리하는 동안 사용자 B는 기다려야 합니다. 하지만 비동기 방식에서는 A의 데이터베이스 조회가 진행되는 동안 B의 요청도 동시에 처리할 수 있습니다.

이것이 Node.js가 단일 스레드임에도 높은 동시성을 달성하는 핵심 원리입니다.

코드 예제

// 동기 방식 - 블로킹
const fs = require('fs');
console.log('1. 시작');
const data = fs.readFileSync('large-file.txt', 'utf8');
// 파일을 다 읽을 때까지 여기서 멈춤
console.log('2. 파일 읽기 완료');
console.log('3. 끝');

// 비동기 방식 - 논블로킹
console.log('1. 시작');
fs.readFile('large-file.txt', 'utf8', (err, data) => {
  // 파일 읽기가 완료되면 이 함수가 실행됨
  console.log('2. 파일 읽기 완료');
});
console.log('3. 다음 작업 계속'); // 파일 읽기를 기다리지 않고 바로 실행

설명

이것이 하는 일: 위 코드는 동일한 파일 읽기 작업을 동기와 비동기 두 가지 방식으로 보여줍니다. 첫 번째로, 동기 방식인 readFileSync는 파일을 완전히 읽을 때까지 프로그램의 실행을 멈춥니다.

만약 파일 크기가 크다면, 그 시간 동안 CPU는 놀고 있게 됩니다. 출력 순서는 정확히 1 → 2 → 3 순서가 됩니다.

코드를 읽는 것처럼 직관적이지만, 성능 측면에서는 비효율적입니다. 두 번째로, 비동기 방식인 readFile은 파일 읽기를 시작하고 즉시 다음 줄로 넘어갑니다.

파일 읽기는 백그라운드에서 진행되고, 완료되면 콜백 함수가 호출됩니다. 출력 순서는 1 → 3 → 2가 됩니다.

파일을 읽는 동안에도 다른 작업을 계속할 수 있어 효율적입니다. 세 번째로, 비동기 방식의 콜백 함수는 작업이 완료된 후에 실행됩니다.

이 함수 안에서 파일 내용(data)을 받아서 처리할 수 있습니다. 에러가 발생하면 err 파라미터를 통해 확인할 수 있습니다.

마지막으로, 이 패턴을 이해하면 데이터베이스 조회, HTTP 요청, 타이머 등 모든 비동기 작업을 다룰 수 있습니다. Node.js의 대부분의 I/O 작업은 비동기로 동작하며, 이것이 Node.js의 핵심 철학입니다.

여러분이 이 코드를 사용하면 프로그램이 I/O 작업을 기다리는 동안에도 다른 요청을 처리할 수 있어, 전체적인 처리량이 크게 향상됩니다. 특히 동시에 많은 사용자를 처리해야 하는 웹 서버에서 이 차이는 매우 큽니다.

실전 팁

💡 개발 중에는 동기 방식이 디버깅하기 쉽지만, 프로덕션 코드에서는 반드시 비동기 방식을 사용하세요. 성능 차이가 엄청납니다.

💡 Node.js에서 함수명에 'Sync'가 붙어있다면 동기 함수입니다. 가능한 한 피하세요. 서버가 멈출 수 있습니다.

💡 비동기 코드는 실행 순서가 코드 순서와 다를 수 있습니다. console.log로 실행 흐름을 확인하는 습관을 들이세요.

💡 CPU 집약적 작업(복잡한 계산)과 I/O 작업(파일, 네트워크)을 구분하세요. I/O는 비동기로, CPU 작업은 동기로 처리하는 것이 일반적입니다.


2. 콜백함수

시작하며

여러분이 피자를 주문하면서 "다 되면 전화주세요"라고 말한 적 있나요? 이것이 바로 콜백의 개념입니다.

"나중에 이 일을 해주세요"라고 함수를 미리 등록해두는 것이죠. Node.js 초창기부터 사용된 가장 기본적인 비동기 패턴이 바로 콜백입니다.

파일을 읽거나, 네트워크 요청을 보내거나, 데이터베이스를 조회할 때 작업이 완료되면 실행할 함수를 미리 전달합니다. 지금도 많은 Node.js API와 라이브러리가 콜백 패턴을 사용하고 있습니다.

최신 방식인 Promise와 async/await을 이해하기 위해서라도 콜백의 동작 원리를 반드시 알아야 합니다.

개요

간단히 말해서, 콜백 함수는 다른 함수의 인자로 전달되어 나중에 실행되는 함수입니다. 비동기 작업을 시작할 때, "이 작업이 끝나면 이 함수를 실행해줘"라고 콜백 함수를 등록합니다.

작업이 완료되면 Node.js가 자동으로 그 함수를 호출해주는 것이죠. 마치 배달 주문할 때 전화번호를 남기는 것과 같습니다.

Node.js의 전통적인 콜백 패턴은 "에러 우선 콜백(Error-First Callback)" 규칙을 따릅니다. 첫 번째 인자는 항상 에러 객체이고, 두 번째 인자부터 결과값이 전달됩니다.

에러가 없으면 첫 번째 인자는 null이 됩니다. 이 패턴의 장점은 간단하고 직관적이라는 점입니다.

하지만 콜백이 중첩되면 코드가 복잡해지는 "콜백 지옥" 문제가 발생할 수 있습니다. 그럼에도 불구하고 기본 원리를 이해하는 것은 매우 중요합니다.

코드 예제

const fs = require('fs');

// 콜백 함수를 사용한 비동기 파일 읽기
function readUserData(callback) {
  fs.readFile('user.json', 'utf8', (err, data) => {
    if (err) {
      // 에러가 발생하면 콜백의 첫 번째 인자로 전달
      callback(err, null);
      return;
    }
    // 성공하면 두 번째 인자로 결과 전달
    const user = JSON.parse(data);
    callback(null, user);
  });
}

// 콜백 함수를 등록하여 사용
readUserData((err, user) => {
  if (err) {
    console.error('파일 읽기 실패:', err);
    return;
  }
  console.log('사용자 정보:', user.name);
});

설명

이것이 하는 일: 파일에서 사용자 정보를 비동기로 읽어와서, 완료되면 콜백 함수를 통해 결과를 처리합니다. 첫 번째로, readUserData 함수는 콜백 함수를 파라미터로 받습니다.

이것이 핵심 패턴입니다. "나중에 이 함수를 실행해줘"라고 등록하는 것이죠.

파일 읽기가 언제 끝날지 모르지만, 끝나면 이 콜백이 자동으로 호출됩니다. 두 번째로, fs.readFile 내부의 콜백에서 에러를 먼저 확인합니다.

파일이 없거나 권한이 없으면 err에 에러 객체가 담깁니다. 이 경우 즉시 외부 콜백을 에러와 함께 호출하고 return으로 빠져나옵니다.

이것이 에러 우선 콜백의 핵심입니다. 세 번째로, 에러가 없으면 파일 내용을 JSON으로 파싱하고, 콜백의 두 번째 인자로 결과를 전달합니다.

첫 번째 인자는 null이 되어 "에러가 없다"는 것을 표시합니다. 이 규칙을 지키면 에러 처리가 일관성 있게 됩니다.

네 번째로, 함수를 사용하는 쪽에서는 콜백 안에서 먼저 에러를 체크합니다. 에러가 있으면 에러 처리를, 없으면 정상 로직을 실행합니다.

이 패턴이 Node.js 전체에서 일관되게 사용됩니다. 여러분이 이 코드를 사용하면 비동기 작업의 결과를 안전하게 처리할 수 있습니다.

에러 처리가 명확해지고, 작업 완료 시점을 정확히 포착할 수 있습니다. 많은 Node.js 내장 모듈과 npm 패키지가 여전히 이 패턴을 사용하므로, 반드시 익혀야 합니다.

실전 팁

💡 콜백 함수 안에서 에러를 확인하지 않으면 예상치 못한 크래시가 발생할 수 있습니다. 항상 첫 번째 인자로 에러를 체크하세요.

💡 콜백 내부에서 다시 비동기 함수를 호출하면 중첩이 깊어집니다. 2단계 이상 중첩되면 Promise나 async/await 사용을 고려하세요.

💡 콜백을 호출한 후에는 반드시 return을 사용해서 함수를 종료하세요. 그렇지 않으면 코드가 계속 실행되어 콜백이 두 번 호출될 수 있습니다.

💡 동기 함수처럼 보이지만 실제로는 비동기입니다. 콜백 밖의 코드가 먼저 실행되므로, 실행 순서를 착각하지 마세요.


3. 콜백지옥

시작하며

여러분이 여러 단계의 비동기 작업을 순차적으로 처리해야 한다면 어떻게 하시겠어요? 사용자 정보를 가져오고, 그 정보로 주문 내역을 조회하고, 각 주문의 상세 정보를 다시 가져와야 하는 상황 말이죠.

콜백으로 이런 작업을 구현하면 코드가 점점 오른쪽으로 밀려나면서 피라미드 모양이 됩니다. 이것을 "콜백 지옥(Callback Hell)" 또는 "피라미드 오브 둠(Pyramid of Doom)"이라고 부릅니다.

코드를 읽기도 어렵고, 에러 처리도 복잡해집니다. 이 문제는 Node.js 초기부터 개발자들을 괴롭혀온 악명 높은 문제입니다.

실제 프로젝트에서 5~6단계로 중첩된 콜백을 본 적이 있다면, 그 악몽을 이해하실 겁니다. 다행히 이 문제를 해결하기 위해 Promise와 async/await이 등장했습니다.

하지만 문제를 정확히 이해해야 해결책의 가치를 알 수 있습니다.

개요

간단히 말해서, 콜백 지옥은 비동기 작업이 여러 단계로 중첩되면서 코드 가독성과 유지보수성이 크게 떨어지는 현상입니다. 각 비동기 작업의 결과가 다음 작업의 입력으로 필요한 경우, 콜백 안에 콜백을 넣고, 그 안에 또 콜백을 넣는 구조가 됩니다.

3단계만 넘어가도 코드가 복잡해지고, 5단계 이상이면 거의 읽을 수 없는 수준이 됩니다. 가독성 문제뿐만 아니라 에러 처리도 악몽입니다.

각 단계마다 에러를 확인해야 하는데, 깊이 중첩된 구조에서 에러를 일관되게 처리하기가 매우 어렵습니다. 한 곳에서라도 에러 처리를 빠뜨리면 프로그램이 예상치 못하게 종료될 수 있습니다.

또한 디버깅도 어렵습니다. 스택 트레이스가 복잡해지고, 어느 단계에서 문제가 발생했는지 추적하기 힘듭니다.

코드 수정도 어렵고, 새로운 단계를 추가하려면 전체 구조를 다시 들여쓰기 해야 합니다.

코드 예제

// 전형적인 콜백 지옥의 예
const fs = require('fs');

fs.readFile('user-id.txt', 'utf8', (err, userId) => {
  if (err) return console.error(err);

  db.getUser(userId, (err, user) => {
    if (err) return console.error(err);

    db.getOrders(user.id, (err, orders) => {
      if (err) return console.error(err);

      orders.forEach(order => {
        db.getOrderDetails(order.id, (err, details) => {
          if (err) return console.error(err);

          payment.process(details, (err, result) => {
            if (err) return console.error(err);
            // 여기까지 오면 들여쓰기가 너무 깊어짐
            console.log('결제 완료:', result);
          });
        });
      });
    });
  });
});

설명

이것이 하는 일: 파일에서 사용자 ID를 읽고, 그 ID로 사용자 정보를 조회하고, 주문 목록을 가져온 뒤, 각 주문의 상세 정보를 조회하고, 결제를 처리하는 5단계의 비동기 작업을 순차적으로 실행합니다. 첫 번째로, 파일을 읽는 것부터 시작합니다.

파일 읽기가 완료되면 콜백이 호출되고, 그 안에서 다음 작업인 사용자 조회를 시작합니다. 이미 한 단계 들여쓰기가 시작됩니다.

두 번째로, 사용자 조회가 완료되면 또 다른 콜백이 실행되고, 그 안에서 주문 목록을 조회합니다. 들여쓰기가 두 단계가 되었습니다.

각 단계마다 에러를 확인해야 하는데, 에러 처리 코드가 반복됩니다. 세 번째로, 주문 목록을 forEach로 순회하면서 각 주문의 상세 정보를 조회합니다.

여기서 들여쓰기가 세 단계가 됩니다. 반복문 안에 비동기 작업이 있으면 문제가 더 복잡해집니다.

네 번째로, 상세 정보로 결제를 처리하는 마지막 단계에서 들여쓰기가 네 단계 이상이 됩니다. 코드가 화면 오른쪽 끝까지 밀려나고, 어디서 어떤 블록이 끝나는지 파악하기 어렵습니다.

여러분이 이런 코드를 만나면 유지보수하기가 정말 힘듭니다. 새로운 기능을 추가하거나, 에러 처리를 개선하거나, 특정 단계를 건너뛰는 로직을 추가하는 것이 모두 어렵습니다.

이것이 Promise와 async/await이 필요한 이유입니다.

실전 팁

💡 콜백이 2단계 이상 중첩되기 시작하면, 즉시 Promise나 async/await으로 리팩토링하세요. 나중에는 더 어렵습니다.

💡 각 단계를 별도의 함수로 분리하면 중첩을 줄일 수 있습니다. 함수명으로 의도를 표현할 수도 있어 가독성이 향상됩니다.

💡 에러 처리를 통일하세요. 모든 콜백마다 반복하지 말고, 공통 에러 핸들러 함수를 만들어 사용하는 것이 좋습니다.

💡 forEach 안에서 비동기 작업을 하면 순서를 보장할 수 없습니다. 순차 처리가 필요하면 for...of 루프나 Promise.all을 사용하세요.


4. Promise

시작하며

콜백 지옥의 고통을 경험한 개발자들이 "더 나은 방법이 없을까?"라고 고민한 끝에 탄생한 것이 Promise입니다. 약속이라는 이름처럼, "나중에 값을 줄게"라는 약속을 객체로 표현한 것이죠.

Promise는 ES6(ES2015)에 공식적으로 추가되었고, 현재 Node.js의 표준 비동기 패턴이 되었습니다. 콜백의 중첩 문제를 해결하고, 에러 처리를 체계화하며, 코드를 훨씬 읽기 쉽게 만들어줍니다.

실무에서 외부 API 호출, 데이터베이스 작업, 파일 처리 등 거의 모든 비동기 작업에 Promise를 사용합니다. axios, fetch, mongoose 같은 인기 라이브러리들도 모두 Promise 기반입니다.

Promise를 이해하면 비동기 프로그래밍의 세계가 완전히 달라집니다. 복잡했던 비동기 로직이 명확하고 우아한 코드로 변합니다.

개요

간단히 말해서, Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. Promise는 세 가지 상태를 가집니다.

Pending(대기): 아직 완료되지 않은 초기 상태, Fulfilled(이행): 작업이 성공적으로 완료된 상태, Rejected(거부): 작업이 실패한 상태입니다. 한 번 fulfilled나 rejected 상태가 되면 다시 변경되지 않습니다.

콜백과 달리 Promise는 체이닝이 가능합니다. .then()을 연결해서 여러 비동기 작업을 순차적으로 처리할 수 있고, 코드가 오른쪽으로 밀려나지 않습니다.

각 then은 새로운 Promise를 반환하므로, 계속 연결할 수 있습니다. 에러 처리도 훨씬 간단합니다.

여러 then 체인의 마지막에 .catch() 하나만 붙이면, 중간 어느 단계에서 발생한 에러든 모두 잡아낼 수 있습니다. try-catch와 비슷한 구조로 에러를 중앙에서 관리할 수 있습니다.

코드 예제

const fs = require('fs').promises; // Promise 기반 fs 모듈

// Promise를 반환하는 함수
function getUserData(userId) {
  return fs.readFile(`users/${userId}.json`, 'utf8')
    .then(data => JSON.parse(data))
    .then(user => {
      console.log('사용자 조회 성공:', user.name);
      return user; // 다음 then으로 전달
    });
}

// Promise 체이닝으로 순차 처리
getUserData('123')
  .then(user => {
    return getOrders(user.id); // 새로운 Promise 반환
  })
  .then(orders => {
    return processOrders(orders);
  })
  .then(result => {
    console.log('모든 작업 완료:', result);
  })
  .catch(err => {
    // 어느 단계에서든 에러 발생 시 여기서 처리
    console.error('에러 발생:', err);
  });

설명

이것이 하는 일: 사용자 데이터를 파일에서 읽어와서 JSON으로 파싱하고, 그 결과로 주문 내역을 조회한 뒤 처리하는 일련의 비동기 작업을 Promise 체인으로 연결합니다. 첫 번째로, fs.promises를 사용하면 콜백 대신 Promise를 반환하는 파일 시스템 함수를 사용할 수 있습니다.

readFile은 Promise를 반환하고, 파일 읽기가 성공하면 fulfilled 상태가 되면서 파일 내용을 전달합니다. 두 번째로, .then()에 전달된 함수는 이전 Promise가 fulfilled될 때 실행됩니다.

첫 번째 then에서는 파일 내용을 JSON으로 파싱하고, 두 번째 then에서는 파싱된 객체를 받아 처리합니다. 각 then이 값을 반환하면 그것이 다음 then으로 전달됩니다.

세 번째로, then 안에서 Promise를 반환하면 자동으로 체이닝됩니다. getOrders(user.id)가 Promise를 반환하면, 그 Promise가 완료될 때까지 기다린 후 결과값이 다음 then으로 전달됩니다.

이것이 순차 처리의 핵심입니다. 네 번째로, .catch()는 체인의 어느 단계에서든 발생한 에러를 잡아냅니다.

파일 읽기 실패, JSON 파싱 에러, 주문 조회 실패 등 모든 에러가 여기로 모입니다. 각 단계마다 에러 처리를 반복할 필요가 없습니다.

여러분이 이 코드를 사용하면 콜백 지옥 없이 깔끔한 코드를 작성할 수 있습니다. 코드가 왼쪽에서 시작해서 오른쪽으로 밀려나지 않고, 위에서 아래로 읽히므로 의도를 파악하기 쉽습니다.

에러 처리도 한 곳에 모여있어 관리가 편합니다.

실전 팁

💡 then 안에서 값을 반환하지 않으면 다음 then은 undefined를 받습니다. 항상 다음 단계에 필요한 값을 return하세요.

💡 then 안에서 Promise를 반환하면 자동으로 체이닝되지만, 일반 값을 반환하면 그 값으로 즉시 fulfilled된 Promise가 됩니다.

💡 catch는 체인 중간에도 넣을 수 있습니다. 중간에서 에러를 처리하고 복구한 후 체인을 계속 이어갈 수 있습니다.

💡 finally()를 사용하면 성공/실패 관계없이 항상 실행되는 코드를 작성할 수 있습니다. 리소스 정리에 유용합니다.


5. async/await

시작하며

Promise가 콜백 지옥을 해결했지만, 여전히 .then()을 여러 번 체이닝하면 코드가 복잡해 보일 수 있습니다. "동기 코드처럼 보이면서 비동기로 동작하면 얼마나 좋을까?"라는 생각에서 탄생한 것이 async/await입니다.

ES2017(ES8)에 추가된 async/await은 Promise를 더 쉽게 사용할 수 있게 해주는 문법적 설탕(syntactic sugar)입니다. Promise의 힘은 그대로 유지하면서, 코드를 동기 코드처럼 읽기 쉽게 만들어줍니다.

현재 실무에서 가장 많이 사용되는 비동기 패턴입니다. Express 라우트 핸들러, API 호출, 데이터베이스 작업 등 모든 곳에서 async/await을 볼 수 있습니다.

코드 리뷰어도 이 방식을 선호합니다. async/await을 마스터하면 복잡한 비동기 로직도 마치 동기 코드를 작성하듯이 직관적으로 구현할 수 있습니다.

개요

간단히 말해서, async/await은 Promise를 더 쉽게 사용하기 위한 문법으로, 비동기 코드를 동기 코드처럼 작성할 수 있게 해줍니다. async 키워드를 함수 앞에 붙이면 그 함수는 항상 Promise를 반환합니다.

함수 내부에서 일반 값을 return해도 자동으로 fulfilled Promise로 감싸집니다. 이것이 async 함수의 첫 번째 특징입니다.

await 키워드는 Promise가 settled(fulfilled 또는 rejected)될 때까지 기다립니다. 그 사이에 다른 코드가 실행될 수 있으므로 블로킹이 아닙니다.

Promise가 완료되면 await은 fulfilled된 값을 반환하고, 코드가 다음 줄로 진행됩니다. await은 async 함수 내부에서만 사용할 수 있습니다.

최상위 레벨에서는 사용할 수 없지만, Node.js 14.8 이상에서는 모듈 최상위에서도 사용 가능합니다(top-level await). 일반 함수에서 await을 쓰면 문법 에러가 발생합니다.

에러 처리는 try-catch를 사용합니다. Promise의 catch() 대신 익숙한 동기 코드의 에러 처리 방식을 그대로 사용할 수 있어 매우 직관적입니다.

코드 예제

const fs = require('fs').promises;

// async 함수는 항상 Promise를 반환
async function getUserOrders(userId) {
  try {
    // await으로 Promise가 완료될 때까지 대기
    const userData = await fs.readFile(`users/${userId}.json`, 'utf8');
    const user = JSON.parse(userData);
    console.log('사용자:', user.name);

    // 순차적으로 다음 작업 실행
    const orders = await getOrders(user.id);
    const processed = await processOrders(orders);

    // return한 값은 자동으로 Promise로 감싸짐
    return processed;
  } catch (err) {
    // 어느 단계에서든 에러 발생 시 여기서 처리
    console.error('에러 발생:', err.message);
    throw err; // 에러를 다시 던질 수도 있음
  }
}

// async 함수 호출
getUserOrders('123')
  .then(result => console.log('완료:', result))
  .catch(err => console.error('최종 에러:', err));

설명

이것이 하는 일: 사용자 파일을 읽고, JSON 파싱하고, 주문을 조회하고, 처리하는 일련의 비동기 작업을 동기 코드처럼 순차적으로 작성합니다. 첫 번째로, async 키워드로 함수를 정의하면 그 함수는 자동으로 Promise를 반환합니다.

내부에서 return processed를 하면, 실제로는 Promise.resolve(processed)가 반환됩니다. 이 덕분에 async 함수를 호출한 곳에서 .then()을 사용할 수 있습니다.

두 번째로, await는 Promise가 완료될 때까지 코드 실행을 일시 중지합니다. await fs.readFile()은 파일 읽기가 끝날 때까지 기다리고, 완료되면 파일 내용이 userData 변수에 할당됩니다.

마치 동기 코드처럼 보이지만, 실제로는 비동기로 동작합니다. 세 번째로, await을 여러 번 사용하면 순차적으로 실행됩니다.

orders를 받아야 processOrders를 실행할 수 있으므로, 각 await이 완료될 때까지 기다립니다. 코드가 위에서 아래로 읽히므로 의도가 명확합니다.

네 번째로, try-catch 블록으로 모든 에러를 한 곳에서 처리합니다. await한 Promise 중 하나라도 rejected되면 catch 블록으로 점프합니다.

Promise의 .catch()와 동일한 기능이지만, 동기 코드의 에러 처리 방식을 사용할 수 있어 더 친숙합니다. 여러분이 이 코드를 사용하면 복잡한 비동기 로직을 마치 동기 코드처럼 직관적으로 작성할 수 있습니다.

코드 리뷰할 때도 이해하기 쉽고, 디버깅할 때도 스택 트레이스가 명확합니다. 현재 Node.js에서 가장 권장되는 비동기 패턴입니다.

실전 팁

💡 await을 사용하려면 반드시 async 함수 안에 있어야 합니다. 일반 함수에서 사용하면 문법 에러가 발생합니다.

💡 여러 Promise를 순차적으로 await하면 시간이 오래 걸립니다. 독립적인 작업은 Promise.all()로 병렬 처리하세요.

💡 forEach 내부에서 await을 사용하면 제대로 동작하지 않습니다. for...of 루프를 사용하세요.

💡 async 함수는 항상 Promise를 반환하므로, 호출하는 쪽에서도 await이나 .then()을 사용해야 합니다.

💡 try-catch 없이 await을 사용하면 에러가 처리되지 않을 수 있습니다. 최소한 최상위에서라도 catch하세요.


6. Promise.all

시작하며

여러분이 5개의 API를 호출해야 하는데, 각각 1초씩 걸린다면 어떻게 하시겠어요? 하나씩 순차적으로 호출하면 총 5초가 걸립니다.

하지만 이 API들이 서로 독립적이라면 동시에 호출할 수 있지 않을까요? 바로 이럴 때 필요한 것이 Promise.all()입니다.

여러 개의 Promise를 동시에 실행하고, 모두 완료될 때까지 기다립니다. 병렬 처리를 통해 전체 실행 시간을 크게 줄일 수 있습니다.

실무에서 여러 데이터베이스 쿼리를 동시에 실행하거나, 여러 외부 API를 한 번에 호출하거나, 여러 파일을 동시에 읽어야 할 때 매우 유용합니다. 성능 최적화의 핵심 패턴입니다.

특히 마이크로서비스 아키텍처에서 여러 서비스를 동시에 호출할 때 Promise.all()의 위력이 발휘됩니다.

개요

간단히 말해서, Promise.all()은 여러 Promise를 병렬로 실행하고, 모두 성공하면 결과를 배열로 반환하고, 하나라도 실패하면 즉시 거부되는 메서드입니다. Promise 배열을 인자로 받아서 새로운 Promise를 반환합니다.

모든 Promise가 fulfilled되면 각 결과를 담은 배열로 fulfilled됩니다. 결과 배열의 순서는 Promise 배열의 순서와 동일하게 유지됩니다.

가장 중요한 특징은 "빠른 실패(fail-fast)" 동작입니다. Promise 중 하나라도 rejected되면 즉시 전체가 rejected됩니다.

나머지 Promise들은 계속 실행되지만, 그 결과는 무시됩니다. 이것은 장점이자 단점이 될 수 있습니다.

병렬 실행의 핵심은 Promise들이 거의 동시에 시작된다는 점입니다. 첫 번째 Promise가 끝나기를 기다리지 않고, 모든 Promise가 동시에 시작됩니다.

이것이 성능 향상의 비결입니다.

코드 예제

// 여러 API를 병렬로 호출
async function getDashboardData(userId) {
  try {
    // 세 개의 Promise를 동시에 시작
    const [user, orders, recommendations] = await Promise.all([
      fetch(`/api/users/${userId}`).then(res => res.json()),
      fetch(`/api/orders?userId=${userId}`).then(res => res.json()),
      fetch(`/api/recommendations/${userId}`).then(res => res.json())
    ]);

    // 모두 완료되면 결과를 조합
    return {
      userName: user.name,
      orderCount: orders.length,
      recommendedProducts: recommendations
    };
  } catch (err) {
    // 하나라도 실패하면 여기서 처리
    console.error('데이터 로딩 실패:', err);
    throw err;
  }
}

// 순차 처리와 병렬 처리의 시간 차이
// 순차: 1초 + 1초 + 1초 = 3초
// 병렬: max(1초, 1초, 1초) = 1초

설명

이것이 하는 일: 사용자 정보, 주문 내역, 추천 상품 세 가지 API를 동시에 호출하고, 모두 완료되면 결과를 조합하여 대시보드 데이터를 만듭니다. 첫 번째로, Promise.all()에 세 개의 fetch Promise를 배열로 전달합니다.

이 세 개의 네트워크 요청은 거의 동시에 시작됩니다. 첫 번째 요청이 끝나기를 기다리지 않고, 세 개가 모두 동시에 진행됩니다.

두 번째로, 각 fetch는 응답을 JSON으로 파싱하는 Promise를 반환합니다. 따라서 Promise.all()은 JSON 파싱까지 완료된 결과를 기다립니다.

모든 API 호출과 파싱이 완료되면 Promise.all()이 fulfilled됩니다. 세 번째로, 구조 분해 할당으로 결과 배열을 개별 변수로 받습니다.

[user, orders, recommendations]는 Promise.all()이 반환한 배열의 세 요소를 각각 변수에 할당합니다. 배열 순서가 보장되므로 안전합니다.

네 번째로, 세 개 중 하나라도 실패하면(네트워크 에러, 404 등) 전체가 rejected되고 catch 블록으로 점프합니다. 예를 들어 사용자 정보는 성공했지만 주문 내역 조회가 실패하면, 전체 작업이 실패로 처리됩니다.

여러분이 이 코드를 사용하면 3개의 API를 순차 호출할 때보다 약 3배 빠르게 데이터를 로드할 수 있습니다. 각 API가 1초씩 걸린다면, 순차는 3초, 병렬은 1초가 걸립니다.

사용자 경험을 크게 개선할 수 있습니다.

실전 팁

💡 독립적인 작업만 Promise.all()로 묶으세요. 한 작업의 결과가 다른 작업의 입력이 되면 순차 처리해야 합니다.

💡 하나의 실패가 전체를 실패시키는 게 문제라면 Promise.allSettled()를 사용하세요. 모든 Promise의 성공/실패 결과를 받을 수 있습니다.

💡 수백 개의 Promise를 동시에 실행하면 리소스 고갈이 발생할 수 있습니다. p-limit 같은 라이브러리로 동시 실행 개수를 제한하세요.

💡 결과 배열의 순서는 Promise 배열의 순서와 동일합니다. 완료 순서가 아니라 입력 순서입니다.


7. Promise.race

시작하며

여러분이 여러 서버에 동일한 요청을 보내고, 가장 빨리 응답하는 서버의 결과를 사용하고 싶다면 어떻게 하시겠어요? 또는 네트워크 요청에 타임아웃을 구현하고 싶다면요?

Promise.race()는 이런 경쟁 상황을 처리하는 메서드입니다. 여러 Promise 중 가장 먼저 완료되는(fulfilled 또는 rejected) 하나의 결과만 받습니다.

마라톤에서 1등만 기록하는 것과 비슷합니다. 실무에서 타임아웃 구현, 여러 데이터 소스 중 가장 빠른 것 선택, 폴백 전략 구현 등에 사용됩니다.

성능과 안정성을 동시에 높일 수 있는 패턴입니다. 사용 빈도는 Promise.all()보다 낮지만, 특정 상황에서는 매우 강력한 도구입니다.

개요

간단히 말해서, Promise.race()는 여러 Promise 중 가장 먼저 완료되는 하나의 결과를 반환하는 메서드입니다. Promise 배열을 받아서, 그 중 하나라도 settled(fulfilled 또는 rejected)되는 순간 그 결과를 반환합니다.

나머지 Promise들은 계속 실행되지만, 결과는 무시됩니다. 첫 번째로 도착한 결과만 의미가 있습니다.

가장 먼저 완료된 것이 rejected Promise라면, race() 전체가 rejected됩니다. 성공한 Promise만 기다리는 것이 아니라, 성공이든 실패든 첫 번째 결과를 사용합니다.

이 점을 유의해야 합니다. 타임아웃 구현에 매우 유용합니다.

실제 작업 Promise와 타이머 Promise를 race()로 경쟁시켜서, 타이머가 먼저 완료되면 타임아웃으로 처리할 수 있습니다. 이것이 가장 흔한 사용 사례입니다.

코드 예제

// 타임아웃을 가진 fetch 구현
function fetchWithTimeout(url, timeout = 5000) {
  // 타임아웃 Promise 생성
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`요청 시간 초과: ${timeout}ms`));
    }, timeout);
  });

  // 실제 fetch와 타임아웃을 경쟁
  return Promise.race([
    fetch(url).then(res => res.json()),
    timeoutPromise
  ]);
}

// 사용 예제
async function getDataWithTimeout() {
  try {
    // 5초 안에 응답 안 오면 에러
    const data = await fetchWithTimeout('/api/slow-endpoint', 5000);
    console.log('데이터:', data);
  } catch (err) {
    if (err.message.includes('시간 초과')) {
      console.error('서버 응답이 너무 느립니다');
    } else {
      console.error('요청 실패:', err);
    }
  }
}

설명

이것이 하는 일: API 요청에 타임아웃을 적용하여, 지정된 시간 안에 응답이 없으면 에러를 발생시킵니다. 첫 번째로, timeoutPromise는 지정된 시간 후에 자동으로 rejected되는 Promise입니다.

setTimeout 콜백에서 reject를 호출하면, 그 시간이 지나면 Promise가 에러와 함께 거부됩니다. 이것이 타임아웃의 역할을 합니다.

두 번째로, Promise.race()에 실제 fetch와 타임아웃 Promise를 배열로 전달합니다. 두 Promise가 동시에 시작되고, 둘 중 하나가 먼저 완료되는 순간 race()가 그 결과로 settled됩니다.

경쟁이 시작되는 것이죠. 세 번째로, fetch가 5초 안에 완료되면 race()는 fetch의 결과로 fulfilled됩니다.

데이터가 정상적으로 반환되고 timeoutPromise는 무시됩니다. 타이머는 계속 돌아가지만 결과에 영향을 주지 않습니다.

네 번째로, 5초가 지나도 fetch가 완료되지 않으면 timeoutPromise가 먼저 reject되어 race() 전체가 rejected됩니다. catch 블록에서 에러 메시지를 확인하여 타임아웃인지 다른 에러인지 구분할 수 있습니다.

여러분이 이 코드를 사용하면 느린 API 요청으로 인해 사용자가 무한정 기다리는 상황을 방지할 수 있습니다. 적절한 타임아웃으로 사용자 경험을 개선하고, 리소스가 오래 점유되는 것을 막을 수 있습니다.

실전 팁

💡 타임아웃 Promise에서 reject 대신 resolve를 사용하면 기본값 반환 패턴으로 활용할 수 있습니다.

💡 race()에서 이긴 Promise 외의 나머지는 취소되지 않습니다. 계속 실행되므로 리소스 정리가 필요하면 별도 처리하세요.

💡 여러 서버에서 동일한 데이터를 받아올 때, 가장 빠른 서버의 응답을 사용하는 패턴으로도 활용할 수 있습니다.

💡 빈 배열을 전달하면 race()는 영원히 pending 상태로 남습니다. 최소 하나 이상의 Promise를 전달하세요.


8. 에러처리

시작하며

여러분이 비동기 코드를 작성할 때 가장 놓치기 쉬운 것이 에러 처리입니다. 파일이 없을 수도 있고, 네트워크가 끊길 수도 있고, 서버가 에러를 반환할 수도 있습니다.

이런 상황을 처리하지 않으면 프로그램이 예고 없이 종료됩니다. 비동기 코드의 에러 처리는 동기 코드보다 까다롭습니다.

콜백의 에러는 try-catch로 잡을 수 없고, Promise의 에러는 반드시 catch()로 처리해야 합니다. 처리되지 않은 Promise rejection은 Node.js를 종료시킬 수 있습니다.

실무에서 안정적인 애플리케이션을 만들려면 에러 처리가 필수입니다. 로깅, 모니터링, 사용자 피드백 모두 제대로 된 에러 처리에서 시작됩니다.

각 비동기 패턴마다 에러 처리 방법이 다르므로, 상황에 맞는 올바른 방법을 알아야 합니다.

개요

간단히 말해서, 비동기 에러 처리는 콜백의 에러 우선 패턴, Promise의 .catch(), async/await의 try-catch 세 가지 방법으로 구분됩니다. 콜백에서는 첫 번째 인자로 에러를 받아 확인합니다.

에러가 null이 아니면 에러 처리 로직을 실행하고, null이면 정상 로직을 진행합니다. 이 패턴을 지키지 않으면 에러를 놓칠 수 있습니다.

Promise에서는 .catch()로 에러를 처리합니다. then 체인 어디서든 발생한 에러가 catch로 모입니다.

catch가 없으면 "Unhandled Promise Rejection" 경고가 발생하고, Node.js 15부터는 프로세스가 종료됩니다. async/await에서는 try-catch를 사용합니다.

await한 Promise가 rejected되면 에러가 throw되어 catch 블록으로 점프합니다. 동기 코드의 에러 처리와 동일한 방식이라 직관적입니다.

전역 에러 핸들러도 설정할 수 있습니다. process.on('unhandledRejection')으로 처리되지 않은 모든 Promise rejection을 잡을 수 있습니다.

마지막 안전장치로 사용합니다.

코드 예제

// 1. Promise의 .catch() 사용
function loadUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .catch(err => {
      console.error('사용자 데이터 로딩 실패:', err.message);
      // 에러를 다시 던지거나 기본값 반환
      return { id: userId, name: 'Unknown' };
    });
}

// 2. async/await의 try-catch 사용
async function saveUserData(userId, data) {
  try {
    const response = await fetch(`/api/users/${userId}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    });

    if (!response.ok) {
      throw new Error(`저장 실패: ${response.status}`);
    }

    return await response.json();
  } catch (err) {
    console.error('저장 중 에러:', err);
    throw err; // 상위로 전파
  }
}

// 3. 전역 에러 핸들러
process.on('unhandledRejection', (reason, promise) => {
  console.error('처리되지 않은 Promise 거부:', reason);
  // 로깅, 알림 등 처리
});

설명

이것이 하는 일: 세 가지 방식으로 비동기 작업의 에러를 안전하게 처리하고, 처리되지 않은 에러도 전역 핸들러로 잡아냅니다. 첫 번째로, Promise 체인에서 .catch()는 그 앞의 모든 then에서 발생한 에러를 잡습니다.

HTTP 응답이 실패하면 Error를 throw하고, 이것이 catch로 전달됩니다. catch에서 에러를 로깅하고 기본값을 반환하여 체인이 계속되도록 할 수 있습니다.

두 번째로, async 함수에서 try 블록 안의 await 중 하나라도 실패하면 즉시 catch 블록으로 점프합니다. 여러 await이 있어도 하나의 try-catch로 모두 처리할 수 있습니다.

throw err로 에러를 다시 던지면 호출한 쪽에서도 처리할 수 있습니다. 세 번째로, HTTP 응답 상태 코드를 확인하는 것이 중요합니다.

fetch는 404나 500 같은 HTTP 에러에서 Promise를 reject하지 않습니다. res.ok를 확인하고 수동으로 에러를 throw해야 합니다.

네 번째로, 전역 'unhandledRejection' 이벤트는 catch되지 않은 모든 Promise rejection을 잡습니다. 이것은 마지막 안전망이며, 여기서도 처리되지 않으면 Node.js 15 이상에서는 프로세스가 종료됩니다.

개발 중에는 경고로, 프로덕션에서는 로깅과 알림을 설정하세요. 여러분이 이 코드를 사용하면 예상치 못한 에러로 인한 프로그램 종료를 방지할 수 있습니다.

에러를 적절히 처리하고 로깅하면 문제를 빠르게 파악하고 해결할 수 있습니다. 사용자에게도 친절한 에러 메시지를 제공할 수 있습니다.

실전 팁

💡 모든 Promise 체인은 반드시 .catch()를 붙이세요. async 함수도 try-catch로 감싸거나 호출하는 쪽에서 catch하세요.

💡 에러를 catch했다고 끝이 아닙니다. 로깅, 모니터링, 사용자 알림 등 적절한 후속 조치가 필요합니다.

💡 에러 객체에는 message, stack 등 유용한 정보가 있습니다. 로깅할 때 전체 스택 트레이스를 포함하세요.

💡 커스텀 에러 클래스를 만들어 에러 타입을 구분하면, 에러별로 다른 처리를 할 수 있어 유용합니다.

💡 개발 환경에서는 에러를 자세히 출력하고, 프로덕션에서는 민감한 정보를 숨기세요.


9. EventEmitter

시작하며

여러분이 파일 업로드 진행률을 실시간으로 보여주거나, 채팅 메시지를 수신할 때마다 처리하거나, 서버 상태 변화를 감지해야 한다면 어떻게 하시겠어요? 단순히 Promise나 콜백으로는 연속적인 이벤트를 처리하기 어렵습니다.

Node.js의 EventEmitter는 이벤트 기반 비동기 프로그래밍의 핵심입니다. 특정 이벤트가 발생하면 등록된 리스너(콜백)들이 자동으로 실행됩니다.

Observer 패턴의 구현체라고 볼 수 있습니다. Node.js의 많은 핵심 모듈이 EventEmitter를 상속합니다.

HTTP 서버, 파일 스트림, 자식 프로세스 등이 모두 이벤트를 발생시킵니다. 이것을 이해하면 Node.js의 내부 동작을 깊이 이해할 수 있습니다.

실시간 통신, 스트리밍 데이터 처리, 플러그인 시스템 등 다양한 곳에서 EventEmitter가 활용됩니다.

개요

간단히 말해서, EventEmitter는 이벤트를 발생시키고(emit), 이벤트에 반응하는 리스너를 등록(on)할 수 있는 객체입니다. 이벤트는 이름으로 구분됩니다.

'data', 'error', 'complete' 같은 문자열로 이벤트를 정의하고, 각 이벤트마다 여러 개의 리스너를 등록할 수 있습니다. 이벤트가 발생하면 등록된 순서대로 모든 리스너가 실행됩니다.

.on() 또는 .addEventListener()로 리스너를 등록하고, .emit()으로 이벤트를 발생시킵니다. emit할 때 인자를 전달하면 모든 리스너가 그 인자를 받습니다.

한 번만 실행되는 리스너는 .once()로 등록할 수 있습니다. Promise나 콜백과의 차이점은 "일대다" 관계라는 점입니다.

하나의 이벤트에 여러 리스너가 반응할 수 있고, 같은 이벤트가 여러 번 발생할 수 있습니다. 스트림처럼 연속적인 데이터를 처리할 때 적합합니다.

코드 예제

const EventEmitter = require('events');

// EventEmitter를 상속하는 커스텀 클래스
class FileUploader extends EventEmitter {
  upload(file) {
    console.log('업로드 시작...');
    this.emit('start', { fileName: file.name });

    // 진행률 시뮬레이션
    let progress = 0;
    const interval = setInterval(() => {
      progress += 20;
      this.emit('progress', { percent: progress });

      if (progress >= 100) {
        clearInterval(interval);
        this.emit('complete', { fileName: file.name });
      }
    }, 500);
  }
}

// 사용 예제
const uploader = new FileUploader();

uploader.on('start', data => {
  console.log(`시작: ${data.fileName}`);
});

uploader.on('progress', data => {
  console.log(`진행률: ${data.percent}%`);
});

uploader.once('complete', data => {
  console.log(`완료: ${data.fileName}`);
});

uploader.upload({ name: 'document.pdf' });

설명

이것이 하는 일: 파일 업로드 과정에서 시작, 진행률, 완료 이벤트를 발생시키고, 각 이벤트에 등록된 리스너들이 자동으로 실행됩니다. 첫 번째로, FileUploader 클래스가 EventEmitter를 상속받아 이벤트 발생 능력을 갖습니다.

this.emit()으로 이벤트를 발생시킬 수 있고, 외부에서 .on()으로 리스너를 등록할 수 있습니다. 이것이 이벤트 기반 아키텍처의 기본입니다.

두 번째로, upload 메서드에서 세 가지 이벤트를 발생시킵니다. 'start' 이벤트는 업로드 시작 시, 'progress'는 진행률 변경 시, 'complete'는 완료 시 발생합니다.

emit의 두 번째 인자로 데이터를 전달할 수 있습니다. 세 번째로, 외부에서 .on()으로 각 이벤트에 리스너를 등록합니다.

하나의 이벤트에 여러 리스너를 등록할 수 있고, 모두 순서대로 실행됩니다. 'progress' 이벤트는 여러 번 발생하므로, 리스너도 여러 번 실행됩니다.

네 번째로, .once()로 등록한 'complete' 리스너는 한 번만 실행되고 자동으로 제거됩니다. 완료 이벤트는 한 번만 발생하므로 once가 적합합니다.

on과 once를 상황에 맞게 선택하면 됩니다. 여러분이 이 코드를 사용하면 비동기 작업의 여러 단계를 깔끔하게 처리할 수 있습니다.

진행률 표시, 실시간 알림, 로깅 등을 각각의 리스너로 분리하여 관심사를 분리할 수 있습니다. 새로운 기능을 추가할 때도 기존 코드를 수정하지 않고 리스너만 추가하면 됩니다.

실전 팁

💡 항상 'error' 이벤트에 리스너를 등록하세요. 처리되지 않은 error 이벤트는 프로그램을 종료시킵니다.

💡 메모리 누수를 방지하려면 removeListener()로 불필요한 리스너를 제거하세요. 특히 객체가 계속 생성되는 경우 중요합니다.

💡 리스너 내부에서 비동기 작업을 하면 순서를 보장할 수 없습니다. 순서가 중요하면 동기 작업만 하거나 별도 처리가 필요합니다.

💡 this.setMaxListeners()로 리스너 개수 제한을 조정할 수 있습니다. 기본값은 10개이며, 초과 시 경고가 발생합니다.


10. setTimeout/setInterval

시작하며

여러분이 3초 후에 어떤 작업을 실행하거나, 1분마다 서버 상태를 체크해야 한다면 어떻게 하시겠어요? 시간 기반 비동기 작업은 실무에서 매우 흔하게 사용됩니다.

Node.js의 setTimeout과 setInterval은 가장 기본적인 비동기 타이머 함수입니다. 브라우저 JavaScript와 동일하게 동작하지만, Node.js에서는 더 강력한 기능을 제공합니다.

디바운싱, 스로틀링, 폴링, 재시도 로직, 애니메이션 등 다양한 패턴의 기초가 됩니다. 간단해 보이지만 제대로 이해하고 사용하지 않으면 메모리 누수나 예상치 못한 동작이 발생할 수 있습니다.

타이머는 이벤트 루프와 밀접한 관련이 있으므로, 이것을 이해하면 Node.js의 비동기 메커니즘을 더 깊이 이해할 수 있습니다.

개요

간단히 말해서, setTimeout은 지정된 시간 후에 한 번 실행되는 타이머이고, setInterval은 지정된 간격마다 반복 실행되는 타이머입니다. setTimeout(callback, delay)는 delay 밀리초 후에 callback을 한 번 실행합니다.

타이머 ID를 반환하며, clearTimeout()으로 취소할 수 있습니다. delay를 0으로 설정해도 즉시 실행되지 않고, 이벤트 루프의 다음 틱에 실행됩니다.

setInterval(callback, delay)는 delay 밀리초마다 callback을 반복 실행합니다. 명시적으로 clearInterval()을 호출하기 전까지 계속 실행됩니다.

이전 실행이 끝나지 않았어도 시간이 되면 다시 실행되므로 주의가 필요합니다. Promise와 함께 사용하면 더 강력합니다.

setTimeout을 Promise로 감싸서 async/await과 함께 사용할 수 있습니다. 이것이 딜레이나 재시도 로직을 구현하는 현대적인 방법입니다.

코드 예제

// 1. 기본 setTimeout
setTimeout(() => {
  console.log('3초 후 실행');
}, 3000);

// 2. Promise로 감싼 타이머 (유용한 패턴)
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 3. async/await과 함께 사용
async function retryFetch(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) return await response.json();

      console.log(`재시도 ${i + 1}/${maxRetries}...`);
      await delay(1000 * (i + 1)); // 지수 백오프
    } catch (err) {
      if (i === maxRetries - 1) throw err;
    }
  }
}

// 4. setInterval과 cleanup
function startPolling(callback, interval = 5000) {
  const timerId = setInterval(async () => {
    try {
      await callback();
    } catch (err) {
      console.error('폴링 에러:', err);
    }
  }, interval);

  // cleanup 함수 반환
  return () => clearInterval(timerId);
}

// 사용 예제
const stopPolling = startPolling(async () => {
  const status = await checkServerStatus();
  console.log('서버 상태:', status);
}, 10000);

// 30초 후 폴링 중지
setTimeout(stopPolling, 30000);

설명

이것이 하는 일: 타이머를 사용하여 재시도 로직과 주기적인 폴링을 구현하고, 적절한 정리(cleanup)를 수행합니다. 첫 번째로, delay 함수는 setTimeout을 Promise로 감싸서 await 가능하게 만듭니다.

이것이 핵심 패턴입니다. Promise의 resolve를 setTimeout의 콜백으로 전달하면, 지정된 시간 후에 Promise가 fulfilled됩니다.

두 번째로, retryFetch는 실패한 요청을 자동으로 재시도합니다. 각 재시도 사이에 await delay()로 대기 시간을 둡니다.

재시도 횟수가 증가할수록 대기 시간을 늘리는 지수 백오프(exponential backoff) 전략을 사용합니다. 세 번째로, startPolling은 setInterval로 주기적인 작업을 수행합니다.

비동기 콜백을 지원하기 위해 async 함수를 사용하고, 에러가 발생해도 폴링이 중단되지 않도록 try-catch로 감쌉니다. 이것이 안정적인 폴링 패턴입니다.

네 번째로, cleanup 함수를 반환하여 폴링을 중지할 수 있게 합니다. setInterval은 명시적으로 clearInterval을 호출하지 않으면 계속 실행되어 메모리 누수가 발생합니다.

반드시 정리 메커니즘을 제공해야 합니다. 여러분이 이 코드를 사용하면 네트워크 요청 재시도, 서버 헬스 체크, 데이터 동기화 등 실무의 다양한 시나리오를 안정적으로 구현할 수 있습니다.

타이머를 Promise와 결합하면 async/await의 모든 이점을 누리면서도 시간 기반 제어가 가능합니다.

실전 팁

💡 setInterval은 이전 실행이 끝나지 않아도 다시 실행됩니다. 긴 작업은 재귀적 setTimeout을 사용하세요.

💡 컴포넌트나 객체가 제거될 때 반드시 clearTimeout/clearInterval을 호출하세요. 메모리 누수의 주범입니다.

💡 delay를 0으로 설정해도 즉시 실행되지 않습니다. 현재 실행 중인 코드가 끝난 후 이벤트 루프의 다음 틱에 실행됩니다.

💡 재시도 로직에는 최대 재시도 횟수와 백오프 전략을 반드시 구현하세요. 무한 재시도는 서버 부하를 증가시킵니다.

💡 Node.js의 Timeout 객체는 unref() 메서드를 지원합니다. 타이머만 남았을 때 프로세스가 종료되도록 할 수 있습니다.


#Node.js#async/await#Promise#Callback#EventLoop#JavaScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.
이전2/2
다음