← Back to list

Flutter Memory Leak

🚚 Flutter Memory Debugging Series

OD · 2026-03-06 07:15 · 0 claps · 29.5 min read paywalled
#flutter #flutterdebugging #flutterdevtools #memory-leak
Open on Medium ↗
Wiki topics: 💻 · Programming 📱 · Mobile Development

Flutter Memory Leak

🚚 Flutter Memory Debugging Series

“이 글은 멤버 전용 콘텐츠이나, *[여기]*를 클릭하시면 전체 내용을 바로 확인하실 수 있습니다.”

Memory관리는 Application성능에 많은 영향을 줍니다.

Memory 문제는 주로 불필요하게 memory를 많이 점유하여 메모리가 부족해 발생합니다. 메모리가 부족해지면 app이 crash 되거나 앱 속도가 느려질 수 있습니다.

Dart에서 객체를 만들면 heap memory공간에 상주합니다. 이 공간(heap)은 Dart VM에 의해 관리됩니다. Dart VM은 memory할당과 GC(garbage collection)을 이용해 memory해제를 합니다.

Memory leak은 왜 발생할까요?

GC는 준수한 메모리 관리를 하지만 완벽하게 할 수 없어요. 그러므로 개발자가 항상 object의 메모리 누수를 관리해야합니다. 이를 보조하기 위해 DevTool을 제공합니다.

그렇다면 왜 GC는 모든 object를 적절히 unallocate시키지 못하는 것일까요?

GC가 모든 unreachable object를 처리하는 기본 조건은, “Application이 사용하지 않는 object는 unreachable 이여야 한다” 입니다. 그런데 만약, 더 이상 필요없는 object인데 reference가 남아 있다면 어떨까요? GC는 unallocate를 할 수 없죠. GC 입장에서는 얘들이 여전히 사용된다고 여기게 되니까요. 이런일은 global 변수, static 변수, 또는 lifecycle이 긴 object에서 자주 발생됩니다. 이런게 반복되면 out-of-memory error가 발생되는 것이죠.

diagram으로 살펴보죠.

우리는 Object A가 GC에 의해 unallocate되는 상황을 살펴볼 것입니다.

가비지 컬렉터(GC)는 현대 프로그래밍 언어에서 준수한 메모리 관리 기능을 수행하지만, 모든 상황을 완벽하게 통제할 수는 없습니다. 따라서 개발자는 GC의 메커니즘을 맹신하기보다, 애플리케이션 내 객체들이 적절히 해제되고 있는지 항상 주시해야 합니다. 이를 위해 DevTools와 같은 메모리 프로파일링 도구는 개발자가 메모리 누수를 관리하는 데 있어 필수적인 보조 도구가 됩니다.

그렇다면 왜 GC는 사용되지 않는 모든 객체를 자동으로 해제하지 못하는 것일까요?

기본적으로 GC가 도달 불가능한(Unreachable) 객체를 처리하기 위해서는 “더 이상 사용하지 않는 객체는 참조 경로가 끊어져야 한다”는 전제 조건이 충족되어야 합니다. 그러나 논리적으로는 더 이상 필요 없는 객체임에도 불구하고, 코드 내에 어떠한 형태로든 참조(Reference)가 남아 있다면 문제가 발생합니다. GC의 입장에서는 참조가 존재하는 객체를 여전히 ‘사용 중’인 것으로 간주하여 메모리 할당을 해제(Unallocate)하지 않기 때문입니다.

이러한 현상은 주로 전역 변수(Global Variable)정적 변수(Static Variable), 혹은 생명주기(Lifecycle)가 긴 객체가 필요 이상의 참조를 유지하고 있을 때 빈번하게 발생합니다. 이러한 ‘방치된 참조’가 반복적으로 누적될 경우, 시스템은 가용 메모리를 모두 소진하게 되며 결국 Out-of-Memory(OOM) 에러로 이어지게 됩니다.

이제 다이어그램을 통해 객체 A(Object A)가 어떤 과정을 거쳐 GC에 의해 정상적으로 해제되는지 구체적인 상황을 살펴보겠습니다.

1️⃣ 초기 상태: 참조가 두 개 있는 경우

