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는 다음과 같은 상태를 가집니다:
- 대기(pending): 초기 상태, 이행되거나 거부되지 않은 상태
- 이행(fulfilled): 연산이 성공적으로 완료됨
- 거부(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 개발에서는 다음과 같은 모범 사례를 따르는 것이 좋습니다:
새 코드에는 async/await 사용: 가독성과 오류 처리의 용이성 때문에 새 코드에는 async/await를 사용합니다.
Promise 지원 라이브러리 선택: 콜백 기반 API 대신 Promise를 지원하는 라이브러리를 선택합니다.
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); } }
병렬 처리 최적화: 서로 독립적인 비동기 작업은
Promise.all()
을 사용하여 병렬로 처리합니다.적절한 오류 처리: async/await 사용 시에도 try/catch 블록을 사용하여 오류를 적절히 처리합니다.
무한 중첩 방지: async/await 함수 내에서도 과도한 중첩을 피하고, 필요한 경우 함수로 분리합니다.
요약
- 콜백 패턴은 Node.js의 전통적인 비동기 처리 방법이지만, 복잡한 비동기 로직에서는 '콜백 지옥'이 발생할 수 있습니다.
- Promise는 비동기 작업의 미래 결과를 나타내는 객체로, 체이닝을 통해 순차적 비동기 처리가 가능하며 오류 처리도 일관되게 할 수 있습니다.
- async/await는 Promise 기반 비동기 코드를 동기적으로 작성된 것처럼 보이게 하는 문법으로, 가독성과 오류 처리가 크게 향상됩니다.
현대 Node.js 개발에서는 대부분의 경우 async/await를 사용하는 것이 권장되지만, 기존 콜백 기반 API나 라이브러리와의 호환을 위해 모든 패턴을 이해하는 것이 중요합니다. 또한, Promise의 고급 기능(Promise.all, Promise.race 등)은 async/await와 함께 사용해도 매우 유용합니다.