Node.js & 서버 개발

Node.js에서 비동기 처리 방식 총정리 – Callback, Promise, async/await

devcomet 2025. 5. 21. 17:20
728x90
반응형

Node.js에서 비동기 처리 방식 총정리 – Callback, Promise, async/await

 

Node.js는 싱글 스레드 기반의 비동기 이벤트 주도 아키텍처를 가진 JavaScript 런타임 환경입니다.

이러한 특성 때문에 Node.js에서는 비동기 처리가 매우 중요한 개념으로 자리 잡고 있습니다.

본 글에서는 Node.js에서 사용되는 세 가지 주요 비동기 처리 방식인

Callback, Promise, 그리고 async/await에 대해 상세히 알아보고,

각각의 장단점과 실제 적용 예제를 통해 이해를 돕고자 합니다.

비동기 프로그래밍의 필요성과 Node.js

JavaScript는 본래 브라우저에서 동작하는 단일 스레드 언어로 설계되었습니다.

이는 한 번에 하나의 작업만 처리할 수 있다는 것을 의미합니다.

하지만 웹 애플리케이션이 점점 복잡해지면서, 여러 작업을 동시에 처리해야 하는 필요성이 증가했습니다.

Node.js는 이러한 한계를 극복하기 위해 비동기 이벤트 루프 모델을 도입했습니다.

이 모델은 I/O 작업(파일 읽기/쓰기, 네트워크 요청 등)을 비동기적으로 처리하여 블로킹 없이 다른 작업을 계속할 수 있게 합니다.

비동기 프로그래밍의 주요 이점은 다음과 같습니다:

  1. 향상된 성능: I/O 작업이 완료되기를 기다리는 동안 다른 코드를 실행할 수 있어 리소스 활용도가 높아집니다.
  2. 사용자 경험 개선: 웹 서버에서 여러 요청을 동시에 처리할 수 있어 응답 시간이 단축됩니다.
  3. 확장성: 단일 스레드로도 많은 연결을 효율적으로 처리할 수 있습니다.

Node.js에서 비동기 작업을 처리하는 방식은 시간이 지남에 따라 진화해왔으며, 현재는 크게 세 가지 패턴이 사용되고 있습니다: Callback, Promise, 그리고 async/await입니다.

Callback 함수를 통한 비동기 처리

Callback 함수란?

콜백 함수(Callback function)는 Node.js 초기부터 사용된 가장 기본적인 비동기 패턴입니다. 콜백은 다른 함수에 인자로 전달되어, 해당 함수의 작업이 완료된 후에 실행되는 함수를 말합니다.

// 기본적인 콜백 함수 예제
function fetchData(callback) {
  // 비동기 작업 수행 (예: 파일 읽기, API 호출 등)
  setTimeout(() => {
    const data = { id: 1, name: 'Node.js 비동기 처리' };
    callback(null, data); // 작업 완료 후 콜백 실행
  }, 1000);
}

// 콜백 함수 사용
fetchData((error, data) => {
  if (error) {
    console.error('에러 발생:', error);
    return;
  }
  console.log('데이터 수신 성공:', data);
});

위 예제에서 fetchData 함수는 1초 후에 데이터를 반환하는 비동기 작업을 시뮬레이션합니다. 작업이 완료되면 콜백 함수가 호출되어 결과를 처리합니다.

Node.js의 오류-우선 콜백 패턴

Node.js에서는 "오류-우선 콜백(Error-first Callback)" 패턴을 널리 사용합니다. 이 패턴에서 콜백 함수의 첫 번째 매개변수는 항상 오류 객체입니다. 오류가 없는 경우 이 매개변수는 null 또는 undefined가 됩니다.

const fs = require('fs');

// 파일 읽기 작업에 오류-우선 콜백 사용
fs.readFile('./example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('파일 읽기 실패:', err);
    return;
  }
  console.log('파일 내용:', data);
});

