react

[react] SPA에서 라우팅 2: 쿼리 파라미터를 사용하는 이유

tea-tea 2025. 3. 4. 15:32

이 글은 SPA에서 라우팅을 하는 전략에 대해 개괄한다.

이전 글의 링크는 아래와 같다.

[react] SPA에서 쿼리 파라미터를 사용하는 이유

 

[react] SPA에서 라우팅 1: 기본 전략

SPA는 기술적으로 단일 페이지인 프론트엔드 앱이지만, 유저에게는 몇 가지 이유로 MPA처럼 보여야 하는 경우가 있다. SPA에서는 UX를 위해 라우팅 기능이 기본적으로 필요하다.프레임워크가 없는

chartist1206.tistory.com

 

이전 글에서는 SPA에서 MPA처럼 라우트를 적극 사용하는 이유에 대해 알아보았다.

이번에는 라우팅 전략에서도 동적 처리에 중요한 쿼리 파라미터에 대해 말해보자.

 

앞의 글에서는 라우팅 전략을 pathname, nested route, dynamic route로 구분했고, 특히 dynamic route가 페이지 내 데이터처럼 동적 처리에 유용하다고 말하였다. 그렇다면 쿼리 파라미터는 어떤 경우에 사용하는가.

 

쿼리 파라미터를 사용하는 조건


1. 페이지 내에서 부차적인 동적 처리를 할 때

만약 문서 전체 보기 페이지에 이런 요구사항이 추가된다고 해보자. 

  • 페이지네이션 : 문서 전체 조회를 할 때 20개를 단위로 포스트를 나눠서 보여줘.
  • 리스트 타입 : 문서 리스트의 나열 방식을 썸네일과 리스트형중 하나로 선택할 수 있게 해줘.
  • 검색 : 사용자가 리스트에서 특정 문서를 검색하면, 해당하는 문서만 필터링해서 보여줘.

이런 요구사항을 /post/list 라는 url에서 어떻게 만족할 수 있을까.

  • 일단, 라우팅의 pathname (path, list) 자체를 바꾸는 건 곤란하다. 이 요구사항은 '기존 페이지를 유지'하며 새로운 기능을 덧붙이는 거니까.
  • 중첩 라우팅 방식도 곤란하다. 이 요구사항은 '새로운 내부 페이지를 추가하는 것'이 아니라 '기존 기능에 동적인 추가 기능'을 덧붙이는 거니까.

그나마 위에 설명에서 적합한 방식을 고른다면 동적 라우트일 것이다. 앞서 말했듯, 동적 라우트를 추출하여 페이지 렌더링을 동적으로 처리하는 건 일반적인 패턴이기 때문이다. 문제는 동적 라우트는 단일한 인자 그 이상의 역할을 하기 힘들다.

  • 예컨대, 위 기능을 구현하기 위해서 기존 경로에 동적 라우트를 붙여서 /post/list/"page번호_list타입_검색문자열" 형식으로 구성하여 파싱한다고 가정하자("/post/list/1_small-thumnail_포스트이름1" 이런 식으로) 하지만, 이 방식은 문제가 있다.
    • 언더바로 연결되는 각 값이 무엇을 의미하는지 한 눈에 들어오지 않는다. (1이 페이지 넘버인가? 아니면 리스트 타입인가? url만 봐서는 알 수 없다)
    • 만약 옵션 사항이 늘어난다면 언더바로 이어지는 옵션 사항들이 늘어날 것이다. 이를 다른 동료 개발자들이 알아보기 어렵다(언더바 4번째에 오는 옵션이 리스트 타입인지, 페이지네이션인지 점점 헷갈릴 것이다)

이 때 쿼리 파라미터가 빛을 발한다. 이 방식을 사용하면 아래처럼 변경이 가능하다.

/post/list?page_no=1&list_type=thumnail&search=포스트이름1

 

정리하자면 컴포넌트 단위 렌더링 패턴에서 pathname은 전체 레이아웃 자체를, nested route는 내부에 추가되는 컴포넌트를 의미한다면, dynamic route는 컴포넌트의 기능 및 데이터(주로 데이터)의 동적 처리를 담당하는 것이 일반적이다. 반면, 쿼리 파라미터는 부차적인 동적 처리(필터링, 나열 방식, 테마 등등)를 하는 데 적합하다.

 

2. 유저의 관점에서 문서 내용을 열람할 때 영향을 주는가

앞서 말했듯, 유저들은 웹을 아직은 문서처럼 정보가 담긴 페이지들로 여기는 경향이 있다.

반면, SPA는 동일한 페이지 내에서도 탭이나 페이지네이션처럼 동적으로 문서 내용을 변경한다. 

이처럼 동적 처리가 문서 내용을 변화시키는 경우에는 유저들이 특정한 탭, 페이지, 검색 결과를 기억하고 싶어할 수 있다.

