sehyun.dev
ReactVue성능개선

URL 파라미터 통한 클라이언트 상태 관리 진화 히스토리: 야매 → 혼돈 → 완성 (React & Vue)

2025년 7월 18일

URL 파라미터 관리 진화 히스토리: 야매 → 혼돈 → 완성 (React & Vue)

웹 애플리케이션에서 검색 조건, 필터링, 페이지네이션 등의 상태 관리는 사용자 경험을 좌우하는 핵심 요소입니다. 특히 브라우저 뒤로가기/앞으로가기URL 공유새로고침 시 상태 유지 등을 지원하기 위해서는 체계적인 접근이 필요합니다.

본 문서는 실제 프로덕션 환경에서 3년 가량 프로젝트를 진행하면서 겪은 3단계 진화 과정을 통해 React와 Vue 모두에서 URL 파라미터 관리 및 로컬 상태 관리를 이렇게 관리하면 최적화가 되지 않을까 라고 생각이 되는 점을 작성하였습니다.

1. 1단계: 야매 시절 - Flux Pattern 및 LocalStorage 의존

(1) 잘못된 Flux Pattern의 사용 및 Vue의 양방향 데이터 흐름의 무시

  • React 진영에서는 많이 사용하고 있는 전략으로 단방향 흐름의 핵심인 Redux를 출발로 Vue 프로젝트를 처음 신규 개발하면서 Vue의 양방향 바인딩을 무시하고 모든 상태를 중앙 집중화 시킨 아쉬운 경험이 있습니다
  • 모든 입력·상태를 컴포넌트 내부에서 처리하지 않고 전부 Vuex(Flux 패턴)로만 관리한 결과, 아래처럼 모든 입력값·상태computed로 store에서 읽고, 값이 바뀔 때마다 dispatch로만 업데이트하게 되어 즉, input의 v-model(양방향 바인딩)도 없이, 무조건 store만 경유하게 되어 작은 변화조차 불필요하게 전역 스토어와 얽혀버렸습니다.
// 값을 읽을 때
const userInfo = computed(() => instance.$store.getters['client/login/userInfo']);
// 값이 바뀔 때
instance.$store.dispatch('client/login/setUpdate', { type: 'id', data: { value: 'xxx' } });
 
// 모든 input, 버튼 이벤트가 store 경유 → 로컬 state/양방향 바인딩 완전 배제

(2) URL 파라미터를 통한 상태 관리 전략 부재

  • 초기 개발 단계에서는 URL 파라미터 관리에 통해 검색 필터 등 관리을 해야겠다는 생각을 하질 못했습니다. 실제로 페이지네이션 혹은 상세 페이지 진입 후 검색 필터 및 조건이 유지되지 않아 그래서 브라우저 뒤로가기페이지 새로고침URL 공유 등의 문제를 해결하기 위해 LocalStorage를 활용한 야매 방식을 사용했습니다.
  • 이로 인하여 프로젝트의 복잡도가 상승하고 어떤 상태인지의 화면인지 인지 할 수 없는 큰 불편함이 생겨났습니다.
<!-- ❌ 1단계: Vue에서 LocalStorage 야매 방식 -->
<script lang="ts">
export default defineComponent({
  beforeRouteLeave(to, from, next) {
    // 🔥 문제: 페이지 이동 시 로컬스토리지로 상태 추적
    if (to.path !== getLocalStorage('currentPage')) {
      this.$store.dispatch('common/global/searchParamAction', { page: 1 });
    }
    next();
  },
  
  setup(props) {
    onMounted(async () => {
      // 🔥 문제: 현재 페이지를 로컬스토리지에 저장
      if (props.where) setLocalStorage('currentPage', props.where);
      
    });
  }
});
</script>

2. 2단계: 혼돈 시절 - useSearchParam의 등장 (React.memo로 임시 해결)

