iBetter Books
수정

클래스를 제어하다 — sealed와 class modifier

PART 05에서 클래스를 배울 때 abstract, extends, implements, with를 배웠습니다. 그런데 Dart 3.0은 클래스의 "사용 가능한 범위"를 제어하는 새로운 수식어들을 추가했습니다. 상속해도 되는지, 인터페이스로만 써야 하는지, 아예 더 이상 확장이 불가능한지 — 이것을 class modifier라고 합니다.

그 중에서도 sealed는 특별합니다. sealed class는 switch 표현식의 완전성 검사와 결합하여, 상태 관리에서 놀라운 타입 안전성을 제공합니다.

sealed class — 닫힌 클래스 계층

sealed는 클래스 계층을 같은 파일(라이브러리) 안으로 제한합니다. 다른 파일에서는 sealed class를 상속하거나 구현할 수 없습니다.

// 파일: shapes.dart

sealed class Shape {}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}

class Rectangle extends Shape {
  final double width;
  final double height;
  Rectangle(this.width, this.height);
}

class Triangle extends Shape {
  final double base;
  final double height;
  Triangle(this.base, this.height);
}

Shape가 sealed이므로 컴파일러는 이 파일 안에 Circle, Rectangle, Triangle 세 가지 서브클래스만 존재한다는 것을 알 수 있습니다.

이 지식을 활용하면 switch 표현식에서 _ 없이도 완전성 검사가 통과합니다.

double area(Shape shape) => switch (shape) {
  Circle(:var radius) => 3.14159 * radius * radius,
  Rectangle(:var width, :var height) => width * height,
  Triangle(:var base, :var height) => base * height / 2,
};

void main() {
  var shapes = [
    Circle(5.0),
    Rectangle(4.0, 6.0),
    Triangle(3.0, 8.0),
  ];

  for (var shape in shapes) {
    print('넓이: ${area(shape).toStringAsFixed(2)}');
  }
}

실행 결과입니다.

넓이: 78.54
넓이: 24.00
넓이: 12.00

Circle(:var radius)는 객체 패턴입니다. Circle 타입이고, 그 radius 필드를 radius라는 변수에 담습니다. :var radius:radius로 줄여 쓸 수도 있습니다.

만약 나중에 sealed class Shape 파일에 Hexagon 서브클래스를 추가하면, area 함수의 switch가 즉시 컴파일 오류를 냅니다. 처리를 추가하라는 신호입니다.

sealed class + switch = 타입 안전한 상태 관리

Flutter 앱에서 가장 흔한 패턴이 API 결과를 세 가지 상태로 표현하는 것입니다. 로딩 중, 성공, 오류. 이것을 sealed class로 설계해 봅시다.

sealed class ApiResult<T> {}

class Loading<T> extends ApiResult<T> {}

class Success<T> extends ApiResult<T> {
  final T data;
  Success(this.data);
}

class Failure<T> extends ApiResult<T> {
  final String message;
  final int? statusCode;
  Failure(this.message, {this.statusCode});
}

// sealed class를 switch 표현식으로 처리
String renderResult(ApiResult<String> result) => switch (result) {
  Loading() => '불러오는 중...',
  Success(:var data) => '완료: $data',
  Failure(:var message, statusCode: var code) =>
    code != null ? '오류 $code: $message' : '오류: $message',
};

void main() {
  List<ApiResult<String>> states = [
    Loading(),
    Success('사용자 목록 10명'),
    Failure('인증 실패', statusCode: 401),
    Failure('연결 없음'),
  ];

  for (var state in states) {
    print(renderResult(state));
  }
}

실행 결과입니다.

불러오는 중...
완료: 사용자 목록 10명
오류 401: 인증 실패
오류: 연결 없음

ApiResult가 sealed이므로 Loading, Success, Failure 세 가지만 존재합니다. switch에서 세 경우를 모두 처리하면 _ 없이 완전성 검사가 통과합니다. 새로운 상태가 추가되면 처리하지 않은 switch들이 전부 컴파일 오류로 드러납니다.

