상속은 "A는 B다"라는 관계입니다. 개는 동물이고, 자동차는 탈것입니다. 그런데 "날 수 있다", "저장할 수 있다", "로그를 남길 수 있다" 같은 기능은 "있다/없다"의 문제지, 계층 관계가 아닙니다. 믹스인(mixin)은 이런 기능 조각을 독립적으로 만들고, 필요한 클래스에 조합해서 붙이는 방법입니다.
레고 블록을 생각해보세요. 기본 몸체(클래스)에 날개 블록, 무기 블록, 방어 블록을 원하는 대로 붙이는 것이 믹스인의 개념입니다.
mixin 선언
// 파일: main.dart
mixin Flyable {
double altitude = 0;
void fly() {
altitude = 1000;
print('${runtimeType}이(가) ${altitude}m 상공으로 날아오릅니다.');
}
void land() {
altitude = 0;
print('${runtimeType}이(가) 착지합니다.');
}
}
mixin Swimmable {
void swim() {
print('${runtimeType}이(가) 헤엄칩니다.');
}
}
mixin Runnable {
double speed = 0;
void run(double targetSpeed) {
speed = targetSpeed;
print('${runtimeType}이(가) 시속 ${speed}km로 달립니다.');
}
}
class Duck with Flyable, Swimmable, Runnable {
String name;
Duck(this.name);
}
class Penguin with Swimmable, Runnable {
String name;
Penguin(this.name);
}
class Hawk with Flyable, Runnable {
String name;
Hawk(this.name);
}
void main() {
var duck = Duck('오리');
duck.fly();
duck.swim();
duck.run(10);
print('');
var penguin = Penguin('펭귄');
penguin.swim();
penguin.run(30);
// penguin.fly(); // 오류: 펭귄에는 Flyable이 없음
print('');
var hawk = Hawk('매');
hawk.fly();
hawk.run(40);
}
mixin 이름 { } 으로 선언하고, 클래스에서는 with 믹스인명으로 붙입니다. 여러 믹스인은 쉼표로 나열합니다. extends와 with를 함께 쓸 때는 순서가 정해져 있습니다.
class MyClass extends BaseClass with Mixin1, Mixin2 { ... }
믹스인에 상태와 메서드 함께 사용
믹스인은 필드와 메서드를 모두 가질 수 있습니다. 붙여진 클래스에서 그대로 사용 가능합니다.
// 파일: main.dart
mixin Logger {
final List<String> _logs = [];
void log(String message) {
var timestamp = DateTime.now().toIso8601String().substring(11, 19);
var entry = '[$timestamp] $message';
_logs.add(entry);
print(entry);
}
void printLogs() {
print('--- 로그 목록 (${_logs.length}건) ---');
for (var l in _logs) print(l);
}
void clearLogs() => _logs.clear();
}
mixin Validator {
final Map<String, String> _errors = {};
void addError(String field, String message) {
_errors[field] = message;
}
bool get isValid => _errors.isEmpty;
void clearErrors() => _errors.clear();
void printErrors() {
if (_errors.isEmpty) {
print('유효성 검사 통과');
return;
}
print('--- 오류 목록 ---');
_errors.forEach((field, msg) => print(' $field: $msg'));
}
}
class RegistrationForm with Logger, Validator {
String username = '';
String email = '';
String password = '';
void validate() {
clearErrors();
log('유효성 검사 시작');
if (username.isEmpty) addError('username', '사용자명을 입력하세요.');
if (!email.contains('@')) addError('email', '올바른 이메일 형식이 아닙니다.');
if (password.length < 8) addError('password', '비밀번호는 8자 이상이어야 합니다.');
if (isValid) {
log('유효성 검사 통과');
} else {
log('유효성 검사 실패');
}
}
void submit() {
if (!isValid) {
log('제출 실패: 유효성 검사 오류');
printErrors();
return;
}
log('폼 제출 완료: $username');
}
}
void main() {
var form = RegistrationForm()
..username = 'dartuser'
..email = 'invalid-email'
..password = '123';
form.validate();
form.submit();
form.printLogs();
}
RegistrationForm은 Logger와 Validator의 기능을 동시에 가집니다. 두 믹스인은 서로 독립적으로 재사용할 수 있습니다.
on 제약
믹스인을 특정 클래스(또는 그 자식 클래스)에만 붙일 수 있도록 제한하는 것이 on 키워드입니다. 믹스인이 특정 클래스의 기능에 의존할 때 씁니다.
// 파일: main.dart
class Animal {
String name;
Animal(this.name);
void breathe() => print('$name이(가) 호흡합니다.');
}
// Animal(또는 그 자식)에만 적용 가능
mixin Domestic on Animal {
String owner = '주인 없음';
void greetOwner() {
// Animal의 name 필드에 접근 가능
print('$name이(가) $owner에게 인사합니다.');
}
void setOwner(String ownerName) {
owner = ownerName;
print('$name의 새 주인: $ownerName');
}
}
mixin Wild on Animal {
String territory = '영역 없음';
void markTerritory(String area) {
territory = area;
print('$name이(가) $area를 영역으로 표시합니다.');
}
void hunt() {
print('$name이(가) $territory에서 사냥합니다.');
}
}
class Dog extends Animal with Domestic {
Dog(String name) : super(name);
}
class Wolf extends Animal with Wild {
Wolf(String name) : super(name);
}
// class Robot with Domestic { } // 오류: Robot은 Animal이 아님
void main() {
var dog = Dog('바둑이');
dog.breathe();
dog.setOwner('김철수');
dog.greetOwner();
print('');
var wolf = Wolf('늑대');
wolf.breathe();
wolf.markTerritory('북쪽 숲');
wolf.hunt();
}
on Animal이 붙은 믹스인 안에서는 Animal의 name 필드에 직접 접근할 수 있습니다. on 제약 덕분에 타입이 보장되기 때문입니다.
다중 믹스인과 충돌
같은 이름의 메서드를 가진 믹스인을 여러 개 붙이면 마지막으로 붙인 것이 적용됩니다. Dart는 선형화(linearization) 방식으로 충돌을 해결합니다.
// 파일: main.dart
mixin A {
String greet() => 'A의 인사';
}
mixin B {
String greet() => 'B의 인사';
}
mixin C {
String greet() => 'C의 인사';
}
class MyClass with A, B, C {
// greet()는 마지막 C의 것이 적용됨
}
class AnotherClass with C, B, A {
// greet()는 마지막 A의 것이 적용됨
}
void main() {
print(MyClass().greet()); // C의 인사
print(AnotherClass().greet()); // A의 인사
}
믹스인 순서가 중요합니다. 나중에 나열한 것이 우선순위를 갖습니다.
믹스인 vs 상속 vs 인터페이스
세 가지 관계 방식을 비교합니다.
| 구분 | extends (상속) | implements (인터페이스) | with (믹스인) |
|---|---|---|---|
| 목적 | "A는 B다" 관계 | "A는 B를 할 수 있다" 약속 | 기능 조합 |
| 구현 상속 | 받음 | 없음 (직접 구현) | 받음 |
| 개수 제한 | 1개 | 무제한 | 무제한 |
| 인스턴스 생성 | 가능 | 가능 | 직접 불가 |
| 상태(필드) | 가능 | 가능 | 가능 |
Flutter에서 AnimationController를 쓰려면 with SingleTickerProviderStateMixin을 사용합니다. 이것이 실제 프로젝트에서 믹스인을 만나는 가장 흔한 방식입니다. PART 05를 익히고 나면 이 패턴이 자연스럽게 읽힐 것입니다.
다음 챕터에서는 선택지를 타입으로 표현하는 열거형(enum)을 배웁니다. Dart의 열거형은 단순한 상수 나열을 넘어 필드와 메서드까지 가질 수 있는 강력한 기능입니다.