(1) 잘못된 상태 관리에 대한 인지로 React 프로젝트에서 useSearchParam Custom Hook의 등장

  • LocalStorage 방식의 한계를 깨닫고 URL 파라미터 기반 관리를 도입되었습니다. 실제로 로컬 상태 관리와 필터 및 조건 동작 상태, 전역 상태를 확실히 구분하여 이전보단 깔끔한 상태 관리가 되었으나 설계 경험 부족 인한 오히려 더 복잡한 문제들이 발생되었습니다.
  • useSearchParam 커스텀 훅의 Core 로직
    • 비교를 통해 적절한 새로운 객체 업데이트가 일어나야 하나 그렇지 못한 모습입니다. 그로 인한 문제점 아래 두 가지를 보여드립니다.
export const useSearchParam = (searchParams?: any) => {
  const [querySearchParams, setQuerySearchParams] = useState<MyObject | null>(
    () => makeSearchParams()
  );
 
  useEffect(() => {
    const obj: MyObject = { ...searchParams }; // 매번 새로운 객체
    
    for (const key of initSearchParams.keys()) {
      // 복잡한 파싱 로직...
    }
    
    // 🔥 매번 새로운 객체로 상태 업데이트
    setQuerySearchParams(Object.values(obj).length ? {...obj} : null);
  }, [initSearchParams]); // 🔥 URL 변경시마다 실행
 
  return {
    querySearchParams, // 🔥 매번 새로운 참조
    replaceURL, // 🔥 매번 새로운 함수
  };
};

(2) 치명적 성능 문제들

  • 문제 1. 매번 새로운 객체 참조로 인한 리렌더링 최적화 실패

    • 상태/props 변화로 부모가 리렌더 → 자식(그리드 등)까지 같이 렌더링되어 React는 새 Virtual DOM과 이전 Virtual DOM을 트리 구조로 비교하고 각 노드의 props가 변경되었는지 확인하게 되는데 이때 항상 새로운 객체로 판단하게 되어 리렌더링이 발생되어 최적화 실패가 일어납니다.
    // ❌ MspCostList.tsx - 실제 문제 상황
    const MspCostListPage = () => {
      const {querySearchParams, replaceURL} = useSearchParam({
        businessName: selectAttributes.businessName.value,
      });
     
      // 🔥 문제: API 호출 결과가 매번 새로운 배열
      const {mspDistributions, lastCalculatedDate} = useGetDistributionDataCustomerQuery({
        businessName: querySearchParams.businessName,
      });
     
      return (
    	  <MspCostListSearchArea
          gridApiRef={gridApiRef}
          selectAttributes={selectAttributes} // 🔥 내부에서 입력 발생 시 Diffing으로 인한 리렌더링이 발생
        />
        <MspCostListGrid
          mspDistributions={mspDistributions || []} // 🔥 매번 새로운 배열 참조하게 되어 리렌더링이 계속 발생되어 Grid 자체가 가지고 있는 기능들로 부하가 발생
          lastCalculatedDate={lastCalculatedDate}
        />
      );
    };
     
    // ✅ React.memo로 임시 해결
    const MspCostListGrid = memo(({mspDistributions, lastCalculatedDate}) => {
     
      return <Grid rows={rows} />;
    });
     
    🔥 React의 얕은 비교 과정
    1. 이전 렌더링: mspDistributions = [1, 2, 3] (참조: 0x123)
    2. 새로운 렌더링: mspDistributions = [1, 2, 3] (참조: 0x456)
    3. React.memo 비교: Object.is(0x123, 0x456) = false
    4. 결과: 리렌더링 발생! (내용은 같지만 참조가 다름)
    • 임시 방편으로 React.memo 의 메모라이제이션 기법을 통해 shallow compare 에 한에서는 최적화를 적용 시켰으며 이미지는 실제 PPT를 작성하여 코드리뷰를 진행 했습니다.

      image.png

      image.png

  • 문제 2. 문제 1번에서 발생한 리렌더링 과정으로 인해 URL을 다시 반영하는 와중 리렌더링이 발생

