Signale로 Next.js CLI 도구와 시각적 로깅 마스터하기




지난 Log4js 포스트에서 엔터프라이즈급 로깅을 다뤘다면, 이번에는 완전히 다른 관점의 Signale을 살펴보겠습니다.

Signale은 “터미널에서의 시각적 경험”에 특화된 로깅 라이브러리입니다. 화려한 아이콘과 색상, 브랜딩 커스터마이징을 통해 CLI 도구나 개발 스크립트에서 사용자 경험을 극대화하는 것이 주목적입니다. 단순한 로그 출력을 넘어서 “터미널 UI”의 영역까지 다루는 독특한 라이브러리죠.

Signale이란?

Signale은 2018년 Klaus Sinani에 의해 개발된 “Highly configurable logging utility”입니다. 다른 로깅 라이브러리들이 기능성에 집중한다면, Signale은 시각적 임팩트와 사용자 경험에 집중합니다.

특히 CLI 도구, 빌드 스크립트, 배포 도구 등에서 진행 상황을 직관적으로 보여주는 데 탁월합니다.

Signale의 핵심 철학

Visual First: 터미널에서의 시각적 경험을 최우선으로 고려 Status Indication: 단순한 로그를 넘어 작업 상태를 명확히 표시 Brand Consistency: 프로젝트나 회사의 브랜딩과 일관성 유지 Developer Joy: 개발자가 즐겁게 사용할 수 있는 경험 제공

설치 및 기본 사용법

npm install signale
# 또는
yarn add signale

즉시 사용해보기

// lib/logger.js
import { Signale } from 'signale';

const signale = new Signale();

// 기본 로그 타입들
signale.success('서버가 성공적으로 시작되었습니다');
signale.error('데이터베이스 연결에 실패했습니다');
signale.warn('메모리 사용량이 높습니다');
signale.info('새로운 사용자가 등록되었습니다');
signale.note('환경 설정을 확인해주세요');
signale.pending('데이터를 처리하는 중...');
signale.complete('모든 작업이 완료되었습니다');

export default signale;

아름다운 출력 결과

✔  success   서버가 성공적으로 시작되었습니다
✖  error     데이터베이스 연결에 실패했습니다
⚠  warning   메모리 사용량이 높습니다
ℹ  info      새로운 사용자가 등록되었습니다
✎  note      환경 설정을 확인해주세요
◐  pending   데이터를 처리하는 중...
☑  complete  모든 작업이 완료되었습니다

각 로그 타입마다 고유한 아이콘과 색상이 자동으로 적용되어 한눈에 상황을 파악할 수 있습니다.

Next.js 프로젝트에서의 활용

1. 개발 서버 시작 스크립트

// scripts/dev-server.js
import { Signale } from 'signale';

const devLogger = new Signale({
  scope: 'dev-server'
});

export async function startDevServer() {
  devLogger.pending('개발 서버를 시작하는 중...');
  
  try {
    // 환경 변수 검증
    devLogger.info('환경 변수 검증 중');
    await validateEnvironment();
    devLogger.success('환경 변수 검증 완료');
    
    // 포트 확인
    devLogger.info('포트 사용 가능 여부 확인');
    const port = await checkPortAvailability(3000);
    devLogger.success(`포트 ${port} 사용 가능`);
    
    // 의존성 확인
    devLogger.info('의존성 확인 중');
    await checkDependencies();
    devLogger.success('모든 의존성 확인 완료');
    
    // 서버 시작
    devLogger.pending('Next.js 개발 서버 시작 중...');
    await startNextServer(port);
    
    devLogger.complete('개발 서버가 성공적으로 시작되었습니다!');
    
    // 접속 정보 표시
    devLogger.info(`로컬: http://localhost:${port}`);
    devLogger.info(`네트워크: http://192.168.1.100:${port}`);
    devLogger.note('Hot reload가 활성화되었습니다');
    
  } catch (error) {
    devLogger.error('개발 서버 시작 실패');
    devLogger.fatal(error.message);
    process.exit(1);
  }
}

async function validateEnvironment() {
  const requiredVars = ['DATABASE_URL', 'JWT_SECRET', 'NEXT_PUBLIC_API_URL'];
  const missing = requiredVars.filter(varName => !process.env[varName]);
  
  if (missing.length > 0) {
    throw new Error(`필수 환경 변수가 없습니다: ${missing.join(', ')}`);
  }
}

2. 빌드 프로세스 로깅

// scripts/build-logger.js
import { Signale } from 'signale';

export class BuildLogger {
  constructor() {
    this.signale = new Signale({
      scope: 'build',
      types: {
        // 커스텀 로그 타입 정의
        compile: {
          badge: '🔨',
          color: 'yellow',
          label: 'compile'
        },
        optimize: {
          badge: '⚡',
          color: 'cyan',
          label: 'optimize'
        },
        bundle: {
          badge: '📦',
          color: 'magenta',
          label: 'bundle'
        },
        deploy: {
          badge: '🚀',
          color: 'green',
          label: 'deploy'
        }
      }
    });
    
    this.startTime = Date.now();
    this.steps = [];
  }
  
