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

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

이 글의 핵심

Node.js 비동기 프로그래밍에 대한 실전 가이드입니다. Callback, Promise, Async/Await 등을 예제와 함께 상세히 설명합니다.

들어가며

비동기 프로그래밍이란?

비동기(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)

동작 원리

JavaScript 엔진 한 줄로만 코드를 실행하는 동안, 타이머·파일·네트워크 완료 같은 일은 libuv 등이 맡고, 끝나면 “이제 이 콜백 실행해 주세요”가 이벤트 루프 큐에 쌓입니다. 식당으로 치면 홀 직원은 주문과 서빙 순서만 관리하고, 요리는 주방에서 돌아가는 구조에 가깝습니다.

   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  I/O 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  내부용
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  I/O 이벤트 대기
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──│      close callbacks      │  소켓 종료 등
   └───────────────────────────┘

실행 순서

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

실행 순서:

  1. 동기 코드 (Call Stack)
  2. Microtasks (Promise, process.nextTick)
  3. Macrotasks (setTimeout, setInterval, setImmediate)

process.nextTick


console.log('1');

process.nextTick(() => {
    console.log('3. nextTick');
});

Promise.resolve().then(() => {
    console.log('4. Promise');
});

console.log('2');

// 출력:
// 1
// 2
// 3. nextTick (Microtask 중 가장 먼저)
// 4. Promise

주의: process.nextTick을 남용하면 이벤트 루프가 블로킹될 수 있음.


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)

스트림이란?

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

장점:

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

스트림 종류:

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

파일 스트림

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. 이벤트 루프: 동기 → Microtask → Macrotask
  6. 스트림: 대용량 데이터 처리, 파이프라인

비교: 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 사용 (권장):

  • 복잡한 비동기 로직
  • 순차적 작업
  • 가독성 중요

다음 단계

  • Express.js 웹 프레임워크
  • Node.js 파일 시스템
  • Node.js 데이터베이스 연동

추천 학습 자료

공식 문서:

도구:

  • async - 비동기 유틸리티
  • p-limit - 동시 실행 제한

관련 글

  • JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리
  • C++ future와 promise | “비동기” 가이드
  • Swift 비동기 프로그래밍 | async/await, Task
  • C++ 코루틴 | “비동기 프로그래밍” C++20 가이드
  • C++ JavaScript 스크립팅 완벽 가이드 | V8 임베딩·컨텍스트·C++↔JS 바인딩 [실전]