이 예제에서 fs.readFile은 파일 시스템에서 비동기적으로 파일을 읽는 Node.js의 내장 함수입니다. 작업이 완료되면 콜백 함수가 호출되며, 오류가 발생했는지 여부에 따라 적절한 처리를 수행합니다.

콜백 지옥(Callback Hell)

콜백 패턴의 가장 큰 단점은 여러 비동기 작업을 순차적으로 실행해야 할 때 발생하는 "콜백 지옥(Callback Hell)" 또는 "피라미드 오브 둠(Pyramid of Doom)"이라고 불리는 현상입니다.

// 콜백 지옥 예제
getUserData(userId, (error, userData) => {
  if (error) {
    console.error('사용자 데이터 조회 실패:', error);
    return;
  }

  getOrderHistory(userData.id, (error, orders) => {
    if (error) {
      console.error('주문 내역 조회 실패:', error);
      return;
    }

    getProductDetails(orders[0].productId, (error, product) => {
      if (error) {
        console.error('상품 상세 정보 조회 실패:', error);
        return;
      }

      getProductReviews(product.id, (error, reviews) => {
        if (error) {
          console.error('상품 리뷰 조회 실패:', error);
          return;
        }

        // 최종 결과 처리
        console.log('사용자:', userData.name);
        console.log('첫 번째 주문 상품:', product.name);
        console.log('상품 리뷰 수:', reviews.length);
      });
    });
  });
});

이러한 깊은 중첩은 코드의 가독성을 크게 저하시키고, 오류 처리와 코드 유지 관리를 어렵게 만듭니다. 이러한 문제를 해결하기 위해 Promise가 도입되었습니다.

Promise를 활용한 비동기 처리

Promise란?

Promise는 ES6(ECMAScript 2015)에서 도입된 비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체입니다. Promise는 다음 세 가지 상태 중 하나를 가집니다:

  1. Pending(대기): 초기 상태, 비동기 작업이 아직 완료되지 않음
  2. Fulfilled(이행): 작업이 성공적으로 완료됨
  3. Rejected(거부): 작업이 실패함
// Promise 기본 구조
const myPromise = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  const success = true; // 작업 성공 여부 (예시)

  if (success) {
    resolve('작업 성공!'); // 성공 시 resolve 호출
  } else {
    reject(new Error('작업 실패!')); // 실패 시 reject 호출
  }
});

// Promise 사용
myPromise
  .then(result => {
    console.log(result); // '작업 성공!' 출력
  })
  .catch(error => {
    console.error(error); // 에러 발생 시 실행
  });

콜백 함수를 Promise로 변환하기

기존의 콜백 기반 함수를 Promise 기반으로 변환할 수 있습니다. 이를 "프로미스화(Promisification)"라고 합니다.

// 콜백 기반 함수
function fetchDataWithCallback(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'Node.js 비동기 처리' };
    callback(null, data);
  }, 1000);
}

