Tuesday, July 11, 2023

Dart 리스트의 불변성과 타입 캐스팅: from, cast, of 심층 분석

Dart 프로그래밍 언어의 핵심에는 강력하고 유연한 컬렉션 프레임워크가 자리 잡고 있으며, 그 중심에는 List가 있습니다. 거의 모든 애플리케이션에서 데이터의 목록을 관리하는 것은 필수적인 작업이며, Dart의 List는 이러한 요구사항을 효과적으로 충족시키는 다양한 기능을 제공합니다. 그러나 데이터를 다루다 보면 단순히 요소를 추가하거나 삭제하는 것을 넘어, 기존 리스트를 바탕으로 새로운 리스트를 생성하거나, 데이터의 타입을 변환해야 하는 복잡한 상황에 직면하게 됩니다.

특히 Dart의 강력한 정적 타입 시스템은 코드의 안정성과 예측 가능성을 높여주지만, 동시에 다른 타입의 데이터를 다룰 때 명시적인 처리를 요구합니다. 예를 들어, JSON API로부터 받은 List<dynamic> 데이터를 애플리케이션의 모델 객체인 List<User>로 변환하는 작업은 매우 흔한 시나리오입니다. 이러한 과정에서 어떤 메소드를 사용해야 하는지에 대한 정확한 이해는 코드의 성능, 안정성, 그리고 가독성에 직접적인 영향을 미칩니다.

이 글에서는 Dart에서 리스트를 복사하고 타입을 변환하는 데 사용되는 핵심적인 메커니즘인 List.from(), cast(), 그리고 그와 유사한 역할을 하는 다른 메소드들을 심층적으로 분석합니다. 단순히 각 메소드의 기능을 나열하는 것을 넘어, 내부 동작 원리, 성능상의 특징, 그리고 발생할 수 있는 잠재적 오류까지 깊이 있게 탐구할 것입니다. 이를 통해 개발자들이 특정 상황에 가장 적합한 도구를 선택하여 더 견고하고 효율적인 Dart 코드를 작성할 수 있도록 돕는 것을 목표로 합니다.

1. 복사의 기본: 참조, 얕은 복사, 그리고 깊은 복사

리스트 변환에 대해 논의하기 전에, Dart에서 객체(리스트 포함)가 메모리에서 어떻게 다루어지는지 이해하는 것이 중요합니다. 많은 초급 개발자들이 저지르는 흔한 실수 중 하나는 대입 연산자(=)가 리스트를 복사할 것이라고 가정하는 것입니다.

1.1. 참조 할당의 함정

다음 코드를 살펴보겠습니다.


void main() {
  List<int> originalList = [1, 2, 3];
  List<int> referencedList = originalList;

  referencedList.add(4);

  print('Original List: $originalList');   // 출력: Original List: [1, 2, 3, 4]
  print('Referenced List: $referencedList'); // 출력: Referenced List: [1, 2, 3, 4]
}

referencedList에만 요소를 추가했지만, originalList에도 동일한 변경 사항이 반영되었습니다. 이는 referencedList = originalList 구문이 리스트의 데이터 자체를 복사한 것이 아니라, 메모리에 있는 동일한 리스트 객체를 가리키는 '참조(reference)' 또는 '주소값'만을 복사했기 때문입니다. 결국 originalListreferencedList는 이름만 다른, 완전히 동일한 실체를 가리키는 변수가 된 것입니다. 이러한 동작은 의도치 않은 데이터 오염(Data Contamination)을 유발할 수 있어 매우 주의해야 합니다.

1.2. 얕은 복사 (Shallow Copy)

원본 리스트에 영향을 주지 않는 독립적인 새 리스트를 만들고 싶을 때 '복사'가 필요합니다. Dart에서 가장 일반적으로 사용되는 복사 방법은 **얕은 복사(Shallow Copy)**입니다. 얕은 복사는 리스트의 최상위 구조, 즉 요소들을 담고 있는 컨테이너 자체는 새로 생성하지만, 그 안의 요소들은 원본의 참조를 그대로 가져옵니다.