반면, 내가 페이지 내에서 어디 쯤을 보고 있었는지 혹은 선택된 테마가 무엇인지, 사이드 메뉴로 무엇을 선택했는지는 그렇게 기억하려고 하지 않을 수 있다. 이런 경우에는 쿼리 파라미터로 기억할 필요성이 떨어진다.

 

3. 영속적으로 저장될 필요가 있는가.

쉽게 말해, 새로고침이 되어도 그다지 기억될 필요가 없고 오히려 리셋되길 바라는 데이터가 여기 해당한다.

 

4. 유저가 접근해도 되는 데이터인가.

URL은 유저가 마음대로 접근 가능하므로 민감한 데이터를 담아서는 안된다. 페이지 위치나 검색 문자열은 유저가 마음대로 접근하고 변경하며 기억해도 상관 없다. 하지만, 아이디나 비밀번호라든지, 게임 재화양 같은 걸 여기 기억하면 쉽게 위변조가 가능할 것이다.

반영형 XSS와 url
XSS는 해커가 악성 스크립트를 다른 유저의 웹 페이지로 전달하여 실행시키는 해킹 방식을 의미한다. 이 중에서 반영형 XSS는 백엔드를 거치지 않고 실행되는 것인데, 가령, url에서 search_string이라는 파라미터를 특정한 div 내에 그대로 html로 넣는다면, 해당 파라미터에 <script></script>를 삽입하고 이를 불특정 다수에게 이메일 링크로 누르도록 유도하여 다른 사람 웹 페이지에서 스크립트를 실행시킬 수 있다.

따라서, url 값을 html로 렌더링할 때는 익스케이프 처리가 필수이다.

 

쿼리 파라미터의 사용 사례


용도 

페이지네이션, 검색 문자열, 정렬 방식, 선택된 탭 - 주로 문서 내용을 동적 처리하고 이를 기억할 때 사용했는데, 이런 느낌이었다.

{url 경로}?sort=가나다순&search_string=&page=1

 

구현 방식: 쿼리 파라미터 - state 연동 전략

이 전략은 쿼리 파라미터를 리엑트의 state처럼 사용하는 것으로 다음과 같다.

  • 쿼리 파라미터를 기반으로 동적 로직을 분기 처리
  • 유저와의 상호작용 과정에서 쿼리 파라미터를 업데이트하는 방식

 

react-router-dom(rrd)을 기준으로 useSearchParam훅을 사용하면 쿼리 파라미터를 추출하고 쿼리 파라미터를 업데이트할 수 있다. 보통 패턴은 이런 식이다.

// @ts-check
import React from 'react';
import { useSearchParams } from 'react-router-dom';

export default function TestPage() {
    //   페이지용 쿼리파라미터 추출
    // setState 함수
    const [searchParams, setSearchParams] = useSearchParams();

    return (
        <>
            <div>
                {/* 쿼리 파라미터를 이용한 동적 처리 */}
                <p>page {searchParams.get('page')}</p>
                <p>sort {searchParams.get('sort')}</p>

                {/* 쿼리 파라미터 업데이트 */}
                <button
                    onClick={() => {
                        setSearchParams();
                    }}
                >
                    update query parameter
                </button>
            </div>
        </>
    );
}
필자는 쿼리파라미터를 추출하여 사용할 시, 변수명에 접두사로 QP를 붙여서 구분한다.

const [searchParams, setSearchParams] = useSearchParams();
const QPpage = searchParams.get('page');
const QPsort = searchParams.get('sort');

보다시피, 쿼리 파라미터를 추출하고 업데이트 훅을 사용하는 것 자체는 별로 어렵지 않다.

하지만, 여기에는 몇 가지 보완할 사항이 있다.

 

1. 쿼리 파라미터의 예외 처리

쿼리 파라미터는 useState와 다르게 유저가 직접 url로 수정 가능하므로 예상치 못한 값들이 들어올 수 있다. 예컨대 page 넘버로 문자열이 온다던지, sort_type으로 예상하던 타입이 아닌 것이 들어오던지 말이다. 따라서, 쿼리 파라미터에 허용된 값이 들어오는지 유효성 검사가 필요하고, 필요 시 값을 변경하는 예외 처리 로직이 필요하다.

 

2. 쿼리 파라미터 업데이트 방식의 복잡성

setSearchParams으로 쿼리 파라미터를 수정하기 위해서는 불변성을 지키기 위해 new UrlSearchParam()으로 인스턴스를 만들고 메소드로 수정하는 방식을 써야 한다. 그래서 변경하는 것이 많아지면 로직이 길어서 지저분해진다.

 

3. 브라우저 히스토리 미반영 문제