// ❌ 2단계: React 컴포넌트에서의 복잡한 사용법
export const useMspCostListQueryString = ({
  querySearchParams,
  replaceURL,
}: useMspCostListQueryStringProps) => {
  const {makeMonthOptions} = useMspCostListSetYearMonth();
 
  useEffect(() => {
    if (querySearchParams && Object.keys(querySearchParams).length > 0) {
      replaceURL({...querySearchParams});
 
      const availableMonths = makeMonthOptions(
        Number(querySearchParams.year),
        dayjs().month() + 1
      );
 
      setSelectForm((prev: FixedCostInitType) => {
        return {
          ...prev,
          .. 코드 중략
        };
      });
    }
  }, [querySearchParams]);
 
  const querySearchParamsMemo = useMemo(() => {
    return {
      ...querySearchParams,
    };
  }, [querySearchParams]);
 
  return {querySearchParamsMemo} as const;
};

3. 3단계: 완성

(1) 인지 하기

  • 부모, 자식간 컴포넌트 리렌더링 및 상태 관리의 문제를 해결 하지 못했는가?
    • 기존에 서버 상태는 react-query 로 이미 위임되고 있고 유저 인터렉션에 의해 form과 잦은 상태 변경으로 React-hook-Form 도입 통해 책임을 분리
  • 적절한 참조 동일성 문제(useMemo 혹은 React.memo 등)를 해결하지 못했는가?
    • 참조 안정성 및 비교 안정성을 높이기 위한 커스텀 훅 도입

(2) 근본적인 문제 해결을 위한 새로운 접근

2단계의 문제점들을 분석한 결과, 부모-자식 컴포넌트 간의 불필요한 리렌더링복잡한 상태 동기화참조 동일성 문제가 핵심임을 파악했습니다. 이를 해결하기 위해 React Hook Form의 내부는 Context API와 구조의 각 필드들이 Subscribe key로 등록 되어 Object.definProperty 즉, **Proxy**와 유사하게 객체에 새로운 **property**를 정의하거나 이미 존재하면 해당 객체를 반환 할 수 있게 되어 필요한 데이터만 갱신하도록 되어 있기에 최적화 상태 관리체계적인 URL 파라미터 관리 시스템을 구축 및 React 프로젝트에서는 점진적 개선 및 Vue의 신규 프로젝트에 도입이 되었습니다.

  • React 프로젝트: 문제 발생은 없기 때문에 기존 구조를 유지하고 리팩토링 점진적 개선 진행
  • Vue 프로젝트: Vue 프로젝트의 경우 기존에 위 언급했던 1단계와 같이 개발한 항목이 있지만 인력 부족 및 유지보수로 인해 신규개발이 되지 않는 점으로 진행을 하지 않고 이전 소스 기반으로 새로 강화된 솔루션을 개발하라는 지시 사항으로 새롭게 다시 접근하여 **Vue-query, Vee-validate, Provide 및 Inject, 양방향 상태 관리 적용(Proxy)**를 도입을 진행하였습니다.

🛠️ React/Vue 구조 비교 표

  • 근본적인 개발 Set은 동일하다는 조건이 성립되었고 약간의 문법이 조금씩 달라질 수 있겠다라는 판단을 했습니다.
  • 결론적으로는 두 프로젝트가 사용하고자 하는 방향성은 같으면서 기존 아키텍처에서 추구하는 방향성을 깨지 않았습니다.
항목ReactVue
상태 공유Context APIProvide/Inject
최적화RHF (subscribe key 기반의 Proxy )
  1. 실제로 사용한 프로퍼티 만 추적하여 실제 state를 반환하지 않고 Ref 를 반환하고 Object.definProperty 통해 업데이트를 트리거하여 구독 상태만 리렌더링 | Vue reactivity(v-model 등 Proxy 기반)
  2. Vue 2 (Object.definProperty)
  3. Vue 3 (Proxy) | | 상태 관리 흐름 | 단방향(RHF 내부 formValues로 상태 갱신) | 양방향(v-model, Proxy로 즉각 반응) |

(3) React 프로젝트 - React Hook Form 도입

  • Context API를 통한 상태 공유 최적화
    • Props Drilling 제거: 자식 컴포넌트 트리에서 props 전달 불필요
    • 리렌더링 최적화: Context 구독 컴포넌트만 리렌더링
