본문으로 건너뛰기
Previous
Next
Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await

Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await

Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await

이 글의 핵심

Node.js 비동기 프로그래밍: Callback, Promise, Async/Await. Callback (콜백)·Promise.

들어가며

비동기 프로그래밍이란?

비동기(Asynchronous) 프로그래밍은 작업이 완료될 때까지 기다리지 않고 다음 코드를 실행하는 방식입니다. 이 글에서 자주 쓰는 비유는 다음과 같습니다. 콜백은 “일이 끝나면 이 번호로 연락 주세요”처럼 전화번호를 남기는 것과 같습니다. Promise는 나중에 결과를 받을 수 있는 약속 쿠폰·영수증에 가깝고, 성공·실패 상태가 적혀 있습니다. async/await는 실제로는 비동기인데 위에서 아래로 읽히는 동기 코드처럼 보이게 정리한 문법입니다. 이벤트 루프는 식당에서 주문만 받고 주방·배달은 다른 곳에서 처리하는 흐름, 또는 우체국 창구처럼 순번이 돌아올 때까지 기다렸다가 처리하는 시스템으로 이해하시면 됩니다. 동기 vs 비동기:

// 동기 (Synchronous)
console.log('1');
console.log('2');
console.log('3');
// 출력: 1 → 2 → 3 (순서대로)
// 비동기 (Asynchronous)
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 출력: 1 → 3 → 2 (2는 나중에)

Node.js가 비동기를 사용하는 이유:

  • Non-blocking I/O: 파일, 네트워크 작업 중에도 다른 요청 처리
  • 높은 동시성: 단일 스레드로 수천 개의 동시 연결 처리
  • 효율성: CPU 대기 시간 최소화 같은 자바스크립트 계열이라도 브라우저·JS 엔진의 Promise·async/await는 이 모델과 맞닿아 있고, C++의 std::async처럼 별도 스레드에 작업을 넘기는 방식과는 출발점이 다릅니다. 경량 동시성 모델은 Go 고루틴·Kotlin 코루틴·Rust async 글과 비교해 보면 이해가 빨라집니다.

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

1. Callback (콜백)

기본 개념

콜백은 다른 함수에 인자로 전달되는 함수입니다. “작업이 끝나면 이 함수를 실행해 주세요”라고 연락처를 넘기는 것과 같아서, Node.js의 비동기 API는 대부분 이런 형태로 결과를 돌려줍니다.

// 동기 콜백
function greet(name, callback) {
    const message = `안녕하세요, ${name}님!`;
    callback(message);
}
greet('홍길동', (msg) => {
    console.log(msg);
});
// 안녕하세요, 홍길동님!
// 비동기 콜백
setTimeout(() => {
    console.log('1초 후 실행');
}, 1000);

에러 우선 콜백 (Error-First Callback)

Node.js의 표준 콜백 패턴:

const fs = require('fs');
// 첫 번째 인자: 에러
// 두 번째 인자: 결과
fs.readFile('file.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('에러 발생:', err.message);
        return;
    }
    
    console.log('파일 내용:', data);
});

Callback Hell (콜백 지옥)

비동기 작업을 순차적으로 실행하다 보면 콜백이 중첩되어 코드가 읽기 어려워집니다: 문제 상황:

const fs = require('fs');
// 파일 3개를 순서대로 읽기 (각 파일을 읽은 후 다음 파일 읽기)
fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    // 첫 번째 콜백 - 1단계 들여쓰기
    if (err1) {
        console.error(err1);
        return;
    }
    
    console.log('파일 1:', data1);
    
    // 두 번째 파일 읽기 (첫 번째가 끝난 후)
    fs.readFile('file2.txt', 'utf8', (err2, data2) => {
        // 두 번째 콜백 - 2단계 들여쓰기
        if (err2) {
            console.error(err2);
            return;
        }
        
        console.log('파일 2:', data2);
        
        // 세 번째 파일 읽기 (두 번째가 끝난 후)
        fs.readFile('file3.txt', 'utf8', (err3, data3) => {
            // 세 번째 콜백 - 3단계 들여쓰기
            if (err3) {
                console.error(err3);
                return;
            }
            
            console.log('파일 3:', data3);
            
            // 더 깊어질 수 있음....(4단계, 5단계...)
            // 이런 구조를 "피라미드 오브 둠(Pyramid of Doom)"이라고 부름
        });
    });
});

문제점:

  • 가독성 저하: 피라미드 구조로 코드가 오른쪽으로 계속 들여쓰기됨
  • 에러 처리 중복: 각 콜백마다 if (err) 체크 반복
  • 유지보수 어려움: 코드 수정이나 디버깅이 매우 어려움
  • 로직 파악 어려움: 실행 흐름을 따라가기 힘듦 실제 프로젝트에서는:
  • 5~10단계 이상 중첩되는 경우도 있음
  • 각 단계마다 에러 처리, 로깅, 유효성 검사 추가
  • 코드가 수백 줄로 늘어나 관리 불가능

2. Promise

기본 개념

Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 나중에 찾아가면 결과를 주겠다는 약속 쿠폰처럼 생각하시면 됩니다. 아직 대기 중(pending)이거나, 성공(fulfilled)으로 찍혔거나, 거절(rejected)된 상태 중 하나입니다. 상태:

  • Pending: 대기 중
  • Fulfilled: 성공 (.then() 실행)
  • Rejected: 실패 (.catch() 실행)

