11 min read

Sub-path Routing 다국어 사이트에서의 뒤로가기 핸들링

Sub-path Routing 다국어 사이트에서의 뒤로가기 핸들링
Sub-path Routing 다국어 사이트에서의 뒤로가기 핸들링
Problem when backing out of Sub-path Routing.

개발 환경

  • React, Next.js, React Query
  • Sub-path Routing 방식 Locale 관리(en/cart or ko/cart)

Sub-path Routing

우리 사이트에서는 URL 주소에 Locale을 포함시키는 Sub-path Routing 방식을 사용하고 있습니다.

사용자가 언어를 전환할 수 있는 방법은 두가지 입니다.

언어 전환 버튼을 클릭하는 방식

첫째로 사용자는 Header의 언어 전환 버튼을 이용해서 사이트의 언어를 변경할 수 있습니다.

주소를 직접 변경하는 방식

두번째로 주소창의 URL을 직접 수정해도 언어를 변경할 수 있습니다.

  • 언어를 전환하는 방법

(1) Header의 언어 전환 버튼 클릭

(2) 사이트의 URL 직접 수정

첫번째 방식으로 언어를 변경하면 코드상에서 사이트의 URL을 수정하게 됩니다. 따라서 사용자가 어떤 방식을 사용하든 결과적으로는 사이트의 URL이 변경됩니다.

언어 전환이 화면에 적용되기 까지 일어나는 일

URL이 바뀌면 아래의 동작들이 일어나면서 화면의 컨텐츠를 다시 그리게 됩니다.

1️⃣ Router 오브젝트의 Locale 변경이 감지되었습니다. 아래 코드가 실행되면서 Cookie의 Language 값을 업데이트하고 데이터를 재요청합니다.

/* useSetLanguage 코드의 일부입니다.
** 아래 코드는 Locale 변경이 일어나면 자동으로 실행됩니다.
** prevLocale이 'ko', currLocale이 'en'라고 가정합니다.
*/

if (prevLocale !== currLocale) {
  /* 데이터 요청에 사용되는 'Language' 쿠키를 세팅합니다.
  ** currLocale인 'en'으로 설정됩니다.
  */
  setCookie('Language', currLocale, { path: '/' });

  /* queryClient의 데이터를 모두 invalidate 처리합니다.
  ** invalidate 이후 데이터가 재요청됩니다.
  ** 새로운 응답값은 영어 컨텐츠를 포함합니다.
  */
  queryClient.invalidateQueries(undefined);
}

2️⃣ 새로 불러온 데이터가 화면에 그려집니다. 이때 언어에 영향을 받지 않는 컨텐츠(이미지, 영상 등)는 다시 그려지지 않고 이전 데이터와 비교했을때 변화가 있는 항목만 다시 그려집니다.

문제 발생(As-is)

그런데 이렇게 언어를 전환한 뒤에 사용자가 뒤로가기 버튼을 누르면 어떻게 될까요?

  • 사용자가 My-page에서 머물다가 Cart 페이지로 이동 했습니다. 그리고 한영 전환 버튼을 눌러서 언어를 한글로 바꾸었습니다.
  • 그리고 곧바로 뒤로가기 버튼을 눌렀습니다.

사용자는 My-page의 컨텐츠들이 한글로 보이길 기대했겠지만, 컨텐츠는 영어로 되어있습니다.

뒤로가기를 누르면 Locale 정보를 포함한 URL 전체가 이전 History로 변경되므로 현재 페이지에서의 Locale 변경사항이 폐기되는 것처럼 보입니다.

도달해야하는 목표(To-be)

개발자는 사이트의 언어정보와 URL의 연관관계를 알기 때문에 이를 자연스러운 현상으로 느낄 수 있습니다. 하지만 사용자는 뒤로가기를 하면 적용한 언어가 바뀌는 것을 버그로 느낄 수 있습니다.

한번 언어를 전환하면 뒤로가기 시에도 계속 해당 언어가 적용되도록 바꾸어 보겠습니다.

현재 코드는 URL(router 객체)에 의존하여 Locale의 변화를 감지하고 있습니다. 데이터 요청에 사용되는 Cookie의 Language 값은 URL의 Locale값에 따라 달라집니다.

  • 사용자가 한영 전환 버튼을 누른 경우
  • 사용자가 URL을 직접 수정한 경우

이렇게 두가지 케이스만 생각하면 Cookie 값이 URL의 Locale을 참조하는 것이 타당해보입니다.

