Dart에서 추상 클래스와 인터페이스 클래스는 모두 코드를 구성하고 객체 간의 상호 작용을 정의하는 데 사용되는 중요한 개념입니다.둘 모두 객체를 생성할 수 없고 대신 클래스를 제작하기 위한 설계가 됩니다. 객체를 설계하고, 객체 간의 상호 작용을 정의하며, 코드 재사용성을 높이는 데 중요한 역할을 하는 것이죠.

abstract class

추상 클래스는 설계도와 같다. 그런데 공사는 조금 진행이 되었을 수도 있다. 출처: copilot


추상 클래스(abstract class)는 추상 메서드(구현되지 않은 메서드)와 구체적인 메서드(구현된 메서드) 모두(속성까지!)를 포함할 수 있는 클래스입니다. 완성이 되지 않은 클래스이며 클래스를 만들기 위한 설계도와 같습니다. 추상 클래스로는 인스턴스를 직접 생성할 수 없고 상속을 통해 자식 클래스에서 인스턴스를 생성하여야 합니다. 상속을 받은 클래스는 추상 클래스의 속성과 메서드를 가지고 있습니다. 상속을 받은 클래스는 추상 메서드를 반드시 구현하여야 합니다. 그렇지 않으면 상속받은 클래스도 인스턴스를 생성할 수 없는 추상 클래스가 되기 때문입니다. 정리하자면 추상 클래스는 공통된 특징을 가진 클래스를 정의하고, 이를 상속받아 구체적인 동작을 구현할 수 있는 특수한 클래스입니다.

 

다음의 예시를 보시죠.

void main() {
  var man = Person();

  print(man.name);
  man.walk();
  man.eat();
}

abstract class AbstractPerson {
  late String name;

  void walk() {
    print('$name이 걷습니다.');
  }

  void eat() {}
}

class Person extends AbstractPerson {
  @override
  String name = '사람';

  @override
  void eat() {
    print('$name이 먹습니다.');
  } 
}

AbstractPerson는 추상 클래스이며 구체적인 메서드인 walk와 추상 메서드 eat을 가지고 있습니다. 이 설계도를 가지고 완성된 클래스를 만든 것이 바로 Person 클래스입니다. Person 클래스에서는 name 필드를 초기화하고, eat 메서드를 오버라이드하여 구현합니다. Person 클래스에는 walk를 정의하지 않았음에도 walk 메서드를 사용할 수 있습니다. AbstractPerson에서 name은 상속받을 클래스에서 할당할 것이기때문에 late 키워드가 필요합니다.

interface

인터페이스는 클래스가 어떻게 구성되어야 하는지 정해놓은 계약서와 같다. 출처: copilot


인터페이스(interface)는 클래스를 만들기 위한 일종의 "계약서"입니다. 추상 클래스가 클래스를 만드는 설계도인 것과 달리 인터페이스는 클래스가 가지고 있어야 하는 메서드와 속성의 구성에 대해 적어 놓기만 할 뿐입니다. 다만 계약서의 강제력은 강해서 인터페이스는 클래스의 행동(또는 속성)을 정의하고, 클래스가 이러한 행동을 구현하도록 강제합니다. 클래스가 인터페이스를 사용하려면 계약서의 모든 내용을 구현하여야 하는 것이죠.

 

Dart의 공식 블로그에서도 interface를 다른 클래스가 구현해야될 계약이라고 표현하고 있습니다.

 

인터페이스는 클래스의 행동을 정의하고, 클래스가 이러한 행동을 구현하도록 강제하는 강력한 도구입니다. 하지만 인터페이스 자체는 클래스가 아니기 때문에 인스턴스를 생성할 수는 없습니다. 인터페이스는 클래스의 구성을 명시적으로 정의하고, 인터페이스를 사용하려는 클래스에서 해당 구성을 따르고자 할 때 사용됩니다. 정리하자면, 추상 클래스와 인터페이스는 모두 객체 지향 프로그래밍에서 다형성을 구현하는 데 사용되는 중요한 개념이지만, 추상 클래스는 구현을 포함할 수도 있지만 인터페이스는 선언만을 포함합니다.