Promise 생성

Promise를 직접 만들어서 비동기 작업을 캡슐화할 수 있습니다:

function delay(ms) {
    // new Promise: Promise 객체 생성
    // executor 함수: (resolve, reject) => { ....}
    // resolve: 성공 시 호출할 함수
    // reject: 실패 시 호출할 함수
    return new Promise((resolve, reject) => {
        // 입력 검증
        if (ms < 0) {
            // reject 호출: Promise를 실패 상태로 만듦
            // Error 객체를 전달하면 .catch()에서 받을 수 있음
            reject(new Error('시간은 양수여야 합니다'));
            return;
        }
        
        // setTimeout: ms 밀리초 후에 콜백 실행
        setTimeout(() => {
            // resolve 호출: Promise를 성공 상태로 만듦
            // 전달한 값은 .then()에서 받을 수 있음
            resolve(`${ms}ms 대기 완료`);
        }, ms);
    });
}
// Promise 사용
delay(1000)
    // then: Promise가 성공하면 실행
    // result: resolve에 전달한 값
    .then((result) => {
        console.log(result);  // 1000ms 대기 완료
    })
    // catch: Promise가 실패하면 실행
    // err: reject에 전달한 Error 객체
    .catch((err) => {
        console.error('에러:', err.message);
    });

Promise 생성자의 동작:

  1. new Promise() 호출 시 executor 함수가 즉시 실행됨
  2. 비동기 작업 수행 (예: setTimeout, 파일 읽기)
  3. 작업 성공 시 resolve(value) 호출 → .then()으로 이동
  4. 작업 실패 시 reject(error) 호출 → .catch()로 이동 Promise의 장점:
  • 콜백보다 읽기 쉬운 체이닝 구조
  • 에러 처리를 한 곳에서 관리 (.catch())
  • 여러 Promise를 조합 가능 (.all(), .race() 등)

Promise 체이닝

const fs = require('fs').promises;
// Callback Hell 해결
fs.readFile('file1.txt', 'utf8')
    .then((data1) => {
        console.log('파일 1:', data1);
        return fs.readFile('file2.txt', 'utf8');
    })
    .then((data2) => {
        console.log('파일 2:', data2);
        return fs.readFile('file3.txt', 'utf8');
    })
    .then((data3) => {
        console.log('파일 3:', data3);
    })
    .catch((err) => {
        console.error('에러:', err.message);
    })
    .finally(() => {
        console.log('작업 완료');
    });

Promise 정적 메서드

Promise.all (모두 성공해야 함):

const fs = require('fs').promises;
Promise.all([
    fs.readFile('file1.txt', 'utf8'),
    fs.readFile('file2.txt', 'utf8'),
    fs.readFile('file3.txt', 'utf8')
])
.then(([data1, data2, data3]) => {
    console.log('파일 1:', data1);
    console.log('파일 2:', data2);
    console.log('파일 3:', data3);
})
.catch((err) => {
    console.error('에러:', err.message);
    // 하나라도 실패하면 catch 실행
});

Promise.allSettled (모든 결과 확인):

Promise.allSettled([
    fs.readFile('file1.txt', 'utf8'),
    fs.readFile('file2.txt', 'utf8'),
    fs.readFile('nonexistent.txt', 'utf8')
])
.then((results) => {
    results.forEach((result, index) => {
        if (result.status === 'fulfilled') {
            console.log(`파일 ${index + 1} 성공:`, result.value);
        } else {
            console.log(`파일 ${index + 1} 실패:`, result.reason.message);
        }
    });
});

Promise.race (가장 빠른 것):

Promise.race([
    delay(1000).then(() => '1초'),
    delay(2000).then(() => '2초'),
    delay(500).then(() => '0.5초')
])
.then((result) => {
    console.log('가장 빠른 것:', result);  // 0.5초
});

Promise.any (하나라도 성공):

Promise.any([
    Promise.reject('에러 1'),
    Promise.reject('에러 2'),
    Promise.resolve('성공!')
])
.then((result) => {
    console.log(result);  // 성공!
})
.catch((err) => {
    console.error('모두 실패:', err);
});

3. Async/Await

기본 사용법

async/await는 Promise를 더 읽기 쉽게 만드는 문법적 설탕(Syntactic Sugar)입니다:

const fs = require('fs').promises;
// async 키워드: 이 함수가 비동기 함수임을 선언
// async 함수는 항상 Promise를 반환
async function readFiles() {
    try {
        // await: Promise가 완료될 때까지 대기
        // 코드는 동기처럼 보이지만 실제로는 비동기로 동작
        // await는 async 함수 안에서만 사용 가능
        
        // 첫 번째 파일 읽기 (완료될 때까지 대기)
        const data1 = await fs.readFile('file1.txt', 'utf8');
        console.log('파일 1:', data1);
        
        // 첫 번째가 끝난 후 두 번째 파일 읽기
        const data2 = await fs.readFile('file2.txt', 'utf8');
        console.log('파일 2:', data2);
        
        // 두 번째가 끝난 후 세 번째 파일 읽기
        const data3 = await fs.readFile('file3.txt', 'utf8');
        console.log('파일 3:', data3);
        
        // 모든 작업이 성공하면 이 값을 resolve
        return '모든 파일 읽기 완료';
        
    } catch (err) {
        // await 중 에러가 발생하면 catch 블록으로 이동
        // Promise의 .catch()와 동일한 역할
        console.error('에러:', err.message);
        throw err;  // 에러를 다시 던져서 호출자에게 전달
    }
}
// async 함수 호출
readFiles()
    .then((result) => {
        console.log(result);  // 모든 파일 읽기 완료
    })
    .catch((err) => {
        console.error('최종 에러:', err.message);
    });

