⛰️ 일산에 사는 개발자들

URL의 쿼리 문자열을 상태로 관리하기

dorage
dorage

요구사항

NextJS에서 상품을 필터링할 수 있는 컴포넌트를 제작하고, 모든 필터에 정보를 URL에 쿼리형태로 저장해야 했다.

이러한 기능의 필요성은 본인이 필터링했던 검색결과의 URL을 공유하면 동일한 필터가 적용된 화면이 보여야 한다는 니즈에서 왔다.

또한, 동시에 새로운 모달을 구현하며, 모달이 열린 상태에서 브라우저의 뒤로가기의 액션이 단순히 모달의 종료로서 작동을 했으면 좋겠다는 부분이 있어서 이 부분도 쿼리를 변경하는 history.pushState로 해결이 될 수 있을 거라 생각을 했다.

그래서 쿼리를 일종의 전역 상태로서 사용할 방법을 찾기 위해 여러 시행착오를 거쳐보았다.

현재 일반적인 상황에서는 안정적으로 작동을 한다고 생각을 하고, 이를 이용해 다른 쿼리를 조작하는 모든 부분을 해당 형태로 교체를 해나가고 있다.

  1. url에 query 문자열이 있다면, 이 값들을 기반으로 페이지가 렌더링 되어야 한다.

  2. history API를 통해 query 문자열에 변화를 주면, 이 값들을 기반으로 페이지가 렌더링 되어야 한다.

종국에는 다음과 같은 형태의 시스템이 완성되었다.

최종적으로 완성된 쿼리 문자열 관리 플로우차트

사용된 라이브러리

외부 라이브러리는 기존 프로젝트에서 상태관리에 사용하던 Recoil과 URL 쿼리스트링을 파싱/스트링화 시켜주는 Query-String을 사용했다.

URL 추적하기

Next.JS에는 routeChangeStart, routeChangeComplete 라는 Next.JS 기본 router가 제공하는 URL의 변경에 따라 실행되는 이벤트가 있다.

const router = useRouter();
useEffect(() => {
    router.event.on('routeChangeStart', () => {
        console.log('start');
    });
    router.event.on('routeChangeComplete', () => {
        console.log('complete');
    });
}, []);

하지만 이러한 이벤트는 브라우저가 호출한은 것이 아닌 Next.JS 가 router의 변경을 감지하고, 발동이 되게 되는데, 이 부분에서 항상 일정하게 이벤트가 호출되지 못하는 모습을 보여주었다.

특히 뒤로가기 시, 이벤트가 간헐적으로 호출되는 현상이 심했다.

그래서 가장 단순하게 URL을 직접 관찰하는 방법을 사용하기로 결정했다.

hrefChecker라는 함수를 만들어 1초에 대략 20번 정도 URL이 변경이 되었는지 확인하고, 변경점이 있다면, callback함수를 호출하는 형태의 함수를 만들었다.

const hrefChecker = () => {
    let callback = () => {};
    let prevHref = '';

    setInterval(() => {}, 50);

    return [
        (cb) => {
            callback = cb;
        },
        (href) => {
            prevHref = href;
        },
    ];
};

export const [HrefChecker, setPrevHref] = hrefChecker();

또한 변경점이 적용되면 렌더링에 적용을 해야하므로, 변경점을 알리고 구독할 수 있는 notifier 훅을 만들었다.

const useHrefChangeNotifier = () => {
    const [changed, setChanged] = useState({});

    useEffect(() => {
        HrefChecker(() => setChanged({}));
    }, []);

    return [changed];
};

쿼리 문자열을 저장하기

쿼리를 어떻게 저장할까도 상당히 깊게 고민을 해보았다. 수많은 시행착오가 있었지만, 배포만을 기준으로 보면 2번의 시도가 있었다.

현재는 2번의 형태로 파싱된 쿼리를 관리하고 있다.

  1. Recoil atom(state) + context (state + action)

    recoil atom이 모든 쿼리스트링을 객체형태로 갖고 있고, 작은 context들이 필요한 필드값들을 나누어 갖고 해당 필드값만을 조정하던 방식이였다. 첫 번째 배포 당시까지는 이러한 형태로 관리를 했었는데, 이러한 방식은 싱크에 문제가 많았어서 바로 픽스에 들어갔었다.

  2. Recoil atom(state) + context (action)

    현재 관리하고 있는 방식으로, atom이 url 쿼리 문자열의 파싱된 객체를 갖고 있고, _app 에서 전파되는 전역 컨텍스트로 해당 atom을 조작할 수 있는 액션을 전파하는 방식이다. 간단하게 사용하는걸 목표로 만들었고, 단순히 객체를 조정하듯이 사용할 수 있게끔 만들었다.

