클래스로 관계 정의하기

클래스로 관계 정의하기

Dart의 객체지향 프로그래밍에 대해 배워 봐요. 추상 클래스, 상속, 오버라이드, enum을 다루고, 잘 설계된 CLI 앱을 위한 프레임워크를 만들어 봐요.

출처: Define relationships with classes

본문

이 장에서는 Dart에서 객체지향 프로그래밍(OOP)의 힘을 탐구해 봐요. 클래스를 만들고, 상속추상 클래스를 포함해 클래스 사이의 관계를 정의하는 방법을 배워요. 잘 구조화된 CLI 애플리케이션을 만들기 위한 토대도 함께 다져요.

  • 추상 클래스를 설계하고 이해해요.
  • 부모 클래스를 확장하고 메서드를 오버라이드해요.
  • enum으로 고정된 값 집합을 표현해 봐요.
  • CLI 프레임워크를 계속 만들어 가요.

사전 준비 (Prerequisites)

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

  • 4장을 마치고 dartpedia 프로젝트가 있는, 동작하는 Dart 개발 환경을 갖췄는지 확인해요.
  • 변수, 함수, 제어 흐름 같은 기본 프로그래밍 개념에 익숙한지 확인해요.
  • Dart의 패키지와 라이브러리 개념을 이해하고 있는지 확인해요.

작업 (Tasks)

명령줄 인터페이스(CLI)는 사용자가 터미널에 입력할 수 있는 명령(command), 옵션(option), 인자(argument)로 정의돼요.

이 강의가 끝나면 이런 명령을 이해할 수 있는 프레임워크를 만들게 될 거예요:

$ dartpedia help --verbose --command=search

각 부분을 살펴볼게요:

  • dartpedia: **실행 파일(executable)**이에요. 여러분 애플리케이션의 이름이죠.
  • help: **명령(command)**이에요. 애플리케이션이 수행하길 원하는 동작이에요.
  • --verbose: 플래그(flag)(값을 받지 않는 옵션의 한 종류)로, 명령의 동작을 바꿔 줘요.
  • --command=search: 값을 받는 **옵션(option)**이에요. 여기서 option의 이름은 command이고, 그 값은 search예요.

다음 작업들에서 만드는 클래스와 로직은 바로 이런 명령을 파싱하고 실행하기 위한 토대를 만들어 줘요.

작업 1: CLI 요소 계층 정의하기 (Define the CLI element hierarchy)

먼저 CliElement 클래스, Option 클래스, Command 클래스를 정의해서 상속 관계를 확립해 봐요.

  • command_runner/lib/src/arguments.dart 파일을 만들어요. 이 파일에는 CliElement, Option, Command, ArgResults 클래스 정의가 들어갈 거예요.

  • OptionType이라는 enum을 정의해 주세요:

enum OptionType { flag, option }

enum은 옵션의 타입을 나타내요. flag(불리언 옵션)일 수도 있고 일반 option(값을 받는 옵션)일 수도 있어요. enum은 가능한 고정된 값 집합을 나타낼 때 유용해요.

  • CliElement라는 abstract class를 정의해 주세요.

CliElement 클래스의 기본 구조를 정의하는 것부터 시작해 봐요. abstract로 선언할 텐데, 이는 다른 클래스가 확장할 수 있는 기본 클래스 역할을 하지만 그 자체로는 인스턴스화(instantiate)될 수 없다는 뜻이에요.

방금 추가한 enum 아래에 다음 코드를 붙여 넣어 주세요:

// Paste this new class below the enum you added
abstract class CliElement {
  String get name;
  String? get help;

  // In the case of flags, the default value is a bool.
  // In other options and commands, the default value is a String.
  // NB: flags are just Option objects that don't take arguments
  Object? get defaultValue;
  String? get valueHelp;

  String get usage;
}
  • **name**은 인자를 고유하게 식별하는 String이에요.
  • **help**는 설명을 제공하는 옵션 String이에요.
  • **defaultValue**는 bool(플래그의 경우)일 수도 String일 수도 있어서 타입이 Object?예요.
  • **valueHelp**는 예상 값을 암시하는 옵션 String이에요.
  • usage getter는 인자를 어떻게 사용하는지 보여 주는 문자열을 제공해요.

