Flutter에서 키(keys)란 무엇이며 왜 사용되나요?

질문

Flutter에서 키(keys)의 개념과 중요성, 그리고 언제 사용해야 하는지에 대해 설명해주세요.

답변

Flutter에서 키(Key)는 위젯 식별을 위한 중요한 메커니즘으로, 효율적인 위젯 트리 관리와 상태 보존에 필수적인 요소입니다. 키의 개념과 사용법, 중요성을 자세히 살펴보겠습니다.

1. 키(Key)의 개념

Flutter에서 키는 위젯의 인스턴스를 고유하게 식별하는 객체입니다. 모든 위젯은 선택적으로 key 속성을 가질 수 있으며, 이는 Widget 클래스의 생성자에서 첫 번째 매개변수로 전달됩니다.

// 키의 기본 사용법
Widget build(BuildContext context) {
  return Container(
    key: ValueKey('unique-container'),
    // 다른 속성들...
  );
}

2. 키의 종류

Flutter에서는 다양한 유형의 키를 제공합니다:

2.1 ValueKey

가장 일반적인 키 유형으로, 단일 값을 기반으로 위젯을 식별합니다.

ValueKey<String>('unique-string')
ValueKey<int>(42)

2.2 ObjectKey

객체의 ID를 사용하여 위젯을 식별합니다. 객체가 같은지 확인하기 위해 == 연산자를 사용합니다.

class User {
  final String id;
  final String name;

  User({required this.id, required this.name});
}

// 사용자 객체 자체를 키로 사용
ObjectKey(user)

2.3 UniqueKey

생성될 때마다 고유한 키를 생성합니다. 동적으로 고유성이 필요한 경우 유용합니다.

UniqueKey() // 항상 새로운 고유 키 생성

2.4 GlobalKey

가장 강력한 키 유형으로, 애플리케이션의 어디서나 위젯에 직접 액세스할 수 있게 해줍니다. 또한 위젯의 상태와 BuildContext에도 접근할 수 있습니다.

// GlobalKey 선언
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

// GlobalKey 사용
Form(
  key: _formKey,
  child: Column(
    children: [
      // 폼 필드들...
    ],
  ),
)

// 다른 곳에서 폼 상태에 접근
_formKey.currentState?.validate();

2.5 LocalKey

ValueKey, ObjectKey, UniqueKey는 모두 LocalKey의 하위 클래스로, 위젯 트리의 특정 위치에서만 고유합니다.

3. 키가 필요한 상황

3.1 동적 리스트에서 위젯 식별

동적 리스트에서 항목이 추가, 제거, 재정렬되는 경우, 키를 사용하여 각 항목을 고유하게 식별해야 합니다.

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      key: ValueKey(items[index].id), // 고유 ID를 키로 사용
      title: Text(items[index].title),
    );
  },
)

키를 사용하지 않으면 Flutter는 위치만으로 위젯을 식별하게 되어, 리스트가 변경될 때 다음과 같은 문제가 발생할 수 있습니다:

  1. 잘못된 상태 보존
  2. 불필요한 위젯 재생성
  3. 애니메이션 효과 오작동

3.2 상태 보존이 필요한 위젯

사용자 입력이나 중요한 상태를 포함하는 위젯이 트리에서 이동하면, 키를 사용하여 상태를 보존해야 합니다.

class TodoItem extends StatefulWidget {
  final Todo todo;

  TodoItem({
    Key? key,  // 키가 없으면 이동 시 상태 손실
    required this.todo,
  }) : super(key: key);

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

3.3 GlobalKey를 사용한 위젯 접근

특정 위젯에 접근하여 메서드를 호출하거나 상태를 읽어야 하는 경우 GlobalKey를 사용합니다.

// 폼 검증
final _formKey = GlobalKey<FormState>();

// 스크롤 컨트롤
final _scrollKey = GlobalKey();
_scrollKey.currentContext?.findRenderObject()?.showOnScreen();

// 스낵바 표시
final _scaffoldKey = GlobalKey<ScaffoldState>();
_scaffoldKey.currentState?.showSnackBar(SnackBar(content: Text('메시지')));

4. 키의 작동 방식

Flutter의 위젯 재조정(reconciliation) 알고리즘은 다음과 같이 작동합니다:

  1. 키가 없는 경우: 위젯의 타입과 위치만 비교
  2. 키가 있는 경우: 타입, 키, 위치를 모두 비교

키가 있으면 Flutter는 위젯이 움직이더라도 해당 위젯을 추적할 수 있어 상태를 보존하고 불필요한 재구축을 방지합니다.

5. 키 사용 시 주의사항

5.1 모든 위젯에 키가 필요하지 않음

정적 UI의 경우, 대부분의 위젯에는 키가 필요하지 않습니다. 불필요한 키 사용은 오히려 성능에 부담을 줄 수 있습니다.

5.2 리스트 항목 키 선택

리스트 항목의 키는 다음 조건을 충족해야 합니다:

  • 고유성: 리스트 내에서 항목을 고유하게 식별
  • 안정성: 항목이 변경되어도 동일한 키 유지
  • 예측 가능성: 같은 항목에 대해 항상 같은 키 제공
// 좋은 예: 데이터의 고유 ID 사용
ListView.builder(
  itemBuilder: (context, index) => MyWidget(
    key: ValueKey(items[index].id),
    item: items[index],
  ),
)

// 나쁜 예: 인덱스 사용 (항목 순서가 바뀌면 문제 발생)
ListView.builder(
  itemBuilder: (context, index) => MyWidget(
    key: ValueKey(index),  // 항목 순서가 바뀌면 키도 바뀜
    item: items[index],
  ),
)

5.3 GlobalKey 사용 최소화

GlobalKey는 강력하지만 성능 비용이 높습니다. 꼭 필요한 경우에만 사용하세요:

