Winston으로 Next.js 엔터프라이즈급 로깅 시스템 구축하기




지난 포스트에서 다양한 로깅 라이브러리들을 살펴봤는데, 그중에서도 Winston은 가장 성숙하고 기능이 풍부한 라이브러리입니다.

많은 개발자들이 Winston을 “복잡하다”고 생각하지만, 한 번 제대로 설정해두면 강력하고 유연한 로깅 시스템을 구축할 수 있습니다. 이 포스트에서는 Next.js 프로젝트에 Winston을 적용하는 실전 가이드를 제공하겠습니다.

Winston이란?

Winston은 2010년부터 개발되기 시작한 Node.js의 대표적인 로깅 라이브러리입니다. “A logger for just about everything”이라는 슬로건처럼, 거의 모든 로깅 요구사항을 만족시킬 수 있는 유연성을 제공합니다.

Winston의 핵심 철학

다양한 출력 방식 지원: 콘솔, 파일, 데이터베이스, HTTP 엔드포인트 등 어디든 로그를 전송할 수 있습니다.

로그 레벨 관리: error, warn, info, debug 등 상황에 맞는 로그 레벨을 제공합니다.

포맷팅 자유도: JSON, 일반 텍스트, 커스텀 포맷 등 원하는 형태로 로그를 출력할 수 있습니다.

설치 및 기본 설정

먼저 Winston을 Next.js 프로젝트에 설치해보겠습니다.

npm install winston
# 또는
yarn add winston

기본 로거 설정

lib/logger.js 파일을 생성하고 기본 Winston 로거를 설정해보겠습니다:

// lib/logger.js
import winston from 'winston';

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp({
      format: 'YYYY-MM-DD HH:mm:ss'
    }),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'nextjs-app' },
  transports: [
    // 에러 로그는 별도 파일에 저장
    new winston.transports.File({ 
      filename: 'logs/error.log', 
      level: 'error' 
    }),
    // 모든 로그는 combined.log에 저장
    new winston.transports.File({ 
      filename: 'logs/combined.log' 
    })
  ],
});

// 개발 환경에서는 콘솔에도 출력
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    )
  }));
}

export default logger;

로그 레벨과 출력 예시

Winston의 기본 로그 레벨은 다음과 같습니다 (우선순위 순):

// lib/logger.js에 추가
export const logExample = () => {
  logger.error('데이터베이스 연결 실패', { 
    error: 'Connection timeout',
    database: 'mongodb://localhost:27017' 
  });
  
  logger.warn('API 응답 시간이 느립니다', { 
    responseTime: 2500,
    endpoint: '/api/users' 
  });
  
  logger.info('사용자가 로그인했습니다', { 
    userId: 123,
    email: 'user@example.com',
    ip: '192.168.1.1'
  });
  
  logger.debug('디버그 정보', { 
    requestBody: { name: 'John', age: 30 },
    headers: { 'user-agent': 'Mozilla/5.0...' }
  });
};

콘솔 출력 예시:

2024-01-15 10:30:45 error: 데이터베이스 연결 실패 {"error":"Connection timeout","database":"mongodb://localhost:27017","service":"nextjs-app"}
2024-01-15 10:30:46 warn: API 응답 시간이 느립니다 {"responseTime":2500,"endpoint":"/api/users","service":"nextjs-app"}  
2024-01-15 10:30:47 info: 사용자가 로그인했습니다 {"userId":123,"email":"user@example.com","ip":"192.168.1.1","service":"nextjs-app"}

Next.js 특화 설정

환경별 로그 레벨 관리

// lib/logger.js 수정
const getLogLevel = () => {
  switch (process.env.NODE_ENV) {
    case 'production':
      return 'warn';
    case 'test':
      return 'error';
    default:
      return 'debug';
  }
};

const logger = winston.createLogger({
  level: getLogLevel(),
  // ... 나머지 설정
});

로그 디렉토리 자동 생성

// lib/logger.js에 추가
import fs from 'fs';
import path from 'path';

const logDir = 'logs';

// logs 디렉토리가 없으면 생성
if (!fs.existsSync(logDir)) {
  fs.mkdirSync(logDir);
}

실제 프로젝트 적용 예시

1. API 라우트 로깅

// pages/api/users/[id].js
import logger from '../../../lib/logger';

