Pingu
영차영차! Backend

console.log 성능 측정과 최적화: 실제 서버에서의 부하 분석

2026년 1월 28일
5개 태그
nodejs
console.log
performance
optimization
logging-series

console.log 성능 측정과 최적화: 실제 서버에서의 부하 분석

서론

이전 글에서 console.log의 내부 동작을 소스 코드 레벨에서 분석했습니다. 이번 글에서는 실제로 성능을 측정하고, 어떤 부분이 병목인지 파악한 후, 최적화 방안을 제시하겠습니다.

1. 성능 측정 환경 설정

1.1 측정 도구

Node.js의 perf_hooks 모듈을 사용하여 정밀한 성능 측정을 수행합니다:

const { performance, PerformanceObserver } = require('perf_hooks');

const perfObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.error(`[PERF] ${entry.name}: ${entry.duration.toFixed(3)}ms`);
  }
});
perfObserver.observe({ entryTypes: ['measure'] });

1.2 측정 항목

다음 항목들을 측정합니다:

  • console.log 호출의 평균 실행 시간
  • 메모리 사용량 변화
  • 포맷팅 시간 vs I/O 시간
  • 다양한 데이터 타입별 성능 차이

2. 기본 성능 측정

2.1 측정 스크립트

const { performance } = require('perf_hooks');

function measureConsoleLog(iterations, testName, ...args) {
  // 가비지 컬렉션 강제 실행
  if (global.gc) {
    global.gc();
  }
  
  const startMem = process.memoryUsage();
  const startTime = performance.now();
  
  performance.mark(`${testName}-start`);
  
  // 출력 억제 (I/O 오버헤드 제거하여 순수 포맷팅 시간 측정)
  const originalLog = console.log;
  console.log = () => {};
  
  for (let i = 0; i < iterations; i++) {
    console.log(...args);
  }
  
  console.log = originalLog;
  
  performance.mark(`${testName}-end`);
  performance.measure(testName, `${testName}-start`, `${testName}-end`);
  
  const endTime = performance.now();
  const endMem = process.memoryUsage();
  
  const duration = endTime - startTime;
  const memDelta = {
    rss: endMem.rss - startMem.rss,
    heapUsed: endMem.heapUsed - startMem.heapUsed,
    heapTotal: endMem.heapTotal - startMem.heapTotal,
    external: endMem.external - startMem.external
  };
  
  return {
    iterations,
    duration,
    avgDuration: duration / iterations,
    memoryDelta: memDelta
  };
}

// 테스트 실행
console.error('=== Console.log Performance Test ===\n');

const test1 = measureConsoleLog(1000, 'simple-string', 'Hello World');
console.error(`Test 1 - Simple String (1000 iterations):`);
console.error(`  Total: ${test1.duration.toFixed(2)}ms`);
console.error(`  Avg per log: ${test1.avgDuration.toFixed(3)}ms`);
console.error(`  Memory delta: ${(test1.memoryDelta.heapUsed / 1024).toFixed(2)} KB`);

const test2 = measureConsoleLog(1000, 'multiple-args', 'Value:', 42, true, null);
console.error(`Test 2 - Multiple Args (1000 iterations):`);
console.error(`  Total: ${test2.duration.toFixed(2)}ms`);
console.error(`  Avg per log: ${test2.avgDuration.toFixed(3)}ms`);
console.error(`  Memory delta: ${(test2.memoryDelta.heapUsed / 1024).toFixed(2)} KB`);

const test3 = measureConsoleLog(1000, 'object', { a: 1, b: 2, c: { d: 3 } });
console.error(`Test 3 - Object (1000 iterations):`);
console.error(`  Total: ${test3.duration.toFixed(2)}ms`);
console.error(`  Avg per log: ${test3.avgDuration.toFixed(3)}ms`);
console.error(`  Memory delta: ${(test3.memoryDelta.heapUsed / 1024).toFixed(2)} KB`);

const test4 = measureConsoleLog(100, 'large-array', Array.from({ length: 1000 }, (_, i) => i));
console.error(`Test 4 - Large Array (100 iterations):`);
console.error(`  Total: ${test4.duration.toFixed(2)}ms`);
console.error(`  Avg per log: ${test4.avgDuration.toFixed(3)}ms`);
console.error(`  Memory delta: ${(test4.memoryDelta.heapUsed / 1024).toFixed(2)} KB`);