  startBuild() {
    this.signale.star('빌드 프로세스 시작');
    this.signale.info(`Node.js 버전: ${process.version}`);
    this.signale.info(`환경: ${process.env.NODE_ENV || 'development'}`);
    this.signale.info(`시작 시간: ${new Date().toLocaleString()}`);
  }
  
  compileStart() {
    this.signale.compile('TypeScript 컴파일 시작...');
    this.currentStep = { name: 'compile', start: Date.now() };
  }
  
  compileComplete() {
    const duration = Date.now() - this.currentStep.start;
    this.steps.push({ ...this.currentStep, duration });
    this.signale.success(`TypeScript 컴파일 완료 (${duration}ms)`);
  }
  
  bundleStart() {
    this.signale.bundle('웹팩 번들링 시작...');
    this.currentStep = { name: 'bundle', start: Date.now() };
  }
  
  bundleComplete(stats) {
    const duration = Date.now() - this.currentStep.start;
    this.steps.push({ ...this.currentStep, duration });
    
    this.signale.success(`번들링 완료 (${duration}ms)`);
    this.signale.info(`번들 크기: ${formatBytes(stats.size)}`);
    this.signale.info(`청크 수: ${stats.chunks}`);
  }
  
  optimizeStart() {
    this.signale.optimize('코드 최적화 시작...');
    this.currentStep = { name: 'optimize', start: Date.now() };
  }
  
  optimizeComplete(before, after) {
    const duration = Date.now() - this.currentStep.start;
    this.steps.push({ ...this.currentStep, duration });
    
    const reduction = ((before - after) / before * 100).toFixed(1);
    this.signale.success(`최적화 완료 (${duration}ms)`);
    this.signale.info(`크기 감소: ${formatBytes(before)} → ${formatBytes(after)} (${reduction}%)`);
  }
  
  buildComplete() {
    const totalDuration = Date.now() - this.startTime;
    
    this.signale.complete(`빌드 완료! (${totalDuration}ms)`);
    
    // 단계별 소요시간 표시
    this.signale.info('단계별 소요시간:');
    this.steps.forEach(step => {
      this.signale.info(`  ${step.name}: ${step.duration}ms`);
    });
  }
  
  buildError(error) {
    this.signale.error('빌드 실패!');
    this.signale.fatal(error.message);
    
    if (error.stack) {
      this.signale.debug('스택 추적:');
      console.log(error.stack);
    }
  }
}