그런데 이런 경우에는 어떨까요?

  • 뒤로가기를 통한 페이지 이동인 경우

이 경우에는 Cookie와 URL의 Locale 정보가 불일치 하면 Cookie값을 수정하는 것이 아니라, URL을 수정해주어야 합니다. History 스택에 담긴 URL이 최신 Locale을 반영하지 못하기 때문입니다.

정리해보면 URL의 Locale 정보와 쿠키의 정보가 불일치하는 경우, 일반적인 상황에서는 URL을 채택하지만 뒤로 가기시에는 Cookie를 우선으로 취급해야 합니다.

  • Cookie의 Language 값 !== URL Locale
    • 일반적인 경우: Cookie를 수정
    • 뒤로가기 이벤트가 발생한 경우: URL을 수정

문제의 해결(BeforePopState)

  • 현재의 로직을 그대로 유지하면서 뒤로가기 이벤트가 발생하였을때 추가적인 처리를 해주기로 합니다. 그러기 위해서는 아래의 단계를 따라야합니다.
    • 뒤로가기 이벤트 캐치
    • URL과 쿠키의 정보가 불일치 하는 경우 URL Locale 값 수정

1️⃣ 뒤로가기를 감지하려면 History StackpopState 이벤트에 대해 알아야 합니다.

    0️⃣     <- new entry 
    
    ↓   (push or replace)    
    
|   1️⃣   | <- current entry
|   2️⃣   |
|   3️⃣   |      * push: 1위에 0이 쌓임
|   4️⃣   |      * replace: 1삭제후 0이 추가됨
|   5️⃣   |
+--------+
* History Stack
  • History Stack
    • 각 entry는 history.pushState()혹은 history.replaceState()를 통해 생성됩니다.
    • Stack의 가장 위에 있는 entry가 가장 최근에 쌓인 요소입니다.
  • popState 이벤트
    • Window 인터페이스의 popstate 이벤트는 사용자의 세션 기록 탐색으로 인해 현재 활성화된 기록 항목이 바뀔 때 발생합니다.
    • 이 이벤트는 새로운 페이지로 이동하는 프로세스에서 뒷 부분에 실행됩니다.
    • 페이지가 변경될때의 프로세스에서 popState 이벤트는 new entry가 current entry가 된 이후에 호출됩니다.
    • 즉, popState 이벤트에 등록한 함수가 실행되는 시점에 History Stack의 top을 수정할 수 있다면 Locale을 수정할 수 있을 겁니다.

window의 popState를 사용하는 대신 Next에서 제공하는 beforePopState 이벤트를 사용하겠습니다.

router.beforePopState(cb)
  • beforePopState에 함수를 넘기면 popState 이벤트에 등록할 수 있습니다.
  • 이 함수는 router가 작동되기 전에 실행 되며, 리턴값을 통해 popState에 대한 router의 작동여부를 컨트롤 할 수 있습니다. false를 리턴하면 Next는 popState에 대한 router의 기본 동작을 실행시키지 않습니다. 개발자는 필요에 따라 브라우저의 이전/다음 버튼 또는 히스토리 관리를 더 세밀하게 제어할 수 있게 됩니다.
  • cbprops
    • url: 이동할 페이지의 경로
    • as: 브라우저에 표시되는 url(사용자 친화적 url)
    • options: scroll, shallow, locale과 같은 설정 옵션

2️⃣ router.beforePopState를 이용하여 쿠키값과 뒤로 가기 URL의 Locale 정보가 일치하지 않는 경우의 조건문을 생성합니다. 우선은 로그가 제대로 찍히는지 확인해보겠습니다.

/* History Back을 핸들링하는 Hook의 일부입니다.
** router: Next.js의 router 객체
** currentPageLocale: cookie에 'Language' key로 저장된 값
*/

  useEffect(() => {
    /* 라우터 객체에 이벤트를 등록합니다. */
    router.beforePopState(({ url, as, options }) => {
      const { locale: prevPageLocale } = options;
      
      /* 라우터의 Locale과 Cookie에서 가져온 값을 비교합니다.*/
      if (prevPageLocale !== currentPageLocale) {
        /* TODO: Locale이 일치하지 않을때 추가 처리 */
        console.log('Locale does not match.');
      }
      return true;
    });

    return () => {
      router.beforePopState(() => true);
    };
  }, [router, currentPageLocale]);