Object A 를 Reference 1, Reference 2 두 변수가 가리키고 있음. 이 상태에서는 당연히 GC 대상이 아님

2️⃣ 한 개의 참조가 끊어진 경우

Reference 1이 null이 됨. 하지만 Reference 2는 여전히 살아 있음. 따라서 GC는 여전히 수거하지 못함

3️⃣ GC가 동작하는 조건

참조가 모두 끊긴 경우로서 객체를 가리키는 참조가 하나도 없습니다. 이때 비로소 GC 대상이 됩니다.

Memory leak을 막는 핵심은 의도치않은 reference를 남기지 않는 것입니다. 항상 주의하세요.

Root object, retaining path, and reachability

  1. Root object 모든 Dart Application은 Root object를 만들고 이 object는 직접적이든, 간접적이든 application의 모든 object의 reference를 가집니다.
  2. Reachability 특정시점에 root object는 어떤 object A에 대한 참조를 멈춥니다. 이때 A는 더이상 닿을수 없는 상태인 unreachable이 됩니다. 이는 GC에 의해 deallocate되는 조건이 됩니다.
  3. Retaining path root object가 특정 object까지 도달하는 sequence를 object의 retaining path라 부릅니다. 이를 통해 GC로 부터 object를 유지하는 것입니다. 하나의 object는 여러 retaining path를 가질 수 있습니다. object가 하나 이상의 retaining path를 가지면 reachable object로 판단합니다.

예제로 object의 retaining에 대해 알아보죠.

class Child{}

class Parent {
  Child? child;
}

Parent parent1 = Parent();
void myFunction() {

  Child? child = Child();
  //여기서 Child() object하나가 생깁니다. A라고 해보죠. A를 위한 memory 할당이 발생하죠.
  //child 변수가 생성된 A object의 주소를 저장하고 있죠. GC로 부터 A object를 유지시키게 됩니다. 
  // 이 A object의 retaining path 는 다음과 같아요(root …-> myFunction -> child).

  Parent? parent2 = Parent()..child = child;
  //parent2의 child라는 변수를 B라고 해보죠. B에는 A의 refernce를 할당합니다. 
  parent1.child = child;
  //parent1의 child라는 변수를 C라고 해보죠. C에는 A의 reference를 할당합니다. 

  // 이 시점에 A 객체의 retaining path는 세 개입니다. 
  // root …-> myFunction -> child
  // root …-> myFunction -> parent2 -> child
  // root -> parent1 -> child

  child = null;
  parent1.child = null;
  parent2 = null;

  // 세 개의 retainng path를 모두 제거하면 A 객체는 unrechable이 되고 GC의 수거대상이 됩니다. 
  // 수거시점은 즉시는 아니고 GC의 판단에 의해 특정 시점에 수거 될 것입니다. 

}

자주 발생하는 Memory Leak Case

특히 우리가 매우 조심해야 하는 상황이 두 개 입니다.

⒈ closure closure를 사용할때 우리는 object reference 체크가 어렵습니다. 아래 예제를 보죠. myHugeObject는 handler라는 closure에 저장되어 있죠? retaining path가 있는 것입니다. handler는 setHandler 함수에 전달됩니다. 함수는 통상 긴 시간 살아 있어요. 결과적으로 setHandler라는 함수가 사라지기 전까지 myHugeObject도 GC에 의해 제거되지 못합니다. 이런 패턴은 개발자가 실수하기 좋은 구조입니다. object가 함수의 lifecycle만큼 살아있음을 인지하기 어려운 구조입니다.

final handler = () => print(myHugeObject.name);
setHandler(handler);

⒉ BuildContext Flutter에서 자주 발생되는 상황입니다. StatelessWidget의 build 메서드에서 handler를 생성했다고 가정해 보겠습니다. StatelessWidget 인스턴스 자체는 비교적 짧은 생명주기를 가지며 곧 소멸될 수 있습니다. 하지만 handler는 클로저이기 때문에, 내부에서 사용한 context를 캡처하고 있습니다. context는 element tree, render tree까지 갖고있는 거대한 객체입니다. 만약 useHandler가 이 handler를 어딘가에 저장하고 있다면, StatelessWidget이 소멸되더라도 useHandler가 제거되지 않는 한 context에 대한 참조는 계속 유지됩니다. build는 매우 자주 호출됩니다. 호출될 때마다 새로운 handler 객체가 생성되고, 이전 handler가 해제되지 않는다면 이 클로저들은 계속 누적됩니다. 결국 참조가 끊기지 않은 handler들이 쌓이게 되고, 메모리는 점점 증가하게 됩니다. 이 상태가 반복되면 메모리 누수로 이어질 수 있으며, 심한 경우 Out of Memory 상황이 발생할 수 있습니다.

