Flutter 컬렉션 기본: List / Map / Set 선언과 수정

Flutter 개발에서 컬렉션(List/Map/Set)은 상태(state), UI 렌더링 데이터, API 응답 파싱 결과를 담는 핵심 자료구조입니다. 문제는 “담는 것”보다 어떻게 바꾸는지(수정), 그리고 어떻게 안 바뀌게(불변) 설계하는지에서 실수가 많이 난다는 점입니다. 이 글에서 선언/수정/불변 패턴을 한 번에 정리해드리겠습니다.

List / Map / Set 요약

  • List: 순서가 있는 값들의 묶음 (인덱스로 접근, 중복 허용)

  • Map: key → value 매핑 (키는 유일, 값은 중복 가능)

  • Set: 중복 없는 값들의 집합 (순서 개념은 약함, “유일성”이 핵심)

선언(생성) 기본 패턴

List 선언

// 타입 명시
List numbers = [1, 2, 3];

// 타입 추론
var names = ['a', 'b', 'c']; // List

// 빈 리스트: 타입 지정 습관이 버그를 줄임
final List tags = [];

자주 하는 실수

var empty = []; 
// empty는 "List"으로 추론되기 쉬워서, 이후 타입 안정성이 약해질 수 있음

권장

final List emptyInts = [];

Map 선언

Map scores = {
  'kim': 10,
  'lee': 20,
};

var user = {
  'name': 'hong',
  'age': 30,
}; // Map 비슷하게 추론될 수 있음(값 타입이 섞이면)

빈 맵도 타입 지정 권장

final Map headers = {};

Set 선언 (중요: {}는 기본이 Map!)

Dart에서 {}는 빈 Set이 아니라 빈 Map입니다. Set은 이렇게 선언합니다:

final Set ids = {1, 2, 3};

// 빈 Set은 타입을 명시해야 Set으로 인식
final Set uniqueNames = {};

실수 예

var s = {}; 
// 이건 Set이 아니라 Map로 추론됨

수정(변경) 기본 API

List 수정: add / insert / remove / removeAt / clear

final list = ['a', 'b'];

list.add('c');          // ['a','b','c']
list.insert(1, 'x');    // ['a','x','b','c']

list.remove('b');       // 값 기준 삭제(첫 번째 1개)
list.removeAt(0);       // 인덱스 기준 삭제
list.clear();           // 모두 삭제

추가로 자주 쓰는 패턴

final numbers = [1, 2, 3, 4];
numbers.removeWhere((n) => n.isEven); // 홀수만 남김 -> [1,3]

Map 수정: []= / putIfAbsent / remove / update

final map = {'a': 1};

map['b'] = 2; // 추가/수정

map.putIfAbsent('c', () => 3); // 없을 때만 생성
map.update('a', (v) => v + 10); // 키가 있을 때 업데이트

map.remove('b'); // 삭제

키 존재 여부 체크

if (map.containsKey('a')) {
  // ...
}

Set 수정

final set = {1, 2, 3};

set.add(3);    // 변화 없음(이미 존재)
set.add(4);    // {1,2,3,4}
set.remove(2); // {1,3,4}

final a = {1, 2, 3};
final b = {3, 4, 5};

final u = a.union(b);         // {1,2,3,4,5}
final i = a.intersection(b);  // {3}
final d = a.difference(b);    // {1,2}

불변(Immutable) 패턴이 중요한 이유 

Flutter에서는 특히 다음 상황에서 “기존 컬렉션을 직접 수정”하면 문제가 생기기 쉽습니다.

  • 상태관리에서 “같은 리스트를 계속 수정” → 변경 감지가 꼬이거나, 예상치 못한 UI 갱신/미갱신 발생

  • 여러 곳에서 같은 리스트 참조 공유 → 한 곳에서 바꿨는데 다른 곳 데이터도 같이 바뀌는 “사이드 이펙트”

그래서 실무에서는 “원본은 그대로 두고, 새 컬렉션을 만들어 교체”하는 방식(불변 업데이트)을 많이 씁니다.

불변 업데이트(복사해서 수정) 실전 패턴

List 불변 업데이트: 스프레드(...)로 새 리스트 생성

final oldList = [1, 2, 3];

// 추가
final newList = [...oldList, 4];

// 중간 삽입(예: index 1에 99)
final inserted = [
  ...oldList.take(1),
  99,
  ...oldList.skip(1),
];

// 삭제(조건)
final removed = oldList.where((x) => x != 2).toList();

Map 불변 업데이트: 스프레드로 새 맵 생성

final oldMap = {'a': 1, 'b': 2};