// Promise로 변환
function fetchDataWithPromise() {
  return new Promise((resolve, reject) => {
    fetchDataWithCallback((error, data) => {
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  });
}

// Promise 사용
fetchDataWithPromise()
  .then(data => {
    console.log('데이터 수신 성공:', data);
  })
  .catch(error => {
    console.error('에러 발생:', error);
  });

Node.js의 유틸리티 모듈인 util.promisify를 사용하면 콜백 기반 함수를 더 쉽게 Promise로 변환할 수 있습니다.

const util = require('util');
const fs = require('fs');

// fs.readFile을 Promise 기반 함수로 변환
const readFilePromise = util.promisify(fs.readFile);

// Promise 기반 함수 사용
readFilePromise('./example.txt', 'utf8')
  .then(data => {
    console.log('파일 내용:', data);
  })
  .catch(error => {
    console.error('파일 읽기 실패:', error);
  });

Promise 체이닝과 병렬 처리

Promise의 주요 장점 중 하나는 여러 비동기 작업을 체이닝(chaining)하여 순차적으로 실행하거나, Promise.all 등을 사용하여 병렬로 처리할 수 있다는 것입니다.

Promise 체이닝 예제:

// Promise 체이닝
getUserDataPromise(userId)
  .then(userData => {
    console.log('사용자 데이터:', userData);
    return getOrderHistoryPromise(userData.id);
  })
  .then(orders => {
    console.log('주문 내역:', orders);
    return getProductDetailsPromise(orders[0].productId);
  })
  .then(product => {
    console.log('상품 상세 정보:', product);
    return getProductReviewsPromise(product.id);
  })
  .then(reviews => {
    console.log('상품 리뷰:', reviews);
  })
  .catch(error => {
    console.error('처리 중 오류 발생:', error);
  });

Promise.all을 사용한 병렬 처리:

// 여러 API 요청을 병렬로 처리
const promiseArray = [
  fetchUserData(userId),
  fetchPopularProducts(),
  fetchSiteStatistics()
];

Promise.all(promiseArray)
  .then(([userData, popularProducts, siteStats]) => {
    // 모든 데이터가 준비되면 한 번에 처리
    console.log('사용자 데이터:', userData);
    console.log('인기 상품:', popularProducts);
    console.log('사이트 통계:', siteStats);
  })
  .catch(error => {
    // 하나라도 실패하면 catch로 이동
    console.error('데이터 로딩 실패:', error);
  });

기타 유용한 Promise 메서드

Promise 객체는 여러 유용한 정적 메서드를 제공합니다:

  1. Promise.resolve(value): 주어진 값으로 이행된 Promise 객체를 생성합니다.
  2. Promise.reject(reason): 주어진 이유로 거부된 Promise 객체를 생성합니다.
  3. Promise.race(iterable): iterable의 Promise 중 가장 먼저 완료되는 것의 결과(또는 오류)를 반환합니다.
  4. Promise.allSettled(iterable): 모든 Promise가 완료(이행 또는 거부)될 때까지 기다린 후, 각 Promise의 결과를 객체 배열로 반환합니다.
// Promise.race 예제: 타임아웃 구현
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(error => console.error('에러:', error));

Promise는 콜백 지옥 문제를 상당 부분 해결했지만, 여전히 코드가 복잡해질 수 있습니다. 이러한 한계를 극복하기 위해 async/await가 도입되었습니다.

async/await를 통한 비동기 처리

async/await 소개

async/await는 ES2017(ES8)에서 도입된 기능으로, Promise 기반 비동기 코드를 마치 동기 코드처럼 작성할 수 있게 해주는 문법적 설탕(syntactic sugar)입니다. 이를 통해 비동기 코드의 가독성과 유지보수성이 크게 향상됩니다.

// async/await 기본 구조
async function fetchData() {
  try {
    // await 키워드는 Promise가 완료될 때까지 기다립니다
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log('데이터:', data);
    return data;
  } catch (error) {
    console.error('데이터 조회 실패:', error);
    throw error;
  }
}

// async 함수 호출
fetchData()
  .then(result => {
    console.log('함수 실행 결과:', result);
  })
  .catch(error => {
    console.error('함수 실행 중 오류:', error);
  });

Promise 체이닝을 async/await로 변환

앞서 본 Promise 체이닝 예제를 async/await로 변환하면 코드가 훨씬 간결하고 읽기 쉬워집니다.

// Promise 체이닝 코드를 async/await로 변환
async function getUserCompleteData(userId) {
  try {
    const userData = await getUserDataPromise(userId);
    console.log('사용자 데이터:', userData);

    const orders = await getOrderHistoryPromise(userData.id);
    console.log('주문 내역:', orders);

    const product = await getProductDetailsPromise(orders[0].productId);
    console.log('상품 상세 정보:', product);

    const reviews = await getProductReviewsPromise(product.id);
    console.log('상품 리뷰:', reviews);

    return {
      user: userData,
      orders,
      product,
      reviews
    };
  } catch (error) {
    console.error('처리 중 오류 발생:', error);
    throw error;
  }
}

// 함수 실행
getUserCompleteData(123)
  .then(data => {
    console.log('모든 데이터 로딩 완료:', data);
  })
  .catch(error => {
    console.error('데이터 로딩 실패:', error);
  });

병렬 처리와 async/await

async/await를 사용하면서도 Promise.all을 함께 활용하여 여러 비동기 작업을 병렬로 처리할 수 있습니다.

// async/await와 Promise.all을 함께 사용한 병렬 처리
async function loadDashboardData(userId) {
  try {
    // 여러 API 요청을 병렬로 처리
    const [userData, popularProducts, siteStats] = await Promise.all([
      fetchUserData(userId),
      fetchPopularProducts(),
      fetchSiteStatistics()
    ]);

    // 모든 데이터가 준비되면 사용
    console.log('사용자 데이터:', userData);
    console.log('인기 상품:', popularProducts);
    console.log('사이트 통계:', siteStats);

    return {
      user: userData,
      products: popularProducts,
      stats: siteStats
    };
  } catch (error) {
    console.error('데이터 로딩 실패:', error);
    throw error;
  }
}

// 함수 실행
loadDashboardData(456).then(dashboardData => {
  // 대시보드 렌더링 등의 작업 수행
  renderDashboard(dashboardData);
});

주의사항과 베스트 프랙티스

async/await를 사용할 때 몇 가지 주의할 점이 있습니다:

  1. 항상 try/catch 블록 사용하기: 비동기 코드에서 발생하는 예외를 적절히 처리하기 위해 항상 try/catch 블록을 사용하는 것이 좋습니다.
  2. await는 async 함수 내에서만 사용 가능: await 키워드는 반드시 async 함수 내에서만 사용할 수 있습니다.
  3. async 함수는 항상 Promise를 반환: async 함수는 내부에서 명시적으로 Promise를 반환하지 않더라도 항상 Promise를 반환합니다.
  4. 불필요한 순차 처리 피하기: 서로 의존관계가 없는 비동기 작업은 Promise.all을 사용하여 병렬로 처리하는 것이 성능상 유리합니다.
// 나쁜 예: 불필요한 순차 처리
async function loadData() {
  const users = await fetchUsers();
  const products = await fetchProducts(); // users에 의존하지 않음
  const orders = await fetchOrders(); // 이전 결과들에 의존하지 않음
  return { users, products, orders };
}

// 좋은 예: 병렬 처리
async function loadData() {
  const [users, products, orders] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchOrders()
  ]);
  return { users, products, orders };
}

