버튼을 누를 때마다 _count는 분명 1, 2, 3으로 늘어난다. 그런데 화면의 숫자는 0에서 꿈쩍하지 않는다. 값이 바뀐 것과 Flutter에 그 값을 다시 그리라고 알린 것은 다른 일이다. 카운터 하나로 두 단계를 분리해 보자.
Flutter에서 값이 바뀌어도 화면이 그대로인 이유
StatefulWidget의 바뀔 수 있는 값은 보통 별도의 State 객체에 둔다. build()는 그 시점의 상태를 읽어 화면에 보여 줄 위젯을 만든다. 필드 값을 직접 바꾸는 것만으로는 Flutter가 그 변경을 화면에 반영해야 한다는 신호를 받지 못한다.
왼쪽 경로는 값만 바꾼 경우다. 오른쪽 경로는 setState()로 변경을 알린 경우다. 같은 값 2라도 화면까지 가는 경로가 다르다.
setState 없이 카운터를 늘리면 무엇이 빠질까?
아래 카운터는 처음에 0을 보여 준다. 버튼을 누르면 _count는 증가하지만, 클릭 핸들러는 필드만 바꾼다.
import 'package:flutter/material.dart';
class CountCard extends StatefulWidget {
const CountCard({super.key});
@override
State<CountCard> createState() => _CountCardState();
}
class _CountCardState extends State<CountCard> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$_count'),
TextButton(
onPressed: () {
_count++; // 값은 바뀌지만 이 변경만으로 재빌드가 예약되지는 않는다.
},
child: const Text('Add'),
),
],
);
}
}_count가 1이 되어도 이 변경 자체는 build()를 다시 부르지 않는다. 다른 이유로 이 위젯이 나중에 재빌드되면 그때 1이 보일 수는 있다. 그래서 '가끔 뒤늦게 숫자가 맞는다'는 현상도 생긴다. Flutter가 마음을 바꾼 게 아니라, 화면을 다시 만들 계기가 뒤늦게 온 것이다.
setState는 값을 바꾸고 build를 언제 다시 부를까?
클릭 핸들러의 변경 부분만 다음처럼 바꾼다.
onPressed: () {
setState(() {
_count++;
});
},setState()는 전달한 함수를 즉시 실행해 _count를 바꾸고, 이 State의 재빌드를 예약한다. build()가 다시 실행될 때 Text('$_count')가 새 값을 읽는다. Flutter의 State.setState API는 상태만 직접 바꾸면 재빌드가 예약되지 않을 수 있다는 점과 이 호출의 범위를 설명한다.
setState()가 화면의 픽셀을 직접 수정하는 것은 아니다. '상태가 달라졌으니 이 부분을 다시 살펴봐 주세요'라고 프레임워크에 알리는 호출에 가깝다. 콜백에는 실제 상태 변경만 넣고, 시간이 걸리는 요청이나 계산을 통째로 넣지 않는다. setState(() async { ... })처럼 비동기 콜백으로 만드는 것도 피해야 한다.
setState를 불렀는데도 숫자가 그대로라면?
이번에는 신호를 보냈는데 화면이 안 바뀐다고 해 보자. 먼저 build()가 어느 값을 읽는지 본다. 같은 카운터에서 _count를 늘리면서 화면은 변경되지 않은 _displayCount를 읽는다면, 재빌드되어도 화면에는 이전 숫자가 나온다.
int _count = 0;
int _displayCount = 0;
// 클릭 때 setState(() { _count++; })를 불러도 아래 화면은 그대로다.
Text('$_displayCount')
// 이 카운터에서 화면에 연결하려던 값은 이것이다.
Text('$_count')setState 호출 여부와 build가 참조하는 값은 별개의 확인 항목이다. 디버깅할 때는 ① 클릭 핸들러가 실행됐는가 → ② 화면에 쓸 상태가 실제로 바뀌었는가 → ③ build()가 바로 그 상태를 읽는가 순서로 좁히면 된다. 화면이 안 바뀐다고 아무 곳에나 setState를 덧붙이면 원인을 더 숨길 수 있다.
비동기 작업 뒤에 화면이 사라졌다면
서버 응답을 기다리는 동안 사용자가 화면을 떠날 수도 있다. dispose()된 State에 setState()를 부르는 것은 오류다. 요청이나 구독을 취소할 수 있다면 생명주기에 맞춰 정리하고, 취소할 수 없는 결과를 화면에 적용하기 직전에는 mounted를 확인한다. 이 글의 카운터는 비동기 요청을 포함하지 않으므로, 이는 같은 원리를 실제 앱에 옮길 때 확인할 경계다.
핵심 요약
필드 변경과 화면 갱신 신호는 다르다. _count++만 하면 값은 바뀌어도 그 변경 때문에 build()가 예약되지는 않는다. setState(() { _count++; })는 변경을 알리지만, 화면이 새 값을 보여 주려면 build()도 _count를 읽어야 한다. 문제를 만나면 클릭, 상태 변경, 화면이 읽는 값을 이 순서대로 확인하자.
작성자
기초 개념을 구현과 검증, 실제 운영 판단까지 연결해 기록합니다.