List.from() 생성자나 스프레드 연산자(...)가 바로 이 얕은 복사를 수행합니다.


void main() {
  List<int> originalList = [1, 2, 3];
  List<int> copiedList = List.from(originalList); // 또는 List<int> copiedList = [...originalList];

  copiedList.add(4);

  print('Original List: $originalList');   // 출력: Original List: [1, 2, 3]
  print('Copied List: $copiedList');     // 출력: Copied List: [1, 2, 3, 4]
}

이제 copiedList의 변경이 originalList에 영향을 미치지 않습니다. 왜냐하면 List.from()이 메모리에 새로운 리스트 객체를 할당하고 원본 리스트의 요소들을 순서대로 채워 넣었기 때문입니다. 하지만 '얕은' 복사라는 점을 기억해야 합니다. 리스트의 요소가 원시 타입(int, double, String 등)일 때는 문제가 없지만, 가변(mutable) 객체일 경우 이야기가 달라집니다.

다음은 얕은 복사의 한계를 보여주는 예시입니다.


class User {
  String name;
  User(this.name);

  @override
  String toString() => 'User(name: $name)';
}

void main() {
  var user1 = User('Alice');
  var user2 = User('Bob');
  
  List<User> originalUsers = [user1, user2];
  List<User> copiedUsers = List.from(originalUsers);

  // 복사된 리스트의 첫 번째 User 객체의 이름을 변경합니다.
  copiedUsers[0].name = 'Alicia';

  print('Original Users: $originalUsers'); // 출력: Original Users: [User(name: Alicia), User(name: Bob)]
  print('Copied Users: $copiedUsers');   // 출력: Copied Users: [User(name: Alicia), User(name: Bob)]
}

copiedUsers 리스트 자체는 새로운 객체이지만, 그 안의 User 객체들은 원본 리스트가 참조하던 것과 동일한 객체입니다. 따라서 copiedUsers[0]name을 변경하면, originalUsers[0]이 가리키는 동일한 User 객체의 name이 변경되는 것입니다.

1.3. 깊은 복사 (Deep Copy)

리스트 내부의 객체까지 모두 복제하여 완전히 독립적인 복사본을 만드는 것을 **깊은 복사(Deep Copy)**라고 합니다. Dart는 내장된 깊은 복사 기능을 직접 제공하지는 않지만, map 메소드와 객체의 복사 생성자(또는 copyWith 메소드)를 조합하여 구현할 수 있습니다.


class User {
  String name;
  User(this.name);
  
  // 자신을 복제하는 메소드
  User copy() => User(name);

  @override
  String toString() => 'User(name: $name)';
}

void main() {
  var user1 = User('Alice');
  var user2 = User('Bob');
  
  List<User> originalUsers = [user1, user2];
  
  // 깊은 복사 실행
  List<User> deepCopiedUsers = originalUsers.map((user) => user.copy()).toList();

  deepCopiedUsers[0].name = 'Alicia';

  print('Original Users: $originalUsers');   // 출력: Original Users: [User(name: Alice), User(name: Bob)]
  print('Deep Copied Users: $deepCopiedUsers'); // 출력: Deep Copied Users: [User(name: Alicia), User(name: Bob)]
}

이제 deepCopiedUsers의 요소를 변경해도 원본에 아무런 영향을 미치지 않습니다. map을 통해 각 User 객체에 대해 copy() 메소드를 호출하여 새로운 User 인스턴스를 생성했기 때문입니다. 이처럼 리스트를 다룰 때는 내가 하려는 작업이 단순 참조인지, 얕은 복사인지, 깊은 복사인지 명확히 인지하는 것이 버그를 예방하는 첫걸음입니다.

2. 새로운 리스트 생성: List.from, List.of, 그리고 스프레드 연산자

얕은 복사를 통해 새로운 리스트를 생성하는 방법은 여러 가지가 있으며, 각각 약간의 차이와 용례가 있습니다.

