learn-flutter/content/docs/part3/stateless-stateful.md
ChangJoo Park(박창주) 900b0ab68b update markdown
2025-05-14 09:34:13 +09:00

23 KiB

Stateless / Stateful 위젯

Flutter에서 위젯은 크게 Stateless 위젯과 Stateful 위젯으로 구분됩니다. 이 두 가지 위젯 유형은 UI 구성의 기본 요소이며, 각각 다른 용도와 특성을 가지고 있습니다. 이 장에서는 두 위젯의 차이점, 동작 원리, 사용 패턴에 대해 알아보겠습니다.

Stateless 위젯 (StatelessWidget)

Stateless 위젯은 내부 상태(state)를 가지지 않는 불변(immutable)의 위젯입니다. 한번 빌드되면 입력된 매개변수가 변경되지 않는 한 다시 빌드되지 않습니다. 정적인 UI 요소를 표현하는데 적합합니다.

특징

  • 불변성: 내부 상태를 가지지 않으며, 생성 후 변경되지 않습니다.
  • 성능: 상태가 없어 효율적이고 빠르게 렌더링됩니다.
  • 단순성: 구현이 간단하고 이해하기 쉽습니다.
  • 용도: 정적인 UI, 표시 전용 컴포넌트에 적합합니다.

생명 주기

Stateless 위젯의 생명 주기는 간단합니다:

graph TD
    A[생성자 호출] --> B[빌드 메서드 호출]
    B --> C[렌더링]
    D[부모 위젯 재빌드 또는 매개변수 변경] --> B
  1. 생성자 호출: 위젯이 생성되고 속성이 초기화됩니다.
  2. 빌드 메서드 호출: build() 메서드가 호출되어 위젯 트리를 구성합니다.
  3. 렌더링: 생성된 위젯 트리가 화면에 렌더링됩니다.
  4. 재빌드: 부모 위젯이 재빌드되거나 매개변수가 변경되면 새로운 위젯 인스턴스가 생성되고 다시 빌드됩니다.

구현 예제

class GreetingCard extends StatelessWidget {
  final String name;
  final String message;
  final Color backgroundColor;

  // 생성자 - 필요한 데이터를 주입받습니다
  const GreetingCard({
    Key? key,
    required this.name,
    required this.message,
    this.backgroundColor = Colors.white,
  }) : super(key: key);

  // 빌드 메서드 - UI를 정의합니다
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16.0),
      color: backgroundColor,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            '안녕하세요, $name님!',
            style: const TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            message,
            style: const TextStyle(fontSize: 14),
          ),
        ],
      ),
    );
  }
}

// 사용 예시
GreetingCard(
  name: '홍길동',
  message: '오늘도 좋은 하루 되세요!',
  backgroundColor: Colors.amber.shade100,
)

위 예제에서 GreetingCard는 이름, 메시지, 배경색을 입력받아 인사말 카드를 표시하는 Stateless 위젯입니다. 이 위젯은 내부 상태를 가지지 않으며, 입력된 속성에 따라 UI를 구성합니다.

Stateful 위젯 (StatefulWidget)

Stateful 위젯은 시간에 따라 변경될 수 있는 내부 상태(state)를 가진 위젯입니다. 사용자 상호작용, 데이터 변경, 애니메이션 등으로 인해 UI가 동적으로 변경되어야 할 때 사용합니다.

특징

  • 가변성: 생성 후에도 상태가 변경될 수 있습니다.
  • 상태 관리: 위젯의 생명 주기 동안 상태를 유지하고 관리합니다.
  • 복잡성: Stateless 위젯보다 구현이 복잡합니다.
  • 용도: 사용자 입력, 애니메이션, 동적 데이터 표시 등에 적합합니다.

구조

Stateful 위젯은 두 개의 클래스로 구성됩니다:

  1. StatefulWidget 클래스: 위젯의 설정과 매개변수를 정의합니다.
  2. State 클래스: 위젯의 상태를 관리하고 UI를 구성합니다.
