React 프로젝트 진행 중, 로그인한 유저만 접근 가능한 마이페이지에서 로그아웃 시도 시 콘솔창에 Rendered more hooks than during the previous render 에러가 발생했다. 🚨

if (!currentUser) {
alert('로그인이 필요한 서비스입니다. 홈으로 이동합니다.');
navigate('/');
return null;
// ... MyPage 데이터 호출 로직
// ...MyPage 컴포넌트 렌더링 로직
마이페이지에서 로그아웃 시, '로그인이 필요한 서비스입니다.' 라는 alert 메시지와 함께 즉시 홈으로 리다이렉트되는 플로우를 원했다.
마이페이지에서 로그아웃 시, 콘솔창에 해당 에러가 표시되며 곧바로 홈으로 리다이렉트되지 않는 버그가 발생했다.
마이페이지 컴포넌트가 렌더링되는 도중에 BrowserRouter의 상태를 업데이트하려고 시도했기 때문에 발생한 에러이다.
React는 한 컴포넌트의 렌더링 중에 다른 컴포넌트의 상태를 변경하는 것을 허용하지 않는다.
마이페이지 컴포넌트 조건부 렌더링 도중 인증 상태가 변경되어 라우팅을 변경하려고 했기 때문에 발생한 문제이다.
useEffect(() => {
if (!currentUser) {
alert('로그인이 필요한 서비스입니다. 홈으로 이동합니다.');
navigate('/');
}
}, [currentUser]);
useEffect 훅 내부에서 alert과 navigate를 모두 실행해 인증 검증과 리다이렉트를 한꺼번에 처리하는 방식을 채택했으나 다음과 같은 에러가 발생했다.


에러 메시지를 분석해보면 이전 렌더링에서 호출된 hooks 보다 현재 렌더링에서 hooks가 더 많이 호출된 결과로 발생한 에러라고 한다. 리액트에서 hooks는 매 렌더링마다 동일한 호출을 보장받아야 하기 때문에 해당 에러가 발생한다.
💫 여기서 해당 버그 해결을 위해 useEffect 훅을 렌더링 관점에서 제대로 이해하는 것이 관건이었다.
useEffect훅은 컴포넌트가 마운트, 업데이트, 언마운트 '될 때' 실행된다.useEffect훅은 컴포넌트의 렌더링과 동시에 실행되지 않고, 렌더링이 완료된 후에 작업을 수행한다.
리액트의 useEffect는 컴포넌트 초기 렌더링이 한 번 발생한 후에 useEffect 내 로직이 실행된다.
즉, 문제 상황에 적용하면 currentUser가 null 이더라도 마이페이지 컴포넌트가 한 번 렌더링된 후 useEffect 내부에서 navigate로 페이지 이동을 시도한다. 따라서 이미 초기 렌더링에서 api 호출과 쿼리 실행이 이루어진 상태가 되며, 이는 마이페이지 컴포넌트의 불필요한 렌더링과 불필요한 api 호출이 된다.
앞서 발생한 문제를 해결하기 위해 조건부 렌더링을 활용하였다.
// useEffect 내에서는 alert만 처리
useEffect(() => {
if (!currentUser) {
alert('로그인이 필요한 서비스입니다. 홈으로 이동합니다.');
}
}, [currentUser]);
...
// 조건부 렌더링 - currentUser가 null이면 즉시 렌더링을 중단하고 Navigate 반환
if (!currentUser) {
return <Navigate to='/' replace />;
}
// currentUser가 있을 경우 아래 코드들 실행
이러한 흐름으로 코드를 작성하면 초기 컴포넌트 렌더링 시 currentUser 유무를 판단해 없을 경우 렌더링을 중단하고 즉시 홈으로 리다이렉트되기 때문에 불필요한 나머지 렌더링이 발생하지 않게 된다. 👍🏻
버그를 해결하면서, 추가로 리액트에서 라우팅을 처리하는 방식 중 useNavigate 훅과 <Navigate /> 컴포넌트는 어떤 차이가 있는지 궁금해졌다.
우선 앞선 첫 번째 해결 시도에서 알게 된 한 가지 차이점은 useEffect 훅 내부에서 사용할 수 있는가의 여부이다. useEffect 내부에 navigate('/')로 라우팅을 시도하면 이전에 언급했던 렌더링 관련 이슈가 발생한다. 그러나 useEffect 내부에 <Navigate /> 컴포넌트를 사용해 라우팅을 시도하면 컴파일 에러가 발생한다.
useEffect(() => {
if (!currentUser) {
alert('로그인이 필요한 서비스입니다. 홈으로 이동합니다.');
return <Navigate to='/' replace />; // 컴파일 에러
}
}, [currentUser]);
useEffect 내부에서 JSX 반환이 불가능하기 때문이다. useEffect 훅은 컴포넌트가 렌더링된 이후에 실행되는 함수로, JSX를 반환하는 렌더링 코드와는 별개로 사용된다.
추가적으로 찾아보니, 두 라우팅 방식의 근본적인 차이점은 선언형과 명령형 방식의 차이에 있다고 한다.
선언형 프로그래밍은 "무엇을" 원하는지 명시하는 방식이고, 명령형 프로그래밍은 "어떻게" 해야 하는지 단계별로 지시하는 방식이다. React는 기본적으로 선언적 패러다임을 따른다.
이때 useNavigate 훅과 <Navigate /> 컴포넌트 중 선언형 프로그래밍으로써 리액트 라우팅에 더 부합하는 쪽은 <Navigate /> 컴포넌트이다.
return <Navigate to="/" replace />;
이처럼 작성하면 "이 시점에서 홈 페이지로 리다이렉션해야 한다"는 의도를 명확하게 파악할 수 있고, JSX 내에서 리다이렉션 로직이 명확하게 표현되므로 선언적 패턴이 적용되었다고 할 수 있다.
반면 navigate() 함수는 명령형 접근 방식이다.
navigate('/');
이 함수는 "지금 이 경로로 이동하라"는 명령을 실행하며, 렌더링과 별개로 실행된다.
이러한 구분은 공식 문서에서도 명확하게 제시하고 있다! React Router v6 공식 문서에서는 <Navigate> 컴포넌트와 선언적 네비게이션에 대해 다음과 같이 언급한다.
"A
<Navigate>element changes the current location when it is rendered. It's a component wrapper arounduseNavigate, and accepts all the same arguments as props."
"If you're familiar with React Router, you know that we prefer declarative, React-y ways to do things. The component is a declarative version of useNavigate."
정리하면 다음과 같다.
<Navigate> 컴포넌트
navigate() 함수
useEffect와 같은 훅 내에서 호출<Navigate> 컴포넌트가 React의 선언적 프로그래밍 모델에 더 부합하고, React Router가 의도적으로 이러한 선언적 API를 제공한다는 것은 새롭게 알게 된 내용이다. 선언형과 명령형 프로그래밍 방식에 대해 추후에 더 공부해 봐야겠다.
참고자료
https://dev.to/rem0nfawzi/dont-use-useeffect-3ca8
https://velog.io/@sinf/Rendered-more-hooks-than-during-the-previous-render
https://nno3onn.tistory.com/entry/React-Error-Rendered-more-hooks-than-during-the-previous-render
https://api.reactrouter.com/v7/functions/react_router.Navigate.html