[React] 공식문서 톺아보기 5
Part 5. State의 보존과 초기화, 그리고 Key의 역할
React는 컴포넌트 트리를 비교할 때 위치와 Key를 기준으로 State를 유지하거나 파괴한다. 이 메커니즘을 이해하면 불필요한 리렌더링을 막거나, 반대로 의도적으로 State를 초기화 가능하다.
1. 위치에 따른 State 보존
질문: 화면에 보이는 컴포넌트는 똑같은데 왜 State가 초기화되는가?
조건문 if-else를 사용하여 isActive가 참일 때는 <div> 안에, 거짓일 때는 <section> 안에 같은 Counter 컴포넌트를 렌더링하도록 작성했다. 단순히 감싸는 태그만 바뀌었을 뿐 내부의 Counter는 동일하므로 점수(State)가 유지될 것이라 예상했지만, 토글할 때마다 점수가 0으로 초기화된다.
답변: UI 트리에서의 위치가 달라지면 다른 컴포넌트다.
React는 JSX를 분석하여 UI 트리를 만든다. 이때 React는 컴포넌트의 종류와 그 컴포넌트가 트리의 어디에 위치해 있는지를 보고 동일성을 판단한다.
<div> 태그가 <section> 태그로 바뀌면, React는 이를 완전히 다른 트리로 인식한다. 이전 트리의 루트였던 <div>와 그 아래에 있던 모든 자식 컴포넌트(Counter 포함)는 파괴되고, 새로운 <section>과 새로운 Counter가 생성된다. 따라서 기존에 저장된 State도 함께 메모리에서 삭제된다. State를 유지하고 싶다면 DOM 구조를 유지한 채 속성만 변경해야 한다.
2. 렌더 트리와 가상 슬롯
질문: 조건부 렌더링에서 앞의 요소가 사라지면 뒤의 요소가 당겨지지 않을까?
{isPlayerA && <Counter person="Taylor" />}
{!isPlayerA && <Counter person="Sarah" />}
위 코드에서 isPlayerA가 참이면 첫 번째 줄이 렌더링되고, 거짓이면 두 번째 줄이 렌더링된다. 화면상으로는 항상 같은 자리에 하나의 Counter만 보인다. 따라서 React가 이를 같은 위치의 컴포넌트로 인식하여 State를 공유할 것이라 생각할 수 있다 (내가 그랬다). 하지만 실제로는 서로 다른 컴포넌트로 취급되어 State가 공유되지 않는다.
답변: false도 자리를 차지하는 노드다.
React가 보는 렌더 트리에는 컴포넌트뿐만 아니라 false, null, undefined 같은 값들도 노드로 존재하여 자리를 차지한다. 이를 배열의 인덱스로 생각하면 이해가 쉽다.
- isPlayerA가 참일 때:
[0번: Counter, 1번: false] - isPlayerA가 거짓일 때:
[0번: false, 1번: Counter]
false는 화면에 아무것도 그리지 말라는 명령일 뿐, 0번 인덱스라는 자리는 그대로 유지한다. 따라서 두 번째 줄의 Counter가 첫 번째 줄로 이동하지 않는다. React는 0번 인덱스의 Counter를 파괴하고, 1번 인덱스에 새로운 Counter를 생성한다. 서로 다른 주소지에 사는 별개의 컴포넌트이므로 State는 공유되지 않는다.
3. Key의 역할: 위치 정보 덮어쓰기
질문: 같은 위치에 있지만 강제로 State를 초기화하고 싶다면?
때로는 탭을 전환하거나 프로필을 바꿀 때, 같은 위치에 같은 컴포넌트를 사용하더라도 이전 State를 지우고 싶을 때가 있다. 이때 key 속성을 사용한다.
답변: 순서 대신 Key를 유일한 식별자로 사용한다.
React 요소에 key를 부여하면, React는 더 이상 배열의 순서(인덱스)를 보지 않고 key 값을 기준으로 대상을 식별한다.
같은 0번 인덱스에 있더라도, 이전 렌더링의 key="Taylor"와 현재 렌더링의 key="Sarah"가 다르다면 React는 이를 다른 컴포넌트로 판단한다. 즉시 기존 컴포넌트를 파괴하고 새로운 컴포넌트를 생성하여 State를 초기화한다. 단, key가 같더라도 컴포넌트의 종류(Type)가 다르면 무조건 파괴된다. 동일성 판단의 공식은 ‘컴포넌트 종류 일치’와 ‘Key 일치’ 두 가지 조건이 모두 충족되어야 한다.
4. Key의 범위와 전달
질문: Key는 전역적으로 유일해야 하는가? 그리고 컴포넌트 내부에서 Key 값을 사용할 수 있는가?
데이터베이스의 ID처럼 key도 전체 애플리케이션에서 유일해야 한다고 생각하거나, 자식 컴포넌트에서 props.key로 접근하여 ID를 사용하려다 실패하는 경우가 있다.
답변: 형제 사이에서의 유일성, 그리고 React의 예약어.
key는 부모 컴포넌트 안에서 형제 요소들끼리만 겹치지 않으면 된다. 서로 다른 배열이나 컴포넌트에 있는 key끼리는 값이 같아도 무방하다.
또한 key는 React가 컴포넌트를 관리하기 위해 사용하는 예약된 속성이다. React는 컴포넌트를 생성하기 전에 key 값을 추출하여 내부적으로 사용하고, 자식 컴포넌트에게 전달하는 props 객체에서는 제외한다. 따라서 자식 컴포넌트 내부에서는 props.key를 조회해도 undefined가 나온다. 만약 ID 값이 필요하다면 key={id} id={id}와 같이 별도의 속성을 하나 더 만들어 명시적으로 전달해야 한다.
5. 인덱스(Index)를 Key로 사용할 때의 부작용
[질문: 데이터의 순서를 바꿨는데 State는 제자리에 머물러 있다.] 배열을 렌더링할 때 고유한 ID가 없어 편의상 map 함수의 인덱스(i)를 key로 사용하는 경우가 많다. 그런데 배열의 순서를 뒤집거나 항목을 삭제했을 때, 항목의 내용은 바뀌었는데 체크박스나 펼치기/접기 같은 State는 엉뚱한 위치에 남아있는 버그를 발견하게 된다.
[답변: Key가 변하지 않으면 State도 이동하지 않는다.] 인덱스를 key로 사용하면(key={i}), React는 데이터가 아니라 ‘리스트의 몇 번째 줄인가’를 식별자로 삼는다.
배열이 [Alice, Bob]에서 [Bob, Alice]로 순서가 바뀌었다고 가정하자.
- 첫 번째 줄(Key=0): 내용은 Bob으로 바뀌었지만, React는 여전히 Key가 0인 컴포넌트로 인식한다. 따라서 기존 첫 번째 줄에 있던 ‘펼쳐짐’ 상태를 그대로 유지한다.
- 두 번째 줄(Key=1): 내용은 Alice로 바뀌었지만, 기존 두 번째 줄의 ‘닫힘’ 상태를 유지한다.
결과적으로 데이터(Bob)는 위로 올라갔지만, 상태(펼쳐짐)는 따라오지 못하고 제자리에 남는 불일치가 발생한다. 순서가 변하거나 항목이 추가/삭제되는 배열에서는 절대로 인덱스를 Key로 사용해서는 안 되며, 데이터 고유의 ID(contact.id)를 사용해야 React가 State를 데이터와 함께 이동시킨다.
6. 상태의 적절한 위치는 어디인가
각 항목의 상태를 부모 컴포넌트로 끌어올려야 하는가? 하는 의문이 공식 문서 내 챌린지 문제를 보다가 들었다. 리스트 UI를 구현할 때, 각 항목의 열림/닫힘 같은 상태를 자식 컴포넌트 내부에 두는 것이 좋은지, 아니면 부모 컴포넌트에서 통합 관리하는 것이 좋은지 결정하기 어려웠다.
결론은, 항목들이 독립적인지, 연동되는지에 따라 결정한다. 기술적인 문제라기보다 UI의 동작 방식에 따른 설계의 문제였다.
만약 각 항목이 서로에게 영향을 주지 않고 독립적으로 작동해야 한다면 자식 컴포넌트가 스스로 useState를 가지는 것이 적절하다. 예를 들어 여러 개의 이메일 내용을 동시에 펼쳐서 비교해야 한다면, 각 항목은 자신의 상태를 각자 관리하면 된다. 이때 고유한 Key만 제대로 부여한다면 순서가 바뀌어도 상태는 안전하게 유지된다.
반면, 한 항목을 펼치면 다른 항목들이 자동으로 닫혀야 하는 아코디언 같은 UI라면 이야기가 다르다. 자식 컴포넌트들은 서로의 존재를 모르기 때문에, 부모 컴포넌트가 상태를 관리해야 한다. 부모가 현재 활성화된 ID 하나를 기억하고, 자식들에게는 현재 활성화 여부만 props로 내려주는 방식을 사용한다. 즉, 항목 간의 연동이나 제어가 필요한 경우에만 상태를 부모로 끌어올리는 것이 적절하다.
어떤 설계를 채택하든 동작하게 만들 수는 있다. 그렇지만 이게 유연한지, 직관적인지, 성능적으로는 어떤지 등등 각 상황에 적절한 설계를 하는 것에 방점이 찍힌 것이었다.