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. 비동기 패턴 선택 가이드

상황에 따른 적절한 비동기 패턴 선택:

  1. 간단한 일회성 비동기 작업:

    • Async/Await 또는 Promise가 가장 적합
  2. 여러 비동기 작업의 조합:

    • Promise.all(), Promise.allSettled() 등과 함께 Async/Await 사용
  3. 지속적인 이벤트 스트림:

    • EventEmitter 또는 스트림 사용
  4. 대용량 데이터 처리:

    • 스트림 활용
  5. CPU 집약적 작업:

    • Worker Threads 사용
  6. 복잡한 비동기 흐름 제어:

    • RxJS 또는 Async.js와 같은 라이브러리 활용
  7. 레거시 콜백 기반 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. 최신 비동기 패턴 개발 동향

  1. 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));
  1. 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));
  1. 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는 다양한 비동기 패턴을 제공하며, 각각 특정 사용 사례에 적합합니다:

  1. 콜백: 단순하지만 중첩될 경우 관리하기 어려움
  2. 프로미스: 체이닝과 통합된 오류 처리로 더 나은 흐름 제어 제공
  3. Async/Await: 가독성이 가장 좋으며 동기 코드와 유사한 스타일 제공
  4. 이벤트 이미터: 발생-구독 패턴에 적합하며 지속적인 비동기 작업에 유용
  5. 스트림: 대용량 데이터를 메모리 효율적으로 처리하는 데 최적화
  6. 워커 스레드: CPU 집약적 작업을 병렬로 처리하여 메인 스레드 차단 방지
  7. 유틸리티 라이브러리: 복잡한 비동기 흐름 제어를 위한 추가 도구 제공

최신 Node.js 애플리케이션에서는 일반적으로 Async/Await를 기본으로 사용하고, 필요에 따라 다른 패턴들을 보완적으로 활용하는 것이 권장됩니다. 업무의 특성과 요구사항에 맞는 적절한 패턴을 선택하는 것이 중요합니다.

results matching ""

    No results matching ""