변수 스코프와 생명주기

Flutter(Dart)로 앱을 만드시는 초보자분들은 질문이 거의 비슷합니다. “변수는 어디에 저장돼요?”, “왜 값이 자꾸 초기화돼요?”, “왜 UI가 안 바뀌죠?” 같은 질문입니다. 이 문제의 핵심은 대부분 스코프(scope) 와 생명주기(lifetime) 를 제대로 구분하지 못해서 생깁니다. 이번 글에서는 전역/지역/클래스 필드의 스코프 차이, Flutter에서의 상태 생명주기, 그리고 실수를 유발하는 shadowing(섀도잉)까지 한 번에 정리해드리겠습니다.

변수 스코프(scope)

스코프는 “이 변수를 어디에서 접근할 수 있나”를 의미합니다. 같은 이름의 변수라도 스코프가 다르면 서로 다른 변수이며, 안쪽 스코프는 바깥 스코프를 가릴 수도 있습니다(섀도잉).

Dart에서 가장 자주 마주치는 스코프는 크게 3가지입니다. 전역(Top-level), 지역(Local), 클래스 필드(Field)입니다.

전역 변수

전역 변수(Top-level)는 파일 최상단(함수/클래스 밖)에 선언된 변수입니다. 어디서든 접근 가능하다는 점 때문에 “편의용”으로 쓰고 싶어지지만, 앱 규모가 커질수록 전역 상태는 디버깅과 테스트를 어렵게 만들고, 화면 간 의도치 않은 결합을 만들기 쉽습니다.

int globalCounter = 0;

void bump() {
  globalCounter++;
}

전역 변수가 문제가 되는 전형적인 패턴은 ‘어느 화면에서 누가 바꿨는지 추적이 안 되는 상태’가 생기는 경우입니다. 또한 핫리로드/핫리스타트 상황에서 초기화 타이밍이 달라 보일 수 있어, 초보자 입장에서는 “왜 갑자기 값이 남아있지?” 같은 혼란을 만들기도 합니다.

전역은 보통 “상수(const)”, “순수 함수”, “정말 제한된 설정값” 정도에만 두고, 공유 상태는 Provider/Riverpod/BLoC 같은 구조로 옮기는 편이 유지보수에 유리합니다.

지역 변수

지역 변수는 함수, 메서드, 또는 {} 블록 내부에 선언된 변수입니다. 선언된 스코프를 벗어나면 더 이상 접근할 수 없습니다.

void foo() {
  int x = 10;

  if (x > 0) {
    int y = 20;
    print(y);
  }

  // print(y); // 컴파일 에러: y는 if 블록 밖에서 보이지 않음
}

지역 변수는 기본적으로 “해당 함수 호출이 끝날 때” 생명주기가 끝난다고 이해하면 됩니다. Flutter에서는 이 지역 변수를 build() 안에서 사용하게 되는데, 여기서 결정적인 착각이 생깁니다. build()는 생각보다 자주 다시 호출됩니다. 따라서 build() 안에서 만든 지역 변수는 “상태 저장” 용도가 아니라 “그 순간 계산” 용도에 가깝습니다.

클래스 필드

클래스 필드(멤버 변수)는 클래스 인스턴스에 속한 변수입니다. 인스턴스가 살아있는 동안 값이 유지됩니다.

class Counter {
  int value = 0;           // 인스턴스 필드
  static int total = 0;    // static 필드(클래스 공유)

  void inc() {
    value++;
    total++;
  }
}

여기서 static은 사실상 “전역에 가까운 공유”로 동작합니다. 반면 인스턴스 필드는 인스턴스마다 따로 존재합니다. Flutter에서 정말 중요한 것은 바로 State 객체의 인스턴스 필드입니다. 화면이 유지되는 동안 계속 값을 들고 있을 수 있는 가장 기본적인 저장 공간이기 때문입니다.

Flutter에서 제일 중요한 구분: State 필드 vs build() 지역 변수

Flutter 초보자가 가장 많이 하는  실수는 “상태를 build 안에 넣는 것”입니다.

@override
Widget build(BuildContext context) {
  int count = 0; // build가 호출될 때마다 다시 0
  return Text('$count');
}

이 코드는 화면이 다시 그려질 때마다 count가 다시 0으로 생성됩니다. 버튼을 눌러 값을 바꾸고 싶어도, 다음 리빌드에서 다시 초기화됩니다.

반대로 State 필드에 두면, State 인스턴스가 살아있는 동안 값이 유지됩니다.

class _MyPageState extends State {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$count'),
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: const Text('증가'),
        ),
      ],
    );
  }
}

이때 setState()는 단순히 “다시 build를 호출해라”라는 신호입니다. 필드를 바꿔도 setState()가 없으면 UI가 재렌더링되지 않을 수 있습니다(상태관리 라이브러리를 쓰면 setState 대신 notify/emit 등으로 바뀝니다).

