← posts

URL 필터 상태를 동기화하다 발견한 컴포넌트 책임 경계

2026-07-03 · 회고

검색 필터를 다시 보다

이번 루퍼스 3주차 발제 주제는 레이어 분리였다. UI, 데이터, 로직을 어떻게 나눌지, 컴포넌트가 어디까지 알아야 할지에 대한 내용이었다.

발제 자료와 과제를 보는데 문득 회사에서 작업 중인 어드민 화면 하나가 떠올랐다. 회원 상태별 탭이 있고, 탭마다 목록과 필터가 조금씩 달라지는 화면이었다. "우리 코드는 어떻게 되어 있지?" 싶어서 바로 열어봤다.

그런데 검색 필터 쪽에 묘한 코드가 있었다.

URL q와 input state를 useEffect로 동기화하던 기존 코드

URL의 q를 input state로 복사하고, qstatus가 바뀌면 다시 setInput(q)로 맞추는 코드였다. 심지어 react-hooks/set-state-in-effect 규칙을 끄고 있었다.

처음 든 생각은 이거였다.

"뭐야 누가 이래놨지?"

작성자를 보니..

문제의 코드 작성자가 나였음을 보여주는 git blame 결과

나였다.

문제의 작성자가 나였다는 걸 깨달은 순간의 반응

조금 억울한 마음으로 과거의 나를 변호해보면, 그때도 이유가 없었던 건 아니었다. 사용자가 검색어를 입력하는 동안 글자는 보여줘야 했고, Enter를 누르기 전까지는 URL에 반영하면 안 됐다. 그러니 검색창에는 draft가 필요했다.

문제는 그 draft를 URL의 q와 계속 맞추려 했다는 점이었다.

검색창에는 두 종류의 값이 있었다.

URL q = 이미 적용된 검색어
input value = 아직 제출하지 않은 draft

사용자가 글자를 입력하는 동안에는 draft가 필요하다. 그런데 그 draft를 React state로 들고 있는 순간, 이미 URL에 q라는 원천이 있는데 같은 값을 로컬에 한 번 더 복사해둔 모양이 됐다.

이 지점이 미스처럼 느껴졌다. 이미 적용된 검색어는 URL이 들고 있는데, 그 값을 다시 로컬 state에 옮겨두고 입력 중 값처럼 다루다 보니 결국 둘을 맞추는 동기화 코드가 따라붙었다.

첫 번째 시도: draft를 React state에서 DOM으로 옮기기

이 검색창은 타이핑할 때마다 검색되는 구조가 아니었다. 사용자가 입력한 뒤 Enter를 눌렀을 때만 URL에 반영하면 됐다.

처음부터 바로 uncontrolled input으로 바꾼 건 아니었다. 기존 코드를 어떻게 고칠 수 있을지 선택지를 몇 개로 나눠봤다.

첫 번째는 Controlled + 렌더 중 synced 비교였다. 다른 화면의 FilterBar는 URL query가 바뀌면 렌더 중 이전 prop과 비교해서 input state를 조정하고 있었다. useEffect는 사라지지만, 외부 값에 맞춰 내부 state를 다시 조정하는 로직은 여전히 컴포넌트 안에 남는다. React 문서에서도 이런 방식은 마지막 수단에 가깝게 다루고 있어서, 이 화면의 첫 선택지로 두고 싶지는 않았다.

두 번째는 Controlled + key였다. input의 valueonChange는 그대로 두되, URL의 q나 탭이 바뀔 때 key로 폼을 새로 마운트하는 방식이다. 이렇게 하면 동기화 useEffect 없이 draft를 리셋할 수 있다. 입력 중인 값으로 다른 UI를 계산해야 하거나, input을 반드시 React state로 제어해야 한다면 이쪽도 가능했다.

세 번째는 Uncontrolled + key였다. 입력 중인 draft는 DOM이 들고 있고, 제출 순간에만 FormData로 값을 읽는다. 외부에서 URL의 q가 바뀌는 경우에는 key로 폼을 새로 시작한다.

이 판단은 React 공식 문서의 Preserving and Resetting StateYou Might Not Need an Effect에서 말하는 방향과도 맞았다. React는 key가 바뀌면 해당 subtree를 다른 컴포넌트로 보고 state를 reset할 수 있다고 설명한다. 또 prop 변경에 맞춰 전체 state를 리셋해야 하는 경우, Effect로 다시 맞추기보다 key로 경계를 나누는 방식을 안내한다.