classDiagram
    class MyStatefulWidget {
        +createState() State
    }
    class MyWidgetState {
        -data
        +initState()
        +didUpdateWidget()
        +build()
        +setState()
        +dispose()
    }
    MyStatefulWidget --> MyWidgetState : creates

생명 주기

Stateful 위젯의 생명 주기는 Stateless 위젯보다 복잡합니다:

graph TD
    A[생성자 호출] --> B[createState]
    B --> C[initState]
    C --> D[didChangeDependencies]
    D --> E[빌드]
    E --> F[화면에 표시]

    G[setState 호출] --> E
    H[부모 위젯 재빌드] --> I[didUpdateWidget]
    I --> E

    J[위젯 제거] --> K[deactivate]
    K --> L[dispose]
  1. 생성자 호출: 위젯이 생성되고 속성이 초기화됩니다.
  2. createState(): 위젯의 State 객체를 생성합니다.
  3. initState(): State가 초기화됩니다. 이 메서드는 State 객체가 생성된 직후 한 번만 호출됩니다.
  4. didChangeDependencies(): State가 의존하는 객체가 변경될 때 호출됩니다.
  5. build(): UI를 구성합니다. 이 메서드는 initState 후, 그리고 setState가 호출될 때마다 호출됩니다.
  6. setState(): 상태 변경을 알리고 rebuild를 예약합니다.
  7. didUpdateWidget(): 부모 위젯이 재빌드되어 위젯이 재구성될 때 호출됩니다.
  8. deactivate(): State 객체가 위젯 트리에서 제거될 때 호출됩니다.
  9. dispose(): State 객체가 영구적으로 제거될 때 호출됩니다. 리소스를 정리하는 데 사용됩니다.

구현 예제

// 1. StatefulWidget 클래스
class Counter extends StatefulWidget {
  final int initialCount;

  const Counter({Key? key, this.initialCount = 0}) : super(key: key);

  @override
  _CounterState createState() => _CounterState();
}

// 2. State 클래스
class _CounterState extends State<Counter> {
  late int _count;

  // 초기화
  @override
  void initState() {
    super.initState();
    _count = widget.initialCount;
    print('Counter 위젯 초기화');
  }

  // 위젯이 업데이트될 때
  @override
  void didUpdateWidget(Counter oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.initialCount != widget.initialCount) {
      _count = widget.initialCount;
      print('초기 카운트 값 변경: $_count');
    }
  }

  // 상태 변경 함수
  void _increment() {
    setState(() {
      _count++;
      print('카운트 증가: $_count');
    });
  }

  // 리소스 정리
  @override
  void dispose() {
    print('Counter 위젯 제거');
    super.dispose();
  }

  // UI 구성
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16.0),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(8.0),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            '현재 카운트: $_count',
            style: const TextStyle(fontSize: 18),
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: _increment,
            child: const Text('증가'),
          ),
        ],
      ),
    );
  }
}

// 사용 예시
Counter(initialCount: 5)

위 예제에서 Counter는 카운트 값을 관리하고 증가 버튼이 있는 Stateful 위젯입니다. 상태(_count)는 _CounterState 클래스 내에서 관리되며, 버튼 클릭 시 setState()를 호출하여 UI를 업데이트합니다.

Stateless vs Stateful 위젯 비교

두 위젯 유형의 주요 차이점을 비교해 보겠습니다:

graph TB
    subgraph "Stateless Widget"
    A[생성자로 데이터 받음]
    B[상태 없음]
    C[빌드 한 번]
    D[변경 불가]
    end

    subgraph "Stateful Widget"
    E[생성자로 초기 데이터 받음]
    F[내부 상태 관리]
    G[빌드 여러 번]
    H[setState로 변경]
    end
항목 Stateless 위젯 Stateful 위젯
상태 관리 내부 상태 없음 내부 상태 관리
빌드 빈도 부모 위젯 변경 시에만 setState() 호출 시마다
생명 주기 간단함 복잡함
성능 일반적으로 더 효율적 상태 관리 오버헤드
용도 정적 UI 동적 UI
구현 복잡도 낮음 중간-높음
예시 Text, Icon, RichText TextField, Checkbox, AnimatedContainer

