들어가며
React를 개발하다 보면 배열 요소에 key를 넣어야한다는 에러가 뜨곤합니다. 과거엔 단순히 배열에는 key를 넣어야 리렌더링이 발생하지 않는다 정도를 규칙으로 알고 있었습니다. 하지만 이 key의 존재 이유와 동작 방식을 깊이 이해한다면, React의 성능 최적화와 렌더링 과정을 좀 더 효과적으로 다룰 수 있습니다!
리액트 렌더링은 언제 일어날까요?
우선 렌더링이 언제 일어나는지부터 알아보겠습니다. React에서 렌더링은 크게 두 가지의 경우에 발생합니다. 바로 최초 렌더링과 리렌더링인데, 이게 React 렌더링의 전부입니다.
- 최초 렌더링 (Initial Rendering)
- 컴포넌트가 처음 마운트될 때 발생
- Virtual DOM 트리를 처음으로 생성
- 실제 DOM에 전체 트리를 반영
- 리렌더링 (Re-rendering)
- props나 state가 변경될 때
- 부모 컴포넌트가 리렌더링될 때
- context value가 변경될 때
- forceUpdate가 호출될 때
여기서 리렌더링의 첫 번째 조건인 props나 state가 변경될 때를 좀 더 깊게 알아보겠습니다. React에서 모든 속성은 props로 처리되지만, 그 처리 방식은 다를 수 있습니다. 아래와 같은 속성들이 props로 분류됩니다.
<Component
normalProp={value} // 1. 일반적인 props
key={uniqueId} // 2. 특별한 prop (React 내부 동작에 영향)
ref={someRef} // 3. 특별한 prop (React 내부 동작에 영향)
style={{ color: 'red' }} // 4. 객체 prop
onClick={() => {}} // 5. 함수 prop
className={`${dynamic}`} // 6. 동적 문자열 prop
>
<Child /> // 7. children prop
</Component>
하지만 여기서 key와 ref는 React Fiber에 의해 특별하게 처리되는데, 이는 렌더링 최적화와 밀접한 관련이 있습니다. 이번에는 key에 대한 부분을 중점으로 알아보도록 하겠습니다.
React Fiber와 Props의 처리
React Fiber는 컴포넌트의 변경을 추적하고 관리하는 내부 엔진입니다. Fiber는 다음과 같은 구조로 컴포넌트 정보를 관리합니다.
{
type: 'div', // 해당 Fiber 노드가 나타내는 요소의 타입 (div, span, 사용자 정의 컴포넌트 등)
key: null, // 요소의 고유 식별자. 형제 요소들 사이에서 비교할 때 사용
stateNode: null, // 실제 DOM 노드나 클래스 컴포넌트의 인스턴스를 참조
child: FiberNode, // 첫 번째 자식 Fiber 노드를 가리킴
sibling: FiberNode, // 다음 형제 Fiber 노드를 가리킴
return: FiberNode, // 부모 Fiber 노드를 가리킴 (작업이 끝난 후 '돌아갈' 노드)
pendingProps: {}, // 이번 렌더링에서 적용될 새로운 props
memoizedProps: {}, // 이전 렌더링에서 사용된 props (비교를 위해 저장)
}
이러한 구조를 통해 React는 컴포넌트 트리를 연결 리스트 형태로 관리하고, 효율적인 렌더링 작업을 수행하고 있습니다. 여기서 우리가 주목할 부분은 key인데요. 일반 props와 이 key 속성은 어떻게 다르게 처리되는걸까요? 왜 고유 식별자는 형제 요소들 사이에서 비교할 때만 사용하게 되는 걸까요? 천천히 이 질문에 대해 대답해보겠습니다.
일단, 일반 props의 값이 변경된다면 같은 컴포넌트에서 값만 업데이트됩니다.
// 일반 props 변경
<Button color="red" /> -> <Button color="blue" />
하지만 key의 값이 변경된다면 완전히 새로운 컴포넌트 인스턴스가 생성이되고, 이 전 인스턴스는 unmount되게 됩니다.
// key 변경
<Button key="1" /> -> <Button key="2" />
왜 이런 구조로 만들어진걸까요?
리액트가 이렇게 동작하는 이유는 위에서 본 Fiber의 구조와 연관이 있습니다. React Fiber는 형제 노드들을 구분할 때, 두 가지를 확인합니다. 바로 노드의 타입(type)과 노드의 키(key)입니다.
// Fiber 노드 비교 시
{
type: 'Button',
key: '1',
// ...다른 속성들
}
{
type: 'Button',
key: '2',
// ...다른 속성들
}
이 두 노드는 type이 Button으로 같지만, key가 다르므로 완전히 다른 노드입니다. 리액트는 key가 1인 Button 컴포넌트와 key가 2인 Button 컴포넌트를 완전히 다른 컴포넌트로 인식하는 것이죠. 그래서 key가 변경되게 되면, 리액트는 기존 key=1인 컴포넌트를 제거(unmount)하고 key=2인 컴포넌트를 생성(mount)합니다. 이렇게 만든 이유는 배열의 순서 변경이나 요소 교체를 명확하게 처리할 수 있기 때문입니다. 이게 무슨 말인지 좀 더 자세히 알아보겠습니다.
왜 배열에서 key가 중요할까?
배열 요소의 렌더링은 일반적인 컴포넌트의 렌더링과 다르게 고려해야할 부분들이 있습니다. 배열은 일반적인 컴포넌트와 달리 순서가 자주 바뀔 수 있고, 중간에 요소가 삭제되거나 수정될 수 있으며, 이런 여러 변경이 동시에 발생할 수도 있습니다. 이 때 React는 리렌더링을 처리하기 위해 고민해야할 부분이 늘어나게 됩니다.
// 예시: 배열 순서 변경
const before = ['A', 'B', 'C'];
const after = ['B', 'A', 'C'];
// key가 없는 경우의 렌더링
<div>A</div> // 첫번째 -> B로 내용 변경 필요
<div>B</div> // 두번째 -> A로 내용 변경 필요
<div>C</div> // 세번째 -> 변경 없음
위와 같은 경우, React 입장에서는 아래와 같은 고민 사항이 생겨납니다.
- 이 전 첫 번째 요소와 현재 첫 번째 요소가 같은 컴포넌트일까? (그러면 재생성 안해도 되는데...)
- 순서가 바뀐건가? 아니면 다른 데이터가 들어간건가? (순서가 바뀐거면 노드들의 순서만 바꾸면 될 것 같은데...)
- 어떤 요소를 재사용하고 어떤 요소를 새로 만들어야 할까?
위의 고민들을 해결하기 위해 React는 key를 활용해 형제 요소들 사이에서 동일한 요소를 식별할 수 있도록 했습니다. 이 key는 같은 컴포넌트인지를 구별하기 위해 사용되고, 변경 사항을 정확하고 효율적으로 파악하고 처리할 수 있도록 합니다.
그런데 왜 key 값에 인덱스는 사용이 불가할까요?
위의 예시를 살펴보면 단순히 인덱스로도 변경 사항을 효율적으로 파악할 수 있을 것 같아보입니다만, key에는 인덱스 사용을 지양하라고 합니다. 그 이유는 정확이 무엇일까요?
// 초기 상태
const items = ['A', 'B', 'C'];
// index만으로 구분하는 경우
<div>A</div> // index: 0
<div>B</div> // index: 1
<div>C</div> // index: 2
// 배열이 ['B', 'A', 'C']로 변경된다면?
<div>B</div> // index: 0
<div>A</div> // index: 1
<div>C</div> // index: 2
지금 위의 코드를 살펴보면 실제로는 요소들의 위치만 바뀐 것인데, React는 이를 내용 변경으로 인식하게 됩니다. 그러면 불필요한 DOM 조작이 일어나게 됩니다. 요소를 이동하는 대신 내용을 변경하게 되고, 실제 DOM에서 더 많은 작업이 발생하게 됩니다.
또, 아래의 예시를 살펴보면 DOM 조작이 필요함에도 아무런 변경도 일어나지 않는 것처럼 보일 수 있습니다.
function App() {
const [items, setItems] = useState(['user1', 'user2']);
return (
<>
{items.map((item, index) => (
<div key={index}>
<input
placeholder={`${item}의 이름`}
// 각 input은 자신만의 state를 가집니다
/>
</div>
))}
<button onClick={() => setItems(['user2', 'user1'])}>순서 변경</button>
</>
);
}
순서 변경 버튼을 클릭하게 되면 기존의 ['user1', 'user2']의 순서가 ['user2', 'user1']로 변경되게 됩니다. 하지만 실제 우리가 보는 화면은 어떻게 될까요? 아무런 변화가 발생하지 않고, 초기 렌더링 시의 "user1의 이름", "user2의 이름"을 가진 인풋의 순서가 그대로 유지됩니다.
이 이유는 무엇일까요?
// 초기 렌더링
[
<Input key={0} /> // 인스턴스1 (state: "user1")
<Input key={1} /> // 인스턴스2 (state: "user2")
]
// items 배열의 순서가 바뀌어도
// React는 여전히 위치(index)로만 인스턴스를 식별
[
<Input key={0} /> // 여전히 인스턴스1 (state: "user1")
<Input key={1} /> // 여전히 인스턴스2 (state: "user2")
]
이 코드의 진행 순서를 하나씩 살펴보겠습니다.
- 리렌더링 발생
- Virtual DOM도 새로 생성
- 하지만 key가 index이기 때문에, React는 "위치"에 있는 컴포넌트를 같은 것으로 인식
- 결과적으로 실제 DOM에서는 요소의 이동이 아닌 내용(placeholder) 변경만 발생하게 됨
(0번 key에 맞는 placeholder를 넣어야겠구나 "user1"...)
그렇기에 key값은 index로 사용하는 것을 지양해야합니다. 여기서 한 가지 궁금한 점이 생깁니다.
그럼 모든 컴포넌트에 key를 사용하는게 좋지않을까요? React는 왜 굳이 배열에만 key를 사용하는걸까요?
배열이나 리스트는 자주 동적 변경이 일어납니다. 요소가 추가되는 것을 떠나, 순서가 바뀌는 것도 중요하죠. 위에서 살펴본 예시대로 단순하게 위치로는 컴포넌트의 정체성을 추적하기 어렵습니다. 그러나 배열이 아닌 요소들은 정적인 구조를 가지고, 추적할 형제 요소들이 존재하지 않습니다. 기존의 위치만으로도 충분히 비교/구분이 가능하죠. Header, Footer와 같은 컴포넌트가 단적인 예시입니다. 이런 모든 컴포넌트에 key를 넣는 것은 불필요한 코드를 추가하고, React의 비교 알고리즘에 추가적인 부담을 주는 결과를 초래할 수 있습니다. 실제로 저번 블로그 글에서 알아보았듯이 가상DOM을 활용할 때, diffing 알고리즘이 더 빠르지 않았던 결과를 보면 추가적인 부담을 초래할 것으로 예상할 수 있습니다.
key를 이용한 고급 테크닉
지금까지 key는 리액트 컴포넌트에서 정체성을 의미함을 알 수 있었습니다. 이 특징을 이용하면 state의 변화와 관계없이 key를 활용해서 강제로 리렌더링을 일으킬 수도 있습니다. 어떤 경우에 이 key를 활용할 수 있을까요?
[1]
const Component = ({ data }) => {
// 1. useEffect 사용시: 컴포넌트는 유지되고 data가 변경될 때만 이 로직 실행
const [state1, setState1] = useState(null);
const [state2, setState2] = useState(null);
const ref = useRef(null);
useEffect(() => {
setState1(calculateState1(data));
setState2(calculateState2(data));
ref.current = newValue;
}, [data]);
return (
<div>
{state1} {state2}
</div>
);
};
// vs
const Component = ({ data }) => {
// 2. key 사용시: 컴포넌트가 새로 마운트될 때마다 이 부분부터 처음부터 실행
const [state1] = useState(calculateState1(data));
const [state2] = useState(calculateState2(data));
const ref = useRef(newValue);
return (
<div key={JSON.stringify(data)}>
{state1} {state2}
</div>
);
};
위와 같은 복잡한 초기화 상태 변경이 필요한 경우가 있다고 해봅시다. 기존의 prop부분은 data의 값이 변경될 때마다 useEffect를 통해 모든 state를 초기화시켜주는 작업을 직접 진행해주고 있습니다. 하지만 key를 사용한다면 해당 로직 없이도, key의 변경으로 컴포넌트가 mount될 때마다 컴포넌트 인스턴스가 완전히 새로 생성되어 모든 state가 초기값으로 리셋됩니다.
이렇게 useEffect로 하는 상태 업데이트를 key 변경을 통한 새로운 마운트로 대체할 수 있습니다. 하지만 모든 cleanup 함수를 실행하고, 기존 컴포넌트 인스턴스를 제거하고, 새로운 인스턴스를 생성하는 등 더 "무거운" 작업이 될 수 있으므로, 정말 완전한 리셋이 필요한 경우에만 사용하는 것이 좋습니다.
[2]
// 애니메이션 라이브러리의 경우
const Animation = ({ trigger }) => {
// props 변경만으로는 애니메이션을 처음부터 다시 시작하기 어려울 수 있음
return (
<motion.div
key={trigger}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
Content
</motion.div>
);
};
위와 같은 경우에도 props변경만으로 특정 라이브러리의 애니메이션을 처음부터 시작하기 어려운 경우, key를 활용해 볼 수도 있습니다.
글을 마치며
지금까지 React의 key에 대해 알아보았습니다. props의 변경은 리렌더링을 유발하지만, 모든 props가 동일한 방식으로 처리되는 것은 아닙니다. 특히 key와 ref는 React Fiber에 의해 특별하게 처리되며, 이러한 특별한 처리 방식을 이해하고 활용하면 더 효율적인 렌더링 최적화가 가능했습니다. 다시 한 번 정리해보자면, key는 단순히 효율적 렌더링을 위한 것이라기보단 다음과 같은 중요한 역할을 합니다:
- 배열 요소의 효율적인 업데이트와 재사용을 가능하게 함
- 컴포넌트의 정체성을 관리하여 상태를 적절히 보존하거나 리셋
- 필요한 경우 강제로 컴포넌트를 새로 마운트하는 수단으로 활용
저도 다음 프로젝트에서는 적절한 key 전략을 사용함으로써 불필요한 리렌더링을 방지하고, 필요한 경우에만 컴포넌트를 새로 마운트하여 애플리케이션의 성능을 최적화해보아야겠습니다!
'React > React' 카테고리의 다른 글
번들러란? Vite, Webpack 뭐가 다를까? (0) | 2025.01.14 |
---|---|
[1편] 3D 웹게임 렌더링 최적화: 33FPS -> 61FPS 성능 개선 사례 (0) | 2024.12.18 |
가상 DOM과 실제 DOM의 차이: 정말 성능 향상이 있을까? (1) | 2024.11.10 |
OAuth 로그인 요청의 주체는 어디일까? (클라이언트(React)? 서버(Spring)?) (1) | 2023.10.05 |