이 화면에서는 세 번째가 가장 적절해 보였다. 입력 중인 값으로 다른 상태를 계산하지 않았고, debounce 검색도 아니었다. 필요한 건 "사용자가 입력한 값을 Enter 시점에 URL에 반영한다"는 것뿐이었다.

그래서 controlled input을 uncontrolled input으로 바꿨다.

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const value = new FormData(e.currentTarget).get('q');
  setQ(typeof value === 'string' ? value : '');
};
 
return (
  <form key={`${status}:${q}`} onSubmit={handleSubmit}>
    <input name="q" defaultValue={q} />
  </form>
);

이렇게 바꾸면 검색창 입력값을 위한 useState가 사라진다. URL과 input state를 맞추기 위한 useEffect도 사라진다. 제출 시점에는 FormData로 값을 읽어 URL에 반영하면 된다.

다만 uncontrolled input에는 한 가지 특성이 있다. defaultValue는 말 그대로 초기값이다. 마운트 이후에 q가 바뀌어도 이미 그려진 input의 값은 자동으로 바뀌지 않는다.

그래서 외부에서 URL q가 바뀌는 경우에는 폼을 새로 마운트해야 했다. React에서 이럴 때 쓸 수 있는 방법이 key다.

처음에는 여기까지 보고 "이제 됐다"고 생각했다. 하지만 바로 다음 냄새가 보였다.

왜 key에 status가 들어가지?

처음 작성한 key는 q만이 아니라 status까지 포함하고 있었다.

<form key={`${status}:${q}`} onSubmit={handleSubmit}>

q는 이해가 됐다. URL의 검색어가 바뀌면 input draft를 새로 시작해야 하니까.

그런데 status는 왜 필요할까?

이유는 탭 전환 때문이었다. 탭을 바꾸면 URL에서 q가 사라진다. 그런데 기존 draft가 input에 남아 있으면 안 된다. 특히 이전 탭에서 입력만 하고 제출하지 않은 검색어가 새 탭에 그대로 보이는 문제가 있었다.

그래서 status를 key에 넣어 탭이 바뀔 때도 폼을 새로 마운트하게 했다.

이 방식도 틀린 건 아니었다. 하지만 질문이 하나 남았다.

검색 필터가 왜 탭 상태를 알아야 하지?

검색 필터가 탭 상태를 알아야만 제대로 리셋된다면, 어쩌면 검색 필터의 위치가 잘못된 것 아닐까?

진짜 문제는 공용 필터바였다

그때 기존 구조를 다시 봤다.

<MembersFilterBar showApprovedOnlyFilters={isApproved} />

호출부만 보면 필터 쪽 차이는 showApprovedOnlyFilters 하나에 숨어 있었다. 내부를 조금 열어보면 이런 모양이었다.

export function MembersFilterBar({ showApprovedOnlyFilters }: MembersFilterBarProps) {
  const { gender, plan, hasActiveFilters, setGender, setPlan, resetFilters } =
    useMembersSearch();
 
  return (
    <div>
      <form>{/* 검색 input */}</form>
 
      <select
        value={gender ?? ''}
        onChange={(e) => setGender((e.target.value || undefined) as Gender | undefined)}
      >
        <option value="">성별 전체</option>
        <option value="male">남성</option>
        <option value="female">여성</option>
      </select>
 
      {showApprovedOnlyFilters && (
        <select
          value={plan ?? ''}
          onChange={(e) => setPlan((e.target.value || undefined) as Plan | undefined)}
        >
          <option value="">요금제 전체</option>
          <option value="free">무료</option>
          <option value="pro">유료</option>
        </select>
      )}
 
      {hasActiveFilters && (
        <button type="button" onClick={resetFilters}>
          초기화
        </button>
      )}
    </div>
  );
}

검색과 성별 필터는 모든 탭에 있고, 요금제 필터만 승인 탭에서 보여야 했다. 그런데 이 차이는 showApprovedOnlyFilters라는 boolean prop 하나로 MembersFilterBar 내부에 숨어 있었다.

즉 호출부에서는 "승인 탭 전용 필터가 있다"는 사실만 보이고, 실제로 어떤 필터가 렌더링되는지는 컴포넌트 내부를 열어봐야 알 수 있었다.