변수 저장 위치

Dart에서 많은 값은 객체로 존재하며, 변수는 그 객체를 가리키는 참조(핸들)를 들고 있는 경우가 많습니다. 그래서 변수 이름이 사라져도, 다른 곳에서 여전히 참조하고 있으면 객체는 남아있습니다.

void main() {
  var list = [1, 2, 3];
  var same = list;   // 객체 복사 아님, 참조 복사
  same.add(4);
  print(list);       // [1, 2, 3, 4]
}

객체는 “어딘가(힙 메모리)”에 있고, 변수는 그 객체를 가리키는 방식이라고 이해하면 됩니다. 그 객체를 더 이상 아무도 참조하지 않으면, 가비지 컬렉터(GC)가 회수 대상으로 올립니다.

Flutter 관점에서 보면, build() 지역 변수는 “그 호출 동안 쓰는 참조”이고, State 필드는 “State 객체가 유지되는 동안 참조를 계속 들고 있는 것”입니다. 그래서 상태로 들고 있으면 유지되고, 지역으로 두면 리빌드마다 사라지는 것처럼 보입니다.

Shadowing(섀도잉)

섀도잉은 “바깥 스코프에 같은 이름이 있는데, 안쪽에서 같은 이름으로 다시 선언해 바깥 변수를 가리는 것”입니다.

int x = 1;

void foo() {
  int x = 2; // 전역 x를 가림
  print(x);  // 2
}

Flutter에서 위험한 이유는 “필드를 바꿨다고 착각”하기 쉬워서입니다.

class _MyState extends State {
  int count = 0;

  void bad() {
    int count = 10; // 필드 count를 가림
    count++;        // 지역 변수만 증가
  }
}

이 경우 화면에 반영되지 않는 것이 당연합니다. 필드는 그대로이고, 지역 변수만 바뀌고 버려졌기 때문입니다. 섀도잉을 피하려면 지역 변수 이름을 다르게 짓거나, 의도를 명확히 this.count를 쓰면 됩니다.

void good() {
  setState(() {
    this.count++;
  });
}

스코프/생명주기 실전 문제 3가지

값이 자꾸 초기화

대부분 build() 안 지역 변수로 상태를 관리하려 했기 때문입니다. 리빌드는 “새로 그리는 것”이므로 지역 변수는 다시 만들어집니다. 해결은 상태를 State 필드로 올리는 것입니다.

UI가 안 바뀜

필드가 아니라 지역 변수를 바꾸고 있거나(섀도잉 포함), 필드를 바꿨는데 setState()(또는 상태관리 라이브러리의 notify/emit)가 호출되지 않는 경우가 흔합니다.

메모리가 계속 늘거나 리소스가 해제되지 않음

State 필드로 컨트롤러나 구독을 만들었으면 생명주기 끝에 반드시 해제해야 합니다. 대표적으로 TextEditingController, AnimationController, StreamSubscription이 해당됩니다.

