Extension / Mixin
Dart는 코드 재사용과 확장성을 위한 다양한 방법을 제공합니다. 이 장에서는 두 가지 강력한 기능인 Extension과 Mixin에 대해 알아보겠습니다. 이 두 기능은 기존 클래스를 수정하지 않고도 기능을 확장하거나 여러 클래스 간에 코드를 효율적으로 공유할 수 있게 해줍니다.
Extension(확장)
Section titled “Extension(확장)”Extension은 기존 클래스(심지어 라이브러리 클래스나 외부 라이브러리의 클래스)에 새로운 기능을 추가할 수 있는 방법입니다. 원본 클래스의 소스 코드를 수정하거나 상속할 필요 없이 새로운 메서드, 프로퍼티, 연산자를 추가할 수 있습니다.
extension <확장명> on <대상 타입> { // 메서드, 게터, 세터, 연산자 등 추가}
확장명은 선택 사항이지만, 확장을 명시적으로 가져오거나 충돌을 해결할 때 유용합니다.
예제: String 확장
Section titled “예제: String 확장”// String 클래스에 기능 추가extension StringExtension on String { // 첫 글자만 대문자로 변환 String get capitalize => isNotEmpty ? '${this[0].toUpperCase()}${substring(1)}' : '';
// 문자열이 이메일인지 확인 bool get isValidEmail => RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
// 문자열을 n번 반복 String repeat(int n) => List.filled(n, this).join();
// 문자열의 모든 단어를 대문자로 시작하도록 변환 String toTitleCase() { return split(' ') .map((word) => word.isNotEmpty ? '${word[0].toUpperCase()}${word.substring(1)}' : '') .join(' '); }}
void main() { String name = 'john doe'; print(name.capitalize); // John doe print(name.toTitleCase()); // John Doe print('hello'.repeat(3)); // hellohellohello print('test@example.com'.isValidEmail); // true print('invalid.email'.isValidEmail); // false}
예제: int 확장
Section titled “예제: int 확장”extension IntExtension on int { // 초를 시:분:초 형식으로 변환 String toTimeString() { int h = this ~/ 3600; int m = (this % 3600) ~/ 60; int s = this % 60; return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; }
// 숫자가 소수인지 확인 bool get isPrime { if (this <= 1) return false; if (this <= 3) return true; if (this % 2 == 0 || this % 3 == 0) return false; int i = 5; while (i * i <= this) { if (this % i == 0 || this % (i + 2) == 0) return false; i += 6; } return true; }
// 숫자의 팩토리얼 계산 int get factorial { if (this < 0) throw ArgumentError('음수의 팩토리얼은 정의되지 않습니다.'); if (this <= 1) return 1; return this * (this - 1).factorial; }}
void main() { int seconds = 3665; print(seconds.toTimeString()); // 01:01:05
print(7.isPrime); // true print(8.isPrime); // false
print(5.factorial); // 120}
예제: List 확장
Section titled “예제: List 확장”extension ListExtension<T> on List<T> { // 리스트의 첫 번째 요소 (안전하게 가져오기) T? get firstOrNull => isEmpty ? null : first;
// 리스트의 마지막 요소 (안전하게 가져오기) T? get lastOrNull => isEmpty ? null : last;
// 중복 제거 List<T> get distinct => toSet().toList();
// 리스트 분할 List<List<T>> chunk(int size) { return List.generate( (length / size).ceil(), (i) => sublist(i * size, (i + 1) * size > length ? length : (i + 1) * size), ); }}
void main() { List<int> numbers = [1, 2, 3, 4, 5, 1, 2]; print(numbers.distinct); // [1, 2, 3, 4, 5]
List<String> fruits = ['사과', '바나나', '오렌지', '딸기', '포도']; print(fruits.chunk(2)); // [[사과, 바나나], [오렌지, 딸기], [포도]]
List<int> empty = []; print(empty.firstOrNull); // null print(empty.lastOrNull); // null}
확장 게터와 세터
Section titled “확장 게터와 세터”extension NumberParsing on String { int? get asIntOrNull => int.tryParse(this); double? get asDoubleOrNull => double.tryParse(this);
bool get asBool { final lower = toLowerCase(); return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'y'; }}
void main() { print('42'.asIntOrNull); // 42 print('3.14'.asDoubleOrNull); // 3.14 print('abc'.asIntOrNull); // null print('true'.asBool); // true print('YES'.asBool); // true}
정적 확장 멤버
Section titled “정적 확장 멤버”확장은 정적 메서드와 프로퍼티도 포함할 수 있습니다:
extension DateTimeExtension on DateTime { // 인스턴스 메서드 String get formattedDate => '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
// 정적 메서드 static DateTime fromFormattedString(String formattedString) { final parts = formattedString.split('-'); if (parts.length != 3) { throw FormatException('잘못된 날짜 형식: $formattedString'); } return DateTime(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); }
// 정적 프로퍼티 static DateTime get tomorrow => DateTime.now().add(Duration(days: 1));}
void main() { final now = DateTime.now(); print(now.formattedDate); // 예: 2023-11-15
// 정적 메서드 호출 final date = DateTimeExtension.fromFormattedString('2023-11-15'); print(date); // 2023-11-15 00:00:00.000
// 정적 프로퍼티 접근 print(DateTimeExtension.tomorrow);}
확장과 타입 매개변수
Section titled “확장과 타입 매개변수”제네릭 타입에 대한 확장도 정의할 수 있습니다:
extension OptionalExtension<T> on T? { // null인 경우 기본값 반환 T orDefault(T defaultValue) => this ?? defaultValue;
// null이 아닌 경우에만 변환 함수 적용 R? mapIf<R>(R Function(T) mapper) => this != null ? mapper(this as T) : null;
// 조건부 실행 void ifPresent(void Function(T) action) { if (this != null) { action(this as T); } }}
void main() { String? nullableString = null; print(nullableString.orDefault('기본값')); // 기본값
int? nullableNumber = 42; print(nullableNumber.orDefault(0)); // 42
// 변환 예제 String? name = '홍길동'; int? length = name.mapIf((n) => n.length); // 3
// 조건부 실행 name.ifPresent((n) => print('안녕하세요, $n님!')); // 안녕하세요, 홍길동님! nullableString.ifPresent((s) => print(s)); // 아무것도 출력되지 않음}
확장 충돌 해결
Section titled “확장 충돌 해결”동일한 타입에 대해 동일한 이름의 메서드나 프로퍼티를 정의하는 여러 확장이 범위 내에 있는 경우, 충돌이 발생합니다. 이를 해결하기 위해서는 확장을 명시적으로 지정해야 합니다:
extension NumberParsing on String { int parseInt() => int.parse(this);}
extension StringParsing on String { int parseInt() => int.parse(this) * 2; // 다른 구현}
void main() { // 컴파일 오류: 모호한 호출 // print('42'.parseInt());
// 확장을 명시적으로 지정하여 해결 print(NumberParsing('42').parseInt()); // 42 print(StringParsing('42').parseInt()); // 84}
Mixin(믹스인)
Section titled “Mixin(믹스인)”Mixin은 여러 클래스 계층 구조 간에 코드를 재사용하는 방법입니다. 단일 상속만 지원하는 Dart에서 믹스인을 사용하면 여러 클래스의 기능을 결합할 수 있습니다.
mixin <믹스인명> [on <상위클래스>] { // 메서드, 게터, 세터 등}
class <클래스명> [extends <상위클래스>] with <믹스인1>, <믹스인2>, ... { // 클래스 정의}
간단한 믹스인 예제
Section titled “간단한 믹스인 예제”// Logger 믹스인 정의mixin Logger { void log(String message) { print('로그: $message'); }
void error(String message) { print('오류: $message'); }
void warn(String message) { print('경고: $message'); }}
// 유효성 검사 믹스인 정의mixin Validator { bool isValid(String value) { return value.isNotEmpty; }
void validate(String value) { if (!isValid(value)) { throw ArgumentError('유효하지 않은 값: $value'); } }}
// 믹스인을 사용한 클래스 정의class User with Logger, Validator { String name;
User(this.name) { validate(name); log('새 사용자 생성: $name'); }
void changeName(String newName) { try { validate(newName); name = newName; log('이름 변경됨: $name'); } catch (e) { error('이름 변경 실패: $e'); } }}
void main() { final user = User('홍길동'); user.changeName('김철수');
// 빈 문자열은 유효성 검사에 실패 user.changeName('');}
// 출력:// 로그: 새 사용자 생성: 홍길동// 로그: 이름 변경됨: 김철수// 오류: 이름 변경 실패: ArgumentError: 유효하지 않은 값:
on 키워드를 사용한 믹스인 제한
Section titled “on 키워드를 사용한 믹스인 제한”믹스인이 특정 클래스나 해당 하위 클래스에서만 사용될 수 있도록 제한할 수 있습니다:
class Animal { String name; Animal(this.name);
void eat() { print('$name이(가) 먹고 있습니다.'); }}
// Animal 클래스나 그 하위 클래스에서만 사용 가능한 믹스인mixin Swimmer on Animal { void swim() { print('$name이(가) 수영하고 있습니다.'); // Animal의 메서드 호출 가능 eat(); }}
class Dog extends Animal with Swimmer { Dog(String name) : super(name);
void bark() { print('$name: 멍멍!'); }}
// 다음 코드는 컴파일 오류 발생// class Fish with Swimmer { // 오류: Swimmer는 Animal의 하위 클래스에서만 사용 가능// String name;// Fish(this.name);// }
void main() { final dog = Dog('바둑이'); dog.swim(); // 바둑이이(가) 수영하고 있습니다. + 바둑이이(가) 먹고 있습니다. dog.bark(); // 바둑이: 멍멍!}
믹스인에서 변수 선언
Section titled “믹스인에서 변수 선언”믹스인에서도 변수를 선언할 수 있습니다:
mixin Counter { int _count = 0;
int get count => _count;
void increment() { _count++; }
void reset() { _count = 0; }}
class ClickCounter with Counter { String name;
ClickCounter(this.name);
void click() { increment(); print('$name 버튼이 $count번 클릭되었습니다.'); }}
void main() { final button = ClickCounter('제출'); button.click(); // 제출 버튼이 1번 클릭되었습니다. button.click(); // 제출 버튼이 2번 클릭되었습니다. button.reset(); button.click(); // 제출 버튼이 1번 클릭되었습니다.}
믹스인 충돌 해결
Section titled “믹스인 충돌 해결”여러 믹스인에서 동일한 이름의 메서드나 프로퍼티를 정의하는 경우, 충돌이 발생합니다. 이 경우 마지막으로 적용된 믹스인의 메서드가 우선합니다:
mixin A { void method() { print('A의 메서드'); }}
mixin B { void method() { print('B의 메서드'); }}
// 순서에 따라 결과가 달라짐class MyClass1 with A, B {}class MyClass2 with B, A {}
void main() { MyClass1().method(); // B의 메서드 (B가 A 이후에 적용됨) MyClass2().method(); // A의 메서드 (A가 B 이후에 적용됨)}
충돌을 명시적으로 해결하기 위해 클래스에서 메서드를 오버라이드할 수 있습니다:
class MyClass with A, B { @override void method() { print('MyClass의 메서드'); super.method(); // B의 메서드 호출 (마지막으로 적용된 믹스인) }}
void main() { MyClass().method(); // 출력: // MyClass의 메서드 // B의 메서드}
super 호출과 믹스인 순서
Section titled “super 호출과 믹스인 순서”믹스인 내에서 super
키워드를 사용하면 믹스인 적용 순서에 따라 결정되는 상위 구현을 호출합니다:
mixin A { void method() { print('A의 메서드'); }}
mixin B { void method() { print('B의 메서드'); super.method(); // 상위 구현 호출 }}
mixin C { void method() { print('C의 메서드'); super.method(); // 상위 구현 호출 }}
class Base { void method() { print('Base의 메서드'); }}
class MyClass extends Base with A, B, C {}
void main() { MyClass().method(); // 출력: // C의 메서드 // B의 메서드 // A의 메서드 // Base의 메서드}
위 예제에서 호출 순서는 다음과 같습니다:
MyClass
에 적용된 마지막 믹스인C
의method()
C
에서super.method()
를 호출하면B
의method()
호출B
에서super.method()
를 호출하면A
의method()
호출A
에서super.method()
가 명시적으로 호출되지 않았지만, 암시적으로Base
의method()
호출
클래스와 믹스인 비교
Section titled “클래스와 믹스인 비교”클래스와 믹스인 모두 코드 재사용 메커니즘을 제공하지만, 다음과 같은 차이점이 있습니다:
- 인스턴스화: 클래스는 인스턴스화할 수 있지만, 믹스인은 단독으로 인스턴스화할 수 없습니다.
- 상속: 믹스인은 다중 상속과 유사한 기능을 제공합니다.
- 한정:
on
키워드를 사용하여 믹스인을 특정 클래스에만 적용되도록 제한할 수 있습니다.
// 클래스로 정의class Logger { void log(String message) { print('로그: $message'); }}
// 믹스인으로 정의mixin LoggerMixin { void log(String message) { print('로그: $message'); }}
// 클래스 사용class UserService1 { final Logger logger = Logger();
void doSomething() { logger.log('작업 수행 중'); }}
// 믹스인 사용class UserService2 with LoggerMixin { void doSomething() { log('작업 수행 중'); // 직접 호출 가능 }}
void main() { // 클래스는 인스턴스화 가능 Logger().log('직접 로깅');
// 믹스인은 인스턴스화 불가능 // LoggerMixin().log('직접 로깅'); // 오류
UserService1().doSomething(); UserService2().doSomething();}
실전 예제: 확장과 믹스인 결합
Section titled “실전 예제: 확장과 믹스인 결합”다음 예제는 확장과 믹스인을 결합하여 데이터 검증, 직렬화, 로깅 기능을 갖춘 시스템을 구현합니다:
// JSON 직렬화 믹스인mixin JsonSerializable { Map<String, dynamic> toJson();
String stringify() { return jsonEncode(toJson()); }}
// 유효성 검사 믹스인mixin Validatable { bool validate();
List<String> getValidationErrors();
void validateOrThrow() { if (!validate()) { throw ValidationException(getValidationErrors()); } }}
// 로깅 믹스인mixin Loggable { String get logTag => runtimeType.toString();
void log(String message) { print('[$logTag] $message'); }
void logInfo(String message) => log('INFO: $message'); void logError(String message) => log('ERROR: $message');}
// 예외 클래스class ValidationException implements Exception { final List<String> errors;
ValidationException(this.errors);
@override String toString() => 'ValidationException: ${errors.join(', ')}';}
// 날짜 확장extension DateTimeExtension on DateTime { String get formattedDate => '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
bool isSameDay(DateTime other) { return year == other.year && month == other.month && day == other.day; }}
// User 모델 클래스class User with JsonSerializable, Validatable, Loggable { final String id; final String name; final String email; final DateTime createdAt;
User({ required this.id, required this.name, required this.email, required this.createdAt, }) { logInfo('새 사용자 생성: $name'); }
@override Map<String, dynamic> toJson() { return { 'id': id, 'name': name, 'email': email, 'created_at': createdAt.formattedDate, }; }
@override bool validate() { return id.isNotEmpty && name.isNotEmpty && email.contains('@') && email.contains('.'); }
@override List<String> getValidationErrors() { final errors = <String>[];
if (id.isEmpty) errors.add('ID는 비어있을 수 없습니다.'); if (name.isEmpty) errors.add('이름은 비어있을 수 없습니다.'); if (!email.contains('@') || !email.contains('.')) { errors.add('유효한 이메일이 아닙니다.'); }
return errors; }}
// String 확장: 이메일 검증extension EmailValidation on String { bool get isValidEmail => RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);}
void main() { try { // 유효한 사용자 final user1 = User( id: 'user123', name: '홍길동', email: 'hong@example.com', createdAt: DateTime.now(), );
user1.validateOrThrow(); user1.logInfo('사용자 유효성 검사 통과'); print(user1.stringify());
// 유효하지 않은 사용자 final user2 = User( id: 'user456', name: '', email: 'invalid-email', createdAt: DateTime.now(), );
user2.validateOrThrow(); // 예외 발생 } catch (e) { print('오류 발생: $e'); }
// 확장 메서드 사용 final date = DateTime.now(); print('오늘 날짜: ${date.formattedDate}');
// 이메일 유효성 검사 확장 사용 print('test@example.com은 유효한 이메일인가? ${'test@example.com'.isValidEmail}'); print('invalid-email은 유효한 이메일인가? ${'invalid-email'.isValidEmail}');}
Extension과 Mixin은 Dart에서 코드를 재사용하고 확장하는 강력한 방법을 제공합니다:
-
Extension은 기존 클래스(자신이 작성하지 않은 클래스도 포함)에 새로운 기능을 추가할 수 있게 해주며, 원본 코드를 수정하지 않고도 기능을 확장할 수 있습니다.
-
Mixin은 여러 클래스 계층 구조 간에 코드를 재사용하는 방법을 제공하며, 여러 클래스의 기능을 한 클래스에 결합할 수 있습니다.
이러한 기능을 적절히 활용하면 코드의 가독성, 재사용성, 유지 관리성을 크게 향상시킬 수 있습니다. Flutter 개발에서도 위젯 확장, 공통 기능 추출 등 다양한 상황에서 이러한 패턴을 활용할 수 있습니다.
다음 파트에서는 Flutter 위젯과 기본 UI 구성 요소에 대해 알아보겠습니다.