2.1. List<E>.from(Iterable elements, {bool growable = true})

List.from은 가장 전통적이고 명시적인 리스트 복사 방법입니다. 이 생성자는 어떠한 Iterable(반복 가능한 객체, List, Set 등)도 인자로 받아 새로운 List를 생성합니다.

  • 동작 방식: 인자로 받은 elements를 처음부터 끝까지 순회하며 각 요소를 새로운 리스트에 순서대로 추가합니다. 이 과정은 즉시(Eager) 실행되며, 새로운 메모리 공간을 할당받습니다.
  • growable 파라미터: 이 생성자의 중요한 특징 중 하나는 growable 옵션입니다.
    • growable: true (기본값): 생성된 리스트의 길이를 변경할 수 있습니다 (add, remove 등 사용 가능). 일반적인 경우에 해당합니다.
    • growable: false: 생성된 리스트는 길이가 고정됩니다(Fixed-length list). 생성 후에 길이를 변경하려는 시도는 UnsupportedError를 발생시킵니다. 성능상 약간의 이점이 있을 수 있으며, 리스트의 크기가 변하지 않음을 보장하고 싶을 때 유용합니다.

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

  // Growable list 생성 (기본값)
  List<int> growableList = List.from(numberSet);
  growableList.add(4); // 성공
  print('Growable: $growableList'); // 출력: Growable: [1, 2, 3, 4]

  // Fixed-length list 생성
  List<int> fixedList = List.from(numberSet, growable: false);
  try {
    fixedList.add(4); // 오류 발생!
  } catch (e) {
    print('Error on fixed list: $e'); // 출력: Error on fixed list: Unsupported operation: add
  }
}

2.2. List<E>.of(Iterable<E> elements, {bool growable = true})

List.ofList.from과 기능적으로 거의 동일한 또 다른 팩토리 생성자입니다.


// 이 두 코드는 사실상 동일하게 동작합니다.
var list1 = List<int>.from([1, 2, 3]);
var list2 = List<int>.of([1, 2, 3]);

List.of는 이름에서 "다른 Iterable로부터" 라는 의미가 더 명확하게 드러나, 코드의 가독성을 높이고자 하는 의도로 볼 수 있습니다. 과거 버전의 Dart에서는 약간의 차이가 있었으나, 최신 Dart에서는 내부 구현과 성능이 거의 동일하므로 어느 것을 사용해도 무방합니다. 팀의 코딩 컨벤션이나 개인의 선호에 따라 선택할 수 있습니다.

2.3. 스프레드 연산자 (Spread Operator: ...)

ES6 JavaScript에 익숙한 개발자라면 스프레드 연산자가 매우 친숙할 것입니다. Dart에서도 리스트 리터럴([]) 내에서 다른 컬렉션의 요소들을 펼쳐 넣는 용도로 스프레드 연산자를 사용할 수 있습니다. 이는 리스트를 복사하는 가장 간결하고 현대적인 방법 중 하나입니다.


void main() {
  List<int> originalList = [1, 2, 3];

  // 1. 단순 복사
  List<int> copiedList = [...originalList];
  copiedList.add(4);
  print('Original: $originalList'); // 출력: Original: [1, 2, 3]
  print('Copied: $copiedList');   // 출력: Copied: [1, 2, 3, 4]

  // 2. 다른 리스트와 결합
  List<int> anotherList = [7, 8, 9];
  List<int> combinedList = [...originalList, 4, 5, 6, ...anotherList];
  print('Combined: $combinedList'); // 출력: Combined: [1, 2, 3, 4, 5, 6, 7, 8, 9]
}

스프레드 연산자는 List.from이나 List.of와 마찬가지로 얕은 복사를 수행하며, 항상 growable: true인 리스트를 생성합니다. 간결함과 가독성 덕분에 단순 복사나 여러 리스트를 합치는 용도로 널리 사용됩니다.

