Dart 변수 스코프와 Flutter 상태 생명주기

Flutter에서 숫자가 초기화되는 문제는 “변수를 어디에 선언했나”와 “그 변수를 소유한 State가 교체됐나”를 함께 봐야 풀립니다. build의 지역 변수, State 필드, 부모가 새로 준 초기값은 서로 다른 시점에 만들어지고 유지됩니다. key를 매번 바꾸는 코드에서는 필드에 저장해도 값이 다시 초기화될 수 있습니다.

2026년 10월 8일 Flutter 3.47.5·Dart 3.13.4의 격리 위젯 테스트로 재빌드, 같은 key의 설정 변경, 새 key, 제거 후 재삽입, 구독 대상 변경을 직접 실행했습니다. 분석 오류 없이 두 테스트가 통과했습니다. 별도 Dart VM 코드에서는 클로저가 보존한 지역 변수와 같은 이름의 지역 변수에 가려진 필드를 네 조건으로 확인했습니다.

접근 범위와 값이 살아 있는 기간은 다르다

스코프는 이름을 어디에서 참조할 수 있는지를 정합니다. 생명주기는 값과 객체가 언제까지 사용 가능한지를 봅니다. “함수 호출이 끝나면 모든 지역 변수도 끝난다”는 설명에는 예외가 있습니다. 반환한 클로저가 지역 변수를 캡처하면, 함수 호출이 끝난 뒤에도 그 값을 다음 호출에서 사용할 수 있습니다.

int Function() makeCounter() {
  var value = 0;
  return () => ++value;
}
void main() {
  final next = makeCounter();
  print(next()); // 1
  print(next()); // 2
}

직접 실행에서 1 다음 2가 출력됐습니다. 원래 함수가 이미 반환됐다는 사실만으로 값 보존 여부를 판단할 수 없는 이유입니다. 스코프를 벗어난 이름에 바깥 코드가 직접 접근할 수는 없어도, 그 이름을 캡처한 함수가 접근할 수 있습니다. 이 테스트는 가비지 컬렉터의 실제 회수 시점을 측정한 것은 아닙니다.

Flutter 재빌드 같은 key 새 key 재삽입과 listener 교체 생명주기 테스트 결과
2026년 10월 8일 격리 환경의 실제 실행 결과를 요약한 설명 도식입니다.

재빌드 때 필드 1과 지역 변수 0을 함께 관찰

테스트 위젯은 State 필드 count=0을 가지고, build 안에는 int local=0을 선언했습니다. 버튼에서 두 값에 모두 1을 더하고 setState를 호출했습니다. 다음 build가 만든 화면은 field=1;local=0이었습니다.

int count = 0; // State 필드
Widget build(BuildContext context) {
  int local = 0;
  return TextButton(
    onPressed: () => setState(() { count++; local++; }),
    child: Text('field=$count;local=$local'),
  );
}

버튼 콜백은 그 build 호출의 local을 캡처합니다. 콜백이 바꾼 local은 1이지만, 다음 build 호출은 새로운 local을 0으로 만듭니다. 따라서 화면에서 지속해야 하는 값은 State 필드나 더 오래 사는 상태 소유자에 두고, build 지역 변수에는 현재 화면을 계산할 임시값을 둡니다.

새 초기값을 넘겼는데 필드가 그대로인 이유

조작 관찰한 State 필드 값 초기화 판단
버튼으로 setState 기존 State 1 initState 재호출 없음
같은 key, initial=100 같은 State 1 didUpdateWidget 호출
key A를 B로 변경 새 State 100 새 initState
완전히 제거 후 key B 재삽입 새 State 100 이전 State는 dispose

같은 위치에서 runtimeType과 key가 같은 위젯으로 갱신하면 State를 유지할 수 있습니다. 실험은 같은 타입과 ValueKey를 사용했고, identical로 State 객체가 같은지 확인했습니다. initial 값을 100으로 바꿔도 initState에서 한 번 복사한 count는 1을 유지했습니다.

initial이라는 이름을 “매번 현재값을 덮어쓰는 값”으로 오해하면 입력 내용이 사라지거나 반대로 초기화가 안 됩니다. 초기값을 한 번만 사용할지, 부모 값이 바뀔 때 내부 편집값도 바꿀지 먼저 정합니다. 동기화가 필요하면 didUpdateWidget에서 이전 설정과 새 설정을 비교하며, 사용자 편집을 덮어써도 되는 조건까지 명시합니다.

구독하는 객체가 바뀌면 listener도 옮긴다

ValueNotifier A의 값 10을 11로 바꾸자 콜백이 한 번 실행됐습니다. 같은 State에 notifier B를 넘긴 뒤 A를 12로 바꿔도 추가 호출은 없었고, B를 20에서 21로 바꾸자 두 번째 콜백이 실행됐습니다. 위젯 제거 뒤 B를 22로 바꿔도 추가 콜백과 Flutter 예외가 없었습니다.

@override
void didUpdateWidget(covariant Probe oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.source != widget.source) {
    oldWidget.source.removeListener(changed);
    widget.source.addListener(changed);
  }
}
@override
void dispose() {
  widget.source.removeListener(changed);
  super.dispose();
}

생성은 initState, 대상 교체는 didUpdateWidget, 해제는 dispose에서 대응시켰습니다. notifier가 아니라 Stream이나 controller라면 해당 API의 해제 방식을 사용해야 합니다. dispose에서 현재 대상만 정리해도, 교체할 때 이전 대상에서 해제하지 않았다면 과거 객체가 콜백을 계속 보유할 수 있습니다.

필드를 바꿨다는 착각은 이름 가리기로 확인

별도 클래스의 필드 value는 0이었습니다. 메서드 안에서 같은 이름의 local value=10을 만들고 1을 더하자 반환값은 11, 필드는 여전히 0이었습니다. 재빌드 문제를 찾기 전에 실제로 어떤 값을 수정했는지 확인합니다. 지역 이름을 구분하거나 this.value로 필드 접근을 명확히 할 수 있습니다.

초기화 문제를 찾을 때 남길 네 가지 기록

  1. 값의 선언 위치: build 지역 변수, State 필드, 부모 객체 중 어디인가?
  2. initState·didUpdateWidget·dispose 중 무엇이 호출됐는가?
  3. 위젯의 runtimeType과 key가 바뀌었는가?
  4. listener가 구독하는 실제 객체가 바뀌었는가?

이 기록이면 “같은 객체에서 값을 덮어썼다”와 “새 객체를 만들었다”를 나눌 수 있습니다. 리스트의 각 행에서 상태가 섞이면 데이터 ID로 key를 정했는지도 확인합니다. GlobalKey 재배치, IndexedStack 유지, 상태 복원, 앱 프로세스 종료는 이번 실험 범위 밖이며 위 표를 모든 이동에 일반화하지 않습니다.

dart run scope_closure.dart
flutter analyze
flutter test --reporter expanded
// closure: 4 조건 통과 / widget tests: 2 passed

값을 변경한 뒤 화면에 알리는 문제는 변수 재할당과 화면 갱신의 차이로 이어서 볼 수 있습니다.

검증한 흐름의 공식 근거는 Dart lexical closures, Flutter State 생명주기, Widget key, didUpdateWidget의 구독 교체 규칙입니다. 2026년 10월 8일 현재 문서를 확인했으며 테스트는 실기기나 앱 재시작을 포함하지 않았습니다.