  • 폼 검증
  • 위젯 액세스가 정말 필요한 경우
  • 위젯을 동적으로 분리하고 다시 첨부하는 경우

6. 실제 사례: 키를 사용해야 하는 상황

6.1 항목 재정렬 시 상태 보존

class ReorderableItemList extends StatefulWidget {
  @override
  _ReorderableItemListState createState() => _ReorderableItemListState();
}

class _ReorderableItemListState extends State<ReorderableItemList> {
  List<Item> items = List.generate(
    10,
    (index) => Item(id: '$index', name: 'Item $index'),
  );

  @override
  Widget build(BuildContext context) {
    return ReorderableListView(
      onReorder: (oldIndex, newIndex) {
        setState(() {
          if (oldIndex < newIndex) {
            newIndex -= 1;
          }
          final item = items.removeAt(oldIndex);
          items.insert(newIndex, item);
        });
      },
      children: items.map((item) {
        // 키가 없으면 재정렬 시 상태 손실 발생
        return ItemTile(
          key: ValueKey(item.id),
          item: item,
        );
      }).toList(),
    );
  }
}

class ItemTile extends StatefulWidget {
  final Item item;

  ItemTile({Key? key, required this.item}) : super(key: key);

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

class _ItemTileState extends State<ItemTile> {
  bool isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(widget.item.name),
      trailing: IconButton(
        icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
        onPressed: () {
          setState(() {
            isExpanded = !isExpanded;
          });
        },
      ),
      subtitle: isExpanded ? Text('추가 정보...') : null,
    );
  }
}

6.2 애니메이션 리스트에서 항목 제거

class AnimatedListExample extends StatefulWidget {
  @override
  _AnimatedListExampleState createState() => _AnimatedListExampleState();
}

class _AnimatedListExampleState extends State<AnimatedListExample> {
  final List<Item> items = List.generate(
    10,
    (index) => Item(id: '$index', name: 'Item $index'),
  );

  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();

  void _removeItem(int index) {
    final removedItem = items[index];

    _listKey.currentState?.removeItem(
      index,
      (context, animation) => SizeTransition(
        sizeFactor: animation,
        child: ItemTile(
          // 제거된 항목에도 고유 키 유지
          key: ValueKey(removedItem.id),
          item: removedItem,
        ),
      ),
    );

    items.removeAt(index);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedList(
      key: _listKey,
      initialItemCount: items.length,
      itemBuilder: (context, index, animation) {
        return SizeTransition(
          sizeFactor: animation,
          child: Dismissible(
            // Dismissible에도 키 필요
            key: ValueKey(items[index].id),
            onDismissed: (_) => _removeItem(index),
            child: ItemTile(
              key: ValueKey(items[index].id),
              item: items[index],
            ),
          ),
        );
      },
    );
  }
}

7. 디버깅 키 관련 문제

키 관련 문제를 디버깅하는 방법:

7.1 중복 키 감지

Flutter는 같은 위치에서 중복 키를 사용하면 경고를 표시합니다:

flutter: The following assertion was thrown building MyWidget:
flutter: Duplicate keys found.
flutter: If multiple keyed widgets exist as children of another widget, they must have unique keys.

7.2 키 관련 성능 프로파일링

Flutter DevTools의 Performance 탭을 사용하여 위젯 재구축을 분석하고, 키가 있거나 없는 경우의 차이를 확인할 수 있습니다.

8. 고급 키 사용 패턴

8.1 PageStorageKey

스크롤 위치를 자동으로 저장하고 복원하기 위해 사용됩니다:

ListView.builder(
  key: PageStorageKey('my-list-view'),
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)

8.2 RepaintBoundary와 키

특정 위젯의 리페인팅을 격리하여 성능 향상:

RepaintBoundary(
  key: _repaintKey,
  child: MyExpensiveWidget(),
)

// 나중에 이미지로 캡처할 수 있음
RenderRepaintBoundary? boundary = _repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
ui.Image image = await boundary?.toImage();

결론

Flutter에서 키(Key)는 위젯의 ID 카드와 같은 역할을 합니다. 키는 위젯 트리가 변경될 때 Flutter가 위젯을 효율적으로 식별하고 그 상태를 관리할 수 있게 해줍니다. 모든 위젯에 키가 필요한 것은 아니지만, 동적 리스트, 재정렬 가능한 항목, 상태 보존이 필요한 위젯에는 적절한 키를 사용하는 것이 중요합니다.

키를 올바르게 사용하면 다음과 같은 이점이 있습니다:

  1. 위젯 상태 보존
  2. 애니메이션 및 전환 효과 개선
  3. 불필요한 위젯 재구축 방지
  4. 효율적인 위젯 트리 업데이트

키를 사용할 때는 항상 그 목적을 명확히 이해하고, 상황에 맞는 적절한 키 유형을 선택하는 것이 중요합니다. 특히 GlobalKey는 강력하지만 성능 비용이 높으므로 꼭 필요한 경우에만 사용해야 합니다.

results matching ""

    No results matching ""