디버깅과 모니터링을 위한 로깅 추가하기

디버깅과 모니터링을 위한 로깅 추가하기

Dart 애플리케이션에 로깅을 추가해 디버깅과 모니터링에 도움을 주는 방법을 배워 봐요.

출처: Add logging for debugging and monitoring

본문

이 장에서는 Dart 애플리케이션에 로깅을 추가하는 방법을 배워요. 로깅은 다양한 환경에서 애플리케이션의 동작을 디버깅하고 모니터링하며 이해하는 데 아주 중요한 도구예요.

  • logging 패키지를 추가하고 배워 봐요.
  • 로그 레벨과 파일 출력을 구성해요.
  • CLI 명령에 로깅을 통합해요.

사전 준비 (Prerequisites)

이 장을 시작하기 전에 다음을 확인해 주세요:

  • 11장을 마치고 dartpedia 프로젝트가 있는, 동작하는 Dart 개발 환경을 갖췄는지 확인해요.
  • 디버깅의 기본과, 애플리케이션에서 오류와 이벤트를 추적하는 것이 왜 중요한지 이해하고 있는지 확인해요.

작업 (Tasks)

이 장에서는 wikipedia 패키지 명령을 통합하고, 오류를 추적하고 동작을 모니터링하는 데 도움이 되도록 로깅을 추가해서 dartpedia CLI 애플리케이션을 완성해요. 여기에는 패키지 의존성 추가, CLI 명령 만들기, Logger 인스턴스 구성, 그리고 완성된 애플리케이션 실행이 포함돼요.

작업 1: cli 패키지에 의존성 추가하기 (Add dependencies to the cli package)

먼저 wikipedia 패키지(이전 장에서 만든 것)와 logging 패키지를 CLI 프로젝트의 의존성에 추가해 주세요.

  • cli/pubspec.yaml 파일을 열어 주세요.

  • dependencies 섹션을 찾아 주세요.

  • wikipedialogging 패키지를 의존성에 추가해 주세요:

dependencies:
  http: ^1.3.0
  command_runner:
    path: ../command_runner
  # Add the following lines
  wikipedia:
    path: ../wikipedia
  logging: ^1.2.0
  • cli 디렉터리에서 dart pub get을 실행해 새 의존성을 가져와 주세요.

작업 2: 로거 만들기 (Create a logger)

다음으로 Logger 인스턴스를 만들고 로그 메시지를 파일에 쓰도록 구성해요. 로거용 새 파일을 만들고 필요한 import를 설정하는 작업이 포함돼요.

  • cli/lib/src/logger.dart라는 새 파일을 만들어 주세요.

  • 필요한 import를 추가하고 initFileLogger 함수를 정의해 주세요:

import 'dart:io';
import 'package:logging/logging.dart';

Logger initFileLogger(String name) {
  // Enables logging from child loggers.
  hierarchicalLoggingEnabled = true;

  // Create a logger instance with the provided name.
  final logger = Logger(name);
  final now = DateTime.now();

  // The rest of the function will be added below.
  // ...

  return logger;
}
  • 프로젝트의 루트 디렉터리를 찾고, logs 디렉터리가 없으면 만들고, 고유한 로그 파일을 만드는 코드를 추가해 주세요:
Logger initFileLogger(String name) {
  hierarchicalLoggingEnabled = true;
  final logger = Logger(name);
  final now = DateTime.now();

  // Get the path to the project directory from the current script.
  final scriptFile = File(Platform.script.toFilePath());
  final projectDir = scriptFile.parent.parent.path;

  // Create a 'logs' directory if it doesn't exist.
  final dir = Directory('$projectDir/logs');
  if (!dir.existsSync()) dir.createSync();

  // Create a log file with a unique name based on
  // the current date and logger name.
  final logFile = File(
    '${dir.path}/${now.year}_${now.month}_${now.day}_$name.txt',
  );

  // The rest of the function will be added below.
  // ...

  return logger;
}
  • 로거의 레벨을 구성하고 로그 메시지를 파일에 쓰는 리스너를 설정해 주세요:
Logger initFileLogger(String name) {
  hierarchicalLoggingEnabled = true;
  final logger = Logger(name);
  final now = DateTime.now();

  final scriptFile = File(Platform.script.toFilePath());
  final projectDir = scriptFile.parent.parent.path;
  final dir = Directory('$projectDir/logs');
  if (!dir.existsSync()) dir.createSync();
  final logFile = File(
    '${dir.path}/${now.year}_${now.month}_${now.day}_$name.txt',
  );

  // Set the logger level to ALL, so it logs all messages regardless of severity.
  // Level.ALL is useful for development and debugging, but you'll likely want to
  // use a more restrictive level like Level.INFO or Level.WARNING in production.
  logger.level = Level.ALL;

  // Listen for log records and write each one to the log file.
  logger.onRecord.listen((record) {
    final msg =
        '[${record.time} - ${record.loggerName}] ${record.level.name}: ${record.message}';
    logFile.writeAsStringSync('$msg \n', mode: FileMode.append);
  });

  return logger;
}