// 키 추가/수정
final newMap = {...oldMap, 'b': 999, 'c': 3};

// 키 삭제(복사 후 제거)
final removed = Map.from(oldMap)..remove('a');

Set 불변 업데이트

final oldSet = {1, 2, 3};

// 추가
final newSet = {...oldSet, 4};

// 삭제
final removed = oldSet.where((x) => x != 2).toSet();

unmodifiable vs const 컬렉션 차이 (핵심 정리)

여기가 가장 헷갈리는 구간입니다. 

  • const 컬렉션: 컴파일 타임 상수로 만들어지는 “진짜 불변”에 가까움(수정 시도 자체가 런타임 전에 막히거나, 런타임에서 예외).

  • unmodifiable 컬렉션: “수정 메서드만 막는 래퍼/스냅샷” 성격. 얕은(shallow) 불변이며, 원본 참조가 살아있으면 원본이 변할 수도 있음.

const 컬렉션

const list = [1, 2, 3];
// list.add(4); // 불가

const map = {'a': 1};
// map['b'] = 2; // 불가

const set = {1, 2, 3};
// set.add(4); // 불가

특징

  • 요소들도 const로 구성되어야 합니다(중첩도 포함).

  • “앱 실행 중 바뀌면 안 되는 설정값/기본값”에 적합.

  • Flutter에서 위젯 트리 최적화에도 도움이 되는 경우가 많습니다(특히 const 위젯과 함께).

unmodifiable 컬렉션

final source = [1, 2, 3];
final view = List.unmodifiable(source);

// view.add(4); // 불가(예외)

중요 포인트(자주 터지는 버그)

final source = [1, 2, 3];
final view = List.unmodifiable(source);

source.add(999);

print(view); // [1, 2, 3, 999] 처럼 보일 수 있음(“원본이 바뀌면 뷰도 바뀜”)

즉, unmodifiable“이 참조를 통해서는 수정 못 하게” 막는 성격이지, 원본까지 봉인하는 “완전 불변”이 아닙니다.

const vs unmodifiable 사용 시기

  • const

    • 앱 전체에서 고정되는 값(상수 데이터, 라우트 이름 목록, 고정 메뉴, 고정 매핑 등)

    • “절대 바뀌면 안 된다”를 컴파일 타임부터 강하게 보장하고 싶을 때

  • unmodifiable

    • 외부로 컬렉션을 노출할 때(캡슐화) “읽기 전용 뷰”를 주고 싶을 때

    • 예: 클래스 내부 리스트는 수정 가능하지만, getter로는 수정 못 하게 공개

 
class UserStore {
  final List _users = [];

  List get users => List.unmodifiable(_users);

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

final / const / unmodifiable 혼동 포인트 정리

final은 참조 변경 불가

final list = [1, 2, 3];
list.add(4); // 가능 (내용 변경)

 // list = [9, 9, 9]; // 불가 (참조 변경)

var은 재할당 가능

var list = [1, 2];
list.add(3);     // 가능
list = [9, 9];   // 가능

const 내용 변경 자체가 불가

const list = [1, 2];
// list.add(3); // 불가

unmodifiable 수정 메서드만 막는 읽기 전용 성격

final src = [1, 2];
final ro = List.unmodifiable(src);
// ro.add(3); // 불가

실무 팁

  • Flutter 상태 업데이트는 가능하면 이런 형태를 권장합니다.

    • List: state = [...state, newItem]

    • Map: state = {...state, key: value}

  • 외부로 컬렉션을 노출할 때는 unmodifiable getter로 보호하세요.

