iBetter Books
수정

PART 04에서 List<String>Map<String, int>를 이미 써봤습니다. 그런데 그 꺾쇠 괄호 안의 타입은 정확히 무엇이었을까요. 단순한 문법 장식이 아닙니다. 그것이 바로 제네릭(Generic)입니다. 이 챕터에서는 제네릭이 왜 필요한지, 그리고 우리가 이미 제네릭을 써왔다는 사실을 확인합니다.

문제 1 — dynamic을 쓰면 타입 안전성이 사라진다

처음에는 간단해 보입니다. "어떤 타입이든 담을 수 있는 상자"가 필요하다면, dynamic을 쓰면 되지 않을까요.

void main() {
  dynamic box = 42;
  print(box);       // 42

  box = '안녕';
  print(box);       // 안녕

  box = true;
  print(box);       // true
}

동작은 합니다. 하지만 이 코드에는 숨겨진 위험이 있습니다.

void main() {
  dynamic box = '안녕하세요';

  // Dart는 box가 String인지 int인지 알 수 없습니다.
  // 컴파일러는 이 코드를 통과시킵니다.
  int result = box + 10;  // 런타임 오류!
  print(result);
}

컴파일 시점에는 아무 경고도 없습니다. 그런데 실행하면 프로그램이 터집니다. String10을 더할 수 없기 때문입니다. dynamic은 컴파일러를 잠재우는 대신, 오류를 런타임으로 미룰 뿐입니다.

Dart의 가장 큰 강점 중 하나는 "컴파일 타임에 오류를 잡아준다"는 것입니다. dynamic은 그 강점을 스스로 포기하는 셈입니다.

문제 2 — 같은 로직을 타입별로 복사한다

이번에는 다른 상황을 봅니다. 숫자 배열에서 첫 번째 값을 꺼내는 함수가 필요합니다.

int firstInt(List<int> items) {
  return items[0];
}

그런데 문자열 배열에서도 같은 로직이 필요합니다.

String firstString(List<String> items) {
  return items[0];
}

그리고 double도 필요하고, bool도 필요합니다. 함수 본체는 items[0] 하나인데, 타입만 다른 함수를 몇 개나 복사해야 할까요.

double firstDouble(List<double> items) {
  return items[0];
}

bool firstBool(List<bool> items) {
  return items[0];
}

이것은 좋은 코드가 아닙니다. 로직이 동일하다면 한 번만 작성해야 합니다. 이것이 DRY(Don't Repeat Yourself) 원칙입니다.

dynamic으로 통일하면 어떨까요.

dynamic firstAny(List<dynamic> items) {
  return items[0];
}

void main() {
  var result = firstAny([1, 2, 3]);
  // result는 dynamic입니다.
  // int인 줄 알지만, 컴파일러는 모릅니다.
  print(result + 100);  // 동작하긴 하지만, 타입 안전성이 없습니다.
}

dynamic으로 통일하면 중복은 없어지지만, 타입 안전성이 다시 사라집니다. 딜레마입니다.

해결책 — 타입을 매개변수로 받는다

제네릭은 이 딜레마를 우아하게 해결합니다. 함수를 정의할 때 타입을 "미정"으로 남겨두고, 호출할 때 타입을 결정하는 방식입니다.

T first<T>(List<T> items) {
  return items[0];
}

void main() {
  int a = first<int>([10, 20, 30]);
  String b = first<String>(['사과', '바나나', '딸기']);
  double c = first<double>([3.14, 2.71, 1.41]);

  print(a);  // 10
  print(b);  // 사과
  print(c);  // 3.14
}

T는 타입 파라미터(type parameter)입니다. 함수를 정의하는 시점에서는 T가 무엇인지 모릅니다. 호출하는 시점에 Tint인지 String인지 결정됩니다.

이제 함수 하나로 모든 타입을 처리할 수 있습니다. 그리고 타입 안전성도 유지됩니다. first<int>(...) 를 호출하면 반환 타입이 int임을 컴파일러가 알기 때문에, int result = first<int>([1, 2, 3]) 같은 코드가 완전히 안전합니다.

사실, 이미 쓰고 있었다

PART 04에서 사용한 코드를 떠올려봅니다.

void main() {
  List<String> fruits = ['사과', '바나나', '딸기'];
  Map<String, int> scores = {'수학': 90, '영어': 85};

  fruits.add('포도');
  print(fruits[0]);  // 사과
  print(scores['수학']);  // 90
}

List<String>List는 Dart 표준 라이브러리에 정의된 제네릭 클래스입니다. String은 그 클래스에 전달하는 타입 인수(type argument)입니다. Map<String, int> 역시 두 개의 타입 파라미터를 받는 제네릭 클래스입니다.

우리는 이미 제네릭을 사용하고 있었습니다. 단지 그것이 제네릭이라고 인식하지 못했을 뿐입니다.

Dart 표준 라이브러리를 보면 제네릭이 곳곳에 있습니다.

void main() {
  // Set<T>
  Set<int> numbers = {1, 2, 3};

  // Future<T> — 비동기 처리 (PART 08에서 다룹니다)
  // Future<String> futureText = ...;

  // Stream<T> — 데이터 스트림
  // Stream<int> counter = ...;

  // Iterable<T>
  Iterable<String> names = ['다트', '플러터'];

  print(numbers);  // {1, 2, 3}
  print(names.first);  // 다트
}

제네릭은 Dart 언어 전체에 걸쳐 있는 핵심 개념입니다.

타입 파라미터 이름 관례

관례적으로 타입 파라미터에는 대문자 한 글자를 씁니다.

// T — 일반적인 타입 (Type)
class Box<T> { }

// E — 컬렉션의 원소 (Element)
class Stack<E> { }

// K, V — 키와 값 (Key, Value)
class Pair<K, V> { }

// R — 반환 타입 (Return)
R transform<R, T>(T input, R Function(T) converter) {
  return converter(input);
}

이 관례는 강제 사항이 아닙니다. T 대신 Element처럼 의미 있는 이름을 써도 됩니다. 하지만 짧은 이름이 가독성에 유리할 때가 많아 관례를 따르는 편이 좋습니다.

정리

dynamic은 어떤 타입이든 받을 수 있지만, 컴파일러의 도움을 잃습니다. 타입별로 함수를 복사하면 안전하지만, 중복이 발생합니다. 제네릭은 이 두 문제를 동시에 해결합니다. 타입을 매개변수로 받아 하나의 코드로 여러 타입을 처리하면서도, 컴파일 타임 타입 안전성을 유지합니다.

그리고 List<String>, Map<String, int>처럼 우리가 이미 쓰던 것들이 모두 제네릭이었습니다. 익숙한 것에서 출발해 개념을 이해했으니, 이제 직접 만들어볼 차례입니다.

다음 챕터에서는 제네릭 클래스와 제네릭 함수를 직접 작성해봅니다.