Search

UI의 상태를 URL에 저장하기 #3 - BatchRouter로 URL 변경을 여러 번 호출하기

생성일
2022/11/17 02:17
태그
Front-end
작성자
이번에는 이전 글에서 next-query-state를 만들며 다루었던 ‘URL의 상태를 여러 번 변경하지 못하는 문제’에 대해서 더 깊게 다룹니다. 그리고 그 문제를 어떻게 해결해 next-batch-router 를 만들었는지 설명합니다.

Next.js router의 문제

윗글의 next-usequerystate를 이용하여 URL을 동시에 변경하는 예시를 next/router만을 사용해보면 아래와 같이 됩니다. useRouter로 가져오는 router는 URL이 변경되어도 값이 바뀌지 않기 때문에 Router 글로벌 객체를 사용해 보았으나, 그래도 문제는 여전합니다.
import Router, { useRouter } from "next/router"; export default function ExamplePage() { const router = useRouter(); const setA = (v: string) => router.push({ query: { ...Router.query, a: v } }); const setB = (v: string) => router.push({ query: { ...Router.query, b: v } }); const handleClick = () => { setA("1"); setB("2"); }; return <button onClick={handleClick}>변경</button>; }
JavaScript
정상적으로 작동하지 않는 코드
실행 결과
실행 결과를 보시면 URL이 /?a=1&b=2로 변경되어야 하는데, /?a=1로 변경됩니다. 그리고 뒤로 가기를 했을 때 /?b=2로 나오게 됩니다. 이는 경로가 처음의 / 에서 /?b=2 로 변경된 후, /?a=1로 덮어씌워지기 때문입니다. 심지어 a가 먼저 바뀌지도 않고 순서가 뒤죽박죽입니다.

URL 변경을 여러 번 호출할 수 있어야 하는 이유

Next.js에서 router를 위처럼 동작하게 한 데에는 굳이 URL 변경을 여러 번에 걸쳐 할 필요가 없다고 생각했기 때문일 것 같습니다. 그러한 필요성을 느끼는 사람들도 거의 없었을 것 같고요.
실제로도 URL 변경을 굳이 router.push()를 여러 번 호출해서 해야 할 이유가 쉽게 떠오르지 않기도 하죠. 그러면 어떤 경우에 URL 변경을 여러 번에 걸쳐 할 필요가 있을까요?
결론부터 일단 말씀드리자면, URL 변경을 여러 번 호출할 수 없어 한 번의 렌더링 안에서 한 번의 URL 변경만 가능하다는 제약이 있다면 코드는 이 제약에 맞추기 위해 더 복잡해지고 결합할 수 밖에 없습니다. 한 예시로, next router에 기반한 useQueryStateuseState와는 달리 서로가 독립적일 수 없습니다. a라는 state와 b라는 state를 바꾼다고 할 때, aa대로 바꾸고, bb대로 독립적으로 바꿀 수 있지 않고, a, b가 둘 다 바뀌어야 하는 경우 같이 바꾸어주어야 합니다.
좀 더 쉽게 예를 들어 아래와 같이 필터와 페이지네이션이 되는 페이지를 생각해봅시다.
전체 예시 코드 실행화면
import { queryTypes, useQueryStates } from "next-usequerystate"; import { useRouter } from "next/router"; export default function Page() { return ( <div> <Filter /> <Pagination /> </div> ); } function Filter() { const router = useRouter(); const [states, setStates] = useQueryStates( { numberFilter: queryTypes.integer, page: queryTypes.integer.withDefault(1), }, ); const onFilterChange = (value: string) => setStates({ numberFilter: Number.parseInt(value), page: 1 }); return ( <div> <div> numberFilter:{" "} {(router.isReady ? states.numberFilter : null) ?? "None"} </div> <input value={states.numberFilter?.toString()} onChange={(e) => onFilterChange(e.target.value)} /> </div> ); } function Pagination() { const router = useRouter(); const [states, setStates] = useQueryStates( { page: queryTypes.integer.withDefault(1), pageSize: queryTypes.integer.withDefault(20), }, ); const onNextPage = () => setStates({ page: states.page + 1 }); const onPageSizeChange = (value: string) => setStates({ pageSize: Number.parseInt(value) }); return ( <div> <div> Page: {router.isReady ? states.page : 1} PageSize:{" "} {router.isReady ? states.pageSize : 20} </div> <button onClick={onNextPage}>다음 페이지</button> <select onChange={(e) => onPageSizeChange(e.target.value)}> <option value="20">20</option> <option value="50">50</option> </select> </div> ); }
JavaScript
전체 예시 코드