setSearchParams로 변경한 url은 브라우저 히스토리 스택에 추가되지 않는다. 쉽게 말해, 뒤로 가기나 앞으로 가기를 할 때 이전 상태로 돌아갈 수 없다. 따라서, useLocation 훅으로 기존 path에 쿼리 파라미터만 변경한 것을 합치고, useNavigate 훅으로 리다이렉트 처리해야 한다.

 

3가지 다 구현해보면 생각보다 귀찮다. 그래서 위 3가지를 해주는 공용 훅 2가지를 만들었다.

 

훅: useValidateUrlParams, useSetQueryParams

useValidateUrlParams은 해당 페이지에서 사용할 쿼리파라미터를 선언하고 허용 값을 지정하며 유효성 검사 실패 시 콜백 함수를 통해 쿼리 파라미터를 변경할 수 있다.

 

아래는 훅 사용 예시이다.

  • 인자
    • parameters
      • 사용할 쿼리 파라미터를 선언
      • 쿼리 파라미터의 제약 조건 설정
      • 제약 조건으로 유효성 검사 실패 시, 실행될 콜백 설정 (예시에서는 기본 쿼리파라미터 값으로 롤백함)
  • 반환 값
    • queryParam
      • 사용할 쿼리 파라미터를 추출
    • setQueryParams
      • 쿼리 파라미터를 변경하고 웹 히스토리 스택에 추가. (별도의 훅으로 useSetQueryParams)
//훅 호출 예시
const {
        //쿼리 파라미터 setter
        setQueryParams,
        
        // 이 페이지에서 사용하는 파라미터
        // 파라미터 값이 없거나 허용 값이 아니면 기본 파라미터 값으로 변경
        queryParam: {
            map: QPmap,
            regionCategory: QPregionCategory,
            page: QPpage,
        },
    } = useValidateUrlParams({
        parameters: [
            {
            // 사용할 쿼리 파라미터
                key: MAP,
                value: {
                // 기본 값
                    default: DEFAUT_PAGE_SETTING[MAP],
                    
                    // 제약 조건
                    check: {
                    	// 허용되는 값
                        allowValues: {
                            values: [
                                NATIONAL_RIVER_FLOOD_MAP,
                                PROVINCE_RIVER_FLOOD_MAP,
                                CITY_FLOOD_MAP,
                            ],
                            // 제약 조건 예외 발생 시, 실행되는 콜백
                            callback: ({ key, value, allowValues }) => {
                                return allowValues[0];
                            },
                        },
                    },
                },
            },
            {
                key: REGION_CATEGORY,
                value: {
                    default: DEFAUT_PAGE_SETTING[REGION_CATEGORY],
                    check: {
                        allowValues: {
                            values: [
                                ALL_AREA,
                                ADMINISTRATIVE_AREA,
                                REGION,
                                WATERSHED,
                            ],
                            callback: ({ key, value, allowValues }) => {
                                return allowValues[0];
                            },
                        },
                    },
                },
            },
            {
                key: PAGE,
                value: {
                    default: DEFAUT_PAGE_SETTING[PAGE],
                },
            },
        ],
    });
    
    
    // 쿼리 파라미터 업데이트 함수 호출 예시
       setQueryParams({
       // 변경할 파라미터
                param: { page },
                
                option: {
                // replace를 true로 설정하면 히스토리 스택에 추가
                    navigate: { replace: false },
                },
            });

 

구현 소스 코드

아래 두 가지 소스 코드를 복제해서 사용하면 된다.

 

@useValidateUrlParams.js

// @ts-check
import { useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useSetQueryParams } from './useSetQueryParams';

/**
 * @typedef IUseValidateUrlParamsArgParametersElement
 * @property {string} key
 * @property {{
 * default?: string;
 * check?:{
 *  allowValues?:{
 *   values:string[];
 *   callback:(arg:{key:string; value:string | undefined; allowValues:string[]})=>void | string
 *  }
 * }
 * }} [value]
 */

/**

 * @typedef  useValidateUrlParamsReturn
 * @property {URLSearchParams} searchParams
 * @property {import('react-router-dom').SetURLSearchParams} setSearchParams
 * @property {{[key:string] : string | undefined}} queryParam
 * @property {(arg:{param:{[key:string] : string | undefined}, 
 * option?:{
 * navigate?:import('react-router-dom').NavigateOptions
 * }})
 * =>void} setQueryParams
 */

/**
 * @callback FUseValidateUrlParams
 * @param {{parameters:IUseValidateUrlParamsArgParametersElement[]}} arg - 함수의 입력 객체
 * @return {useValidateUrlParamsReturn} - 함수의 반환 값
 */

/**
 * @type {FUseValidateUrlParams}
 */