class modifier 종류

Dart 3.0은 abstract 외에 다섯 가지 modifier를 추가했습니다. 각각 클래스의 사용 방식을 제한합니다.

base — 상속은 되지만 implements 불가

base class Animal {
  final String name;
  Animal(this.name);

  void breathe() => print('$name이 숨을 쉽니다.');
}

// 상속은 가능
class Dog extends Animal {
  Dog(super.name);
  void bark() => print('$name이 짖습니다.');
}

// implements는 불가 — 컴파일 오류
// class FakeAnimal implements Animal { ... }

void main() {
  var dog = Dog('바둑이');
  dog.breathe();
  dog.bark();
}

base class는 상속 계층을 통한 사용만 허용합니다. implements로 인터페이스처럼 쓰는 것을 막아, Animal의 구현이 항상 breathe()를 실제로 포함하도록 보장합니다.

interface — implements만 가능

interface class Serializable {
  String serialize() => throw UnimplementedError();
}

// implements는 가능
class Document implements Serializable {
  final String title;
  Document(this.title);

  @override
  String serialize() => '{"title": "$title"}';
}

// extends는 불가 — 컴파일 오류
// class SpecialDoc extends Serializable { ... }

interface class는 인터페이스로만 사용합니다. 상속(extends)을 막아, 계층 관계 없이 계약(contract)만 강제합니다.

final — 상속, implements 모두 불가

final class Config {
  final String apiUrl;
  final int timeout;

  Config({required this.apiUrl, required this.timeout});
}

// 상속 불가 — 컴파일 오류
// class ExtendedConfig extends Config { ... }

// implements 불가 — 컴파일 오류
// class FakeConfig implements Config { ... }

void main() {
  var config = Config(apiUrl: 'https://api.example.com', timeout: 30);
  print(config.apiUrl);
}

final class는 완전히 닫힌 클래스입니다. 외부에서 확장이나 변형이 불가능하므로, 클래스의 동작을 완벽히 보장해야 하는 핵심 설정 클래스 등에 사용합니다.

mixin class — class와 mixin 역할을 동시에

mixin class Logger {
  void log(String message) {
    print('[LOG] $message');
  }
}

// class로 상속
class BaseService extends Logger {}

// mixin으로 사용
class UserService with Logger {
  void createUser(String name) {
    log('사용자 생성: $name');
  }
}

void main() {
  var service = UserService();
  service.createUser('김다트');
}

mixin classextends로 상속하거나 with로 믹스인하는 두 가지 방식 모두 사용할 수 있습니다.

modifier 조합 규칙

modifier들은 일부 조합이 가능합니다. 자주 사용하는 조합입니다.

// abstract + base: 상속용 추상 클래스, implements 불가
abstract base class Repository<T> {
  Future<T?> findById(int id);
  Future<List<T>> findAll();
}

// abstract + interface: 인터페이스 전용 추상 클래스
abstract interface class Serializable {
  Map<String, dynamic> toJson();
}

// abstract + sealed: 밀봉된 추상 클래스 (자주 사용)
abstract sealed class Event {}

class ClickEvent extends Event {
  final int x;
  final int y;
  ClickEvent(this.x, this.y);
}

class KeyEvent extends Event {
  final String key;
  KeyEvent(this.key);
}

abstract sealed는 실무에서 자주 등장합니다. 상태 패턴이나 이벤트 처리에서 각 케이스를 concrete 클래스로 표현하고, 부모를 abstract sealed로 만듭니다.

실전 예제 — 결제 시스템

sealed class와 class modifier를 활용한 결제 시스템을 설계해 봅니다.

// 결제 방법 — sealed로 닫힌 계층
sealed class PaymentMethod {}

final class CreditCard extends PaymentMethod {
  final String cardNumber;
  final String holderName;
  CreditCard(this.cardNumber, this.holderName);
}

final class BankTransfer extends PaymentMethod {
  final String bankCode;
  final String accountNumber;
  BankTransfer(this.bankCode, this.accountNumber);
}