// 직접 stream.write와 비교
console.error('\n=== Comparison: Direct stream.write ===');
const test5Start = performance.now();
for (let i = 0; i < 1000; i++) {
  process.stdout.write('Hello World\n');
}
const test5Duration = performance.now() - test5Start;
console.error(`Direct stream.write (1000 iterations):`);
console.error(`  Total: ${test5Duration.toFixed(2)}ms`);
console.error(`  Avg per write: ${(test5Duration / 1000).toFixed(3)}ms`);

2.2 측정 결과

출력 억제 상태 (순수 포맷팅 시간):

테스트반복 횟수총 시간평균/로그메모리 증가
단순 문자열10000.27ms0.000ms30.53 KB
여러 인자10000.04ms0.000ms4.74 KB
객체10000.07ms0.000ms11.08 KB
큰 배열1000.03ms0.000ms3.63 KB
직접 stream.write10001.27ms0.001ms-

주요 발견사항:

  1. 출력 억제 시: console.log 자체의 오버헤드는 매우 작습니다 (0.000-0.001ms).
  2. 실제 I/O: stream.write가 실제 병목입니다 (1.27ms for 1000 writes).
  3. 포맷팅: 복잡한 객체일수록 메모리 사용량이 증가합니다.
  4. Fast Path 효과: 단순 문자열은 포맷팅을 스킵하여 매우 빠릅니다.

3. 실제 I/O 포함 성능 측정

출력을 억제하지 않고 실제 I/O를 포함한 측정을 수행합니다:

const { performance } = require('perf_hooks');

function measureWithIO(iterations, testName, ...args) {
  const startTime = performance.now();
  const startMem = process.memoryUsage();
  
  for (let i = 0; i < iterations; i++) {
    console.log(...args);
  }
  
  const endTime = performance.now();
  const endMem = process.memoryUsage();
  
  return {
    iterations,
    duration: endTime - startTime,
    avgDuration: (endTime - startTime) / iterations,
    memoryDelta: {
      heapUsed: endMem.heapUsed - startMem.heapUsed
    }
  };
}

// 실제 출력 포함 테스트
const results = [];
results.push(measureWithIO(100, 'simple-string-io', 'Hello World'));
results.push(measureWithIO(100, 'object-io', { a: 1, b: 2, c: { d: 3 } }));
results.push(measureWithIO(100, 'complex-io', {
  nested: {
    deep: {
      array: [1, 2, 3, { more: 'data' }],
      map: new Map([['key', 'value']]),
      set: new Set([1, 2, 3])
    }
  }
}));

results.forEach(result => {
  console.error(`${result.iterations} iterations: ${result.avgDuration.toFixed(3)}ms per log`);
});

실제 I/O 포함 결과:

  • 단순 문자열 (100회): 평균 0.5-1.0ms/로그
  • 객체 (100회): 평균 1.5-2.5ms/로그
  • 복잡한 객체 (100회): 평균 3.0-5.0ms/로그

I/O 오버헤드가 포맷팅보다 훨씬 큼을 확인할 수 있습니다.


4. 실행 흐름 추적

실제로 console.log가 호출될 때 각 단계에서 얼마나 시간이 걸리는지 추적합니다:

const originalLog = console.log;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);

const traceSteps = [];

function addTrace(step, info) {
  const timestamp = process.hrtime.bigint();
  traceSteps.push({ step, timestamp: timestamp.toString(), info });
  console.error(`[STEP ${step}] ${info}`);
}

console.log = function(...args) {
  addTrace(2, `console.log called with ${args.length} arguments`);
  addTrace(3, `Argument types: ${args.map(a => typeof a).join(', ')}`);
  
  const formatStart = process.hrtime.bigint();
  addTrace(4, 'Starting formatWithOptions (kFormatForStdout)');
  
  originalLog.apply(console, args);
  
  const formatEnd = process.hrtime.bigint();
  const formatDuration = Number(formatEnd - formatStart) / 1_000_000;
  addTrace(5, `Formatting completed in ${formatDuration.toFixed(3)}ms`);
};

process.stdout.write = function(chunk, encoding, callback) {
  const chunkSize = chunk ? (typeof chunk === 'string' ? chunk.length : chunk.byteLength) : 0;
  addTrace(8, `stream.write called with ${chunkSize} bytes`);
  
  const writeStart = process.hrtime.bigint();
  
  const result = originalStdoutWrite(chunk, encoding, function(err) {
    const writeEnd = process.hrtime.bigint();
    const writeDuration = Number(writeEnd - writeStart) / 1_000_000;
    addTrace(9, `stream.write completed in ${writeDuration.toFixed(3)}ms`);
    if (callback) callback(err);
  });
  
  return result;
};

// 테스트 실행
console.log('Test 1: Simple string');
console.log('Test 2:', 42, true);
console.log('Test 3:', { a: 1, b: 2 });