실제 프로젝트에서의 비동기 처리 패턴 선택

세 가지 비동기 처리 방식(Callback, Promise, async/await)은 각각 장단점이 있으며, 상황에 따라 적절한 방식을 선택하는 것이 중요합니다.

각 방식의 비교

방식 장점 단점 적합한 상황
Callback - 간단한 구현
- 모든 JavaScript 환경에서 지원
- 콜백 지옥
- 오류 처리 복잡
- 간단한 비동기 작업
- 레거시 코드 유지보수
Promise - 체이닝 가능
- 에러 처리 개선
- 병렬 처리 용이
- ES6 이상 필요
- 코드가 여전히 복잡할 수 있음
- 다중 비동기 작업
- 라이브러리 개발
async/await - 가독성 우수
- 동기 코드와 유사한 구문
- 디버깅 용이
- ES2017 이상 필요
- 남용 시 성능 저하 가능
- 복잡한 비동기 흐름
- 사용자 인터페이스 관련 코드

실제 프로젝트 적용 사례: REST API 서버

다음은 Express.js를 사용한 REST API 서버에서 각 비동기 처리 방식을 적용한 예제입니다.

1. Callback 방식

// Callback 패턴을 사용한 사용자 정보 조회 API
const express = require('express');
const app = express();
const db = require('./database');