async/await의 동작 원리:

  1. async function은 내부적으로 Promise를 반환
  2. await는 Promise가 완료될 때까지 함수 실행을 일시 중지
  3. 다른 코드는 계속 실행됨 (블로킹하지 않음)
  4. Promise가 완료되면 함수 실행 재개 Callback Hell → Promise → async/await 비교:
// Callback Hell (읽기 어려움)
fs.readFile('file1.txt', (err, data) => {
    fs.readFile('file2.txt', (err, data) => {
        fs.readFile('file3.txt', (err, data) => {
            // ...
        });
    });
});
// Promise (나아짐)
fs.promises.readFile('file1.txt')
    .then(() => fs.promises.readFile('file2.txt'))
    .then(() => fs.promises.readFile('file3.txt'));
// async/await (가장 읽기 쉬움)
const data1 = await fs.promises.readFile('file1.txt');
const data2 = await fs.promises.readFile('file2.txt');
const data3 = await fs.promises.readFile('file3.txt');

병렬 처리

순차 실행 (느림):

async function sequential() {
    const start = Date.now();
    
    const data1 = await delay(1000);  // 1초 대기
    const data2 = await delay(1000);  // 1초 대기
    const data3 = await delay(1000);  // 1초 대기
    
    const elapsed = Date.now() - start;
    console.log(`총 ${elapsed}ms`);  // 약 3000ms
}

병렬 실행 (빠름):

async function parallel() {
    const start = Date.now();
    
    // 동시에 시작
    const promise1 = delay(1000);
    const promise2 = delay(1000);
    const promise3 = delay(1000);
    
    // 모두 완료 대기
    const [data1, data2, data3] = await Promise.all([
        promise1,
        promise2,
        promise3
    ]);
    
    const elapsed = Date.now() - start;
    console.log(`총 ${elapsed}ms`);  // 약 1000ms
}

Promise.all 사용:

// 실행 예제
async function parallelWithAll() {
    const files = ['file1.txt', 'file2.txt', 'file3.txt'];
    
    const results = await Promise.all(
        files.map(file => fs.readFile(file, 'utf8'))
    );
    
    results.forEach((data, index) => {
        console.log(`파일 ${index + 1}:`, data);
    });
}

에러 처리

async function handleErrors() {
    try {
        const data = await fs.readFile('nonexistent.txt', 'utf8');
        console.log(data);
    } catch (err) {
        if (err.code === 'ENOENT') {
            console.error('파일을 찾을 수 없습니다');
        } else {
            console.error('에러:', err.message);
        }
    } finally {
        console.log('작업 완료');
    }
}
// 또는 .catch() 사용
async function handleErrorsWithCatch() {
    const data = await fs.readFile('file.txt', 'utf8')
        .catch((err) => {
            console.error('파일 읽기 실패:', err.message);
            return '기본값';  // 기본값 반환
        });
    
    console.log(data);
}

4. 이벤트 루프 (Event Loop) — 단계·마이크로태스크·nextTick

한 줄 요약

자바스크립트는 한 스레드에서 콜 스택을 하나씩 비우는 동안 실행되고, 타이머·소켓·파일 완료는 libuv가 OS에 붙어 처리한 뒤 “이제 이 콜백을 실행해도 된다”는 신호를 단계(phase) 큐로 넘깁니다. 그 사이사이에 nextTick 큐마이크로태스크(Promise) 큐가 끼어들어, 동기 코드 → nextTick → Promise → (페이즈별 매크로태스크) 순으로 미세하게 갈립니다.

libuv 페이즈(phase)가 의미하는 것

아래 다이어그램은 Node 문서에서 자주 인용하는 페이즈 순회를 단순화한 것입니다. 실제 구현 세부는 버전·플랫폼에 따라 달라질 수 있으나, “어떤 종류의 콜백이 어느 시점에 합류하는가”를 이해하는 데는 충분합니다.

   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  I/O 콜백 (일부 지연된 콜백)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  내부용
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  I/O 이벤트 대기·수신
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──│      close callbacks      │  소켓·핸들 close
   └───────────────────────────┘
  • timers: 타이머 힙에서 만료된 항목의 콜백을 실행합니다. setTimeout(fn, 0)은 “즉시”가 아니라 최소 지연으로 스케줄될 뿐이며, 보장되는 것은 그 시점 이후입니다.
  • pending callbacks: 이전 루프에서 미완료로 남은 일부 I/O 콜백이 여기로 이월될 수 있습니다(버전·케이스에 따라 다름).
  • poll: 준비된 파일 디스크립터에서 읽을 데이터가 있는지, 쓸 수 있는지 등을 짧게 대기하며 이벤트를 수집합니다. 네트워크 대기 시간이 길어질수록 이 단계의 체류가 늘어납니다.
  • check: setImmediate 콜백이 실행됩니다. I/O 직후에 setImmediate를 쓰면 setTimeout(0)과의 상대 순서가 플랫폼·상황에 따라 달라질 수 있다는 점이 자주 언급됩니다.
  • close: socket.on('close', …) 같은 종료 처리 콜백이 이쪽에 가깝게 정리됩니다.