function formatBytes(bytes) {
  if (bytes === 0) return '0 Bytes';
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// next.config.js에서 사용
export default {
  webpack: (config, { buildId, dev, isServer }) => {
    if (!dev) {
      const buildLogger = new BuildLogger();
      
      buildLogger.startBuild();
      buildLogger.compileStart();
      
      // 웹팩 플러그인으로 빌드 단계 추적
      config.plugins.push(
        new (class BuildLoggerPlugin {
          apply(compiler) {
            compiler.hooks.compile.tap('BuildLogger', () => {
              buildLogger.bundleStart();
            });
            
            compiler.hooks.done.tap('BuildLogger', (stats) => {
              buildLogger.bundleComplete({
                size: stats.compilation.assets['main.js']?.size() || 0,
                chunks: Object.keys(stats.compilation.chunks).length
              });
              
              buildLogger.buildComplete();
            });
            
            compiler.hooks.failed.tap('BuildLogger', (error) => {
              buildLogger.buildError(error);
            });
          }
        })()
      );
    }
    
    return config;
  }
};

3. 배포 스크립트 로깅

// scripts/deploy.js
import { Signale } from 'signale';

const deployLogger = new Signale({
  scope: 'deploy',
  types: {
    backup: {
      badge: '💾',
      color: 'blue',
      label: 'backup'
    },
    upload: {
      badge: '⬆️',
      color: 'cyan',
      label: 'upload'
    },
    health: {
      badge: '🏥',
      color: 'green',
      label: 'health'
    },
    rollback: {
      badge: '↩️',
      color: 'red',
      label: 'rollback'
    }
  }
});

export async function deployToProduction() {
  deployLogger.star('프로덕션 배포 시작');
  
  try {
    // 1. 사전 검증
    deployLogger.pending('배포 사전 검증 중...');
    await preDeploymentChecks();
    deployLogger.success('사전 검증 완료');
    
    // 2. 백업 생성
    deployLogger.backup('현재 버전 백업 생성 중...');
    const backupId = await createBackup();
    deployLogger.success(`백업 생성 완료: ${backupId}`);
    
    // 3. 애플리케이션 빌드
    deployLogger.pending('애플리케이션 빌드 중...');
    await buildApplication();
    deployLogger.success('빌드 완료');
    
    // 4. 파일 업로드
    deployLogger.upload('파일 업로드 중...');
    await uploadFiles();
    deployLogger.success('업로드 완료');
    
    // 5. 서비스 재시작
    deployLogger.pending('서비스 재시작 중...');
    await restartService();
    deployLogger.success('서비스 재시작 완료');
    
    // 6. 헬스 체크
    deployLogger.health('헬스 체크 진행 중...');
    const healthResult = await performHealthCheck();
    
    if (healthResult.success) {
      deployLogger.success('헬스 체크 통과');
      deployLogger.complete('🎉 배포가 성공적으로 완료되었습니다!');
      
      // 배포 정보 표시
      deployLogger.info(`버전: ${process.env.APP_VERSION}`);
      deployLogger.info(`배포 시간: ${new Date().toLocaleString()}`);
      deployLogger.info(`URL: https://myapp.com`);
      
    } else {
      throw new Error('헬스 체크 실패');
    }
    
  } catch (error) {
    deployLogger.error('배포 실패!');
    deployLogger.fatal(error.message);
    
    // 롤백 진행
    deployLogger.rollback('롤백 진행 중...');
    await rollbackDeployment(backupId);
    deployLogger.success('롤백 완료');
    
    process.exit(1);
  }
}

async function preDeploymentChecks() {
  const checks = [
    { name: '환경 변수', check: () => checkEnvironmentVariables() },
    { name: '데이터베이스 연결', check: () => checkDatabaseConnection() },
    { name: '외부 API', check: () => checkExternalAPIs() },
    { name: '디스크 공간', check: () => checkDiskSpace() }
  ];
  
  for (const { name, check } of checks) {
    deployLogger.pending(`${name} 확인 중...`);
    
    try {
      await check();
      deployLogger.success(`${name} 확인 완료`);
    } catch (error) {
      deployLogger.error(`${name} 확인 실패: ${error.message}`);
      throw error;
    }
  }
}

4. 테스트 실행 로깅

// scripts/test-runner.js
import { Signale } from 'signale';

export class TestRunner {
  constructor() {
    this.signale = new Signale({
      scope: 'test',
      types: {
        suite: {
          badge: '📝',
          color: 'blue',  
          label: 'suite'
        },
        pass: {
          badge: '✅',
          color: 'green',
          label: 'pass'
        },
        fail: {
          badge: '❌',
          color: 'red',
          label: 'fail'
        },
        skip: {
          badge: '⏭️',
          color: 'yellow',
          label: 'skip'
        },
        coverage: {
          badge: '📊',
          color: 'magenta',
          label: 'coverage'
        }
      }
    });
    
    this.stats = {
      total: 0,
      passed: 0,
      failed: 0,
      skipped: 0,
      suites: []
    };
  }
  
  startTesting() {
    this.signale.star('테스트 실행 시작');
    this.signale.info(`Node.js 버전: ${process.version}`);
    this.signale.info(`테스트 환경: ${process.env.NODE_ENV || 'test'}`);
    this.startTime = Date.now();
  }
  
  startSuite(suiteName) {
    this.signale.suite(`테스트 스위트: ${suiteName}`);
    this.currentSuite = {
      name: suiteName,
      startTime: Date.now(),
      tests: []
    };
  }
  
  testPass(testName, duration) {
    this.signale.pass(`${testName} (${duration}ms)`);
    this.stats.passed++;
    this.stats.total++;
    this.currentSuite.tests.push({ name: testName, status: 'pass', duration });
  }
  
  testFail(testName, error, duration) {
    this.signale.fail(`${testName} (${duration}ms)`);
    this.signale.error(`  ${error.message}`);
    this.stats.failed++;
    this.stats.total++;
    this.currentSuite.tests.push({ 
      name: testName, 
      status: 'fail', 
      duration, 
      error: error.message 
    });
  }
  
  testSkip(testName, reason) {
    this.signale.skip(`${testName} - ${reason}`);
    this.stats.skipped++;
    this.stats.total++;
    this.currentSuite.tests.push({ name: testName, status: 'skip', reason });
  }
  
  endSuite() {
    const duration = Date.now() - this.currentSuite.startTime;
    this.currentSuite.duration = duration;
    this.stats.suites.push(this.currentSuite);
    
    const passed = this.currentSuite.tests.filter(t => t.status === 'pass').length;
    const failed = this.currentSuite.tests.filter(t => t.status === 'fail').length;
    
    if (failed === 0) {
      this.signale.success(`${this.currentSuite.name} 완료 (${passed}/${passed + failed})`);
    } else {
      this.signale.error(`${this.currentSuite.name} 완료 (${passed}/${passed + failed})`);
    }
  }
  
  showCoverage(coverageData) {
    this.signale.coverage('코드 커버리지 결과:');
    
    Object.entries(coverageData).forEach(([type, percentage]) => {
      const color = percentage >= 80 ? 'green' : percentage >= 60 ? 'yellow' : 'red';
      this.signale.info(`  ${type}: ${percentage}%`);
    });
  }
  
  endTesting() {
    const totalDuration = Date.now() - this.startTime;
    
    if (this.stats.failed === 0) {
      this.signale.complete(`모든 테스트 통과! (${totalDuration}ms)`);
    } else {
      this.signale.error(`${this.stats.failed}개 테스트 실패 (${totalDuration}ms)`);
    }
    
    // 테스트 요약 표시
    this.signale.info('테스트 결과 요약:');
    this.signale.info(`  총 테스트: ${this.stats.total}개`);
    this.signale.info(`  통과: ${this.stats.passed}개`);
    this.signale.info(`  실패: ${this.stats.failed}개`);
    this.signale.info(`  건너뜀: ${this.stats.skipped}개`);
    this.signale.info(`  성공률: ${Math.round((this.stats.passed / this.stats.total) * 100)}%`);
    
    // 가장 느린 테스트들
    const slowTests = this.getAllTests()
      .filter(test => test.duration)
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 5);
    
    if (slowTests.length > 0) {
      this.signale.warn('가장 느린 테스트들:');
      slowTests.forEach(test => {
        this.signale.info(`  ${test.name}: ${test.duration}ms`);
      });
    }
  }
  
  getAllTests() {
    return this.stats.suites.flatMap(suite => 
      suite.tests.map(test => ({ ...test, suite: suite.name }))
    );
  }
}

