Dart 튜토리얼 9편: 클래스 제어자와 API 설계(class modifiers)

요약#

핵심 요지#

  • 문제 정의: 외부에 공개한 타입(= 다른 코드에서 가져다 쓸 수 있는 타입)을 다른 사람이 extends1·implements2·with3로 마음대로 재사용하면, 나중에 내부 구현을 바꾸기 어렵다.
  • 핵심 주장: Dart의 class modifiers4는 타입을 “만들기(construct5)”·“상속(extend6)”·“구현(implement7)”·“믹스인 적용(mix in8)” 중 무엇을 허용할지 의도적으로 고르게 만든다.
  • 주요 근거: abstract9, base10, interface11, final12, sealed13은 목적과 제약이 다르고, 조합 가능/불가 규칙도 따로 정리되어 있다.
  • 실무 기준: 외부가 “코드를 재사용해도 되는지”(extend), “인터페이스만 다시 구현해도 되는지”(implement), “상속 계층을 닫아야 하는지”(final)부터 결정한다.

문서가 설명하는 범위#

  • abstract / base / interface / final / sealed의 의미와 예시
  • sealed로 서브타입 집합을 제한하고 switch14에서 exhaustiveness checking15을 활용하는 흐름
  • class modifiers for API maintainers16가 제시하는 적용 순서와 버전 변경 주의점
  • 조합 가능/불가와 결과 기능을 정리한 modifier reference17

읽는 시간: 22분 | 난이도: 중급


참고 자료#


문제 상황#

클래스는 “기능”이자 “약속”입니다.
내가 만든 타입을 다른 코드가 extendsimplements로 쓰기 시작하면, 그 순간부터 변경이 조심스러워집니다.
내부 구현을 바꾸는 것만으로도 외부의 상속 클래스가 깨질 수 있기 때문입니다.
Dart는 이 문제를 해결하기 위해, 타입 재사용 방식을 제한하는 class modifiers를 제공합니다.

이 글은 입문자가 “그래서 언제 어떤 제어자를 붙이지?”를 빠르게 결정할 수 있도록, 기준을 단계별로 정리합니다.


해결 방법#

핵심은 “내 타입이 외부에서 무엇을 할 수 있어야 하는가”를 먼저 정하는 것입니다.
modifier reference는 이를 Construct? / Extend? / Implement? / Mix in? / Exhaustive?로 정리합니다.

먼저 이 표를 한 번 보고, 뒤에서 각 제어자를 예시로 확인하면 이해가 빠릅니다.
표의 열은 “가능/불가”를 뜻하며, exhaustive18switch에서 완전성 검사와 연결되는 항목입니다.

선언Construct?Extend?Implement?Mix in?Exhaustive?
class
abstract class
base class
interface class
final class
sealed class

단계 1: abstract로 “직접 생성은 막고, 설계의 뼈대”만 제공하기#

Why#

NOTE

타입이 “완성되지 않은 상태”인데도 바로 생성할 수 있으면, 사용자가 잘못된 상태로 사용하기 쉽습니다.
그래서 “직접 생성은 막고, 구현/상속을 통해 완성하게 하는” 장치가 필요합니다.

abstract class Vehicle {
void moveForward(int meters);
}
// Vehicle(); // 컴파일 오류: abstract는 직접 생성할 수 없다.

What#

NOTE

abstract class는 인스턴스를 직접 만들 수 없습니다.
대신 상속하거나 구현해서 구체 타입을 만들게 유도합니다.

How#

TIP
abstract class Vehicle {
void moveForward(int meters);
}
import 'a.dart';
// 오류: `Vehicle`은 `abstract`로 표시되어 직접 생성할 수 없다.
Vehicle myVehicle = Vehicle();
// 상속 가능.
class Car extends Vehicle {
int passengers = 4;
@override
void moveForward(int meters) {
}
}
// 구현 가능.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
}
}

Watch out#

WARNING

abstract class는 “직접 생성은 불가”일 뿐, 상속/구현은 열려 있습니다.
즉, API 설계 목적이 “상속을 막고 싶다”인 경우에는 다른 제어자를 함께 고려해야 합니다.

결론: “이 타입은 직접 만들지 말고, 구현하거나 상속해서 쓰라”는 의도를 컴파일 단계에서 강제할 수 있습니다.


단계 2: interface vs base로 “코드 재사용”을 허용할지 결정하기#

Why#

NOTE

외부가 내 타입을 “상속해서 코드 재사용”할지, “구현만 해서 인터페이스를 맞출지”를 열어두면 변경이 어려워집니다.
그래서 재사용 형태를 미리 선택해 두는 편이 유지보수에 유리합니다.

// 외부가 extends/implements 중 무엇을 할 수 있는지부터 정해야 한다.