마이크로태스크 큐와 nextTick: “페이즈 사이의 끼어들기”

Node는 페이즈를 한 바퀴 도는 것과 별도로, 다음 두 큐를 매우 높은 우선순위로 처리합니다.

  1. process.nextTick: 이름은 “다음 틱”이지만 실제로는 Promise 마이크로태스크보다도 먼저 비웁니다.
  2. 마이크로태스크 큐: Promise.then, queueMicrotask, async 함수가 마이크로태스크로 스케줄하는 후속 작업이 여기에 들어갑니다.

핵심 규칙: 한 페이즈에서 콜백이 실행되기 전·후, nextTick과 마이크로태스크는 “현재 턴”을 끝내기 전에 가능한 한 비웁니다. 특히 nextTick에서 또 nextTick을 예약하면 I/O 콜백이 오랫동안 실행되지 못하는 기아(starvation) 가 생길 수 있어, 라이브러리에서도 남용을 경계합니다.

실행 순서 예시 (동기 → nextTick → Promise → 타이머)

console.log('1. 동기 코드');
setTimeout(() => {
    console.log('4. setTimeout (0ms)');
}, 0);
setImmediate(() => {
    console.log('5. setImmediate');
});
Promise.resolve().then(() => {
    console.log('3. Promise (Microtask)');
});
console.log('2. 동기 코드');
// 출력 순서:
// 1. 동기 코드
// 2. 동기 코드
// 3. Promise (Microtask)
// 4. setTimeout (0ms)
// 5. setImmediate

위 예에서 타이머와 setImmediate의 순서는 “항상 고정”이라기보다 첫 턴에 타이머가 등록되는 방식poll 단계의 체류에 영향을 받습니다. 반대로 Promise는 setTimeout보다 항상 앞선다는 점은 마이크로태스크 모델에 더 가깝습니다.

process.nextTick과 Promise의 상대 위치

console.log('1');
process.nextTick(() => {
    console.log('3. nextTick');
});
Promise.resolve().then(() => {
    console.log('4. Promise');
});
console.log('2');
// 출력:
// 1
// 2
// 3. nextTick (Promise보다 먼저)
// 4. Promise

실무적 함의:

  • nextTick: 스택이 완전히 빠진 직후, 아직 같은 논리적 “작업” 안에서 후속을 보장하고 싶을 때 씁니다. 예: 에러를 터뜨리기 전에 상태를 정리하거나, 공개 API가 동기적으로 보이게 내부만 비동기로 나눌 때.
  • queueMicrotask / Promise: 표준에 가깝고, 브라우저와의 정신적 모델을 맞추기 좋습니다. 사용자 코드에서는 Promise.resolve().then(...)이나 queueMicrotask가 더 흔합니다.

주의: process.nextTick을 연쇄적으로 호출하면 I/O·타이머 콜백이 뒤로 밀려 이벤트 루프가 사실상 한 종류의 작업에 점유될 수 있습니다. CPU 프로파일에서 “루프는 도는데 네트워크 핸들러가 안 뜬다”면 이 패턴을 의심합니다.

정리: 이벤트 루프를 읽는 법

  1. 동기 코드가 콜 스택을 비웁니다.
  2. nextTickPromise 마이크로태스크를 반복해 소진합니다(내부에서 새로 예약된 것까지).
  3. libuv 페이즈에 따라 매크로태스크(타이머·I/O·setImmediate 등)가 실행됩니다.
  4. 각 매크로태스크 뒤에도 다시 2번이 끼어들 수 있습니다(환경·버전에 따라 “매크로태스크마다 마이크로태스크 드레인” 모델이 강해집니다).

이 모델을 알고 있으면 “왜 로그 순서가 이렇게 나왔는가”를 재현 가능한 가설로 바꿀 수 있고, “왜 이 비동기 코드는 I/O보다 먼저 도는가”를 설계할 때 근거가 됩니다.


5. Callback → Promise 변환

util.promisify

const fs = require('fs');
const util = require('util');
// Callback 기반 함수를 Promise로 변환
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
async function main() {
    try {
        const data = await readFile('file.txt', 'utf8');
        console.log(data);
        
        await writeFile('output.txt', data, 'utf8');
        console.log('파일 쓰기 완료');
    } catch (err) {
        console.error('에러:', err.message);
    }
}
main();

수동 변환

