[Flutter] Key란 무엇인가?

2020. 3. 21. 23:18Mobile/Flutter

기본적으로 플러터의 위젯은 생성자에서 Key매개변수를 받을 수 있습니다. 하지만 그렇게 많이 사용되지는 않습니다. 위젯이 위젯트리에서 위치를 변경하더라도 Key는 상태정보를 유지합니다. 따라서 리스트의 컬렉션이 수정될 때 스크롤 위치를 기억하거나, 상태를 기억해야할 때 Key는 유용하게 사용될 수 있습니다.

이 포스트는 구글의 When to Use Key 비디오를 바탕으로 하는 내용입니다.

Key에 대한 상세정보

대부분의 경우 Key가 필요하지 않습니다. Key를 사용해도 특별한 문제가 없지만 별로 필요한 것도 아니고 불필요하게 공간만 차지할 뿐이죠. new 키워드를 사용한다거나, 혹은 변수타입을 양쪽변에 기재하는 것과 같은 불필요한 일입니다. (예: Map<Foo, Bar> aMap = Map<Foo, Bar>();)

그러나 Key를 사용하는 것이 필요할 때도 있습니다. 동일한 상태의 동일 위젯으로 구성된 컬렉션을 재정렬하거나, 추가/삭제하는 등의 작업을 한다면, 머지않아 Key를 사용하게 될 것입니다.

Key의 필요성을 설명하기 위해 간단한 앱을 소개합니다. 이 앱은 버튼을 누를 때 마다 위젯들이 위치를 교환하게 됩니다.

이 앱은 임의의 색상을 가진 2개의 StatelessWidget 타일을 Row 위젯으로 배치하고 있습니다. 그리고 PositionedTiles 위젯이 타일들을 변수에 담아두고 위치를 교환하는 동작을 수행 합니다. 하단에 위치한 FloatingActionButton을 누르면 2개의 타일을 교환하는 swapTiles() 메서드를 호출합니다. (코드URL)

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
 List<Widget> tiles = [
   StatelessColorfulTile(),
   StatelessColorfulTile(),
 ];

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: Row(children: tiles),
     floatingActionButton: FloatingActionButton(
         child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
   );
 }

 swapTiles() {
   setState(() {
     tiles.insert(1, tiles.removeAt(0));
   });
 }
}

class StatelessColorfulTile extends StatelessWidget {
 Color myColor = UniqueColorGenerator.getColor();
 @override
 Widget build(BuildContext context) {
   return Container(
       color: myColor, child: Padding(padding: EdgeInsets.all(70.0)));
 }
}

여기서 ColorfulTile 위젯을 StatefulWidget으로 변경하면, 버튼을 눌렀을 때 아무런 반응을 보이지 않게 됩니다. (코드URL)

List<Widget> tiles = [
   StatefulColorfulTile(),
   StatefulColorfulTile(),
];

...
class StatefulColorfulTile extends StatefulWidget {
 @override
 ColorfulTileState createState() => ColorfulTileState();
}

class ColorfulTileState extends State<ColorfulTile> {
 Color myColor;

 @override
 void initState() {
   super.initState();
   myColor = UniqueColorGenerator.getColor();
 }

 @override
 Widget build(BuildContext context) {
   return Container(
       color: myColor,
       child: Padding(
         padding: EdgeInsets.all(70.0),
       ));
 }
}

위에서 작성한 코드는 버튼을 눌러도 타일들이 위치를 변경하지 않으므로 버그가 있는 상태입니다. 문제를 해결하려면 StatefulWidget에 Key 매개변수를 추가해야합니다. 그러면 위젯들은 의도한대로 위치를 교환할 것입니다. (코드URL)

List<Widget> tiles = [
  StatefulColorfulTile(key: UniqueKey()), // 여기에 Key가 추가됨
  StatefulColorfulTile(key: UniqueKey()),
];

...
class StatefulColorfulTile extends StatefulWidget {
  StatefulColorfulTile({Key key}) : super(key: key);  // Key를 받는 생성자 구성
 
  @override
  ColorfulTileState createState() => ColorfulTileState();
}

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

