우당탕탕 Next.js 개발기 - ② App Router 에서 Shallow Routing 의 구현과 라이브러리화 해보기

김동현·2024년 7월 19일
14
post-thumbnail

이번 포스팅은 Shallow Routing 과 관련된 이야기 입니다.
후술하겠지만, pages router 에서의 shallow routing 은 코드 한 줄이면 되지만, apps router 의 경우 약간의 고민이 필요합니다. 그 고민을 공유하고자 합니다.
제가 고민을 했던 시간만큼 다른 프론트엔드 개발자분들의 시간을 절약해줄 수 있기를 희망합니다.

기본 개념 설명

Shallow Routing 이란 무엇일까요? 그 정의를 먼저 살펴봅시다.

얕은 라우팅을 사용하면 getServerSideProps, getStaticProps 및 getInitialProps를 포함하는 데이터 가져오기 메서드를 다시 실행하지 않고도 URL을 변경할 수 있습니다.

상태를 잃지 않고 라우터 객체(useRouter 또는 withRouter에 의해 추가됨)를 통해 업데이트된 경로 이름과 쿼리를 받게 됩니다.

쉽게 말해서 SPA 환경에서 새로 고침 없이 URL 만 변경할 수 있게 하는 작업이 Shallow Routing 이라고 볼 수 있습니다. 이러한 작업은 왜 필요할까요? 가장 자주 필요한 부분은 바로 퍼널 구조입니다.

퍼널은 마케팅 용어로 시작하여 토스 팀에서 재정의 한 구조인데요. 여러 페이지들을 통해 상태를 수집하고 결과 페이지를 보여주는 형태입니다. 바로 이때, Shallow Routing 이 필요합니다. 예시를 들어보겠습니다.

회원 가입 과정에서 다음과 같은 Sequence Diagram 이 있다고 합시다. 즉, 퍼널의 뒷 프로세스로 가는것은 버튼을 눌러서 가게 되고, 앞 프로세스로 가는 것은 뒤로가기 버튼을 통해서 가는 것입니다.

가장 먼저 떠오르는 방법은 모든 프로세스 마다 페이지를 생성하는 것입니다. 하지만 아무리 최적화를 잘 해내더라도 페이지가 새로 고침이 되는 것은 막을 수가 없습니다. 또한 전역 상태 변수 관리가 거의 필수적으로 보입니다. 다른 페이지간의 상태를 공유하기 위해서 말입니다.

이럴때 Shallow Rotuing 이 필요합니다. Shallow Routing 을 이용해서 페이지를 따로따로 관리하지 않아도 브라우저의 히스토리를 관리해주기 때문입니다. 따라서 요구사항을 다 충족시키면서도 전역 상태등을 선언하지 않고 프론트엔드를 구현할 수 있습니다.

App router 에서 Shallow Routing 의 문제점 - 공식지원이 없다.

Pages Router 에서 shallow routing 은 비교적 쉬웠습니다. 이 문서에 따르면 옵션 값을 하나만 추가해주면 됩니다.
router.push('/?counter=10', undefined, { shallow: true })

하지만 App Router 에서는 이를 공식적으로 지원하지 않습니다. 우선 router 를 import 해야 하는 곳 자체가 다릅니다.
import { useRouter } from 'next/navigation' 으로 next/navigation 에서 import 를 해야합니다. 직접 import 를 해보면 알겠지만 shallow option 은 존재하지도 않습니다. 그러면 대체 어떻게 구현 해야 할까요?

해결책 - Native History API 를 사용하자

여기서 밝히는 해결책은 프로젝트의 초기 기술 스택을 Next.js 로 정하지 말자 에 따르면 비교적 최신의 해결책입니다. 따라서 제가 구현한 방식이 반드시 최적화된 방식이 아닐 수 있으며, 피드백은 언제든지 환영합니다.

먼저 어떤 해결책을 Next.js 에서 제시했는지를 살펴 봅시다. Next.js 는 공식적으로 native History API 를 지원하고 있습니다. Server Component 와 Client Component 상관없이 사용할 수 있는 API 입니다. 총 2가지 API 를 사용할 수 있는데요. 그 중에서 우리가 사용할 것은 window.history.pushState 입니다.

window.history.pushState 는 브라우저의 히스토리 스택에 새로운 요소를 삽입합니다. 또한 유저는 이전의 상태로 돌아갈 수도 있다고 합니다. 우리가 원하는 동작과 거의 비슷해보입니다.

하지만 단순하게 pushState 를 한다고 어플리케이션의 state 가 변하지 않습니다. 따라서 다음과 같은 sequence 를 따라야 합니다. (위의 예제 중 하나만 살펴봅시다.)

이해가 되시나요? 차근차근 하나씩 코드를 사용하면서 따라가봅시다. 첫번째 단계는 App.tsx 를 작성하는 단계 입니다. state 를 선언하고, 퍼널 구조에 해당하는 아이템들을 하나로 묶습니다. (코드의 응집도를 높이고 추상화 수준을 맞추는 작업이며 자세한 사항은 토스 슬래쉬 발표 영상을 보시면 좋습니다..)