// Callback 기반 함수
function fetchData(url, callback) {
    setTimeout(() => {
        if (url) {
            callback(null, { data: 'result' });
        } else {
            callback(new Error('URL이 필요합니다'));
        }
    }, 1000);
}
// Promise로 변환
function fetchDataPromise(url) {
    return new Promise((resolve, reject) => {
        fetchData(url, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}
// 사용
async function main() {
    try {
        const data = await fetchDataPromise('https://api.example.com');
        console.log(data);
    } catch (err) {
        console.error('에러:', err.message);
    }
}

6. 실전 예제

예제 1: 파일 처리 파이프라인

const fs = require('fs').promises;
const path = require('path');
async function processFiles(inputDir, outputDir) {
    try {
        // 1. 디렉토리 읽기
        const files = await fs.readdir(inputDir);
        console.log(`${files.length}개 파일 발견`);
        
        // 2. 출력 디렉토리 생성
        await fs.mkdir(outputDir, { recursive: true });
        
        // 3. 각 파일 처리 (병렬)
        const results = await Promise.allSettled(
            files.map(async (file) => {
                const inputPath = path.join(inputDir, file);
                const outputPath = path.join(outputDir, `processed-${file}`);
                
                // 파일 읽기
                const data = await fs.readFile(inputPath, 'utf8');
                
                // 처리 (예: 대문자 변환)
                const processed = data.toUpperCase();
                
                // 파일 쓰기
                await fs.writeFile(outputPath, processed, 'utf8');
                
                return { file, success: true };
            })
        );
        
        // 4. 결과 요약
        const succeeded = results.filter(r => r.status === 'fulfilled').length;
        const failed = results.filter(r => r.status === 'rejected').length;
        
        console.log(`성공: ${succeeded}개, 실패: ${failed}개`);
        
        // 실패한 파일 출력
        results.forEach((result, index) => {
            if (result.status === 'rejected') {
                console.error(`파일 ${files[index]} 실패:`, result.reason.message);
            }
        });
        
    } catch (err) {
        console.error('치명적 에러:', err.message);
    }
}
processFiles('./input', './output');

예제 2: API 요청 재시도

async function fetchWithRetry(url, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            console.log(`시도 ${i + 1}/${maxRetries}`);
            
            const response = await fetch(url);
            
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }
            
            const data = await response.json();
            return data;
            
        } catch (err) {
            console.error(`시도 ${i + 1} 실패:`, err.message);
            
            if (i === maxRetries - 1) {
                throw new Error(`${maxRetries}번 시도 후 실패`);
            }
            
            // 지수 백오프 (Exponential Backoff)
            const delay = Math.pow(2, i) * 1000;
            console.log(`${delay}ms 후 재시도...`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}
// 사용
async function main() {
    try {
        const data = await fetchWithRetry('https://api.example.com/data');
        console.log('데이터:', data);
    } catch (err) {
        console.error('최종 실패:', err.message);
    }
}
main();

예제 3: 동시 실행 제한

async function limitConcurrency(tasks, limit) {
    const results = [];
    const executing = [];
    
    for (const task of tasks) {
        const promise = task().then((result) => {
            // 완료된 작업 제거
            executing.splice(executing.indexOf(promise), 1);
            return result;
        });
        
        results.push(promise);
        executing.push(promise);
        
        // 동시 실행 수 제한
        if (executing.length >= limit) {
            await Promise.race(executing);
        }
    }
    
    return Promise.all(results);
}
// 사용 예제
async function main() {
    const urls = [
        'https://api.example.com/1',
        'https://api.example.com/2',
        'https://api.example.com/3',
        'https://api.example.com/4',
        'https://api.example.com/5'
    ];
    
    // 최대 2개씩만 동시 실행
    const tasks = urls.map(url => () => fetch(url).then(r => r.json()));
    const results = await limitConcurrency(tasks, 2);
    
    console.log('결과:', results);
}

예제 4: 타임아웃 처리

function timeout(ms) {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error(`${ms}ms 타임아웃`));
        }, ms);
    });
}
async function fetchWithTimeout(url, timeoutMs = 5000) {
    try {
        const result = await Promise.race([
            fetch(url).then(r => r.json()),
            timeout(timeoutMs)
        ]);
        
        return result;
    } catch (err) {
        if (err.message.includes('타임아웃')) {
            console.error('요청 시간 초과');
        }
        throw err;
    }
}
// 사용
async function main() {
    try {
        const data = await fetchWithTimeout('https://slow-api.com/data', 3000);
        console.log(data);
    } catch (err) {
        console.error('에러:', err.message);
    }
}

7. 스트림 (Stream) — 백프레셔·pipeline 내부

스트림이란?

스트림은 데이터를 청크(chunk) 단위로 처리하는 방식입니다. 통째로 컵에 붓는 대신 호스로 조금씩 흘려 보내는 것과 비슷해서, 수 GB 파일도 전부 메모리에 올리지 않고 처리할 수 있습니다.

장점:

  • 메모리 효율: 전체 데이터를 메모리에 로드하지 않음
  • 빠른 시작: 첫 청크부터 처리 시작
  • 파이프라인: 여러 작업을 연결

스트림 종류:

  • Readable: 읽기 (파일 읽기, HTTP 요청)
  • Writable: 쓰기 (파일 쓰기, HTTP 응답)
  • Duplex: 읽기/쓰기 (TCP 소켓)
  • Transform: 변환 (압축, 암호화)

백프레셔(backpressure): “빨리 읽지 마라”는 신호

