최근 리쿠르팅 사이트의 어드민 페이지를 개발하며, 지원서 열람 페이지의 복잡한 필터와 다중 정렬 로직을 처리해야 했습니다. 처음에는 당연하게도 Redux나 Zustand 같은 전역 상태 관리 라이브러리와 로컬 상태 관리를 먼저 떠올렸습니다. 하지만 현직자분과의 코드 리뷰 도중, "복잡한 로컬 상태에 매몰되지 말고, URL에 상태를 담아보라"는 조언을 듣게 되었습니다.
이 조언은 제가 가진 '상태 관리'에 대한 고정관념을 깨는 계기가 되었습니다. 흔히 상태 관리라고 하면 외부 라이브러리를 통한 메모리 관리를 떠올리지만, 어드민 서비스에서 URL은 단순한 주소창을 넘어 공유성, 보존성, 신뢰성을 보장하는 최적의 상태 저장소였기 때문입니다.
왜 URL인가? (The Power of URL as State)
우리는 흔히 상태 관리라고 하면 useState나 전역 상태 관리를 먼저 떠올립니다.
하지만 어드민 서비스에서 URL은 단순한 주소가 아닌, 공유성, 보존성, 신뢰성을 얻을 수 있는 하나의 상태가 됩니다.
공유 가능성(Shareability)
"13기 프론트엔드 파트 합격자 명단 좀 봐주세요"라는 요청에 필터가 적용된 URL 하나만 던지면 됩니다. 어드민 사용자 내부의 링크 공유가 편해집니다.
보존 가능성(Persistence)
상세 페이지를 보고 뒤로 가기를 누르거나 새로고침을 해도 내가 설정한 검색 조건과 페이지 번호가 그대로 유지됩니다.
신뢰성(Reliability)
브라우저의 '뒤로 가기' 버튼이 사용자의 기대대로 작동합니다.
2. 설계: 무엇을 URL에 담을 것인가?
어드민 지원서 열람 탭은 다음과 같은 페이지로 구성되어 있습니다.
어드민 사용자는 기수 정보 드롭다운을 통해 해당 기수에 대한 지원서들을 확인할 수 있고, 파트별로 지원자를 확인하거나, 필터와 검색을 활용한 정렬, 페이지네이션 기능을 이용할 수 있습니다.
이때 기수, 지원 파트, 합격/불합격 상태, 현재 페이지 번호, 최종 제출일자 정렬에 관한 상태들이 필요한데, 모든 상태를 url 파라미터로 담아서 구현한다면 공유성이 높아지게 될 겁니다.

| 파라미터 | 역할 | 예시 |
| generationId | 기수 선택 | ?generationId=13 |
| part | 지원 파트 필터 | ?part=FE |
| passViewStatuses | 합격/불합격 상태 필터 (다중 선택) | ?passViewStatuses=PASS&passViewStatuses=FAIL |
| page | 현재 페이지 번호 | ?page=2 |
| sort | 정렬 기준 및 방향 | ?sort=submittedAt,desc |
3. 구현: React Query와 Next.js의 조합
구현의 핵심은 컴포넌트 내부에 별도의 state를 두지 않고, URL을 Single Source of Truth으로 삼는 것입니다.
① 읽기: 선언적 데이터 페칭 (AdminApplicationsContainer)
useSearchParams를 통해 URL의 변화를 감지하고, 이를 React Query의 queryKey에 전달합니다. URL이 바뀌면 쿼리 키가 바뀌고, 데이터는 자동으로 새로고침됩니다.
export const AdminApplicationsContainer = () => {
const searchParams = useSearchParams();
// 1. URL에서 상태 읽기
const rawParams = {
generationId: Number(searchParams.get('generationId') ?? 13),
partViewType: searchParams.get('part') ?? 'ALL',
passViewStatuses: searchParams.getAll('passViewStatuses'),
page: Number(searchParams.get('page') ?? 1) - 1,
// ...
};
// 2. 파라미터 검증 (Zod 활용)
const filter = GetAdminApplicationsParamsSchema.parse(rawParams);
// 3. URL 상태에 따라 자동으로 데이터 페칭
const { data, isLoading } = useAdminApplicationsQuery(filter);
// ... 렌더링 로직
};
② 쓰기: URL 업데이트 로직 (AdminApplicationsTabContainer)
사용자가 탭을 클릭하거나 필터를 변경하면 router.push를 통해 URL을 갱신합니다. 이때 scroll: false 옵션을 사용하여 불필요한 스크롤 튀기 현상을 방지합니다.
const handleTabClick = (part: ApplicationPartViewType) => {
const params = new URLSearchParams(searchParams.toString());
params.set('part', part);
params.set('page', '1'); // 파트 변경 시 첫 페이지로 리셋
router.push(`?${params.toString()}`, { scroll: false });
};
4. 실전에서 배운 모범 사례
구현 과정에서 아티클의 조언을 따라 다음과 같은 디테일을 챙겼습니다.
- 기본값 처리: URL에 모든 값을 다 넣기보다, 기본값(예: 1페이지, 정렬 없음)인 경우에는 파라미터를 생략하여 URL을 깔끔하게 유지했습니다.
- 다중 값 처리: passViewStatuses 처럼 여러 필터를 선택할 때 searchParams.getAll()을 활용하여 배열 형태의 상태를 효과적으로 관리했습니다.
- 로딩 UX 분리: 전체 페이지 로딩(isLoading)과 쿼리 파라미터 변경에 따른 데이터 갱신(isFetching)을 구분하여 사용자에게 끊김 없는 경험을 제공했습니다.
5. 마치며: 웹의 본질로 돌아가기
처음 "URL에 상태를 담으라"는 조언을 들었을 때는 Redux나 Zustand 같은 화려한 라이브러리를 써야만 '잘 만든' 서비스라고 생각했던 제 고정관념이 깨지는 기분이었습니다. 하지만 직접 구현해보며 깨달은 것은, 가장 좋은 기술은 트렌디한 도구가 아니라 사용자의 당연한 기대를 저버리지 않는 기술이라는 점입니다.
"뒤로 가기를 눌렀는데 왜 내가 보던 검색 결과가 안 나오지?"라는 사용자의 불편함은 결국 개발자가 웹의 기본 요소인 URL을 소홀히 했기 때문에 발생한다는 사실을 배웠습니다. 이번 경험을 통해 브라우저가 기본적으로 제공하는 강력한 인터페이스들을 다시금 돌아보게 되었습니다.
'프론트엔드' 카테고리의 다른 글
| 모노레포에서 미디어 쿼리가 무시되는 이유 (0) | 2026.03.02 |
|---|---|
| @use-funnel로 복잡한 온보딩 흐름 제어하기 (feat. Zod) (1) | 2026.02.23 |
| 프로젝트 통합으로 생산성 높이기: Turborepo 도입 및 모노레포 마이그레이션 여정 (0) | 2026.02.22 |
| Next.js 프로젝트에서 Storybook 도입해보기 (0) | 2026.01.29 |
| 시각장애인을 위한 모달 컴포넌트 웹 접근성 개선기 (Focus Trap 적용기) (0) | 2026.01.29 |