Dart List·Map·Set 선언과 안전한 수정 방법

Dart 컬렉션을 안전하게 쓰려면 요소 타입, 컬렉션 자체의 수정 가능 여부, 원본 변경을 따라가는지까지 구분해야 합니다. 특히 List.unmodifiable로 만든 리스트와 UnmodifiableListView는 같은 동작이 아닙니다. 원본 리스트에 항목을 추가했을 때 결과가 달라지는 지점부터 확인하세요.

2026년 10월 23일 공식 API와 대조해 기존 글의 잘못된 원본 변경 설명을 정정하고, 코드에 빠졌던 제네릭 타입 표기를 보완했습니다. 아래 코드는 설명용이며 이번 검수에서 Dart analyzer·VM·Flutter로 실행한 결과는 포함하지 않습니다.

List·Map·Set은 접근 방법과 중복 기준으로 고르세요

종류 주요 접근 선택 예
List<E> 0부터 시작하는 위치 인덱스, 중복 요소 허용 순서가 있는 화면 항목
Map<K, V> 키로 값 조회, 같은 키는 한 항목 사용자 ID별 데이터
Set<E> 같은 요소의 중복 제거와 포함 검사 선택된 태그의 집합

순서 동작은 구체 구현까지 확인하세요. 특히 Set을 인덱스 기반 목록 대신 쓰거나, 모든 Map 구현이 같은 정렬 순서를 제공한다고 가정하면 안 됩니다. Dart 컬렉션 문서에서 리터럴과 타입 추론을 함께 확인할 수 있습니다.

빈 컬렉션에도 타입 인자를 남기세요

List<int> numbers = [1, 2, 3];
final List<String> tags = <String>[];
final Map<String, int> scores = {'kim': 10, 'lee': 20};
final Map<String, String> headers = <String, String>{};
final Set<String> uniqueNames = <String>{};

var inferredSet = {'a', 'b'}; // Set<String>
var emptyMap = {};           // Map<dynamic, dynamic>

타입 문맥 없는 {}는 빈 Map이 됩니다. 빈 Set이 필요하면 <String>{}처럼 요소 타입을 쓰거나 변수에 Set 타입을 명시합니다. raw List·Map 선언은 요소 타입 검사를 약하게 만들 수 있으므로, 외부 JSON을 내부 컬렉션에 넣기 전에는 실제 요소 타입도 확인하세요. 제네릭을 적는 일과 외부 값을 검증하는 일은 다릅니다.

수정과 새 컬렉션 생성을 구분하세요

수정 가능한 List의 add·insert·remove·removeAt, Map의 []=·update·remove, Set의 add·remove는 해당 객체를 바꿉니다. 같은 객체를 다른 코드도 들고 있다면 변경이 공유됩니다. 이전 상태와 새 상태를 구분해야 하는 곳에서는 복사 후 갱신을 검토하세요.

final source = <int>[1, 2, 3];
final appended = <int>[...source, 4];
final withoutTwo = source.where((value) => value != 2).toList();

final oldScores = <String, int>{'kim': 10, 'lee': 20};
final changed = <String, int>{...oldScores, 'kim': 30};
final removed = <String, int>{...oldScores}..remove('lee');

스프레드나 where(…).toList()는 위 예제에서 새 바깥 컬렉션을 만듭니다. 중첩된 객체까지 복제하는 깊은 복사는 아닙니다. Flutter에서도 복사만 하면 모든 상태관리 도구가 자동으로 갱신된다는 보장은 없습니다. 실제 상태를 교체하고 알리는 방식은 사용 중인 API 계약에 맞춰야 합니다.

List.unmodifiable와 원본을 따라가는 뷰의 차이

List.unmodifiable 생성자는 전달받은 요소들로 수정 불가 리스트를 만듭니다. 이후 원본 바깥 리스트의 길이·요소를 바꿨다고 결과 리스트의 항목이 같은 방식으로 추가·교체되지 않습니다. 원본 변경을 반영하는 읽기 전용 뷰는 UnmodifiableListView로 구분합니다.

