Tuesday, August 1, 2023

Flutter FutureBuilder 성능 최적화: 불필요한 재빌드를 방지

현대의 모바일 애플리케이션은 비동기 데이터 통신에 크게 의존합니다. 사용자가 앱을 사용하는 동안 백그라운드에서는 서버로부터 데이터를 가져오고, 파일을 읽고, 복잡한 연산을 수행하는 등 다양한 작업이 끊임없이 일어납니다. Flutter는 Google이 개발한 강력한 UI 툴킷으로, 이러한 비동기 작업을 선언적(declarative) UI 패러다임 안에서 우아하게 처리할 수 있는 다양한 도구를 제공합니다. 그중에서도 FutureBuilder는 비동기 작업의 결과를 손쉽게 UI에 반영할 수 있게 해주는 핵심 위젯입니다.

Future는 Dart 언어에서 비동기 작업의 최종 결과를 나타내는 객체입니다. 지금 당장은 아니지만 미래의 어느 시점에 값 또는 오류를 제공하겠다는 '약속'과 같습니다. FutureBuilder는 이 '약속'을 받아, 약속이 이행되는 과정(대기, 완료, 오류)에 따라 UI를 동적으로 렌더링합니다. 매우 편리한 위젯이지만, 잘못 사용될 경우 애플리케이션의 성능을 심각하게 저하시키는 주범이 되기도 합니다. 가장 흔한 실수는 바로 불필요한 재빌드(rebuild)를 유발하는 것입니다.

이 글에서는 FutureBuilder의 불필요한 재빌드가 왜 발생하며, 이것이 앱 성능에 어떤 악영향을 미치는지 심도 있게 분석합니다. 더 나아가 Flutter의 위젯 생명주기(lifecycle)에 대한 깊은 이해를 바탕으로 이러한 문제를 근본적으로 해결하는 올바른 패턴과 다양한 고급 시나리오에 대처하는 방법을 상세한 코드 예제와 함께 알아보겠습니다.

1. 문제의 발단: 왜 FutureBuilder 재빌드는 치명적인가?

문제를 해결하기에 앞서, 왜 FutureBuilder의 불필요한 재빌드가 문제가 되는지 명확히 이해해야 합니다. Flutter의 UI는 '상태(state)'의 변화에 따라 다시 그려집니다. 위젯의 build 메서드는 UI를 구성하는 설계도와 같으며, 이 메서드는 생각보다 훨씬 더 자주 호출될 수 있습니다.

다음과 같은 상황에서 build 메서드가 호출됩니다.

  • setState() 호출로 인해 위젯의 내부 상태가 변경될 때
  • 부모 위젯이 재빌드될 때 (자식 위젯도 함께 재빌드됨)
  • 화면 회전, 앱 창 크기 변경 등 MediaQuery 정보가 변경될 때
  • 테마(Theme)가 변경될 때
  • 화면 전환(Navigation)으로 인해 위젯이 다시 화면에 나타날 때

이제 가장 흔히 저지르는 실수를 담은 코드를 살펴보겠습니다.

잘못된 사용 예시: build 메서드 내에서 Future 생성


import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// 데이터를 가져오는 비동기 함수
Future<String> fetchData() async {
  print("fetchData() called!"); // 함수 호출 여부 확인용
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
  await Future.delayed(const Duration(seconds: 2)); // 의도적인 지연
  if (response.statusCode == 200) {
    return json.decode(response.body)['title'];
  } else {
    throw Exception('Failed to load data');
  }
}

class BadExampleScreen extends StatefulWidget {
  const BadExampleScreen({super.key});

  @override
  State<BadExampleScreen> createState() => _BadExampleScreenState();
}

class _BadExampleScreenState extends State<BadExampleScreen> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("잘못된 FutureBuilder 사용")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            FutureBuilder<String>(
              // 🚨 문제의 지점: build 메서드가 호출될 때마다 fetchData()가 새로 실행됨
              future: fetchData(),
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const CircularProgressIndicator();
                } else if (snapshot.hasError) {
                  return Text('Error: ${snapshot.error}');
                } else if (snapshot.hasData) {
                  return Text(
                    snapshot.data!,
                    style: Theme.of(context).textTheme.headlineMedium,
                    textAlign: TextAlign.center,
                  );
                }
                return const Text("No data");
              },
            ),
            const SizedBox(height: 20),
            Text('You have pushed the button this many times: $_counter'),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 화면의 다른 부분의 상태를 변경하기 위해 setState 호출
          setState(() {
            _counter++;
          });
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