이 코드는 다음을 수행해요:

  • hierarchicalLoggingEnabled = true로 계층적 로깅(hierarchical logging)을 활성화해요.
  • 주어진 이름으로 Logger 인스턴스를 만들어요.
  • Platform.script.path에서 프로젝트 디렉터리를 가져와요.
  • logs 디렉터리가 없으면 만들어요.
  • 현재 날짜와 로거 이름으로 로그 파일을 만들어요.
  • 로거 레벨을 Level.ALL로 설정해 모든 메시지를 기록하게 해요. 개발과 디버깅에 유용하지만, 프로덕션에서는 Level.INFOLevel.WARNING 같은 더 제한적인 레벨을 쓰고 싶을 거예요.
  • 로그 레코드를 듣고 로그 파일에 기록해요.

작업 3: SearchCommand 명령 만들기 (Create the SearchCommand command)

CLI의 핵심 기능은 명령들에 있어요. SearchCommandGetArticleCommand 파일을 만들고, 로깅과 오류 처리를 포함한 필요한 코드를 추가해 주세요.

  • cli/lib/src/commands/search.dart라는 새 파일을 만들어 주세요.

  • import와 기본 클래스 구조를 추가해 주세요. 이 SearchCommand 클래스는 Command를 확장하며, 생성자는 Logger 인스턴스를 받아요. 생성자에서 로거를 받는 것은 의존성 주입(dependency injection)이라는 흔한 패턴으로, 명령이 자체 로거를 만들 필요 없이 이벤트를 기록할 수 있게 해 줘요.

import 'dart:async';
import 'dart:io';

import 'package:command_runner/command_runner.dart';
import 'package:logging/logging.dart';
import 'package:wikipedia/wikipedia.dart';

class SearchCommand extends Command {
  SearchCommand({required this.logger}) {
    addFlag(
      'im-feeling-lucky',
      help:
          'If true, prints the summary of the top article that the search returns.',
    );
  }

  final Logger logger;

  @override
  String get description => 'Search for Wikipedia articles.';

  @override
  bool get requiresArgument => true;

  @override
  String get name => 'search';

  @override
  String get valueHelp => 'STRING';

  @override
  String get help =>
      'Prints a list of links to Wikipedia articles that match the given term.';

  @override
  FutureOr<String> run(ArgResults args) async {
    // Command logic will be added below.
    // ...
    return '';
  }
}
  • Wikipedia를 검색하고 결과를 형식화하는 명령 로직을 구현해 주세요:
// ...
  @override
  FutureOr<String> run(ArgResults args) async {
    if (requiresArgument &&
        (args.commandArg == null || args.commandArg!.isEmpty)) {
      throw ArgumentException('Please include a search term', name);
    }

    final buffer = StringBuffer('Search results:\n');
    final SearchResults results = await search(args.commandArg!);

    if (args.flag('im-feeling-lucky')) {
      final title = results.results.first.title;
      final Summary article = await getArticleSummaryByTitle(title);
      buffer.writeln('Lucky you!');
      buffer.writeln(article.titles.normalized.titleText);
      if (article.description != null) {
        buffer.writeln(article.description);
      }
      buffer.writeln(article.extract);
      buffer.writeln();
      buffer.writeln('All results:');
    }

    for (var result in results.results) {
      buffer.writeln('${result.title} - ${result.url}');
    }
    return buffer.toString();
  }
// ...
  • 마지막으로 주요 로직을 try/catch 블록으로 감싸 주세요. 이렇게 하면 네트워크 문제나 데이터 형식화 문제에서 발생할 수 있는 잠재적 예외를 처리할 수 있어요. 주입된 logger를 사용해 이런 오류를 로그 파일에 기록해요.
import 'dart:async';
import 'dart:io';

import 'package:command_runner/command_runner.dart';
import 'package:logging/logging.dart';
import 'package:wikipedia/wikipedia.dart';

class SearchCommand extends Command {
  SearchCommand({required this.logger}) {
    addFlag(
      'im-feeling-lucky',
      help:
          'If true, prints the summary of the top article that the search returns.',
    );
  }

