Node.js 인터뷰 질문 42

질문: Node.js에서 Promise, async/await를 사용한 비동기 처리에 대해 설명하고, 콜백 패턴과 비교하여 장단점을 설명해주세요.

답변:

Node.js는 비동기 이벤트 기반 런타임으로, 비동기 작업을 처리하는 여러 방법을 제공합니다. 초기에는 콜백 함수가 주로 사용되었지만, 현대 JavaScript에서는 Promise와 async/await 패턴이 더 널리 사용되고 있습니다. 이러한 패턴들이 어떻게 작동하고 어떤 장단점이 있는지 살펴보겠습니다.

콜백 패턴

콜백 패턴은 Node.js의 초기부터 사용된 전통적인 비동기 처리 방식입니다.

기본 사용법

const fs = require("fs");

// 비동기적으로 파일 읽기
fs.readFile("file.txt", "utf8", (err, data) => {
  if (err) {
    console.error("파일을 읽는 중 오류 발생:", err);
    return;
  }
  console.log("파일 내용:", data);
});

콜백 지옥 (Callback Hell)

콜백 패턴의 주요 문제점 중 하나는 여러 비동기 작업을 순차적으로 처리할 때 발생하는 '콜백 지옥'입니다:

const fs = require("fs");

fs.readFile("file1.txt", "utf8", (err, data1) => {
  if (err) {
    console.error("파일1 읽기 오류:", err);
    return;
  }

  fs.readFile("file2.txt", "utf8", (err, data2) => {
    if (err) {
      console.error("파일2 읽기 오류:", err);
      return;
    }

    fs.writeFile("output.txt", data1 + data2, (err) => {
      if (err) {
        console.error("파일 쓰기 오류:", err);
        return;
      }

      console.log("파일 합치기 완료");
    });
  });
});

이 코드는 중첩된 콜백으로 인해 가독성이 떨어지고 오류 처리가 복잡해집니다.

Promise

Promise는 비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체입니다. ES6(ES2015)에서 표준으로 도입되었습니다.

기본 구조

Promise는 다음과 같은 상태를 가집니다:

  1. 대기(pending): 초기 상태, 이행되거나 거부되지 않은 상태
  2. 이행(fulfilled): 연산이 성공적으로 완료됨
  3. 거부(rejected): 연산이 실패함

기본 사용법

const fs = require("fs").promises; // Node.js 10 이상에서 제공되는 Promise 기반 API

// Promise를 반환하는 함수
fs.readFile("file.txt", "utf8")
  .then((data) => {
    console.log("파일 내용:", data);
  })
  .catch((err) => {
    console.error("파일을 읽는 중 오류 발생:", err);
  });

직접 Promise 생성

function readFilePromise(filePath) {
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, "utf8", (err, data) => {
      if (err) {
        reject(err); // 실패 시 reject 호출
      } else {
        resolve(data); // 성공 시 resolve 호출
      }
    });
  });
}

readFilePromise("file.txt")
  .then((data) => console.log("파일 내용:", data))
  .catch((err) => console.error("오류:", err));

Promise 체이닝

Promise의 가장 큰 장점 중 하나는 체이닝을 통해 여러 비동기 작업을 순차적으로 처리할 수 있다는 것입니다:

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

fs.readFile("file1.txt", "utf8")
  .then((data1) => {
    console.log("파일1 내용:", data1);
    return fs.readFile("file2.txt", "utf8"); // 새로운 Promise 반환
  })
  .then((data2) => {
    console.log("파일2 내용:", data2);
    return fs.writeFile("output.txt", data1 + data2); // 이 부분에서 오류! data1에 접근할 수 없음
  })
  .then(() => {
    console.log("파일 합치기 완료");
  })
  .catch((err) => {
    console.error("오류 발생:", err);
  });

위 코드에는 문제가 있습니다. data1은 첫 번째 .then() 콜백 내에서만 사용 가능하고 두 번째 .then()에서는 접근할 수 없습니다. 이 문제를 해결하려면 다음과 같이 코드를 수정해야 합니다:

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

let data1;
fs.readFile("file1.txt", "utf8")
  .then((content) => {
    data1 = content; // 변수에 저장하여 이후에도 사용 가능하게 함
    return fs.readFile("file2.txt", "utf8");
  })
  .then((data2) => {
    return fs.writeFile("output.txt", data1 + data2);
  })
  .then(() => {
    console.log("파일 합치기 완료");
  })
  .catch((err) => {
    console.error("오류 발생:", err);
  });

Promise.all과 Promise.race