What#

NOTE

interfacebase를 비교할 때는, “코드 재사용”을 허용할지부터 생각하면 됩니다.

  • interface: 사용자가 “내 코드를 재사용”하는 것은 막되, “인터페이스를 다시 구현”하는 것은 허용한다.
  • base: 사용자가 내 타입을 쓰려면 “내 코드를 재사용”하도록 강제하고, 타입의 인스턴스가 실제 클래스/서브클래스라는 성질을 유지한다.

How#

TIP

interface class는 다른 library19에서 상속은 막고, 구현은 허용하는 흐름으로 이해하면 됩니다.

interface class Vehicle {
void moveForward(int meters) {
}
}
import 'a.dart';
// 생성 가능.
Vehicle myVehicle = Vehicle();
// 오류: `Vehicle`은 `interface`로 표시되어 다른 library에서 상속할 수 없다.
class Car extends Vehicle {
int passengers = 4;
}
// 구현 가능.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
}
}

Watch out#

WARNING

아래 예시의 import 'a.dart';는 “서로 다른 library” 상황을 만들기 위한 장치입니다.
즉, 같은 파일/라이브러리 안에서는 제약이 다르게 보일 수 있습니다.

base class는 반대 방향으로, 외부가 “구현만” 해서 타입을 흉내 내는 것을 제한합니다.

base class Vehicle {
void moveForward(int meters) {
}
}
import 'a.dart';
// 생성 가능.
Vehicle myVehicle = Vehicle();
// 상속 가능.
base class Car extends Vehicle {
int passengers = 4;
}
// 오류: `Vehicle`은 `base`로 표시되어 다른 library에서 구현할 수 없다.
base class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
}
}

이 예시에서 import 'a.dart';는 “different library” 상황을 만들기 위한 장치입니다.
즉, Vehicle을 정의한 코드와, Vehicle을 사용하는 코드가 서로 다른 library에 있다고 가정합니다.

결론: 외부가 “상속으로 코드 재사용”을 해야 하는지, “구현으로 인터페이스만 맞추면 되는지”를 의도적으로 선택할 수 있습니다.


단계 3: final로 “상속/구현을 모두 닫기”#

Why#

NOTE

외부가 상속/구현을 시작하면, 공개 타입의 내부 구현 변경이 어려워질 수 있습니다.
그래서 “확장 지점이 필요 없는 타입”이라면 아예 닫아두는 편이 단순합니다.

final class Vehicle {
void moveForward(int meters) {}
}

What#

NOTE

final class은 다른 library에서 상속과 구현을 모두 제한합니다.
즉, 사용자는 해당 타입을 그대로 생성해서 쓰는 방식만 남습니다.

How#

TIP
final class Vehicle {
void moveForward(int meters) {
}
}
import 'a.dart';
// 생성 가능.
Vehicle myVehicle = Vehicle();
// 오류: `Vehicle`은 `final`로 표시되어 다른 library에서 상속할 수 없다.
class Car extends Vehicle {
int passengers = 4;
}
// 오류: `Vehicle`은 `final`로 표시되어 다른 library에서 구현할 수 없다.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
}
}

Watch out#

WARNING

final class은 “상속/구현”을 막습니다.
즉, 테스트에서 구현체를 만들어 대체하고 싶다면, 설계 단계에서 다른 선택(예: interface class)이 필요할 수 있습니다.

결론: 타입의 확장 지점을 원천 봉쇄해서, 공개 API의 변경 범위를 단순화할 수 있습니다.


단계 4: sealed로 “서브타입 집합”을 고정하고 switch를 더 안전하게 만들기#

Why#

NOTE

가능한 상태/종류가 정해져 있는데, 외부가 임의로 서브타입을 추가할 수 있으면 분기 로직이 깨질 수 있습니다.
그래서 “가능한 서브타입 집합”을 닫아, 분기를 더 안전하게 만들고 싶을 수 있습니다.

sealed class Vehicle {}
class Car extends Vehicle {}
class Bicycle extends Vehicle {}

What#

NOTE

sealed class는 암묵적으로 abstract이기도 해서 직접 생성할 수 없습니다.
대신 같은 library 안에서만 서브타입을 만들 수 있는 형태로, “타입 가족”을 닫는 데 사용합니다.

How#

TIP

이 특성은 switch와 결합될 때, exhaustiveness checking에 도움이 됩니다.

