Flutter에서 Key 위젯의 역할과 종류는 무엇인가요?

질문

Flutter에서 Key의 역할과 다양한 종류의 Key(ValueKey, ObjectKey, UniqueKey, GlobalKey 등)에 대해 설명해주세요. 또한 실제로 언제 사용해야 하는지 예시와 함께 알려주세요.

답변

Flutter에서 Key는 위젯 트리에서 위젯의 상태를 보존하고 식별하는 중요한 역할을 합니다. 기본적으로 Flutter는 위젯의 타입과 위치를 기반으로 위젯을 식별하지만, 동적인 UI에서는 이것만으로 충분하지 않을 때가 있습니다. 이럴 때 Key가 필요합니다.

1. Key의 기본 개념과 역할

Flutter의 위젯 트리는 빌드마다 재구성됩니다. 이 과정에서 Flutter는 이전 빌드와 현재 빌드 사이에 위젯이 어떻게 변했는지 판단해야 합니다. Key는 이 과정에서 다음과 같은 역할을 합니다:

  1. 위젯 식별: 같은 유형의 여러 위젯 중에서 특정 위젯을 고유하게 식별
  2. 상태 보존: 위젯이 위치를 바꾸거나 목록에서 재정렬될 때 상태를 유지
  3. 위젯 참조: GlobalKey를 사용하여 위젯 트리의 어느 곳에서든 특정 위젯에 접근

2. Key의 종류

Flutter에서 제공하는 다양한 Key 클래스들은 각각 특정 사용 사례에 맞게 설계되어 있습니다.

2.1 LocalKey

LocalKey는 같은 부모 아래에서만 위젯을 식별합니다. 주로 목록이나 컬렉션 내에서 사용됩니다.

2.1.1 ValueKey

ValueKey는 단일 값(문자열, 숫자 등)을 기반으로 위젯을 식별합니다.

ValueKey(value)

값 자체가 고유하고 변하지 않는 경우에 적합합니다.

2.1.2 ObjectKey

ObjectKey는 객체의 identity(동일성)를 기반으로 위젯을 식별합니다. 객체의 hashCode== 연산자를 사용하여 비교합니다.

ObjectKey(object)

여러 속성을 가진 복잡한 객체를 식별할 때 유용합니다.

2.1.3 UniqueKey

UniqueKey는 생성될 때마다 고유한 키를 생성합니다.

UniqueKey()

완전히 무작위적이고 예측할 수 없는 키가 필요할 때 사용합니다.

2.2 GlobalKey

GlobalKey는 전체 앱에서 위젯을 고유하게 식별합니다. 위젯 트리 어디서든 접근할 수 있고, 위젯의 상태와 같은 정보에도 접근할 수 있습니다.

GlobalKey()
GlobalKey<State<StatefulWidget>>()

특정 위젯의 상태나 메서드에 접근해야 할 때 사용합니다.

3. 언제 어떤 Key를 사용해야 하는가?

3.1 ValueKey 사용 사례

고유한 ID나 문자열이 있을 때 사용합니다. 예를 들어, API에서 가져온 항목 목록을 표시할 때:

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

여기서 각 아이템은 고유한 ID를 가지고 있어, 목록이 변경되더라도 Flutter가 각 위젯을 올바르게 추적할 수 있습니다.

3.2 ObjectKey 사용 사례

객체 자체가 고유하지만 별도의 ID가 없을 때 사용합니다:

class Task {
  final String title;
  final bool isCompleted;

  Task(this.title, this.isCompleted);
}

// 사용 예:
ListView(
  children: tasks.map((task) =>
    TaskTile(
      key: ObjectKey(task), // 객체 자체를 키로 사용
      task: task,
    ),
  ).toList(),
)

객체의 속성들이 함께 고유성을 결정할 때 유용합니다.

3.3 UniqueKey 사용 사례

위젯이 재생성될 때마다 강제로 새로운 상태를 생성하고 싶을 때 사용합니다:

ElevatedButton(
  onPressed: () {
    setState(() {
      // 위젯에 새 키를 할당하여 완전히 재생성
      refreshKey = UniqueKey();
    });
  },
  child: Text('새로고침'),
),

Container(
  key: refreshKey, // 버튼을 누를 때마다 새로운 키 할당
  child: MyWidget(),
)

이 방법은 위젯을 강제로 재구축하여 초기 상태로 되돌리고 싶을 때 사용합니다.

3.4 GlobalKey 사용 사례