@override
Widget build(BuildContext context) {
  final handler = () => apply(Theme.of(context));
  useHandler(handler);
…

Flutter Widget memory leak example

flutter에서 자주 겪는 memory leak 상황을 예제로 시뮬레이션 해보겠습니다. 이제부터 다룰 예제 전체 코드는 이 글의 마지막에 첨부합니다. 필요하신 분은 참고하세요.

예제 앱 동작을 간단히 살펴 보겠습니다.

📝 기사 필터링 및 검색 앱

이 앱은 총 20개의 기사를 보여주고, 사용자가 원하는 태그로 내용을 걸러내는 기능을 제공합니다. 단순해 보이지만, 화면이 열리고 닫히는 과정에서 메모리가 어떻게 관리되는지(혹은 새는지) 테스트하기 위해 설계되었습니다.

  1. 메인 화면: Article 목록 확인 앱을 실행하면 총 20개의 Article이 목록으로 나타납니다. 각 기사에는 분류를 위한 태그(Tag) 정보가 포함되어 있습니다.
  2. 검색 팝업: 필터 조건 입력 화면의 검색 버튼을 누르면, 특정 태그를 입력할 수 있는 Dialog가 뜹니다. 이 팝업창은 사용자가 필터링 조건을 설정하는 독립적인 공간입니다.
  3. 필터링 및 미리보기: 결과 개수 확인 팝업창에서 원하는 태그(텍스트)를 입력하고 [필터] 버튼을 누릅니다. 그러면 전체 기사 중 해당 태그를 가진 기사가 몇 개인지 계산하여, 제목 옆에 숫자로 미리 보여줍니다. (예: “정치 (5개)”)
  4. 적용: 리스트 업데이트 [적용] 버튼을 누르면 설정한 필터가 실제 앱 화면에 반영됩니다. 팝업창이 닫히면서 메인 화면의 리스트에는 필터링된 기사들만 출력됩니다.

💡 팁 (메모리 누수 관점) 이 과정에서 주의 깊게 봐야 할 부분은 “팝업창이 닫힐 때”입니다.

  • 👍 정상적인 경우: 팝업창을 닫으면 사용했던 메모리(입력한 텍스트, 계산된 숫자 등)가 모두 사라져야 합니다.
  • ❌ 메모리 누수가 발생하는 경우: Dialog는 눈에 보이지 않지만, 내부적으로는 메모리를 계속 차지하고 있어 앱이 점점 무거워질 수 있습니다.

이 앱을 통해 그런 현상이 어디서 발생하는지 함께 찾아보겠습니다.

💻 코드 수준의 데이터 흐름과 누수 지점

예제 코드부터 알아보죠. 이 앱은 MVVM(Model-View-ViewModel) 구조로 설계되었습니다. 복잡하게 생각할 것 없이, ‘데이터를 관리하는 상자(ViewModel)’와 ‘화면을 그리는 도구(View)’가 역할을 나눴다고 이해하시면 됩니다.

MVVM을 다뤄보지 않은분은 흐름중심으로 봐 주세요. 누수(leak)가 발생하는 부분은 SearchDialog입니다.

  • SearchViewModel은 articleList state를 갖습니다. 이를 HomeScreen이 Observing 하여 화면에 출력합니다.
  • 검색 버튼을 누르면 나타나는 팝업창입니다. 실제 필터링 로직은 직접 처리하지 않고 ViewModel에게 요청합니다.
  • SearchDialog는 사용자가 글자를 입력하면, SearchViewModel의 calculatePreviewCount(query) 함수를 실행합니다.
  • ViewModel은 Filter 결과가 몇 개인지 계산한 뒤,previewCount에 filter된 article 개수를 저장하고 “자, 숫자가 업데이트됐어!”라고 자신을 Observing하는 애들에게 알립니다. (이것을 notifyListeners라고 부릅니다.)
  • SearchDialog는 SearchViewModel의 notify를 듣기 위해 귀를 기울이고(Listening) 있습니다.
  • notify 소식이 들리면 SearchDialog는 제목 옆에 previewCount(예: 5개)를 표시합니다.
  • 사용자가 ‘닫기’나 ‘적용’을 누르면 SearchDialog가 닫힙니다.

❌ Memory 누수 : dialog가 종료 됐음에도 여전히 dialog에 의해 생성된 Object들 중 GC에 의해 unallocate되지 않은 것들이 남아 있고, 이 개수가 증가합니다.

🧑‍💻 구체적 설명은 핵심코드(SearchDialog)만으로 이어가겠습니다.

  • SearchDialog가 생성되면 initState에 의해 익명함수(A)가 하나 생성됩니다.
  • 익명함수(A)는 addListener를 통해 ViewModel에 binding됩니다.
  • SearchDialog가 소멸됩니다. ViewModel은 여전히 존재하고 익명함수(A)에 대한 참조 역시 살아있죠.
  • 이 참조는 ViewModel이 소멸되기까지 유지됩니다. GC는 익명함수(A)를 unallocate할 수 없습니다.
  • SearchDialog를 10번 열었다 닫으면, 화면에는 아무것도 없지만 ViewModel의 addListener에는 10개의 유령 함수가 쌓이게 됩니다. 이 유령들이 메모리를 조금씩 차지하며 앱을 점점 무겁게 만드는 것, 이것이 바로 메모리 누수의 실체입니다. 이런 문제는 SearchDialog가 열릴때마다 누적되며 memory를 소비합니다.
//filename: search_dialog.dart
. . . //code 줄임
class SearchDialog extends StatefulWidget {. . .}

class _SearchDialogState extends State<SearchDialog> {
  final TextEditingController _controller = TextEditingController();
  int _previewCount = 0;
  List<MemorySample> samples=[];

  @override
  void initState() {
    super.initState();
    final viewModel = Provider.of<SearchViewModel>(context, listen: false);
    _controller.text = viewModel.currentQuery;

 //메모리 낭비를 극적으로 보여주기위한 가짜코드
    for (int i=0;i < 300000;i++) {
      samples.add(MemorySample('Id: $i'));
    }

    //메모리 낭비가 발생하는 핵심 코드
    viewModel.addListener((){
      setState(() {
        _previewCount = viewModel.previewCount;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    final viewModel = context.read<SearchViewModel>();

    return AlertDialog(
      title: Text("Tag 필터 ($_previewCount)"),
      . . .
    );
  }
}

Solution

SearchDialog 예제에서 메모리 누수가 발생하는 핵심 이유는 수명이 긴 객체(ViewModel)수명이 짧은 객체(Dialog의 Closure)를 붙잡고 있기 때문입니다. 이를 해결하는 두 가지 확실한 방법을 소개합니다.

  1. 사용한 리스너는 반드시 직접 제거하기 (removeListener) 다이얼로그처럼 잠시 나타났다 사라지는 위젯은 화면에서 제거될 때(dispose), 자신이 등록했던 리스너도 함께 거두어 가야 합니다. 방법: StatefulWidget의 dispose 생명주기 메서드에서 removeListener를 호출합니다. 효과: ViewModel이 쥐고 있던 클로저(함수)의 손을 놓게 되어, GC(가비지 컬렉터)가 다이얼로그와 관련된 메모리를 정상적으로 회수할 수 있습니다.
  2. Listener 등록이 필요 없는 구조로 설계하기 가장 좋은 해결책은 누수가 생길 환경을 아예 만들지 않는 것입니다. ViewModel은 앱 실행 내내 살아있는 전역 변수와 같으므로, 여기에 짧은 수명의 위젯을 직접 연결하는 것은 위험합니다. 방법: addListener 대신 Flutter가 제공하는 반응형 위젯들을 활용하세요. context.watch<T>() 또는 Consumer<T> 사용 효과: 이러한 위젯들은 위젯 트리에서 사라질 때 자동으로 구독을 해제하므로, 개발자가 실수로 removeListener를 빼먹어도 메모리 누수가 발생하지 않습니다.

👍 결론: “오래 사는 객체(ViewModel)에게 짧게 사는 객체(Dialog)를 맡길 때는 반드시 뒷정리를 해야 하며, 가급적 자동으로 정리해 주는 도구를 쓰는 것이 안전합니다.”

예제 전체 코드

🌳 main.dart

import 'package:devtool/home_screen.dart';
import 'package:devtool/search_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    // 1. 상태를 앱 최상단에서 제공 (보편적인 방식)
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => SearchViewModel()),
      ],
      child: const MaterialApp(home: HomeScreen()),
    ),
  );
}

