iBetter Books
수정

String 클래스에 이메일 유효성 검사 메서드가 있으면 좋겠다는 생각을 해본 적 있나요. 직접 String 클래스를 수정할 수는 없습니다. 표준 라이브러리 코드를 바꾸는 건 현실적이지 않으니까요. 하지만 Dart에는 extension 키워드가 있습니다. 기존 클래스를 수정하지 않고 새 메서드를 "붙이는" 방법입니다.

extension 기본 문법

extension on String {
  bool get isEmail {
    return contains('@') && contains('.');
  }

  String capitalize() {
    if (isEmpty) return this;
    return this[0].toUpperCase() + substring(1);
  }
}

void main() {
  String email = '[email protected]';
  print(email.isEmail);  // true

  String greeting = 'hello world';
  print(greeting.capitalize());  // Hello world

  print('invalid'.isEmail);  // false
}

extension on String 블록 안에 정의한 메서드와 게터는 String 인스턴스에서 직접 호출할 수 있습니다. 마치 처음부터 String에 있던 메서드처럼 사용됩니다.

this는 확장 메서드 안에서 원본 객체를 가리킵니다. capitalize() 안의 this는 메서드를 호출한 String 인스턴스입니다.

이름 있는 확장

익명 확장은 파일 내에서만 사용합니다. 다른 파일에서 가져오거나, 이름 충돌을 해결하려면 이름을 붙입니다.

// string_extensions.dart
extension StringValidation on String {
  bool get isEmail {
    final regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$');
    return regex.hasMatch(this);
  }

  bool get isPhoneNumber {
    final regex = RegExp(r'^\d{3}-\d{3,4}-\d{4}$');
    return regex.hasMatch(this);
  }

  bool get isNotEmpty => !isEmpty;

  String truncate(int maxLength, {String ellipsis = '...'}) {
    if (length <= maxLength) return this;
    return '${substring(0, maxLength)}$ellipsis';
  }
}

void main() {
  print('[email protected]'.isEmail);     // true
  print('010-1234-5678'.isPhoneNumber);  // true
  print(''.isNotEmpty);                  // false

  String longText = '이것은 아주 긴 문자열입니다. 줄임이 필요합니다.';
  print(longText.truncate(10));          // 이것은 아주 긴...
  print(longText.truncate(10, ellipsis: '…'));  // 이것은 아주 긴…
}

StringValidation이 확장의 이름입니다. 다른 파일에서 import해서 쓸 수 있습니다. 이름 충돌이 생기면 showhide로 선택적으로 가져올 수도 있습니다.

int 확장

숫자 타입에도 확장 메서드를 추가할 수 있습니다.

extension IntExtension on int {
  bool get isEven => this % 2 == 0;
  bool get isOdd => this % 2 != 0;
  bool get isPositive => this > 0;

  // n번 반복 실행
  void times(void Function(int) action) {
    for (int i = 0; i < this; i++) {
      action(i);
    }
  }

  // 범위 리스트 생성
  List<int> to(int end) {
    if (this <= end) {
      return List.generate(end - this + 1, (i) => this + i);
    } else {
      return List.generate(this - end + 1, (i) => this - i);
    }
  }
}

void main() {
  print(4.isEven);    // true
  print(7.isOdd);     // true
  print((-3).isPositive);  // false

  3.times((i) => print('반복 $i'));
  // 반복 0
  // 반복 1
  // 반복 2

  print(1.to(5));   // [1, 2, 3, 4, 5]
  print(5.to(1));   // [5, 4, 3, 2, 1]
}

3.times(...) 같은 코드는 Ruby나 Kotlin의 스타일과 비슷합니다. 확장 메서드를 활용하면 이런 표현력 높은 코드를 작성할 수 있습니다.

제네릭 확장 — List 확장하기

확장 메서드와 제네릭을 조합하면 더 강력해집니다. List<T>에 제네릭 확장을 추가해봅니다.

extension ListExtension<T> on List<T> {
  // null이 아닌 첫 번째 원소 반환
  T? firstOrNull() {
    return isEmpty ? null : first;
  }

  // null이 아닌 마지막 원소 반환
  T? lastOrNull() {
    return isEmpty ? null : last;
  }

  // 중복 제거 (순서 유지)
  List<T> distinct() {
    return toSet().toList();
  }

  // 조건에 맞는 원소가 없으면 null 반환
  T? firstWhereOrNull(bool Function(T) test) {
    for (final item in this) {
      if (test(item)) return item;
    }
    return null;
  }
}

void main() {
  List<int> numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3];

  print(numbers.firstOrNull());   // 3
  print(numbers.lastOrNull());    // 3
  print(numbers.distinct());      // [3, 1, 4, 5, 9, 2, 6] (순서는 구현에 따라 다를 수 있습니다)

  int? found = numbers.firstWhereOrNull((n) => n > 7);
  print(found);  // 9

  int? notFound = numbers.firstWhereOrNull((n) => n > 100);
  print(notFound);  // null

  // 빈 리스트
  List<String> empty = [];
  print(empty.firstOrNull());  // null
}