CliElement 클래스를 완전히 정의했으니 모든 종류의 명령줄 인자에 대한 공통 인터페이스를 갖게 됐어요. 다음으로 CliElement를 확장하는 특정 CLI 요소 타입인 Option을 정의해 이 위에 구축해 봐요.

  • CliElementextends하는 Option이라는 클래스를 정의해 주세요.

Option 클래스는 --verbose--output=file.txt 같은 명령줄 옵션을 나타내요. CliElement 클래스에서 상속받을 거예요.

파일 맨 아래에 다음 Option 클래스를 추가해 주세요:

class Option extends CliElement {
  Option(
    this.name, {
    required this.type,
    this.help,
    this.abbr,
    this.defaultValue,
    this.valueHelp,
  });

  @override
  final String name;

  final OptionType type;

  @override
  final String? help;

  final String? abbr;

  @override
  final Object? defaultValue;

  @override
  final String? valueHelp;

  @override
  String get usage {
    if (abbr != null) {
      return '-$abbr,--$name: $help';
    }

    return '--$name: $help';
  }
}

extends 키워드가 상속 관계를 확립해요. 클래스는 속성과 getter에 @override 어노테이션을 사용해서 CliElement에 정의된 자리 표시자 멤버를 대체하고 있음을 나타내요.

또한 type(OptionType enum 사용)과 옵션의 축약형을 위한 옵션 abbr도 추가해요. usage getter는 사용자에게 명확한 안내를 제공하도록 구현돼요.

Option을 완성했으니 특화된 CLI 요소 타입을 갖게 됐어요. 다음으로 CLI 애플리케이션에서 사용자가 수행할 수 있는 주요 동작을 나타내는 또 다른 CLI 요소 타입인 Command 클래스를 정의해요.

  • CliElementextends하는 abstract classCommand를 정의해 주세요.

Command 클래스는 실행 가능한 동작을 나타내요. 다른 명령이 따를 템플릿을 제공하므로 **abstract**로 선언할 거예요.

// Add this class below the Option class
abstract class Command extends CliElement {
  // Properties and methods will go here
}

abstract 키워드는 Command를 직접 인스턴스화할 수 없다는 뜻이에요. 다른 클래스의 기본 클래스 역할을 해요.

이제 핵심 속성을 추가해 주세요. 명령은 namedescription이 필요해요. 또 그것을 실행하는 CommandRunner에 대한 역참조도 필요해요.

abstract class Command extends CliElement {
  @override
  String get name;

  String get description;

  bool get requiresArgument => false;

  late CommandRunner runner;

  @override
  String? help;

  @override
  String? defaultValue;

  @override
  String? valueHelp;
}

runner 속성의 타입은 CommandRunner인데, 나중에 command_runner_base.dart에서 정의할 거예요.

late 키워드를 주목해 주세요. 변수에 접근하기 전에 반드시 초기화하겠다고 Dart에 약속하게 해 주며, non-nullable 변수를 즉시 할당하지 않고도 선언할 수 있게 해 줘요. 변수 초기화가 다른 객체에 의존할 때(예: 명령이 runner에 추가될 때) 유용해요.

Dart가 이 클래스를 알게 하려면 정의된 파일을 import해야 해요. command_runner/lib/src/arguments.dart 맨 위에 다음 import를 추가해 주세요:

import '../command_runner.dart';

다음으로 명령에 자신만의 옵션 집합을 부여할 거예요. 코드의 다른 부분이 이 옵션들을 예기치 않게 수정하지 못하게 하기 위해 private 리스트(_options)에 저장해요. Dart에서 변수나 필드 이름 앞에 밑줄(_)을 붙이면 라이브러리-프라이빗이 돼요.