  final Logger logger;

  @override
  String get description => 'Search for Wikipedia articles.';

  @override
  bool get requiresArgument => true;

  @override
  String get name => 'search';

  @override
  String get valueHelp => 'STRING';

  @override
  String get help =>
      'Prints a list of links to Wikipedia articles that match the given term.';

  @override
  FutureOr<String> run(ArgResults args) async {
    if (requiresArgument &&
        (args.commandArg == null || args.commandArg!.isEmpty)) {
      throw ArgumentException('Please include a search term', name);
    }

    final buffer = StringBuffer('Search results:\n');
    try {
      final SearchResults results = await search(args.commandArg!);

      if (args.flag('im-feeling-lucky')) {
        final title = results.results.first.title;
        final Summary article = await getArticleSummaryByTitle(title);
        buffer.writeln('Lucky you!');
        buffer.writeln(article.titles.normalized.titleText);
        if (article.description != null) {
          buffer.writeln(article.description);
        }
        buffer.writeln(article.extract);
        buffer.writeln();
        buffer.writeln('All results:');
      }

      for (var result in results.results) {
        buffer.writeln('${result.title} - ${result.url}');
      }
      return buffer.toString();
    } on HttpException catch (e) {
      logger
        ..warning(e.message)
        ..warning(e.uri)
        ..info(usage);
      return e.message;
    } on FormatException catch (e) {
      logger
        ..warning(e.message)
        ..warning(e.source)
        ..info(usage);
      return e.message;
    }
  }
}

작업 4: GetArticleCommand 명령 만들기 (Create the GetArticleCommand command)

이제 GetArticleCommand 파일을 만들고 필요한 코드를 추가해 주세요. 이전 SearchCommand와 비슷하며, 역시 try/catch 블록으로 잠재적인 네트워크나 데이터 오류를 처리해요.

  • cli/lib/src/commands/get_article.dart라는 새 파일을 만들어 주세요.

  • get_article.dart에 다음 코드를 추가해 주세요:

import 'dart:async';
import 'dart:io';

import 'package:command_runner/command_runner.dart';
import 'package:logging/logging.dart';
import 'package:wikipedia/wikipedia.dart';

class GetArticleCommand extends Command {
  GetArticleCommand({required this.logger});

  final Logger logger;

  @override
  String get description => 'Read an article from Wikipedia';

  @override
  String get name => 'article';

  @override
  String get help => 'Gets an article by exact canonical wikipedia title.';

  @override
  String get defaultValue => 'cat';

  @override
  String get valueHelp => 'STRING';

  @override
  FutureOr<String> run(ArgResults args) async {
    try {
      var title = args.commandArg ?? defaultValue;
      final List<Article> articles = await getArticleByTitle(title);
      // API returns a list of articles, but we only care about the closest hit.
      final article = articles.first;
      final buffer = StringBuffer('\n=== ${article.title.titleText} ===\n\n');
      buffer.write(article.extract.split(' ').take(500).join(' '));
      return buffer.toString();
    } on HttpException catch (e) {
      logger
        ..warning(e.message)
        ..warning(e.uri)
        ..info(usage);
      return e.message;
    } on FormatException catch (e) {
      logger
        ..warning(e.message)
        ..warning(e.source)
        ..info(usage);
      return e.message;
    }
  }
}

방금 추가한 코드를 살펴보세요. SearchCommandGetArticleCommand는 이제:

  • command_runner, logging, wikipedia 같은 필요한 패키지를 import해 그 클래스와 함수를 사용해요.
  • 생성자를 통해 Logger 인스턴스를 받아요. 이는 의존성 주입이라는 흔한 패턴으로, 명령이 자체 로거를 만들 필요 없이 이벤트를 기록할 수 있게 해 줘요.
  • 명령의 로직을 정의하는 run 메서드를 구현해요. 이 메서드는 적절한 wikipedia API를 호출하고 출력을 형식화해요.
  • 네트워크 오류(HttpException)와 데이터 파싱 오류(FormatException)를 우아하게 처리하고 디버깅을 위해 기록하는 try/catch 블록을 포함해요.

작업 5: 명령 export하고 cli.dart 연결하기 (Export commands and wire up cli.dart)

이제 cli 라이브러리에서 로거와 명령을 export하고, cli/bin/cli.dart에서 연결해 완전한 CLI 애플리케이션을 만들어 봐요.

  • cli/lib/cli.dart 파일을 열어 주세요. 자리 표시자 내용을 로거와 명령에 대한 export로 교체해 주세요:
export 'src/commands/get_article.dart';
export 'src/commands/search.dart';
export 'src/logger.dart';