// Jest와 연동
export function createJestReporter() {
  const runner = new TestRunner();
  
  return class SignaleReporter {
    onRunStart() {
      runner.startTesting();
    }
    
    onTestSuiteStart(test) {
      runner.startSuite(test.path);
    }
    
    onTestCaseResult(test, testCaseResult) {
      const duration = testCaseResult.duration || 0;
      
      if (testCaseResult.status === 'passed') {
        runner.testPass(testCaseResult.title, duration);
      } else if (testCaseResult.status === 'failed') {
        const error = testCaseResult.failureMessages[0] || 'Unknown error';
        runner.testFail(testCaseResult.title, { message: error }, duration);
      } else if (testCaseResult.status === 'pending') {
        runner.testSkip(testCaseResult.title, 'Pending');
      }
    }
    
    onTestSuiteResult() {
      runner.endSuite();
    }
    
    onRunComplete(contexts, results) {
      if (results.coverageMap) {
        const coverage = results.coverageMap.getCoverageSummary();
        runner.showCoverage({
          '라인': coverage.lines.pct,
          '함수': coverage.functions.pct,
          '브랜치': coverage.branches.pct,
          '구문': coverage.statements.pct
        });
      }
      
      runner.endTesting();
    }
  };
}

커스터마이징과 브랜딩

1. 회사 브랜딩 적용

// lib/branded-logger.js
import { Signale } from 'signale';

export const companyLogger = new Signale({
  // 회사 로고나 이름을 스코프로 사용
  scope: 'MyCompany',
  
  // 브랜드 컬러 적용
  types: {
    info: {
      badge: 'ℹ️',
      color: 'blue',  // 회사 브랜드 컬러
      label: 'info'
    },
    success: {
      badge: '🎉',
      color: 'green',
      label: 'success'
    },
    warning: {
      badge: '⚠️',
      color: 'orange', // 회사 서브 컬러
      label: 'warning'
    },
    error: {
      badge: '🚨',
      color: 'red',
      label: 'error'
    },
    // 회사 특화 로그 타입
    deployment: {
      badge: '🚀',
      color: 'magenta',
      label: 'deploy'
    },
    security: {
      badge: '🔒',
      color: 'yellow',
      label: 'security'
    },
    performance: {
      badge: '⚡',
      color: 'cyan',
      label: 'perf'
    }
  }
});

// 팀별 로거 생성
export const createTeamLogger = (teamName, teamColor) => {
  return new Signale({
    scope: `${teamName}-team`,
    types: {
      info: {
        badge: 'ℹ️',
        color: teamColor,
        label: 'info'
      },
      task: {
        badge: '📋',
        color: teamColor,
        label: 'task'
      },
      done: {
        badge: '✅',
        color: 'green',
        label: 'done'
      }
    }
  });
};

// 사용 예시
const frontendLogger = createTeamLogger('Frontend', 'blue');
const backendLogger = createTeamLogger('Backend', 'green');
const devopsLogger = createTeamLogger('DevOps', 'cyan');

frontendLogger.info('컴포넌트 개발 시작');
backendLogger.task('API 엔드포인트 구현');
devopsLogger.done('CI/CD 파이프라인 구축 완료');

2. 진행률 표시기

// lib/progress-logger.js
import { Signale } from 'signale';

export class ProgressLogger {
  constructor(total, title = '진행 중') {
    this.signale = new Signale({
      types: {
        progress: {
          badge: '⏳',
          color: 'blue',
          label: 'progress'
        },
        step: {
          badge: '👣',
          color: 'gray',
          label: 'step'
        }
      }
    });
    
    this.total = total;
    this.current = 0;
    this.title = title;
    this.startTime = Date.now();
  }
  