직접 접근을 허용하는 대신 읽기 전용의 수정 불가 뷰(UnmodifiableSetView)를 통해 옵션을 노출해요. 이 접근 방식은 캡슐화(encapsulation)의 핵심 부분이에요. 캡슐화는 의도하지 않은 간섭을 막기 위해 클래스의 내부 상태에 대한 직접 접근을 제한하는 관행이죠.

UnmodifiableSetView 클래스는 Dart의 핵심 컬렉션 라이브러리의 일부예요. 그것을 사용하려면 해당 라이브러리를 import해야 해요.

파일 맨 위의 import를 dart:collection을 포함하도록 갱신해 주세요:

import 'dart:collection'; // New import
import '../command_runner.dart';

이제 Command 클래스에 options 리스트와 getter를 추가해 주세요:

abstract class Command extends CliElement {
  // ... existing properties ...

  @override
  String? valueHelp;


  // Add the following lines to the bottom of your Command class:

  final List<Option> _options = [];

  UnmodifiableSetView<Option> get options =>
      UnmodifiableSetView(_options.toSet());
}

_options가 private이므로 외부 코드가 직접 옵션을 추가할 수 없어요. 명령이 자신의 옵션을 정의할 수 있게 두 개의 내부 헬퍼 메서드인 addFlagaddOption을 제공할 거예요. 이 메서드들은 적절한 Option 객체를 인스턴스화해 private 리스트에 추가해요.

abstract class Command extends CliElement {
  // ... existing properties and getters ...

  UnmodifiableSetView<Option> get options =>
      UnmodifiableSetView(_options.toSet());


  // Add the following lines to the bottom of your Command class:

  // A flag is an [Option] that's treated as a boolean.
  void addFlag(String name, {String? help, String? abbr, String? valueHelp}) {
    _options.add(
      Option(
        name,
        help: help,
        abbr: abbr,
        defaultValue: false,
        valueHelp: valueHelp,
        type: OptionType.flag,
      ),
    );
  }

  // An option is an [Option] that takes a value.
  void addOption(
    String name, {
    String? help,
    String? abbr,
    String? defaultValue,
    String? valueHelp,
  }) {
    _options.add(
      Option(
        name,
        help: help,
        abbr: abbr,
        defaultValue: defaultValue,
        valueHelp: valueHelp,
        type: OptionType.option,
      ),
    );
  }
}

마지막으로 모든 명령은 호출될 때 실행할 로직이 있어야 해요. 구체적인 명령이 구현해야 하는 추상 run 메서드를 정의할 거예요.

명령은 동기적일 수도 비동기적일 수도 있으므로, run 메서드는 dart:asyncFutureOr 타입을 반환해 원시 값이나 Future를 반환할 수 있게 해 줘요. 이것이 마지막 필수 import예요.

파일 맨 위의 import를 dart:async를 포함하도록 갱신해 주세요:

import 'dart:async'; // New import
import 'dart:collection';
import '../command_runner.dart';

이제 추상 run 메서드를 추가하고 usage 구현을 제공해 Command 클래스를 완성해 주세요:

abstract class Command extends CliElement {
  // ... existing properties, getters, and methods ...

  void addOption(
    String name, {
    String? help,
    String? abbr,
    String? defaultValue,
    String? valueHelp,
  }) {
    _options.add(
      Option(
        name,
        help: help,
        abbr: abbr,
        defaultValue: defaultValue,
        valueHelp: valueHelp,
        type: OptionType.option,
      ),
    );
  }


  // Add the following lines to the bottom of your Command class:
  FutureOr<Object?> run(ArgResults args);

  @override
  String get usage {
    return '$name:  $description';
  }
}
  • run(ArgResults args): 명령의 로직이 자리하는 추상 메서드예요. 구체적인 하위 클래스가 반드시 구현해야 해요.
  • usage: 명령의 namedescription을 결합한 간단한 사용법 문자열을 제공하는 getter예요.

Command 클래스는 이제 CLI 앱의 모든 명령을 위한 견고한 토대를 제공해요. 클래스 계층이 갖춰졌으니, 파싱된 입력을 담을 ArgResults를 정의할 준비가 됐어요.

  • ArgResults라는 클래스를 정의해 주세요.