sealed class Vehicle {}
class Car extends Vehicle {}
class Truck implements Vehicle {}
class Bicycle extends Vehicle {}
// 오류: `Vehicle`은 `sealed`로 표시되어(암묵적으로 abstract) 직접 생성할 수 없다.
Vehicle myVehicle = Vehicle();
// `sealed`의 서브타입은(추가 제약이 없으면) 생성할 수 있다.
Vehicle myCar = Car();
extension VehicleSounds on Vehicle {
String get sound {
// 오류: `switch`가 `Vehicle`의 가능한 모든 경우를 다루지 않는다.
// 이 예시에서는 런타임 타입이 `Bicycle`인 `Vehicle`이 어떤 케이스에도 매칭되지 않는다.
return switch (this) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
};
}
}

Watch out#

WARNING

sealed class는 “서브타입을 닫는” 도구입니다.
즉, 서브타입을 계속 확장해야 하는 확장형 API에는 맞지 않을 수 있습니다.

결론: “이 타입은 이 서브타입들만 가능하다”는 가정이 성립해서, 분기 로직을 더 안전하게 작성할 수 있습니다.


적용 순서(가이드)#

새 제어자는 선택지를 늘립니다.
그래서 처음부터 한 번에 다 적용하지 않아도 됩니다.
기존 API를 유지하고 싶다면, 이미 믹스인으로 쓰이던 클래스에 mixin20을 명시하는 것부터 시작할 수 있습니다.
그리고 시간이 지나 더 강한 제어가 필요해지면 interface·base·final·sealed을 고려할 수 있습니다.

마지막으로, 이런 제어자 적용은 “제약을 추가”하는 변화입니다.
그래서 패키지 배포에서는 breaking changes21가 될 수 있으며, 이 경우 메이저 버전(major version) 증가를 고려해야 합니다.

Footnotes#

  1. extends(익스텐즈): 다른 클래스를 상속받아 새 클래스를 만드는 키워드다.

  2. implements(임플리먼츠): 다른 타입의 인터페이스를 구현하는 키워드다.

  3. with(위드): 클래스에 mixin을 적용하는 키워드다.

  4. class modifiers(클래스 제어자): 클래스/믹스인 선언에 붙여, 생성·상속·구현·믹스인 적용 가능 범위를 제한하거나 의미를 부여하는 키워드 묶음이다.

  5. construct(생성): 타입의 인스턴스를 만들 수 있는지(표의 Construct?)를 말한다.

  6. extend(상속): 다른 타입을 extends1로 상속해서 새 타입을 만들 수 있는지(표의 Extend?)를 말한다.

  7. implement(구현): 다른 타입을 implements2로 구현할 수 있는지(표의 Implement?)를 말한다.

  8. mix in(믹스인 적용): 다른 타입을 with3로 섞어 넣을 수 있는지(표의 Mix in?)를 말한다.

  9. abstract(추상): 인스턴스를 직접 만들 수 없고, 상속/구현을 통해 구체 타입을 만들도록 유도하는 제어자다.

  10. base(베이스): 외부에서 타입을 사용할 때 코드 재사용을 강제하는 방향으로 제약을 거는 제어자다.

  11. interface(인터페이스): 외부에서 상속을 막고, 인터페이스 재구현(implements2)을 허용하는 방향의 제어자다.

  12. final(파이널): 외부에서 상속과 구현을 모두 막는 제어자다.

  13. sealed(실드): 같은 library 안에서만 서브타입을 만들 수 있게 하여, 서브타입 집합을 닫는 제어자다.

  14. switch(스위치): 값에 따라 실행 경로를 나누는 분기 문법이다.

  15. exhaustiveness checking(완전성 검사): switch14가 가능한 모든 경우를 빠짐없이 다루는지 컴파일 단계에서 확인하는 기능이다.

  16. API maintainers(API 유지보수자): 공개 API를 제공하고, 변경이 사용자에게 영향을 주지 않도록 관리하는 입장의 사람을 말한다.

  17. modifier reference(제어자 레퍼런스): 제어자 조합별로 가능 동작(construct/extend/implement/mix in/exhaustive)을 표로 정리한 자료다.

  18. exhaustive(완전성): 표의 Exhaustive?로, sealed13 등과 결합해 switch14 완전성 검사와 연결되는 항목이다.

  19. library(라이브러리): Dart에서 파일/선언이 속한 코드 단위로, 예시의 “different library”는 다른 library에서 사용했을 때를 가정한 표현이다.

  20. mixin(믹스인): 여러 클래스에 재사용할 멤버 묶음을 섞어 넣는 선언/기능이다.

  21. breaking changes(호환성 깨짐 변경): 기존 사용자 코드가 동작/컴파일하지 않게 될 수 있는 변경을 말한다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Dart 튜토리얼 9편: 클래스 제어자와 API 설계(class modifiers)
https://moodturnpost.net/posts/dart/dart-class-modifiers-api-design/
작성자
Moodturn
게시일
2026-01-04
Moodturn

목차