이 화면은 탭마다 필요한 UI가 조금씩 달라지는 화면이다. 그렇다면 어떤 필터를 보여줄지도 탭을 조립하는 쪽에서 드러나는 편이 자연스러웠다. 그런데 지금 구조에서는 호출부가 그 차이를 직접 말하지 않고, 공용 필터바 내부의 boolean 분기에 맡기고 있었다.

이 지점이 중요했다. showApprovedOnlyFilters는 단순한 옵션처럼 보였지만, 사실은 "이 컴포넌트를 모든 탭에서 공통으로 쓰는 게 맞나?"라는 신호였다.

처음에는 검색 input의 useEffect가 문제라고 생각했다. 그 다음에는 keystatus가 들어가는 게 문제처럼 보였다. 그런데 더 따라가 보니, 진짜 문제는 필터의 소유권이 탭 쪽에 있지 않다는 점이었다.

필터도 목록처럼 탭이 조합하게 만들기

공용 필터바 내부에 숨은 분기 조건을 탭별 조합으로 옮기는 before after 다이어그램

공용 필터바 내부에 숨어 있던 탭별 차이를, 각 탭이 직접 조합하는 구조로 옮겼다.

그래서 필터바를 하나의 큰 컴포넌트로 두지 않고, 작은 필터 컴포넌트로 나눴다.

<MemberSearchFilter />
<MemberGenderFilter />
<MemberPlanFilter />
<MemberResetFilters />

그리고 각 탭이 필요한 필터만 조합하게 했다.

{status === 'PENDING_REVIEW' && (
  <>
    <MemberFilters>
      <MemberSearchFilter key={q} defaultValue={q} onSubmit={setQ} />
      <MemberGenderFilter value={gender} onChange={setGender} />
      <MemberResetFilters visible={hasActiveFilters} onReset={resetFilters} />
    </MemberFilters>
    <MemberTable members={members} showJoinedAt onRowClick={openDetail} />
  </>
)}
 
{status === 'APPROVED' && (
  <>
    <MemberFilters>
      <MemberSearchFilter key={q} defaultValue={q} onSubmit={setQ} />
      <MemberGenderFilter value={gender} onChange={setGender} />
      <MemberPlanFilter value={plan} onChange={setPlan} />
      <MemberResetFilters visible={hasActiveFilters} onReset={resetFilters} />
    </MemberFilters>
    <MemberTable members={members} showName showJoinedAt showPlan onRowClick={openDetail} />
  </>
)}

이제 승인 탭에만 요금제 필터가 필요하면 승인 탭에서만 <MemberPlanFilter />를 쓰면 된다. 탈퇴 탭에서 이름 검색을 빼고 싶다면 그 탭에서 <MemberSearchFilter />를 지우면 된다.

공용 컴포넌트 안에 showApprovedOnlyFilters 같은 플래그를 추가하지 않아도 된다.

필터 컴포넌트는 URL을 몰라도 된다

여기서 한 번 더 다듬었다.

처음 필터 컴포넌트로 나눴을 때는 각 필터가 직접 useMembersSearch()를 호출했다. 동작은 괜찮았다. URL이 source of truth라 같은 값을 읽고 있었기 때문이다.

하지만 레이어 관점에서는 조금 애매했다. 필터 UI 컴포넌트가 URL 검색 상태와 라우팅 갱신 방식을 알고 있었다. 이러면 UI 컴포넌트와 로직 레이어가 다시 섞인다.

그래서 useMembersSearch()MembersPage에서 한 번만 호출하고, 필터 컴포넌트에는 값과 이벤트만 props로 내려주도록 바꿨다.

필터 컴포넌트는 이제 URL을 모른다.

interface MemberGenderFilterProps {
  value: Gender | undefined;
  onChange: (value: Gender | undefined) => void;
}
 
export function MemberGenderFilter({ value, onChange }: MemberGenderFilterProps) {
  return (
    <select
      value={value ?? ''}
      onChange={(e) => onChange((e.target.value || undefined) as Gender | undefined)}
    >
      <option value="">성별 전체</option>
      <option value="male">남성</option>
      <option value="female">여성</option>
    </select>
  );
}

검색 필터도 같은 방향으로 바꿨다. MemberSearchFilter는 URL을 직접 갱신하지 않고, defaultValueonSubmit만 받는다.