export default async function handler(req, res) {
  const { id } = req.query;
  const { method } = req;
  
  // 요청 로깅
  logger.info('API 요청 시작', {
    method,
    url: req.url,
    userId: id,
    userAgent: req.headers['user-agent'],
    ip: req.connection.remoteAddress
  });
  
  try {
    if (method === 'GET') {
      const user = await getUserById(id);
      
      if (!user) {
        logger.warn('존재하지 않는 사용자 요청', { userId: id });
        return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
      }
      
      logger.info('사용자 조회 성공', { 
        userId: id,
        responseTime: Date.now() - req.startTime 
      });
      
      res.status(200).json(user);
    }
  } catch (error) {
    logger.error('API 요청 처리 중 오류 발생', {
      method,
      url: req.url,
      userId: id,
      error: error.message,
      stack: error.stack
    });
    
    res.status(500).json({ error: '서버 내부 오류' });
  }
}

2. 미들웨어 로깅

// middleware.js
import { NextResponse } from 'next/server';
import logger from './lib/logger';

export function middleware(request) {
  const start = Date.now();
  
  // 요청 정보 로깅
  logger.info('요청 시작', {
    method: request.method,
    url: request.url,
    userAgent: request.headers.get('user-agent'),
    referer: request.headers.get('referer')
  });
  
  const response = NextResponse.next();
  
  // 응답 정보 로깅
  response.headers.set('x-response-time', `${Date.now() - start}ms`);
  
  logger.info('요청 완료', {
    method: request.method,
    url: request.url,
    status: response.status,
    responseTime: Date.now() - start
  });
  
  return response;
}

export const config = {
  matcher: '/api/:path*'
};

3. 에러 경계 로깅

// components/ErrorBoundary.js
import React from 'react';
import logger from '../lib/logger';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    logger.error('React 컴포넌트 에러 발생', {
      error: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
      props: this.props,
      url: typeof window !== 'undefined' ? window.location.href : 'SSR'
    });
  }

  render() {
    if (this.state.hasError) {
      return <h1>문제가 발생했습니다.</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

고급 기능 활용

1. 커스텀 포맷터 만들기

// lib/logger.js에 추가
const customFormat = winston.format.printf(({ level, message, timestamp, ...metadata }) => {
  let msg = `${timestamp} [${level.toUpperCase()}]: ${message}`;
  
  if (Object.keys(metadata).length > 0) {
    msg += ` | ${JSON.stringify(metadata)}`;
  }
  
  return msg;
});

// 로거 설정에서 사용
const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    customFormat
  ),
  // ... 나머지 설정
});

2. 로그 파일 로테이션

npm install winston-daily-rotate-file
// lib/logger.js 수정
import DailyRotateFile from 'winston-daily-rotate-file';

const logger = winston.createLogger({
  transports: [
    new DailyRotateFile({
      filename: 'logs/application-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '20m',
      maxFiles: '14d'
    }),
    new DailyRotateFile({
      filename: 'logs/error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      maxSize: '20m',
      maxFiles: '30d'
    })
  ]
});

3. 외부 서비스 연동

Slack으로 에러 알림 보내기:

npm install winston-slack-webhook-transport
// lib/logger.js에 추가
import SlackHook from 'winston-slack-webhook-transport';

if (process.env.NODE_ENV === 'production') {
  logger.add(new SlackHook({
    webhookUrl: process.env.SLACK_WEBHOOK_URL,
    level: 'error',
    formatter: (info) => {
      return {
        text: `🚨 프로덕션 에러 발생`,
        attachments: [{
          color: 'danger',
          fields: [{
            title: 'Error Message',
            value: info.message,
            short: false
          }, {
            title: 'Service',
            value: info.service,
            short: true
          }, {
            title: 'Timestamp',
            value: info.timestamp,
            short: true
          }]
        }]
      };
    }
  }));
}

성능 최적화 팁

1. 비동기 로깅 활용

// lib/logger.js 수정
const logger = winston.createLogger({
  // ... 기본 설정
  transports: [
    new winston.transports.File({
      filename: 'logs/combined.log',
      handleExceptions: true,
      handleRejections: true,
      maxsize: 5242880, // 5MB
      maxFiles: 3
    })
  ],
  exitOnError: false
});

2. 조건부 로깅

// 디버그 로그는 개발 환경에서만
if (process.env.NODE_ENV === 'development') {
  logger.debug('상세한 디버그 정보', { 
    largeObject: someComplexData 
  });
}

// 또는 로그 레벨 체크
if (logger.isDebugEnabled()) {
  logger.debug('디버그 정보', computeExpensiveData());
}

3. 메모리 사용량 모니터링

// lib/logger.js에 추가
export const logMemoryUsage = () => {
  const usage = process.memoryUsage();
  logger.info('메모리 사용량', {
    rss: `${Math.round(usage.rss / 1024 / 1024)} MB`,
    heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`,
    heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`,
    external: `${Math.round(usage.external / 1024 / 1024)} MB`
  });
};