final class MobilePayment extends PaymentMethod {
  final String provider; // KakaoPay, NaverPay, etc.
  final String phoneNumber;
  MobilePayment(this.provider, this.phoneNumber);
}

// 결제 결과 — sealed + generic
sealed class PaymentResult {}

final class PaymentSuccess extends PaymentResult {
  final String transactionId;
  final int amount;
  PaymentSuccess(this.transactionId, this.amount);
}

final class PaymentFailure extends PaymentResult {
  final String reason;
  PaymentFailure(this.reason);
}

// 결제 처리
String describeMethod(PaymentMethod method) => switch (method) {
  CreditCard(:var holderName, cardNumber: var num) =>
    '신용카드 ($holderName, $num)',
  BankTransfer(:var bankCode, :var accountNumber) =>
    '계좌이체 ($bankCode-$accountNumber)',
  MobilePayment(:var provider, :var phoneNumber) =>
    '모바일결제 ($provider, $phoneNumber)',
};

PaymentResult processPayment(PaymentMethod method, int amount) {
  // 실제로는 외부 API를 호출하겠지만, 예제에서는 시뮬레이션합니다
  if (amount <= 0) {
    return PaymentFailure('금액이 유효하지 않습니다.');
  }
  return PaymentSuccess('TXN-${DateTime.now().millisecondsSinceEpoch}', amount);
}

void main() {
  var methods = [
    CreditCard('1234-5678-9012-3456', '홍길동'),
    BankTransfer('088', '123-456-789012'),
    MobilePayment('KakaoPay', '010-1234-5678'),
  ];

  for (var method in methods) {
    var result = processPayment(method, 15000);

    var message = switch (result) {
      PaymentSuccess(:var transactionId, :var amount) =>
        '결제 완료 — $transactionId (${amount}원)',
      PaymentFailure(:var reason) =>
        '결제 실패 — $reason',
    };

    print('${describeMethod(method)}: $message');
  }
}

실행 결과입니다.

신용카드 (홍길동, 1234-5678-9012-3456): 결제 완료 — TXN-... (15000원)
계좌이체 (088-123-456-789012): 결제 완료 — TXN-... (15000원)
모바일결제 (KakaoPay, 010-1234-5678): 결제 완료 — TXN-... (15000원)

새로운 결제 방법(예: CryptoCurrency)이 추가되면, describeMethod의 switch가 즉시 컴파일 오류를 냅니다. 처리를 잊을 수 없습니다. 이것이 sealed class와 switch 표현식이 함께 만들어내는 타입 안전한 상태 관리입니다.

PART 09 마무리

이 PART에서 Dart 3.0의 새로운 기능들을 배웠습니다. 레코드로 여러 값을 하나로 묶고, 패턴 매칭으로 값의 구조를 검사하고 분해했습니다. switch 표현식으로 분기를 값으로 만들고, 완전성 검사로 실수를 컴파일 타임에 잡았습니다. 구조 분해로 복합 값에서 필요한 부분을 손쉽게 꺼냈습니다. 그리고 sealed class와 class modifier로 클래스 계층의 사용 범위를 정밀하게 제어했습니다.

이 기능들은 서로 독립적이지 않습니다. 레코드 + 구조 분해, sealed class + switch 표현식 + 패턴 매칭처럼 함께 사용할 때 진가를 발휘합니다. 처음에는 새로운 문법이 낯설게 느껴질 수 있지만, 직접 코드를 써보면서 익히면 금방 손에 익습니다.

다음 PART에서는 지금까지 배운 모든 것을 활용하여 실제 CLI 앱을 만들어봅니다. 할 일 관리 앱을 처음부터 설계하고 구현하는 과정에서 Dart의 다양한 기능이 실전에서 어떻게 맞물리는지 경험할 수 있습니다.

Ch 05. 클래스를 제어하다 — sealed와 class modifier — 소설처럼 읽는 Dart | iBetter Books