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분 | 난이도: 중급
참고 자료
- Class modifiers - 각 제어자의 의미와 예시
- Class modifiers for API maintainers - 패키지 API 설계 관점의 적용 가이드
- Class modifiers reference - 조합/기능 표(construct/extend/implement/mix in/exhaustive)
문제 상황
클래스는 “기능”이자 “약속”입니다.
내가 만든 타입을 다른 코드가 extends나 implements로 쓰기 시작하면, 그 순간부터 변경이 조심스러워집니다.
내부 구현을 바꾸는 것만으로도 외부의 상속 클래스가 깨질 수 있기 때문입니다.
Dart는 이 문제를 해결하기 위해, 타입 재사용 방식을 제한하는 class modifiers를 제공합니다.
이 글은 입문자가 “그래서 언제 어떤 제어자를 붙이지?”를 빠르게 결정할 수 있도록, 기준을 단계별로 정리합니다.
해결 방법
핵심은 “내 타입이 외부에서 무엇을 할 수 있어야 하는가”를 먼저 정하는 것입니다.
modifier reference는 이를 Construct? / Extend? / Implement? / Mix in? / Exhaustive?로 정리합니다.
먼저 이 표를 한 번 보고, 뒤에서 각 제어자를 예시로 확인하면 이해가 빠릅니다.
표의 열은 “가능/불가”를 뜻하며, exhaustive18는 switch에서 완전성 검사와 연결되는 항목입니다.
| 선언 | 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
TIPabstract class Vehicle {void moveForward(int meters);}import 'a.dart';// 오류: `Vehicle`은 `abstract`로 표시되어 직접 생성할 수 없다.Vehicle myVehicle = Vehicle();// 상속 가능.class Car extends Vehicle {int passengers = 4;@overridevoid moveForward(int meters) {}}// 구현 가능.class MockVehicle implements Vehicle {@overridevoid moveForward(int meters) {}}
Watch out
WARNING
abstract class는 “직접 생성은 불가”일 뿐, 상속/구현은 열려 있습니다.
즉, API 설계 목적이 “상속을 막고 싶다”인 경우에는 다른 제어자를 함께 고려해야 합니다.
결론: “이 타입은 직접 만들지 말고, 구현하거나 상속해서 쓰라”는 의도를 컴파일 단계에서 강제할 수 있습니다.
단계 2: interface vs base로 “코드 재사용”을 허용할지 결정하기
Why
NOTE외부가 내 타입을 “상속해서 코드 재사용”할지, “구현만 해서 인터페이스를 맞출지”를 열어두면 변경이 어려워집니다.
그래서 재사용 형태를 미리 선택해 두는 편이 유지보수에 유리합니다.// 외부가 extends/implements 중 무엇을 할 수 있는지부터 정해야 한다.
What
NOTE
interface와base를 비교할 때는, “코드 재사용”을 허용할지부터 생각하면 됩니다.
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 {@overridevoid 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 {@overridevoid 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
TIPfinal 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 {@overridevoid 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
-
extends(익스텐즈): 다른 클래스를 상속받아 새 클래스를 만드는 키워드다. ↩
-
implements(임플리먼츠): 다른 타입의 인터페이스를 구현하는 키워드다. ↩
-
with(위드): 클래스에 mixin을 적용하는 키워드다. ↩
-
class modifiers(클래스 제어자): 클래스/믹스인 선언에 붙여, 생성·상속·구현·믹스인 적용 가능 범위를 제한하거나 의미를 부여하는 키워드 묶음이다. ↩
-
construct(생성): 타입의 인스턴스를 만들 수 있는지(표의 Construct?)를 말한다. ↩
-
extend(상속): 다른 타입을
extends1로 상속해서 새 타입을 만들 수 있는지(표의 Extend?)를 말한다. ↩ -
implement(구현): 다른 타입을
implements2로 구현할 수 있는지(표의 Implement?)를 말한다. ↩ -
mix in(믹스인 적용): 다른 타입을
with3로 섞어 넣을 수 있는지(표의 Mix in?)를 말한다. ↩ -
abstract(추상): 인스턴스를 직접 만들 수 없고, 상속/구현을 통해 구체 타입을 만들도록 유도하는 제어자다. ↩
-
base(베이스): 외부에서 타입을 사용할 때 코드 재사용을 강제하는 방향으로 제약을 거는 제어자다. ↩
-
interface(인터페이스): 외부에서 상속을 막고, 인터페이스 재구현(
implements2)을 허용하는 방향의 제어자다. ↩ -
final(파이널): 외부에서 상속과 구현을 모두 막는 제어자다. ↩
-
sealed(실드): 같은 library 안에서만 서브타입을 만들 수 있게 하여, 서브타입 집합을 닫는 제어자다. ↩
-
switch(스위치): 값에 따라 실행 경로를 나누는 분기 문법이다. ↩
-
exhaustiveness checking(완전성 검사):
switch14가 가능한 모든 경우를 빠짐없이 다루는지 컴파일 단계에서 확인하는 기능이다. ↩ -
API maintainers(API 유지보수자): 공개 API를 제공하고, 변경이 사용자에게 영향을 주지 않도록 관리하는 입장의 사람을 말한다. ↩
-
modifier reference(제어자 레퍼런스): 제어자 조합별로 가능 동작(construct/extend/implement/mix in/exhaustive)을 표로 정리한 자료다. ↩
-
exhaustive(완전성): 표의 Exhaustive?로,
sealed13 등과 결합해switch14 완전성 검사와 연결되는 항목이다. ↩ -
library(라이브러리): Dart에서 파일/선언이 속한 코드 단위로, 예시의 “different library”는 다른 library에서 사용했을 때를 가정한 표현이다. ↩
-
mixin(믹스인): 여러 클래스에 재사용할 멤버 묶음을 섞어 넣는 선언/기능이다. ↩
-
breaking changes(호환성 깨짐 변경): 기존 사용자 코드가 동작/컴파일하지 않게 될 수 있는 변경을 말한다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!