// ████████████████████████████████████████████████████████
// Query Atom
// ████████████████████████████████████████████████████████

/**
 * query string에 parse와 stringify에 사용되는 공통 옵션입니다
 */
export const QS_PARSE_OPTIONS = {
    arrayFormat: 'separator',
    arrayFormatSeparator: '|',
};
export const QS_STRINGIFY_OPTIONS = {
    arrayFormat: 'separator',
    arrayFormatSeparator: '|',
    skipEmptyString: true,
    skipNull: true,
};

const initialQuery = () => {
    if (globalThis.location == null) return {};
    const search = getCurrentSearch();
    const parsed = QueryString.parse(search, QS_PARSE_OPTIONS);
    return parsed;
};

/**
 * 쿼리 스트링이 바뀔 때마다, 주소창에 이를 적용시킵니다
 */
const SetQueryStringAtomEffect = ({ onSet }) => {
    onSet((newValue) => {
        const { pathname } = location;
        const search = QueryString.stringify(
            newValue.query,
            QS_STRINGIFY_OPTIONS
        );
        const as = `${pathname}?${search}`;
        // 쿼리에 변화에 따른 변화는 HrefChecker에 걸리지 않게 하기 위함
        setPrevHref(search);

        const state = { ...history.state, as };
        if (newValue.action === 'PUSH') history.pushState(state, '', as);
        if (newValue.action === 'REPLACE') history.replaceState(state, '', as);
    });
};

/**
 * profile 페이지에서 tab 선택 상태
 */
export const QueryStringAtom = {
    key: `queryStringAtom`,
    default: { action: 'REPLACE', pathname: '', query: initialQuery() },
    effects: [SetQueryStringAtomEffect],
};

// ████████████████████████████████████████████████████████
// Query Context
// ████████████████████████████████████████████████████████

export const QueryContext = React.createContext({
    add: () => {},
    replace: () => {},
    remove: () => {},
    clear: () => {},
    getValue: () => '',
    getArray: () => [],
});

const QueryContextProvider = ({ children }) => {
    const { query } = useRecoilValue(QueryStringAtom);
    const setQueryStringAtom = useSetRecoilState(QueryStringAtom);
    const router = useRouter();
    const [change] = useHrefChangeNotifier();

    /**
     * location.pathname이 변경될 때 마다, state를 주소와 동기화합니다
     */
    useEffect(() => {
        const { href, pathname } = location;
        const [_, search] = href.split('?');

        const parsed = QueryString.parse(search, QS_PARSE_OPTIONS);

        setQueryStringAtom(() => ({
            action: 'REPLACE',
            pathname,
            query: parsed,
        }));
    }, [change]);

    /**
     * 하나의 값을 얻습니다
     * 만약 값이 여러개로 추정된다면 두번째 인자를 통해 원하는 위치에서 값을 얻습니다
     * 값이 없다면, 빈 문자열을 반환합니다
     * @returns
     */
    const getValue = (key, at = 0) => {};

    /**
     * 해당 쿼리키의 값을 배열형태로 받습니다.
     * 값이 없다면, 빈 배열을 반환합니다
     * @returns
     */
    const getArray = (key) => {};

    /**
     * 값이 추가되는 형태가 아닌 할당되는 형태를 표현하기 위해 만들었습니다
     * query[key]를 json[key] 로 교체합니다
     */
    const add = (action, json) => {};

    /**
     * 배열형태의 쿼리스트링에서 원하는 값을 삭제하기 위해서 만들었습니다.
     * query[key]에 json[key] 를 삭제합니다
     */
    const remove = (action, json) => {};

    /**
     * 값이 추가되는 형태가 아닌 할당되는 형태를 표현하기 위해 만들었습니다 ( 예) 검색어, 정렬기준 )
     * query[key]를 json[key] 로 교체합니다
     */
    const replace = (action, json) => {};

    /**
     * 원하는 key의 쿼리를 쿼리스트링에서 삭제하기 위해서 만들었습니다
     *  query[...keys] 필드를 삭제합니다
     */
    const clear = (action, ...keys) => {};

    return (
        <QueryContext.Provider
            value={{ getValue, getArray, add, remove, replace, clear }}
        >
            {children}
        </QueryContext.Provider>
    );
};

export default QueryContextProvider;

필터링 영상