3. 타입 캐스팅의 세계: cast()List.castFrom()

데이터를 다루다 보면 리스트의 타입 자체를 변경해야 할 필요가 생깁니다. 예를 들어, List<Object>를 받았지만 내부 요소가 모두 String임을 확신하는 경우, 이를 List<String>으로 다루고 싶을 것입니다. 이때 사용되는 것이 타입 캐스팅(Type Casting)입니다.

3.1. cast<R>(): 게으른(Lazy) 타입 캐스팅 뷰

cast() 메소드는 가장 오해하기 쉬우면서도 강력한 타입 변환 도구입니다. 이 메소드의 핵심적인 특징은 **새로운 리스트를 생성하지 않는다**는 점입니다. 대신, 원본 리스트를 지정된 타입으로 감싸는 **'뷰(View)'** 객체를 반환합니다.

  • 동작 방식: cast()가 호출되는 순간에는 아무런 연산도 일어나지 않습니다. 메모리 할당도, 요소 순회도 없습니다. 단지 원본 리스트를 가리키고, "이 리스트의 요소들은 이제 <R> 타입으로 간주될 것이다"라고 약속하는 래퍼(wrapper) 객체만 생성합니다.
  • 게으른 평가 (Lazy Evaluation): 실제 타입 확인은 뷰의 요소를 접근하는 시점에 일어납니다. 예를 들어 castedList[0]을 호출할 때, 비로소 원본 리스트의 0번째 요소가 <R> 타입으로 캐스팅될 수 있는지 검사합니다.
  • 런타임 오류의 위험: 만약 약속과 달리 실제 요소가 지정된 타입으로 변환될 수 없다면, 해당 요소에 접근하는 순간 TypeError가 발생하며 프로그램이 중단될 수 있습니다. 이는 컴파일 시점에는 발견되지 않는 런타임 오류이므로 각별한 주의가 필요합니다.

다음은 cast()의 동작과 위험성을 보여주는 예시입니다.


void main() {
  List<num> numbers = [1, 2.5, 3]; // int와 double이 섞여 있음

  // numbers의 모든 요소가 int라고 "주장"하는 뷰를 생성.
  // 이 시점에서는 아무런 오류도 발생하지 않음.
  List<int> intView = numbers.cast<int>();

  print('Cast operation completed without error.');

  try {
    // 첫 번째 요소 접근: 1은 int이므로 성공.
    print('First element: ${intView.first}'); // 출력: First element: 1

    // 두 번째 요소 접근: 2.5는 int가 아니므로 여기서 TypeError 발생!
    print('Second element: ${intView.elementAt(1)}'); 
  } catch (e) {
    print('\nError accessing second element: $e');
  }
  
  // 원본 리스트의 변경은 뷰에도 반영됩니다.
  numbers.add(4);
  print('\nOriginal list length: ${numbers.length}'); // 출력: 4
  print('View length: ${intView.length}');         // 출력: 4
}

언제 cast()를 사용해야 할까? cast()는 다음 두 조건이 모두 충족될 때 유용합니다. 1. 리스트의 모든 요소가 목표 타입과 호환된다는 것을 100% 확신할 수 있을 때. 2. 새로운 리스트를 생성하는 데 드는 메모리 및 성능 비용을 피하고 싶을 때 (예: 매우 큰 리스트를 다룰 때). 하지만 타입이 확실하지 않은 상황에서 cast()를 남용하는 것은 잠재적인 런타임 폭탄을 심는 것과 같습니다.

3.2. List.castFrom<S, T>(List<S> source): 폐기된(Deprecated) 메소드

과거에는 List.castFrom()이라는 정적 메소드가 존재했습니다. 이 메소드는 source.cast<T>().toList()와 유사하게, 원본 리스트를 캐스팅하여 **새로운 리스트를 생성**하는 역할을 했습니다. cast()가 게으른 뷰를 반환하는 것과 달리, castFrom()은 즉시(Eager) 모든 요소를 검사하고 복사하여 새 리스트를 만들었습니다.

