useRouter은 못말려

In9_9yu·2023년 6월 9일
2

mobae

목록 보기
6/9

들어가기 전에

유저 프로필 기능 추가를 해결하려 '노력' 하며 겪었던 문제들을 정리했습니다.

⚠️ 노력했다고만 했지 해결은 못할 수도 있습니다.
⚠️ 임시 방편이 난무할 수 있습니다.
⚠️ 뇌피셜이 있을 수 있습니다. 도와주세요

SSR은 왜 나를 괴롭히는가

당연하지만, 유저 프로필 기능을 위해서는 다음 조건이 선행되어야 합니다.

  • 유저의 로그인 여부 파악

일반적인(내가 바라는) 유저 플로우를 생각하면,

  • 로그인
  • 헤더에 있는 프로필 클릭
    프로필
  • /profile 로 이동

하지만, 브라우저에 곧바로 https://mobae.zxc/profile 을 입력하면,

  • 로그인 여부를 확인
  • 로그인이 된 경우 -> profile 페이지 렌더링
  • 로그인이 안 된 경우 -> login 페이지 렌더링

이해를 돕기 위해 현재 로그인 로직의 플로우를 도식화해보면 다음과 같습니다.

로그인 도식화

  • useSilentLoginQuery는 새로고침을 했을 때, 로그인 여부를 파악하기 위해서 refresh token이 유효한지 확인하는 api입니다.

이를 바탕으로 프로필 페이지의 코드를 보겠습니다.

const ProfilePage = () => {
  const router = useRouter();
  const user = useRecoilValue(userState);
  
  if(user){
    return <ProfileComponent/>
  }
   
  router.push('/login');
  return null;
}
export default ProfilePage;

당연히 동작할 줄 알았던 코드는 에러를 던져줍니다.
error

친절하게도, 에러에서 문제해결에 도움이 될만한 링크를 제공합니다. 해당 문서에 들어가면 다음과 같은 내용이 나옵니다.

Why This Error Occurred
During Pre-rendering (SSR or SSG) you tried to access a router method push, replace, back, which is not supported.

실제로 router를 console.log로 출력해보면, 프론트 서버에 다음과 같은 결과가 나옵니다.

// 정말 없네...
ServerRouter {
  route: '/profile',
  pathname: '/profile',
  query: {},
  asPath: '/profile',
  isFallback: false,
  basePath: '',
  locale: undefined,
  locales: undefined,
  defaultLocale: undefined,
  isReady: false,
  domainLocales: undefined,
  isPreview: false,
  isLocaleDomain: false
}

Possible Ways to Fix It
In a function Component you can move the code into the useEffect hook.
This way the calls to the router methods are only executed in the browser.

문제를 해결하는 방법으로 제시하는 것은 useEffect hook을 사용하는 것...

useEffect라니, (이유는 없지만) 본능적으로 거부하게 됩니다. (굳이... 써야할까?)

마땅한 방법이 생각나지 않으니, 시도해보겠습니다.


⚠️ 첫 번째 해결책 - useEffect

다들 아시다시피, useEffect는 해당 컴포넌트가 렌더링 된 후에 주어진 함수를 실행합니다.

따라서 다음과 같은 코드가 예상됩니다.

const ProfilePage = () => {
  const router = useRouter();
  const user = useRecoilValue(userState);
  
  useEffect(()=>{
	if(!user){
    	router.push('/login');
    }
  },[user])
  
  return <Profile/>

}
export default ProfilePage;

profile1

와 된다! 라고 하기에는 참으로 양심이 찔립니다. 비록 5프레임밖에 되지 않지만 중간에 보이는 프로필 화면...

당연히 해당 화면이 뜰 수 밖에 없는 이유는, Next js는 pre-rendering을 하기 때문에 일단 받아오는 document를 먼저 사용자에게 보여줍니다.

document

그리고 그 이후에 hydration 이라는 멋진 작업을 통해 사용자와 상호작용을 하게 되죠.

다시말해서, 중간에 다른 화면은 hydration 이후 useEffect가 돌기 전까지 사용자에게 보여지는 화면입니다.

결론 : 되긴 되는데, 여러모로 별로다 (양심적으로... 기술적으로도...)


⚠️ useEffect를 제거할 수는 없을까? + 눈속임

useEffect를 붙인 이유는, 서버에서는 router 객체에 push 메소드가 없었기 때문입니다. (client side의 router에만 있습니다.)

따라서 useEffect를 제거하는 방법은, 강제로 profile page를 csr을 이용하여 load하면 됩니다.

그렇게되면, 해당 profile page는 처음부터 client side에서 읽히는 것이기 때문에 router.push를 정상적으로 실행시킬 수 있습니다.

next/dynamic 모듈로 다음과 같이 작성할 수 있습니다.