const App = () => {
  const [step, setStep] = useState<'ID_Input'|'Password_Input'>('ID_Input');
  
  return (
    <>
      {step === 'ID_Input' && <IdInput/>}
      {step === 'Password_Input' && <PasswordInput/>}
    </>
  );
}

두번째 단계는 는 onClick 를 넘겨주는 작업입니다. 이때 로직상 pushState 를 써야합니다.

const App = () => {
  const [step, setStep] = useState<'ID_Input'|'Password_Input'>('ID_Input');
  
  return (
    <>
      {step === 'ID_Input' && 
       	<IdInput 
       		onClick={() => window.history.pushState(null, '', 'password')}
	  	/>
	  }
      {step === 'Password_Input' && <PasswordInput/>}
    </>
  );
}

세번째 단계는 url 변경을 감지하고 useEffect 내에서 state 를 변경하는 단계 입니다. 가장 중요한 단계입니다. url 변경 감지는 next.js 에서 제공하는 usePathname 를 사용해보도록 합시다.

const App = () => {
  const [step, setStep] = useState<'ID_Input'|'Password_Input'>('ID_Input');
  const currentPathname = usePathname();
  
  useEffect(() => {
    if(currentPathname.includes('password')){
      	setStep('Password_Input');
    }

  }, [currentPathname]);
  
  return (
    <>
      {step === 'ID_Input' && 
       	<IdInput 
       		onClick={() => window.history.pushState(null, '', 'password')}
	  	/>
	  }
      {step === 'Password_Input' && <PasswordInput/>}
    </>
  );
}

마지막 단계는 유저가 브라우저의 뒤로가기 버튼을 눌렀을때 정상적으로 뒤로 가지도록 pathname 에 id 가 속하는지를 판단하고 세번째 단계를 반복하는 단계입니다.

const App = () => {
  const [step, setStep] = useState<'ID_Input'|'Password_Input'>('ID_Input');
  const currentPathname = usePathname();
  
  useEffect(() => {
    if(currentPathname.includes('password')){
      	setStep('Password_Input');
    }
    // 맨 처음 URL 에 id 가 있다고 가정
	if(currentPathname.includes('id')){
      	setStep('ID_Input');
    } 

  }, [currentPathname]);
  
  return (
    <>
      {step === 'ID_Input' && 
       	<IdInput 
       		onClick={() => window.history.pushState(null, '', 'password')}
	  	/>
	  }
      {step === 'Password_Input' && <PasswordInput/>}
    </>
  );
}

이제 우리가 원하는 방식으로 모두 동작하게 됐습니다! 🎉

해결책 ② - Custom Hook 으로 분리하기

위의 해결책은 불완전합니다. 왜냐하면 앞으로 퍼널 구조가 깊어질 수록 무수히 많은 If 문을 추가해야 하며 재사용성도 매우 떨어집니다. 저는 이를 Custom Hook 으로 분리해보고자 합니다.

Custom Hook 으로 분리할때는 다음과 같은 원칙을 세웠습니다.

  1. Custom Hook 을 호출하는 측에서 추상화 수준에 맞게끔 parameter 를 넘겨줘야 함 (선언적 프로그래밍)
  2. 재사용성이 뛰어나야 함

이를 만족시키기는 Custom Hook 을 다음과 같이 제작했습니다.

type Action = () => void;

export const usePathnameAction = (pathnames: string[], actions: Action[]) => {
  const currentPathname = usePathname();

  useEffect(() => {
    const index = pathnames.findIndex(pathname =>
      new RegExp(pathname).test(currentPathname),
    );

    if (index !== -1) {
      actions[index]();
    }
  }, [currentPathname, pathnames, actions]);
};

실제로 App.tsx 에서는 다음과 같이 사용하면 됩니다.

const App = () => {
  const [step, setStep] = useState<'ID_Input'|'Password_Input'>('ID_Input');
  
  usePathnameAction(
  	['id', 'password'],
    [() => setState('ID_Input'), () => setState('Password_Input')]
  );
  
  return (
    <>
      {step === 'ID_Input' && 
       	<IdInput 
       		onClick={() => window.history.pushState(null, '', 'password')}
	  	/>
	  }
      {step === 'Password_Input' && <PasswordInput/>}
    </>
  );
}

정말 깔끔해졌죠? App.tsx 에서는 각각의 pathName 에 따라 어떤 작업을 수행해야 한다 라는 것을 선언적으로 말하면 그 세부 구현 사항은 usepathNameAction 에서 처리할 수 있게 만들었습니다.

마치면서

Shallow Routing 은 분명 강력한 기법이나 App router 에서 공식적인 지원을 하지 않아 구현에 어려움을 겪는 개발자들이 많을 것으로 생각합니다. 특히 한글로 된 자료가 더더욱 없기도 합니다. 제 구현 방식이 조금이나마 도움이 되길 바라며, 상술 했듯 더 나은 방식을 향한 열린 토론을 저는 지향합니다. 로직상 아쉬운 부분과 개선할 부분이 있다면 가감없이 댓글을 달아주시면 감사하겠습니다.

Reference

profile
Frontend Developer

0개의 댓글