import 'dart:collection';

void main() {
  final source = <int>[1, 2, 3];
  final snapshot = List<int>.unmodifiable(source);
  final liveView = UnmodifiableListView<int>(source);

  source.add(999);
  print(source);   // 계약상 예상: [1, 2, 3, 999]
  print(snapshot); // 계약상 예상: [1, 2, 3]
  print(liveView); // 계약상 예상: [1, 2, 3, 999]

  // snapshot.add(4); // 이 참조를 통한 수정은 허용하지 않음
  // liveView.add(4); // 이 참조를 통한 수정도 허용하지 않음
}

위 주석은 API 계약에 따른 예상값이며 이번 글에서 실행해 얻은 출력은 아닙니다. 호출자에게 넘기는 시점의 목록을 고정할지, 이후 저장소 변경도 보이게 할지에 따라 두 방식을 선택하세요. 두 결과 모두 그 참조를 통한 add나 요소 대입은 허용하지 않습니다.

수정 불가 리스트라도 내부 요소는 따로 봐야 합니다

final inner = <int>[1];
final outer = List<List<int>>.unmodifiable([inner]);
inner.add(2);
// outer의 길이와 요소 참조는 바꿀 수 없지만,
// 참조한 inner 자체는 여전히 수정 가능한 List<int>입니다.

외부 리스트에 새 요소를 넣는 것과 기존 요소인 inner를 수정하는 것은 다른 동작입니다. 내부까지 고정해야 한다면 각 요소도 수정 불가 값으로 만들거나 필요한 깊이만큼 별도로 복사·변환하세요. const 컬렉션은 상수 요소로 구성하는 별도 방식이며, final은 변수 재대입을 막을 뿐 수정 가능한 컬렉션의 add까지 막지 않습니다.

외부에 제공하는 getter의 수명을 명시하세요

class UserStore {
  final List<String> _users = <String>[];

  // 호출 시점의 요소로 새 수정 불가 리스트를 반환합니다.
  List<String> get users => List<String>.unmodifiable(_users);

  void addUser(String name) {
    _users.add(name);
  }
}

한 번 받아 둔 store.users 결과에는 이후 addUser로 추가한 항목이 자동으로 들어오지 않습니다. getter를 다시 호출하면 그 시점의 요소를 가진 새 결과를 받습니다. 실시간 뷰가 필요한 API라면 UnmodifiableListView를 사용하는지와 변경 알림 방법을 계약에 명시해야 합니다. String 대신 수정 가능한 사용자 객체를 담는다면 각 객체의 변경 가능성도 따로 다뤄야 합니다.

적용할 때 비교할 확인표

조건 확인할 내용
List.unmodifiable 생성 후 원본에 add 원본 길이는 늘어도 기존 결과 리스트의 길이는 유지되는지
UnmodifiableListView 생성 후 원본에 add 뷰를 다시 만들지 않아도 원본 항목 변화가 반영되는지
수정 불가 결과에 직접 add 또는 요소 대입 정상 수정으로 처리되지 않고 오류로 드러나는지
수정 가능한 내부 List를 바깥 수정 불가 List에 넣음 안쪽 변경이 보이는 얕은 보호의 한계를 이해했는지
빈 Set과 Map 선언 프로젝트 분석기에서 의도한 타입으로 추론되는지

이 표는 독자가 프로젝트 SDK에서 수행할 검증 계획입니다. 복사 범위, 원본 공유 여부와 상태 알림까지 함께 확인하고 결과를 기록하세요.

확인한 공식 자료와 관련 가이드

확인일은 2026년 10월 23일입니다. 공식 계약을 설명한 예제이며 성능·실기기·상태관리 라이브러리별 동작은 측정하지 않았습니다.