이 글은 Next.js 12 버전을 기반으로 작성되었습니다. 13 버전에서는 URL의 query(search) parameter에 접근하는 방법이 달라졌습니다.
이 글에서는 URL의 상태를 어떻게 useState를 사용하는 것처럼 간단하게 관리할 수 있게 되었는지를 적어보았습니다.
Next.js에서 URL 상태를 관리하는 방법
Next.js에서는 URL을 관리하는 데 이용할 수 있는 router 객체를 제공합니다. (공식 문서)
하지만 상태를 URL에 저장하는 것은 단순히 useState를 사용하는 것보다는 복잡한 부분이 많습니다.
useState를 사용해 페이지 번호를 관리하는 간단한 예시를 아래처럼 만들어 보았습니다.
import { useState } from "react";
export default function ExamplePage() {
const [pageNum, setPageNum] = useState(1);
return (
<div>
<div>현재 페이지: {pageNum}</div>
<button onClick={() => setPageNum(pageNum + 1)}>다음 페이지</button>
</div>
);
}
TypeScript
복사
useState를 이용한 방법
그러나 Next.js에서 URL을 이용해 페이지 번호를 관리하려면 아래처럼 더 복잡해집니다.
import { useRouter } from "next/router";
const tryParseInt = (str: string | string[] | undefined, _default: number): number => {
const parsed = Array.isArray(str) ? parseInt(str[0]) : parseInt(str || "");
if (isNaN(parsed)) return _default;
return parsed;
};
export default function ExamplePage() {
const router = useRouter();
const pageNum = tryParseInt(router.query.pageNum, 1);
const setPageNum = (newPage: number) => {
router.push({ query: { ...router.query, pageNum: newPage } });
};
return (
<div>
<div>현재 페이지: {pageNum}</div>
<button onClick={() => setPageNum(pageNum + 1)}>다음 페이지</button>
</div>
);
}
TypeScript
복사
next/router를 이용한 방법
next/router를 이용한 방법 실행 화면
URL에서 상태를 읽어오기
Next.js는 URL의 query string 부분을 파싱하여 router.query 객체에 넣어줍니다. 하지만 여기 몇 가지 특이한 점들이 있습니다.
데이터 타입이 string | string[] | undefined 입니다
string으로 이루어진 URL에서 파싱해오면서 Next.js는 pageNum이 어떤 타입인지 판단할 수 없으므로 number가 아니라 string 타입으로 나온다는 점은 자연스럽습니다. 그리고 query string에서 빠져있는 경우 오브젝트에 존재하지 않아 undefined인 것도 이해할 수 있고요. 그러나 string[] 타입은 어디서 나오게 된 걸까요?
?pageNum=1&pageNum=2 처럼 동일한 이름의 파라미터가 query string에 들어갈 수 있어 그렇습니다. 이 경우 router.query.pageNum은 ["1", "2"] 배열로 들어있게 됩니다. 이 동작 때문에 안전한 코드를 위해서는 항상 배열인지 확인해주어야 합니다.
위 예시 코드의 tryParseInt는 이런 이유로 만들어지게 되었습니다. 파라미터가 여러 개인 경우 제일 앞의 파라미터만 받고, 타입을 int로 파싱하고, 파라미터가 없거나 잘못된 경우 _default 값으로 돌려줍니다.
첫 렌더링 시 router.query는 비어있습니다
혹시 위 실행 화면 영상에서 pageNum=8이라고 URL을 직접 입력해 이동한 직후 현재 페이지가 1이라고 잠시 표시되는 것 보이시나요? 처음 렌더링할 때 router.query가 빈 객체로 들어있어 그렇습니다.
이는 Next.js가 pre-rendering을 해주기 때문인데요, 서버에서 HTML 페이지를 브라우저에 보내줄 때 빈 화면에 자바스크립트만 보내주는 것이 아니라, 리액트 코드가 서버에서 빌드 시간에 실행되어 HTML이 미리 만들어진 채로 보내집니다. 이후 브라우저에서도 리액트 코드가 실행되며 서버에서 보내진 HTML을 Hydration 해줍니다.
그러면 여기서 어떤 문제가 생기냐하면, 빌드 시간에는 query string이 존재할 수 없는데, 클라이언트에서는 query string이 있는 경우, query string에 따라 UI가 달라지면 서버와 클라이언트 사이 HTML이 다르게 됩니다. 이는 hydration error를 발생시키므로 Next.js에서는 이러한 문제를 피하고자 일부러 첫 렌더링에서 router.query를 빈 객체로 제공합니다.
Next.js는 이것에 대해 router.isReady로 알려줍니다. 첫 렌더링이라 router.query를 일부러 비워둔 경우 false , 첫 렌더링이 지났거나 첫 렌더링이더라도 query string이 비어있으면 true 값을 가집니다.
이러한 동작으로 인해 만약 useEffect를 통하여 pageNum에 따라 API 요청을 보내야 하는 경우에는 router.isReady가 true인 경우에만 요청을 보내도록 처리해야 불필요한 API 요청을 보내지 않을 수 있습니다.
URL 변경하기
다음으로, URL을 변경하는 부분입니다. Next.js에서는 router.push() 혹은 router.replace()를 이용해 URL을 변경할 수 있습니다. 둘의 차이점은 히스토리를 남겨 뒤로 가기를 할 수 있는지 여부입니다.
router.push({ query: newQuery }) 처럼 호출하게 되면 현재 페이지 내에서 query string 부분을 newQuery에 들어있는 방법대로 변경할 수 있습니다.
그러나 예시 코드를 보시면 newQuery 부분이 { ...router.query, pageNum: newPage } 로 되어있습니다. 이는 query로 넣어주는 객체가 기존 query에 합쳐(merge)지지 않고 통채로 교체해버리기 때문입니다.
같은 페이지에서 pageNum 외에 pageSize, sort 등 다른 파라미터도 이용하는 경우 다른 값들이 지워지는 문제를 방지하기 위해서는 예시 코드처럼 기존 파라미터들을 ...router.query로 전부 넣어준 뒤, pageNum 값만 덮어씌워주어야 합니다.
또 한 가지 차이점으로, 예시 코드의 setPageNum은 useState를 사용한 것과 달리 함수형 업데이트(functional update)를 할 수 없습니다. 이것도 가능하게 만들려면 코드가 더 복잡해질 것입니다.
함수형 업데이트(functional update)란 변경할 값으로 ‘이전 값에서 새로운 값을 만들어내는 함수’를 넣어주는 것을 뜻합니다.
const [value, setValue] = useState(0);
setValue(prev => prev + 1);
TypeScript
복사
next-usequerystate 라이브러리를 이용하는 방법
위에서 보여드린 것처럼 Next.js에서 제공하는 router를 직접 이용하게 되면 URL에 상태를 저장하기 복잡한 부분이 많습니다. 그래서 이를 해결해주는 패키지가 없는지 찾아보았는데 생각보다 관련된 패키지가 적었습니다.
그중 가장 기능도 많고 잘 만들어진 것으로 보이는 next-usequerystate 라는 라이브러리를 사용하고자 했습니다.
next-usequerystate 패키지는 useQueryState라는 훅을 제공해 아주 간단하게 컴포넌트의 상태를 URL에 저장할 수 있게 해줍니다. 사용법은 useState와 매우 유사한데요, 앞서 보여드렸던 예시 코드를 useQueryState를 사용하도록 변경하면 아래와 같습니다.
import { queryTypes, useQueryState } from "next-usequerystate";
export default function ExamplePage() {
const [pageNum, setPageNum] = useQueryState("pageNum", queryTypes.integer.withDefault(1));
return (
<div>
<div>현재 페이지: {pageNum}</div>
<button onClick={() => setPageNum(pageNum + 1)}>다음 페이지</button>
</div>
);
}
TypeScript
복사
useQueryState 사용 예시
보시면 useQueryState에 “pageNum”을 넣어 query string에 어떤 이름으로 저장되는지를 지정하고, queryTypes.integer.withDefault(1) 를 통해 타입 변환과 query string에 없는 경우의 기본값을 명시하는 것을 볼 수 있습니다.
next/router를 사용하는 예제에서의 tryParseInt와 router.push를 사용하는 부분을 간단하게 만들어준 것을 볼 수 있고, useState처럼 함수형 업데이트도 지원합니다.
next-usequerystate의 문제점과 해결
그러나, next-usequerystate에는 두 가지 문제점이 있었습니다. 이를 해결하기 위해 별도의 패키지를 만들게 되었는데요, 어떤 문제점이 있었는지와 어떻게 해결했는지 설명하고자 합니다.
1. 동시에 상태 변경을 여러 번 할 수 없다
URL의 상태를 한 번의 렌더링 안에서 여러 번 변경하는 경우에 일부 상태가 제대로 업데이트되지 않는 문제가 있습니다. 예를 들어 아래와 같은 코드는 정상적으로 작동하지 않습니다.
import { useQueryState } from "next-usequerystate";
export default function ExamplePage() {
const [a, setA] = useQueryState("a", { history: "push" });
const [b, setB] = useQueryState("b", { history: "push" });
const handleClick = () => {
setA("1");
setB("2");
};
return <button onClick={handleClick}>변경</button>;
}
TypeScript
복사
정상적으로 작동하지 않는 코드
실행 결과
실행 결과를 보시면 URL이 /?a=1&b=2로 변경되어야 하는데, /?a=1로 변경됩니다. 그리고 뒤로 가기를 했을 때 /?b=2로 나오게 됩니다. 이는 경로가 처음의 / 에서 /?b=2 로 변경된 후, /?a=1로 덮어씌워지기 때문입니다. 심지어 a가 먼저 바뀌지도 않고 순서가 뒤죽박죽입니다.
이는 사실 next-userquerystate의 문제라기보다는 next router의 push()가 즉각적으로 URL을 바꿔주지 않고, functional update를 지원하지도 않기 때문입니다.
next-usequerystate에서는 이러한 문제를 해결하는 방법으로 await을 이용해서 상태를 동기적으로 변경하거나, useQueryStates로 한 번의 호출로 동시에 변경하는 방법을 제안합니다. (문서 링크)
import { useQueryState } from "next-usequerystate";
export default function ExamplePage() {
const [a, setA] = useQueryState("a", { history: "push" });
const [b, setB] = useQueryState("b", { history: "push" });
const handleClick = async () => {
await setA("1");
await setB("2");
};
}
TypeScript
복사
await을 이용해 덮어씌워지는 문제 없이 상태를 여러 차례 변경하는 방법
실행 결과
이를 이용하면 웬만한 문제는 해결할 수 있지만, 코드가 복잡해지고 여러 컴포넌트로 나누어져 상태를 변경해야 하는 상황에서는 적용할 수 없다는 경우가 생깁니다.
이 밖에도, URL 상태를 변경할 때 history를 replace 하는 대신 push 하는 방식을 사용해야 하는 경우, 위 실행 결과에서 보이듯이 /?a=1과 같이 상태가 부분적으로만 변경된 불필요한 방문 기록이 남게 되는 문제도 있습니다.
해결한 방법
이러한 문제를 아래와 같은 방법으로 근본적으로 해결할 수 있었습니다.
한 번의 렌더링 안에서 setA, setB 가 호출되어 URL 변경이 여러 번 요청 될 시 이들을 합쳐서 하나의 URL 변경으로 처리하는 방식으로 해결했습니다. next/router 대신 이를 감싸는 BatchRouter를 만들었고, BatchRouter는 push, replace 메서드가 호출되더라도 즉시 URL을 변경하는 것이 아니라, 렌더링이 끝나면 하나로 합쳐 URL을 변경해줍니다. 이에 대해서는 아래 세 번째 글에서 자세하게 다루었습니다. 잠시 아랫글부터 읽고 오시는 것도 추천해 드립니다.
2. Client-server hydration error
위에서 Next.js는 서버에서 미리 HTML 파일을 빌드하기 때문에 첫 렌더링에서 router.query가 비어있다고 설명해 드렸는데요, next-usequerystate에서는 router.query를 읽지 않고 window.location에 직접 접근해 읽어옵니다. 그렇기 때문에 아래처럼 hydration 에러가 발생하게 됩니다.
앞서 설명해 드린 것처럼 pre-rendering을 할 때는 query string이 비어있는 상태를 기준으로 HTML 코드가 만들어집니다. 그리고 해당 HTML 코드가 브라우저에서 hydration 되어야 하는데, 클라이언트에서 렌더링할 때 Next.js에서 일부러 비워둔 router.query가 아니라 window.location의 정보를 직접 읽어버리면 UI가 서버에서 보내온 HTML 코드와 달라지어 hydration error가 발생합니다.
따라서 UI에 표시할 때 router.isReady가 false인 경우 useQueryState에서 나온 값을 그대로 사용하면 안 되고, 기본값으로 보여주어야 합니다.
import { queryTypes, useQueryState } from "next-usequerystate";
import { useRouter } from "next/router";
const DEFAULT_PAGE_NUM = 1;
export default function ExamplePage() {
const [pageNum, setPageNum] = useQueryState("pageNum", queryTypes.integer.withDefault(DEFAULT_PAGE_NUM));
const router = useRouter();
return (
<div>
<div>현재 페이지: {router.isReady ? pageNum : DEFAULT_PAGE_NUM}</div>
</div>
);
}
TypeScript
복사
hydration error 피하는 예시
이렇게 router.isReady에 따라 분기해야 하고, 기본값을 별도로 관리해야 한다는 점이 불편하게 느껴져 내부 동작을 변경했습니다. 애초에 window.location에서 읽어오는 대신 router.query에서 읽어오도록 수정해 첫 렌더링에서는 그냥 기본값이 나오도록 만들 수 있었습니다.
next-query-state 패키지
위에서 언급한 두 가지 문제를 해결하고 그 외 몇몇 부분을 더 수정하여(null과 undefined를 구분하고, null을 값처럼 쓸 수 있는 등) next-query-state라는 패키지를 만들게 되었습니다. 자세한 사항은 패키지의 readme를 참고해주세요.
많이 사용해주시면 좋겠고, 기여를 환영합니다.
URL 변경을 여러 번 할 수 없다는 문제를 해결해 next-query-state를 가능하게 한 next-batch-router에 대해서 다음 글로 이어집니다.
글 목록
저자소개
이동엽은 Full stack 개발자로서 유하 서비스의 기틀을 마련하였고, YOUHA market이란 시험 서비스의 Lead engineer를 맡았으며, YOUHA Boost 프로토타입의 백엔드를 구축하는 등 주로 새로운 제품을 설계하고 개척하는 임무를 많이 맡았습니다. 새로운 기술에 대한 도전을 끊임없이 하면서 깊이 있는 이해를 가지려고 노력하고 있습니다.