언제 어떤 위젯을 사용해야 할까?

Stateless 위젯 사용 사례

  • 정적 콘텐츠 표시: 텍스트, 이미지, 아이콘 등 변하지 않는 UI 요소
  • 데이터 표시 전용 컴포넌트: 입력 데이터만으로 UI를 구성할 때
  • 재사용 가능한 UI 컴포넌트: 여러 곳에서 동일한 모양으로 사용되는 컴포넌트
  • 부모로부터 모든 데이터를 받아 표시: 자체 상태가 필요 없는 경우

예시:

  • 프로필 카드
  • 제품 정보 표시
  • 헤더/푸터
  • 아이콘 버튼

Stateful 위젯 사용 사례

  • 사용자 입력 처리: 폼, 텍스트 입력, 체크박스 등
  • 애니메이션 및 변환 효과: 상태에 따라 변하는 UI
  • 데이터 로딩 및 진행 상황 표시: 로딩 인디케이터, 진행 바
  • 타이머 또는 주기적 업데이트: 시계, 카운트다운 타이머
  • 내부적으로 데이터를 관리하는 컴포넌트: 자체 상태가 필요한 경우

예시:

  • 로그인 폼
  • 이미지 슬라이더
  • 카운터
  • 토글 버튼

상태 관리의 기본 원칙

1. 상태의 범위 최소화

위젯 트리에서 가능한 낮은 위치에 상태를 유지하는 것이 좋습니다. 이는 불필요한 리빌드를 방지하고 성능을 향상시킵니다.

// 좋지 않은 예: 부모 위젯에서 불필요한 상태 관리
class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('제목'),
        SomeWidget(), // 이 위젯은 _isExpanded와 관련 없음
        AnotherWidget(), // 이 위젯도 _isExpanded와 관련 없음
        ExpandableWidget(
          isExpanded: _isExpanded,
          onToggle: () => setState(() => _isExpanded = !_isExpanded),
        ),
      ],
    );
  }
}

// 좋은 예: 관련 상태를 해당 위젯 내에서 관리
class ExpandableWidget extends StatefulWidget {
  @override
  _ExpandableWidgetState createState() => _ExpandableWidgetState();
}

class _ExpandableWidgetState extends State<ExpandableWidget> {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () => setState(() => _isExpanded = !_isExpanded),
          child: Text(_isExpanded ? '접기' : '펼치기'),
        ),
        if (_isExpanded)
          Container(
            child: Text('펼쳐진 내용'),
          ),
      ],
    );
  }
}

2. 상태 끌어올리기

여러 위젯이 동일한 상태를 공유해야 하는 경우, 상태를 공통 부모 위젯으로 "끌어올립니다".

graph TD
    A[상태 공유가 필요한 경우]
    A --> B[공통 부모 위젯으로 상태 끌어올리기]
    B --> C[자식 위젯에 상태와 상태 변경 콜백 함수 전달]
    C --> D[자식 위젯은 Stateless로 구현]
// 공통 부모 위젯에서 상태 관리
class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 두 위젯이 동일한 상태(_count)를 공유
        CounterDisplay(count: _count),
        CounterControls(onIncrement: _increment),
      ],
    );
  }
}

// 표시만 담당하는 Stateless 위젯
class CounterDisplay extends StatelessWidget {
  final int count;

  const CounterDisplay({Key? key, required this.count}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text('현재 카운트: $count');
  }
}

// 조작만 담당하는 Stateless 위젯
class CounterControls extends StatelessWidget {
  final VoidCallback onIncrement;

  const CounterControls({Key? key, required this.onIncrement}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onIncrement,
      child: Text('증가'),
    );
  }
}

3. 상태를 종류별로 관리

상태의 종류와 범위에 따라 적절한 관리 방법을 선택합니다:

  1. UI 상태(임시 상태): 텍스트 필드 포커스, 애니메이션 진행 상태 등
    • Stateful 위젯 내에서 관리
  2. 앱 상태(영구 상태): 사용자 설정, 로그인 정보, 장바구니 등
    • Provider, Riverpod, Bloc 등의 상태 관리 솔루션 사용