위젯 트리의 다른 부분에서 위젯에 접근하거나 위젯의 상태에 접근할 때 사용합니다:

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

// 사용
Form(
  key: formKey,
  child: Column(
    children: [
      TextFormField(
        validator: (value) => value!.isEmpty ? '필수 입력 항목입니다' : null,
      ),
      ElevatedButton(
        onPressed: () {
          // 폼 유효성 검사 - 다른 위젯에서도 접근 가능
          if (formKey.currentState!.validate()) {
            // 폼 제출 로직
          }
        },
        child: Text('제출'),
      ),
    ],
  ),
)

GlobalKey는 강력하지만 성능 비용이 있으므로 꼭 필요한 경우에만 사용해야 합니다.

4. 실제 사용 시나리오 및 예제

4.1 동적 목록에서 위젯 상태 유지하기

class TodoApp extends StatefulWidget {
  @override
  _TodoAppState createState() => _TodoAppState();
}

class _TodoAppState extends State<TodoApp> {
  List<Todo> todos = [
    Todo(id: '1', title: '우유 사기', completed: false),
    Todo(id: '2', title: '책 읽기', completed: true),
    Todo(id: '3', title: '운동하기', completed: false),
  ];

  void reorderTodos() {
    setState(() {
      // 목록 순서 변경
      todos = List.from(todos)..shuffle();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('할 일 목록')),
      body: ListView(
        children: todos.map((todo) =>
          TodoItem(
            key: ValueKey(todo.id), // 고유 ID를 키로 사용
            todo: todo,
            onToggle: (bool isCompleted) {
              setState(() {
                todo.completed = isCompleted;
              });
            },
          ),
        ).toList(),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: reorderTodos,
        child: Icon(Icons.shuffle),
      ),
    );
  }
}

class Todo {
  final String id;
  final String title;
  bool completed;

  Todo({required this.id, required this.title, this.completed = false});
}

class TodoItem extends StatefulWidget {
  final Todo todo;
  final ValueChanged<bool> onToggle;

  TodoItem({Key? key, required this.todo, required this.onToggle}) : super(key: key);

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

class _TodoItemState extends State<TodoItem> {
  late TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: widget.todo.title);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Checkbox(
        value: widget.todo.completed,
        onChanged: (bool? value) {
          widget.onToggle(value ?? false);
        },
      ),
      title: TextField(controller: _controller),
    );
  }
}

위 예제에서 Key를 사용하지 않으면 목록을 재정렬할 때 텍스트 필드의 내용이 섞이게 됩니다. ValueKey를 사용함으로써 각 TodoItem의 상태가 올바르게 유지됩니다.

4.2 Form 유효성 검사에 GlobalKey 사용하기