🌳 home_screen.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'search_dialog.dart';
import 'search_view_model.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<SearchViewModel>();
    final articles = viewModel.filteredArticles;

    return Scaffold(
      appBar: AppBar(
        title: const Text("Article"),
        actions: [
          IconButton(
            onPressed: () => showDialog(
              context: context,
              builder: (_) => SearchDialog(),
            ),
            icon: const Icon(Icons.search),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: articles.length,
        itemBuilder: (context, index) {
          final article = articles[index];
          return ListTile(
            leading: ClipRRect(
              borderRadius: BorderRadius.circular(8),
              child: Image.network(
                article.imageUrl,
                width: 60,
                height: 60,
                fit: BoxFit.cover,
                errorBuilder: (context, error, stackTrace) =>
                    const Icon(Icons.fitness_center, size: 60),
              ),
            ),
            title: Text(article.title),
            subtitle: Text(article.tags.map((tag) => '#$tag').join(' ')),
            onTap: () {
              // 기사 상세 보기 등의 로직 추가 가능
            },
          );
        },
      ),
    );
  }
}

🌳 search_view_model.dart

import 'package:flutter/material.dart';
import 'workout.dart';

class SearchViewModel extends ChangeNotifier {
  String _currentQuery = "";
  String get currentQuery => _currentQuery;

