setState를 썼는데 왜 화면이 안 바뀌죠? (불변성의 중요성)
1. "분명히 add 했는데..."
초보 시절 제가 가장 많이 저지른 실수입니다. 리스트에 아이템을 추가하고, 화면을 갱신하려고 했습니다.
// ❌ 잘못된 코드
List<String> items = ['A', 'B'];
void addItem() {
setState(() {
items.add('C'); // 리스트에 C를 추가함
});
}
제 머릿속 로직:
items에 'C'가 들어갔다.setState를 불렀다.- 화면이 다시 그려질 때
items는 3개일 것이다. - 리스트뷰에 'C'가 뜰 것이다.
현실: 아무 일도 일어나지 않았습니다. (혹은, 복잡한 위젯 트리에서는 가끔 바뀌고 가끔 안 바뀌는 유령 같은 버그가 되었습니다.)
2. 원리 이해: 참조(Reference)와 비교(Equality)
Flutter나 React 같은 현대적 UI 프레임워크는 효율성을 위해 "변했는지 안 변했는지"를 끊임없이 검사합니다. 그리고 그 검사 방법은 대부분 "메모리 주소가 같은가?"(Reference Equality)입니다.
위의 코드에서 items 변수가 가리키는 리스트 객체(메모리 상의 방)는 변하지 않았습니다.
단지 그 방 안에 사는 사람(데이터)이 늘어났을 뿐입니다.
만약 여러분이 스마트한 위젯(예: Selector, Riverpod, 또는 최적화된 리스트 위젯)을 쓰고 있다면,
Flutter는 이렇게 생각합니다.
"어? items의 메모리 주소를 보니까 아까랑 그 전이랑 똑같네? 안 변했구나. 다시 그리지 말아야지(Skip Rebuild)."
이것이 Mutable(가변) 객체의 함정입니다.
3. 해결책: 새로운 객체로 갈아치워라 (Immutability)
Flutter에게 "변했다!"라고 확실하게 알리는 방법은, 아예 새로운 리스트를 만들어서 대입하는 것입니다.
// ✅ 올바른 코드
void addItem() {
setState(() {
// 1. 기존 리스트를 복사해서 새로운 리스트 생성 (Spread Operator)
items = [...items, 'C'];
});
}
이제 items 변수는 완전히 새로운 메모리 주소를 가리킵니다.
Flutter는 "어? 주소가 바뀌었네? 내용물이 달라졌구나! 다시 그려야겠다!"라고 즉각 반응합니다.
이것이 바로 불변성(Immutability)을 지키는 코딩 스타일입니다.
4. 심화: Provider/Riverpod에서의 침묵
이 문제는 상태 관리 라이브러리를 쓸 때 더 치명적입니다.
// Riverpod 예시
final listProvider = StateProvider<List<String>>((ref) => []);
// ❌ 나쁜 예
ref.read(listProvider).add('New Item');
// Provider는 값이 변했다는 알림(notify)을 못 받음
Provider나 Riverpod은 state = something 처럼 대입 연산자(=)가 실행될 때만 "값이 변했다"고 판단하고 구독자(위젯)들에게 알림을 보냅니다.
내부 메서드인 .add(), .remove()를 쓰는 건 아무런 효과가 없습니다.
// ✅ 좋은 예
final oldList = ref.read(listProvider);
ref.read(listProvider.notifier).state = [...oldList, 'New Item'];
5. 심화: ValueNotifier와 ChangeNotifier
setState는 위젯 전체를 다시 그립니다(Rebuild). 비효율적이죠.
데이터만 살짝 바꾸고 싶을 땐 ValueNotifier를 씁니다.
// Controller 부분
final counter = ValueNotifier<int>(0);
// UI 부분
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('$value'); // 여기만 바뀜!
}
);
// 업데이트
counter.value = 1; // .value에 대입하는 순간 알림이 감
하지만 여기서도 함정이 있습니다. ValueNotifier<List<int>>를 쓸 때,
counter.value.add(1)만 하면 알림이 안 갑니다.
반드시 counter.value = [...counter.value, 1] 처럼 새로운 리스트를 대입해야 알림이 갑니다.
6. 심화: const 생성자의 함정
만약 자식 위젯을 const로 선언했다면, 부모가 setState를 해도 자식은 다시 그려지지 않습니다.
// ❌ const가 있으면 부모가 setState 해도 무시됨
const MyWidget(data: items),
Flutter는 const를 "영원히 변하지 않는 위젯"으로 취급하고, 컴파일 타임에 상수로 박제해버립니다.
데이터가 동적으로 바뀌어야 한다면 const를 깨세요.
7. Deep Dive: Selector로 핀셋처럼 골라내기
Provider를 쓸 때 context.watch<MyModel>()을 하면 모델의 아무 필드나 바뀌어도 위젯 전체가 리빌드됩니다.
마치 "이름"만 바꿨는데 "나이", "주소"를 보여주는 위젯까지 다시 그려지는 셈입니다.
이때 Selector를 쓰면 진짜 필요한 데이터가 변했을 때만 리빌드할 수 있습니다.
Selector<UserProvider, String>(
selector: (context, provider) => provider.name, // "이름"만 감시한다
builder: (context, name, child) {
return Text(name); // "이름"이 변할 때만 여기만 다시 그려짐
// "나이"가 변해도 이 위젯은 꿈쩍도 안 함 (Rebuild Skip)
},
)
이건 불변성(Immutability)과 짝꿍입니다.
provider.name이 단순 변수가 아니라 객체라면, 그 객체의 참조(Reference)가 바뀌었는지를 체크하기 때문입니다.
8. Case Study: Freezed 3분 요리
"매번 새로운 객체를 복사해서 만들기 귀찮아요. copyWith 메소드 짜기 힘들어요."
그래서 우리는 freezed 패키지를 씁니다.
Before:
class User {
final String name;
final int age;
User(this.name, this.age);
// 이거 직접 다 쳐야 됨... (오타 나면 망함)
User copyWith({String? name, int? age}) {
return User(name ?? this.name, age ?? this.age);
}
}
After:
@freezed
class User with _$User {
factory User(String name, int age) = _User;
}
// 사용
state = state.copyWith(age: 20); // 마법처럼 자동 생성됨!
freezed는 불변 객체를 강제하고, == 연산자(Equality)도 자동으로 오버라이딩해줍니다.
"화면이 안 바뀌는 버그"의 99%를 컴파일 타임에 예방해줍니다. 실제 필수템입니다.
9. Case Study: 장바구니에 담았는데 0원?
쇼핑몰 앱에서 CartProvider를 구현했습니다.
class Cart extends ChangeNotifier {
final List<Product> _items = [];
void add(Product item) {
_items.add(item);
// notifyListeners(); // 실수로 이걸 주석 처리함
}
}
사용자가 '담기'를 눌렀는데 장바구니 아이콘의 숫자가 올라가지 않았습니다. 하지만 다른 페이지 갔다가 오면(화면이 새로 그려지면) 숫자가 1로 바뀝니다. 이게 전형적인 "데이터는 변했는데 UI가 모르는" 상황입니다.
ChangeNotifier에서는 반드시 데이터 변경 후 notifyListeners()를 직접 호출해야 합니다.
반면 Riverpod나 Bloc 같은 최신 라이브러리는 state = newState 패턴을 강제함으로써 이 실수를 원천 차단합니다. (개발자가 notify를 까먹을 수 없게 만듦)
10. Architecture: 상태 관리 도구 비교 (Provider vs Riverpod vs Bloc)
setState의 한계를 느꼈다면 다음 단계로 넘어가야 합니다.
하지만 선택지가 너무 많아서 고민입니다. 짧게 정리해드립니다.
- Provider (A급 판독기): 가장 기본. Flutter 공식 문서가 추천합니다. 하지만
GlobalKey이슈나context의존성 때문에 대세는 지고 있습니다. - Riverpod (S급 전술핵): Provider 제작자가 "내가 만든 Provider의 단점을 다 고쳤다"며 내놓은 걸작.
context없이 어디서든 상태를 부를 수 있습니다. 컴파일 타임 안전성이 뛰어납니다. (강력 추천) - Bloc (대기업 표준): 이벤트(Event)와 상태(State)를 엄격하게 분리합니다. 코드가 길어지지만, 팀 프로젝트에서 유지보수하기 가장 좋습니다. 은행/금융권 앱에서 선호합니다.
11. FAQ: "왜 GetX는 추천 안 해요?"
GetX는 context 없이 간편하게 코딩할 수 있어서 초보자에게 인기가 많습니다. (Obx, Get.to 등)
하지만 "생태계 파괴자"라는 별명이 있습니다.
- Flutter의 기본 원칙 위배: 플러터는 트리 구조(Tree Structure)가 핵심인데, GetX는 이걸 무시하고 전역으로 다 뚫어버립니다.
- 유지보수 불확실성: 1인 개발자가 너무 방대한 기능을 다 관리합니다. (상태관리, 라우팅, 유틸리티 등)
- 나중에 떼어내기 힘듦: 앱이 커지면 GetX 의존성을 걷어내는 게 불가능에 가까워집니다.
취미용 앱이라면 OK, 커리어용/회사용 앱이라면 Riverpod이나 Bloc을 배우세요.
12. Refactoring Challenge: Mutable to Immutable
문제:
List<int> numbers = [1, 2, 3]; 이 있습니다.
numbers.add(4);를 쓰지 않고, 4를 추가한 새로운 리스트를 만드세요.
정답:
final newNumbers = [...numbers, 4];
이 습관 하나가 여러분의 연봉을 올립니다.
13. Glossary
- Reference Equality: Checking if two variables point to the same memory location (
a == bin Dart defaults to this for Objects). - Immutability: The state cannot be modified after it is created. To change it, you must create a new object.
- Spread Operator (
...): A syntax to expand an iterable into individual elements. Commonly used to copy lists:[...oldList, newItem]. - Rebuild: The process of calling the
build()method of a widget again to reflect new state.
9. Summary
"화면이 안 바뀐다"는 건 90% 확률로 "Flutter가 바뀐 줄 모르고 있다"는 뜻입니다.
- 원본을 건드리지 마라 (Don't Mutate).
- 복사해서 새로 만들어라 (Copy & Replace).
- Spread Operator(
[...])를 친구처럼 지내라. - ChangeNotifier는
notifyListeners()를 잊지 마라.
이 불변성의 원칙만 지키면, 갱신 버그의 늪에서 탈출할 수 있습니다.