app.get('/api/users/:id', (req, res) => {
  const userId = req.params.id;

  db.getUser(userId, (err, user) => {
    if (err) {
      return res.status(500).json({ error: '사용자 조회 실패' });
    }

    if (!user) {
      return res.status(404).json({ error: '사용자를 찾을 수 없음' });
    }

    db.getUserPosts(userId, (err, posts) => {
      if (err) {
        return res.status(500).json({ error: '게시물 조회 실패' });
      }

      res.json({
        user,
        posts
      });
    });
  });
});

app.listen(3000, () => {
  console.log('서버가 3000번 포트에서 실행 중입니다.');
});

2. Promise 방식

// Promise 패턴을 사용한 사용자 정보 조회 API
const express = require('express');
const app = express();
const db = require('./database-promise'); // Promise 기반 DB 모듈

app.get('/api/users/:id', (req, res) => {
  const userId = req.params.id;

  db.getUser(userId)
    .then(user => {
      if (!user) {
        return res.status(404).json({ error: '사용자를 찾을 수 없음' });
      }

      return db.getUserPosts(userId)
        .then(posts => {
          res.json({
            user,
            posts
          });
        });
    })
    .catch(err => {
      console.error('API 오류:', err);
      res.status(500).json({ error: '서버 오류' });
    });
});

app.listen(3000, () => {
  console.log('서버가 3000번 포트에서 실행 중입니다.');
});

3. async/await 방식

// async/await 패턴을 사용한 사용자 정보 조회 API
const express = require('express');
const app = express();
const db = require('./database-promise'); // Promise 기반 DB 모듈

app.get('/api/users/:id', async (req, res) => {
  const userId = req.params.id;

  try {
    const user = await db.getUser(userId);

    if (!user) {
      return res.status(404).json({ error: '사용자를 찾을 수 없음' });
    }

    const posts = await db.getUserPosts(userId);

    res.json({
      user,
      posts
    });
  } catch (err) {
    console.error('API 오류:', err);
    res.status(500).json({ error: '서버 오류' });
  }
});

app.listen(3000, () => {
  console.log('서버가 3000번 포트에서 실행 중입니다.');
});

모던 Node.js 프로젝트에서의 비동기 처리 패턴

현대적인 Node.js 프로젝트에서는 대체로 async/await 패턴을 선호하는 추세입니다. 하지만 다양한 상황에 맞게 여러 패턴을 적절히 조합하여 사용하는 것이 일반적입니다.

util.promisify와 Node.js의 비동기 API

Node.js 10 이상에서는 기본 내장 모듈의 많은 부분이 Promise 기반 API를 제공합니다. 예를 들어 fs 모듈은 fs.promises 네임스페이스를 통해 Promise 기반 메서드를 제공합니다.

// fs.promises 사용 예제
const fs = require('fs').promises;

async function readAndProcessFile(filePath) {
  try {
    const content = await fs.readFile(filePath, 'utf8');
    const processedData = processData(content);
    await fs.writeFile(`${filePath}.processed`, processedData);
    console.log('파일 처리 완료');
  } catch (error) {
    console.error('파일 처리 중 오류:', error);
  }
}

readAndProcessFile('./data.txt');

에러 처리 전략

비동기 코드에서의 에러 처리는 매우 중요합니다. 각 비동기 처리 방식에 따라 적절한 에러 처리 전략을 선택해야 합니다.

  1. Callback 방식: 오류-우선 콜백 패턴 사용
  2. Promise 방식: .catch() 메서드 또는 .then(onSuccess, onError) 사용
  3. async/await 방식: try/catch 블록 사용

특히 Express.js와 같은 웹 프레임워크에서는 에러 핸들링 미들웨어를 활용하는 것이 좋습니다.

// Express.js에서의 비동기 에러 처리
const express = require('express');
const app = express();

// 비동기 함수용 에러 처리 래퍼
const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/api/data', asyncHandler(async (req, res) => {
  const data = await fetchData(); // 에러가 발생하면 자동으로 next(error)로 전달됨
  res.json(data);
}));

// 에러 처리 미들웨어
app.use((err, req, res, next) => {
  console.error('API 오류:', err);
  res.status(500).json({ error: '서버 오류', message: err.message });
});