Key는 업데이트되는 위젯의 하위에 StatefulWidget이 위치할 때 필요합니다. 만약 컬렉션에 포함된 모든 위젯 서브트리가 Stateless라면 Key가 필요하지 않습니다.

플러터에서 Key를 사용해야하는 상황에 대한 기술적인 내용은 이게 전부입니다. 아래에서는 왜 Key를 써야하는지 좀 더 자세하게 설명합니다.

Key가 필요한 이유

플러터는 내부적으로 모든 위젯 마다 Element를 생성합니다. 위젯 트리를 만드는 것처럼 Element 트리도 생성합니다. Element는 단순하게 구성되어 있습니다. 대응되는 위젯의 타입 정보와 자식 Element에 대한 참조를 가지고 있습니다. Element 트리는 플러터 앱의 골격으로 볼 수 있습니다. 앱의 구조를 보여주지만 자세한 정보는 원본 위젯에 대한 참조를 통해 확인할 수 있습니다.

위 예제에서 Row 위젯은 자식에 대한 정렬된 집합을 가지고 있습니다. Row 위젯의 자식인 Tile 위젯의 순서를 바꾸면 플러터는 Element 트리를 따라내려가면서 구조가 변경되었는지 확인합니다.

Row 위젯부터 시작해서 자식 위젯으로 이동하며 검사를 수행합니다. Element는 대응되는 위젯의 Type과 Key가 이전의 위젯과 동일한지 확인합니다. 만약 동일하다면 위젯에 대한 참조를 갱신합니다. StatelessWidget 버전에서는 Key를 가지지 않으므로 플러터는 단지 Type만 확인합니다. (만약 복잡하게 느껴지신다면 위의 그림을 참고해주세요.)

StatefulWidget의 경우 내부적인 Element 트리 구조가 약간 다릅니다. 이전과 동일하게 Element와 Widget이 존재하고, 거기에다가 State Object도 따로 존재합니다. 색상 정보는 위젯이 아니라 State Object에 저장되게 됩니다.

Stateful 타일에 Key를 지정하지 않았을 때를 생각해봅시다. 두 타일의 순서를 바꾸면 플러터는 Element 트리를 검사합니다. 먼저 Row 위젯의 Type을 검사하고, 동일하므로 참조를 업데이트 합니다. 첫번째 Tile Element도 자신의 Widget의 Type을 검사합니다. Type이 동일하므로 Widget의 참조를 업데이트합니다. 두번째 Tile Element에도 동일한 작업이 진행됩니다. 위젯의 순서가 변경되었음에도 Element 트리의 순서는 변경되지 않았고 Widget에 대한 참조만 업데이트되었습니다. 플러터는 위젯을 화면에 출력하기 위해 Element 트리와 State Object의 정보를 사용합니다. 따라서 위젯의 순서가 변경되었음에도 출력되는 화면은 변경되지 않습니다.

버그를 수정하기 위해 Stateful 타일에 Key 값을 추가한 경우에는 다르게 동작합니다. 위젯의 순서를 변경하면 Row 위젯은 이전과 동일하게 Type 검사를 진행합니다. Tile Element에서는 Element의 Key 값과 Widget의 Key 값을 비교하게 되고 서로 다르다는 것을 발견합니다. 플러터는 불일치가 발견된 해당 TileElement를 비활성화하고 Element 트리에서 삭제합니다.

그 다음 플러터는 불일치가 발견된 Row의 자식 Widget들을 탐색하면서 Key가 일치하는 Element를 찾기 시작합니다. 일치하는 것을 찾으면 해당 Widget으로 참조를 업데이트 합니다. 이제 플러터는 우리가 기대하는 결과를 보여줄 것입니다. 버튼을 누를 때마다 위젯의 위치를 변경하고 색상을 업데이트하게 됩니다.

요약하면 컬렉션에 포함된 Stateful 위젯의 순서나 개수를 변경할 때 Key가 중요한 역할을 합니다. 예제에서는 상태를 설명하기 위해 색상을 사용했지만, 실제상황에서 상태란 훨신 미묘합니다. 애니메이션 재생, 사용자가 입력한 정보, 스크롤 위치 등이 모두 상태와 관련이 있습니다.

Key를 어디에 위치시켜야 합니까?