🧪 이제 언어전환을 한 뒤에 뒤로가기 버튼을 누르면 콘솔창에 'Locale does not match.'가 찍히는 것을 볼 수 있습니다.

3️⃣ 로그를 지우고, 뒤로 가기 시의 History 핸들링을 직접 하기 위해 false를 리턴합니다.

router.beforePopState(({ url, as, options }) => {
      const { locale: prevPageLocale } = options;
      
      if (prevPageLocale !== currentPageLocale) {
        /* History 조작을 직접 하기 위해 false 리턴 */
        return false;
      }
      return true;
    });

    return () => {
      router.beforePopState(() => true);
    };

🧪 테스트를 해보면, Locale 변경 후 뒤로가기를 눌렀을때는 주소창의 URL은 바뀌지만 페이지 이동이 일어나지 않습니다. 리턴값을 false로 설정함으로써 뒤로가기에 대한 Next의 기본 동작을 막았기 때문입니다.

/* Next.js 코드 일부(onPopState)
** this._bps: beforePopState에 넘긴 콜백함수
** return 값이 false이면 this.change(...)부분을 실행하지 않습니다.
** 따라서 router객체의 상태 없데이트를 직접 해주어야 합니다.
*/   
    if (this._bps && !this._bps(state)) {
      return
    }

    this.change(
      'replaceState',
      url,
      as,
      Object.assign<{}, TransitionOptions, TransitionOptions>({}, options, {
        shallow: options.shallow && this._shallow,
        locale: options.locale || this.defaultLocale,
        // @ts-ignore internal value not exposed on types
        _h: 0,
      }),
      forcedScroll

false를 리턴함으로써 Next에서 건너뛰는 부분

4️⃣ History Stack의 최상단 entry를 알맞은 Locale로 설정해줍니다.

router.beforePopState(({ url, as, options }) => {
      const { locale: prevPageLocale } = options;
      
      if (prevPageLocale !== currentPageLocale) {
        /* 쿠키의 Locale를 이용하여 새로운 path, options 생성 */
        const newPath = getNewPath(currentPageLocale, as);
        const newOptions = getNewOption(currentPageLocale, options);

        /* 최근 History 변경 */
        router.replace(newPath, undefined, newOptions);
        return false;
      }
      return true;
    });

    return () => {
      router.beforePopState(() => true);
    };
  • 먼저 쿠키에서 가져온 Current Locale를 이용하여 생성한 새로운 path, options를 생성합니다.
  • router를 수정합니다. 이때, 최근 History를 변경해야 하기 때문에 push가 아닌 replace 메서드를 사용합니다.

🧪 다시 테스트를 해보면 Locale 변경 후에 뒤로가기를 해도 최근 적용한 Locale이 유지됩니다.

5️⃣ 이제 의도대로 동작하지만 주소창이 바뀌는 과정에서 깜빡거리는 이슈가 남아있습니다.

router.beforePopState(({ url, as, options }) => {
      const { locale: prevPageLocale } = options;
      
      if (prevPageLocale !== currentPageLocale) {
        const newPath = getNewPath(currentPageLocale, as);
        const newOptions = getNewOption(currentPageLocale, options);

        router.replace(newPath, undefined, newOptions);
        /* window.history.replaceState를 호출합니다 */
        window.history.replaceState(newOptions, '', newPath);
        return false;
      }
      return true;
    });

    return () => {
      router.beforePopState(() => true);
    };
  • window.history.replaceState를 호출하면 주소창이 깜빡거리지 않습니다.
  • 이 부분에 대한 내용은 다음 게시글에서 추가적으로 다루어보겠습니다.
/* import 생략 */

export const useLocalizedBackNavigation = () => {
  const router = useRouter();
  const [cookies] = useCookies<string>(['Language']);
  const currentPageLocale = cookies['Language'] || Locales.EN;

  useEffect(() => {
    router.beforePopState(({ url, as, options }) => {
      const { locale: prevPageLocale } = options;
      if (prevPageLocale !== currentPageLocale) {
        const newPath = getNewPath(currentPageLocale, as);
        const newOptions = getNewOptions(currentPageLocale, options);
        
        router.replace(newPath, undefined, newOptions);
        window.history.replaceState(newOptions, '', newPath);
        
        return false;
      }
      return true;
    });

    return () => {
      router.beforePopState(() => true);
    };
  }, [router, currentPageLocale]);
}

완성된 Hook 전체코드 입니다.

🚀 이제 한영 전환이후 뒤로가기를 해도 Locale이 유지됩니다.