  int _previewCount = 0;
  int get previewCount => _previewCount;

  final List<Workout> _workoutArticles = [
    Workout(
      id: 1,
      title: '초보자를 위한 스쿼트 정석',
      content: '스쿼트는 하체 운동의 꽃입니다. 무릎이 발끝을 넘지 않도록 주의하며 엉덩이를 뒤로 빼는 것이 중요합니다.',
      tags: ['헬스', '기초', '근력'],
      imageUrl: 'https://picsum.photos/seed/workout1/300/200',
    ),
    Workout(
      id: 2,
      title: '아침 10분 스트레칭 루틴',
      content: '잠에서 깨어난 몸을 부드럽게 깨워주는 스트레칭입니다. 목, 어깨, 허리를 중심으로 10분만 투자해보세요.',
      tags: ['스트레칭', '아침', '유연성'],
      imageUrl: 'https://picsum.photos/seed/workout2/300/200',
    ),
    Workout(
      id: 3,
      title: '완벽한 푸쉬업 자세 잡기',
      content: '푸쉬업은 전신 운동입니다. 코어에 힘을 주고 몸을 일직선으로 유지하는 것이 핵심입니다.',
      tags: ['헬스', '맨몸운동', '가슴'],
      imageUrl: 'https://picsum.photos/seed/workout3/300/200',
    ),
    Workout(
      id: 4,
      title: '체지방 연소를 위한 고강도 인터벌(HIIT)',
      content: '짧은 시간에 폭발적인 에너지를 소모하여 운동 후에도 칼로리 연소 효과를 지속시키는 HIIT 루틴입니다.',
      tags: ['다이어트', '유산소', '고강도'],
      imageUrl: 'https://picsum.photos/seed/workout4/300/200',
    ),
    Workout(
      id: 5,
      title: '등 근육을 선명하게 만드는 풀업 비법',
      content: '풀업이 어려운 초보자들을 위해 밴드를 활용한 연습법부터 정석 자세까지 모두 공개합니다.',
      tags: ['헬스', '등', '풀업'],
      imageUrl: 'https://picsum.photos/seed/workout5/300/200',
    ),
    Workout(
      id: 6,
      title: '사무실에서 할 수 있는 거북목 예방 운동',
      content: '장시간 모니터를 보는 직장인들을 위한 필수 운동입니다. 턱을 당기고 가슴을 펴는 동작을 반복하세요.',
      tags: ['체형교정', '거북목', '오피스'],
      imageUrl: 'https://picsum.photos/seed/workout6/300/200',
    ),
    Workout(
      id: 7,
      title: '단단한 복근을 위한 플랭크 심화 버전',
      content: '기본 플랭크가 익숙해졌다면 다리를 들어 올리거나 팔을 뻗는 동작을 추가하여 코어를 강화해보세요.',
      tags: ['헬스', '코어', '플랭크'],
      imageUrl: 'https://picsum.photos/seed/workout7/300/200',
    ),
    Workout(
      id: 8,
      title: '러닝 후 필수 쿨다운 스트레칭',
      content: '러닝 후 뭉친 근육을 풀어주지 않으면 부상으로 이어질 수 있습니다. 햄스트링과 종아리를 집중 관리하세요.',
      tags: ['러닝', '회복', '하체'],
      imageUrl: 'https://picsum.photos/seed/workout8/300/200',
    ),
    Workout(
      id: 9,
      title: '덤벨을 이용한 어깨 깡패 만들기',
      content: '사이드 레터럴 레이즈와 숄더 프레스를 조합하여 입체감 있는 어깨 라인을 만들어보세요.',
      tags: ['헬스', '어깨', '웨이트'],
      imageUrl: 'https://picsum.photos/seed/workout9/300/200',
    ),
    Workout(
      id: 10,
      title: '집에서 즐기는 요가 홈트레이닝',
      content: '명상과 함께하는 요가 동작으로 몸과 마음의 안정을 찾으세요. 태양 예배 자세부터 시작합니다.',
      tags: ['요가', '홈트', '멘탈케어'],
      imageUrl: 'https://picsum.photos/seed/workout10/300/200',
    ),
    Workout(
      id: 11,
      title: '런지로 탄탄한 힙업 라인 완성하기',
      content: '런지는 스쿼트만큼 효과적인 하체 운동입니다. 앞발과 뒷발의 균형을 잘 잡는 것이 포인트입니다.',
      tags: ['헬스', '힙업', '근력'],
      imageUrl: 'https://picsum.photos/seed/workout11/300/200',
    ),
    Workout(
      id: 12,
      title: '무릎 통증 없는 안전한 유산소 운동',
      content: '무릎 관절이 약한 분들을 위해 수영, 실내 자전거 등 충격이 적은 유산소 운동을 추천합니다.',
      tags: ['유산소', '재활', '건강'],
      imageUrl: 'https://picsum.photos/seed/workout12/300/200',
    ),
    Workout(
      id: 13,
      title: '강력한 하체를 위한 데드리프트 기초',
      content: '데드리프트는 전신 근력을 강화하는 최고의 운동입니다. 허리가 굽지 않도록 복압 유지에 신경 쓰세요.',
      tags: ['헬스', '전신', '3대운동'],
      imageUrl: 'https://picsum.photos/seed/workout13/300/200',
    ),
    Workout(
      id: 14,
      title: '운동 전 에너지 업! 동적 스트레칭',
      content: '정적 스트레칭보다는 몸의 온도를 높여주는 동적 스트레칭으로 부상을 예방하고 퍼포먼스를 높이세요.',
      tags: ['준비운동', '스트레칭', '퍼포먼스'],
      imageUrl: 'https://picsum.photos/seed/workout14/300/200',
    ),
    Workout(
      id: 15,
      title: '팔뚝 살 타파! 삼두 운동 루틴',
      content: '덤벨 킥백과 오버헤드 익스텐션으로 처진 팔뚝 살을 탄탄하게 만들어보세요.',
      tags: ['헬스', '팔운동', '다이어트'],
      imageUrl: 'https://picsum.photos/seed/workout15/300/200',
    ),
    Workout(
      id: 16,
      title: '골프 실력을 높여주는 회전 코어 운동',
      content: '골프 스윙의 비거리를 늘리고 싶다면 흉추 가동성과 코어의 회전력을 강화하는 운동이 필수입니다.',
      tags: ['골프', '코어', '유연성'],
      imageUrl: 'https://picsum.photos/seed/workout16/300/200',
    ),
    Workout(
      id: 17,
      title: '수면의 질을 높여주는 저녁 스트레칭',
      content: '긴장된 근육을 이완시키고 부교감 신경을 활성화하여 깊은 잠을 유도하는 스트레칭입니다.',
      tags: ['스트레칭', '숙면', '회복'],
      imageUrl: 'https://picsum.photos/seed/workout17/300/200',
    ),
    Workout(
      id: 18,
      title: '케틀벨을 이용한 전신 근지구력 훈련',
      content: '케틀벨 스윙은 심폐 지구력과 근력을 동시에 키워주는 효율적인 운동입니다. 힙 드라이브를 활용하세요.',
      tags: ['헬스', '케틀벨', '근지구력'],
      imageUrl: 'https://picsum.photos/seed/workout18/300/200',
    ),
    Workout(
      id: 19,
      title: '바른 자세를 위한 폼롤러 마사지법',
      content: '운동 전후 폼롤러를 사용하여 근막을 이완시키면 근육통 감소와 유연성 향상에 큰 도움이 됩니다.',
      tags: ['마사지', '폼롤러', '회복'],
      imageUrl: 'https://picsum.photos/seed/workout19/300/200',
    ),
    Workout(
      id: 20,
      title: '초보자를 위한 헬스장 기구 사용법',
      content: '헬스장이 낯선 초보자들을 위해 레그 프레스, 렛풀다운 등 주요 기구의 올바른 사용법을 정리했습니다.',
      tags: ['헬스', '입문', '기구'],
      imageUrl: 'https://picsum.photos/seed/workout20/300/200',
    ),
  ];

