
setState를 썼는데 왜 화면이 안 바뀌죠? (불변성의 중요성)
분명 코드를 실행했는데 화면은 그대로입니다. 리스트에 add를 했는데 반응이 없습니다. Dart의 메모리 참조(Reference)와 불변성(Immutability)을 이해하면, 당신의 앱은 다시 살아납니다.

분명 코드를 실행했는데 화면은 그대로입니다. 리스트에 add를 했는데 반응이 없습니다. Dart의 메모리 참조(Reference)와 불변성(Immutability)을 이해하면, 당신의 앱은 다시 살아납니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

초보 시절 제가 가장 많이 저지른 실수입니다. 리스트에 아이템을 추가하고, 화면을 갱신하려고 했습니다.
// ❌ 잘못된 코드
List<String> items = ['A', 'B'];
void addItem() {
setState(() {
items.add('C'); // 리스트에 C를 추가함
});
}
제 머릿속 로직:
items에 'C'가 들어갔다.setState를 불렀다.items는 3개일 것이다.현실: 아무 일도 일어나지 않았습니다. (혹은, 복잡한 위젯 트리에서는 가끔 바뀌고 가끔 안 바뀌는 유령 같은 버그가 되었습니다.)
Flutter나 React 같은 현대적 UI 프레임워크는 효율성을 위해 "변했는지 안 변했는지"를 끊임없이 검사합니다. 그리고 그 검사 방법은 대부분 "메모리 주소가 같은가?"(Reference Equality)입니다.
위의 코드에서 items 변수가 가리키는 리스트 객체(메모리 상의 방)는 변하지 않았습니다.
단지 그 방 안에 사는 사람(데이터)이 늘어났을 뿐입니다.
만약 여러분이 스마트한 위젯(예: Selector, Riverpod, 또는 최적화된 리스트 위젯)을 쓰고 있다면,
Flutter는 이렇게 생각합니다.
"어? items의 메모리 주소를 보니까 아까랑 그 전이랑 똑같네? 안 변했구나. 다시 그리지 말아야지(Skip Rebuild)."
이것이 Mutable(가변) 객체의 함정입니다.
Flutter에게 "변했다!"라고 확실하게 알리는 방법은, 아예 새로운 리스트를 만들어서 대입하는 것입니다.
// ✅ 올바른 코드
void addItem() {
setState(() {
// 1. 기존 리스트를 복사해서 새로운 리스트 생성 (Spread Operator)
items = [...items, 'C'];
});
}
이제 items 변수는 완전히 새로운 메모리 주소를 가리킵니다.
Flutter는 "어? 주소가 바뀌었네? 내용물이 달라졌구나! 다시 그려야겠다!"라고 즉각 반응합니다.
이것이 바로 불변성(Immutability)을 지키는 코딩 스타일입니다.
이 문제는 상태 관리 라이브러리를 쓸 때 더 치명적입니다.
// 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'];
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] 처럼 새로운 리스트를 대입해야 알림이 갑니다.
const 생성자의 함정만약 자식 위젯을 const로 선언했다면, 부모가 setState를 해도 자식은 다시 그려지지 않습니다.
// ❌ const가 있으면 부모가 setState 해도 무시됨
const MyWidget(data: items),
Flutter는 const를 "영원히 변하지 않는 위젯"으로 취급하고, 컴파일 타임에 상수로 박제해버립니다.
데이터가 동적으로 바뀌어야 한다면 const를 깨세요.
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)가 바뀌었는지를 체크하기 때문입니다.
Freezed 3분 요리"매번 새로운 객체를 복사해서 만들기 귀찮아요. copyWith 메소드 짜기 힘들어요."
그래서 우리는 freezed 패키지를 씁니다.
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%를 컴파일 타임에 예방해줍니다. 실제 필수템입니다.
쇼핑몰 앱에서 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를 까먹을 수 없게 만듦)
setState의 한계를 느꼈다면 다음 단계로 넘어가야 합니다.
하지만 선택지가 너무 많아서 고민입니다. 짧게 정리해드립니다.
GlobalKey 이슈나 context 의존성 때문에 대세는 지고 있습니다.context 없이 어디서든 상태를 부를 수 있습니다. 컴파일 타임 안전성이 뛰어납니다. (강력 추천)GetX는 context 없이 간편하게 코딩할 수 있어서 초보자에게 인기가 많습니다. (Obx, Get.to 등)
하지만 "생태계 파괴자"라는 별명이 있습니다.
취미용 앱이라면 OK, 커리어용/회사용 앱이라면 Riverpod이나 Bloc을 배우세요.
문제:
List<int> numbers = [1, 2, 3]; 이 있습니다.
numbers.add(4);를 쓰지 않고, 4를 추가한 새로운 리스트를 만드세요.
final newNumbers = [...numbers, 4];
이 습관 하나가 여러분의 연봉을 올립니다.
a == b in Dart defaults to this for Objects)....): A syntax to expand an iterable into individual elements. Commonly used to copy lists: [...oldList, newItem].build() method of a widget again to reflect new state."화면이 안 바뀐다"는 건 90% 확률로 "Flutter가 바뀐 줄 모르고 있다"는 뜻입니다.
[...])를 친구처럼 지내라.
notifyListeners()를 잊지 마라.
이 불변성의 원칙만 지키면, 갱신 버그의 늪에서 탈출할 수 있습니다.