iBetter Books
수정

상속은 부모의 구현을 물려받는 것입니다. 그런데 때로는 구현이 아니라 "이런 기능을 반드시 제공해야 한다"는 약속만 정하고 싶을 때가 있습니다. 결제 시스템을 예로 들면, 카드결제와 계좌이체는 동작이 다르지만 둘 다 "결제할 수 있어야 하고, 환불할 수 있어야 한다"는 공통된 약속이 필요합니다. 이 역할을 추상 클래스와 인터페이스가 담당합니다.

abstract class

abstract 키워드를 붙인 클래스는 직접 인스턴스를 만들 수 없습니다. 자식 클래스가 반드시 구현해야 할 메서드를 선언해두는 용도입니다.

// 파일: main.dart
abstract class Animal {
  String name;

  Animal(this.name);

  // 추상 메서드: 바디 없음, 자식이 반드시 구현해야 함
  void makeSound();
  String get habitat;

  // 일반 메서드: 기본 구현 있음
  void breathe() {
    print('$name이(가) 숨을 쉽니다.');
  }

  void introduce() {
    print('나는 $name입니다. 서식지: $habitat');
    makeSound();
  }
}

class Dog extends Animal {
  Dog(String name) : super(name);

  @override
  void makeSound() => print('$name: 왈왈!');

  @override
  String get habitat => '가정';
}

class Eagle extends Animal {
  Eagle(String name) : super(name);

  @override
  void makeSound() => print('$name: 끼이익!');

  @override
  String get habitat => '산악 지대';
}

void main() {
  // var a = Animal('동물');  // 오류: abstract class는 인스턴스 불가

  var dog = Dog('바둑이');
  var eagle = Eagle('독수리');

  dog.introduce();
  eagle.introduce();

  List<Animal> animals = [Dog('해피'), Eagle('청수리'), Dog('코코')];
  for (var a in animals) {
    a.makeSound();
  }
}

추상 클래스에는 추상 메서드와 일반 메서드를 섞어 쓸 수 있습니다. breathe()처럼 공통 구현은 그대로 물려주고, makeSound()처럼 동물마다 다른 동작은 자식에게 맡깁니다.

implements

Dart에서 특이한 점이 있습니다. 모든 클래스는 암묵적으로 인터페이스를 정의합니다. implements 키워드를 사용하면 어떤 클래스든 인터페이스로 사용할 수 있습니다. 단, 인터페이스로 사용할 때는 해당 클래스의 모든 멤버를 직접 구현해야 합니다.

// 파일: main.dart
abstract class Printable {
  void print();
}

abstract class Saveable {
  void save(String path);
  Future<void> saveAsync(String path);
}

class Document implements Printable, Saveable {
  final String content;
  final String title;

  Document(this.title, this.content);

  @override
  void print() {
    // Dart의 print 함수와 이름이 겹치므로 전체 경로 지정
    // ignore: avoid_print
    dart_print('=== $title ===\n$content');
  }

  @override
  void save(String path) {
    // ignore: avoid_print
    dart_print('$title을(를) $path에 저장합니다.');
  }

  @override
  Future<void> saveAsync(String path) async {
    await Future.delayed(const Duration(milliseconds: 100));
    // ignore: avoid_print
    dart_print('$title을(를) $path에 비동기 저장 완료.');
  }
}

// 충돌 방지용 별칭
void dart_print(String msg) => print(msg);

void main() async {
  var doc = Document('Dart 안내서', 'Dart는 구글이 만든 언어입니다.');
  doc.print();
  doc.save('/docs/dart_guide.txt');
  await doc.saveAsync('/backup/dart_guide.txt');
}

extends vs implements

이 두 키워드의 차이를 명확히 정리합니다.

// 파일: main.dart
class Logger {
  void log(String message) {
    print('[LOG] $message');
  }

  void error(String message) {
    print('[ERROR] $message');
  }
}

// extends: log(), error() 구현을 물려받음
class FileLogger extends Logger {
  final String filePath;
  FileLogger(this.filePath);

  @override
  void log(String message) {
    super.log(message);
    print('  → $filePath에 기록');
  }
}