  increment(stepName, amount = 1) {
    this.current = Math.min(this.current + amount, this.total);
    
    const percentage = Math.round((this.current / this.total) * 100);
    const progressBar = this.createProgressBar(percentage);
    const elapsed = Date.now() - this.startTime;
    const eta = this.current > 0 ? (elapsed / this.current * (this.total - this.current)) : 0;
    
    this.signale.step(stepName);
    this.signale.progress(
      `${this.title}: ${progressBar} ${percentage}% (${this.current}/${this.total}) ETA: ${Math.round(eta/1000)}s`
    );
    
    if (this.current === this.total) {
      this.complete();
    }
  }
  
  createProgressBar(percentage, width = 20) {
    const filled = Math.round(width * percentage / 100);
    const empty = width - filled;
    return '█'.repeat(filled) + '░'.repeat(empty);
  }
  
  complete() {
    const duration = Date.now() - this.startTime;
    this.signale.success(`${this.title} 완료! (${duration}ms)`);
  }
}

// 사용 예시
const fileProcessor = new ProgressLogger(100, '파일 처리');

for (let i = 0; i < 100; i++) {
  await processFile(files[i]);
  fileProcessor.increment(`파일 ${i + 1} 처리 완료`);
  await sleep(100); // 시뮬레이션
}

3. 인터랙티브 로깅

// lib/interactive-logger.js
import { Signale } from 'signale';
import readline from 'readline';

export class InteractiveLogger {
  constructor() {
    this.signale = new Signale({
      types: {
        question: {
          badge: '❓',
          color: 'blue',
          label: 'question'
        },
        choice: {
          badge: '🤔',
          color: 'yellow',
          label: 'choice'
        },
        answer: {
          badge: '💬',
          color: 'green',
          label: 'answer'
        }
      }
    });
    
    this.rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout
    });
  }
  
  async askQuestion(question, defaultAnswer = '') {
    this.signale.question(question);
    
    return new Promise((resolve) => {
      this.rl.question(`기본값: "${defaultAnswer}" > `, (answer) => {
        const result = answer.trim() || defaultAnswer;
        this.signale.answer(`선택: ${result}`);
        resolve(result);
      });
    });
  }
  
  async askConfirmation(message) {
    this.signale.choice(`${message} (y/N)`);
    
    return new Promise((resolve) => {
      this.rl.question('> ', (answer) => {
        const confirmed = answer.toLowerCase().startsWith('y');
        this.signale.answer(confirmed ? '확인됨' : '취소됨');
        resolve(confirmed);
      });
    });
  }
  
  async selectFromList(message, options) {
    this.signale.choice(message);
    
    options.forEach((option, index) => {
      console.log(`  ${index + 1}. ${option}`);
    });
    
    return new Promise((resolve) => {
      this.rl.question('선택 번호 > ', (answer) => {
        const index = parseInt(answer) - 1;
        
        if (index >= 0 && index < options.length) {
          this.signale.answer(`선택: ${options[index]}`);
          resolve(options[index]);
        } else {
          this.signale.error('잘못된 선택입니다');
          resolve(this.selectFromList(message, options));
        }
      });
    });
  }
  
  close() {
    this.rl.close();
  }
}

// 사용 예시 - 배포 환경 선택
export async function selectDeploymentEnvironment() {
  const interactive = new InteractiveLogger();
  
  try {
    const environment = await interactive.selectFromList(
      '배포할 환경을 선택하세요:',
      ['development', 'staging', 'production']
    );
    
    const confirmed = await interactive.askConfirmation(
      `정말로 ${environment} 환경에 배포하시겠습니까?`
    );
    
    if (confirmed) {
      const version = await interactive.askQuestion(
        '배포할 버전을 입력하세요:',
        'latest'
      );
      
      return { environment, version };
    } else {
      throw new Error('배포가 취소되었습니다');
    }
  } finally {
    interactive.close();
  }
}

실제 CLI 도구 구축

1. Next.js 프로젝트 스캐폴딩 도구

// bin/create-nextjs-project.js
#!/usr/bin/env node

import { Signale } from 'signale';
import fs from 'fs-extra';
import path from 'path';
import { execSync } from 'child_process';

const cli = new Signale({
  scope: 'create-nextjs',
  types: {
    create: {
      badge: '📁',
      color: 'blue',
      label: 'create'
    },
    install: {
      badge: '📦',
      color: 'yellow',
      label: 'install'
    },
    config: {
      badge: '⚙️',
      color: 'cyan',
      label: 'config'
    },
    git: {
      badge: '🔗',
      color: 'magenta',
      label: 'git'
    }
  }
});