// 주기적으로 메모리 사용량 로깅
setInterval(logMemoryUsage, 60000); // 1분마다

프로덕션 배포 고려사항

1. 환경변수 설정

// .env.production
LOG_LEVEL=warn
LOG_MAX_FILE_SIZE=50m
LOG_MAX_FILES=30
SLACK_WEBHOOK_URL=https://hooks.slack.com/...

2. 로그 수집 시스템 연동

ELK 스택(Elasticsearch, Logstash, Kibana)과의 연동:

// lib/logger.js에 추가
import { ElasticsearchTransport } from 'winston-elasticsearch';

if (process.env.NODE_ENV === 'production') {
  logger.add(new ElasticsearchTransport({
    clientOpts: {
      node: process.env.ELASTICSEARCH_URL,
      auth: {
        username: process.env.ELASTICSEARCH_USERNAME,
        password: process.env.ELASTICSEARCH_PASSWORD
      }
    },
    level: 'info'
  }));
}

3. 보안 고려사항

민감한 정보 필터링:

// lib/logger.js에 추가
const filterSensitiveData = winston.format((info) => {
  const sensitiveFields = ['password', 'token', 'apiKey', 'creditCard'];
  
  const filterObject = (obj) => {
    if (typeof obj !== 'object' || obj === null) return obj;
    
    const filtered = { ...obj };
    for (const key in filtered) {
      if (sensitiveFields.some(field => key.toLowerCase().includes(field))) {
        filtered[key] = '[REDACTED]';
      } else if (typeof filtered[key] === 'object') {
        filtered[key] = filterObject(filtered[key]);
      }
    }
    return filtered;
  };
  
  return filterObject(info);
});

const logger = winston.createLogger({
  format: winston.format.combine(
    filterSensitiveData(),
    winston.format.json()
  ),
  // ... 나머지 설정
});

Winston의 장단점 분석

✅ 장점

풍부한 기능: Transport, Format, Filter 등 로깅에 필요한 모든 기능 제공 높은 확장성: 플러그인 생태계를 통한 무한 확장 가능 안정성: 10년 이상의 검증된 라이브러리 커뮤니티: 활발한 커뮤니티와 풍부한 문서

❌ 단점

복잡한 설정: 초기 학습 곡선이 높음 상대적으로 무거움: 단순한 로깅에는 오버스펙일 수 있음 설정 파일 관리: 복잡한 설정으로 인한 관리 부담

다른 라이브러리와의 비교

Winston vs Pino: Winston은 기능의 풍부함, Pino는 성능에 초점 Winston vs Console.log: Winston은 구조화된 로깅과 다양한 출력 방식 지원 Winston vs Consola: Winston은 프로덕션 환경, Consola는 개발 편의성에 특화

실전 팁과 베스트 프랙티스

1. 로그 구조화

// 일관된 로그 구조 사용
logger.info('API 호출', {
  action: 'user.get',
  userId: 123,
  method: 'GET',
  endpoint: '/api/users/123',
  responseTime: 150,
  status: 'success'
});

2. 상관관계 ID 사용

// 요청별로 고유 ID 생성하여 추적
import { v4 as uuidv4 } from 'uuid';

export default function handler(req, res) {
  const correlationId = uuidv4();
  req.correlationId = correlationId;
  
  logger.info('요청 시작', { 
    correlationId,
    method: req.method,
    url: req.url 
  });
  
  // 모든 후속 로그에 correlationId 포함
}

3. 에러 스택 추적

try {
  // 비즈니스 로직
} catch (error) {
  logger.error('처리 중 오류 발생', {
    error: {
      message: error.message,
      stack: error.stack,
      name: error.name
    },
    context: {
      userId: req.user?.id,
      action: 'user.update'
    }
  });
}

마무리

Winston은 처음에는 복잡해 보일 수 있지만, 한 번 제대로 설정해두면 강력하고 안정적인 로깅 시스템을 구축할 수 있습니다. 특히 팀 단위 개발이나 프로덕션 환경에서는 Winston의 진가가 발휘됩니다.

다음 포스트에서는 고성능 JSON 로깅에 특화된 Pino를 다뤄보겠습니다. Winston과는 완전히 다른 접근 방식으로 로깅 성능을 극대화하는 방법을 알아보세요.




1 thought on “Winston으로 Next.js 엔터프라이즈급 로깅 시스템 구축하기”

Leave a Comment