한 컴포넌트 안에서 URL 변경을 여러 번 호출하는 케이스

minSubscriber 필터 수치를 바꾸게 되면 필터 조건이 달라졌으니 페이지를 1페이지로 옮겨주어야 합니다. 이 경우 URL 변경을 여러 번 호출하지 않으려면 한 번에 필터를 바꾸어주어야 하므로 아래와 같은 코드가 필요합니다.
const [states, setStates] = useQueryStates( { numberFilter: queryTypes.integer, page: queryTypes.integer.withDefault(1), }, ); const onFilterChange = (value: string) => setStates({ numberFilter: Number.parseInt(value), page: 1 });
JavaScript
보시면 필터 상태와 페이지 상태를 statessetStates를 통해 하나의 객체로 관리해야 합니다.
만약 URL 변경을 여러 번 호출할 수 있다면 아래처럼 필터와 페이지를 분리해 다룰 수 있고, 페이지 처리 로직을 usePagination이라는 훅으로 만들어 재사용하고 관심사를 구분하기 더 쉬워집니다.
const [numberFilter, setNumberFilter] = useQueryState( "numberFilter", queryTypes.integer, ); const [pagination, setPagination] = usePagination(); const onFilterChange = (value: string) => { setNumberFilter(Number.parseInt(value)); setPagination({ page: 1 }); };
JavaScript

여러 컴포넌트가 URL 변경을 여러 번 호출하는 케이스

여러 개의 분리된 컴포넌트에서 동시에 URL 변경을 해야 하는 경우는 더 까다롭습니다.
만약 URL에 파라미터가 없으면 페이지 로딩 직후 기본값을 넣어주어야 한다고 합시다. 만약 아래 코드처럼 만들면 useEffect가 연속적으로 실행되어 URL 변경이 여러 번 호출되어 URL이 정상적으로 변경되지 않습니다.
function Filter() { const [states, setStates] = useQueryStates({ numberFilter: queryTypes.integer, page: queryTypes.integer, }); useEffect(() => { setStates({ numberFilter: states.numberFilter || 0 }); }, [states.numberFilter]); } function Pagination() { const [states, setStates] = useQueryStates({ page: queryTypes.integer, pageSize: queryTypes.integer, }); useEffect(() => { const payload = { page: states.page || 1, pageSize: states.pageSize || 20, }; setStates(payload); }, [states.page, states.pageSize]); }
JavaScript
URL이 정상적으로 변경되지 않는 코드
원래 ?numberFilter=0&page=1&pageSize=20 로 변경되어야 하는데 그렇지 못한 실행 결과
따라서 아래와 같이 공통 조상인 Page 컴포넌트에서 useQueryStates를 또 사용해야 합니다. 만약 필터 종류가 늘어나거나, 요구사항이 복잡해진다면 Page 컴포넌트는 훨씬 더 복잡해질 것입니다. numberFilter, page 등의 URL 파라미터를 사용하는 컴포넌트의 수가 많아져 하나의 파라미터가 여러 컴포넌트에서 읽고 쓰이게 될 수 있습니다. 이는 URL이 동시에 변경되는 경우가 생기는지 파악하기 힘들어 알지 못한 버그가 발생할 수도 있고요. 구조적인 관점에서도 가능하면 필터 관련 관심사는 Filter 컴포넌트 안으로 모으는 편이 좋을 것입니다.
export default function Page() { const [states, setStates] = useQueryStates({ numberFilter: queryTypes.integer, page: queryTypes.integer, pageSize: queryTypes.integer, }); useEffect(() => { const payload = { numberFilter: states.numberFilter || 0, page: states.page || 1, pageSize: states.pageSize || 20, }; setStates(payload); }, [states.numberFilter, states.page, states.pageSize]); return ( <div> <Filter /> <Pagination /> </div> ); }
TypeScript
URL 변경을 한 번만 호출하기 위해 공통 조상에서 useEffect 사용하는 코드
이처럼 URL 변경을 여러 번 호출할 수 없으면 모든 URL 변경 코드가 서로에게 예측하기 힘든 영향을 줄 수 있게 되며, 이로 인한 문제를 피하려 하면서 코드의 결합이 강해지고 복잡해집니다.