app.listen(3000);

고급 비동기 패턴과 최적화 전략

실전 비동기 처리 최적화 팁

1. 병렬 처리 활용하기

독립적인 여러 비동기 작업을 순차적으로 처리하면 불필요한 대기 시간이 발생합니다. Promise.all()을 사용하여 여러 작업을 병렬로 처리하면 전체 실행 시간을 크게 단축할 수 있습니다.

// 최적화 전: 순차 처리
async function getDataSequential() {
  console.time('sequential');

  const users = await fetchUsers();
  const products = await fetchProducts();
  const orders = await fetchOrders();

  console.timeEnd('sequential'); // 약 300ms + 200ms + 250ms = 750ms
  return { users, products, orders };
}

// 최적화 후: 병렬 처리
async function getDataParallel() {
  console.time('parallel');

  const [users, products, orders] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchOrders()
  ]);

  console.timeEnd('parallel'); // 약 300ms (가장 오래 걸리는 작업 기준)
  return { users, products, orders };
}

2. 비동기 작업의 캐싱

자주 요청되는 비동기 작업 결과를 캐싱하면 성능을 크게 향상시킬 수 있습니다.

// 간단한 메모이제이션 함수
function memoize(fn) {
  const cache = new Map();

  return async function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = await fn(...args);
    cache.set(key, result);
    return result;
  };
}

// 비동기 함수 캐싱
const fetchUserCached = memoize(fetchUser);

// 사용 예
async function handleRequest(userId) {
  // 같은 userId로 여러 번 호출해도 실제 fetch는 한 번만 발생
  const user = await fetchUserCached(userId);
  return user;
}

3. 비동기 작업의 취소(Cancellation)

Node.js에서는 AbortController API를 사용하여 불필요한 비동기 작업을 취소할 수 있습니다.

// API 요청 취소 예제
async function fetchWithTimeout(url, timeoutMs) {
  const controller = new AbortController();
  const { signal } = controller;

  // 타임아웃 설정
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal });
    clearTimeout(timeoutId);
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error(`요청이 ${timeoutMs}ms 후 취소되었습니다.`);
    }
    throw error;
  }
}

// 사용 예
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log('데이터:', data);
} catch (error) {
  console.error('에러:', error.message);
}

마이크로서비스 아키텍처에서의 비동기 통신

현대적인 Node.js 백엔드 개발에서는 마이크로서비스 아키텍처가 자주 사용됩니다. 마이크로서비스 간 통신에는 다양한 비동기 패턴이 활용됩니다.

1. HTTP/REST를 통한 동기식 통신

가장 기본적인 방식으로, 서비스 간 HTTP 요청을 통해 통신합니다.

// axios를 사용한 서비스 간 통신
const axios = require('axios');

async function getUserDetails(userId) {
  try {
    // 사용자 서비스 호출
    const userResponse = await axios.get(`http://user-service/users/${userId}`);
    const user = userResponse.data;

    // 주문 서비스 호출
    const orderResponse = await axios.get(`http://order-service/orders?userId=${userId}`);
    const orders = orderResponse.data;

    return { user, orders };
  } catch (error) {
    console.error('서비스 간 통신 오류:', error);
    throw new Error('사용자 상세 정보 조회 실패');
  }
}

2. 메시지 큐를 활용한 비동기 통신

RabbitMQ, Kafka 등의 메시지 큐를 활용하여 서비스 간 비동기 통신을 구현할 수 있습니다.

// AMQP 라이브러리를 사용한 RabbitMQ 메시지 발행
const amqp = require('amqplib');

