개인 공부용으로 정리한 글입니다.
참고한 글 :
https://cekrem.github.io/posts/react-reconciliation-deep-dive/
https://roy-jung.github.io/250414-react-reconciliation-deep-dive/
리액트는 요소의 타입(type)과 트리 내 위치(position)를 기준으로 컴포넌트의 정체성을 판단한다
타입과 위치가 같으면 상태를 유지하고, 타입이 다르면 기존 요소를 언마운트하고 새로 마운트한다.
다음 예제를 살펴본다.
const UserInfoForm = () => {
const [isEditing, setIsEditing] = useState(false);
return (
<div>
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Cancel" : "Edit"}
</button>
{isEditing ? (
<input type="text" placeholder="Enter your name" />
) : (
<input type="text" placeholder="Enter your name" disabled />
)}
</div>
);
};
Edit 버튼을 누르고 입력한 텍스트는 Cancel 후 다시 Edit해도 사라지지 않는다.
이는 리액트가 input 타입이 같고 위치가 같음을 인식하여 DOM을 재사용하기 때문이다.
타입을 바꿔서 다음처럼 작성하면 다르게 동작한다
{isEditing ? (
<input type="text" placeholder="Enter your name" />
) : (
<div>Name will appear here</div>
)}
이 경우, input과 div는 타입이 다르므로 리액트는 기존 노드를 제거하고 새로 생성한다. 따라서 입력한 값은 사라진다.
리액트는 "가상 DOM"이라고 부르기보다는 "요소 트리"를 관리한다.
JSX는 다음과 같은 객체 트리로 변환된다.
{
type: 'div',
props: {
children: [
{ type: 'h1', props: { children: 'Hello' } },
{ type: 'p', props: { children: 'World' } }
]
}
}
div, input)는 문자열 타입Input)는 함수 참조 타입{
type: Input, // Input 함수 자체에 대한 참조
props: {
id: "company-tax-id",
placeholder: "Enter company Tax ID"
}
}비교 시 따르는 주요 규칙은 다음과 같다
타입이 바뀌면 하위 트리를 통째로 다시 만든다.
// 첫 번째 렌더링
<div>
<Counter />
</div>
// 두 번째 렌더링
<span>
<Counter />
</span>
div → span으로 바뀌었으므로 전체 하위 트리가 다시 생성된다.
같은 위치에 다른 타입의 컴포넌트가 오면 기존 컴포넌트를 언마운트하고 새 컴포넌트를 마운트한다.
<>
{showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>
showDetails가 true → false로 바뀌면, 위치는 같지만 타입이 다르므로 UserProfile은 언마운트되고 LoginPrompt가 마운트된다.
같은 타입이라면 상태를 유지한다.
<>
{isPrimary ? (
<UserProfile userId={123} role="primary" />
) : (
<UserProfile userId={456} role="secondary" />
)}
</>
isPrimary 값에 관계없이 타입(UserProfile)이 같기 때문에 상태가 유지된다.
key를 사용하면 위치가 바뀌더라도 컴포넌트 정체성을 유지할 수 있다.
const TabContent = ({ activeTab, tabs }) => {
return (
<div>
{tabs.map((tab) => (
<div key={tab.id}>
{activeTab === tab.id ? (
<UserProfile key="active-profile" userId={tab.userId} role={tab.role} />
) : (
<div key="placeholder">Select this tab to view {tab.userId}'s profile</div>
)}
</div>
))}
</div>
);
};
active-profile이라는 key를 부여했기 때문에 어떤 탭이 활성화되더라도 UserProfile의 상태를 유지할 수 있다.
렌더링 구조 예시는 다음과 같다.
tsx
복사편집
// activeTab === "1"
<div>
<div key="1">
<UserProfile key="active-profile" userId="a" role="aa" />
</div>
<div key="2">
<div key="placeholder">Select this tab to view b's profile</div>
</div>
</div>
// activeTab === "2"
<div>
<div key="1">
<div key="placeholder">Select this tab to view a's profile</div>
</div>
<div key="2">
<UserProfile key="active-profile" userId="b" role="bb" />
</div>
</div>
UserProfile 컴포넌트의 타입과 key가 같기 때문에 리액트는 상태를 유지한다.
const Component = () => {
const [isReverse, setIsReverse] = useState(false);
return (
<>
<Input key={isReverse ? "some-key" : null} />
<Input key={!isReverse ? "some-key" : null} />
</>
);
};
isReverse가 변경되면 some-key를 가진 인풋의 위치가 이동하면서 상태도 함께 이동한다.
*isReverse = true*일 때 6번 줄의 *Input*과, *isReverse = false*일 때 7번 줄의 *Input*은 모두 동일한 key(*'some-key'*)를 가지므로, 리액트는 이를 동일한 컴포넌트가 6번 줄에서 7번 줄로 이동한 것으로 파악합니다.
<>
{items.map((item) => (
<ListItem key={item.id} />
))}
<StaticElement />
</>
동적 리스트와 정적 요소가 함께 있을 때, 리액트는 동적 리스트를 하나의 덩어리로 처리한다. StaticElement는 항상 고정된 위치를 유지하므로 리스트가 변경되어도 재마운트되지 않는다.
상태의 지역화는 상태를 사용하는 곳에 최대한 가깝게 유지하는 패턴. 상태를 사용하는 컴포넌트 내부로 옮기면 불필요한 렌더링을 줄일 수 있다.
const App = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<>
<SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
<ExpensiveComponent />
</>
);
};
filterText 변경 시 ExpensiveComponent까지 재렌더링된다.
const UserSection = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<><SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
</>
);
};
const App = () => (
<><UserSection />
<ExpensiveComponent />
</>
);
UserSection만 재렌더링되어 성능이 향상된다.
React.memo 사용 이전에 고려해야 할 점:
const ProductPage = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetchProductDetails(productId);
fetchReviews(productId).then(setReviews);
}, [productId]);
return (
<div>
<ProductInfo selectedSize={selectedSize} onSizeChange={setSelectedSize} quantity={quantity} onQuantityChange={setQuantity} />
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
<Reviews reviews={reviews} />
</div>
);
};
모든 상태가 하나의 컴포넌트에 집중되어 있어, 불필요한 렌더링이 발생한다.
const ProductPage = ({ productId }) => {
return (
<div>
<ProductConfig productId={productId} />
<ReviewsSection productId={productId} />
</div>
);
};
const ProductConfig = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
return (
<>
<ProductInfo selectedSize={selectedSize} onSizeChange={setSelectedSize} quantity={quantity} onQuantityChange={setQuantity} />
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
</>
);
};
const ReviewsSection = ({ productId }) => {
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetchReviews(productId).then(setReviews);
}, [productId]);
return <Reviews reviews={reviews} />;
};
컴포넌트 경계를 나누어 필요한 부분만 재렌더링되도록 최적화한다.
리액트의 조정 알고리즘은 클린 아키텍처 원칙과 완벽하게 일치합니다.
조정에 대한 심층 분석을 바탕으로 다음과 같은 실용적인 가이드라인을 제안합니다.