해결방안: URL 상태를 Batch Update 하기

URL 상태를 여러 번 변경할 때 발생하는 문제를 어떻게 해결할 수 있을지 고민하다가 이런 생각이 떠올랐습니다.
URL에 상태 변경이 일어나야 할 때, 이를 즉시 반영하지 않고 다음 렌더링 때 하나로 합쳐 처리하도록 하면 문제를 해결할 수 있지 않을까? React의 Batch Update 처럼요. (공식 문서)
이렇게 하면 URL 변경을 여러 번 호출해도 되겠다고 생각했습니다.
이를 구현하려면 URL을 변경하려 할 때 즉시 페이지 이동을 발생시키지 않고, 변경 요청을 쌓아두도록 만들어야 합니다. 그런데 이러한 상태 변경 요청을 어디에 저장해야 할까요? 어딘가 상위 레벨에서 요청을 모아 둘 곳이 필요합니다.
이를 위해 BatchRouter 클래스와 BatchRouterProvider 컴포넌트를 만들었습니다.

BatchRouter

BatchRouter 클래스는 컴포넌트에서 URL 상태를 변경해야 할 때 Next.js router 대신 이용하게 됩니다. 컴포넌트는 아래 예시처럼 Next.js router의 push(), replace() 메서드와 유사하게 push(), replace() 메서드를 호출해 URL 상태를 변경할 수 있습니다.
import { useBatchRouter } from "next-batch-router"; import { useRouter } from "next/router"; export default () => { const batchRouter = useBatchRouter(); // (1) const router = useRouter(); const name = router.query.name; const setName = (name: string | null) => batchRouter.push({ name: name }); // (2) return ( // useQueryState 사용 예시와 동일 <> <h1>Hello, {name || "anonymous visitor"}!</h1> <input value={name || ""} onChange={(e) => setName(e.target.value)} /> <button onClick={() => setName(null)}>Clear</button> </> ); };
TypeScript
BatchRouter 사용 예시
BatchRouterpush() 호출로 들어온 상태 변경 요청을 queue에 저장만 해두고, 이후 flush() 메서드가 호출되면 그제야 요청들을 하나로 합쳐 Next.js router를 이용해 URL을 변경시켜줍니다.
BatchRouter의 단순화된 구현은 아래와 같습니다. 복잡한 기능은 빼고 query 관련 로직만 남겼습니다.
import { parseUrl } from "next/dist/shared/lib/router/utils/parse-url"; import Router from "next/router"; type QueryValue = string | number | boolean | string[] | number[] | boolean[] | null; type QueryObject = Record<string, QueryValue | undefined>; type SetQueryAction = QueryObject | ((prev: QueryObject) => QueryObject); export class BatchRouter { private forceRender: () => void; private queue: SetQueryAction[] = []; private pushHistory = false; public constructor(forceRender: () => void) { this.forceRender = forceRender; } public async push(query: SetQueryAction) { return this.change("push", query); } public async replace(query: SetQueryAction) { return this.change("replace", query); } private async change(history: "push" | "replace", query: SetQueryAction) { this.queue.push(query); if (history === "push") this.pushHistory = true; this.forceRender(); } public async flush() { if (!this.queue.length) return; let newQuery: QueryObject = { ...Router.query }; for (const query of this.queue) { if (isUpdaterFunction(query)) newQuery = query(newQuery); else Object.entries(query) .filter(([k, v]) => v !== undefined) .forEach(([k, v]) => (newQuery[k] = v)); } (this.pushHistory ? Router.push : Router.replace)({ query: newQuery }); this.clear(); } private clear() { this.queue = []; this.pushHistory = false; } } function isUpdaterFunction<T extends QueryObject>( input: T | ((prevState: T) => T) ): input is (prevState: T) => T { return typeof input === "function"; }
TypeScript

BatchRouterProvider

BatchRouterProvider는 두 가지 역할을 가집니다.
1.
BatchRouter 인스턴스를 생성하고 자식 컴포넌트들에게 컨텍스트 API를 통해 제공
2.
flush()를 호출하여 “다음 렌더링에 한꺼번에 처리" 하는 동작을 트리거
import React, { useContext, useReducer, useRef } from "react"; import { BatchRouter, BatchRouterCore } from "./BatchRouterCore"; const BatchRouterContext = React.createContext<BatchRouterCore | null>(null); /** Provider required to use useBatchRouter hook */ export function BatchRouterProvider(props: Props) { const [, forceRender] = useReducer((prev) => prev + 1, 0); const batchRouter = useRef(new BatchRouterCore(forceRender)); batchRouter.current.flush(); return ( <BatchRouterContext.Provider value={batchRouter.current}> {props.children} </BatchRouterContext.Provider> ); } export function useBatchRouter() { return useContext(BatchRouterContext); }
TypeScript
이를 위해 아래처럼 컴포넌트 부모 트리에 BatchRouterProvider가 존재해야 합니다. Next.js에서는 보통 pages/_app.tsx에 두는 것이 일반적입니다.
function App({ Component, pageProps }) { return ( <BatchRouterProvider> <Component {...pageProps} /> </BatchRouterProvider> ); }
TypeScript

동작 설명

어떤 순서로 동작하는지를 순서대로 설명해보면 아래와 같습니다.
1.
컴포넌트 트리 최상위의 BatchRouterProvider 에서 useReducer를 이용해 리렌더링을 일으킬 수 있는 forceRender를 만들고, 이를 집어넣어 batchRouter 인스턴스를 생성합니다. batchRouter는 컨텍스트 API를 통해 자식 컴포넌트들에게 제공됩니다.
2.
자식인 ChildComponent에서 useBatchRouter 훅을 통해 batchRouter 객체를 받습니다.
3.
ChildComponent에서 batchRouter.push()를 호출하면 변경할 url이 batchRouter.queue에 저장되고, forceRender가 호출되어 BatchRouterProvider의 리렌더링이 예약됩니다.
4.
batchRouter.push() 는 같은 컴포넌트에서 여러 번, 혹은 서로 다른 컴포넌트에서도 여러 번 호출될 수 있습니다. 변경할 url에 대한 정보가 계속 batchRouter.queue에 저장됩니다.
5.
렌더링이 모두 끝나면, 예약된 대로 BatchRouterProvider가 리렌더링 됩니다. 최상위에 위치하기 때문에 제일 먼저 렌더링되며, batchRouter.flush()를 호출합니다.
6.
flush()는 쌓여있던 url 수정 요청들을 하나로 합쳐 Router.push 또는 Router.replace를 통해 url을 실제로 변경합니다. 이는 렌더링을 도중에 중단시키고, URL이 변경된 상태로 새로 리렌더링을 일으킵니다.
7.
리렌더링이 일어나 자식 컴포넌트들의 UI가 알맞게 변경됩니다.
이렇게 URL 변경을 연속적으로 여러 번 할 수 있는 방법을 마련했고, 앞의 글 UI의 상태를 URL에 저장하기 #2 - URL 상태 관리를 useState처럼 쉽게 만들기 에서 useQueryState를 만드는데 사용하게 되었습니다.

next-batch-router

위에서 설명드린 방법에 기반하여 기능을 좀 더 추가해 npm 패키지로 만들었습니다. 자세한 사항은 패키지의 README를 확인해주세요.
직접 사용해도 되나 next-query-state를 통해 이용하시는 것을 더 추천드립니다.
이렇게 만들어진 패키지들을 이용해 어떻게 리팩토링했는지 다음 글로 이어집니다.
글 목록

저자소개

이동엽은 Full stack 개발자로서 유하 서비스의 기틀을 마련하였고, YOUHA market이란 시험 서비스의 Lead engineer를 맡았으며, YOUHA Boost 프로토타입의 백엔드를 구축하는 등 주로 새로운 제품을 설계하고 개척하는 임무를 많이 맡았습니다. 새로운 기술에 대한 도전을 끊임없이 하면서 깊이 있는 이해를 가지려고 노력하고 있습니다.