위 코드에서 FloatingActionButton을 누르면 어떤 일이 발생할까요? 버튼을 누르면 _counter 값이 증가하고 setState()가 호출됩니다. setState()는 Flutter 프레임워크에 "이 위젯의 상태가 변경되었으니 화면을 다시 그려달라"고 요청하는 역할을 합니다. 따라서 _BadExampleScreenStatebuild 메서드가 다시 실행됩니다.

이때 치명적인 문제가 발생합니다. build 메서드가 다시 실행되면서 FutureBuilderfuture 속성에 할당된 fetchData() 함수가 또다시 호출됩니다. 콘솔 창에는 "fetchData() called!"가 계속해서 출력되고, UI는 2초 동안 로딩 인디케이터를 보여주다가 데이터를 표시하는 과정을 무한히 반복하게 됩니다. 이는 사용자와 서버 양쪽 모두에게 재앙적인 결과를 초래합니다.

불필요한 재빌드의 구체적인 폐해

  • 네트워크 및 리소스 낭비: 상태 변경이 일어날 때마다 불필요한 API 호출이 발생하여 서버에 과도한 부하를 주고, 사용자의 데이터 요금제를 소모시킵니다. 데이터베이스 쿼리나 파일 I/O 같은 작업이라면 기기의 배터리와 CPU 자원을 심각하게 낭비하게 됩니다.
  • 형편없는 사용자 경험(UX): UI가 계속해서 로딩 상태와 완료 상태를 오가며 '깜빡'거리는 현상이 발생합니다. 사용자가 스크롤하던 위치나 입력하던 텍스트 같은 UI 상태가 초기화될 수도 있습니다. 이는 앱을 불안정하고 신뢰할 수 없게 만듭니다.
  • 성능 저하 및 버벅임(Jank): 불필요한 비동기 작업과 위젯 재빌드는 앱의 메인 스레드를 바쁘게 만듭니다. 이로 인해 애니메이션이 끊기거나 사용자의 터치 입력에 대한 반응이 느려지는 등 전반적인 앱 성능이 저하됩니다.

2. 근본 원인: 위젯 생명주기에 대한 이해 부족

이 문제의 근본 원인은 Flutter의 위젯, 특히 StatefulWidget의 생명주기(Lifecycle)를 올바르게 이해하지 못한 데 있습니다. Flutter에서 위젯은 불변(immutable) 객체입니다. 즉, 한번 생성된 위젯의 속성은 변경할 수 없습니다. 상태가 변경되면 기존 위젯을 수정하는 것이 아니라, 새로운 속성값을 가진 새로운 위젯 인스턴스를 생성하여 기존 위젯을 대체합니다.

StatelessWidget은 내부 상태가 없으므로 부모로부터 받은 인자(argument)에 의해서만 UI가 결정됩니다. 반면 StatefulWidget은 위젯 자체와 그 위젯의 '상태'를 관리하는 State 객체, 두 부분으로 나뉩니다.

  • Widget: 불변이며, 설정값(configuration)을 가집니다. 부모로부터 새로운 설정값을 받으면 파괴되고 새로 생성될 수 있습니다.
  • State: 가변(mutable)이며, 위젯의 생명주기 동안 유지됩니다. 위젯이 여러 번 교체되더라도 State 객체는 살아남아 상태를 보존합니다.

핵심은 바로 이 State 객체의 생명주기에 있습니다. State 객체는 다음과 같은 주요 단계를 거칩니다.

  1. createState(): 프레임워크가 StatefulWidget을 위젯 트리에 삽입할 때 이 메서드를 호출하여 State 객체를 생성합니다.
  2. initState(): State 객체가 생성된 후 단 한 번만 호출됩니다. 이곳은 상태 변수를 초기화하거나, 스트림을 구독하거나, 애니메이션 컨트롤러를 생성하는 등 일회성 작업을 수행하기에 가장 이상적인 장소입니다.
  3. didChangeDependencies(): initState() 호출 직후에 호출되며, 위젯이 의존하는 객체(예: InheritedWidget)가 변경될 때마다 다시 호출됩니다.
  4. build(): UI를 그리는 메서드로, 앞서 설명한 다양한 이유로 여러 번 호출될 수 있습니다. build 메서드는 반드시 위젯을 반환해야 하며, 가능한 한 순수 함수(pure function)처럼 동작하여 부수 효과(side effect)를 일으키지 않아야 합니다.
  5. setState(): 프레임워크에 상태가 변경되었음을 알리는 메서드입니다. 이 메서드가 호출되면 프레임워크는 build() 메서드를 다시 호출하여 화면을 갱신합니다.
  6. didUpdateWidget(): 부모 위젯이 재빌드되어 현재 위젯이 새로운 설정값(다른 인스턴스)으로 교체될 때 호출됩니다.
  7. dispose(): State 객체가 위젯 트리에서 영구적으로 제거될 때 호출됩니다. 리소스를 해제하는 작업(예: 스트림 구독 취소, 애니메이션 컨트롤러 폐기)을 수행하는 곳입니다.