다음 예시를 보시죠.

void main() {
  var man = Person();

  print(man.name);
  man.walk();
  man.eat();
}

interface class InterfacePerson {
  late String name;

  void walk() {}

  void eat() {}
}

class Person implements InterfacePerson {
  @override
  String name = '사람';

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

  @override
  void eat() {
    print('$name이 먹습니다.');
  }
}

위의 추상 클래스와 비슷하지만 walk가 클래스를 생성할 때 구현해야 된다는 점이 달라졌습니다. 실제로 InterfacePerson에 walk를 구현해도 오류가 발생하지는 않지만 그렇다고 추상 클래스처럼 Person에 walk를 구현하지 않는다면 구현이 필요하다는 에러를 볼 수 있습니다. 결국 추상 클래스와 인터페이스의 가장 큰 차이점은 구체적인 메서드나 속성을 자식에게 상속해 줄 수 있는지 없는지에서 볼 수있습니다. 이러한 차이로 인해 추상 클래스는 클래스의 일부 구현을 공유하고 확장하는 데 사용되는 반면, 인터페이스는 클래스 간의 계약을 정의하고 클래스가 특정 행동을 구현하도록 강제하는 데 사용됩니다.

abstract와 interface의 역할

앞서 언급한 구체적인 메서드의 보유 여부 외에도 다른 차이점이 있습니다. 하나의 클래스는 여러 인터페이스의 계약에 맞추어 구현(implements)할 수 있지만 추상 클래스의 경우 단 하나의 추상 클레스만 확장(extends)할 수 있습니다. 만약 클래스가 여러 클래스를 상속받을 필요가 있다면 mixin을 사용하여야 하죠. mixin은 다음에 살펴보기로 하고 먼저 abstract와 interface를 각각 어떤 시점에 사용해야 될지 살펴보겠습니다.

추상 클래스

공통된 속성과 기능을 가진 클래스들의 계층 구조를 정의하는 데 사용됩니다. 여기서 일부 메서드는 추상적으로 남겨 자식 클래스에 구체적인 구현(@override)을 위임할 수 있습니다. 직접 인스턴스를 만들지는 않지만 다른 클래스에 상속해 주어 코드 재사용성을 높일 수 있습니다. 
다시 말해, 공통적인 속성과 기능을 가진 클래스 계층 구조를 정의하고, 하위 클래스에서 이를 상속받아 (선택적으로)특화된 기능을 추가 또는 재정의하는 경우에 추상 클래스를 사용하는 것이 좋습니다.

인터페이스 클래스

클래스들이 서로 어떻게 상호 작용해야 하는지 명확하고 예측 가능한 방식으로 규정하는 데 사용됩니다. 이렇게 만들어진 규정은 코드의 유연성을 높이고 다형성을 구현하는 데 도움이 됩니다. 인터페이스의 이러한 특징으로 인해 특정 기능을 제공하는 클래스를 작성하도록 외부 클래스를 규제하는 데 유용합니다. 인터페이스는 서로 다른 클래스 간의 공통적인 행동을 (구체적인 구현 방식을 완전히 숨기고 추상적으로)정의하고, 각 클래스에서 자유로운 구현을 허용해야 하는 경우에 사용하기 좋습니다.

 

정리

사실 interface abstract로 사용하여도 문제가 발생하지 않을 겁니다. 실제로 Dart 3.0에서 interface가 등장하기 이전에 abstract를 interface처럼 사용하였기 때문입니다. Dart 2.x 버전에서 abstract class를 인터페이스처럼 사용하는 것이 일반적이었습니다. 하지만 코드의 명확한 의도 전달을 위해 interface와 abstract 키워드를 어느 정도 구분하여 사용하는 것이 좋을 것 같습니다.

 

P.S. 참고로 abstract 키워드와 interface 키워드를 함께 사용하는 abstract interface class도 작성할 수 있습니다. 다만 abstract 하나만 사용한 것과 큰 차이가 없는 것처럼 보입니다.

참고 자료

Abstract Classes and Interfaces – Flutter
Abstract Classes and Abstract Methods in Dart

Dart, Class modifiers

+ Recent posts