이 파일은 라이브러리의 공개 인터페이스 역할을 하며, initFileLogger, SearchCommand, GetArticleCommand를 export해서 cli/bin/cli.dartpackage:cli/cli.dart에서 import할 수 있게 해 줘요.

  • cli/bin/cli.dart 파일을 열어 주세요.

  • clicommand_runner에 대한 import를 추가해 주세요:

import 'package:cli/cli.dart';
import 'package:command_runner/command_runner.dart';
  • main 함수를 수정해 로거를 초기화하고 CommandRunner에 명령들을 등록해 주세요:
import 'package:cli/cli.dart';
import 'package:command_runner/command_runner.dart';

void main(List<String> arguments) async {
  final errorLogger = initFileLogger('errors');
  final app =
      CommandRunner(
          onOutput: (String output) async {
            await write(output);
          },
          onError: (Object error) {
            if (error is Error) {
              errorLogger.severe(
                '[Error] ${error.toString()}\n${error.stackTrace}',
              );
              throw error;
            }
            if (error is Exception) {
              errorLogger.warning(error);
              print(error);
            }
          },
        )
        ..addCommand(HelpCommand())
        ..addCommand(SearchCommand(logger: errorLogger))
        ..addCommand(GetArticleCommand(logger: errorLogger));

  app.run(arguments);
}

이 코드는 다음을 수행해요:

  • initFileLogger('errors')Logger 인스턴스를 초기화해요.
  • 로거 인스턴스를 SearchCommandGetArticleCommand에 전달해요.
  • 모든 명령을 CommandRunner에 등록해요.

작업 6: 애플리케이션 실행하고 로그 확인하기 (Run the application and check the logs)

이제 CLI 명령을 구현하고 bin/cli.dart에서 CommandRunner를 연결했으며 로깅을 구성했으니, 터미널에서 애플리케이션을 테스트해 봐요. 이 명령들은 반드시 cli 디렉터리(/dartpedia/cli)에서 실행해야 해요.

  • 문서를 검색하도록 CLI 애플리케이션을 실행해 주세요:
dart run bin/cli.dart search "Dart programming"

검색어와 일치하는 Wikipedia 문서를 나열하는 터미널 출력이 보여야 해요.

  • --im-feeling-lucky 플래그로 명령을 실행해 보세요:
dart run bin/cli.dart search "Dart" --im-feeling-lucky
  • 오류 로깅을 테스트하려면 필수 search 인자 없이 애플리케이션을 실행해 주세요:
dart run bin/cli.dart search

터미널에 오류가 출력되는 게 보여야 해요:

ArgumentException: Please include a search term
  • 프로젝트의 cli/logs 디렉터리를 확인해 주세요. 현재 날짜의 이름을 가진 파일이 있어야 해요. 예: <year>_<month>_<day>_errors.txt.

  • 로그 파일을 열어 오류 메시지가 기록됐는지 확인해 주세요:

[2025-02-20 16:23:45.678 - errors] WARNING: ArgumentException: Please include a search term

참고: SearchCommandGetArticleCommand가 잡은 네트워크 오류(예: 기기가 오프라인이거나 Wikipedia에 도달할 수 없을 때)도 WARNING 레벨 항목으로 이 파일에 기록되고, 그 뒤에 명령 사용법이 담긴 INFO 항목이 이어져요.

  • 작업공간 구조가 완성된 프로젝트와 일치하는지 확인해 주세요:
dartpedia/
├── pubspec.yaml               # Workspace root configuration
├── cli/
│   ├── bin/
│   │   └── cli.dart           # Application entrypoint
│   ├── lib/
│   │   ├── cli.dart           # Library exports
│   │   └── src/
│   │       ├── commands/
│   │       │   ├── get_article.dart
│   │       │   └── search.dart
│   │       └── logger.dart    # Logging configuration
│   ├── logs/
│   │   └── <date>_errors.txt  # Generated error logs
│   └── pubspec.yaml
├── command_runner/
│   ├── lib/
│   │   ├── command_runner.dart
│   │   └── src/
│   │       ├── arguments.dart
│   │       ├── command_runner_base.dart
│   │       ├── console.dart
│   │       ├── exceptions.dart
│   │       └── help_command.dart
│   └── pubspec.yaml
└── wikipedia/
    ├── lib/
    │   ├── wikipedia.dart
    │   └── src/
    │       ├── api/
    │       │   ├── get_article.dart
    │       │   ├── search.dart
    │       │   └── summary.dart
    │       └── model/
    │           ├── article.dart
    │           ├── search_results.dart
    │           ├── summary.dart
    │           └── title_set.dart
    └── pubspec.yaml