export const useValidateUrlParams = ({ parameters }) => {
    const [searchParams, setSearchParams] = useSearchParams();
    const navigate = useNavigate();
    const { pathname, hash } = useLocation();

    const parseParameters = (function (argParameters) {
        const [modelQueryParam, modelConstraint] = parameters.reduce(
            (acc, curVal) => {
                acc[0].push([
                    curVal.key,
                    searchParams.get(curVal.key) || curVal?.value?.default,
                ]);
                acc[1].push([curVal.key, curVal?.value?.check]);
                return acc;
            },
            [[], []],
        );

        return [modelQueryParam, modelConstraint];
    })(parameters);

    // url의 필수 쿼리 파라미터
    // 없을 시 기본 파라미터 세팅
    const queryParam = Object.fromEntries(parseParameters[0]);

    /**
     * 쿼리 파라미터의 제약조건
     * @type {{[key:string]:
     * IUseValidateUrlParamsArgParametersElement["value"]["check"]
     * | undefined}}
     */
    const constraint = Object.fromEntries(parseParameters[1]);

    const setQueryParams = useSetQueryParams();

    useEffect(() => {
        // 파라미터 존재 여부
        const { newParams: newParams1, isUpdated: isUpdated1 } =
            (function setDefaultQueryParams(argSearchParams, argQueryParam) {
                return Object.entries(argQueryParam).reduce(
                    (acc, currentValue) => {
                        const existParam = argSearchParams.get(currentValue[0]);
                        if (existParam) {
                            acc.newParams.set(currentValue[0], existParam);
                            return acc;
                        }
                        acc.isUpdated = true;
                        acc.newParams.set(
                            currentValue[0],
                            currentValue[1] === undefined
                                ? ''
                                : currentValue[1],
                        );

                        return acc;
                    },
                    {
                        newParams: new URLSearchParams(argSearchParams),
                        isUpdated: false,
                    },
                );
            })(searchParams, queryParam);

        // 파라미터의 올바른 값 여부 체크 & 허용되지 않을 값일 시, callback 실행
        const { newParams: newParams2, isUpdated: isUpdated2 } =
            (function checkConstrainAllowValues(
                argSearchParams,
                argConstraint,
                argIsUpdated,
            ) {
                const newParams = new URLSearchParams(argSearchParams);
                let isUpdated = argIsUpdated;
                Object.entries(argConstraint).forEach((el) => {
                    const isNotAllowedParams = (() => {
                        if (
                            !(
                                el[1]?.allowValues?.callback &&
                                el[1]?.allowValues.values
                            )
                        )
                            return false;

                        return !el[1].allowValues.values.includes(
                            newParams.get(el[0]),
                        );
                    })();

                    if (!isNotAllowedParams) return;
                    const newValue = el[1].allowValues.callback({
                        key: el[0],
                        value: searchParams.get(el[0]),
                        allowValues: el[1].allowValues.values,
                    });
                    if (newValue) newParams.set(el[0], newValue);
                    isUpdated = true;

                    return;
                });

                return { newParams, isUpdated };
            })(newParams1, constraint, isUpdated1);

        // 새 url로 리다이렉트 replace
        if (isUpdated2) {
            const newUrl = `${pathname}?${newParams2.toString()}${hash}`;
            navigate(newUrl, { replace: true });
        }
    }, []);

    return { searchParams, setSearchParams, queryParam, setQueryParams };
};

 

@useSetQueryParams.js

// @ts-check

import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';

/**
 * 쿼리파라미터를 redirect 하는 함수
 *
 */
export const useSetQueryParams = function () {
    const [searchParams, setSearchParams] = useSearchParams();
    const navigate = useNavigate();
    const { pathname, hash } = useLocation();

    /**
     *
     * @type {import("./useValidateUrlParams")
     * .useValidateUrlParamsReturn["setQueryParams"]}
     */
    const setQueryParams = function ({ param, option }) {
        // 파라미터의 올바른 값 여부 체크 & 허용되지 않을 값일 시, callback 실행

        const { newParams, isUpdated } = Object.entries(param).reduce(
            (acc, currentValue) => {
                const existParam = searchParams.get(currentValue[0]);

                if (existParam != currentValue[1]) {
                    acc.isUpdated = true;
                }

                acc.newParams.set(
                    currentValue[0],
                    currentValue[1] === undefined ? '' : currentValue[1],
                );

                return acc;
            },
            {
                newParams: new URLSearchParams(searchParams),
                isUpdated: false,
            },
        );
        if (!isUpdated) return;

        const newUrl = `${pathname}?${newParams.toString()}${hash}`;
        navigate(newUrl, { replace: true, ...option?.navigate });
    };

    return setQueryParams;
};

 

참고
해당 기능을 추상화하여 제공하는 라이브러리로 use-query-params가 존재한다.
해당 훅의 유지 보수 및 확장성을 고려하면 처음부터 이 라이브러리를 쓰는 게 나을 것 같고,
나처럼 간단하게만 필요하거나 저수준에서의 작동을 알고 싶으면 직접 구현한 훅이 나을 것 같다.