프로그래밍/Node.js

Node.js 성능 최적화: 완벽 가이드 (모니터링, 메모리 관리, 이벤트 루프)

shimdh 2025. 2. 19. 00:10
728x90

I. 성능 모니터링: 문제 조기 발견 및 자원 사용 분석

A. 성능 모니터링의 중요성

  • 문제 조기 발견: 성능 저하나 오류 발생 전에 잠재적 문제를 파악하고 해결하여 시스템 안정성을 확보합니다.
  • 자원 사용 분석: CPU, 메모리 사용량을 추적하여 자원 과소비 지점을 식별하고 최적화합니다.
  • 사용자 경험 향상: 응답 시간 지연으로 인한 사용자 이탈을 방지하고, 전반적인 사용자 만족도를 향상시킵니다.

B. Node.js 성능 모니터링 도구

  1. Node.js 내장 프로파일러:

    • node --inspect 명령어를 사용하여 Chrome DevTools와 연결, 실시간 코드 디버깅 및 분석을 지원합니다.
    • CPU, 메모리 사용량을 분석하여 병목 지점을 파악하는 데 유용합니다.
      • 예시:
        • node --inspect server.js: 디버거를 활성화하여 server.js를 실행합니다.
        • Chrome DevTools를 열고 chrome://inspect로 접속하여 해당 Node.js 프로세스에 연결합니다.
        • Performance 탭에서 CPU 프로파일링 및 메모리 스냅샷을 캡처하고 분석합니다.
  2. PM2 (Process Manager 2):

    • Node.js 프로세스 관리 도구로, 애플리케이션 상태를 지속적으로 모니터링하고 로그를 기록합니다.
    • pm2 monit 명령어를 통해 PM2 대시보드를 열어 CPU, 메모리 사용량 등을 실시간으로 확인할 수 있습니다.
      • 예시:
        pm2 start app.js       # app.js 실행
        pm2 status           # 실행 중인 앱 목록 확인
        pm2 monit            # PM2 모니터링 대시보드 실행
        pm2 logs app         # 앱 로그 확인
        pm2 stop app         # 앱 중지
        pm2 restart app      # 앱 재시작
        pm2 delete app       # 앱 삭제
  3. New Relic 및 Datadog:

    • 서드파티 APM(Application Performance Management) 서비스로, 더욱 정교한 모니터링 기능을 제공합니다.
    • 요청 처리 시간, 데이터베이스 쿼리 시간, 에러 발생률 등 다양한 지표를 시각화하여 분석할 수 있습니다.
      • 예시:
        • New Relic 또는 Datadog 계정을 생성합니다.
        • 해당 서비스에서 제공하는 Node.js 에이전트를 설치 및 설정합니다.
        • 애플리케이션의 성능 데이터를 실시간으로 모니터링합니다.
        • 특정 트랜잭션 성능 분석, 데이터베이스 쿼리 분석, 에러 트래킹 등을 활용합니다.
        • 대시보드를 통해 CPU, 메모리, 네트워크 등 다양한 지표를 확인합니다.
  4. Prometheus & Grafana:

    • Prometheus는 메트릭 수집 시스템이고, Grafana는 수집된 메트릭을 시각화하는 도구입니다.

    • Node.js 애플리케이션의 다양한 성능 지표를 수집하고, 이를 시각적으로 모니터링하는 강력한 솔루션입니다.

      • 예시:

        • npm install prom-client를 사용하여 Prometheus 클라이언트 라이브러리를 설치합니다.

        • 애플리케이션에 메트릭을 노출하는 엔드포인트를 설정합니다.

          const client = require('prom-client');
          const register = new client.Registry();
          
          const httpRequestDurationMicroseconds = new client.Histogram({
            name: 'http_request_duration_ms',
            help: 'Duration of HTTP requests in ms',
            labelNames: ['method', 'route', 'status_code'],
            buckets: [0.1, 5, 15, 50, 100, 500],
          });
          
          register.registerMetric(httpRequestDurationMicroseconds);
          
          // Express 미들웨어에서 메트릭 수집 예제
          app.use((req, res, next) => {
            const start = Date.now();
            res.on('finish', () => {
                const duration = Date.now() - start;
                httpRequestDurationMicroseconds.labels(req.method, req.path, res.statusCode).observe(duration);
                console.log(`${req.method} ${req.url} took ${duration}ms`); // 요청 시간 로그 출력
            });
            next();
          });
          
          // Prometheus 메트릭 엔드포인트
          app.get('/metrics', async (req, res) => {
            res.setHeader('Content-Type', register.contentType);
            res.send(await register.metrics());
          });
        • Prometheus를 설정하여 해당 엔드포인트에서 메트릭을 스크랩합니다.

        • Grafana를 설정하여 Prometheus에서 수집한 메트릭을 시각화합니다.

        • 대시보드를 통해 실시간 애플리케이션 성능을 모니터링합니다.