하지만 이 메소드는 Dart 2.7부터 **폐기(deprecated)**되었습니다. 공식적으로 source.cast<T>().toList()List<T>.from(source) 와 같은 더 명확하고 일관된 패턴을 사용하도록 권장하고 있습니다. 따라서 최신 Dart 코드에서는 이 메소드를 사용하지 않아야 합니다.

4. 유연성의 왕: map()whereType()

실제 애플리케이션 개발에서는 단순한 타입 캐스팅보다 더 복잡한 데이터 변환이 필요할 때가 많습니다. 이때 가장 강력하고 안전한 도구가 바로 map()whereType()입니다.

4.1. map<T>((E) => T): 모든 종류의 변환

map() 메소드는 리스트의 각 요소를 하나씩 순회하며, 주어진 함수를 적용하여 새로운 형태의 요소로 변환합니다. 그 결과로 새로운 타입의 Iterable을 반환하며, 보통 .toList()를 붙여 새로운 리스트로 만듭니다.

map은 단순 캐스팅을 넘어 다음과 같은 다양한 작업을 수행할 수 있습니다.

  • 타입 변환: intString으로, Map을 객체로 변환
  • 데이터 추출: 객체 리스트에서 특정 속성(예: name)만 추출하여 새 리스트 생성
  • 데이터 가공: 각 숫자에 2를 곱하거나, 문자열을 대문자로 변경

void main() {
  List<String> stringNumbers = ['1', '2', '3', 'invalid', '5'];

  // 예시 1: String 리스트를 int 리스트로 안전하게 변환
  List<int> integers = stringNumbers
      .map((s) => int.tryParse(s)) // int.tryParse는 변환 실패 시 null 반환
      .where((i) => i != null)     // null이 아닌 것만 필터링
      .cast<int>()                 // 이제 타입이 List<int?>에서 List<int>로 확정됨
      .toList();
  print('Parsed Integers: $integers'); // 출력: Parsed Integers: [1, 2, 3, 5]

  // 예시 2: JSON(Map) 리스트를 User 객체 리스트로 변환
  List<Map<String, dynamic>> jsonData = [
    {'name': 'Charlie', 'age': 30},
    {'name': 'David', 'age': 25}
  ];
  List<User> users = jsonData.map((json) => User.fromJson(json)).toList();
  print('User objects: $users');
}

class User {
  final String name;
  final int age;
  User(this.name, this.age);

  factory User.fromJson(Map<String, dynamic> json) {
    return User(json['name'], json['age']);
  }
  @override
  String toString() => 'User(name: $name, age: $age)';
}

source.cast<T>().toList()source.map((e) => e as T).toList()는 결과적으로 동일한 새 리스트를 생성하지만, map은 변환 로직 내에 예외 처리나 추가적인 로직을 삽입할 수 있어 훨씬 더 유연하고 안전합니다.

4.2. whereType<T>(): 안전한 타입 필터링

whereType<T>() 메소드는 cast()의 안전한 대안으로 볼 수 있습니다. 이 메소드는 리스트를 순회하며 지정된 타입 T에 해당하는 요소'만'을 걸러내어 새로운 Iterable<T>를 반환합니다. 타입이 맞지 않는 요소는 조용히 무시되며, 런타임 오류를 발생시키지 않습니다.


void main() {
  List<dynamic> mixedList = [1, 'hello', 3.14, true, 'world', 42];

  // mixedList에서 String 타입인 요소만 안전하게 추출
  List<String> strings = mixedList.whereType<String>().toList();
  print('Strings only: $strings'); // 출력: Strings only: [hello, world]

  // mixedList에서 int 타입인 요소만 안전하게 추출
  List<int> integers = mixedList.whereType<int>().toList();
  print('Integers only: $integers'); // 출력: Integers only: [1, 42]
}