추적 결과:

[STEP 2] console.log called with 1 arguments
[STEP 3] Argument types: string
[STEP 4] Starting formatWithOptions (kFormatForStdout)
[STEP 8] stream.write called with 22 bytes
Test 1: Simple string
[STEP 5] Formatting completed in 0.639ms
[STEP 9] stream.write completed in 0.012ms

[STEP 2] console.log called with 3 arguments
[STEP 3] Argument types: string, number, boolean
[STEP 4] Starting formatWithOptions (kFormatForStdout)
[STEP 8] stream.write called with 16 bytes
Test 2: 42 true
[STEP 5] Formatting completed in 0.045ms
[STEP 9] stream.write completed in 0.008ms

[STEP 2] console.log called with 2 arguments
[STEP 3] Argument types: string, object
[STEP 4] Starting formatWithOptions (kFormatForStdout)
[STEP 8] stream.write called with 23 bytes
Test 3: { a: 1, b: 2 }
[STEP 5] Formatting completed in 0.130ms
[STEP 9] stream.write completed in 0.010ms

관찰사항:

  • 포맷팅 시간: 단순 문자열(0.639ms) > 객체(0.130ms) > 여러 인자(0.045ms)
    • 단순 문자열이 더 오래 걸리는 이유: Fast path이지만 여전히 함수 호출 오버헤드
  • I/O 시간: 매우 짧음 (0.008-0.012ms)
  • 총 시간: 포맷팅이 대부분을 차지

5. 실제 서버 환경에서의 부하 테스트

5.1 테스트 서버

const http = require('http');

const server = http.createServer((req, res) => {
  const startTime = process.hrtime.bigint();
  
  // 다양한 타입의 console.log 테스트
  console.log('=== Request Start ===');
  console.log('URL:', req.url);
  console.log('Method:', req.method);
  console.log('Headers:', req.headers);
  console.log('Timestamp:', new Date().toISOString());
  
  // 복잡한 객체 테스트
  const complexObj = {
    nested: {
      deep: {
        array: [1, 2, 3, { more: 'data' }],
        map: new Map([['key', 'value']]),
        set: new Set([1, 2, 3])
      }
    },
    circular: null
  };
  complexObj.circular = complexObj; // 순환 참조
  
  console.log('Complex Object:', complexObj);
  
  // 대량 데이터 테스트
  const largeArray = Array.from({ length: 1000 }, (_, i) => `item-${i}`);
  console.log('Large Array (first 10):', largeArray.slice(0, 10));
  
  const endTime = process.hrtime.bigint();
  const duration = Number(endTime - startTime) / 1_000_000; // ms로 변환
  
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: 'Request processed',
    processingTime: `${duration.toFixed(2)}ms`,
    timestamp: new Date().toISOString()
  }));
  
  console.log('=== Request End ===');
  console.log(`Processing time: ${duration.toFixed(2)}ms`);
});

server.listen(3000, () => {
  console.log('Server started on http://localhost:3000');
});

5.2 부하 테스트 클라이언트

const http = require('http');

async function makeRequest(url) {
  return new Promise((resolve, reject) => {
    const startTime = Date.now();
    const req = http.get(url, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        const duration = Date.now() - startTime;
        resolve({
          statusCode: res.statusCode,
          duration,
          data: JSON.parse(data)
        });
      });
    });
    req.on('error', reject);
    req.setTimeout(5000, () => {
      req.destroy();
      reject(new Error('Request timeout'));
    });
  });
}

async function runLoadTest() {
  const concurrent = 10; // 동시 요청 수
  const total = 100; // 총 요청 수
  const url = 'http://localhost:3000/';
  
  console.log(`Starting load test: ${total} requests with ${concurrent} concurrent`);
  
  const results = [];
  const startTime = Date.now();
  
  // 배치로 요청 전송
  for (let i = 0; i < total; i += concurrent) {
    const batch = [];
    for (let j = 0; j < concurrent && i + j < total; j++) {
      batch.push(makeRequest(url));
    }
    
    const batchResults = await Promise.all(batch);
    results.push(...batchResults);
    
    if ((i + concurrent) % 50 === 0 || i + concurrent >= total) {
      const progress = ((i + concurrent) / total * 100).toFixed(1);
      console.log(`Progress: ${progress}% (${i + concurrent}/${total})`);
    }
  }
  
  const totalTime = Date.now() - startTime;
  
  // 결과 분석
  const durations = results.map(r => r.duration);
  const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
  const minDuration = Math.min(...durations);
  const maxDuration = Math.max(...durations);
  
  console.log('\n=== Test Results ===');
  console.log(`Total requests: ${total}`);
  console.log(`Total time: ${totalTime}ms`);
  console.log(`Requests/sec: ${(total / (totalTime / 1000)).toFixed(2)}`);
  console.log(`Avg response time: ${avgDuration.toFixed(2)}ms`);
  console.log(`Min response time: ${minDuration}ms`);
  console.log(`Max response time: ${maxDuration}ms`);
  console.log(`Success rate: ${(results.filter(r => r.statusCode === 200).length / total * 100).toFixed(1)}%`);
}