C. 실시간 모니터링 구현 예시

아래 예시는 Express 애플리케이션에서 미들웨어를 사용하여 각 요청의 소요 시간을 측정하고 로그를 기록하는 방법을 보여줍니다. 이를 통해 어떤 라우트가 느린지 쉽게 파악할 수 있으며, 에러 핸들링 미들웨어를 추가하여 애플리케이션 에러도 로깅할 수 있습니다.

const express = require('express');
const app = express();

// 요청 시간을 기록하는 미들웨어
app.use((req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.url} took ${duration}ms`); // 요청 시간 로그 출력
  });

  res.on('error', (err) => {
    console.error(`Error processing ${req.method} ${req.url}: `, err); // 에러 로깅
  });

  next();
});

// 간단한 라우트 설정
app.get('/', (req, res) => {
  res.send('Hello World!');
});

// 에러 처리 미들웨어 (마지막으로 추가)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

// 일부러 에러를 발생시키는 라우트 추가
app.get('/error', (req, res, next) => {
    throw new Error('This is a test error');
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

II. 메모리 관리: 효율적인 메모리 사용 및 누수 방지

A. 메모리 관리의 중요성

  • 성능 향상: 효율적인 메모리 관리는 애플리케이션의 응답 속도를 높이고 전반적인 성능을 향상시킵니다.
  • 안정성 유지: 불필요한 메모리 사용은 시스템 크래시를 유발할 수 있으며, 메모리 관리를 통해 안정성을 확보할 수 있습니다.

B. Node.js 메모리 구조

Node.js는 V8 JavaScript 엔진을 사용하여 코드를 실행하며, 메모리는 다음과 같이 구성됩니다.

  • 힙 (Heap): 동적으로 할당되는 객체, 배열, 함수 클로저 등의 데이터 저장 공간입니다. 가비지 컬렉션(Garbage Collection)이 이루어지는 영역입니다.
  • 스택 (Stack): 함수 호출, 지역 변수, 함수 인자 등이 저장되는 공간입니다. 각 함수 호출 시 생성되고 반환 시 소멸되는 LIFO(Last In, First Out) 구조입니다.

C. 일반적인 메모리 문제

  1. 메모리 누수 (Memory Leak): 더 이상 필요 없는 객체에 대한 참조가 남아있어 가비지 컬렉터가 메모리를 해제하지 못하는 현상입니다.

    • 예시:

      let data = [];
      
      function addData() {
          for (let i = 0; i < 10000; i++) {
              data.push({ id: i, data: new Array(1000).fill('some string') }); // 계속해서 새로운 객체와 큰 배열을 생성하여 추가
          }
          console.log('Data added'); // 추가 후 로그
      }
      
      addData(); // 메모리 누수를 유발하는 코드
      • addData 함수가 호출될 때마다 data 배열에 새로운 객체가 추가되며, 각 객체는 큰 배열을 포함합니다. 만약 이 배열이 더 이상 사용되지 않는데도 계속 참조를 유지한다면 메모리 누수가 발생할 수 있습니다.

      • 예시:

          const eventEmitter = require('events');
          const emitter = new eventEmitter();
          let listener;
        
          function addListener() {
              listener = () => { console.log('Event occurred'); };
              emitter.on('data', listener);
              console.log('Listener added')
          }
        
          addListener();
          // ... 나중에 listener 제거를 하지 않으면 계속 메모리에 남아있게 됩니다.
          // emitter.removeListener('data', listener);  // 올바른 예시
  2. 불필요한 전역 변수 사용: 전역 변수는 프로그램 실행 동안 메모리에 유지되므로, 불필요하게 큰 데이터를 저장하거나 많은 전역 변수를 사용하면 메모리 낭비를 초래합니다. 전역 변수의 증가는 스크립트가 실행되는 동안 메모리에 계속 머무르기 때문에 불필요한 메모리 사용을 야기합니다.

    • 예시:
      global.largeData = new Array(1000000).fill('global data'); // 전역 변수로 큰 배열 생성
      function processData() {
          console.log(global.largeData.length);
      }
      processData();
  3. 클로저 내에서의 대량 데이터 유지: 클로저는 외부 함수의 변수에 접근할 수 있게 해주는 기능이지만, 클로저 내에서 큰 데이터를 계속 참조하고 있다면 가비지 컬렉션이 어려워져 메모리 누수의 원인이 될 수 있습니다.

    • 예시:

      function createDataProcessor() {
          const largeData = new Array(1000000).fill('data');
          return function() {
              console.log(largeData.length); // 클로저 내부에서 큰 데이터를 계속 참조
          }
      }
      
      const dataProcessor = createDataProcessor();
      dataProcessor();
      • createDataProcessor 함수가 반환하는 내부 함수가 외부 스코프의 largeData 변수를 계속 참조합니다. 이 클로저가 계속해서 참조를 유지하면 largeData는 가비지 컬렉션 대상에서 제외되어 메모리에 남아있게 됩니다.

D. 효율적인 메모리 관리 방법

  1. 변수 및 객체 범위 최소화: 변수는 필요한 시점에 선언하고, 지역 변수를 활용하여 스코프를 제한합니다. 불필요하게 큰 객체나 변수를 전역 스코프에서 선언하지 않고 함수 내부에서 지역 변수로 선언하여 사용 후에는 메모리에서 해제되도록 합니다.

    function processData(input) {
        let localVariable = "I am local"; // 지역 변수로 사용
        const processedData = input.map(item => item * 2); // 배열 변환 후 사용 후 메모리에서 해제
        // ...
        return processedData
    }
  2. 사용 후 null 처리: 더 이상 사용하지 않는 객체나 배열은 null로 설정하여 가비지 컬렉션 대상이 되도록 합니다. 배열이나 객체의 크기가 클수록 메모리 사용량을 줄이는 데 효과적입니다.

    let largeArray = new Array(1000000);
        // 작업 완료 후 null 처리
    largeArray = null;
    let largeObject = { bigData: new Array(1000000).fill('data') };
    largeObject = null;
  3. Weak References 사용: WeakMap 또는 WeakSet을 사용하여 객체에 대한 약한 참조를 만들면, 객체가 더 이상 참조되지 않을 때 가비지 컬렉터에 의해 회수될 수 있습니다.

    • 예시:
      const weakMap = new WeakMap();
      let key = {};
      let value = { data: 'some data' };
      weakMap.set(key, value);
      console.log(weakMap.get(key)); // 정상 접근
      key = null; // key 객체에 대한 참조 제거
      setTimeout(() => {
          console.log(weakMap.get(key)) // undefined, 가비지 컬렉션에 의해 회수
      }, 1000);
  4. 메모리 프로파일링 툴 활용: Node.js의 --inspect 플래그와 Chrome DevTools를 사용하여 힙 스냅샷을 분석, 메모리 문제를 진단합니다.

    • 예시:
      • node --inspect memory-hog.js와 같이 inspect 모드로 애플리케이션을 실행합니다.
      • Chrome DevTools의 'Memory' 탭에서 힙 스냅샷을 캡처합니다.
      • 스냅샷을 비교하여 객체 할당 및 메모리 누수를 분석합니다.
      • 특정 객체가 메모리에서 해제되지 않는 원인을 파악합니다.
  5. 캐싱 및 큐 시스템 활용: 반복적으로 사용하는 대량 데이터를 캐싱하거나, 요청 처리를 큐 시스템을 통해 분산시켜 메모리 사용량을 최적화합니다. 자주 사용하는 데이터는 메모리에 캐싱하여 불필요한 연산을 줄이고, 요청을 큐에 넣어 순차적으로 처리하여 메모리 과부하를 방지합니다.

    • 예시:

      const cache = new Map();
      
      async function fetchData(key) {
          if (cache.has(key)) {
              console.log('캐시에서 가져옴:', key);
              return cache.get(key);
          }
          console.log('캐시에 없음:', key);
          const data = await fetchFromDatabase(key);
          cache.set(key, data);
          return data;
      }
      
      async function fetchFromDatabase(key) {
          // 데이터베이스에서 데이터 가져오는 로직
          return { key, data: "data from db" };
      }
      fetchData('item1').then(res => console.log(res));
      fetchData('item1').then(res => console.log(res));
      • fetchData 함수는 cache에 데이터가 존재하면 캐시된 데이터를 반환하고, 없으면 데이터베이스에서 데이터를 가져와 캐싱합니다. 이렇게 함으로써 동일한 데이터를 요청할 때마다 데이터베이스에 접근하는 것을 방지할 수 있습니다.
  6. 스트림 사용: 대용량 데이터 처리 시 스트림을 활용하여 메모리 사용량을 줄입니다. 파일을 읽거나 데이터를 처리할 때 한 번에 모든 데이터를 메모리에 로드하는 대신 스트림을 사용하여 데이터를 조금씩 읽고 처리할 수 있습니다.

    • 예시:

      const fs = require('fs');
      const readableStream = fs.createReadStream('large_file.txt', { encoding: 'utf8', highWaterMark: 1024 });
      let dataCount = 0;
      readableStream.on('data', (chunk) => {
          dataCount++;
          // process the chunk
          console.log('chunk received: ', chunk.length, ' - count: ', dataCount);
      });
      
      readableStream.on('end', () => {
          console.log('stream reading finished');
      });

III. 이벤트 루프 최적화: 효율적인 비동기 처리

A. 이벤트 루프란 무엇인가?

이벤트 루프는 Node.js의 핵심 메커니즘으로, 단일 스레드에서 비동기 작업을 효율적으로 처리합니다. 비동기 I/O 작업을 통해 여러 작업을 동시에 처리할 수 있게 해줍니다.

B. 이벤트 루프 단계

  1. 타이머 (Timer): setTimeout, setInterval 콜백 실행, 지정된 시간이 지난 후 실행될 콜백 함수들을 관리합니다.
  2. I/O 콜백 (I/O Callbacks): 대부분의 I/O 관련 콜백 처리, 파일 읽기, 네트워크 요청 완료 후 콜백을 처리합니다.
  3. idle, prepare: 내부적인 준비 단계이며, 개발자는 직접 관여하지 않습니다.
  4. poll: 새로운 I/O 이벤트를 확인하고, 완료된 I/O 작업에 대한 콜백을 실행합니다.
  5. check: setImmediate 콜백을 실행합니다.
  6. close callbacks: 소켓 연결 종료, 파일 스트림 닫기 등 종료 시점에 필요한 콜백 함수들을 처리합니다.

C. 성능 최적화를 위한 팁

  1. CPU 집약적인 작업 피하기: CPU를 많이 사용하는 연산은 메인 스레드를 차단하므로, 별도의 작업 스레드로 분리하는 것이 좋습니다. worker_threads 모듈을 사용하여 별도의 스레드에서 처리합니다.

    const { Worker } = require('worker_threads');
    
    function runHeavyComputationInWorker() {
        return new Promise((resolve, reject) => {
            const worker = new Worker('./heavyComputationWorker.js');
            worker.on('message', resolve);
            worker.on('error', reject);
            worker.on('exit', (code) => {
                if (code !== 0) reject(new Error(`Worker stopped with code ${code}`));
            });
        });
    }
    
    // heavyComputationWorker.js
    const { parentPort } = require('worker_threads');
    
    const heavyComputation = () => {
    let result = 0;
        for (let i = 0; i < 1e9; i++) {
             result += i;
        }
        return result;
    };
    
    parentPort.postMessage(heavyComputation());
    
    // 메인 스레드
    async function main() {
        console.time('Worker computation');
        const result = await runHeavyComputationInWorker();
        console.timeEnd('Worker computation');
        console.log('Result from worker:', result);
        console.log("Main Thread is still alive")
    }
    
    main();
    • 위 예시에서는 worker_threads 모듈을 사용하여 heavyComputation 함수를 별도의 스레드에서 실행합니다. runHeavyComputationInWorker 함수는 worker 스레드를 생성하고 작업이 완료되면 결과를 resolve하는 Promise를 반환하여 메인 스레드의 블로킹을 방지합니다.
  2. 비동기 적극 활용: I/O 연산은 최대한 비동기적으로 처리하여 메인 스레드의 블로킹을 최소화합니다. 콜백, Promise, async/await를 사용하여 비동기 I/O 작업을 수행합니다.

    const fs = require('fs');
    
    // 콜백 예시
    fs.readFile('file.txt', 'utf8', (err, data) => {
        if (err) throw err;
        console.log(data); // 파일 읽기 완료 후 콜백 실행
    });
    
    // Promise 예시
    const readFilePromise = (path) => {
        return new Promise((resolve, reject) => {
            fs.readFile(path, 'utf8', (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(data);
                }
            });
        });
    };
    readFilePromise('file.txt').then(data => console.log(data)).catch(err => console.error(err));
    
    // async/await 예시
    async function readFileAsync(path) {
        try {
            const data = await readFilePromise(path);
            console.log(data);
        } catch (err) {
            console.error(err);
        }
    }
    readFileAsync('file.txt');
    console.log("파일 읽기가 진행되는 동안 다른 코드 실행"); // 비동기 처리 확인
  3. 메모리 관리: 가비지 컬렉션이 효율적으로 작동하도록 불필요한 객체 및 변수를 정리합니다.

  4. EventEmitter 사용: 불필요한 리스너를 제거하여 메모리 누수를 방지합니다.

    const eventEmitter = require('events');
    const emitter = new eventEmitter();
    
    function eventHandler(data) {
        console.log('Event occurred:', data);
    }
    
    emitter.on('data', eventHandler);
    
    emitter.emit('data', 'test data1');
    emitter.emit('data', 'test data2');
    
    emitter.removeListener('data', eventHandler); // 불필요한 리스너 제거
    emitter.emit('data', 'test data3'); // 제거 후 이벤트 발생하지 않음
    • 위 예제에서 eventHandler 함수를 data 이벤트 리스너로 등록한 후, 이벤트가 두 번 발생하면 해당 핸들러가 실행되어 로그를 출력합니다. 이후 removeListener를 사용하여 리스너를 제거하면 더 이상 해당 이벤트에 반응하지 않습니다.
  5. setImmediateprocess.nextTick의 적절한 사용:

    • process.nextTick은 현재 이벤트 루프 단계가 끝난 후 바로 실행되는 반면, setImmediate는 다음 이벤트 루프 단계에서 실행됩니다. 콜백 실행 순서에 따라 적절히 선택해야 합니다.

      console.log('start');
      
      setImmediate(() => {
          console.log('setImmediate callback');
      });
      
      process.nextTick(() => {
          console.log('nextTick callback');
      });
      console.log('end');
      // 출력 결과는 다음과 같습니다.
      // start
      // end
      // nextTick callback
      // setImmediate callback
      • 이 코드에서 process.nextTick으로 등록된 콜백은 현재 이벤트 루프 단계가 끝난 직후에 실행되고, setImmediate로 등록된 콜백은 그 다음 이벤트 루프 단계에서 실행됩니다.

D. 모니터링 도구 활용

  • node --inspect: 디버깅 및 프로파일링 기능 제공
  • APM (Application Performance Management) 도구: 실시간 애플리케이션 상태 및 성능 지표 추적
    • New Relic, Datadog, Dynatrace 등의 APM 도구를 사용하여 애플리케이션의 성능을 모니터링하고, 병목 지점을 찾고 성능을 개선할 수 있습니다.

IV. 결론

Node.js 성능 최적화는 지속적인 노력과 세심한 관찰을 요구합니다. 본 가이드에서 제시된 모니터링, 메모리 관리, 이벤트 루프 최적화 전략들을 적용하여 개발하는 애플리케이션의 성능을 향상시키고 사용자에게 더욱 쾌적한 경험을 제공할 수 있습니다. 꾸준한 점검과 테스트를 통해 최적의 Node.js 애플리케이션을 구축해 보세요.

728x90