Flutter에서 상태 관리 코드 분산 문제 해결하기

Flutter에서 상태 관리 코드 분산 문제 해결하기

Flutter로 개발을 하다 보면 위젯 내에 상태 관리 코드가 난잡하게 흩어져 있는 경우를 종종 보 수 있다. 오늘은 이런 문제를 어떻게 해결할 수 있는지 다양한 방법을 알아보자.

문제가 되는 코드 예시

다음은 흔히 볼 수 있는 문제가 있는 코드다:

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

  @override
  State<ProductListScreen> createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  bool _isLoading = false;
  String _error = '';
  List<Product> _products = [];
  bool _isFavoriteOnly = false;

  @override
  void initState() {
    super.initState();
    _fetchProducts();
  }

  Future<void> _fetchProducts() async {
    setState(() => _isLoading = true);
    try {
      final response = await api.getProducts();
      setState(() {
        _products = response;
        _error = '';
      });
    } catch (e) {
      setState(() => _error = e.toString());
    } finally {
      setState(() => _isLoading = false);
    }
  }

  void _toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      _fetchProducts(); // 전체 목록을 다시 불러옴
    } catch (e) {
      setState(() => _error = e.toString());
    }
  }

  void _toggleFavoriteFilter() {
    setState(() {
      _isFavoriteOnly = !_isFavoriteOnly;
      _fetchProducts();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('상품 목록'),
        actions: [
          IconButton(
            icon: Icon(_isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: _toggleFavoriteFilter,
          ),
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _error.isNotEmpty
              ? Center(child: Text(_error))
              : ListView.builder(
                  itemCount: _products.length,
                  itemBuilder: (context, index) {
                    final product = _products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => _toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

이 코드의 문제점:

  • 상태 관리 로직이 UI 코드와 섞여 있음
  • 재사용이 어려움
  • 테스트하기 어려움
  • 코드가 길어지면 유지보수가 어려움

해결 방법 1: 별도의 Controller 클래스로 분리

상태 관리 로직을 별도의 컨트롤러 클래스로 분리해보자:

// product_list_controller.dart
class ProductListController {
  bool isLoading = false;
  String error = '';
  List<Product> products = [];
  bool isFavoriteOnly = false;

  final void Function(void Function()) setState;

  ProductListController(this.setState);

  Future<void> fetchProducts() async {
    setState(() => isLoading = true);
    try {
      final response = await api.getProducts();
      setState(() {
        products = response;
        error = '';
      });
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => isLoading = false);
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      setState(() => error = e.toString());
    }
  }

  void toggleFavoriteFilter() {
    setState(() {
      isFavoriteOnly = !isFavoriteOnly;
      fetchProducts();
    });
  }
}

// product_list_screen.dart
class _ProductListScreenState extends State<ProductListScreen> {
  late final _controller = ProductListController(setState);

  @override
  void initState() {
    super.initState();
    _controller.fetchProducts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('상품 목록'),
        actions: [
          IconButton(
            icon: Icon(_controller.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: _controller.toggleFavoriteFilter,
          ),
        ],
      ),
      body: _controller.isLoading
          ? const Center(child: CircularProgressIndicator())
          : _controller.error.isNotEmpty
              ? Center(child: Text(_controller.error))
              : ListView.builder(
                  itemCount: _controller.products.length,
                  itemBuilder: (context, index) {
                    final product = _controller.products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => _controller.toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

해결 방법 2: ChangeNotifier 활용

Flutter의 ChangeNotifier를 활용하면 더 깔끔한 상태 관리가 가능하다:

// product_list_provider.dart
class ProductListProvider extends ChangeNotifier {
  bool _isLoading = false;
  String _error = '';
  List<Product> _products = [];
  bool _isFavoriteOnly = false;

  bool get isLoading => _isLoading;
  String get error => _error;
  List<Product> get products => _products;
  bool get isFavoriteOnly => _isFavoriteOnly;

  Future<void> fetchProducts() async {
    _isLoading = true;
    notifyListeners();

    try {
      final response = await api.getProducts();
      _products = response;
      _error = '';
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
    }
  }

  void toggleFavoriteFilter() {
    _isFavoriteOnly = !_isFavoriteOnly;
    fetchProducts();
  }
}

// product_list_screen.dart
class ProductListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => ProductListProvider()..fetchProducts(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('상품 목록'),
          actions: [
            Consumer<ProductListProvider>(
              builder: (_, provider, __) => IconButton(
                icon: Icon(provider.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
                onPressed: provider.toggleFavoriteFilter,
              ),
            ),
          ],
        ),
        body: Consumer<ProductListProvider>(
          builder: (_, provider, __) {
            if (provider.isLoading) {
              return const Center(child: CircularProgressIndicator());
            }

            if (provider.error.isNotEmpty) {
              return Center(child: Text(provider.error));
            }

            return ListView.builder(
              itemCount: provider.products.length,
              itemBuilder: (context, index) {
                final product = provider.products[index];
                return ListTile(
                  title: Text(product.name),
                  trailing: IconButton(
                    icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                    onPressed: () => provider.toggleFavorite(product.id),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

해결 방법 3: Riverpod 활용

Riverpod을 사용하면 더 강력하고 유연한 상태 관리가 가능하다:

// product_list_provider.dart
final productListProvider = StateNotifierProvider<ProductListNotifier, ProductListState>((ref) {
  return ProductListNotifier();
});

class ProductListState {
  final bool isLoading;
  final String error;
  final List<Product> products;
  final bool isFavoriteOnly;

  const ProductListState({
    this.isLoading = false,
    this.error = '',
    this.products = const [],
    this.isFavoriteOnly = false,
  });

  ProductListState copyWith({
    bool? isLoading,
    String? error,
    List<Product>? products,
    bool? isFavoriteOnly,
  }) {
    return ProductListState(
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
      products: products ?? this.products,
      isFavoriteOnly: isFavoriteOnly ?? this.isFavoriteOnly,
    );
  }
}

class ProductListNotifier extends StateNotifier<ProductListState> {
  ProductListNotifier() : super(const ProductListState());

  Future<void> fetchProducts() async {
    state = state.copyWith(isLoading: true);

    try {
      final response = await api.getProducts();
      state = state.copyWith(
        products: response,
        error: '',
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(
        error: e.toString(),
        isLoading: false,
      );
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      state = state.copyWith(error: e.toString());
    }
  }

  void toggleFavoriteFilter() {
    state = state.copyWith(
      isFavoriteOnly: !state.isFavoriteOnly,
    );
    fetchProducts();
  }
}

// product_list_screen.dart
class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(productListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('상품 목록'),
        actions: [
          IconButton(
            icon: Icon(state.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: () => ref.read(productListProvider.notifier).toggleFavoriteFilter(),
          ),
        ],
      ),
      body: state.isLoading
          ? const Center(child: CircularProgressIndicator())
          : state.error.isNotEmpty
              ? Center(child: Text(state.error))
              : ListView.builder(
                  itemCount: state.products.length,
                  itemBuilder: (context, index) {
                    final product = state.products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => ref
                            .read(productListProvider.notifier)
                            .toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

각 방법의 장단점

컨트롤러 패턴

장점:

  • 구현이 간단함
  • 기존 StatefulWidget에서 쉽게 전환 가능
  • 별도의 패키지 의존성이 없음

단점:

  • 상태 변화 구독이 불편함
  • 전역 상태 관리가 어려움
  • setState 호출이 필요함

ChangeNotifier

장점:

  • Flutter 기본 제공 기능
  • 상태 변화 구독이 쉬움
  • Provider 패키지와 잘 통합됨

단점:

  • 상태 변화 시 불필요한 리빌드가 발생할 수 있음
  • 복잡한 상태 관리에는 적합하지 않을 수 있음

Riverpod

장점:

  • 컴파일 타임 안정성
  • 세밀한 상태 관리 가능
  • 테스트가 용이함
  • 코드 자동완성이 잘 됨

단점:

  • 학습 곡선이 있음
  • 보일러플레이트 코드가 많아질 수 있음
  • 추가 패키지 의존성

결론

상태 관리 코드를 분리하는 것은 코드의 유지보수성과 재사용성을 크게 향상시킨다. 프로젝트의 규모와 요구사항에 따라 적절한 방법을 선택하면 되는데:

  • 작은 프로젝트: 컨트롤러 패턴
  • 중간 규모: ChangeNotifier
  • 대규모 프로젝트: Riverpod

어떤 방법을 선택하든, 상태 관리 로직을 UI 코드에서 분리하는 것이 중요하다.

참고 자료