Next.js의 useRouter 사용하다 겪은 이슈 이야기 (부제:버그 잡다가 Next.js 동작 원리 조금 맛보기)
배경
사용중인 Next.js 버전은
12.3.4이다.
사내에서 진행했던 프로젝트 중에 Url 에 아래와 같이 query string 이 들어가면 웹사이트에서 특정한 버튼들의 동작을 제한해야하는 요구사항이 있었다.
www.example?app=preview
그래서 해당 코드는 아래와 같이 작성했었다.
import { useRouter } from 'next/router';
import { FormEvent, MouseEvent } from 'react';
const useRestrictEvent = () => {
const { query } = useRouter();
const isPreviewPage = query.app === 'preview';
const handleEventRestrict = (originEventHandler: (event?: any) => void) => (
event: FormEvent<HTMLFormElement> | MouseEvent<HTMLElement>,
) => {
// .. 코드 생략
if (isPreviewPage) {
// query string app 이 'preview'이면 alert를 띄운다.
alert('미리보기에선 지원하지 않는 기능이에요.');
}
};
return [handleEventRestrict];
};
export default useRestrictEvent;
로컬에서는 정말 잘 동작했다. 하지만 배포된 환경에서는 동작하지 않았다. 배포된 환경에서도 테스트서버에서는 동작하고 운영서버에서는 동작하지 않았다.
이렇듯 환경별로 동작 여부가 달라서 디버깅하기 매우 어려웠다. 또한 아무리 구글링을 해도 동일한 이슈를 겪은 경우가 거의 없었다.
사실 프로덕션 코드는 빠르게 고쳐져서 배포되어야 하므로, 일단 문제를 해결 한 후에 원인을 찾아보았다. 해결 방법은 아래에서도 자세히 언급되지만, 짤막하게 말하자면 Next.js의 useRouter가 아니라 직접 window.location 을 참조해서 query string 을 파싱해오는 비교적 간단한 방법이었다.
(원인을 찾아보는 과정에서 Next.js의 코드를 직접 찾아보고, 렌더링 과정들을 파악하며 약간의 성취감같은 것을 맛봤다ㅎ 단순히 문제만 해결하는게 아니라 그 원인 자체를 파악하니 속이 정말 시원했다.)
1. 첫번째 추측 - useRouter의 isReady 를 사용하면 되는게 아닐까
이리저리 구글링을 해보니 Next.js의 router.query 가 undefined여서 애먹은 경우들이 많았다.
그리고 그 이유는 정적으로 최적화 된 페이지에서는 router 매개 변수가 없이 렌더링되는 것 때문이었다. Next.js는 렌더링된 후 쿼리 객체를 채운다고 한다.
따라서, useRouter에서 제공하는isReady를 사용해서 쿼리 객체가 채워졌는지 아닌지를 판별하고 그 후에 query 객체에 접근 할 수 있다고 한다.
import { useRouter } from 'next/router';
import { FormEvent, MouseEvent } from 'react';
const useRestrictEvent = () => {
const { query, isReady } = useRouter();
const [isPreviewPage, setIsPreviewPage] = useState(false);
useEffect(() => {
if (!isReady) return;
if (query.app === 'preview') {
setIsPreviewPage(true);
}
}, [isReady]);
const handleEventRestrict = (originEventHandler: (event?: any) => void) => (
event: FormEvent<HTMLFormElement> | MouseEvent<HTMLElement>,
) => {
// .. 코드 생략
if (isPreviewPage) {
// query string app 이 'preview'이면 alert를 띄운다.
alert('미리보기에선 지원하지 않는 기능이에요.');
}
};
return [handleEventRestrict];
};
export default useRestrictEvent;따라서 그렇게 적용해봤지만 결과는 똑같았다.
그리고 어떠한 사실을 발견했다. 내가 참조하는 useRouter의 query 객체는 그 자체로 undefined는 아니었다.
다만 아래와 같이 빈 객체였다.
{
//.. 생략
query: {
}
}그렇다면 위는 이 문제의 원인이 아니었다.
그래서 두가지 상황을 핵심으로 더 찾아봤다.
- Router query 객체가 로드는되었으나 빈 객체이다.
- 위의 문제점은 로컬환경에서 발생하지 않고 배포된 환경에서만 발생한다.
2. 두번째 추측 - 어쩌면 Next.js의 useRouter 구현부에 허점이 있는게 아닐까
Next 13 버전부터는 useRouter.query와 useRouter.pathname이 제거되고 각각 useSearchParams usePathanme이라는 훅으로 대체되었다는걸 알게되었다. 참고
이 말은 즉슨 Next.js에서도 뭔가 문제가 있다는걸 인지했다는걸까라는 추측을 하면서 Next.js의 해당 코드를 살펴보기로 마음먹었다.
먼저 시도로 알게된 이 버그의 원인(추측)을 세 줄 요약하자면 아래와 같다.
- Next.js는 서버에서 생성한 HTML 파일을 캐싱한다.
- 그런데 useRouter에서 router내 query 객체를 초기화 할 때 HTML파일 내 데이터를 참조한다.
- 따라서 router query 객체 초기화 시 참조하는 데이터가 캐싱 된 HTML파일을 참조하여 배포된 환경에서만 router query 객체가 제대로 초기화 되지 않는 버그가 발생한다.
이를 검증하기 위해서는 Next.js 에서 router 는 query 객체를 어떻게 초기화하는가를 알아봤어야 했다.
먼저 next.js 의 router class 자체를 살펴봤는데 아래와 같이 constructor 부분에서 인자를 받고 그 매개변수를 바탕으로 router 객체 안에 들어갈 값들을 셋팅해주고 있었다.
next/shared/lib/router/router.js
class Router {
//...
constructor(
pathname1,
query1,
as1,
{
initialProps,
pageLoader,
App,
wrapApp,
Component,
err,
subscription,
isFallback,
locale,
locales,
defaultLocale,
domainLocales,
isPreview,
},
) {
//...
}
}그러면 이 Router class를 사용하여 객체를 생성하고 인자를 주입하는 부분을 보아야한다.
살펴보니 렌더링과 관련된 부분에서 createRouter 라는 함수를 사용하고 있었고, createRouter 함수는 아래와 같았다.
next/client/router.js
function createRouter(...args) {
singletonRouter.router = new _router.default(...args);
singletonRouter.readyCallbacks.forEach((cb) => cb());
singletonRouter.readyCallbacks = [];
return singletonRouter.router;
}그리고 이 createRouter를 사용하여 router 객체를 등록하는 부분은 아래와 같다.
next/client/index.js
exports.router = router = (0, _router).createRouter(initialData.page, initialData.query, asPath, {
initialProps: initialData.props,
pageLoader,
App: CachedApp,
Component: CachedComponent,
wrapApp,
err: initialErr,
isFallback: Boolean(initialData.isFallback),
subscription: (info, App, scroll) =>
render(
Object.assign({}, info, {
App,
scroll,
}),
),
locale: initialData.locale,
locales: initialData.locales,
defaultLocale,
domainLocales: dinitialData.domainLocales,
isPreview: initialData.isPreview,
});잘 보니 initialData라는 객체를 참조하여 createRouter를 해주고 있다. 즉, Router 객체를 만들 때 initialData 라는 객체를 넘겨준다는 것이다. 그럼 initialData는 어떤 데이터를 가지고 있을까?
next/client/index.js
function _initialize() {
// ...
initialData = JSON.parse(document.getElementById('__NEXT_DATA__').textContent);
// ...
}_initialize 함수에서 Next.js 서버에서 렌더링한 HTML 파일 내 __NEXT_DATA__ 를 id로 갖는 엘리먼트를 initialData에 할당한다.
그리고 서버에서 반환하는 HTML 파일을 보면 아래와 같다.
<!DOCTYPE html>
<html>
<head>
<!-- ...생략 -->
<script src="/..." defer=""></script>
</head>
<body>
<!-- 생략 -->
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"name": "hi"
},
"__N_SSP": true
},
"page": "/",
"query": {},
"buildId": "development",
"isFallback": false,
"gssp": true,
"scriptLoader": []
}
</script>
</body>
</html>따라서 <script id="__NEXT_DATA__" type="application/json"> 내부에 정의된 객체를 통해서 Router 객체를 초기화 한다는 것을 알게 되었다.
그렇다면 왜 배포된 환경에서 제대로 초기화 되지 않는걸까? 배포된 환경에서는 Next.js가 캐싱된 HTML파일을 서빙하기 때문이다. 따라서 캐싱된 HTML 파일 내의 __NEXT_DATA__를 참조하여 useRouter의 반환값 객체 내 query 객체가 제대로 반영되지 않는다.
3. 해결 하기
그러면 어떻게 해결할까? 두 가지 방법이 있다.
- 캐싱하지 않기
- query string 참조 시 다른 방법 사용하기
첫번째 캐싱하지 않기는 next.config.js에서 설정 할 수 있다. 하지만 2번의 방법이 있는데 캐싱 자체의 이점을 버리기는 너무 아깝다. 따라서 2번의 방법을 사용했다.
v13에 추가된 useSerachParams훅을 사용할 수는 없으니, 직접 window.location을 참조해서 아래와 같이 queryString을 조회하는 훅을 만들었다.
const useSearchParams = () => {
const queryString = isServer ? '' : window.location.search;
const searchParams = new URLSearchParams(queryString);
return searchParams;
};이렇게 사용한 결과 버그는 더 이상 발생하지 않았다.. 😇
나가며
이번에 이렇게 디버깅을 하고 문제를 해결하면서 깨달은 점이 있다면,
-
배포된 환경에서만 혹은 간헐적으로 발견되는 문제라면 내가 작성한 코드 외부의 문제일 확률이 높다.
-
프레임워크에 너무 의존하지 말자 🥲