Dart 튜토리얼 8편: 상속·구성·확장(Mixins·Enums·Extension methods·Extension types)
요약
핵심 요지
문서가 설명하는 범위
mixin과on6 제약,mixin class7 개념enum선언, 멤버 추가,switch8와name9 사용extension method로 기존 타입에 멤버 추가하는 방식extension type의 “컴파일 타임 뷰”와representation type10 개념
읽는 시간: 24분 | 난이도: 초급
참고 자료
- Mixins - mixins, on clause, mixin class
- Enumerated types - enums, members, switch
- Extension methods - extension declarations, resolution
- Extension types - extension types, representation type, erasure
문제 상황
프로젝트가 커지면 “비슷한 기능”이 여러 곳에서 반복됩니다.
이때 가장 쉬운 선택은 상속입니다.
하지만 상속은 한 번 깊어지면 구조를 바꾸기 어렵습니다.
이런 이유로, “상속만으로 해결하지 말고 목적에 맞는 도구를 고르자”는 방향에서 여러 기능을 함께 봐야 합니다.
이 글은 네 가지 도구를 한 번에 묶어 정리합니다.
어떤 상황에서 무엇을 쓰는지 기준을 세우는 것이 목표입니다.
해결 방법
단계 1: mixin으로 “기능 묶음”을 붙이기
Why
NOTE상속은 공통 동작을 재사용하기 좋지만, 계층이 깊어지면 구조 변경이 어려워집니다.
그래서 “타입 계층은 유지하면서 기능만 덧붙이는 방식”이 필요합니다.class A {void log() => print('log');}class B {void log() => print('log'); // 중복}
What
NOTE
mixin은 여러 클래스에서 재사용할 멤버 묶음을 정의하는 방법입니다.
with11를 사용해 클래스에mixin을 적용합니다.
또한on로 “어떤 타입에만 붙일 수 있는지”를 제한할 수 있습니다.
How
TIP
mixin은 여러 클래스에서 재사용할 멤버 묶음을 정의하는 방법입니다.
with를 사용해 클래스에mixin을 적용합니다.
또한on로 “어떤 타입에만 붙일 수 있는지”를 제한할 수 있습니다.mixin Musical {bool canPlayPiano = false;bool canCompose = false;bool canConduct = false;void entertainMe() {if (canPlayPiano) {print('Playing piano');} else if (canConduct) {print('Waving hands');} else {print('Humming to self');}}}class Musician with Musical {}
mixin은 상속이 아니기 때문에, “기능을 덧붙인다”는 느낌에 가깝습니다.
하지만 아무 클래스에나 붙이면 규칙이 흐려질 수 있습니다.
그래서on로 제약을 걸어, 필요한 필드나 메서드가 있는 타입에만 섞도록 범위를 좁힙니다.class Performer {void perform() => print('Performing');}mixin PerformerDuty on Performer {void callTime() => perform();}class Singer extends Performer with PerformerDuty {}
Watch out
WARNING
mixin은 상속이 아니기 때문에, “기능을 덧붙인다”는 느낌에 가깝습니다.
하지만 아무 클래스에나 붙이면 규칙이 흐려질 수 있습니다.
그래서on로 제약을 걸어, 필요한 필드나 메서드가 있는 타입에만 섞도록 범위를 좁힙니다.
결론: 공통 기능을 분리해 재사용하면서도, 적용 대상은 on로 제한해 설계를 흔들리지 않게 할 수 있습니다.
단계 2: enum으로 “상태”를 안전하게 모델링하기
Why
NOTE상태를 문자열로 다루면 오타/누락이 생기기 쉽습니다.
그래서 가능한 값의 집합을 타입으로 고정하는 편이 안전합니다.const status = 'approved';// if (status == 'aproved') {} // 오타가 나도 컴파일 단계에서 잡히지 않는다.
What
NOTE
enum은 가능한 값의 집합을 선언하는 방식입니다.
switch로 분기할 때enum을 쓰면 가독성이 좋아집니다.
또한 각 열거 값에는name속성이 있어 문자열 이름을 얻을 수 있습니다.
How
TIPenum Color { red, green, blue }void main() {final aColor = Color.blue;switch (aColor) {case Color.red:print('Red as roses!');case Color.green:print('Green as grass!');default:print(aColor);}print(Color.blue.name);}
Watch out
WARNING
enum은 단순한 값 목록만이 아니라, 멤버를 가질 수 있는 타입이기도 합니다.
즉, 값의 집합을 선언하면서 해당 값에 대한 계산도 한 곳에 둘 수 있습니다.
결론: 문자열 비교 대신 타입으로 상태를 다루고, switch로 분기를 정리해 실수를 줄일 수 있습니다.
단계 3: extension method로 기존 타입에 “새 API”를 붙이기
Why
NOTE기존 타입을 직접 수정할 수 없거나, 수정하고 싶지 않은 상황이 많습니다.
그래서 “타입은 그대로 두고, 필요한 멤버만 추가하는 방식”이 필요합니다.// String에 parseInt 같은 메서드가 있으면 편하지만, String을 직접 수정할 수는 없다.
What
NOTE
extension method는 기존 타입을 수정하지 않고 새 멤버를 추가하는 방법입니다.
확장 선언에서on12으로 대상 타입을 지정합니다.
그리고 확장 안에서getter13,operator14, 일반 메서드를 모두 정의할 수 있다고 보여줍니다.
How
TIPextension NumberParsing on String {int parseInt() => int.parse(this);}void main() {print('42'.parseInt());}제네릭을 함께 쓰면, 호출한 쪽의 정적 타입에 따라 타입 매개변수가 묶입니다.
extension MyFancyList<T> on List<T> {int get doubleLength => length * 2;List<T> operator -() => reversed.toList();List<List<T>> split(int at) => [sublist(0, at), sublist(at)];}
Watch out
WARNING
extension method는 “해당 이름의 멤버가 없을 때” 주로 효과가 드러납니다.
기존 타입에 같은 이름의 멤버가 이미 있다면, 어느 쪽이 선택되는지(우선순위)가 코드 맥락에 따라 달라질 수 있습니다.
결론: 기존 타입을 감싸는 래퍼 클래스를 만들지 않고도, 프로젝트에 필요한 편의 API를 추가할 수 있습니다.
단계 4: extension type로 “새 타입처럼 보이게” 만들기
Why
NOTE값의 “의미”는 다르지만, 내부 표현은 같은 경우가 있습니다.
예를 들어int로 관리하되 “이 값은 그냥 숫자가 아니라 특정 규칙을 가진 값”이라고 표현하고 싶을 수 있습니다.// 같은 int라도 의미가 다른 값이 섞이면 실수가 생길 수 있다.int userId = 1;int productId = 2;
What
NOTE
extension type는 기존 값을 기반으로 “새 타입처럼” 보이게 만드는 선언입니다.
핵심 메시지는 두 가지입니다.
첫째,extension type은compile time15에 의미가 있지만, 컴파일 과정에서erasure16될 수 있습니다.
둘째,extension type은run time17에 별도의wrapper object18를 만들지 않을 “선택지”를 제공한다고 설명합니다.
일반wrapper class19가 더 안전할 수 있지만,extension type은wrapper object를 피할 수 있어 일부 상황에서 성능에 이점이 있을 수 있습니다.입문자 관점에서는 이렇게 이해하면 됩니다.
extension type은 “새 객체를 하나 더 만드는 것”이 아니라, “이 값은 이런 규칙으로만 쓰겠다”를 타입으로 표현하는 도구입니다.
그래서representation type과 “어떤 멤버가 보이는지” 규칙을 먼저 잡아야 합니다.
How
TIP아래는 “가장 작은 형태 → 멤버 노출 규칙 → 생성자/검증 오해 포인트” 순서로 따라가는 구성입니다.
4-1) 가장 작은 형태:
representation type부터 정하기
extension type E(int i)처럼, 괄호 안에representation type을 둡니다.extension type E(int i) {// 허용할 연산(멤버) 집합을 정의한다.}이때 중요한 점은,
extension type에서 “보이게 할 멤버”를 내가 선택한다는 것입니다.
즉,representation type이 가진 멤버가 자동으로 전부 노출되는 방식이 아닙니다.4-2) “정의한 멤버만” 쓸 수 있다:
NumberE예시로 감 잡기
int를 기반으로NumberE를 만들고,operator·getter·메서드를 직접 정의할 수 있습니다.extension type NumberE(int value) {// 연산자:NumberE operator +(NumberE other) => NumberE(value + other.value);// getter:NumberE get next => NumberE(value + 1);// 메서드:bool isValid() => !value.isNegative;}아래처럼, “extension type의 정적 타입(
static type20)“에서 무엇이 가능한지/불가능한지를 확인할 수 있습니다.void testE() {var num1 = NumberE(1);int num2 = NumberE(2); // 오류: 'NumberE'를 'int'에 할당할 수 없다.num1.isValid(); // 가능: extension type이 정의한 멤버 호출.num1.isNegative(); // 오류: 'NumberE'는 'int'의 'isNegative' 멤버를 정의하지 않는다.var sum1 = num1 + num1; // 가능: 'NumberE'가 '+'를 정의한다.var diff1 = num1 - num1; // 오류: 'NumberE'는 'int'의 '-' 멤버를 정의하지 않는다.var diff2 = num1.value - 2; // 가능: representation type의 값(value)에 참조로 접근할 수 있다.var sum2 = num1 + 2; // 오류: 매개변수 타입 'NumberE'에 'int'를 넣을 수 없다.List<NumberE> numbers = [NumberE(1),num1.next, // 가능: 'next' getter는 'NumberE'를 반환한다.1, // 오류: 'int' 요소를 'NumberE' 리스트에 넣을 수 없다.];}이 예시는 Step 4의 목표(“새 타입처럼 보이게”)를 가장 직관적으로 보여줍니다.
int를 그대로 쓰면 가능한 연산이 너무 많습니다.
반면NumberE처럼 만들면, 타입이 허용하지 않는 사용은 컴파일 단계에서 막힙니다.4-3) 타입만 바뀌었다고 생성자가 자동 실행되지는 않는다
static type의 변화가 곧 생성자 호출을 의미하는 것은 아닙니다.
검증 같은 작업을 “생성자에서 반드시” 수행하고 싶다면,NumberE(i)처럼 명시적으로 생성자를 호출해야 한다고 안내합니다.아래는
int를representation type으로 삼아 “ID처럼 써야 하는 값”에 허용할 연산만 남기는 패턴입니다.
operator를 필요한 만큼만 노출해, 잘못된 연산을 컴파일 단계에서 막을 수 있습니다.extension type IdNumber(int id) {// 'int' 타입의 '<' 연산자를 감싼다.operator <(IdNumber other) => id < other.id;// 예를 들어 '+' 연산자는 선언하지 않는다.// ID에는 더하기가 의미 없기 때문이다.}void main() {// extension type로 규칙을 강제하지 않으면,// 'int'는 ID를 위험한 연산에 노출한다.int myUnsafeId = 42424242;myUnsafeId = myUnsafeId + 10; // 동작하지만, ID에는 허용되면 안 되는 연산이다.var safeId = IdNumber(42424242);safeId + 10; // 컴파일 오류: '+' 연산자가 없다.myUnsafeId = safeId; // 컴파일 오류: 타입이 맞지 않는다.myUnsafeId = safeId as int; // 가능: representation type으로 런타임 캐스팅.safeId < IdNumber(42424241); // 가능: 감싼 '<' 연산자를 사용한다.}
Watch out
WARNING
extension type은compile time에 의미가 있지만,erasure될 수 있습니다.
즉, 실행 중에는 “별도 객체로 감싼 타입”처럼 동작한다고 기대하면 오해가 생길 수 있습니다.또한
static type이 바뀐다고 해서 생성자가 자동으로 실행되는 것이 아닙니다.
검증/정규화가 필요하다면, 생성자 호출을 코드에서 명시해야 합니다.extension type IdNumber(int id) {}void main() {final id = IdNumber(1);// 여기에서 별도 검증 로직이 자동으로 실행되지는 않는다.}
결론: 코드에서는 새 타입처럼 다루면서도, wrapper object를 만들지 않는 선택지로 일부 상황에서는 비용을 줄일 수 있습니다.
Footnotes
-
extends(익스텐즈): 다른 클래스를 상속받아 새 클래스를 만드는 키워드다. ↩
-
mixin(믹스인): 여러 클래스에 재사용할 멤버 묶음을 섞어 넣는 기능이다. ↩
-
enum(열거형): 가능한 값의 집합을 선언하는 타입이다. ↩
-
extension method(확장 메서드): 기존 타입에 새 멤버를 추가하는 기능이다. ↩
-
extension type(확장 타입): 기존 값을 기반으로 새 타입처럼 보이게 하는 타입 선언이다. ↩
-
on(제약 절): mixin을 적용할 수 있는 타입을 제한하는 문법이다. ↩
-
mixin class(믹스인 클래스): 클래스와 mixin 양쪽으로 사용할 수 있는 선언 형태다. ↩
-
switch(스위치): 값에 따라 실행 경로를 나누는 분기 문법이다. ↩
-
name(이름 속성): enum 값의 이름 문자열을 제공하는 속성이다. ↩
-
representation type(표현 타입): extension type이 내부적으로 표현되는 실제 타입이다. ↩
-
with(위드): 클래스에 mixin을 적용하는 키워드다. ↩
-
on(대상 타입): extension method가 적용될 대상 타입을 지정하는 문법이다. ↩
-
getter(게터): 필드처럼 읽히지만 실제로는 값을 반환하는 메서드다. ↩
-
operator(연산자):
+같은 연산 기호로 동작을 정의하는 문법 요소다. ↩ -
compile time(컴파일 시점): 소스 코드를 분석하고 프로그램을 만들 때(컴파일러가 판단하는 단계)를 말한다. ↩
-
erasure(소거): 컴파일 과정에서 특정 타입 정보가 런타임에는 남지 않는 특성이다. ↩
-
run time(실행 시점): 프로그램이 실제로 실행 중일 때를 말한다. ↩
-
wrapper object(래퍼 객체): 다른 값을 감싸서 새로운 동작이나 제약을 제공하는 객체다. ↩
-
wrapper class(래퍼 클래스): 다른 값을 필드로 감싸 별도 객체로 보관하는 일반 클래스다. ↩
-
static type(정적 타입): 컴파일러가 변수/표현식에 대해 알고 있는 타입이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!