개인프로젝트로 만든 사이트를 리팩토링 하던 중, 페이지 이동 시 헤더와 푸터도 같이 리렌더링 되고 있었기에 이를 개선하려고 했다.
기존에는 다음과 같이 App 컴포넌트에서 라우팅 처리를 해둔 상태였다.
function App() {
const dispatch = useDispatch();
const { theme } = useSelector((state: RootState) => state.theme);
useEffect(() => {
const jwtToken = cookies.get('token');
if (jwtToken) {
dispatch(setLogin(true));
} else {
dispatch(setLogin(false));
};
}, []);
const routePath = [
{
path: "/",
element: <Home />
},
{
path: "/posts",
element: <Review />
},
{
path: "/posts/update/:pId",
element: <ReviewUpdate />
},
{
path: "/posts/recommendation/good-product",
element: <Recommend pageType='good-product' />
},
{
path: "/posts/recommendation/bad-product",
element: <Recommend pageType='bad-product' />
},
{
path: "/posts/detail/:pId",
element: <ReviewDetail />
},
{
path: "/create",
element: <ReviewCreate />
},
{
path: "/new-password",
element: <ChangePassword />
},
{
path: "/mypage/:userId",
element: <MyPage />
},
{
path: "/error/not_found",
element: <ErrorPage />
}
];
return (
<>
<ThemeProvider theme={theme === "light" ? lightTheme : darkTheme}>
<GlobalStyle />
<Routes>
{routePath.map(data => (
<Route
key={data.path}
path={data.path}
element={
<LayoutWrapper>
<Header />
{data.element}
<Footer />
</LayoutWrapper>
} />
))}
</Routes>
</ThemeProvider>
</>
);
}
경로에 따른 컴포넌트를 정의한 routePath 배열을 정의하고 이를 순회해 각 경로에 해당하는 Route 컴포넌트를 지정했다. 이 때 Route에 들어갈 element는 헤더-본문-푸터 순으로 배치된 LayoutWrapper 컴포넌트로 지정하였는데, 생각해보니 이러면 페이지 이동 시 바뀌는건 본문 컴포넌트 뿐인데 헤더와 푸터도 같이 라우팅 처리되어 리렌더링이 된다.
내 사이트에선 페이지 이동 시 본문 컴포넌트와 헤더, 푸터를 같이 매칭해줘야 할 필요도 없는 상황이었기에 본문 컴포넌트만 라우팅 처리 되도록 App 컴포넌트의 return 문을 다음과 같이 바꾸었다.
<ThemeProvider theme={theme === "light" ? lightTheme : darkTheme}>
<GlobalStyle />
<LayoutWrapper>
<Header />
<Routes>
{routePath.map(data => (
<Route key={data.path} path={data.path} element={data.element} />
))}
</Routes>
<Footer />
</LayoutWrapper>
</ThemeProvider>
그런데 이렇게 구조를 바꿨는데도 페이지 이동 시 헤더와 푸터가 자꾸 리렌더링이 되었다. 분명 라우팅 처리는 본문 컴포넌트만 했는데...
나의 상식선에서는 생각할 수 있는 경우가 두가지였다.
- 페이지 이동 시에 바뀌는 전역 state를 헤더와 푸터에서 참조하고 있다.
- 페이지 이동 시 App 컴포넌트가 리렌더링된다.
더 간단한 푸터로 살펴보자면, 코드는 다음과 같은 상황이었다.
export default function Footer() {
const { theme } = useSelector((state: RootState) => state.theme);
// category 변경 시 리렌더링 방지
const { category } = useSelector((state: RootState) => state.category, () => { return true });
const dispatch = useDispatch();
const navigate = useNavigate();
const moveToHome = () => {
// 리뷰 페이지의 카테고리가 클릭된 상태에서 홈 화면 이동시 카테고리 state 초기화 -> 안하면 홈 화면의 카테고리 버튼이 활성화 되어있음
if (category !== "none") dispatch(resetCategory());
navigate("/");
};
return (
<FooterWrapper>
<FooterArea>
<IconArea>
<FooterLogo logotheme={theme} onClick={moveToHome} />
<ContactIconArea>
<ContactIcon icontype={facebook} />
<ContactIcon icontype={instagram} />
<ContactIcon icontype={youtube} />
<ContactIcon icontype={mail} />
</ContactIconArea>
</IconArea>
<FooterTextArea>
<FooterText>상품명 : 리뷰잇</FooterText>
<FooterText>사업자등록번호 : 123-45-6789 | 개인프로젝트 | 익스프레스 | 리액트 | 고유번호 : 123456</FooterText>
<FooterText>주소 : 서울시 강남구 무슨무슨로 아무개빌딩 207호 (123456) | 고객센터 : abc123@gmail.com</FooterText>
<FooterText>2023 (주) 개인회사 Inc. All rights reserved.</FooterText>
</FooterTextArea>
</FooterArea>
</FooterWrapper>
)
}
여기서 theme은 다크모드/화이트모드 전환 시 변경되는 state라서 페이지 이동 시에는 바뀌지 않으니 상관 X.
그리고 category는 아래의 페이지에서 카데고리 버튼을 클릭해 카테고리를 변경할 때 바뀌는 state 이다.
그런데 헤더와 푸터는 사용자가 카테고리를 바꿔도 모습이나 상태가 바뀌어야 할 필요가 없다. 따라서 useSelector의 두번째 인자로 무조건 true를 반환하는 함수를 지정하여 category가 변경되어도 리렌더링 되지 않게 했기 때문에 category에 의해 리렌더링 되는것도 아니다.
실제로 위 페이지에서 카테고리 변경 시에는 헤더와 푸터가 리렌더링 되지 않음. 리뷰를 클릭해서 리뷰 페이지로 이동하거나 홈 화면으로 돌아가거나 하는 등 페이지를 이동할 때에만 자꾸 리렌더링 되는 상황이었다.
사실 이건 생각해볼 것도 없긴 하다. App 컴포넌트에서 페이지 이동 시 바뀌는 state를 참조하고 있는 것도 아닌데 페이지 라우팅이 발생한다고 App 컴포넌트가 리렌더링 될리는 없지 않나 싶었다.
혹시 모르니 콘솔 찍어 확인해보니 역시 아니었음. App 컴포넌트는 페이지를 이동한다고 리렌더링 되지 않는다. 따라서 App 컴포넌트 리렌더링으로 인한 헤더, 푸터의 리렌더링도 아닌듯하다.
도무지 원인을 모르겠어서 이것저것 해보다가 생각지도 못한 부분에서 원인을 발견했다. 바로 useNavigate 때문이었다.
푸터에서 const navigate = useNavigate(); 구문을 주석처리하니 페이지 이동 시 리렌더링이 되지 않았다. 헤더도 마찬가지.
문제는 왜 그런지를 모르겠다는 거다... 페이지 라우팅이 발생하면 useNavigate 훅을 가져오는 컴포넌트들은 원래 리렌더링이 되는건가..?
이리저리 검색을 해봤는데 이런 경우에 대해서는 나오는게 없다.
GPT한테도 물어봤고 그에 대한 답변
useNavigate 훅은 React Router의 기능 중 하나로, 특정 경로로 이동하는 함수를 반환합니다. 이 훅을 사용하면 리액트 라우터가 내부적으로 해당 경로로 이동하는데, 이로 인해 컴포넌트의 상태가 변경되고 리렌더링이 발생할 수 있습니다.
따라서 useNavigate 훅을 사용하면 해당 컴포넌트가 리렌더링될 가능성이 있습니다. 특히, 라우팅과 관련된 동작을 수행하는 경우에는 컴포넌트의 리렌더링이 필요할 수 있습니다.
그렇다고는 하는데... 이 말이 사실인지 아닌지를 판별할 수가 없어서 모르겠다. 내가 무료버전 3.5 GPT를 쓰고 있어서 그런지 몰라도 구라를 너무 잘쳐서 신뢰가 안감
하지만 정말 저런게 아니고서야 일단 난 아무리 생각해도 왜 그런지를 모르겠다. 원인은 발견했지만 그 이유를 모르니 참 찝찝하다. 아시는분 계시면 알려주세요..
헤더와 푸터 모두 로고 클릭 시 홈화면으로 이동할 수 있도록 하려고 useNavigate를 사용했는데, 단순히 페이지 이동이라면 Link를 써도 되겠지만 페이지 이동 시 category state를 초기화 시켜야 하기 때문에 useNavigate를 사용했다.
하지만 리렌더링을 방지하려면 useNavigate를 쓰면 안되게 생겼으니.. 어찌해야할지 난감하다.
헤더와 푸터 컴포넌트를 React.memo로 메모이제이션하면 되긴 할텐데 고작 헤더 푸터를 메모리를 할애해서 리렌더링 방지하는게 유의미한 렌더링 최적화인지는 모르겠다. 굳이 그럴필요는 없을 것 같기도
아무튼 페이지 이동 시 라우팅 처리하지 않은 컴포넌트가 리렌더링 되는건 그 컴포넌트에서 useNavigate 훅을 가져오고있기 때문이었다. 일단 다른 부분들 개선하면서 왜그런지 계속 찾아보고 고민해봐야겠다.