// Add this class to the end of the file
class ArgResults {
  Command? command;
  String? commandArg;
  Map<Option, Object?> options = {};

  // Returns true if the flag exists.
  bool flag(String name) {
    // Only check flags, because we're sure that flags are booleans.
    for (var option in options.keys.where(
      (option) => option.type == OptionType.flag,
    )) {
      if (option.name == name) {
        return options[option] as bool;
      }
    }
    return false;
  }

  bool hasOption(String name) {
    return options.keys.any((option) => option.name == name);
  }

  ({Option option, Object? input}) getOption(String name) {
    var mapEntry = options.entries.firstWhere(
      (entry) => entry.key.name == name || entry.key.abbr == name,
    );

    return (option: mapEntry.key, input: mapEntry.value);
  }
}

이 클래스는 명령줄 인자를 파싱한 결과를 나타내요. 감지된 명령, 그 명령에 대한 인자, 그리고 지정된 옵션의 맵을 담아요.

  • flag() 메서드: options 맵의 키를 순회하면서 typeOptionType.flag인 것들만 필터링해요. 플래그가 불리언 값임을 알기 때문에 명시적 타입 캐스팅(as bool)을 사용해요.
  • 레코드 타입: getOption 메서드는 (option: ..., input: ...) 레코드 타입을 반환해요. 전체 클래스를 만들지 않고 여러 값을 함께 그룹화할 수 있게 해 줘요.

이제 명령줄 애플리케이션에서 명령, 인자, 옵션을 다루기 위한 기본 구조를 정의했어요.

작업 2: CommandRunner 클래스 갱신하기 (Update the CommandRunner class)

다음으로 CommandRunner 클래스를 새 CliElement 계층을 사용하도록 갱신해요.

  • command_runner/lib/src/command_runner_base.dart 파일을 열어 주세요.

  • 기존 CommandRunner 클래스를 다음으로 교체해 주세요:

import 'dart:collection';
import 'dart:io';
import 'arguments.dart';

class CommandRunner {
  final Map<String, Command> _commands = <String, Command>{};

  UnmodifiableSetView<Command> get commands =>
      UnmodifiableSetView<Command>(<Command>{..._commands.values});

  Future<void> run(List<String> input) async {
    final ArgResults results = parse(input);
    if (results.command != null) {
      Object? output = await results.command!.run(results);
      print(output.toString());
    }
  }

  void addCommand(Command command) {
    // TODO: handle error (Commands can't have names that conflict)
    _commands[command.name] = command;
    command.runner = this;
  }

  ArgResults parse(List<String> input) {
    var results = ArgResults();
    results.command = _commands[input.first];
    return results;
  }

  // Returns usage for the executable only.
  // Should be overridden if you aren't using [HelpCommand]
  // or another means of printing usage.

  String get usage {
    final exeFile = Platform.script.path.split('/').last;
    return 'Usage: dart bin/$exeFile <command> [commandArg?] [...options?]';
  }
}

이 갱신된 클래스는 여러분의 새 객체지향 구조를 통합해요. commands getter에서 확산 연산자(spread operator, ...)를 사용해 _commands 맵의 값들을 새 집합으로 풀어 넣어요. 이렇게 하면 반환값이 복사본이라는 것이 보장되어 외부 코드가 여러분의 데이터를 수정하지 못해요.

run() 메서드에서 results.command!.run(...)은 not-null 단언 연산자(!)를 사용해 results.command가 null이 아니라고 Dart 분석기에 알려 줘요. 앞선 if 문에서 null이 아니라고 방금 확인했으므로 여기서는 안전해요.

핵심 구현 세부사항은 다음과 같아요:

  • _commands: 명령 이름을 구체적인 Command 객체 인스턴스에 연결하는 private 맵이에요. 그 값들은 UnmodifiableSetView를 통해 노출돼요.

  • addCommand(): 명령을 등록하고 this runner를 명령의 runner 속성에 할당해요. 이것은 앞서 Command 클래스에서 했던 late 초기화 약속을 이행해요.

  • parse()run(): 사용자 입력을 평가하고 맵에서 해당 Command를 식별한 다음 await로 명령의 run() 메서드를 호출해요.

  • command_runner/lib/command_runner.dart를 열고 다음 export를 추가해 주세요:

