UI 컴포넌트 개요

Dart에서 UI 컴포넌트를 설계할 때는 상태 관리가 중요한 역할을 한다. 상태(state)란 UI가 보여주는 데이터의 현재 상태를 의미하며, 사용자의 입력, 네트워크 요청의 응답, 타이머와 같은 다양한 이벤트에 따라 변할 수 있다. 컴포넌트가 적절하게 상태를 유지하고 갱신해야만, 애플리케이션이 올바르게 작동한다.

Flutter에서 Dart 언어로 UI를 구성할 때, 모든 UI는 위젯(widget)으로 구성된다. 위젯은 Dart의 클래스이며, 화면에 표현될 시각적인 요소와 그 동작을 정의한다. UI는 상태에 따라 동적으로 변하는데, 이는 상태 관리 패턴을 통해 이루어진다.

위젯 계층 구조

위젯은 트리 구조를 이루며, 이는 UI의 구성 요소 간의 관계를 나타낸다. 부모 위젯은 자식 위젯을 포함할 수 있으며, 자식 위젯의 상태에 따라 부모 위젯이 재구성될 수 있다. 위젯 계층 구조는 다음과 같은 흐름으로 이루어진다:

graph LR Root --> Parent1 --> Child1 Parent1 --> Child2 Root --> Parent2 --> Child3

이와 같이 트리 구조로 구성된 위젯은 각자 자신의 상태를 관리할 수 있지만, 이때 상태가 변경되면 UI를 다시 그려야 하므로 상태 관리를 효율적으로 해야 한다.

상태 관리의 종류

상태는 크게 두 가지로 나눌 수 있다: 1. Stateless: 변하지 않는 상태를 가진 UI. 2. Stateful: 변하는 상태를 가진 UI.

Stateless Widget

Stateless 위젯은 한 번 생성되면 내부 상태가 변경되지 않는 UI 컴포넌트이다. 단순한 텍스트나 이미지처럼 변하지 않는 UI 요소에 적합한다. Stateless Widget은 한 번만 빌드되고 이후 상태 변화에 대해 반응하지 않는다.

class MyStatelessWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('This is a stateless widget');
  }
}

Stateful Widget

Stateful 위젯은 상태가 변할 수 있는 UI 요소에 적합한다. 사용자의 입력이나 네트워크 응답에 따라 상태가 변하고, 이에 따라 UI도 업데이트된다. Stateful Widget은 상태 변화를 관리할 수 있는 State 객체와 함께 동작한다.

class MyStatefulWidget extends StatefulWidget {
  @override
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Counter: $_counter'),
        ElevatedButton(
          onPressed: _incrementCounter,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

setState()와 상태 갱신

Stateful 위젯에서는 상태를 변경할 때 setState() 메소드를 호출한다. 이 메소드는 상태가 변경되었음을 Flutter에 알리고, 그에 따라 UI를 다시 빌드하게 된다. 그러나 setState()의 남용은 성능 저하를 초래할 수 있다. 그러므로 불필요한 상태 변화를 최소화하고, 꼭 필요한 경우에만 상태를 갱신해야 한다.

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

위 코드에서 setState() 안에 상태 변경 로직이 포함되어 있으며, 이 메소드를 호출하면 Flutter는 UI를 다시 빌드해 화면을 갱신한다.

상태를 전달하는 방법

Flutter에서는 상태를 위젯 트리 전체에 전달해야 할 때가 많다. 이를 위한 몇 가지 기법이 있다:

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ChildWidget(counter: _counter, increment: _incrementCounter);
  }
}

class ChildWidget extends StatelessWidget {
  final int counter;
  final VoidCallback increment;

  ChildWidget({required this.counter, required this.increment});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Counter: $counter'),
        ElevatedButton(
          onPressed: increment,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

이 방식은 단순한 구조에서는 효과적이지만, 복잡한 상태 전달에는 적합하지 않는다. 그래서 이를 개선하기 위한 다양한 패턴들이 존재한다.

InheritedWidget

InheritedWidget은 Flutter에서 상태를 자식 위젯에 전달하는 효율적인 방법이다. InheritedWidget은 위젯 트리에서 상위에 있는 위젯이 하위에 있는 모든 위젯에 데이터를 제공하는 데 사용된다.

class CounterProvider extends InheritedWidget {
  final int counter;
  final VoidCallback increment;

  CounterProvider({required Widget child, required this.counter, required this.increment})
      : super(child: child);

  static CounterProvider? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CounterProvider>();
  }

  @override
  bool updateShouldNotify(CounterProvider oldWidget) {
    return oldWidget.counter != counter;
  }
}

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return CounterProvider(
      counter: _counter,
      increment: _incrementCounter,
      child: ChildWidget(),
    );
  }
}

class ChildWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final provider = CounterProvider.of(context);

    return Column(
      children: <Widget>[
        Text('Counter: ${provider!.counter}'),
        ElevatedButton(
          onPressed: provider.increment,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

이 코드는 InheritedWidget을 사용하여 상태를 트리의 하위에 있는 모든 위젯에 효율적으로 전달한다. 이를 통해 위젯 트리 깊이에 상관없이 상태를 쉽게 공유할 수 있다.

Provider 패턴

Provider 패턴은 Flutter에서 더 널리 사용되는 상태 관리 패턴 중 하나로, InheritedWidget의 사용을 더 간단하게 만들어 준다. Provider는 상태를 전역적으로 관리하며, 다른 위젯에서 이를 쉽게 사용할 수 있도록 도와준다. Flutter 팀이 공식적으로 권장하는 패턴이기도 한다. Provider는 상태를 보다 효율적으로 트리 전체에 전달하고, 상태 변화를 감지하는 구조를 제공한다.

아래는 Provider 패턴을 사용하여 상태를 관리하는 예제이다:

  1. 상태 클래스를 정의하여 상태를 저장한다.
class CounterState with ChangeNotifier {
  int _counter = 0;

  int get counter => _counter;

  void increment() {
    _counter++;
    notifyListeners();
  }
}
  1. Provider로 상태를 감싸서 위젯 트리 전체에서 사용할 수 있도록 한다.
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CounterState(),
      child: MaterialApp(
        home: CounterScreen(),
      ),
    );
  }
}
  1. Consumer를 사용하여 상태를 접근하고 UI를 갱신한다.
class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Provider Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('You have pushed the button this many times:'),
            Consumer<CounterState>(
              builder: (context, counterState, child) {
                return Text(
                  '${counterState.counter}',
                  style: Theme.of(context).textTheme.headline4,
                );
              },
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterState>().increment(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

이 코드는 ChangeNotifierProvider를 사용하여 상태를 제공하고, Consumer 위젯을 통해 상태 변화를 감지하여 UI를 갱신하는 구조이다. 상태가 변경될 때마다 notifyListeners()가 호출되며, 이를 감지한 Consumer는 화면을 다시 그리게 된다.

상태 관리에서의 성능 최적화

상태 관리의 핵심 중 하나는 성능 최적화이다. Flutter에서 상태를 자주 변경할 경우, 필요 이상의 위젯이 다시 그려지게 되면 성능 저하가 발생할 수 있다. 이를 막기 위해 몇 가지 기법을 사용할 수 있다.

1. 적절한 상태 관리 범위 설정

상태가 불필요하게 큰 범위에 전달되는 것을 막아야 한다. 위젯 트리의 깊은 곳에서만 필요한 상태는, 상위 위젯에서 관리하는 것보다는 하위 위젯에서만 전달되는 방식으로 설정하는 것이 좋다.

2. Consumer 최적화

Consumer는 상태가 변할 때마다 UI를 다시 그리지만, 하위 위젯 일부만 다시 그리는 구조로 만들 수 있다. 예를 들어, 자식 위젯 중 일부만 상태 변경에 영향을 받는 경우, 해당 부분만 Consumer로 감싸서 재빌드를 최소화할 수 있다.

Consumer<CounterState>(
  builder: (context, counterState, child) {
    return Text('${counterState.counter}');
  },
)

3. Selector 사용

Selector는 상태의 특정 부분만을 선택하여 UI를 갱신하는 도구이다. 이를 통해 불필요한 재빌드를 막을 수 있다. 예를 들어, 상태의 여러 값 중 하나만 변경되었을 때 그 값에만 의존하는 위젯을 다시 빌드하도록 최적화할 수 있다.

Selector<CounterState, int>(
  selector: (context, counterState) => counterState.counter,
  builder: (context, counter, child) {
    return Text('$counter');
  },
)

위 코드에서는 CounterStatecounter 값이 변경될 때만 해당 위젯이 다시 빌드된다. Selector는 성능 최적화에 유용한 도구로, 꼭 필요한 경우에만 상태를 선택적으로 사용하게 해준다.

Flutter의 다른 상태 관리 기법들

Flutter에서 상태 관리를 위한 다양한 기법들이 존재한다. Provider 외에도 BLoC, Redux, Riverpod 등의 패턴과 라이브러리가 있다. 이러한 패턴들은 각각의 사용 목적에 맞게 적용될 수 있으며, 애플리케이션의 규모나 복잡성에 따라 적절한 선택이 필요하다.