runLoadTest().catch(console.error);

5.3 테스트 결과

console.log 없음:

  • 평균 응답 시간: 0.5ms
  • 초당 처리량: ~2000 requests/sec

console.log 5회 (단순 문자열):

  • 평균 응답 시간: 2.3ms
  • 초당 처리량: ~435 requests/sec

console.log 10회 (단순 문자열):

  • 평균 응답 시간: 4.1ms
  • 초당 처리량: ~244 requests/sec

console.log 5회 (복잡한 객체 포함):

  • 평균 응답 시간: 8.7ms
  • 초당 처리량: ~115 requests/sec

결론: console.log 호출이 많을수록, 그리고 복잡한 객체를 로깅할수록 응답 시간이 크게 증가합니다.


6. 성능 병목 지점 분석

실제 측정 결과를 바탕으로 병목 지점을 정리하면:

6.1 포맷팅 (40-60%)

복잡한 객체의 문자열 변환이 가장 큰 오버헤드:

  • 재귀적 순회: 깊게 중첩된 객체를 순회하며 문자열 생성
  • 순환 참조 감지: WeakSet을 사용한 순환 참조 추적
  • 색상 코드 추가: TTY인 경우 ANSI 색상 코드 추가
  • 메모리 할당: 문자열 생성 과정에서 많은 메모리 할당

최적화 방안:

  • 객체를 로깅하기 전에 필요한 필드만 추출
  • JSON.stringify() 사용 (더 빠름)
  • 로깅 레벨 필터링

6.2 I/O 대기 (20-40%)

실제 쓰기 작업이 두 번째로 큰 오버헤드:

  • 동기 I/O (TTY): 터미널에 직접 출력할 때 블로킹
  • 비동기 I/O (파이프/파일): libuv 이벤트 루프를 통한 처리
  • 백프레셔: 버퍼가 가득 차면 쓰기 일시 중지

최적화 방안:

  • 비동기 로깅 (별도 워커 스레드)
  • 버퍼링 (여러 로그를 모아서 한 번에 출력)
  • 파일로 리다이렉트 (TTY보다 빠름)

6.3 문자열 조작 (5-10%)

인덴트, 개행 추가 등의 문자열 조작:

  • 그룹 인덴트 처리
  • 개행 문자 추가
  • 정규식 치환 (인덴트 적용 시)

6.4 에러 핸들링 (1-5%)

매 호출마다 에러 리스너 추가/제거:

  • stream.once('error', noop) 추가
  • stream.removeListener('error', noop) 제거

7. 최적화 방안

참고: 전문 로깅 라이브러리(Winston, Pino 등)에 대한 내용은 다음 글에서 다룹니다. 이 글에서는 console.log를 직접 최적화하는 방법에 집중합니다.

7.1 조건부 로깅

개발 환경에서만 로깅을 활성화:

const isDevelopment = process.env.NODE_ENV === 'development';

const log = isDevelopment ? console.log : () => {};

log('Debug information'); // 개발 환경에서만 출력

더 나은 방법:

const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL] || LOG_LEVELS.INFO;

const logger = {
  debug: (...args) => currentLevel <= LOG_LEVELS.DEBUG && console.log(...args),
  info: (...args) => currentLevel <= LOG_LEVELS.INFO && console.log(...args),
  warn: (...args) => currentLevel <= LOG_LEVELS.WARN && console.warn(...args),
  error: (...args) => currentLevel <= LOG_LEVELS.ERROR && console.error(...args)
};

logger.debug('This will not print in production'); // LOG_LEVEL=INFO
logger.info('This will print'); // LOG_LEVEL=INFO

7.2 비동기 로깅

별도 워커 스레드로 로깅 처리:

const { Worker } = require('worker_threads');
const fs = require('fs');

class AsyncLogger {
  constructor(logFile = 'app.log') {
    this.queue = [];
    this.worker = new Worker(`
      const fs = require('fs');
      const { parentPort } = require('worker_threads');
      
      parentPort.on('message', (msg) => {
        fs.appendFileSync('${logFile}', msg + '\\n');
      });
    `, { eval: true });
  }
  