async function publishUserUpdatedEvent(userId, userData) {
  try {
    const connection = await amqp.connect('amqp://localhost');
    const channel = await connection.createChannel();

    const exchange = 'user.events';
    const routingKey = 'user.updated';
    const message = JSON.stringify({
      userId,
      userData,
      timestamp: new Date().toISOString()
    });

    await channel.assertExchange(exchange, 'topic', { durable: true });
    channel.publish(exchange, routingKey, Buffer.from(message));

    console.log(`이벤트 발행: ${routingKey} - ${userId}`);

    setTimeout(() => {
      connection.close();
    }, 500);
  } catch (error) {
    console.error('메시지 발행 오류:', error);
    throw error;
  }
}

3. gRPC를 활용한 스트리밍 통신

양방향 스트리밍이 필요한 경우 gRPC를 활용할 수 있습니다.

// gRPC 클라이언트 예제
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

// 프로토 파일 로드
const packageDefinition = protoLoader.loadSync('./protos/stream.proto');
const streamProto = grpc.loadPackageDefinition(packageDefinition).stream;

// gRPC 클라이언트 생성
const client = new streamProto.StreamService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

function processDataStream() {
  return new Promise((resolve, reject) => {
    const results = [];
    const call = client.processStream({});

    // 데이터 수신 처리
    call.on('data', (data) => {
      console.log('스트리밍 데이터 수신:', data);
      results.push(data);

      // 서버로 클라이언트 상태 전송
      call.write({ status: 'processing', itemsReceived: results.length });
    });

    // 스트리밍 완료 처리
    call.on('end', () => {
      console.log('스트리밍 완료');
      call.end();
      resolve(results);
    });

    // 오류 처리
    call.on('error', (error) => {
      console.error('스트리밍 오류:', error);
      reject(error);
    });
  });
}

마무리: 비동기 프로그래밍의 미래

Node.js의 비동기 프로그래밍 모델은 계속 발전하고 있습니다. ECMAScript의 새로운 기능들과 함께 더욱 강력하고 사용하기 쉬운 비동기 패턴이 등장할 것으로 예상됩니다.

현재 관심을 가질만한 몇 가지 발전 방향은 다음과 같습니다:

  1. Top-level await: Node.js 14.8.0부터 지원되며, 모듈 최상위 레벨에서 await 키워드를 사용할 수 있게 되었습니다.
  2. AbortController API: fetch API와 함께 사용하여 비동기 작업을 취소할 수 있는 표준화된 방법을 제공합니다.
  3. Worker Threads: CPU 집약적인 작업을 별도의 스레드에서 처리할 수 있는 기능으로, Node.js의 단일 스레드 한계를 극복하는 데 도움이 됩니다.
  4. Event-driven Architecture: Node.js의 이벤트 루프를 최대한 활용한 이벤트 기반 아키텍처는 계속해서 중요한 패러다임으로 자리 잡을 것입니다.

Node.js에서의 비동기 프로그래밍은 계속해서 진화하고 있으며, 개발자들은 이러한 발전을 따라가며 더 효율적이고 유지보수가 용이한 코드를 작성하기 위해 노력해야 합니다.

결론

Node.js에서의 비동기 처리는 서버 어플리케이션의 성능과 확장성에 직접적인 영향을 미치는 핵심 요소입니다.

이 글에서 다룬 세 가지 비동기 처리 방식(Callback, Promise, async/await)은 각각 고유한 특성과 사용 사례를 가지고 있습니다.

현대적인 Node.js 개발에서는 주로 async/await를 사용하지만, 기존 콜백 기반 API와의 호환성을 위해 Promise와 함께 사용하는 경우가 많습니다.

중요한 것은 프로젝트의 요구사항과 개발 환경에 맞는 적절한 패턴을 선택하고, 일관성 있게 적용하는 것입니다.

각 비동기 처리 방식의 장단점을 이해하고, 상황에 맞게 활용할 수 있다면 Node.js의 비동기 프로그래밍 모델을 최대한 활용하여 효율적이고 유지보수가 용이한 서버 애플리케이션을 개발할 수 있을 것입니다.

비동기 프로그래밍은 Node.js의 핵심이며, 이를 잘 이해하고 활용하는 것이 성공적인 서버 애플리케이션 개발의 열쇠가 될 것입니다.

728x90
반응형