스트림 파이프라인에서 생산자(Readable)소비자(Writable) 보다 빠르면, 내부 버퍼에 청크가 무한히 쌓여 메모리 폭증이 납니다. Node의 stream 구현은 이를 막기 위해 백프레셔를 둡니다.

  • writable.write(chunk)의 반환값: false이면 내부 버퍼가 highWaterMark에 가깝다는 뜻입니다. 이때 생산자는 더 쓰지 말고 'drain' 이벤트가 올 때까지 멈추는 것이 안전합니다.
  • Readable 쪽 readable.pause() / resume(): 상위 API에서 pipe를 쓰면 내부적으로 흐름 제어가 연결되지만, 직접 이벤트로 읽을 때는 소비 속도에 맞춰 읽기를 멈추는 책임이 개발자에게 돌아옵니다.
  • 왜 중요한가: 대용량 업로드·다운로드·로그 파이프라인에서 OOM(메모리 부족) 은 종종 “한 번에 다 읽었다”가 아니라 백프레셔를 무시하고 계속 push했다에서 옵니다.

간단한 관찰용 패턴은 다음과 같습니다. 실서비스에서는 pipeline(아래 절) 사용이 기본입니다.

const fs = require('fs');
const r = fs.createReadStream('big.bin');
const w = fs.createWriteStream('out.bin');

r.on('data', (chunk) => {
  const ok = w.write(chunk);
  if (!ok) r.pause(); // 쓰기 쪽이 못 받을 만큼 찼다 → 읽기 잠시 중단
});

w.on('drain', () => {
  r.resume(); // 버퍼 여유 생김 → 읽기 재개
});

pipestream.pipeline의 차이(에러·종료 처리)

전통적인 readable.pipe(writable)은 편하지만, 중간 스트림에서 에러가 나면 파이프라인 전체를 안전하게 정리하는 규칙을 직접 쓰기 쉽지 않았습니다. stream.pipeline에러 전파·리소스 정리·콜백/Promise 지원을 표준화합니다.

  • pipeline(a, b, c, cb): 각 단계가 끝나거나 에러가 나면 후속 스트림을 적절히 닫으려 시도하고, 최종 콜백에 첫 에러를 넘깁니다.
  • 내부적으로는 각 스트림의 'error', 'finish', 'close' 조합을 올바르게 묶어 누수·행(hang)·이중 파괴를 줄이는 쪽으로 구현되어 있습니다(세부는 Node 버전 릴리스 노트·문서 참고).
const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

pipeline(
  fs.createReadStream('in.txt'),
  zlib.createGzip(),
  fs.createWriteStream('in.txt.gz'),
  (err) => {
    if (err) console.error('파이프라인 실패:', err);
    else console.log('완료');
  }
);

실무 규칙: 새 코드는 pipeline 우선, 레거시는 pipe + 명시적 error 핸들러 조합을 점검합니다.

Transform·Duplex에서의 내부 버퍼

Transform_transform에서 변환 결과를 push하지만, 읽기·쓰기 양쪽에 각각 버퍼가 있을 수 있습니다. 변환이 느리면 읽기 쪽 백프레셔쓰기 쪽 백프레셔가 동시에 걸리는 복합 상황이 됩니다. 이때는 highWaterMark 조정, 객체 모드 여부, 청크 크기를 함께 봐야 합니다.

파일 스트림

const fs = require('fs');
// 읽기 스트림
const readStream = fs.createReadStream('large-file.txt', {
    encoding: 'utf8',
    highWaterMark: 64 * 1024  // 64KB 청크
});
readStream.on('data', (chunk) => {
    console.log(`청크 받음: ${chunk.length} bytes`);
});
readStream.on('end', () => {
    console.log('파일 읽기 완료');
});
readStream.on('error', (err) => {
    console.error('에러:', err.message);
});
// 쓰기 스트림
const writeStream = fs.createWriteStream('output.txt');
writeStream.write('첫 번째 줄\n');
writeStream.write('두 번째 줄\n');
writeStream.end('마지막 줄\n');
writeStream.on('finish', () => {
    console.log('파일 쓰기 완료');
});

파이프 (Pipe)

const fs = require('fs');
const zlib = require('zlib');
// 파일 복사
fs.createReadStream('input.txt')
    .pipe(fs.createWriteStream('output.txt'));
// 파일 압축
fs.createReadStream('input.txt')
    .pipe(zlib.createGzip())
    .pipe(fs.createWriteStream('input.txt.gz'));
// 파일 압축 해제
fs.createReadStream('input.txt.gz')
    .pipe(zlib.createGunzip())
    .pipe(fs.createWriteStream('output.txt'));
// 체이닝
fs.createReadStream('input.txt')
    .pipe(transformStream)  // 변환
    .pipe(zlib.createGzip())  // 압축
    .pipe(fs.createWriteStream('output.txt.gz'));  // 저장

Transform 스트림

const { Transform } = require('stream');
// 대문자 변환 스트림
class UpperCaseTransform extends Transform {
    _transform(chunk, encoding, callback) {
        const upperChunk = chunk.toString().toUpperCase();
        this.push(upperChunk);
        callback();
    }
}
// 사용
fs.createReadStream('input.txt')
    .pipe(new UpperCaseTransform())
    .pipe(fs.createWriteStream('output.txt'));

8. 실전 프로젝트

프로젝트 1: 파일 다운로더

