백엔드 개발자로 일하며 Node.js 프로젝트를 5년 넘게 다루면서 가장 많이 고민했던 부분이 바로 비동기 처리 패턴입니다. 특히 대규모 프로젝트에서 비동기 코드가 복잡하게 얽히면 디버깅이 어려워지고 코드 가독성이 떨어지는 문제를 자주 경험했습니다. 이 글에서는 제가 실무에서 습득한 Node.js 비동기 처리 패턴과 각 패턴별 최적의 사용 시나리오를 공유하고자 합니다.
1. Node.js 비동기 처리의 기본 원리
Node.js는 싱글 스레드 이벤트 루프 모델을 기반으로 동작합니다. 이벤트 루프는 Node.js가 비차단(Non-blocking) I/O 작업을 수행할 수 있게 해주는 핵심 메커니즘입니다. 파일 시스템 접근, 네트워크 요청 등 시간이 소요되는 작업을 수행할 때 Node.js는 해당 작업을 백그라운드로 위임하고, 작업이 완료되면 콜백 함수를 호출하는 방식으로 처리합니다.
이러한 비동기 처리 방식은 서버의 처리량(throughput)을 극대화할 수 있지만, 코드의 흐름을 관리하는 것이 어려워질 수 있습니다. 특히 여러 비동기 작업이 순차적으로 또는 병렬로 실행되어야 할 때 적절한 패턴을 선택하는 것이 중요합니다.
2. 콜백 패턴 (Callback Pattern)
콜백은 Node.js에서 가장 기본적인 비동기 처리 방식입니다. 함수를 인자로 전달하고, 비동기 작업이 완료되면 해당 함수가 호출됩니다.
// 콜백 패턴 예제
const fs = require('fs');
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
if (err) {
console.error('파일 읽기 오류:', err);
return;
}
console.log('파일 내용:', data);
// 파일 내용 처리 후 다른 작업 수행
processData(data, (err, result) => {
if (err) {
console.error('데이터 처리 오류:', err);
return;
}
console.log('처리 결과:', result);
});
});
콜백 패턴의 장점
- Node.js의 모든 기본 API가 콜백 패턴을 지원함
- 구현이 단순하고 직관적임
- 오래된 라이브러리와의 호환성이 좋음
콜백 패턴의 단점
- 콜백 지옥(Callback Hell)이 발생할 수 있음
- 에러 처리가 복잡해질 수 있음
- 코드의 가독성이 떨어짐
실무 활용 팁
실무에서 콜백 패턴을 사용할 때는 다음과 같은 방법으로 콜백 지옥을 완화할 수 있습니다:
- 함수 분리: 콜백 함수를 별도의 명명된 함수로 분리
- 에러 처리 일관성 유지: 항상 첫 번째 매개변수로 에러를 전달하는 패턴 유지
- 중첩 수준 제한: 3단계 이상 중첩되면 다른 패턴 고려
지난해 레거시 프로젝트를 유지보수할 때, 15개 이상의 중첩된 콜백을 발견한 적이 있습니다. 이는 코드를 이해하고 수정하는데 상당한 어려움을 주었습니다. 이 경험을 통해 복잡한 비동기 흐름에서는 콜백보다 다른 패턴이 더 적합하다는 것을 깨달았습니다.
3. Promise 패턴
Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. ES6에서 공식적으로 도입되었으며, 콜백 지옥 문제를 해결하기 위한 대안으로 널리 사용됩니다.
// Promise 패턴 예제
const fs = require('fs').promises; // Node.js 10 이상에서 사용 가능
fs.readFile('/path/to/file.txt', 'utf8')
.then(data => {
console.log('파일 내용:', data);
return processData(data); // 이 함수도 Promise를 반환한다고 가정
})
.then(result => {
console.log('처리 결과:', result);
})
.catch(err => {
console.error('오류 발생:', err);
});
Promise 패턴의 장점
- 체인 형태로 연속된 비동기 작업을 표현할 수 있음
- 중앙집중식 에러 처리 가능 (catch 메소드)
- Promise.all, Promise.race 등을 통한 병렬 처리 지원
Promise 패턴의 단점
- ES6 이전 환경에서는 추가 라이브러리 필요
- 콜백에 비해 약간의 오버헤드 발생
- 디버깅이 상대적으로 복잡할 수 있음
실무 활용 팁
금융 서비스 API를 개발하면서 다양한 외부 시스템과 연동해야 했던 프로젝트에서 Promise의 병렬 처리 기능이 큰 도움이 되었습니다.
// Promise.all을 활용한 병렬 처리 예제
async function getUserProfile(userId) {
try {
const [userInfo, userPosts, userSubscriptions] = await Promise.all([
fetchUserInfo(userId),
fetchUserPosts(userId),
fetchUserSubscriptions(userId)
]);
return {
info: userInfo,
posts: userPosts,
subscriptions: userSubscriptions
};
} catch (error) {
console.error('사용자 프로필 조회 실패:', error);
throw error;
}
}
실무에서 Promise를 효과적으로 사용하기 위한 팁:
- Promise 체인에서 항상 값을 반환하여 체인 연결 유지하기
- Promise.all을 사용하여 독립적인 비동기 작업 병렬 처리하기
- Promise.allSettled를 사용하여 일부 작업 실패해도 전체 결과 받기 (Node.js 12.9.0 이상)
- finally() 메소드를 활용하여 성공/실패와 무관하게 정리 작업 수행하기
4. Async/Await 패턴
Async/await는 ES2017에서 도입된 패턴으로, Promise 기반 비동기 코드를 동기 코드처럼 작성할 수 있게 해줍니다. Node.js 7.6 이상에서 지원됩니다.
// Async/Await 패턴 예제
const fs = require('fs').promises;
async function readAndProcessFile() {
try {
const data = await fs.readFile('/path/to/file.txt', 'utf8');
console.log('파일 내용:', data);
const result = await processData(data);
console.log('처리 결과:', result);
return result;
} catch (err) {
console.error('오류 발생:', err);
throw err;
}
}
// 함수 호출
readAndProcessFile()
.then(finalResult => {
console.log('최종 결과:', finalResult);
})
.catch(error => {
console.error('처리 실패:', error);
});
Async/Await 패턴의 장점
- 동기 코드와 유사한 형태로 작성 가능하여 가독성이 뛰어남
- try-catch 구문을 통한 직관적인 에러 처리
- 디버깅이 용이함
Async/Await 패턴의 단점
- Node.js 7.6 미만 버전에서는 사용 불가
- 병렬 처리를 위해서는 Promise.all과 함께 사용해야 함
- 모든 비동기 함수가 Promise를 반환해야 효과적
실무 활용 팁
현재 진행 중인 마이크로서비스 아키텍처 프로젝트에서는 대부분의 비동기 코드를 async/await로 작성하고 있습니다. 특히 다음과 같은 패턴이 유용했습니다:
// 병렬 처리와 순차 처리를 조합한 예제
async function processUserOrders(userId) {
// 사용자 정보 조회 (순차적 처리 필요)
const user = await userService.findById(userId);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다');
}
// 주문 및 결제 정보 병렬 조회
const [orders, paymentMethods] = await Promise.all([
orderService.findByUserId(userId),
paymentService.getMethodsByUserId(userId)
]);
// 각 주문에 대한 상세 정보 병렬 조회
const orderDetails = await Promise.all(
orders.map(order => orderDetailService.findByOrderId(order.id))
);
return {
user,
orders,
orderDetails,
paymentMethods
};
}
실무에서 async/await를 효과적으로 사용하기 위한 팁:
- 항상 try-catch로 에러 처리하기
- 독립적인 비동기 작업은 Promise.all과 함께 병렬로 처리하기
- 공통 기능은 유틸리티 함수로 분리하여 재사용성 높이기
- 비동기 함수는 항상 async 키워드로 명시적 표시하기
5. 실전: 비동기 패턴 선택 가이드
실무에서 어떤 비동기 패턴을 선택해야 할지 고민될 때, 다음 기준으로 판단하면 도움이 됩니다:
콜백 패턴 선택 시나리오:
- Node.js의 기본 API와 직접 연동하는 간단한 작업
- 오래된 라이브러리를 사용해야 하는 경우
- 이벤트 핸들러나 간단한 비동기 작업
Promise 패턴 선택 시나리오:
- 여러 비동기 작업을 조합해야 하는 경우
- 병렬 처리가 필요한 경우 (Promise.all 활용)
- 레거시 콜백 기반 코드를 개선하는 경우
Async/Await 패턴 선택 시나리오:
- 복잡한 비동기 로직을 구현해야 하는 경우
- 조건부 비동기 처리가 필요한 경우
- 순차적인 비동기 작업이 많은 경우
- 가독성이 중요한 비즈니스 로직
개인적으로 현재 대부분의 프로젝트에서는 async/await를 기본으로 사용하고, 병렬 처리가 필요할 때 Promise.all과 함께 사용하는 방식을 선호합니다. 하지만 레거시 시스템과의 통합이나 특정 라이브러리 사용 시에는 다른 패턴을 적절히 혼합하여 사용합니다.
6. 고급: 비동기 패턴의 에러 처리 전략
비동기 코드에서 에러 처리는 매우 중요합니다. 각 패턴별 에러 처리 방법과 실무에서 효과적인 에러 처리 전략을 살펴보겠습니다.
콜백 패턴의 에러 처리
function processWithCallback(data, callback) {
// 에러 우선 콜백 패턴 (Error-first callback)
someAsyncOperation(data, (err, result) => {
if (err) {
return callback(err);
}
// 에러가 없는 경우 결과와 함께 콜백 호출
callback(null, result);
});
}
Promise 패턴의 에러 처리
function processWithPromise(data) {
return someAsyncOperation(data)
.then(result => {
return transformData(result);
})
.catch(err => {
// 에러 로깅
console.error('처리 중 오류 발생:', err);
// 특정 에러는 변환하여 던지기
if (err.code === 'NETWORK_ERROR') {
throw new CustomError('네트워크 연결을 확인해주세요', err);
}
// 그 외 에러는 그대로 전파
throw err;
});
}
Async/Await 패턴의 에러 처리
async function processWithAsyncAwait(data) {
try {
const result = await someAsyncOperation(data);
return await transformData(result);
} catch (err) {
// 에러 로깅
console.error('처리 중 오류 발생:', err);
// 특정 에러는 변환하여 던지기
if (err.code === 'NETWORK_ERROR') {
throw new CustomError('네트워크 연결을 확인해주세요', err);
}
// 그 외 에러는 그대로 전파
throw err;
}
}
실무 에러 처리 전략
대규모 프로젝트에서 적용했던 효과적인 에러 처리 전략을 공유합니다:
- 계층별 에러 처리: 각 계층(라우터, 서비스, 리포지토리 등)에 적합한 에러 처리 전략 구현
- 에러 표준화: 일관된 형식의 커스텀 에러 클래스 사용
- 상세한 로깅: 에러 발생 시 충분한 컨텍스트 정보 기록
- 재시도 메커니즘: 일시적 오류에 대한 자동 재시도 로직 구현
// 커스텀 에러 클래스 예제
class ApplicationError extends Error {
constructor(message, code, originalError = null) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.originalError = originalError;
Error.captureStackTrace(this, this.constructor);
}
}
class DatabaseError extends ApplicationError {
constructor(message, originalError = null) {
super(message, 'DB_ERROR', originalError);
this.isOperational = true; // 운영 중 발생할 수 있는 에러로 분류
}
}
// 비동기 함수에서 에러 처리 및 재시도 로직
async function fetchDataWithRetry(id, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await dataService.fetchById(id);
} catch (err) {
lastError = err;
// 일시적 오류인 경우만 재시도
if (!isTransientError(err)) {
break;
}
console.warn(`데이터 조회 재시도 중... (${attempt}/${maxRetries})`);
await sleep(Math.pow(2, attempt) * 100); // 지수 백오프
}
}
// 모든 재시도 실패 시
throw new DatabaseError(`데이터 조회 실패 (ID: ${id})`, lastError);
}
7. 결론
Node.js의 비동기 처리 패턴은 콜백, Promise, async/await로 진화해 왔습니다. 각 패턴은 고유한 장단점이 있으며, 상황에 맞게 적절히 선택하는 것이 중요합니다. 개인적으로 현대적인 Node.js 애플리케이션에서는 async/await를 기본으로 사용하고, 필요에 따라 Promise의 병렬 처리 기능을 활용하는 하이브리드 접근 방식을 추천합니다.
비동기 코드를 작성할 때는 가독성, 에러 처리, 성능을 균형 있게 고려해야 합니다. 특히 실무에서는 팀원 간의 코드 이해도와 유지보수성이 중요하므로, 일관된 패턴을 적용하는 것이 좋습니다.
Node.js 비동기 처리는 계속해서 발전하고 있습니다. ECMAScript의 새로운 기능과 Node.js의 업데이트를 지속적으로 학습하면서, 더 효율적인 비동기 코드 작성 방법을 모색해 나가는 것이 중요합니다.
Comments
Post a Comment