Node.js 인터뷰 질문 59
질문: Node.js에서 사용되는 다양한 비동기 패턴에 대해 설명하고, 각 패턴의 장단점을 비교해주세요.
답변:
Node.js는 비동기 I/O를 기반으로 하는 플랫폼으로, 다양한 비동기 패턴을 제공합니다. 각 패턴은 서로 다른 상황에서 유용하며 고유한 장단점을 가지고 있습니다.
1. 콜백(Callbacks)
콜백은 Node.js에서 가장 기본적인 비동기 패턴입니다.
예제 코드:
const fs = require("fs");
fs.readFile("file.txt", "utf8", (err, data) => {
if (err) {
console.error("파일 읽기 오류:", err);
return;
}
console.log("파일 내용:", data);
});
console.log("파일을 비동기적으로 읽는 중...");
장점:
- 간단하고 이해하기 쉬움
- Node.js 초기부터 사용된 패턴으로 많은 내장 API에서 지원
- 메모리 사용량이 적음
단점:
- 콜백 헬(Callback Hell) 또는 파라미드 오브 둠(Pyramid of Doom)이라고 불리는 중첩 콜백 문제
- 오류 처리가 각 콜백마다 반복적으로 이루어져야 함
- 순차적 실행, 병렬 실행, 경쟁 조건 등의 복잡한 흐름 제어가 어려움
2. 프로미스(Promises)
프로미스는 비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체입니다.
예제 코드:
const fs = require("fs").promises;
fs.readFile("file.txt", "utf8")
.then((data) => {
console.log("파일 내용:", data);
return fs.readFile("another-file.txt", "utf8");
})
.then((anotherData) => {
console.log("다른 파일 내용:", anotherData);
})
.catch((err) => {
console.error("파일 읽기 오류:", err);
});
console.log("파일을 비동기적으로 읽는 중...");
장점:
- 체이닝을 통한 가독성 있는 비동기 흐름 제어
- 통합된 오류 처리 메커니즘(.catch())
- Promise.all(), Promise.race() 등을 통한 병렬 처리 지원
- 표준화된 인터페이스로 라이브러리 간 호환성 향상
단점:
- 콜백보다 약간 더 복잡한 개념
- 오류가 표시되지 않는 누락된 .catch() 블록의 위험성
- 디버깅 시 스택 트레이스가 모호할 수 있음
- 취소 메커니즘이 내장되어 있지 않음(ES2021 이전)
3. Async/Await
Async/Await는 프로미스를 기반으로 하는 보다 동기적인 스타일의 코드 작성 방식입니다.
예제 코드:
const fs = require("fs").promises;
async function readFiles() {
try {
const data = await fs.readFile("file.txt", "utf8");
console.log("파일 내용:", data);
const anotherData = await fs.readFile("another-file.txt", "utf8");
console.log("다른 파일 내용:", anotherData);
} catch (err) {
console.error("파일 읽기 오류:", err);
}
}
readFiles();
console.log("파일을 비동기적으로 읽는 중...");
장점:
- 동기 코드와 유사한 가독성으로 코드 이해가 쉬움
- try/catch 구문을 통한 친숙한 오류 처리
- 디버깅이 더 쉽고 스택 트레이스가 명확함
- 기존 프로미스 기반 API와 완벽하게 호환됨
단점:
- 병렬 처리를 위해서는 별도의 Promise.all() 등을 사용해야 함
- 최상위 레벨 await는 최근에야 지원됨(Node.js 14.8.0 이상)
- 비동기 함수의 반환값은 항상 Promise로 래핑됨
4. 이벤트 이미터(Event Emitters)
Node.js의 EventEmitter 클래스는 이벤트 기반 비동기 프로그래밍을 위한 핵심 메커니즘입니다.
예제 코드:
const EventEmitter = require("events");
const fs = require("fs");
class FileReader extends EventEmitter {
readFile(path) {
this.emit("start", path);
fs.readFile(path, "utf8", (err, data) => {
if (err) {
this.emit("error", err);
return;
}
this.emit("data", data);
this.emit("end", path);
});
}
}
const reader = new FileReader();
reader.on("start", (path) => {
console.log(`${path} 읽기 시작`);
});
reader.on("data", (data) => {
console.log("파일 내용:", data.substring(0, 20) + "...");
});
reader.on("end", (path) => {
console.log(`${path} 읽기 완료`);
});
reader.on("error", (err) => {
console.error("읽기 오류:", err);
});
reader.readFile("file.txt");
장점:
- 동일한 이벤트에 대해 여러 리스너 등록 가능
- 느슨한 결합을 통한 모듈화된 코드 구조
- 지속적인 비동기 작업(스트림 등)에 적합
- 발행/구독(pub/sub) 패턴 구현에 이상적
단점:
- 이벤트 기반 흐름은 추적 및 디버깅이 어려울 수 있음
- 메모리 누수 위험(이벤트 리스너가 제대로 제거되지 않을 경우)
- 이벤트 순서에 대한 보장이 없음
- 오류 처리 메커니즘이 명시적이지 않음
5. 스트림(Streams)
스트림은 데이터를 청크 단위로 처리하기 위한 추상 인터페이스로, EventEmitter를 기반으로 합니다.
예제 코드:
const fs = require("fs");
const zlib = require("zlib");
// 파일을 읽고, 압축하고, 새 파일에 쓰기
fs.createReadStream("big-file.txt")
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream("big-file.txt.gz"))
.on("finish", () => {
console.log("파일 압축 완료");
})
.on("error", (err) => {
console.error("스트림 오류:", err);
});
장점:
- 메모리 효율성: 전체 데이터를 메모리에 로드하지 않고 청크 단위로 처리
- 백프레셔(backpressure) 처리를 통한 데이터 흐름 제어
- 파이프라인을 통한 데이터 변환 체인 구성 용이
- 대용량 데이터 처리에 이상적
단점:
- 다른 패턴에 비해 API가 복잡하고 학습 곡선이 가파름
- 오류 처리가 각 스트림마다 필요할 수 있음
- 디버깅이 어려움
- 일부 스트림 작업은 추가 상태 관리가 필요
6. Worker Threads
워커 스레드는 CPU 집약적인 작업을 별도의 스레드에서 실행할 수 있게 해주는 모듈입니다.
예제 코드:
const {
Worker,
isMainThread,
parentPort,
workerData,
} = require("worker_threads");
if (isMainThread) {
// 메인 스레드 코드
const worker = new Worker(__filename, {
workerData: { input: 100 },
});
worker.on("message", (result) => {
console.log("계산 결과:", result);
});
worker.on("error", (err) => {
console.error("워커 오류:", err);
});
worker.on("exit", (code) => {
if (code !== 0) {
console.error(`워커가 종료 코드 ${code}로 종료됨`);
}
});
console.log("워커에게 작업 위임 중...");
} else {
// 워커 스레드 코드
const { input } = workerData;
// CPU 집약적인 작업 시뮬레이션
let result = 0;
for (let i = 0; i < input * 1000000; i++) {
result += i;
}
// 결과를 메인 스레드로 전송
parentPort.postMessage(result);
}
장점:
- 멀티 코어 활용을 통한 CPU 집약적 작업의 병렬 처리
- 메인 이벤트 루프 차단 방지
- SharedArrayBuffer를 통한 효율적인 데이터 공유 가능
- 복잡한 계산이나 암호화 작업에 적합
단점:
- 스레드 생성 및 통신 오버헤드
- 메모리 사용량 증가
- 스레드 간 데이터 직렬화/역직렬화 비용
- 공유 상태로 인한 복잡성
7. 유틸리티 라이브러리(Async.js, RxJS 등)
많은 Node.js 애플리케이션은 비동기 작업을 쉽게 관리하기 위해 외부 라이브러리를 활용합니다.
Async.js 예제:
const async = require("async");
const fs = require("fs");
// 여러 파일을 병렬로 읽기
async.parallel(
{
file1: (callback) => {
fs.readFile("file1.txt", "utf8", callback);
},
file2: (callback) => {
fs.readFile("file2.txt", "utf8", callback);
},
file3: (callback) => {
fs.readFile("file3.txt", "utf8", callback);
},
},
(err, results) => {
if (err) {
console.error("오류 발생:", err);
return;
}
console.log("file1:", results.file1.substring(0, 20) + "...");
console.log("file2:", results.file2.substring(0, 20) + "...");
console.log("file3:", results.file3.substring(0, 20) + "...");
}
);
RxJS 예제:
const { from } = require("rxjs");
const { map, mergeMap, catchError } = require("rxjs/operators");
const fs = require("fs").promises;
// 파일 목록을 관찰 가능한 스트림으로 변환
from(["file1.txt", "file2.txt", "file3.txt"])
.pipe(
mergeMap((file) =>
from(fs.readFile(file, "utf8")).pipe(
map((content) => ({ file, content })),
catchError((error) => from([{ file, error }]))
)
)
)
.subscribe({
next: (result) => {
if (result.error) {
console.error(`${result.file} 읽기 오류:`, result.error);
} else {
console.log(`${result.file}:`, result.content.substring(0, 20) + "...");
}
},
complete: () => console.log("모든 파일 처리 완료"),
error: (err) => console.error("구독 오류:", err),
});
장점:
- 고급 비동기 패턴 및 흐름 제어(병렬, 직렬, 워터폴 등)
- 견고한 오류 처리 메커니즘
- 취소, 재시도, 타임아웃 등의 기능 지원
- 반응형 프로그래밍, 함수형 프로그래밍 스타일 지원
단점:
- 추가 의존성 도입
- 학습 곡선이 가파를 수 있음
- 프로젝트 크기 증가
- 때로는 과도하게 복잡할 수 있음
8. 비동기 패턴 선택 가이드
상황에 따른 적절한 비동기 패턴 선택:
간단한 일회성 비동기 작업:
- Async/Await 또는 Promise가 가장 적합
여러 비동기 작업의 조합:
- Promise.all(), Promise.allSettled() 등과 함께 Async/Await 사용
지속적인 이벤트 스트림:
- EventEmitter 또는 스트림 사용
대용량 데이터 처리:
- 스트림 활용
CPU 집약적 작업:
- Worker Threads 사용
복잡한 비동기 흐름 제어:
- RxJS 또는 Async.js와 같은 라이브러리 활용
레거시 콜백 기반 API 작업:
- util.promisify()를 사용하여 프로미스로 변환 후 Async/Await 사용
9. 패턴 조합 예시
여러 비동기 패턴을 함께 사용하는 예:
const fs = require("fs").promises;
const { Worker } = require("worker_threads");
const stream = require("stream");
const util = require("util");
const pipeline = util.promisify(stream.pipeline);
async function processLargeFile(filePath) {
try {
// 1. async/await를 사용하여 파일 정보 가져오기
const stats = await fs.stat(filePath);
console.log(`파일 크기: ${stats.size} 바이트`);
// 2. CPU 집약적인 작업은 Worker Thread로 위임
const processingResult = await new Promise((resolve, reject) => {
const worker = new Worker("./worker.js", {
workerData: { filePath },
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) {
reject(new Error(`워커가 종료 코드 ${code}로 종료됨`));
}
});
});
// 3. 스트림을 사용하여 결과 파일 생성
await pipeline(
fs.createReadStream(processingResult.tempPath),
fs.createWriteStream(`${filePath}.processed`)
);
console.log("파일 처리 완료");
} catch (err) {
console.error("처리 중 오류 발생:", err);
}
}
processLargeFile("large-dataset.csv");
10. 최신 비동기 패턴 개발 동향
- Top-level await: Node.js 14.8.0부터 지원되는 기능으로, 모듈 최상위 레벨에서 await 사용 가능
// ES 모듈에서 가능 (확장자 .mjs 또는 package.json에 "type": "module")
import { readFile } from "fs/promises";
// 함수 없이 바로 await 사용
const data = await readFile("config.json", "utf8");
console.log(JSON.parse(data));
- AbortController: 비동기 작업 취소를 위한 표준 API (Node.js 15.0.0부터 지원)
const { readFile } = require("fs/promises");
async function readWithTimeout(path, timeout) {
const controller = new AbortController();
const { signal } = controller;
// 타임아웃 설정
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
// signal을 전달하여 취소 가능한 작업으로 만듦
const data = await readFile(path, { signal });
clearTimeout(timeoutId);
return data;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === "AbortError") {
throw new Error("읽기 작업이 시간 초과로 취소되었습니다");
}
throw err;
}
}
readWithTimeout("large-file.txt", 1000)
.then((data) => console.log(`${data.length} 바이트 읽음`))
.catch((err) => console.error(err.message));
- Promise.any(): 가장 먼저 이행되는 프로미스의 결과를 반환 (Node.js 15.0.0부터 지원)
async function fetchWithFallbacks(urls) {
try {
// 여러 URL 중 가장 먼저 응답하는 것을 사용
const response = await Promise.any(urls.map((url) => fetch(url)));
return await response.json();
} catch (err) {
// AggregateError: 모든 프로미스가 거부된 경우
console.error("모든 요청 실패:", err);
return null;
}
}
요약
Node.js는 다양한 비동기 패턴을 제공하며, 각각 특정 사용 사례에 적합합니다:
- 콜백: 단순하지만 중첩될 경우 관리하기 어려움
- 프로미스: 체이닝과 통합된 오류 처리로 더 나은 흐름 제어 제공
- Async/Await: 가독성이 가장 좋으며 동기 코드와 유사한 스타일 제공
- 이벤트 이미터: 발생-구독 패턴에 적합하며 지속적인 비동기 작업에 유용
- 스트림: 대용량 데이터를 메모리 효율적으로 처리하는 데 최적화
- 워커 스레드: CPU 집약적 작업을 병렬로 처리하여 메인 스레드 차단 방지
- 유틸리티 라이브러리: 복잡한 비동기 흐름 제어를 위한 추가 도구 제공
최신 Node.js 애플리케이션에서는 일반적으로 Async/Await를 기본으로 사용하고, 필요에 따라 다른 패턴들을 보완적으로 활용하는 것이 권장됩니다. 업무의 특성과 요구사항에 맞는 적절한 패턴을 선택하는 것이 중요합니다.