Promise.all()은 여러 Promise를 병렬로 실행하고 모든 Promise가 이행될 때까지 기다립니다:

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("모든 파일 읽기 완료");
    return fs.writeFile("output.txt", data1 + data2 + data3);
  })
  .then(() => {
    console.log("파일 합치기 완료");
  })
  .catch((err) => {
    console.error("오류 발생:", err); // 하나라도 실패하면 catch로 이동
  });

Promise.race()는 여러 Promise 중 가장 먼저 완료되는 것의 결과(성공 또는 실패)를 반환합니다:

const Promise = require("bluebird"); // 타임아웃 구현을 위한 예시

function fetchWithTimeout(url, timeout) {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error("요청 시간 초과")), timeout);
  });

  return Promise.race([fetchPromise, timeoutPromise]);
}

fetchWithTimeout("https://api.example.com/data", 5000)
  .then((response) => response.json())
  .then((data) => console.log("데이터:", data))
  .catch((err) => console.error("오류:", err));

async/await

async/await는 ES2017에서 도입된 Promise를 더 쉽게 사용할 수 있게 해주는 문법입니다. async/await를 사용하면 비동기 코드를 동기 코드처럼 작성할 수 있어 가독성이 크게 향상됩니다.

기본 사용법

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

async function readFile() {
  try {
    const data = await fs.readFile("file.txt", "utf8");
    console.log("파일 내용:", data);
  } catch (err) {
    console.error("파일을 읽는 중 오류 발생:", err);
  }
}

readFile();

async 키워드는 함수가 Promise를 반환하도록 만들고, await 키워드는 Promise가 이행될 때까지 함수 실행을 일시 중지합니다.

순차적 비동기 작업 처리

앞서 Promise로 다루었던 여러 파일 읽기 예제를 async/await로 작성하면 다음과 같습니다:

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

async function combineFiles() {
  try {
    const data1 = await fs.readFile("file1.txt", "utf8");
    console.log("파일1 내용:", data1);

    const data2 = await fs.readFile("file2.txt", "utf8");
    console.log("파일2 내용:", data2);

    await fs.writeFile("output.txt", data1 + data2);
    console.log("파일 합치기 완료");
  } catch (err) {
    console.error("오류 발생:", err);
  }
}

combineFiles();

이 코드는 이전의 Promise 체이닝 예제보다 훨씬 가독성이 좋습니다.

병렬 비동기 작업 처리

Promise.all()과 함께 async/await를 사용하면 병렬 비동기 작업을 깔끔하게 처리할 수 있습니다:

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

async function combineFilesInParallel() {
  try {
    // 병렬로 여러 파일 읽기
    const [data1, data2, data3] = await Promise.all([
      fs.readFile("file1.txt", "utf8"),
      fs.readFile("file2.txt", "utf8"),
      fs.readFile("file3.txt", "utf8"),
    ]);

    console.log("모든 파일 읽기 완료");
    await fs.writeFile("output.txt", data1 + data2 + data3);
    console.log("파일 합치기 완료");
  } catch (err) {
    console.error("오류 발생:", err);
  }
}

combineFilesInParallel();

비동기 패턴 비교: 장단점

콜백 패턴

장점:

  • 간단한 비동기 작업에 적합
  • 모든 Node.js 버전에서 지원
  • 오래된 라이브러리와의 호환성

단점:

  • 중첩된 콜백으로 인한 '콜백 지옥'
  • 오류 처리가 복잡함
  • 동기적인 코드 흐름을 표현하기 어려움
  • 여러 비동기 작업을 조율하기 어려움

Promise

장점:

  • 체이닝을 통한 순차적 비동기 작업 처리
  • 일관된 오류 처리 방식 (.catch())
  • 여러 비동기 작업 관리를 위한 유틸리티 (Promise.all, Promise.race 등)
  • 콜백 지옥 문제 완화

단점:

  • 오래된 Node.js 버전에서는 별도 라이브러리 필요
  • 복잡한 로직에서는 체이닝이 길어질 수 있음
  • 분기 처리나 루프에서 사용이 복잡할 수 있음

async/await

장점:

  • 동기 코드와 유사한 가독성 높은 문법
  • try/catch를 통한 직관적인 오류 처리
  • Promise 체이닝보다 더 복잡한 로직에서도 가독성 유지
  • 콜백 지옥 문제 해결

단점:

  • Node.js 7.6 이상에서만 기본 지원됨
  • 오래된 라이브러리와 함께 사용 시 추가 래핑 필요
  • 모든 async 함수는 Promise를 반환하므로 이해가 필요함

실제 사례: 데이터베이스 작업

콜백 패턴