복습 (Review)

배운 내용 (What you accomplished)

이번 강의에서 만들고 배운 내용을 정리해 드릴게요.

  • logging 패키지 추가하고 배우기: 의존성에 logging을 추가하고 CLI에 import했어요. 이 패키지는 구성 가능한 심각도 레벨로 구조화된 로깅을 위한 Logger, Level, LogRecord 클래스를 제공해요.
  • 로그 레벨과 파일 출력 구성하기: initFileLogger()를 만들어 logs/ 디렉터리의 타임스탬프 파일로 계층적 로깅을 설정했어요. 이렇게 하면 이후에 애플리케이션 동작을 검토할 수 있어요.
  • CLI 명령에 로깅 통합하기: 의존성 주입을 통해 로거를 명령에 전달한 다음, logger.warning(), logger.severe(), logger.info()로 관련 컨텍스트와 함께 다른 레벨의 오류를 기록했어요. 이 구성은 개발과 프로덕션 모두에서 문제를 더 쉽게 디버깅하고 필터링하는 데 도움을 줘요.

퀴즈 (Quiz)

이해도를 확인해 봐요 (Check your understanding)

Q1. Dart에서 logging 패키지의 목적은 무엇인가요?

  • 애플리케이션에서 이벤트와 오류를 기록하는 방법을 제공하는 것. — 맞아요! logging 패키지는 애플리케이션 실행 중 이벤트, 경고, 오류, 그리고 다른 메시지를 기록하는 유연한 시스템을 제공해요.
  • HTTP 요청을 처리하는 것. — 아니에요. HTTP 요청은 package:http가 처리해요. logging 패키지는 다른 목적을 가져요.
  • 프로젝트에서 의존성을 관리하는 것. — 아니에요. 의존성은 pubspec.yaml에서 관리해요. logging 패키지는 프로젝트 설정이 아니라 런타임 동작에 관한 거예요.
  • 명령줄 인터페이스를 만드는 것. — 아니에요. CLI 생성은 args 같은 패키지나 사용자 코드를 사용해요. logging 패키지는 애플리케이션의 다른 측면을 도와줘요.

Q2. hierarchicalLoggingEnabled = true; 줄은 무엇을 하나요?

  • 로거가 자식 로거의 이벤트를 포착할 수 있게 해요. — 맞아요! 계층적 로깅을 활성화하면 부모 로거가 자식 로거의 이벤트를 받아 처리할 수 있어요.
  • 계층적 파일 시스템으로 로깅을 활성화해요. — 아니에요. 이 설정은 파일 시스템이나 로그가 저장되는 위치에 영향을 주지 않아요. "계층적(hierarchical)"은 완전히 다른 것을 가리켜요.
  • 콘솔 로깅을 비활성화해요. — 아니에요. 콘솔 출력은 리스너가 제어하지, 이 설정이 제어하지 않아요. 이 설정은 로거들이 서로 상호작용하는 방식에 영향을 줘요.
  • HTTP 요청 로깅을 활성화해요. — 아니에요. HTTP 로깅은 HTTP 코드에서 특정 구성이 필요해요. 이 설정은 로깅 시스템의 내부 구조에 영향을 줘요.

Q3. 이 강의는 logger.severe(), logger.warning(), logger.info()를 사용해요. 그냥 print()를 쓰는 대신 다른 로그 레벨을 쓰는 이유는 무엇인가요?

  • 심각도별로 로그를 필터링할 수 있어요. 개발 중에는 모든 것을 보면서 프로덕션에서는 경고와 오류만 보여 줄 수 있어요. — 맞아요! 로그 레벨로 임계값을 설정할 수 있어요. 프로덕션에서는 경고 이상만 기록하고, 개발에서는 info와 debug 메시지까지 볼 수 있어요.
  • 다른 레벨이 콘솔에서 다른 색을 사용해요. — 아니에요. 색상은 표현 방식의 선택이지 핵심 이유가 아니에요. 실제 이점은 겉모습보다 더 근본적이에요.
  • 각 레벨이 자동으로 다른 파일에 기록돼요. — 아니에요. 파일 대상은 별도로 구성해야 해요. 레벨이 자동으로 로그가 저장되는 곳을 결정하지 않아요.
  • Dart에서 레벨 사용이 필수예요. print()는 프로덕션에서 작동하지 않아요. — 아니에요. print()는 Dart 어디서든 작동해요. 레벨은 선택이지만 print()가 제공할 수 없는 중요한 이점을 줘요.

더 알아보기