export async function createNextjsProject(projectName, options = {}) {
  cli.star(`새로운 Next.js 프로젝트 '${projectName}' 생성 시작`);
  
  try {
    // 1. 프로젝트 디렉토리 생성
    cli.create('프로젝트 디렉토리 생성');
    const projectPath = path.join(process.cwd(), projectName);
    
    if (fs.existsSync(projectPath)) {
      throw new Error(`디렉토리 '${projectName}'이 이미 존재합니다`);
    }
    
    fs.mkdirSync(projectPath);
    process.chdir(projectPath);
    cli.success('프로젝트 디렉토리 생성 완료');
    
    // 2. package.json 생성
    cli.config('package.json 생성');
    const packageJson = {
      name: projectName,
      version: '0.1.0',
      private: true,
      scripts: {
        dev: 'next dev',
        build: 'next build',
        start: 'next start',
        lint: 'next lint',
        test: 'jest',
        'test:watch': 'jest --watch'
      },
      dependencies: {
        'next': '^14.0.0',
        'react': '^18.0.0',
        'react-dom': '^18.0.0'
      },
      devDependencies: {
        '@types/react': '^18.0.0',
        '@types/node': '^20.0.0',
        'typescript': '^5.0.0',
        'eslint': '^8.0.0',
        'eslint-config-next': '^14.0.0',
        'jest': '^29.0.0',
        '@testing-library/react': '^13.0.0'
      }
    };
    
    fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2));
    cli.success('package.json 생성 완료');
    
    // 3. 기본 파일 구조 생성
    cli.create('기본 파일 구조 생성');
    
    const directories = [
      'pages/api',
      'components',
      'lib',
      'styles',
      'public',
      '__tests__'
    ];
    
    directories.forEach(dir => {
      fs.mkdirSync(dir, { recursive: true });
      cli.info(`디렉토리 생성: ${dir}`);
    });
    
    // 4. 기본 파일들 생성
    const files = {
      'pages/_app.tsx': createAppFile(),
      'pages/index.tsx': createIndexFile(projectName),
      'pages/api/hello.ts': createApiFile(),
      'components/Layout.tsx': createLayoutFile(),
      'lib/utils.ts': createUtilsFile(),
      'styles/globals.css': createGlobalStyles(),
      'next.config.js': createNextConfig(),
      'tsconfig.json': createTsConfig(),
      '.eslintrc.json': createEslintConfig(),
      'jest.config.js': createJestConfig(),
      'README.md': createReadme(projectName),
      '.gitignore': createGitignore()
    };
    
    Object.entries(files).forEach(([filename, content]) => {
      fs.writeFileSync(filename, content);
      cli.success(`파일 생성: ${filename}`);
    });
    
    // 5. 의존성 설치
    if (!options.skipInstall) {
      cli.install('의존성 설치 중... (시간이 걸릴 수 있습니다)');
      
      try {
        execSync('npm install', { stdio: 'inherit' });
        cli.success('의존성 설치 완료');
      } catch (error) {
        cli.warn('의존성 설치 실패, 수동으로 npm install을 실행하세요');
      }
    }
    
    // 6. Git 초기화
    if (!options.skipGit) {
      cli.git('Git 저장소 초기화');
      
      try {
        execSync('git init', { stdio: 'pipe' });
        execSync('git add .', { stdio: 'pipe' });
        execSync('git commit -m "Initial commit"', { stdio: 'pipe' });
        cli.success('Git 저장소 초기화 완료');
      } catch (error) {
        cli.warn('Git 초기화 실패, 수동으로 설정하세요');
      }
    }
    
    // 7. 완료 메시지
    cli.complete(`🎉 프로젝트 '${projectName}' 생성 완료!`);
    
    cli.info('다음 단계:');
    cli.info(`  cd ${projectName}`);
    if (options.skipInstall) {
      cli.info('  npm install');
    }
    cli.info('  npm run dev');
    
    cli.note('개발 서버가 http://localhost:3000에서 시작됩니다');
    
  } catch (error) {
    cli.error('프로젝트 생성 실패!');
    cli.fatal(error.message);
    process.exit(1);
  }
}

// 파일 내용 생성 함수들
function createAppFile() {
  return `import type { AppProps } from 'next/app'
import '../styles/globals.css'

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}`;
}

function createIndexFile(projectName) {
  return `import Head from 'next/head'
import Layout from '../components/Layout'

export default function Home() {
  return (
    <Layout>
      <Head>
        <title>${projectName}</title>
        <meta name="description" content="Generated by create-nextjs-project" />
      </Head>
      
      <main>
        <h1>Welcome to ${projectName}!</h1>
        <p>Your Next.js project is ready to go.</p>
      </main>
    </Layout>
  )
}`;
}

// ... 다른 파일 생성 함수들

// CLI 실행부
if (import.meta.url === `file://${process.argv[1]}`) {
  const projectName = process.argv[2];
  
  if (!projectName) {
    console.log('사용법: create-nextjs-project <project-name>');
    process.exit(1);
  }
  
  createNextjsProject(projectName);
}

2. 데이터베이스 마이그레이션 도구

// bin/db-migrate.js
import { Signale } from 'signale';
import fs from 'fs';
import path from 'path';