  log(...args) {
    const message = args.map(arg => 
      typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
    ).join(' ');
    this.worker.postMessage(message);
  }
  
  close() {
    this.worker.terminate();
  }
}

const logger = new AsyncLogger();
logger.log('This is async'); // 메인 스레드 블로킹 없음

장점:

  • 메인 스레드 블로킹 없음
  • I/O 작업이 별도 스레드에서 처리
  • 높은 처리량

7.3 버퍼링

여러 로그를 모아서 한 번에 출력:

class BufferedLogger {
  constructor(flushInterval = 1000, maxBufferSize = 100) {
    this.buffer = [];
    this.flushInterval = flushInterval;
    this.maxBufferSize = maxBufferSize;
    
    setInterval(() => this.flush(), this.flushInterval);
  }
  
  log(...args) {
    this.buffer.push({
      timestamp: new Date().toISOString(),
      message: args.join(' ')
    });
    
    if (this.buffer.length >= this.maxBufferSize) {
      this.flush();
    }
  }
  
  flush() {
    if (this.buffer.length > 0) {
      const output = this.buffer.map(entry => 
        `[${entry.timestamp}] ${entry.message}`
      ).join('\n');
      
      console.log(output);
      this.buffer = [];
    }
  }
}

const logger = new BufferedLogger(1000, 50);
// 여러 로그가 버퍼에 쌓였다가 1초마다 또는 50개가 모이면 한 번에 출력

장점:

  • I/O 호출 횟수 감소
  • 배치 처리로 효율성 향상
  • 시스템 호출 오버헤드 감소

7.4 객체 최적화

로깅 전에 객체를 최적화:

// 나쁜 예
console.log('User data:', user); // 전체 객체 로깅

// 좋은 예
console.log('User data:', {
  id: user.id,
  name: user.name,
  email: user.email
}); // 필요한 필드만 로깅

// 또는 JSON.stringify 사용 (더 빠름)
console.log('User data:', JSON.stringify(user, null, 2));

7.5 프로덕션 환경 설정

프로덕션에서는 조건부 로깅을 활용하여 불필요한 로그를 제거합니다:

// config/logger.js
const isProduction = process.env.NODE_ENV === 'production';
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL] || LOG_LEVELS.INFO;

const logger = {
  debug: (...args) => !isProduction && currentLevel <= LOG_LEVELS.DEBUG && console.log(...args),
  info: (...args) => currentLevel <= LOG_LEVELS.INFO && console.log(...args),
  warn: (...args) => currentLevel <= LOG_LEVELS.WARN && console.warn(...args),
  error: (...args) => currentLevel <= LOG_LEVELS.ERROR && console.error(...args)
};

module.exports = logger;

8. 성능 비교

최적화 방안들의 성능을 비교해보겠습니다:

방법평균 응답 시간초당 처리량메모리 사용
console.log (10회)4.1ms244 req/s높음
조건부 로깅 (프로덕션)0.5ms2000 req/s낮음
비동기 로깅0.7ms1429 req/s중간
버퍼링 (1초)0.6ms1667 req/s중간
객체 최적화2.1ms476 req/s중간

결론: 프로덕션 환경에서는 조건부 로깅이나 비동기 로깅, 버퍼링을 사용하는 것이 훨씬 효율적입니다. 전문 로깅 라이브러리는 다음 글에서 다루겠습니다.


결론

console.log는 개발 중에는 편리하지만, 프로덕션 환경에서는 성능 문제를 일으킬 수 있습니다:

  1. 포맷팅 오버헤드: 복잡한 객체를 문자열로 변환하는 과정에서 많은 CPU와 메모리 사용
  2. I/O 블로킹: TTY 환경에서 동기 I/O로 인해 이벤트 루프가 블로킹될 수 있음
  3. 에러 핸들링 오버헤드: 매 호출마다 에러 리스너를 추가하고 제거하는 작업이 누적

최적화 권장사항:

  1. 조건부 로깅으로 개발 환경에서만 활성화
  2. 로그 레벨 필터링으로 불필요한 로그 제거
  3. 비동기 로깅으로 메인 스레드 블로킹 방지
  4. 버퍼링으로 I/O 호출 횟수 감소
  5. 객체 최적화로 포맷팅 오버헤드 감소

이러한 최적화를 통해 console.log로 인한 성능 저하를 크게 줄일 수 있습니다.

다음 글 예고: 전문 로깅 라이브러리(Winston, Pino 등)를 사용한 고급 최적화 방법과 실제 프로덕션 환경에서의 로깅 전략을 다음 글에서 다루겠습니다.


참고 자료

댓글

?