class _MyState extends State {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

언제 지역 변수, 언제 State 필드인가

지역 변수로 충분한 경우는 “그 순간 계산해서 쓰면 되는 값”입니다. 예를 들어 화면 크기, padding 계산, 리스트의 간단한 가공 결과처럼 리빌드 때 다시 계산해도 문제가 없는 값입니다.

State 필드가 필요한 경우는 “사용자 인터랙션 이후에도 유지되어야 하는 값”입니다. 버튼 클릭, 토글, 선택 상태, 로딩 여부, 서버 응답 데이터처럼 화면이 다시 그려져도 유지되어야 한다면 State 필드로 두고, 변경 시 UI 갱신 트리거를 함께 가져가야 합니다.

결론

Flutter(Dart)에서 변수 문제의 대부분은 스코프(어디서 보이는가)와 생명주기(언제까지 유지되는가)를 혼동해서 발생합니다. build() 내부 지역 변수는 리빌드마다 다시 만들어지는 “일시값”이므로 상태를 저장하는 용도로 쓰면 값이 초기화되는 것이 정상입니다. 반대로 State의 필드(인스턴스 멤버)는 State 객체가 살아있는 동안 유지되므로, 사용자 입력이나 토글/카운터/로딩 여부처럼 화면이 다시 그려져도 유지되어야 하는 값은 필드로 관리하고 변경 시 setState()(또는 상태관리 라이브러리의 notify/emit)를 통해 UI 갱신을 트리거해야 합니다. 또한 같은 이름을 안쪽 스코프에서 다시 선언하는 shadowing은 “필드를 바꾼 줄 알았는데 지역 변수만 바꾸는” 실수를 유발하므로, 이름 충돌을 피하거나 this.로 의도를 명확히 하는 습관이 중요합니다. 마지막으로 컨트롤러/구독처럼 생성-해제가 필요한 객체를 필드로 들고 간다면 initState()에서 만들고 dispose()에서 반드시 정리해야 누수와 비정상 동작을 예방할 수 있습니다.

FAQ

build() 안에서 변수를 선언하면 왜 값이 자꾸 0으로 돌아가나요?

build()는 상태 변화, 부모 위젯 리빌드, 화면 회전 등 다양한 이유로 여러 번 호출됩니다. 따라서 build() 내부 지역 변수는 호출될 때마다 다시 선언되고 초기값으로 재생성됩니다. 화면이 다시 그려져도 유지되어야 하는 값이라면 State 필드로 올려야 합니다.

State 필드로 두면 값이 유지되는 이유는 뭔가요?

StatefulWidget은 화면이 유지되는 동안 대응되는 State 인스턴스가 살아있습니다. State의 인스턴스 필드는 그 객체가 유지되는 동안 함께 유지되므로, 리빌드가 일어나도 값이 사라지지 않습니다. 리빌드는 “UI 재구성”이고, State 객체 자체를 매번 새로 만드는 동작이 아닙니다.

setState()를 안 쓰면 왜 UI가 안 바뀌나요?

필드를 변경하는 것만으로는 Flutter가 “다시 그려야 한다”는 사실을 자동으로 알지 못합니다. setState()는 프레임워크에 변경 사실을 알리고 build()를 다시 호출하게 만드는 신호입니다. Provider/Riverpod/BLoC 같은 상태관리 솔루션을 쓰는 경우에는 setState() 대신 notify/emit 등의 메커니즘이 같은 역할을 합니다.

전역 변수는 언제 쓰고 언제 피해야 하나요?

전역 변수는 접근이 쉬워 빠르게 구현할 때 유혹적이지만, 화면 간 공유 상태가 의도치 않게 얽히고 “누가 언제 바꿨는지” 추적이 어려워집니다. 상수, 순수 함수, 제한된 설정값 정도로만 최소화하고, 공유 상태는 보통 상태관리/DI 구조로 옮기는 편이 안전합니다. static 필드도 클래스 단위 공유라는 점에서 전역과 유사한 주의가 필요합니다.

shadowing(섀도잉)이 정확히 뭐고 왜 문제가 되나요?

shadowing은 바깥 스코프에 같은 이름의 변수가 있는데, 안쪽 스코프에서 동일한 이름으로 다시 선언해 바깥 변수를 가리는 현상입니다. Flutter에서는 State 필드와 같은 이름의 지역 변수를 만들면 “필드를 바꾼 줄 알았는데 지역 변수만 바꾸고 끝나는” 실수가 발생해 UI가 안 바뀌는 원인이 됩니다. 지역 변수 이름을 바꾸거나 this.count처럼 명시해 혼동을 제거하는 것이 좋습니다.

“변수는 어디에 저장되나요?”에 대한 실무적인 답은 뭔가요?

실무에서는 “변수(이름/참조)와 값(객체)은 다르다”로 이해하는 것이 가장 안전합니다. 많은 경우 변수는 객체를 가리키는 참조를 들고 있고, 객체는 메모리 어딘가(힙)에 존재합니다. 그 객체를 더 이상 아무도 참조하지 않으면 GC(가비지 컬렉터)가 회수 대상으로 올립니다. 그래서 지역 변수는 호출이 끝나면 사라져 보이지만, 다른 곳에서 참조 중이면 객체는 남아 있을 수 있습니다.

build() 안에서는 변수를 아예 만들면 안 되나요?

그렇지 않습니다. build() 안 지역 변수는 “상태”가 아니라 “계산 결과/임시 가공값”을 담는 용도로 매우 유용합니다. 예를 들어 화면 크기, padding, 조건에 따른 텍스트, 리스트 필터링 결과처럼 리빌드 때 다시 계산돼도 무방한 값은 지역 변수로 두는 것이 자연스럽습니다. 다만 사용자 상호작용 이후에도 유지돼야 하는 값은 필드로 관리해야 합니다.

initState()와 dispose()는 언제 필요하나요?

TextEditingController, AnimationController, StreamSubscription처럼 생성과 해제가 필요한 리소스를 State 필드로 들고 갈 때 필요합니다. 일반적으로 initState()에서 생성하고 dispose()에서 dispose()/cancel()로 정리합니다. 이를 놓치면 메모리 누수, 이벤트 중복 처리, 예기치 않은 동작(이미 제거된 화면에 콜백 호출 등)이 발생할 수 있습니다.

지역 변수와 State 필드 중 무엇을 선택해야 할지 한 문장으로 정리해줄 수 있나요?

“리빌드가 일어나도 유지되어야 하면 State 필드, 그 순간 계산해서 써도 되면 build() 지역 변수”로 결정하면 대부분의 케이스를 안정적으로 커버할 수 있습니다.

댓글 남기기