/// Support for doing something awesome.
///
/// More dartdocs go here.
library;

export 'src/arguments.dart';
export 'src/command_runner_base.dart';
export 'src/help_command.dart';

// TODO: Export any libraries intended for clients of this package.

이렇게 하면 arguments.dart, command_runner_base.dart, help_command.dart 파일을 command_runner에 의존하는 다른 패키지가 쓸 수 있게 해요.

작업 3: HelpCommand 만들기 (Create a HelpCommand)

Command 클래스를 확장하고 사용법 정보를 출력하는 HelpCommand를 만들어요.

  • command_runner/lib/src/help_command.dart 파일을 만들어 주세요.

  • command_runner/lib/src/help_command.dart에 다음 코드를 추가해 주세요:

import 'dart:async';

import 'arguments.dart';

// Prints program and argument usage.
//
// When given a command as an argument, it prints the usage of
// that command only, including its options and other details.
// When the flag 'verbose' is set, it prints options and details for all commands.
//
// This command isn't automatically added to CommandRunner instances.
// Packages users should add it themselves with [CommandRunner.addCommand],
// or create their own command that prints usage.

class HelpCommand extends Command {
  HelpCommand() {
    addFlag(
      'verbose',
      abbr: 'v',
      help: 'When true, this command will print each command and its options.',
    );
    addOption(
      'command',
      abbr: 'c',
      help:
          "When a command is passed as an argument, prints only that command's verbose usage.",
    );
  }
  @override
  String get name => 'help';

  @override
  String get description => 'Prints usage information to the command line.';

  @override
  String? get help => 'Prints this usage information';

  @override
  FutureOr<Object?> run(ArgResults args) async {
    var usage = runner.usage;
    for (var command in runner.commands) {
      usage += '\n ${command.usage}';
    }

    return usage;
  }
}

HelpCommand 클래스는 상속의 이점을 보여 줘요. 부모의 메서드를 사용해 자신의 옵션을 설정하고, 추상 run 메서드를 오버라이드하며, 사용법 메시지를 만들기 위해 runner 상태에 접근해요.

작업 4: cli.dart를 새 CommandRunner로 갱신하기 (Update cli.dart to use the new CommandRunner)

cli/bin/cli.dart를 수정해 새 CommandRunnerHelpCommand를 사용해 봐요.

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

  • 기존 코드를 다음으로 교체해 주세요:

import 'package:command_runner/command_runner.dart';

const version = '0.0.1';

void main(List<String> arguments) {
  var commandRunner = CommandRunner()..addCommand(HelpCommand());
  commandRunner.run(arguments);
}

이 코드는 CommandRunner 인스턴스를 만들고, 메서드 캐스케이드(..addCommand)로 HelpCommand를 추가해요. 이는 객체를 만든 직후에 그 객체의 메서드를 호출할 수 있게 해 주며, 그다음 명령줄 인자로 command runner를 실행해요.

작업 5: 애플리케이션 실행하기 (Run the application)

CommandRunnerHelpCommand를 테스트해 봐요.

  • 터미널을 열고 cli 디렉터리로 이동해 주세요.

  • dart run bin/cli.dart help 명령을 실행해 주세요.

콘솔에 사용법 정보가 출력되는 게 보여야 해요:

Usage: dart bin/cli.dart <command> [commandArg?] [...options?]
 help:  Prints usage information to the command line.

이것은 CommandRunnerHelpCommand가 올바르게 작동한다는 것을 확인해 줘요.

복습 (Review)