실전 예제: 위젯 타입 선택하기

실제 애플리케이션에서 어떤 위젯 타입을 선택해야 하는지 살펴보겠습니다:

예제 1: 제품 카드

// Stateless 위젯 사용 - 정적 데이터 표시
class ProductCard extends StatelessWidget {
  final String name;
  final double price;
  final String imageUrl;
  final VoidCallback? onAddToCart;

  const ProductCard({
    Key? key,
    required this.name,
    required this.price,
    required this.imageUrl,
    this.onAddToCart,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.all(8.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Image.network(
            imageUrl,
            height: 120,
            width: double.infinity,
            fit: BoxFit.cover,
          ),
          Padding(
            padding: EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                ),
                SizedBox(height: 4),
                Text(
                  '₩${price.toStringAsFixed(0)}',
                  style: TextStyle(color: Colors.green, fontSize: 14),
                ),
                SizedBox(height: 8),
                ElevatedButton(
                  onPressed: onAddToCart,
                  child: Text('장바구니 추가'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

예제 2: 수량 선택 위젯

// Stateful 위젯 사용 - 내부 상태 관리
class QuantitySelector extends StatefulWidget {
  final int initialValue;
  final int min;
  final int max;
  final ValueChanged<int>? onChanged;

  const QuantitySelector({
    Key? key,
    this.initialValue = 1,
    this.min = 1,
    this.max = 10,
    this.onChanged,
  }) : super(key: key);

  @override
  _QuantitySelectorState createState() => _QuantitySelectorState();
}

class _QuantitySelectorState extends State<QuantitySelector> {
  late int _quantity;

  @override
  void initState() {
    super.initState();
    _quantity = widget.initialValue.clamp(widget.min, widget.max);
  }

  void _increment() {
    setState(() {
      if (_quantity < widget.max) {
        _quantity++;
        widget.onChanged?.call(_quantity);
      }
    });
  }

  void _decrement() {
    setState(() {
      if (_quantity > widget.min) {
        _quantity--;
        widget.onChanged?.call(_quantity);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        IconButton(
          icon: Icon(Icons.remove),
          onPressed: _quantity > widget.min ? _decrement : null,
        ),
        Container(
          padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey),
            borderRadius: BorderRadius.circular(4),
          ),
          child: Text(
            _quantity.toString(),
            style: TextStyle(fontSize: 16),
          ),
        ),
        IconButton(
          icon: Icon(Icons.add),
          onPressed: _quantity < widget.max ? _increment : null,
        ),
      ],
    );
  }
}

고급 기법: 위젯 참조 및 통신

GlobalKey를 사용한 위젯 참조

GlobalKey를 사용하면 위젯의 State 객체에 직접 접근할 수 있습니다:

// GlobalKey 예제
class MyApp extends StatelessWidget {
  // GlobalKey 생성
  final formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Form(
          // 위젯에 키 할당
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return '이 필드는 필수입니다';
                  }
                  return null;
                },
              ),
              ElevatedButton(
                onPressed: () {
                  // 폼 상태에 접근
                  if (formKey.currentState!.validate()) {
                    print('유효성 검사 통과');
                  }
                },
                child: Text('제출'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

콜백 함수를 통한 자식-부모 통신

자식 위젯이 부모 위젯과 통신하는 일반적인 방법은 콜백 함수를 사용하는 것입니다:

// 부모 위젯
class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  String _selectedItem = '';

  void _handleItemSelected(String item) {
    setState(() {
      _selectedItem = item;
    });
    print('선택된 항목: $item');
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('선택된 항목: $_selectedItem'),
        ChildWidget(onItemSelected: _handleItemSelected),
      ],
    );
  }
}

// 자식 위젯 (Stateless)
class ChildWidget extends StatelessWidget {
  final Function(String) onItemSelected;

  const ChildWidget({Key? key, required this.onItemSelected}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: () => onItemSelected('항목 1'),
          child: Text('항목 1 선택'),
        ),
        ElevatedButton(
          onPressed: () => onItemSelected('항목 2'),
          child: Text('항목 2 선택'),
        ),
      ],
    );
  }
}