// ✅ FormProvider를 통한 상태 공유
const ManualPageV2 = () => {
  useSettingQueryParameter(MANUAL_PAGE_V2_INITIAL_STATE()); // 현재 조회 조건과 설정된 조회 조건을 비교하여 useQueryParameter으로 부터 파라메터 비교
  const forms= useFormWithQueryParams<ManualFormType>(); // useForm을 커스텀 마이징 내부적으로 useQueryParameter를 호출하여 반영
 
  const onSubmit = (data) => {
    forms.replaceParams(data);
  };
  
  const handleResetButtonClick = () => {
    forms.reset(MANUAL_PAGE_V2_INITIAL_STATE());
    forms.replaceParams(MANUAL_PAGE_V2_INITIAL_STATE());
  };
  
  return (
    <FormProvider {...forms}>
      <form onSubmit={forms.handleSubmit(onSubmit)}>
        <ManualSearchArea
          buttonArea={
            <Grid item marginLeft="auto">
              <AuthButton
                pageName="billing"
                buttonName="INV_PUR_MANUAL_MANAGE"
              >
                <Grid id={'grid-ds-add-panel'} />
              </AuthButton>
            </Grid>
          }
          gridArea={
            <>
              <ManualGridTitleContent /> {/* 🔥 Context를 통해 폼 상태 공유 */}
              <ManualGridV2 apiRef={apiRef} />
            </>
          }
          handleResetButtonClick={handleResetButtonClick}
        >
          <ManualSearchAreaContent />
        </ManualSearchArea>
      </form>
    </FormProvider>
  );
};
  • React Hook Form를 통한 상태 변화 감지
    • 필요한 TextField 만 구독되어 상태가 변화 됩니다.
    • useController 의 경우는 Controller의 Custom Hook 형태로 RHF에서 제공되며 MUI와 같은 라이브러리와 같이 사용 할 시 편리합니다.
// ✅ React Hook Form의 JavaScript Proxy 활용
const ManualSearchAreaContent = () => {
  const {control, getValues, setValue} = useManualFormContext(); // useFormWithQueryParamsContext 구현
 
  ... 코드 중략...
  
  const {field: monthField} = useController({
    control,
    name: 'month',
  });
 
  // 🔥 JavaScript Proxy를 통한 지연 평가 - 실제 접근 시에만 상태 추적
  const monthOptions = useMemo(() => {
    if (!isLoading) {
      const option = createMonthOptions(getValues('year'));
      const find = option.find(item => {
        return Number(item.value) === Number(getValues('month'));
      });
      if (!find) setValue('month', dayjs().format('MM'));
      return option;
    }
    return [];
  }, [isLoading, getValues, yearOptions]);
 
  return (
    <TextField
      {...monthField}
      value={monthField.value?.toString().padStart(2, '0')}
      onChange={monthField.onChange}
      fullWidth
      size="small"
      select
    >
      {renderMenuItems({
        options: monthOptions,
      })}
    </TextField>
  );
};
  • URL 및 상태 관리를 하기 위한 커스텀 훅
    • 페이지네이션 시 기존 URL Query String 유지
    • 목록 > 상세 접근 시 목록의 상태 데이터 유지 기록 및 재 반영
    • 초기 데이터 값 반영 및 변경 데이터 감지 등
const useFormWithQueryParamsContext = <T extends FieldValues>() => {
  const method = useFormContext<T>();
  const {replace, push} = useRoute();
  const parsingQueryStringValue = useQueryParameter(); // 현재 URL Query Params를 파싱
 
  const replaceParams = (
    params: Partial<T>,
    options?: ReplaceParamsOptions
  ) => {
    replace(
      options?.keepAllQueryParams // 페이지 네이션 여부에 따른 기존 URL 유지
        ? {...parsingQueryStringValue, ...params}
        : {...method.getValues(), ...params}
    );
  };
 
  return {...method, replaceParams, pushParams};
};
export const useSettingQueryParameter = <T extends Record<string, any>>(
  settingQueryParams: T
) => {
  const currentQueryParams = useQueryParameter();
  const {replace} = useRoute();
 
  useEffect(() => {
    const settingQueryKey = Object.keys(settingQueryParams);
    const currentQueryKey = Object.keys(currentQueryParams);
 
    const isInvalidQueryParams = currentQueryKey.some(
      key => !settingQueryKey.includes(key)
    );
 
    const isInvalidQueryKeyLength =
      currentQueryKey.length !== settingQueryKey.length;
 
    if (isInvalidQueryParams || isInvalidQueryKeyLength) {
      replace(settingQueryParams);
    }
  }, [settingQueryParams, currentQueryParams]);
};