배운 내용 (What you accomplished)

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

  • 추상 클래스 설계하고 이해하기: 직접 인스턴스화할 수 없는 기본 클래스인 추상 CliElement 클래스를 만들었어요. 추상 클래스는 하위 클래스가 충족해야 하는 계약을 정의해서 클래스 계층 전체에서 일관성을 보장해요.
  • 부모 클래스 확장하고 메서드 오버라이드하기: 추상 CliElement 클래스의 OptionCommand 하위 클래스를 만들기 위해 extends를 사용했어요. 그 하위 클래스 안에서 @override로 추상 멤버의 구체적인 구현을 제공했어요.
  • enum으로 고정된 값 집합 표현하기: 고정된 값 집합을 나타내는 OptionType enum을 정의했어요. Dart에서 enum은 타입 안전하며, 특정 옵션만 유효한 곳에 잘못된 값이 사용되는 것을 막는 데 도움을 줘요.
  • CLI 프레임워크 계속 구축하기: 객체지향 프로그래밍 원칙을 적용해 명령줄 인자를 파싱하는 견고하고 확장 가능한 프레임워크를 구현했어요.

퀴즈 (Quiz)

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

Q1. Option 클래스에서 @override 어노테이션의 목적은 무엇인가요?

  • 부모 클래스에 정의된 메서드나 속성에 구체적인 구현을 제공하는 것. — 맞아요! @override는 추상 멤버에 구체적인 구현을 제공하거나 상속된 구현을 대체한다는 것을 나타내요.
  • 부모 클래스에 없는 새 메서드를 만드는 것. — 아니에요. @override는 새 메서드를 만들지 않아요. 새 메서드는 클래스에 정의하기만 하면 만들어져요.
  • 메서드가 선택적임을 나타내는 것. — 아니에요. @override는 메서드가 선택적인지에 영향을 주지 않아요. 선택적 파라미터는 완전히 다른 문법을 사용해요.
  • 속성을 private으로 만드는 것. — 아니에요. 프라이버시는 앞에 밑줄(_)로 나타내지, 어노테이션으로 나타내지 않아요. @override는 상속과 관련된 다른 목적을 가져요.

Q2. Dart에서 abstract 클래스와 일반 클래스의 차이는 무엇인가요?

  • abstract 클래스는 직접 인스턴스화할 수 없어요. — 맞아요! 추상 클래스는 다른 클래스가 확장하는 청사진 역할을 해요. 추상 클래스의 인스턴스를 직접 만들 수는 없어요.
  • abstract 클래스는 어떤 메서드도 가질 수 없어요. — 아니에요. 추상 클래스는 추상 메서드(구현 없음)와 구체 메서드(구현 있음)를 모두 가질 수 있어요.
  • abstract 클래스는 private 메서드만 가질 수 있어요. — 아니에요. 추상 클래스는 어떤 가시성 레벨의 메서드든 가질 수 있어요. abstract 키워드는 가시성을 제한하지 않아요.
  • abstract 클래스와 일반 클래스 사이에는 차이가 없어요. — 아니에요. 상당한 차이가 있어요. var x = MyAbstractClass();를 써 보면 무슨 일이 일어나는지 알 수 있어요.

Q3. 클래스나 상수 집합 대신 enum을 언제 사용해야 하나요?

  • 고정되고 알려진 값 집합 중 하나만 될 수 있는 타입이 필요할 때. — 맞아요! enum은 flagoption, 요일, 상태 코드처럼 닫힌 옵션 집합이 있을 때 아주 적합해요. 컴파일러가 모든 경우를 다루도록 보장해 줘요.
  • 어떤 문자열이든 될 수 있는 사용자 입력을 저장해야 할 때. — 아니에요. 사용자 입력은 예측이 안 되고 무엇이든 될 수 있어요. enum은 모든 가능한 값을 미리 나열할 수 있을 때 가장 잘 작동해요.
  • 서로 다른 속성을 가진 여러 인스턴스를 만들고 싶을 때. — 아니에요. 그건 클래스가 할 일이에요. 각 enum 값은 싱글톤이라 값마다 인스턴스가 하나뿐이에요.
  • 값에 산술 연산을 수행해야 할 때. — 아니에요. 산술에는 intdouble을 사용하세요. enum은 숫자 값이 아니라 구별되는 범주를 나타내요(인덱스는 있지만요).

더 알아보기