이렇게 두니 레이어가 더 분명해졌다.

파일책임
useMembersSearchURL 검색 상태를 읽고 갱신한다
MembersPage현재 탭에 필요한 필터와 목록을 조합한다
Member*Filter값과 이벤트를 props로 받아 UI만 그린다
MemberFilters필터 줄 레이아웃만 담당한다

처음엔 "필터를 자기완결적으로 만들자"는 생각이었는데, 다시 보니 자기완결성보다 레이어 분리가 더 중요했다. 필터 컴포넌트는 URL을 몰라도 충분히 자기 역할을 할 수 있었다.

key는 사라지지 않았지만, 의미는 줄었다

이 리팩터를 하고 나니 key에서 status가 빠졌다.

<MemberSearchFilter key={q} defaultValue={q} onSubmit={setQ} />

탭 전환은 각 탭 블록이 언마운트되면서 처리된다. 검색어가 바뀌는 경우에만 q를 기준으로 검색 폼을 새로 시작하면 된다.

처음에는 key가 찜찜했다. 뭔가 억지로 컴포넌트를 다시 만들고 있는 것처럼 느껴졌다.

지금은 조금 다르게 본다. 이 경우의 key는 "URL에 반영된 검색어가 바뀌면, 제출 전 draft는 버린다"는 경계를 표현한다. 문제는 key 자체가 아니라, 그 key에 status까지 넣어야 했던 이유였다.

status가 필요했던 이유를 따라가 보니 필터의 위치가 보였다.

결과

바뀐 건 크게 네 가지다.

  1. 검색 input의 useState와 동기화 useEffect가 사라졌다.
  2. showApprovedOnlyFilters boolean prop이 사라졌다.
  3. 필터도 목록처럼 탭별로 조합하게 됐다.
  4. 필터 UI 컴포넌트는 URL을 모르고, MembersPage가 상태와 이벤트를 props로 전달하게 됐다.

코드 길이는 오히려 늘어났다.

대신 변경 이유가 더 잘 보이게 됐다. 승인 탭의 필터가 바뀌면 승인 탭 조합을 보면 되고, 검색 input의 reset 조건이 궁금하면 key={q}를 보면 된다. 필터 UI는 URL 파싱이나 라우팅을 신경 쓰지 않아도 된다.

길이를 줄인 리팩터라기보다, 결합 방향을 다시 세운 리팩터에 가까웠다.

배운 점

이번 기회에 상태를 복사해서 쓰는 것에 대해 다시 생각하게 됐다.

URL query parameter를 source of truth로 둔다면, 가능하면 그 값을 다시 복사한 중간 state를 만들지 않는 편이 좋겠다고 느꼈다. 바로 읽어 쓸 수 있는 값인데 한 번 더 state로 옮기는 순간, 이제는 두 값을 어떻게 맞출지가 문제가 된다.

이번 코드에서는 그 동기화 문제를 고치려고 useEffect가 붙었고, 다시 그 useEffect를 없애려다 보니 keystatus까지 들어갔다. 처음에는 각각의 코드가 작은 해결책처럼 보였지만, 따라가 보니 원천 값을 복사해서 들고 있던 구조에서 출발한 일이었다.

또 하나 체감한 건, props로 넘기는 값이나 컴포넌트 안에서 쓰는 값을 보다가 "이 컴포넌트가 이걸 왜 알아야 하지?"라는 질문이 생긴다면 그게 꽤 좋은 의심 지점이 될 수 있다는 점이다.

이번에는 검색 필터가 status를 알아야 했고, MembersFilterBar가 승인 탭 전용 필터까지 내부에서 판단하고 있었다. 그 질문을 따라가다 보니 필터를 공용 컴포넌트 안에 숨기기보다, 각 탭이 필요한 필터를 직접 조합하는 쪽이 더 자연스럽다는 결론에 닿았다.

지난 주 발제에서 컴포넌트의 경계는 단순히 코드 줄 수를 줄이는 기준이 아니라, 책임과 변경 기준으로 나누는 게 좋다는 이야기를 들었다. 이번 리팩터를 하면서 그 말이 조금 더 명확하게 체감됐다. 누가 어떤 상태를 알아야 하는지, 어떤 변경이 어디에서 일어나는지를 보면 컴포넌트의 위치도 다시 보일 수 있었다.