  • 중첩 컬렉션(예: List<Map<...>>)은 unmodifiable이 얕기 때문에 내부까지 안전하게 하려면 구조 자체를 불변으로 두거나, 별도 불변 모델(예: freezed) 사용을 검토하세요.

결론

List/Map/Set은 선언 자체보다 “어떻게 바꾸는지”와 “어떻게 안 바뀌게 만들지(불변)”에서 실수가 많이 발생합니다. List는 순서 기반, Map은 key 기반, Set은 중복 제거가 핵심이므로 각각의 수정 API(add, remove, update 등)를 정확히 구분해두는 것이 중요합니다. 특히 Flutter에서는 상태 업데이트 시 기존 컬렉션을 직접 수정하기보다 스프레드(…)나 where/toList 같은 방식으로 새 컬렉션을 만들어 교체하는 불변 업데이트 패턴이 UI 안정성과 디버깅 난이도를 크게 개선합니다. 또한 final은 참조만 고정할 뿐 내용은 바뀔 수 있고, const는 컴파일 타임 수준에서 변경을 강하게 막는 반면, unmodifiable은 해당 참조에서의 수정만 막는 “읽기 전용 뷰”에 가깝다는 차이를 명확히 이해해야 예상치 못한 사이드 이펙트를 줄일 수 있습니다. 정리하면, 고정 데이터는 const, 외부 노출은 unmodifiable getter, 상태 갱신은 복사 후 교체 패턴을 기본 원칙으로 삼는 것이 안전합니다.

FAQ

List, Map, Set은 각각 언제 쓰는 게 가장 적절한가요?

List는 “순서”가 의미가 있고 인덱스로 접근해야 할 때 적합합니다. Map은 특정 key로 빠르게 값을 찾고 싶을 때, Set은 중복을 허용하면 안 되는 값 목록(예: 선택된 id 집합, 방문한 페이지 기록 등)을 다룰 때 효율적입니다.

빈 컬렉션을 만들 때 var empty = []; 같은 코드는 왜 위험할 수 있나요?

초기값이 빈 컬렉션이면 타입 정보가 부족해 List<dynamic>처럼 추론되기 쉬워 이후에 타입 안정성이 약해질 수 있습니다. 빈 컬렉션은 final List<String> items = []; 처럼 타입을 명시해두면 컴파일 단계에서 실수를 더 빨리 잡을 수 있습니다.

Set을 {}로 만들면 안 되나요?

{}는 기본적으로 “빈 Map”으로 해석됩니다. 빈 Set은 final Set<String> s = <String>{}; 처럼 타입을 명시해 생성해야 합니다. 이 차이를 모르면 의도와 다르게 Map이 만들어져 로직이 꼬일 수 있습니다.

final 컬렉션이면 내용도 변경이 안 되는 건가요?

final은 “변수에 다른 컬렉션을 다시 대입”하는 재할당만 막습니다. 컬렉션 내부 요소 추가/삭제 같은 변경은 가능합니다. 내용 변경까지 막고 싶다면 const 컬렉션을 쓰거나, 외부에 노출할 때 unmodifiable 뷰를 제공하는 방식이 필요합니다.

const 컬렉션과 unmodifiable 컬렉션의 핵심 차이는 무엇인가요?

const는 컴파일 타임 상수로 만들어져 변경 자체가 원천적으로 제한되는 강한 불변에 가깝습니다. unmodifiable은 해당 객체를 통해 add/remove 같은 수정 메서드를 호출하지 못하게 막는 “읽기 전용 래퍼/뷰” 성격이 강합니다. 즉, const는 데이터 자체를 고정하려는 용도, unmodifiable은 외부에 안전하게 공개하려는 용도로 이해하는 것이 정확합니다.

unmodifiable을 쓰면 원본까지 완전히 안전해지나요?

항상 그렇지는 않습니다. unmodifiable은 얕은 불변(shallow immutability) 성격이라, 원본 컬렉션을 다른 곳에서 수정하면 읽기 전용 뷰에서 보이는 내용도 함께 바뀔 수 있습니다. “완전히 고정”이 목표라면 원본을 수정하지 않는 설계(불변 업데이트)와 함께 사용해야 합니다.

Flutter에서 컬렉션을 직접 수정하면 어떤 문제가 생길 수 있나요?

상태관리 방식에 따라 변경 감지가 꼬이거나, 참조 공유로 인해 예상치 못한 위치에서 데이터가 함께 바뀌는 사이드 이펙트가 발생할 수 있습니다. 특히 리스트를 직접 add/remove하면서 UI가 예상과 다르게 갱신되거나, 디버깅이 어려워지는 경우가 많습니다.

불변 업데이트 패턴은 어떤 식으로 작성하나요?

List는 […oldList, newItem]처럼 스프레드로 새 리스트를 만들고 교체하는 방식이 대표적입니다. Map은 {…oldMap, ‘key’: value}로 새 맵을 만들 수 있고, Set도 {…oldSet, item}처럼 확장해 새 Set을 만들 수 있습니다. 핵심은 “원본을 건드리지 않고 새 객체로 교체”입니다.

컬렉션을 외부에 노출할 때 가장 안전한 방법은 무엇인가요?

클래스 내부에서는 수정 가능한 컬렉션을 유지하되, getter에서는 List.unmodifiable 같은 읽기 전용 뷰로 반환하는 패턴이 안전합니다. 이렇게 하면 외부 코드가 실수로 add/remove를 호출해 내부 상태를 깨뜨리는 것을 막을 수 있습니다.

댓글 남기기