const ProfilePage = () => {
  const user = useRecoilValue(userState);
  const router = useRouter();

  if (user) {
    return <Profile />;
  }

  router.push("/login");
  // 눈속임을 위한 return 
  return <div className="flex-1" />;
};

const CSRProfilePage = dynamic(() => Promise.resolve(ProfilePage), {
  loading: () => <div className="flex-1" />,
  ssr: false,
});

export default CSRProfilePage;

useEffect가 사라진 덕분인지, 조금 더 깔끔해진 느낌입니다. 결과는 어떨까요?

useEffect 제거

흰 화면을 반환하는 약간의 눈속임을 더해서, useEffect를 사용하는 것보다 더 괜찮아보이는 것 같습니다.


🙄 이게 왜 되지?

언뜻 보기에는 잘 동작하는 것 처럼 보이는 코드지만, 생각해보면 이상한 점이 있습니다.

useEffect를 사용한 경우에는, router.push는 반드시 컴포넌트의 렌더링 과정 이후에 실행된다는 것을 알 수 있습니다.

useEffect(()=>{
  	// 컴포넌트 렌더링 후에
	if(!user){
      	// user가 존재하지 않으면, router.push를 호출한다.
    	router.push('/login');
    }
  },[user])

그렇다면 useEffect가 없는 경우에는 router.push는 언제 동작하는 걸까요?
일단 router.push의 실행 시점을 확실하게 알기 위해 console.log 가 포함된 함수로 한 번 감싸보겠습니다.

const ProfilePage = () => {
  const user = useRecoilValue(userState);
  const router = useRouter();
  const wrapperRouter = () => {
  	console.log('wapperRouter 호출');
    router.push('/login');
  }

  useEffect(() => {
    console.log("first render in profile page");
  }, []);

  useEffect(() => {
    console.log("rerender in profile page");
  });

  if (user) {
    return <Profile />;
  }

  console.log("before push");
  wrapRouter();
  console.log("after push");

  return <div className="flex-1" />;
};

결과

콘솔 결과를 보면 profile 페이지가 렌더링 되기 전에 router.push('/login')이 호출되는 것을 볼 수 있습니다.

그럼에도 불구하고 useEffect에 입력한 first render~ 부분이 출력되는 걸 보니, 렌더링 이후에 제가 기대했던 router.push('/login') 가 동작하는 것으로 추측할 수 있습니다.

router.push라는 함수가 비동기함수가 아님을 감안해보면 함수가 호출되었지만, 그 동작은 나중에 실행한다? 라는 점은 조금 의아한 부분입니다.

이 부분을 이해해보기 위해, nextjs의 github 코드를 몇 개 찾아봤습니다.

client/router.ts
router.ts

제가 파악을 못하는 건지 이렇다 할 단서는 찾지 못했습니다.
이거 생각하는데 시간을 너무 많이써서 개발 진행이 너무 더딥니다 흑흑


⚠️ 뇌피셜로 소설쓰기

패배를 인정하고, 뇌피셜로 떠들어보겠읍니다

왜 비동기적으로 작업이 진행되는가?

Router의 정보가 ContextAPI에 의해 관리된다는 점을 주목했습니다.

React Dev Tool을 보면 다음 화면을 볼 수 있습니다.
React Dev Tool

route에 관한 정보들이 Provider를 통해 전달되고 있습니다.

router.push 는 Provider로 전달되는 값을 변경합니다.

Context를 통해 전달되는 값에 변화를 주게 되면 리렌더링이 발생합니다.

위의 결과에서 봤듯이, profile 페이지의 렌더링이 모두 완료된 후 login 페이지로 넘어감을 알 수 있었습니다.

그래서, router.push가 리렌더링을 일으킨 것은 맞지만, profile 페이지의 렌더링 작업이 더 우선순위가 높았다. 라고 생각합니다.

사실 우선순위가 더 높았다기 보다는 공식 문서 : render and commit 를 보면, 상태를 업데이트 하는 경우에 그 작업이 queue에 들어간다고 나와있습니다.

profile 페이지 렌더링이 당연히 먼저 들어왔으니, profile 렌더링 후에 login 페이지가 렌더링 되는건 어찌보면 당연한 일이네요.

그렇기 때문에, router.push의 호출 시점과, 실제로 그 동작이 수행되어 login 페이지가 렌더링 되는 시점이 다르게 되는 것이 아닐까요?

결론

일단 여기까지만 알아보고, 문제를 어떻게 해결했는지는 PR을 닫은 후에 다시 작성해야겠네요...

profile
FE 임니다

2개의 댓글

comment-user-thumbnail
2023년 6월 14일

당연히 동작할 줄 알았던 코드는 에러를 던져줍니다.

🤣🤣🤣
너무 공감되네요. 화이팅입니다 🔥

1개의 답글