// downloader.js
const https = require('https');
const fs = require('fs');
const path = require('path');
async function downloadFile(url, outputPath) {
    return new Promise((resolve, reject) => {
        const file = fs.createWriteStream(outputPath);
        
        https.get(url, (response) => {
            if (response.statusCode !== 200) {
                reject(new Error(`HTTP ${response.statusCode}`));
                return;
            }
            
            const totalSize = parseInt(response.headers['content-length'], 10);
            let downloadedSize = 0;
            
            response.on('data', (chunk) => {
                downloadedSize += chunk.length;
                const progress = ((downloadedSize / totalSize) * 100).toFixed(2);
                process.stdout.write(`\r다운로드: ${progress}%`);
            });
            
            response.pipe(file);
            
            file.on('finish', () => {
                file.close();
                console.log('\n다운로드 완료');
                resolve(outputPath);
            });
            
        }).on('error', (err) => {
            fs.unlink(outputPath, () => {});
            reject(err);
        });
    });
}
// 사용
async function main() {
    try {
        const url = 'https://nodejs.org/dist/latest/node-v20.11.0.tar.gz';
        const output = path.join(__dirname, 'node.tar.gz');
        
        await downloadFile(url, output);
        console.log('저장 위치:', output);
    } catch (err) {
        console.error('다운로드 실패:', err.message);
    }
}
main();

프로젝트 2: 배치 작업 처리

// batch-processor.js
const fs = require('fs').promises;
const path = require('path');
class BatchProcessor {
    constructor(concurrency = 3) {
        this.concurrency = concurrency;
    }
    
    async processFiles(inputDir, processor) {
        try {
            // 파일 목록
            const files = await fs.readdir(inputDir);
            console.log(`총 ${files.length}개 파일`);
            
            // 배치 단위로 처리
            const results = [];
            
            for (let i = 0; i < files.length; i += this.concurrency) {
                const batch = files.slice(i, i + this.concurrency);
                console.log(`\n배치 ${Math.floor(i / this.concurrency) + 1} 처리 중...`);
                
                const batchResults = await Promise.allSettled(
                    batch.map(async (file) => {
                        const filePath = path.join(inputDir, file);
                        const data = await fs.readFile(filePath, 'utf8');
                        const result = await processor(file, data);
                        return { file, result };
                    })
                );
                
                results.push(...batchResults);
                
                // 진행률
                const progress = ((i + batch.length) / files.length * 100).toFixed(1);
                console.log(`진행률: ${progress}%`);
            }
            
            // 결과 요약
            const succeeded = results.filter(r => r.status === 'fulfilled').length;
            const failed = results.filter(r => r.status === 'rejected').length;
            
            console.log(`\n완료: ${succeeded}개, 실패: ${failed}개`);
            
            return results;
            
        } catch (err) {
            console.error('배치 처리 실패:', err.message);
            throw err;
        }
    }
}
// 사용
async function main() {
    const processor = new BatchProcessor(3);
    
    await processor.processFiles('./data', async (filename, content) => {
        // 파일 처리 로직
        console.log(`처리 중: ${filename}`);
        await new Promise(resolve => setTimeout(resolve, 1000));
        return content.length;
    });
}
main();

프로젝트 3: 웹 크롤러

// crawler.js
const https = require('https');
class WebCrawler {
    constructor() {
        this.visited = new Set();
    }
    
    async fetch(url) {
        return new Promise((resolve, reject) => {
            https.get(url, (res) => {
                let data = ';
                
                res.on('data', (chunk) => {
                    data += chunk;
                });
                
                res.on('end', () => {
                    resolve(data);
                });
                
            }).on('error', reject);
        });
    }
    
    async crawl(url, maxDepth = 2, currentDepth = 0) {
        if (currentDepth > maxDepth || this.visited.has(url)) {
            return;
        }
        
        this.visited.add(url);
        console.log(`크롤링 [깊이 ${currentDepth}]: ${url}`);
        
        try {
            const html = await this.fetch(url);
            
            // 링크 추출 (간단한 정규식)
            const linkRegex = /href="(https?:\/\/[^"]+)"/g;
            const links = [];
            let match;
            
            while ((match = linkRegex.exec(html)) !== null) {
                links.push(match[1]);
            }
            
            console.log(`${links.length}개 링크 발견`);
            
            // 재귀적으로 크롤링 (병렬)
            await Promise.allSettled(
                links.slice(0, 5).map(link => 
                    this.crawl(link, maxDepth, currentDepth + 1)
                )
            );
            
        } catch (err) {
            console.error(`크롤링 실패 ${url}:`, err.message);
        }
    }
}
// 사용
async function main() {
    const crawler = new WebCrawler();
    await crawler.crawl('https://example.com', 1);
    console.log(`총 ${crawler.visited.size}개 페이지 방문`);
}
main();

9. 자주 발생하는 문제

문제 1: Unhandled Promise Rejection

에러:

UnhandledPromiseRejectionWarning: Error: Something went wrong

원인: Promise의 .catch()를 빠뜨림 해결:

// ❌ 에러 처리 없음
async function bad() {
    await someAsyncFunction();  // 에러 발생 시 처리 안 됨
}
// ✅ try-catch 사용
async function good() {
    try {
        await someAsyncFunction();
    } catch (err) {
        console.error('에러:', err.message);
    }
}
// ✅ 전역 핸들러
process.on('unhandledRejection', (reason, promise) => {
    console.error('처리되지 않은 Promise 거부:', reason);
    process.exit(1);
});