const migrator = new Signale({
  scope: 'db-migrate',
  types: {
    migration: {
      badge: '📊',
      color: 'blue',
      label: 'migration'
    },
    rollback: {
      badge: '↩️',
      color: 'red',
      label: 'rollback'
    },
    schema: {
      badge: '🗄️',
      color: 'cyan',
      label: 'schema'
    }
  }
});

export class DatabaseMigrator {
  constructor(dbConnection) {
    this.db = dbConnection;
    this.migrationsPath = './migrations';
  }
  
  async runMigrations() {
    migrator.star('데이터베이스 마이그레이션 시작');
    
    try {
      // 마이그레이션 테이블 생성
      await this.ensureMigrationsTable();
      
      // 실행된 마이그레이션 조회
      const executedMigrations = await this.getExecutedMigrations();
      
      // 대기 중인 마이그레이션 파일 조회
      const migrationFiles = this.getMigrationFiles();
      const pendingMigrations = migrationFiles.filter(
        file => !executedMigrations.includes(file)
      );
      
      if (pendingMigrations.length === 0) {
        migrator.success('실행할 마이그레이션이 없습니다');
        return;
      }
      
      migrator.info(`${pendingMigrations.length}개의 마이그레이션을 실행합니다`);
      
      // 마이그레이션 실행
      for (const migration of pendingMigrations) {
        migrator.migration(`실행 중: ${migration}`);
        
        const startTime = Date.now();
        await this.executeMigration(migration);
        const duration = Date.now() - startTime;
        
        migrator.success(`완료: ${migration} (${duration}ms)`);
      }
      
      migrator.complete('모든 마이그레이션이 성공적으로 완료되었습니다');
      
    } catch (error) {
      migrator.error('마이그레이션 실패!');
      migrator.fatal(error.message);
      throw error;
    }
  }
  
  async rollbackMigration(steps = 1) {
    migrator.warn(`최근 ${steps}개 마이그레이션을 롤백합니다`);
    
    try {
      const executedMigrations = await this.getExecutedMigrations();
      const toRollback = executedMigrations.slice(-steps);
      
      for (const migration of toRollback.reverse()) {
        migrator.rollback(`롤백 중: ${migration}`);
        
        await this.rollbackSingleMigration(migration);
        
        migrator.success(`롤백 완료: ${migration}`);
      }
      
      migrator.complete('롤백이 성공적으로 완료되었습니다');
      
    } catch (error) {
      migrator.error('롤백 실패!');
      migrator.fatal(error.message);
      throw error;
    }
  }
  
  async generateMigration(name) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const filename = `${timestamp}_${name}.sql`;
    const filepath = path.join(this.migrationsPath, filename);
    
    const template = `-- Migration: ${name}
-- Created: ${new Date().toISOString()}

-- Up migration
CREATE TABLE IF NOT EXISTS example (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Down migration (for rollback)
-- DROP TABLE IF EXISTS example;
`;
    
    fs.writeFileSync(filepath, template);
    
    migrator.success(`마이그레이션 파일 생성: ${filename}`);
    migrator.note(`파일 위치: ${filepath}`);
  }
  
  async showStatus() {
    migrator.schema('데이터베이스 마이그레이션 상태');
    
    const executedMigrations = await this.getExecutedMigrations();
    const allMigrations = this.getMigrationFiles();
    
    allMigrations.forEach(migration => {
      if (executedMigrations.includes(migration)) {
        migrator.success(`✅ ${migration}`);
      } else {
        migrator.warn(`⏳ ${migration} (대기 중)`);
      }
    });
    
    migrator.info(`총 마이그레이션: ${allMigrations.length}개`);
    migrator.info(`실행됨: ${executedMigrations.length}개`);
    migrator.info(`대기 중: ${allMigrations.length - executedMigrations.length}개`);
  }
}

성능과 사용성 최적화

1. 로그 레벨 필터링

// lib/filtered-signale.js
import { Signale } from 'signale';

export class FilteredSignale extends Signale {
  constructor(options = {}) {
    super(options);
    this.logLevel = process.env.LOG_LEVEL || 'info';
    this.levelPriority = {
      debug: 0,
      info: 1,
      warn: 2,
      error: 3,
      fatal: 4
    };
  }
  
  shouldLog(level) {
    const currentPriority = this.levelPriority[this.logLevel] || 1;
    const messagePriority = this.levelPriority[level] || 1;
    return messagePriority >= currentPriority;
  }
  
  // 레벨별 메서드 오버라이드
  debug(...args) {
    if (this.shouldLog('debug')) {
      super.debug(...args);
    }
  }
  
  info(...args) {
    if (this.shouldLog('info')) {
      super.info(...args);
    }
  }
  
  warn(...args) {
    if (this.shouldLog('warn')) {
      super.warn(...args);
    }
  }
  
  error(...args) {
    if (this.shouldLog('error')) {
      super.error(...args);
    }
  }
}

2. 출력 최적화

// lib/optimized-signale.js
import { Signale } from 'signale';