문제의 원인이 이제 명확해졌습니다. 데이터 로딩과 같은 비동기 작업은 본질적으로 '일회성' 또는 '특정 조건에서만 다시 실행'되어야 하는 작업입니다. 이러한 작업을 수시로 호출될 수 있는 build 메서드 안에 두는 것은 State 객체의 생명주기 원칙에 정면으로 위배되는 행위입니다.

3. 올바른 해결책: State 생명주기를 활용한 Future 관리

문제 해결의 열쇠는 비동기 작업을 build 메서드로부터 분리하고, State 객체의 생명주기 메서드인 initState()에서 관리하는 것입니다.

정석적인 해결 패턴

  1. Future 객체를 저장할 상태 변수를 State 클래스에 선언합니다.
  2. initState() 메서드에서 비동기 함수를 호출하고, 그 결과(Future 객체)를 상태 변수에 할당합니다. 이 작업은 단 한 번만 실행됩니다.
  3. build() 메서드에서는 FutureBuilderfuture 속성에 이 상태 변수를 전달합니다.

이제 앞서 살펴본 잘못된 예시를 올바르게 수정한 코드를 보겠습니다.


// ... fetchData() 함수는 동일 ...

class GoodExampleScreen extends StatefulWidget {
  const GoodExampleScreen({super.key});

  @override
  State<GoodExampleScreen> createState() => _GoodExampleScreenState();
}

class _GoodExampleScreenState extends State<GoodExampleScreen> {
  // 1. Future 객체를 저장할 상태 변수 선언
  late final Future<String> _postTitleFuture;
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    // 2. initState에서 단 한 번만 Future를 생성하고 변수에 할당
    _postTitleFuture = fetchData();
  }

  @override
  Widget build(BuildContext context) {
    print("Build method called!"); // build 호출 확인
    return Scaffold(
      appBar: AppBar(title: const Text("올바른 FutureBuilder 사용")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            FutureBuilder<String>(
              // 3. build 메서드에서는 상태 변수를 사용
              future: _postTitleFuture,
              builder: (context, snapshot) {
                // ... builder 로직은 동일 ...
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const CircularProgressIndicator();
                } else if (snapshot.hasError) {
                  return Text('Error: ${snapshot.error}');
                } else if (snapshot.hasData) {
                  return Text(
                    snapshot.data!,
                    style: Theme.of(context).textTheme.headlineMedium,
                    textAlign: TextAlign.center,
                  );
                }
                return const Text("No data");
              },
            ),
            const SizedBox(height: 20),
            Text('You have pushed the button this many times: $_counter'),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            _counter++;
          });
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

이렇게 수정하면 FloatingActionButton을 아무리 많이 눌러도 fetchData() 함수는 더 이상 호출되지 않습니다. _postTitleFuture라는 Future 객체는 initState에서 생성된 후 State 객체의 생명주기 동안 메모리에 계속 유지됩니다. build 메서드가 재호출될 때마다 FutureBuilder는 매번 새로운 Future가 아닌, 동일한 _postTitleFuture 인스턴스를 받게 됩니다. FutureBuilder는 내부적으로 전달받은 future 객체가 이전과 동일한지 확인하고, 동일하다면 새로운 비동기 작업을 시작하는 대신 기존 작업의 상태를 계속해서 추적합니다. 그 결과, 불필요한 네트워크 요청 없이 안정적으로 UI가 유지됩니다.

4. 심화 시나리오와 고급 패턴

기본적인 문제는 해결했지만, 실제 애플리케이션 개발에서는 더 복잡한 요구사항을 마주하게 됩니다. 몇 가지 흔한 시나리오와 그에 대한 해결책을 알아보겠습니다.

시나리오 1: 데이터 새로고침 (Pull-to-Refresh)