문제 2: async 함수를 기다리지 않음

// ❌ await 누락
async function bad() {
    const data = fetchData();  // Promise 객체 반환
    console.log(data);  // Promise { <pending> }
}
// ✅ await 사용
async function good() {
    const data = await fetchData();
    console.log(data);  // 실제 데이터
}

문제 3: 루프에서 await

// ❌ 순차 실행 (느림)
async function sequential(urls) {
    const results = [];
    
    for (const url of urls) {
        const data = await fetch(url);  // 하나씩 대기
        results.push(data);
    }
    
    return results;
}
// ✅ 병렬 실행 (빠름)
async function parallel(urls) {
    const promises = urls.map(url => fetch(url));
    return Promise.all(promises);
}

문제 4: forEach와 async/await

// ❌ forEach는 async를 기다리지 않음
async function bad(files) {
    files.forEach(async (file) => {
        const data = await fs.readFile(file, 'utf8');
        console.log(data);
    });
    console.log('완료');  // 파일 읽기 전에 출력됨!
}
// ✅ for...of 사용
async function good(files) {
    for (const file of files) {
        const data = await fs.readFile(file, 'utf8');
        console.log(data);
    }
    console.log('완료');  // 모든 파일 읽은 후 출력
}
// ✅ Promise.all 사용 (병렬)
async function better(files) {
    await Promise.all(
        files.map(async (file) => {
            const data = await fs.readFile(file, 'utf8');
            console.log(data);
        })
    );
    console.log('완료');
}

10. 실전 팁

에러 처리 패턴

// 패턴 1: try-catch
async function pattern1() {
    try {
        const data = await fetchData();
        return data;
    } catch (err) {
        console.error('에러:', err.message);
        return null;
    }
}
// 패턴 2: .catch()
async function pattern2() {
    const data = await fetchData().catch((err) => {
        console.error('에러:', err.message);
        return null;
    });
    return data;
}
// 패턴 3: 래퍼 함수
async function safeAsync(fn) {
    try {
        return [null, await fn()];
    } catch (err) {
        return [err, null];
    }
}
// 사용
const [err, data] = await safeAsync(() => fetchData());
if (err) {
    console.error('에러:', err.message);
} else {
    console.log('데이터:', data);
}

성능 최적화

// ✅ 병렬 처리
async function optimized() {
    const [users, posts, comments] = await Promise.all([
        fetchUsers(),
        fetchPosts(),
        fetchComments()
    ]);
    
    return { users, posts, comments };
}
// ✅ 조기 반환
async function findUser(users, id) {
    for (const user of users) {
        if (user.id === id) {
            return user;  // 찾으면 즉시 반환
        }
    }
    return null;
}
// ✅ 캐싱
const cache = new Map();
async function fetchWithCache(url) {
    if (cache.has(url)) {
        console.log('캐시 히트');
        return cache.get(url);
    }
    
    const data = await fetch(url).then(r => r.json());
    cache.set(url, data);
    return data;
}

디버깅

// 비동기 스택 트레이스
async function a() {
    await b();
}
async function b() {
    await c();
}
async function c() {
    throw new Error('에러 발생!');
}
a().catch((err) => {
    console.error(err.stack);
    // 전체 호출 스택 확인 가능
});
// 타이밍 측정
async function measure() {
    console.time('작업');
    
    await someAsyncTask();
    
    console.timeEnd('작업');
    // 작업: 1234.567ms
}

정리

핵심 요약

  1. Callback: 에러 우선 콜백, Callback Hell 주의
  2. Promise: .then(), .catch(), .finally() 체이닝
  3. async/await: 동기 코드처럼 작성, try-catch로 에러 처리
  4. 병렬 처리: Promise.all(), Promise.allSettled()
  5. 이벤트 루프: libuv 페이즈(timers·poll·check 등) + process.nextTick → Promise 마이크로태스크 + 매크로태스크
  6. 스트림: 백프레셔로 생산·소비 속도 맞추기, pipeline으로 에러·종료 처리 표준화

비교: Callback vs Promise vs Async/Await

특징CallbackPromiseAsync/Await
가독성❌ (중첩)⭕ (체이닝)✅ (동기 스타일)
에러 처리각 콜백마다.catch()try-catch
디버깅어려움보통쉬움
병렬 처리복잡Promise.all()Promise.all() + await
사용 권장

선택 가이드

Callback 사용:

  • 기존 API와 호환성 필요
  • 간단한 비동기 작업 Promise 사용:
  • 여러 비동기 작업 체이닝
  • Promise.all(), Promise.race() 활용 Async/Await 사용 (권장):
  • 복잡한 비동기 로직
  • 순차적 작업
  • 가독성 중요

다음 단계

추천 학습 자료

공식 문서:


관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Node.js 비동기 프로그래밍: Callback, Promise, Async/Await. Callback (콜백)·Promise로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Start now. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Node.js, JavaScript, 비동기, Promise, Async, Await, Callback, 이벤트 루프, 스트림, libuv 등으로 검색하시면 이 글이 도움이 됩니다.