React의 조정(Reconciliation) 알고리즘이 동작하는 원리와, 컴포넌트의 렌더링·리렌더링·마운트/언마운트 동작이 실제 애플리케이션에서 어떤 의미를 가지는지, 그리고 key 속성이 왜 필요하며 어떻게 버그를 해결하는지 구체적인 사례와 함께 설명합니다.
React가 컴포넌트를 렌더링할 때 모든 것을 알고 있다고 생각했을 때마다, 우주는 항상 나를 깜짝 놀라게 한다. 단순한 if 문만으로도 충분히 머리를 부여잡을 일이 생긴다. 이번에도 내 "주말 할 일" 리스트를 무시하고 React 공식문서를 뒤적거리다가 "잠깐만, 이게 맞아?"라는 순간을 맞닥뜨렸다. 그렇게 또 다시 주말 계획은 사라지고, 심층 조사와 이 글이 탄생했다. 할 일 리스트 따위 중요한 것도 아니라고…?
이제, 왜 다른 컴포넌트 내부에서 컴포넌트를 만들면 매번 리렌더링할 때마다 다시 마운트되는지, 왜 리스트 밖에서는 key 속성이 필요 없는지 등 React 조정(Reconciliation) 알고리즘의 동작 원리와 흥미로운 질문들에 대해 함께 파헤쳐보자!
먼저, 사연의 시작.
어떤 컴포넌트를 조건부로 렌더링한다고 생각해보자. 예를 들어 회원가입 폼에 가입자가 회사인지 일반 개인인지에 따라 입력 받을 필드를 달리하는 경우다. 회사 체크박스를 선택하면 "사업자 등록번호" 필드를 보이고, 아니면 "인증번호 입력 안 하셔도 됩니다"라는 문구만 보여주고 싶다.
jsxconst Form = () => { const [isCompany, setIsCompany] = useState(false); return ( <> ... // 체크박스 등등 {isCompany ? ( <Input id="company-tax-id-number" placeholder="Enter your company ID" ... /> ) : ( <TextPlaceholder /> )} </> ); };
여기서 사용자가 회사임을 체크해 isCompany 값이 false에서 true로 바뀌면, 어떤 일이 일어날까?
직관적으로 예상할 수 있다시피, Form은 리렌더링이 되고 TextPlaceholder는 언마운트, Input은 새로 마운트된다. 다시 체크박스를 끄면 Input이 언마운트되고 TextPlaceholder가 마운트된다.
이 행동은 즉, Input이 내부적으로 보관하던 state(입력 값 등)가 언마운트 될 때 사라진다는 의미다. 내가 입력 중이던 값은 컴포넌트가 새로 마운트되면 다시 초기화된다.
그럼, 만약 사람에게도 tax id를 입력 받으려면 어떻게 될까? 들어가는 설정값(id, onChange 등)만 다르고, UI/동작은 같다면 다음처럼 짤 것이다.
jsxconst Form = () => { const [isCompany, setIsCompany] = useState(false); return ( <> ... // 체크박스 등등 {isCompany ? ( <Input id="company-tax-id-number" placeholder="Enter your company Tax ID" ... /> ) : ( <Input id="person-tax-id-number" placeholder="Enter your personal Tax ID" ... /> )} </> ); };
이 경우 어떻게 될까?
이번에도 직관적이게도… 언마운트가 일어나지 않는다! 즉, 값을 입력한 뒤 체크박스를 토글해도 텍스트가 그대로 유지된다. React는 두 인풋을 같은 컴포넌트라고 생각하고 단순히 prop만 교체해 렌더링한다.
이게 당연하다고 느끼는 분도 있겠지만, 그렇지 않다면 이제 React의 조정 과정을 파헤칠 시간이다.
이 행동의 핵심 이유는 DOM에 있다. DOM에 항목을 추가·제거하는 것은 느리기 때문에 React는 어떻게 해서든 기존 DOM 요소를 최대한 재활용하고 싶어한다.
React 코드 예시:
jsxconst Input = ({ placeholder }) => { return <input type="text" id={id} />; }; <Input placeholder="Input something here" />;
여기서 placeholder 값을 바꿔도 input 자체를 새로 만드는 것이 아니라 기존 input DOM의 속성만 수정하는 것이 이상적이다. Vanilla JS로 하자면:
jsconst input = document.getElementById('input-id'); input.placeholder = 'new data';
React는 내부적으로 Virtual DOM(실제로는 트리 구조의 JS 객체)을 만들어 실제 변경 부분만 반영하는 최적화를 한다. 예를 들어
jsxconst Input = () => ( <> <label htmlFor={id}>{label}</label> <input type="text" id={id} /> </> );
이것은 다음과 같은 객체 트리로 표현된다:
js[ { type: 'label', ... }, { type: 'input', ... } ]
컴포넌트라면 type이 함수가 된다:
js{ type: Input, ... }
React가 앱을 마운트할 때
이 과정을 반복해 전체 DOM 트리를 만든다.
jsxconst Component = () => ( <div> <Input placeholder="Text1" id="1" /> <Input placeholder="Text2" id="2" /> </div> );
이 코드는 아래와 같이 객체화 된다:
js{ type: 'div', props: { children: [ { type: Input, props: { id: "1", placeholder: "Text1" } }, { type: Input, props: { id: "2", placeholder: "Text2" } } ] } }
렌더 과정이 끝나면 React는 실제 DOM에 appendChild로 요소를 추가한다.
재밌는 건 바로 이후! 어떤 컴포넌트의 state가 변경돼 리렌더링이 발생하면, React는 변경된 부분만 찾아 최소한의 DOM 갱신을 한다. 이때 "이전 트리 vs 다음 트리"에서 type을 비교한다.
jsxconst Component = () => <Input />;
이 경우 이전/이후 모두 { type: Input, ... }이니 타입이 같다고 판단, 인스턴스 재사용하며 렌더링만 트리거한다. 반면,
jsxconst Component = () => { if (isCompany) return <Input />; return <TextPlaceholder />; };
isCompany 가 바뀔 때 비교 대상:
{ type: Input, ... } vs { type: TextPlaceholder, ... }타입이 다르면 React는 기존 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트한다. 즉, 리셋이 발생한다!
본문 처음의 사례로 돌아가자:
jsxconst Form = () => { const [isCompany, setIsCompany] = useState(false); return ( <> ... // 체크박스 등등 {isCompany ? ( <Input id="company-tax-id-number" placeholder="Enter your company Tax ID" ... /> ) : ( <Input id="person-tax-id-number" placeholder="Enter your personal Tax ID" ... /> )} </> ); }
isCompany가 토글될 때 실제 바뀌는 객체를 보면 전/후 모두
{ type: Input, ... }
즉 type은 같고, 단지 prop만 다르다. React 입장에서는 같은 컴포넌트라 판단해 기존 인스턴스를 재사용하며 prop만 갱신한다. 그래서 입력창에 입력한 값이 사라지지 않는다.
이런 동작이 언제나 나쁜 것은 아니지만, 여기에서는 서로 다른 의미의 컴포넌트니까 실제 리마운트, 즉 완전히 새로 마운트되길 원할 수도 있다. 이를 해결할 방법이 있는데, 대표적으로 배열과 key 속성 활용이다.
실제 Form 내부는 React 입장에서 항상 배열 형태(children array)로 처리된다:
jsxconst Form = () => { const [isCompany, setIsCompany] = useState(false); return ( <> <Checkbox onChange={() => setIsCompany(!isCompany)} /> {isCompany ? ( <Input id="company-tax-id-number" ... /> ) : ( <Input id="person-tax-id-number" ... /> )} </> ); };
체크박스, 인풋 등 각 child는 배열로 들어가고, 렌더링시 React는 이 배열의 각 항목을 같은 위치끼리 전/후 type을 비교한다.
직접 인풋 위치를 바꿔서 해결할 수도 있다:
jsx<> <Checkbox onChange={() => setIsCompany(!isCompany)} /> {isCompany ? <Input id="company-tax-id-number" ... /> : null} {!isCompany ? <Input id="person-tax-id-number" ... /> : null} </>
이렇게 하면 항상 세 개의 children([Checkbox, Input/null, Input/null])이 구성된다.
state 변경 시 배열 변화는
이제 React가 아이템별로 순회하며 type을 비교하니, 두 인풋 모두 교체되어 기대한 대로 행동한다. CodeSandbox 예제
key는 특히 리스트 렌더링 시 매우 중요한 속성이다. 예를 들어:
jsxconst data = ['1', '2']; const Component = () => data.map(value => <Input key={value} />);
동적 리스트는 아이템 추가·삭제·순서 변경이 가능하므로, React는 key로 각 항목의 정체성을 추적한다. key가 다르면 새로 마운트, 같으면 기존 인스턴스 재사용이다.
key의 효과:
key가 없으면 입력값 등의 state가 섞여오지만 key가 같으면 해당 컴포넌트 인스턴스(입력값 포함)를 보존한다.
Form 예제도 인풋 각각에 key를 다르게 부여하면 문제없는 동작을 얻을 수 있다:
jsx{isCompany ? ( <Input id="company-tax-id-number" key="company-tax-id-number" ... /> ) : ( <Input id="person-tax-id-number" key="person-tax-id-number" ... /> )}
이렇게 하면 전/후 렌더의 children 배열에 포함된 Input 객체의 key가 바뀌므로 React는 새로운 컴포넌트를 인식해 새로 마운트한다.
만약 같은 위치에 다른 컴포넌트가 오지만 같은 key를 준다면, React는 인스턴스를 재사용한다:
jsx<> <Checkbox onChange={() => setIsCompany(!isCompany)} /> {isCompany ? <Input id="company-tax-id-number" key="tax-input" ... /> : null} {!isCompany ? <Input id="person-tax-id-number" key="tax-input" ... /> : null} </>
여기서는 두 인풋 모두 key가 같다. 그래서 위치가 바뀌어도 React는 같은 인스턴스라 생각해, 입력값이 초기화되지 않는다. 이는 탭, 아코디언 등 특정 상황에서 성능튜닝을 위해 쓸 수 있다.
React는 배열(map 등 반복문)로 동적 요소를 생성할 때만 key를 강제한다. 예시:
jsxconst data = ['1', '2']; const Component = () => ( <> {data.map(value => <Input key={value} />)} </> ); const Component = () => ( <> <Input /> <Input /> </> );
둘 다 내부적으로는 children array로 표현된다. 하지만 첫 번째(fetch/map)는 변동성이 있으니 key를 요구한다.
재미로, 아래처럼 조건에 따라 key를 다르게 주는 경우는 어떻게 될까?
jsxconst Component = () => { const [isReverse, setIsReverse] = useState(false); return ( <> <Input key={isReverse ? 'some-key' : null} /> <Input key={!isReverse ? 'some-key' : null} /> </> ); };
값을 입력한 후 isReverse를 토글해보자. 어떤 일이 일어날지 예측해보자!
코드보기
동적 배열 다음에 일반 요소를 놓고 동적 배열에 아이템을 추가하면, 그 '아래'의 정적인 요소가 마운트/언마운트가 반복될까?
jsxconst data = ['1', '2']; const Component = () => ( <> {data.map(i => <Input key={i} id={i} />)} <Input id="3" /> </> );
다행히 React는 배열과 정적 요소를 별도 child로 두어 static한 요소의 위치를 보장한다. 따라서 아래 Input의 불필요한 리마운트는 일어나지 않는다.
컴포넌트를 render 함수 내부에서 선언하면, 렌더링때마다 새로운 함수 객체가 생성된다. React는 type을 함수 객체로 인식하므로 이전/이후 렌더마다 서로 다른 함수로 판단하고 매번 언마운트/마운트해버린다. (비효율 및 버그 가능성)
정답: 컴포넌트는 항상 렌더 함수 바깥에서 선언하자!
이제부터 React 엘리먼트 대신 내부 객체 트리를 떠올리며 어떤 상황에서 리렌더 vs 리마운트가 발생할지 예측할 수 있길 바란다! 그리고 누군가 "왜 React에서 key가 필요해?"라고 밤에 물으면 좋은 답을 해줄 수 있길~ (단, 동료가 당신을 리렌더링 디버그 도구로 쓸 수도 있음에 주의)
이 글 기반 영상도 있으니 참고하면 이해가 더 잘 될 수 있다.
더 깊이 알고 싶다면 추천:
https://overreacted.io/react-as-a-ui-runtime/