성능 최적화 팁

1. const 생성자 사용

가능한 경우 const 생성자를 사용하여 위젯을 선언하면 Flutter는 동일한 위젯 인스턴스를 재사용할 수 있습니다:

// 상수 위젯 예시
const Text('Hello') // 재사용 가능

// vs

Text('Hello') // 매번 새로운 인스턴스 생성

2. 불필요한 상태 업데이트 방지

setState()를 호출할 때는 실제로 상태가 변경되었는지 확인합니다:

// 좋지 않은 예
void _updateValue(int newValue) {
  setState(() {
    _value = newValue; // 이전 값과 같더라도 항상 setState 호출
  });
}

// 좋은 예
void _updateValue(int newValue) {
  if (_value != newValue) { // 값이 변경된 경우에만 setState 호출
    setState(() {
      _value = newValue;
    });
  }
}

3. 위젯 분리

큰 위젯을 작은 위젯으로 분리하여 필요한 부분만 리빌드되도록 합니다:

// 좋지 않은 예: 전체 위젯이 다시 빌드됨
class WeatherApp extends StatefulWidget {
  @override
  _WeatherAppState createState() => _WeatherAppState();
}

class _WeatherAppState extends State<WeatherApp> {
  bool _isLoading = false;
  WeatherData? _weatherData;

  // ... 날씨 데이터 로드 로직 ...

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AppHeader(), // 재빌드할 필요 없음
        if (_isLoading)
          CircularProgressIndicator()
        else if (_weatherData != null)
          Column(
            children: [
              Text('${_weatherData!.temperature}°C'),
              Text(_weatherData!.description),
              Image.network(_weatherData!.iconUrl),
            ],
          ),
        SearchBar(onSearch: _loadWeather), // 재빌드할 필요 없음
      ],
    );
  }
}

// 좋은 예: 필요한 위젯만 분리하여 재빌드
class WeatherApp extends StatefulWidget {
  @override
  _WeatherAppState createState() => _WeatherAppState();
}

class _WeatherAppState extends State<WeatherApp> {
  bool _isLoading = false;
  WeatherData? _weatherData;

  // ... 날씨 데이터 로드 로직 ...

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const AppHeader(), // const로 선언하여 재사용
        WeatherDisplay(
          isLoading: _isLoading,
          weatherData: _weatherData,
        ),
        SearchBar(onSearch: _loadWeather), // 검색 로직만 전달
      ],
    );
  }
}

// 분리된 날씨 표시 위젯
class WeatherDisplay extends StatelessWidget {
  final bool isLoading;
  final WeatherData? weatherData;

  const WeatherDisplay({
    Key? key,
    required this.isLoading,
    this.weatherData,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (isLoading) {
      return CircularProgressIndicator();
    } else if (weatherData != null) {
      return Column(
        children: [
          Text('${weatherData!.temperature}°C'),
          Text(weatherData!.description),
          Image.network(weatherData!.iconUrl),
        ],
      );
    } else {
      return Text('날씨 정보가 없습니다');
    }
  }
}

결론

Stateless 위젯과 Stateful 위젯은 Flutter UI 구성의 기본 요소입니다. 각 위젯 유형의 특성과 적절한 사용 사례를 이해하면 더 효율적이고 유지 관리가 쉬운 애플리케이션을 개발할 수 있습니다.

  • Stateless 위젯은 간단하고 효율적이며, 정적인 UI 요소에 적합합니다.
  • Stateful 위젯은 동적인 UI와 사용자 상호작용이 필요한 경우에 사용합니다.
  • 상태 관리는 위젯의 복잡성과 성능에 직접적인 영향을 미칩니다.
  • 위젯 분리와 const 생성자는 성능 최적화에 도움이 됩니다.

다음 장에서는 위젯 트리의 구조와 작동 방식에 대해 더 자세히 알아보겠습니다.