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과는 완전히 다른 접근 방식으로 로깅 성능을 극대화하는 방법을 알아보세요.




“Winston으로 Next.js 엔터프라이즈급 로깅 시스템 구축하기”에 대한 1개의 생각

댓글 남기기