class LoginScreen extends StatefulWidget {
  @override
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('로그인')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Form(
          key: _formKey, // GlobalKey를 폼에 할당
          child: Column(
            children: [
              TextFormField(
                controller: _emailController,
                decoration: InputDecoration(labelText: '이메일'),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return '이메일을 입력하세요';
                  }
                  if (!value.contains('@')) {
                    return '유효한 이메일 주소를 입력하세요';
                  }
                  return null;
                },
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _passwordController,
                decoration: InputDecoration(labelText: '비밀번호'),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return '비밀번호를 입력하세요';
                  }
                  if (value.length < 6) {
                    return '비밀번호는 6자 이상이어야 합니다';
                  }
                  return null;
                },
              ),
              SizedBox(height: 24),
              ElevatedButton(
                onPressed: () {
                  // 폼 유효성 검사
                  if (_formKey.currentState!.validate()) {
                    // 로그인 로직 실행
                    login(_emailController.text, _passwordController.text);
                  }
                },
                child: Text('로그인'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void login(String email, String password) {
    // 로그인 로직
    print('Email: $email, Password: $password');
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

GlobalKey를 사용하여 폼의 validate() 메서드에 접근하고, 모든 필드에 대한 유효성 검사를 한 번에 수행할 수 있습니다.

4.3 컬렉션에서 항목 필터링 및 재정렬하기

class FilterableList extends StatefulWidget {
  @override
  _FilterableListState createState() => _FilterableListState();
}

class _FilterableListState extends State<FilterableList> {
  List<Product> allProducts = [
    Product(id: '1', name: '노트북', price: 1200000, category: '전자제품'),
    Product(id: '2', name: '스마트폰', price: 800000, category: '전자제품'),
    Product(id: '3', name: '티셔츠', price: 30000, category: '의류'),
    Product(id: '4', name: '청바지', price: 50000, category: '의류'),
    Product(id: '5', name: '커피머신', price: 300000, category: '주방용품'),
  ];

  String selectedCategory = '전체';
  bool sortByPrice = false;

  List<Product> get filteredProducts {
    List<Product> result = List.from(allProducts);

    // 카테고리 필터링
    if (selectedCategory != '전체') {
      result = result.where((p) => p.category == selectedCategory).toList();
    }

    // 가격순 정렬
    if (sortByPrice) {
      result.sort((a, b) => a.price.compareTo(b.price));
    }

    return result;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('상품 목록')),
      body: Column(
        children: [
          // 필터 컨트롤
          Padding(
            padding: EdgeInsets.all(8.0),
            child: Row(
              children: [
                DropdownButton<String>(
                  value: selectedCategory,
                  items: [
                    '전체',
                    '전자제품',
                    '의류',
                    '주방용품',
                  ].map((category) =>
                    DropdownMenuItem(
                      value: category,
                      child: Text(category),
                    ),
                  ).toList(),
                  onChanged: (value) {
                    setState(() {
                      selectedCategory = value!;
                    });
                  },
                ),
                SizedBox(width: 16),
                Row(
                  children: [
                    Checkbox(
                      value: sortByPrice,
                      onChanged: (value) {
                        setState(() {
                          sortByPrice = value ?? false;
                        });
                      },
                    ),
                    Text('가격순 정렬'),
                  ],
                ),
              ],
            ),
          ),

          // 상품 목록
          Expanded(
            child: ListView.builder(
              itemCount: filteredProducts.length,
              itemBuilder: (context, index) {
                final product = filteredProducts[index];
                return ProductTile(
                  key: ValueKey(product.id), // 고유 ID를 키로 사용
                  product: product,
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

class Product {
  final String id;
  final String name;
  final int price;
  final String category;

  Product({
    required this.id,
    required this.name,
    required this.price,
    required this.category
  });
}

class ProductTile extends StatefulWidget {
  final Product product;

  ProductTile({Key? key, required this.product}) : super(key: key);

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

class _ProductTileState extends State<ProductTile> {
  bool isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.all(8.0),
      child: Column(
        children: [
          ListTile(
            title: Text(widget.product.name),
            subtitle: Text('${widget.product.price}원'),
            trailing: IconButton(
              icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
              onPressed: () {
                setState(() {
                  isExpanded = !isExpanded;
                });
              },
            ),
          ),
          if (isExpanded)
            Padding(
              padding: EdgeInsets.all(16.0),
              child: Text('카테고리: ${widget.product.category}\n상세 설명이 여기에 표시됩니다.'),
            ),
        ],
      ),
    );
  }
}

위 예제에서는 ValueKey를 사용하여 상품 필터링 및 정렬 시에도 각 ProductTile의 확장 상태가 유지되도록 합니다.

5. Key 사용 시 주의사항

  1. 필요할 때만 사용하기: Key는 성능에 영향을 줄 수 있으므로 필요한 경우에만 사용해야 합니다.

  2. GlobalKey 남용하지 않기: GlobalKey는 특히 성능 비용이 크므로 꼭 필요한 경우(폼, 네비게이션 등)에만 사용해야 합니다.

  3. 고유성 보장하기: Key는 같은 부모 위젯 아래에서 고유해야 합니다. 특히 동적인 목록에서는 중복된 키를 사용하지 않도록 주의해야 합니다.

  4. 위젯 생명주기 고려하기: Key를 변경하면 위젯의 상태가 초기화될 수 있으므로, 상태를 유지해야 하는 경우 신중하게 사용해야 합니다.

결론

Flutter의 Key는 중요한 위젯 식별 메커니즘으로, 특히 동적인 UI에서 위젯 상태를 안정적으로 관리하는 데 필수적입니다. Key의 종류별 특성을 이해하고 적절한 상황에서 올바르게 사용하면, UI 상태 관리가 훨씬 용이해지고 예측 가능한 앱 동작을 보장할 수 있습니다.

  • ValueKey: 고유한 값(ID, 문자열 등)이 있을 때
  • ObjectKey: 복잡한 객체를 식별할 때
  • UniqueKey: 매번 새로운 식별자가 필요할 때
  • GlobalKey: 위젯 트리 어디서든 특정 위젯에 접근이 필요할 때

각 상황에 맞는 적절한 Key를 선택하여 Flutter 앱의 UI를 더 견고하게 구현하세요.

results matching ""

    No results matching ""