(3) Vue 프로젝트 - provide , inject 를 활용하여 제대로 된 반응성 사용 및 vee-validate 사용하기

  • provide , inject 를 활용하여 Vue의 양방향 바인딩 반응성 사용
    • Props Drilling 제거: 자식 컴포넌트 트리에서 props 전달 불필요
<template>
  <layout>
    <div class="flex justify-between items-end">
      <DefectListDashboard />
      <AuthorityButtonV2 permissionKey="DEFECT_INSERT" @click="handleDevTaskCreate">
        <template #default="{ onClick, isDisabled }">
          <Button variant="yellow" type="button" :disabled="isDisabled" @click="onClick">
            등록
          </Button>
        </template>
      </AuthorityButtonV2>
    </div>
    <ValidationObserver ref="observer">
      <form @submit.prevent="handleSubmit">
        <DefectSearchArea>
          <div class="clear-search-btn-area">
            <div class="refresh-search">
              <Button variant="refresh" @click="handleReset" size="small">
                <i class="fi fi-rr-rotate-right" />
              </Button>
              <Button type="submit" :debounce="1000">검색</Button>
            </div>
          </div>
        </DefectSearchArea>
        <DefectListGridArea />
      </form>
    </ValidationObserver>
  </layout>
</template>
 
<script lang="ts">
import { defineComponent, onMounted, reactive, ref } from '@vue/composition-api';
import { ValidationObserver } from 'vee-validate';
import { provideFormContext } from '../context/useDefectProvideFormContext';
import { useSettingQueryParameter } from '@/composable/useSettingQueryParameter';
import { useFormWithQueryParams } from '@/composable/useFormWithQueryParams';
import { DEFECT_LIST_INITIAL_STATE } from '../constants';
 
export default defineComponent({
  name: 'DefectList',
  components: { ValidationObserver },
  setup() {
    const observer = ref();
    
    // URL 파라미터와 폼 상태 동기화
    const initialState = reactive(DEFECT_LIST_INITIAL_STATE());
    const settingParams = useSettingQueryParameter(initialState);
    const forms = useFormWithQueryParams({
      defaultValues: {
        ...settingParams,
        page: 1,
        itemsPerPage: 10,
      },
    });
 
    // Provide/Inject로 상태 공유
    const formContext = provideFormContext();
 
    const handleSubmit = async () => {
      const isValid = await observer.value?.validate();
      if (!isValid) {
        // 검증 실패 처리
        return;
      } else {
        // 🔥 검색 조건 변경 시 페이지 1로 초기화
        forms.formData.value.page = 1;
        forms.replaceParams(forms.formData);
      }
    };
 
    const handleReset = () => {
      const initialStateData = {
        ...initialState,
        workflowCodes: initialState.workflowCodes.map((item) => item),
      };
      forms.replaceParams(initialStateData);
      observer.value?.reset();
    };
 
    return {
      observer,
      handleSubmit,
      handleReset,
    };
  },
});
</script>
  • Vue의 반응성을 통한 상태 변화 감지
    • Javacript의 Proxy + Reflect 혹은 defineProperty 를 통해 상태 변화를 동작하게 합니다.
<template>
  <div class="search-filter-area">
    <div class="input-row">
      <TextField
        v-model="formData.workflowCodes"
        input-type="multiSelect"
        :select-options="workflowOptions"
      />
      <TextField
        v-model="formData.priorityCodes"
        input-type="select"
        :select-options="priorityOptions"
      />
      ... 코드 중략 ...
    </div>
    <slot />
  </div>
</template>
 
<script lang="ts">
import { defineComponent, inject, toRefs, computed } from '@vue/composition-api';
import { TextField } from '@/components';
 