사용자가 직접 데이터를 새로고침하도록 하고 싶을 때는 어떻게 해야 할까요? 예를 들어, 화면을 아래로 당겨서 새로고침하는 기능을 구현하는 경우입니다. 이때는 새로운 Future를 생성하고 setState를 호출하여 FutureBuilder가 새로운 Future를 받도록 해야 합니다.


class RefreshableScreen extends StatefulWidget {
  // ...
}

class _RefreshableScreenState extends State<RefreshableScreen> {
  Future<String>? _postTitleFuture;

  @override
  void initState() {
    super.initState();
    _fetchDataAndSetState();
  }
  
  // Future를 가져오고 setState를 호출하는 함수를 분리
  void _fetchDataAndSetState() {
    setState(() {
      _postTitleFuture = fetchData();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: RefreshIndicator(
        onRefresh: () async {
          // onRefresh 콜백에서 데이터를 다시 가져오도록 함
          _fetchDataAndSetState();
        },
        child: ListView( // RefreshIndicator는 스크롤 가능한 자식을 필요로 함
          children: [
            Center(
              child: FutureBuilder<String>(
                future: _postTitleFuture,
                builder: (context, snapshot) {
                  // ... builder 로직 ...
                },
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _fetchDataAndSetState, // 버튼으로도 새로고침 가능
        child: const Icon(Icons.refresh),
      ),
    );
  }
}

핵심은 _fetchDataAndSetState 메서드입니다. 이 메서드는 fetchData()를 호출하여 새로운 Future를 생성하고, 이를 _postTitleFuture 변수에 할당하는 과정을 setState로 감쌉니다. 이렇게 하면 UI가 새로운 Future를 인지하고 로딩 상태부터 다시 시작하게 됩니다. 이 패턴은 '새로고침' 버튼이나 RefreshIndicator의 `onRefresh` 콜백에 쉽게 적용할 수 있습니다.

시나리오 2: 위젯 파라미터에 의존하는 Future

만약 가져와야 할 데이터가 위젯의 생성자를 통해 전달된 ID 값에 따라 달라진다면 어떻게 해야 할까요? 예를 들어, PostDetailScreen(postId: 1)과 같이 특정 게시물의 상세 정보를 보여주는 화면을 생각해 봅시다. postId가 변경되면 데이터를 다시 가져와야 합니다.

이 경우 initState만으로는 충분하지 않습니다. initStateState 객체의 생애 동안 단 한 번만 호출되기 때문에, 위젯이 다른 postId로 업데이트되어도 데이터를 새로 가져오지 않습니다. 이때 필요한 것이 바로 didUpdateWidget 생명주기 메서드입니다.


class PostDetailScreen extends StatefulWidget {
  final int postId;
  const PostDetailScreen({super.key, required this.postId});

  @override
  State<PostDetailScreen> createState() => _PostDetailScreenState();
}

class _PostDetailScreenState extends State<PostDetailScreen> {
  late Future<String> _postTitleFuture;

  // postId에 따라 데이터를 가져오는 함수
  Future<String> fetchPostById(int id) async {
    // ... id를 사용하여 API 호출 ...
    return "Title for post $id";
  }

  @override
  void initState() {
    super.initState();
    // 초기 postId로 데이터 로딩
    _postTitleFuture = fetchPostById(widget.postId);
  }

  @override
  void didUpdateWidget(PostDetailScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 이전 postId와 현재 postId를 비교
    if (widget.postId != oldWidget.postId) {
      // postId가 변경되었다면 데이터를 새로 가져옴
      setState(() {
        _postTitleFuture = fetchPostById(widget.postId);
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Post ID: ${widget.postId}")),
      body: FutureBuilder<String>(
        future: _postTitleFuture,
        builder: (context, snapshot) {
          // ... builder 로직 ...
        },
      ),
    );
  }
}

didUpdateWidget는 부모로부터 새로운 위젯 인스턴스(새로운 postId를 가진)를 받아 기존 위젯을 교체할 때 호출됩니다. 이 메서드 내에서 widget.postId(새로운 값)와 oldWidget.postId(이전 값)를 비교하여, 값이 변경된 경우에만 데이터를 새로 가져오도록 할 수 있습니다.

시나리오 3: 상태 관리 라이브러리와의 통합

애플리케이션이 복잡해질수록 데이터 로딩 로직을 UI(위젯) 코드에서 분리하는 것이 중요해집니다. Provider, Riverpod, BLoC과 같은 상태 관리 라이브러리는 이러한 작업을 훨씬 더 우아하고 효율적으로 처리할 수 있도록 도와줍니다. 이 라이브러리들은 비동기 작업의 결과를 캐싱(caching)하고, 여러 위젯에서 동일한 데이터를 공유하며, 생명주기 관리를 자동화하는 기능을 제공합니다.

예를 들어, Riverpod의 FutureProvider를 사용하면 이 모든 문제가 매우 간단하게 해결됩니다.


import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. Provider 정의: 비동기 로직을 캡슐화
final postTitleProvider = FutureProvider.autoDispose<String>((ref) async {
  // fetchData() 로직이 여기에 들어감
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
  await Future.delayed(const Duration(seconds: 2));
  if (response.statusCode == 200) {
    return json.decode(response.body)['title'];
  } else {
    throw Exception('Failed to load data');
  }
});

// 2. UI에서 Provider 사용 (StatelessWidget으로도 충분)
class RiverpodExampleScreen extends ConsumerWidget {
  const RiverpodExampleScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // provider를 'watch'하여 데이터 상태를 구독
    final asyncValue = ref.watch(postTitleProvider);

    return Scaffold(
      appBar: AppBar(title: Text("Riverpod + FutureProvider")),
      body: Center(
        // AsyncValue는 loading, error, data 상태를 편리하게 처리하는 when 메서드를 제공
        child: asyncValue.when(
          data: (title) => Text(
            title,
            style: Theme.of(context).textTheme.headlineMedium,
          ),
          error: (err, stack) => Text('Error: $err'),
          loading: () => const CircularProgressIndicator(),
        ),
      ),
    );
  }
}

Riverpod을 사용하면 StatefulWidget, initState, setState를 직접 관리할 필요가 없습니다. FutureProvider가 자동으로 비동기 작업의 결과를 캐싱해주기 때문에, 위젯이 재빌드되어도 불필요한 API 호출이 발생하지 않습니다. 또한 새로고침, 파라미터 전달 등의 고급 기능도 손쉽게 구현할 수 있습니다. 복잡한 앱에서는 이러한 상태 관리 솔루션을 도입하는 것을 적극적으로 고려해야 합니다.

결론: 생명주기를 이해하는 것이 핵심이다

FutureBuilder는 Flutter에서 비동기 데이터를 UI에 표시하는 강력하고 직관적인 도구입니다. 하지만 그 편리함 이면에는 위젯의 생명주기라는 중요한 원칙이 숨어 있습니다. "build 메서드 안에서 Future를 생성하지 말라"는 간단한 규칙을 기억하는 것만으로도 수많은 성능 문제와 버그를 예방할 수 있습니다.

이 글에서 다룬 내용을 요약하면 다음과 같습니다.

  • 문제점: build 메서드 내에서 Future를 생성하면, UI가 재빌드될 때마다 불필요한 비동기 작업이 반복 실행되어 성능 저하와 나쁜 사용자 경험을 유발합니다.
  • 근본 원인: Flutter StatefulWidget의 생명주기에 대한 이해 부족. build는 수시로 호출될 수 있는 반면, 데이터 로딩과 같은 작업은 initState처럼 단 한 번만 실행되는 곳에서 처리해야 합니다.
  • 해결책: Future 객체를 State의 멤버 변수로 선언하고, initState에서 초기화한 후, build 메서드의 FutureBuilder에 이 변수를 전달합니다.
  • 고급 활용: 데이터 새로고침은 setState를 통해 새로운 Future를 할당하여 구현하고, 위젯 파라미터 변경에 따른 데이터 갱신은 didUpdateWidget 생명주기 메서드를 활용합니다.
  • 더 나은 접근법: 복잡한 앱에서는 Riverpod과 같은 상태 관리 라이브러리를 사용하여 비동기 로직을 UI에서 분리하고, 캐싱 및 생명주기 관리를 자동화하는 것이 좋습니다.

Flutter 개발의 여정에서 위젯의 생명주기를 깊이 이해하는 것은 선택이 아닌 필수입니다. 이 원칙을 제대로 이해하고 적용한다면 FutureBuilder뿐만 아니라 다른 많은 위젯들도 더 효율적이고 안정적으로 사용할 수 있게 될 것입니다. 이를 통해 사용자에게는 부드럽고 쾌적한 경험을, 개발자에게는 예측 가능하고 유지보수하기 쉬운 코드를 선사할 수 있습니다.


0 개의 댓글:

Post a Comment