const { MongoClient } = require("mongodb");

MongoClient.connect("mongodb://localhost:27017", (err, client) => {
  if (err) {
    console.error("MongoDB 연결 오류:", err);
    return;
  }

  const db = client.db("testdb");
  db.collection("users").findOne({ username: "john" }, (err, user) => {
    if (err) {
      console.error("사용자 조회 오류:", err);
      client.close();
      return;
    }

    if (!user) {
      console.log("사용자를 찾을 수 없습니다");
      client.close();
      return;
    }

    db.collection("posts")
      .find({ author: user._id })
      .toArray((err, posts) => {
        if (err) {
          console.error("게시물 조회 오류:", err);
          client.close();
          return;
        }

        console.log("사용자:", user);
        console.log("게시물:", posts);
        client.close();
      });
  });
});

Promise 패턴

const { MongoClient } = require("mongodb");

MongoClient.connect("mongodb://localhost:27017")
  .then((client) => {
    const db = client.db("testdb");
    let userData;

    return db
      .collection("users")
      .findOne({ username: "john" })
      .then((user) => {
        if (!user) {
          throw new Error("사용자를 찾을 수 없습니다");
        }

        userData = user;
        return db.collection("posts").find({ author: user._id }).toArray();
      })
      .then((posts) => {
        console.log("사용자:", userData);
        console.log("게시물:", posts);
        client.close();
      });
  })
  .catch((err) => {
    console.error("오류 발생:", err);
    if (client) client.close();
  });

async/await 패턴

const { MongoClient } = require("mongodb");

async function findUserAndPosts() {
  let client;

  try {
    client = await MongoClient.connect("mongodb://localhost:27017");
    const db = client.db("testdb");

    const user = await db.collection("users").findOne({ username: "john" });
    if (!user) {
      throw new Error("사용자를 찾을 수 없습니다");
    }

    const posts = await db
      .collection("posts")
      .find({ author: user._id })
      .toArray();

    console.log("사용자:", user);
    console.log("게시물:", posts);
  } catch (err) {
    console.error("오류 발생:", err);
  } finally {
    if (client) await client.close();
  }
}

findUserAndPosts();

비동기 패턴의 진화와 모범 사례

비동기 패턴은 콜백에서 Promise로, 그리고 async/await로 진화해 왔습니다. 현대 Node.js 개발에서는 다음과 같은 모범 사례를 따르는 것이 좋습니다:

  1. 새 코드에는 async/await 사용: 가독성과 오류 처리의 용이성 때문에 새 코드에는 async/await를 사용합니다.

  2. Promise 지원 라이브러리 선택: 콜백 기반 API 대신 Promise를 지원하는 라이브러리를 선택합니다.

  3. util.promisify 활용: Node.js의 util.promisify()를 사용하여 콜백 기반 함수를 Promise 기반으로 변환합니다:

    const util = require("util");
    const fs = require("fs");
    
    // 콜백 기반 함수를 Promise 기반으로 변환
    const readFile = util.promisify(fs.readFile);
    
    async function readAndProcessFile() {
      try {
        const data = await readFile("file.txt", "utf8");
        console.log(data);
      } catch (err) {
        console.error("오류:", err);
      }
    }
    
  4. 병렬 처리 최적화: 서로 독립적인 비동기 작업은 Promise.all()을 사용하여 병렬로 처리합니다.

  5. 적절한 오류 처리: async/await 사용 시에도 try/catch 블록을 사용하여 오류를 적절히 처리합니다.

  6. 무한 중첩 방지: async/await 함수 내에서도 과도한 중첩을 피하고, 필요한 경우 함수로 분리합니다.

요약

  • 콜백 패턴은 Node.js의 전통적인 비동기 처리 방법이지만, 복잡한 비동기 로직에서는 '콜백 지옥'이 발생할 수 있습니다.
  • Promise는 비동기 작업의 미래 결과를 나타내는 객체로, 체이닝을 통해 순차적 비동기 처리가 가능하며 오류 처리도 일관되게 할 수 있습니다.
  • async/await는 Promise 기반 비동기 코드를 동기적으로 작성된 것처럼 보이게 하는 문법으로, 가독성과 오류 처리가 크게 향상됩니다.

현대 Node.js 개발에서는 대부분의 경우 async/await를 사용하는 것이 권장되지만, 기존 콜백 기반 API나 라이브러리와의 호환을 위해 모든 패턴을 이해하는 것이 중요합니다. 또한, Promise의 고급 기능(Promise.all, Promise.race 등)은 async/await와 함께 사용해도 매우 유용합니다.

results matching ""

    No results matching ""