export class OptimizedSignale extends Signale {
  constructor(options = {}) {
    super({
      ...options,
      // 불필요한 출력 비활성화
      disabled: process.env.NODE_ENV === 'production' && !process.env.DEBUG,
      
      // 스트림 최적화
      stream: process.env.NODE_ENV === 'test' ? 
        require('stream').Writable({ write() {} }) : // 테스트 환경에서는 출력 없음
        process.stdout
    });
  }
  
  // 조건부 로깅 메서드 추가
  devOnly(...args) {
    if (process.env.NODE_ENV === 'development') {
      this.info(...args);
    }
  }
  
  prodOnly(...args) {
    if (process.env.NODE_ENV === 'production') {
      this.info(...args);
    }
  }
  
  // 그룹 로깅
  group(title, callback) {
    this.start(title);
    const result = callback();
    this.complete(`${title} 완료`);
    return result;
  }
  
  async asyncGroup(title, asyncCallback) {
    this.pending(title);
    try {
      const result = await asyncCallback();
      this.success(`${title} 완료`);
      return result;
    } catch (error) {
      this.error(`${title} 실패: ${error.message}`);
      throw error;
    }
  }
}

Signale vs 다른 라이브러리

특징 비교표

특성SignaleWinstonPinoConsola
시각적 출력최고기본적좋음*매우 좋음
CLI 특화최고제한적제한적좋음
커스터마이징높음매우 높음보통보통
성능보통좋음최고좋음
프로덕션 적합성제한적최고최고보통
학습 곡선쉬움어려움보통쉬움
브랜딩 지원최고제한적제한적제한적

언제 Signale을 선택해야 할까?

✅ Signale 추천 상황

  • CLI 도구 개발
  • 빌드/배포 스크립트
  • 개발 도구와 유틸리티
  • 프로토타이핑과 데모
  • 팀 내부 도구
  • 브랜딩이 중요한 도구

❌ Signale 비추천 상황

  • 프로덕션 서버 로깅
  • 고성능이 필요한 경우
  • 로그 분석이 필요한 경우
  • 복잡한 로그 구조화
  • 대용량 로그 처리

실전 팁과 베스트 프랙티스

1. 환경별 설정 전략

// lib/environment-signale.js
import { Signale } from 'signale';

export const createEnvironmentLogger = (scope) => {
  const config = {
    scope,
    disabled: false
  };
  
  switch (process.env.NODE_ENV) {
    case 'development':
      config.types = {
        ...Signale.prototype._types,
        debug: {
          badge: '🐛',
          color: 'gray',
          label: 'debug'
        }
      };
      break;
      
    case 'production':
      config.disabled = !process.env.DEBUG;
      config.types = {
        error: { badge: '❌', color: 'red', label: 'error' },
        warn: { badge: '⚠️', color: 'yellow', label: 'warn' },
        info: { badge: 'ℹ️', color: 'blue', label: 'info' }
      };
      break;
      
    case 'test':
      config.disabled = true;
      config.stream = require('stream').Writable({ write() {} });
      break;
  }
  
  return new Signale(config);
};

2. 로그 그룹핑 패턴

// 작업 단위별 그룹핑
const deployment = createEnvironmentLogger('deploy');

deployment.start('배포 프로세스 시작');
  deployment.info('환경 변수 확인');
  deployment.success('환경 변수 검증 완료');
  
  deployment.pending('빌드 시작');
  deployment.success('빌드 완료');
  
  deployment.pending('배포 진행');
  deployment.success('배포 완료');
deployment.complete('배포 프로세스 완료');

3. 에러 처리 패턴

// 예측 가능한 에러 vs 예상치 못한 에러
try {
  await riskyOperation();
} catch (error) {
  if (error instanceof ValidationError) {
    signale.warn('입력 데이터 검증 실패', error.message);
  } else if (error instanceof NetworkError) {
    signale.error('네트워크 연결 오류', error.message);
  } else {
    signale.fatal('예상치 못한 오류 발생', error.message);
    process.exit(1);
  }
}

마무리

Signale은 “기능보다는 경험”을 중시하는 독특한 로깅 라이브러리입니다. 프로덕션 서버에서 사용하기에는 부족하지만, CLI 도구나 개발 스크립트에서는 탁월한 사용자 경험을 제공합니다.

특히 팀 내부 도구나 오픈소스 프로젝트에서 브랜딩과 함께 시각적 일관성을 유지하고 싶을 때 Signale만큼 좋은 선택은 없습니다. 개발자들이 도구를 사용하면서 느끼는 즐거움도 생산성의 중요한 요소라는 것을 보여주는 라이브러리죠.

다음 포스트에서는 이번 시리즈의 마지막으로 “프로덕션에서의 로깅 전략”을 다뤄보겠습니다. 지금까지 살펴본 모든 라이브러리들을 종합하여 실제 서비스에서 어떻게 로깅 아키텍처를 설계해야 하는지 알아보세요.




Leave a Comment