  List<Workout> get workoutArticles => _workoutArticles;

  List<Workout> get filteredArticles {
    if (_currentQuery.isEmpty) {
      return _workoutArticles;
    }
    return _workoutArticles.where((article) {
      return article.tags.any((tag) => tag.contains(_currentQuery));
    }).toList();
  }

  void setQuery(String query) {
    _currentQuery = query;
    notifyListeners();
  }

  void calculatePreviewCount(String query) {
    if (query.isEmpty) {
      _previewCount = _workoutArticles.length;
    } else {
      _previewCount = _workoutArticles.where((article) {
        return article.tags.any((tag) => tag.contains(query));
      }).length;
    }
    notifyListeners();
  }
}

🌳 search_dialog.dart


import 'package:devtool/search_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class SearchDialog extends StatefulWidget {
  SearchDialog({super.key});

  @override
  State<SearchDialog> createState() => _SearchDialogState();
}

class _SearchDialogState extends State<SearchDialog> {
  final TextEditingController _controller = TextEditingController();
  int _previewCount = 0;
  List<MemorySample> samples=[];

  @override
  void initState() {
    super.initState();
    final viewModel = Provider.of<SearchViewModel>(context, listen: false);
    _controller.text = viewModel.currentQuery;
 //메모리 낭비를 유도하는 가짜코드
    for (int i=0;i < 300000;i++) {
      samples.add(MemorySample('Id: $i'));
    }

    viewModel.addListener((){
      setState(() {
        _previewCount = viewModel.previewCount;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    final viewModel = context.read<SearchViewModel>();

    return AlertDialog(
      title: Text("Tag 필터 ($_previewCount)"),
      content: TextField(
        controller: _controller,
        decoration: const InputDecoration(
          hintText: "태그 입력 (예: 헬스, 스트레칭)",
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text("닫기"),
        ),
        TextButton(
          onPressed: () => viewModel.calculatePreviewCount(_controller.text),
          child: const Text("필터"),
        ),
        ElevatedButton(
          onPressed: () {
            viewModel.setQuery(_controller.text);
            Navigator.pop(context);
          },
          child: const Text("적용"),
        ),
      ],
    );
  }
}

메타데이터
post_id
cf1659b4c5f9
slug
flutter-memory-leak-cf1659b4c5f9
url
https://medium.com/@OrdinaryDeveloper/flutter-memory-leak-cf1659b4c5f9
canonical_url
https://medium.com/@OrdinaryDeveloper/flutter-memory-leak-cf1659b4c5f9
author_url
https://medium.com/@OrdinaryDeveloper
status
ok
fetched_at
2026-07-11 10:39:32