export default defineComponent({
  name: 'DefectSearchArea',
  components: { TextField },
  setup() {
    // 🔥 Inject로 상태 주입받기
    const formContext = inject('defectFormContext');
    const { formData, commonCode } = toRefs(formContext as any);
 
    // 🔥 Computed로 옵션 관리 - 반응성 유지 (따로 UI와 비지니스를 분리하여 훅으로 관리해도 되나 코드를 보여주기 위한 예시로 노출)
    const workflowOptions = computed(() => [
      { label: '전체', value: 'ALL' },
      ...(commonCode.value.workflow?.map((item) => ({
        value: item.codeValue,
        label: item.codeName,
      })) || []),
    ]);
 
    const priorityOptions = computed(() => [
      { label: '전체', code: 'ALL' },
      ...(commonCode.value.priority?.map((item) => ({
        code: item.codeValue,
        label: item.codeName,
      })) || []),
    ]);
 
    return {
      formData,
      workflowOptions,
      priorityOptions,
    };
  },
});
</script>

URL 및 상태 관리 일부 커스텀 훅

  • 페이지네이션 시 기존 URL Query String 유지
  • 목록 > 상세 접근 시 목록의 상태 데이터 유지 기록 및 재 반영
  • 초기 데이터 값 반영 및 변경 데이터 감지 등
const useFormWithQueryParams = () => {
  const parsingQueryStringValue = useQueryParameter();
  const { replace, push, route } = useCustomRoute();
 
  const replaceParams = (params: any, options: any = {}) => {
    ... 코드 중략 ...
    const queryParameters = convertEmptyArrayToString(
      options.keepAllQueryParams
        ? { ...(parsingQueryStringValue.value), ...paramsWithTab }
        : { ...getValues(), ...paramsWithTab }
    );
 
    if (options.keepAllQueryParams) {
      push(route.path, queryParameters);
    } else {
      replace(queryParameters);
    }
  };
 
  return {...method, replaceParams, pushParams};
};
export const useSettingQueryParameter = <T extends Record<string, any>>(settingQueryParams: T) => {
  const currentQueryParams = useQueryParameter();
  const { replace } = useCustomRoute();
  const reactiveSettingParams = reactive(settingQueryParams);
 
  watch(
    [() => currentQueryParams, () => reactiveSettingParams],
    ([current, setting]) => {
      const settingQueryKeys = Object.keys(setting );
 
      const currentQueryKeys = Object.keys(current.value);
 
      const isInvalidQueryParams = currentQueryKeys.some((key) => !settingQueryKeys.includes(key));
      const isInvalidQueryKeyLength = currentQueryKeys.length !== settingQueryKeys.length;
 
      if (isInvalidQueryParams || isInvalidQueryKeyLength) {
        replace(setting);
      }
    },
    { deep: true, immediate: true }
  );
 
  return reactiveSettingParams;
};

4. 최종 개선 비교

(1) 개선 전 – 모든 상태와 URL 관리가 부모에서 집중 (비효율 & 무의미한 렌더링 발생)

  • 상태 변경 시마다 전체 컴포넌트가 리렌더링으로 성능 저하 발생 및 유지보수성, 복잡

image.png

(2) 개선 1버전 – React.memo 임시 도입으로 일부 최적화 (리렌더링만 억제)

  • 부모-자식 props 전달 구조 자체는 바뀌지 않았으나, React.memo를 도입해 props가 동일하면 자식 컴포넌트의 불필요한 렌더링을 임시로 차단.

image.png

(3) 개선 완전형 – RHF 도입과 컨텍스트 분리로 구조적 해결 (상태/URL 최적화, 책임 분리)

  • React Hook Form(RHF) 도입으로 폼 상태 관리UI 상태를 점진적으로 리펙토링을 진행하여 완전히 분리
  • RHF가 내부적으로 변경이 발생한 컴포넌트만 리렌더링.
  • URL 파라미터 관리도 별도 훅으로 분리해, URL과 폼 상태의 동기화 데이터를 어디서든 호출이 가능

image.png