extension ListExtension<T> on List<T>에서 <T>가 두 번 등장합니다. 첫 번째 <T>는 확장 자체의 타입 파라미터 선언이고, 두 번째 <T>는 확장할 대상 타입 List<T>에 쓰입니다. 확장 안의 메서드들은 T를 자유롭게 사용할 수 있습니다.

확장에서 static 멤버 사용

확장은 인스턴스 메서드뿐 아니라 static 메서드도 가질 수 있습니다. 단, static 메서드는 확장 이름으로만 호출합니다.

extension DateTimeExtension on DateTime {
  // 인스턴스 메서드
  String toKorean() {
    return '$year년 $month월 $day일';
  }

  bool get isWeekend {
    return weekday == DateTime.saturday || weekday == DateTime.sunday;
  }

  // static 팩토리 메서드
  static DateTime today() {
    final now = DateTime.now();
    return DateTime(now.year, now.month, now.day);
  }
}

void main() {
  DateTime now = DateTime.now();
  print(now.toKorean());    // 2026년 4월 26일 (오늘 날짜 기준)
  print(now.isWeekend);     // 요일에 따라 다릅니다.

  // static 메서드는 확장 이름으로 호출합니다.
  DateTime todayMidnight = DateTimeExtension.today();
  print(todayMidnight);
}

이름 충돌 해결

두 확장이 같은 메서드 이름을 정의하면 충돌이 생깁니다.

extension ExtA on String {
  String shout() => toUpperCase() + '!!!';
}

extension ExtB on String {
  String shout() => '${toUpperCase()}?';
}

void main() {
  String msg = '안녕';

  // 충돌 — 컴파일러가 어떤 shout을 써야 할지 모릅니다.
  // print(msg.shout());  // 오류!

  // 이름으로 명시적으로 호출합니다.
  print(ExtA(msg).shout());  // 안녕!!!
  print(ExtB(msg).shout());  // 안녕?
}

ExtA(msg).shout()처럼 확장 이름을 명시해서 충돌을 해결합니다. 평소에는 쓸 일이 많지 않지만, 여러 라이브러리를 동시에 사용할 때 이런 충돌이 생길 수 있습니다.

확장 메서드의 한계

확장 메서드는 강력하지만 할 수 없는 것도 있습니다.

// 할 수 없는 것 1: 인스턴스 필드 추가
extension on String {
  // int count = 0;  // 오류! 확장에 필드 추가 불가
}

// 할 수 없는 것 2: 기존 메서드 오버라이드
extension on String {
  // String toUpperCase() => ...;  // 오류! 기존 메서드와 충돌
}

// 할 수 있는 것: 연산자 추가
extension on String {
  String operator *(int times) {
    return List.filled(times, this).join();
  }
}

void main() {
  print('다트' * 3);  // 다트다트다트
}

확장으로 필드를 추가하거나 기존 메서드를 오버라이드할 수 없습니다. 새로운 메서드, 게터, 세터, 연산자를 추가하는 것만 가능합니다.

실용적인 조합 — 제네릭 + 확장 + 제약

세 가지를 모두 조합한 예제를 살펴봅니다.

extension ComparableListExtension<T extends Comparable<T>> on List<T> {
  T max() {
    if (isEmpty) throw StateError('리스트가 비어 있습니다.');
    return reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
  }

  T min() {
    if (isEmpty) throw StateError('리스트가 비어 있습니다.');
    return reduce((a, b) => a.compareTo(b) <= 0 ? a : b);
  }

  List<T> sorted() {
    final copy = List<T>.from(this);
    copy.sort();
    return copy;
  }
}

void main() {
  List<int> numbers = [5, 2, 8, 1, 9, 3];
  print(numbers.max());     // 9
  print(numbers.min());     // 1
  print(numbers.sorted());  // [1, 2, 3, 5, 8, 9]

  List<String> fruits = ['바나나', '사과', '딸기', '감'];
  print(fruits.max());      // 사과 (사전순 최대)
  print(fruits.min());      // 감 (사전순 최소)
  print(fruits.sorted());   // [감, 딸기, 바나나, 사과]
}

<T extends Comparable<T>> on List<T>는 "T가 비교 가능한 타입인 List에만 이 확장을 적용한다"는 의미입니다. Comparable을 구현하지 않은 타입의 List에서는 max(), min(), sorted()가 보이지 않습니다.

정리

extension 키워드로 기존 클래스에 새 메서드를 추가할 수 있습니다. 소스 코드를 수정할 수 없는 클래스(표준 라이브러리, 외부 패키지)에 프로젝트 전용 유틸리티를 붙이는 데 특히 유용합니다.

이름 있는 확장은 import로 재사용할 수 있고, 이름 충돌 시 명시적으로 호출할 수 있습니다. 제네릭 확장과 타입 제약을 조합하면 특정 조건을 만족하는 타입에만 메서드를 제공할 수 있습니다.

PART 07을 통해 제네릭의 기초부터 제약, 그리고 확장 메서드까지 살펴봤습니다. 다음 PART 08에서는 비동기 프로그래밍을 다룹니다. Future<T>Stream<T> 역시 제네릭 타입임을 다시 만나게 됩니다.