답변 요약: Key를 앱에 추가해야한다면, 유지해야 하는 상태 정보가 있는 위젯 트리의 최상단에 추가해야 합니다.

사람들이 많이 하는 실수는, 해당 Stateful 위젯에 키를 추가하면 된다고 생각하는 것입니다. 그렇게 하는 것은 예측하지 않은 문제를 일으킬 수 있습니다. 이러한 종류의 문제를 보여주기 위해서 StateColorfulTile 위젯을 Padding 위젯으로 감싸고 Key는 StateColorfulTile에 할당했습니다. (코드URL)

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
  // 위젯 트리의 최상위 레벨에 키를 추가하는 것이 왜 필요한지 보여주기 위해
  // Padding (Stateless 위젯)을 추가하여 위젯 트리의 단계를 추가함
  List<Widget> tiles = [
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(key: UniqueKey()),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(key: UniqueKey()),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(children: tiles),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
    );
  }

  swapTiles() {
    setState(() {
      tiles.insert(1, tiles.removeAt(0));
    });
  }
}

class StatefulColorfulTile extends StatefulWidget {
  StatefulColorfulTile({Key key}) : super(key: key);
 
  @override
  ColorfulTileState createState() => ColorfulTileState();
}

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

이제 실행해보면 버튼을 클릭할 때마다 매번 새로운 색상으로 변하는 것을 볼 수 있습니다.

아래는 Padding 위젯이 추가된 WidgetTree와 ElementTree의 관계도 입니다.

자식의 위치를 바꾸게 되면 플러터의 'Element Widget 매칭 알고리즘'이 트리의 레벨 단위로 진행되게 됩니다. 아래 다이어그램은 한번에 하나의 레벨씩 작업이 진행되는 것을 보여주고 있습니다. 시각적으로 강조하기 위해 Padding 레벨에서 작업이 진행될 때는 Padding의 자식 위젯을 회색으로 보여주고있습니다. 먼저 첫 번째 레벨인 Padding 위젯에서 플러터는 모든 것이 일치하는 것을 발견하게 됩니다.

플러터는 트리의 두 번째 레벨로 내려가게 되고 타일 Element의 Key가 위젯과 불일치하는 것을 발견하게 됩니다. 따라서 타일 Element를 비활성화하고 연결을 끊습니다. 여기서 사용한 Key는 LocalKey 유형입니다. 이게 무슨 뜻이냐면, 플러터는 전체 트리 중 해당 레벨에서만 Key 값을 검색한다는 의미입니다.

플러터는 해당 레벨에서 동일 Key 값을 가진 Element를 찾지 못하게 되고, 새로운 Element를 생성한 후 새로운 State Object를 초기화합니다. 그 결과 위젯이 새로운 색상으로 변경되게 됩니다.

문제를 해결하기 위해 Key를 Padding 레벨에 추가해봅시다. (코드URL)

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
  List<Widget> tiles = [
    Padding(
      // 컬렉션 내에 존재하는 위젯 트리의 최상단에 키를 추가
      key: UniqueKey(), 
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(),
    ),
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(children: tiles),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
    );
  }

  swapTiles() {
    setState(() {
      tiles.insert(1, tiles.removeAt(0));
    });
  }
}

class StatefulColorfulTile extends StatefulWidget {
  StatefulColorfulTile({Key key}) : super(key: key);
 
  @override
  ColorfulTileState createState() => ColorfulTileState();
}

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

이제 플러터는 Padding 위젯 레벨에서 Key를 검색하게 되고, 동일한 Key를 찾아 연결을 올바르게 유지하게 됩니다.

어쩐 종류의 Key를 써야할까?

플러터 API 공급업체들은 우리가 선택할 수 있는 다양한 Key 클래스들을 제공하고 있습니다. 우리가 사용해야 할 Key의 유형은 Key를 필요로하는 아이템의 특성에 따라 달라집니다. 위젯에 저장하는 정보의 유형이 무엇인지 살펴보고 결정하세요. 여기서는 일단 몇가지 Key 유형에 대해 소개해 드립니다. : ValueKey, ObjectKey, UniqueKey, PageStorageKey, GlobalKey.