// implements: log(), error()를 직접 구현해야 함
class RemoteLogger implements Logger {
  final String endpoint;
  RemoteLogger(this.endpoint);

  @override
  void log(String message) {
    print('[REMOTE] $message → $endpoint');
  }

  @override
  void error(String message) {
    print('[REMOTE ERROR] $message → $endpoint');
  }
}

void main() {
  Logger fileLog = FileLogger('/var/log/app.log');
  Logger remoteLog = RemoteLogger('https://log.example.com');

  fileLog.log('서비스 시작');
  remoteLog.log('서비스 시작');
  remoteLog.error('연결 실패');
}
구분 extends implements
구현 상속 받음 받지 않음
반드시 구현할 것 abstract 메서드만 모든 멤버
동시 사용 개수 1개만 여러 개 가능

다중 인터페이스

Dart는 단일 상속(extends는 하나)이지만, implements는 여러 개를 동시에 사용할 수 있습니다.

// 파일: main.dart
abstract class Flyable {
  double altitude;
  Flyable(this.altitude);
  void fly();
  void land();
}

abstract class Swimmable {
  double depth;
  Swimmable(this.depth);
  void swim();
  void surface();
}

abstract class Walkable {
  void walk();
  void run();
}

// 오리는 날고, 헤엄치고, 걸을 수 있음
class Duck implements Flyable, Swimmable, Walkable {
  final String name;

  @override
  double altitude = 0;

  @override
  double depth = 0;

  Duck(this.name);

  @override
  void fly() {
    altitude = 100;
    print('$name이(가) ${altitude}m 상공을 납니다.');
  }

  @override
  void land() {
    altitude = 0;
    print('$name이(가) 착지합니다.');
  }

  @override
  void swim() {
    depth = 0.5;
    print('$name이(가) 물 위를 헤엄칩니다.');
  }

  @override
  void surface() {
    depth = 0;
    print('$name이(가) 수면으로 올라옵니다.');
  }

  @override
  void walk() {
    print('$name이(가) 뒤뚱뒤뚱 걷습니다.');
  }

  @override
  void run() {
    print('$name이(가) 빠르게 뒤뚱거립니다.');
  }
}

void makeFly(Flyable f) => f.fly();
void makeSwim(Swimmable s) => s.swim();

void main() {
  var duck = Duck('도널드');
  duck.fly();
  duck.swim();
  duck.walk();

  // 각 인터페이스 타입으로도 사용 가능
  makeFly(duck);
  makeSwim(duck);
}

Duck 하나가 Flyable, Swimmable, Walkable 세 가지 역할을 모두 수행합니다. makeFly 함수는 Flyable 인터페이스만 알고 있지만, Duck을 전달하면 정상 동작합니다.

extends와 implements 혼용

extendsimplements를 함께 쓸 수도 있습니다.

// 파일: main.dart
abstract class Vehicle {
  String brand;
  Vehicle(this.brand);
  void move();
  void stop();
}

abstract class Electric {
  int batteryLevel;
  Electric(this.batteryLevel);
  void charge();
  int get range;
}

class ElectricCar extends Vehicle implements Electric {
  @override
  int batteryLevel;

  ElectricCar(String brand, this.batteryLevel) : super(brand);

  @override
  void move() => print('$brand 전기차가 조용히 달립니다.');

  @override
  void stop() => print('$brand 전기차가 멈춥니다. 회생제동 작동.');

  @override
  void charge() => print('$brand 충전 중... 현재 $batteryLevel%');

  @override
  int get range => batteryLevel * 5; // 1%당 5km 가정
}

void main() {
  var car = ElectricCar('Ioniq 6', 80);
  car.move();
  car.stop();
  car.charge();
  print('주행 가능 거리: ${car.range}km');
}

추상 클래스와 인터페이스는 코드의 유연성을 높입니다. 구현체가 바뀌어도 인터페이스 타입만 맞으면 코드 수정 없이 교체할 수 있습니다. 이것이 객체지향 설계의 핵심 원칙 중 하나입니다.

다음 챕터에서는 Dart만의 독특한 기능인 믹스인(mixin)을 배웁니다. 상속 계층 없이 기능을 조합하는 방법입니다.