ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • context는 전역 변수가 아니라 트리 안의 위치입니다
    AI Agent/flutter 2026. 7. 4. 09:00
    728x90
    반응형

    책 더미에 기대 세운 폰의 흐릿한 다이얼로그와 노트북
    await 뒤 죽은 context가 던진 예외 메시지

    Looking up a deactivated widget's ancestor is unsafe. 빨간 화면에 이 문장이 떴을 때, 저는 그게 무슨 말인지 몰랐습니다.
    저장 버튼을 누르면 서버에 저장하고, 끝나면 "완료" 다이얼로그를 띄우는 흔한 코드였습니다.
    대부분은 잘 됐습니다.
    그런데 저장이 느린 날, 사용자가 그 사이에 화면을 뒤로 나가면 이 예외가 터졌습니다.

    코드는 이렇게 생겼었습니다.

    Future<void> _save() async {
      await repository.save(form); // 여기서 몇 초가 걸릴 수 있습니다
      showDialog(context: context, builder: (_) => const DoneDialog());
    }
    

    await 앞뒤로 아무 문제가 없어 보였습니다.
    context는 이 State의 필드처럼 늘 거기 있는 값 아니었나요.
    저는 그렇게 믿고 있었습니다.

    그래서 한동안 엉뚱한 데를 팠습니다.
    메시지에 'widget'이라는 단어가 있으니, 저는 그게 제가 띄우려던 DoneDialog를 가리킨다고 읽었습니다.
    다이얼로그가 문제인 줄 알고 builder 안을 이리저리 바꾸고, DoneDialog를 아예 다른 위젯으로 갈아 끼워도 똑같이 터졌습니다.
    나중엔 급한 마음에 showDialogtry-catch로 감싸 예외를 삼켜버렸습니다.
    그랬더니 빨간 화면은 안 뜨는데, 이번엔 저장을 마쳐도 "완료" 창이 안 나온다는 제보가 새로 들어왔습니다.
    예외를 없앤 게 아니라 문제를 옆으로 옮겨놓은 것뿐이었습니다.

    context를 앱 핸들처럼 써왔습니다

    Flutter를 시작하고 한동안, 저는 context를 "화면을 조작하는 리모컨" 정도로 생각했습니다.
    어디서든 꺼내 쓰면 되는 전역 손잡이처럼요.
    Navigator.of(context), Theme.of(context), showDialog(context: ...).
    다 context 하나로 통하니까, 이게 앱 어딘가를 가리키는 만능 키인 줄 알았습니다.

    그런데 context는 손잡이가 아니라 좌표입니다.
    위젯 트리 안에서 "지금 이 위젯이 있는 자리"를 가리키는 표식입니다.
    Theme.of(context)가 테마를 주는 건, context가 자기 위치에서 위로 올라가며 가장 가까운 Theme을 찾기 때문입니다.
    그러니 어느 자리의 context냐에 따라 같은 조회도 다른 답이 나옵니다.

    이걸 확실히 체감한 건 다크 모드를 붙이던 날이었습니다.
    화면 위쪽을 Theme으로 다크 테마로 씌웠는데, 바로 아래 버튼 하나만 계속 밝은 색으로 남았습니다.
    ThemeData를 잘못 만들었나 싶어 색을 몇 번이나 갈아봤고, Theme.of(context).brightness를 print 해보니 dark가 아니라 light가 찍혔습니다.
    분명 다크로 감쌌는데 말입니다.
    그 context가 아직 새 Theme "위"의 자리를 가리키고 있어서, 위로 올라가 봐야 방금 씌운 다크 테마를 못 만났던 겁니다.
    한 칸 아래 Builder에서 읽으니 그제야 dark가 나왔습니다.
    같은 함수, 같은 인자인데 위치가 답을 바꿨습니다.

    죽은 좌표를 들고 있었던 겁니다

    여기까지 오니 예외 메시지가 읽혔습니다.
    사용자가 저장 도중에 화면을 나가면, 그 화면 위젯은 트리에서 떨어져 나갑니다(deactivated).
    그런데 제 await는 그것도 모르고 몇 초 뒤에 깨어나서, 이미 트리에서 사라진 자리의 context로 다이얼로그를 띄우려 했습니다.
    존재하지 않는 좌표를 가리킨 겁니다.
    "unsafe"는 딱 그 뜻이었습니다.

    전역 변수라면 이런 일이 없습니다.
    전역은 화면이 사라져도 그 자리에 있으니까요.
    하지만 context는 트리에 묶여 있어서, 트리에서 떨어지는 순간 무효가 됩니다.
    제가 겪은 버그는 문법 오류가 아니라, context의 정체를 잘못 알고 있어서 생긴 거였습니다.

    저장이 느린 날에만 터진 이유도 여기 있었습니다.
    저장이 빠르면 사용자가 뒤로 나가기 전에 다이얼로그가 떠버리니, context가 아직 살아 있습니다.
    느려야 그 사이에 사용자가 화면을 뜨고, 뒤늦게 깨어난 콜백이 죽은 좌표를 만납니다.
    재현이 들쭉날쭉했던 게 제 착각이 아니라, 타이밍이 만든 진짜 조건이었던 겁니다.
    이걸 알고 나니 "가끔 터지는" 버그가 덜 무서워졌습니다.

    await를 건넌 context는 확인부터

    고치는 건 한 줄이면 됐습니다.
    await 이후에 context를 쓰기 전에, 이 위젯이 아직 트리에 붙어 있는지를 먼저 물어보는 겁니다.

    Future<void> _save() async {
      await repository.save(form);
      if (!mounted) return; // 트리에서 떨어졌으면 여기서 멈춥니다
      showDialog(context: context, builder: (_) => const DoneDialog());
    }
    

    mounted가 false면 이미 화면이 사라진 겁니다.
    그럼 다이얼로그를 안 띄우고 조용히 빠져나옵니다.
    비동기 콜백이 화면보다 오래 살아남을 수 있다는 걸, 저는 이 한 줄을 넣으면서야 몸으로 이해했습니다.

    한 가지 조심할 게 있었습니다.
    mounted를 넣으면 예외는 사라지지만, 저장은 이미 끝난 상태라는 겁니다.
    사용자가 화면을 떴어도 데이터는 서버에 저장됐고, 다만 "완료" 알림만 못 본 거죠.
    상황에 따라선 그게 맞고, 어떤 화면에선 저장 자체를 취소해야 맞습니다.
    mounted 한 줄이 만능은 아니고, "여기서부터는 화면이 없다"는 경계를 알려줄 뿐입니다.
    그 경계 뒤에 뭘 할지는 여전히 제가 정해야 했습니다.

    지금도 context를 리모컨처럼 부르는 코드를 보면 잠깐 멈칫합니다.
    이게 지금 트리의 어느 자리를 가리키고 있는지, 그 자리가 아직 살아 있는지.
    다음에 await 뒤에서 context를 꺼내 쓸 일이 생기면, 저는 아마 손이 먼저 mounted부터 칠 겁니다.

    원문 기준

    원문 기준: https://api.flutter.dev/flutter/widgets/BuildContext-class.html

    확인일: 2026-07-01.
    API 문서의 설명이 조금씩 다듬어지곤 해서, 게시 전에 해당 클래스 페이지를 다시 읽어둡니다.

    728x90
    반응형
Designed by Tistory.