아래 To-do 리스트 앱을 고려해봅시다. 이 앱은 우선순위에 따라 항목의 순서를 바꿀 수 있고 일이 끝나면 항목을 삭제할 수 있습니다.

이 앱에서 각각의 To-do 항목의 텍스트는 리스트에서 중복되지 않는 고유한 상수라고 볼 수 있습니다. 이러한 경우에는 ValueKey가 적절한 후보가 될 수 있으며, To-do 항목의 텍스트가 Value로 사용 됩니다.

return TodoItem(
  key: ValueKey(todo.task),
  todo: todo,
  onDismissed: (direction) => _removeTodo(context, todo),
);

이제 다른 시나리오를 생각해봅시다. 각 사용자의 정보를 리스트로 출력하는 주소록 앱이 있습니다. 리스트의 각 Item 위젯들은 FirstName, LastName, BirthDay를 다루는 AddressBookEntry Object를 가지고 있습니다. 아마도 BirthDay, FirstName 등의 개별 정보는 리스트의 다른 Item과 중복될 수 있겠지만, FirstName+LastName+BirthDay을 조합하면 리스트에서 고유할 것입니다. 이러한 경우에는 ObjectKey가 가장 적절한 선택일 것입니다.

컬렉션에서 각 위젯들이 중복된 정보를 가지고 있거나 혹은 각 위젯들에게 유일한 Key를 직접 부여해야하는 경우라면 UniqueKey를 사용할 수 있습니다. 위의 타일 앱은 UniqueKey를 사용했습니다. 왜냐하면 각 타일의 색상이 유일하다고 확신할 수 없는 앱이기 때문입니다. UniqueKey를 사용할 때 주의해야할 점이 있습니다. 만약 UniqueKey를 Build 메서드내에 위치시킨다면, 플러터가 화면을 새롭게 그릴 때마다 Build 메서드가 실행되고 그때마다 새로운 Key가 생성될 것입니다. 이 경우에는 Key를 사용하는 이점이 사라질 것입니다.

비슷한 사례로 난수를 생성하여 Key에 할당하는 경우에도 문제가 될 수 있습니다. 플러터가 Build 메서드를 실행할 때마다 새로운 난수가 생성될 수 있고, 따라서 플러터가 화면을 그리는 프레임마다 Key가 달라질 것입니다. 이럴바에 차라리 Key를 처음부터 사용하지 않는 편이 더 나을지 모릅니다.

PageStorageKey는 사용자의 스크롤 위치를 저장하는데 특화된 키로서, 앱이 스크롤을 기억할 수 있게 해줍니다.

GlobalKey는 2가지 사용목적이 있습니다: 첫 번째는 위젯이 상태를 잃지 않으면서 부모를 바꿀 수 있도록 해줍니다. 두 번째는 특정 위젯의 정보를 완전히 다른 위젯트리에서 접근가능하게 해줄 수 있습니다. 첫번째 목적에 대한 예시로는, 서로 다른 2개의 화면에서 동일한 위젯을 동일한 상태를 유지하면서 보여주어야 할 때 GlobalKey를 사용하게 됩니다. 두번째의 경우에는 비밀번호는 검사하고 싶은데 해당 상태 정보를 트리의 다른 위젯과 공유하고 싶지 않을 때 사용할 수 있습니다. GlobalKey는 테스트를 진행할 때도 유용하게 사용됩니다. GlobalKey를 사용하여 특정 위젯과 그 상태정보를 추적하고 테스트할 수 있습니다.

항상 그런건 아니지만 GlobalKey는 전역 변수와 유사한 면이 있습니다. 대게는 상태를 공유하고 조회할 수 있는 더 나은 방법이 있습니다. 예를들어 InheritedWidget을 사용하거나 또는 Redux나 BLoC 패턴을 이용하는 것이 있습니다.

요약

요약하면 위젯 트리 간에 상태를 유지하고 싶을 때 Key를 사용할 수 있습니다. 이러한 상황은 리스트 같이 동일한 유형의 위젯 컬렉션을 수정하는 경우에 자주 발생합니다. 유지해야하는 위젯의 위젯트리 최상단에 Key를 추가하고, 위젯에 저장하는 데이터의 유형에 따라 적절한 Key의 유형을 선택하세요.

References