이처럼 리스트에 여러 타입이 섞여 있을 가능성이 있고, 그중 특정 타입의 데이터만 안전하게 필터링하고 싶을 때 whereType()은 최고의 선택입니다.

5. 종합 비교 및 상황별 최적의 선택

지금까지 살펴본 여러 메소드들의 특징과 용도를 표로 정리하면 다음과 같습니다.

| 메소드 | 목적 | 반환 값 | 연산 시점 (Lazy/Eager) | 메모리 | 핵심 특징 및 사용 사례 | | :--- | :--- | :--- | :--- | :--- | :--- | | `List.from(src)` | 얕은 복사 | 새로운 List | Eager | 새 리스트 할당 | 원본과 독립적인 새 리스트를 만들 때. `growable` 옵션 제어 가능. | | `[...src]` | 얕은 복사 | 새로운 List | Eager | 새 리스트 할당 | `List.from`의 간결한 버전. 여러 리스트를 합칠 때 매우 유용. | | `src.cast<T>()` | 타입 캐스팅 | 뷰 (View) | Lazy | 래퍼 객체만 할당 | 메모리 할당 없이 타입을 단언(assert)하고 싶을 때. 런타임 오류 위험이 높음. | | `src.map((e)=>...).toList()` | 변환 및 복사 | 새로운 List | Eager | 새 리스트 할당 | 가장 유연하고 강력함. 타입 변환, 데이터 가공, 객체 매핑 등 모든 변환 작업에 적합. | | `src.whereType<T>().toList()` | 타입 필터링 | 새로운 List | Eager | 새 리스트 할당 | 런타임 오류 없이 특정 타입의 요소만 안전하게 걸러내고 싶을 때. |

상황별 선택 가이드

코드를 작성할 때 어떤 메소드를 선택해야 할지 다음 질문을 통해 결정할 수 있습니다.

  1. 단순히 원본과 독립적인 복사본이 필요한가?
    List.from(source) 또는 [...source]를 사용하세요. 가독성과 간결함을 중시한다면 스프레드 연산자(...)가 좋습니다.
  2. 리스트의 모든 요소가 특정 타입임을 100% 확신하며, 메모리 사용을 최소화하고 싶은가?
    source.cast<T>()를 고려할 수 있습니다. 하지만 이는 '신뢰'에 기반한 위험한 작업임을 명심해야 합니다. 정말 성능이 중요한 극소수의 경우를 제외하고는 더 안전한 대안을 찾는 것이 좋습니다.
  3. 리스트의 요소들을 다른 값이나 다른 타입의 객체로 변환해야 하는가? (예: List<int>List<String>)
    ⇒ 주저 없이 source.map(...).toList()를 사용하세요. 가장 안전하고 유연하며 의도를 명확하게 드러내는 방법입니다.
  4. 여러 타입이 섞인 리스트에서 특정 타입의 요소만 안전하게 골라내고 싶은가?
    source.whereType<T>().toList()가 정답입니다. 오류 없이 원하는 타입만 필터링해 줍니다.

결론

Dart의 List 변환 메소드들은 각기 다른 철학과 목적을 가지고 설계되었습니다. List.from과 스프레드 연산자는 불변성을 지키기 위한 '복사'의 기본을, cast는 성능을 위한 '타입 단언'을, 그리고 mapwhereType은 안전하고 유연한 '데이터 변환'을 담당합니다.

성공적인 Dart 개발자는 이 도구들의 차이점을 명확히 이해하고 상황에 맞는 최적의 칼을 선택할 수 있어야 합니다. 특히 타입 안전성이 중요한 Dart 생태계에서는, 잠재적인 런타임 오류를 유발할 수 있는 cast()의 사용을 최소화하고, map이나 whereType과 같이 의도가 명확하고 타입 검사가 내재된 안전한 방법을 우선적으로 고려하는 습관이 중요합니다. 이러한 이해를 바탕으로 더 깨끗하고, 효율적이며, 예측 가능한 코드를 작성해 나가시길 바랍니다.


0 개의 댓글:

Post a Comment