본문으로 건너뛰기
DEVKBS
개발 노트
아키텍처읽는 데 약 6

필터를 클라이언트 상태에 두면 안 되는 이유

관광 사이트의 숙소 목록 필터를 useState로 만들었다가 URL로 옮기면서 알게 된 것들. 공유·뒤로 가기·검색 색인이 한꺼번에 따라왔습니다.

#Next.js#URL#상태관리#SEO

남해독일마을 사이트를 만들 때의 이야기입니다. 숙소 목록에 지역·유형·기간 필터를 붙여야 했고, 나는 별생각 없이 useState로 시작했습니다. 필터를 바꾸면 상태가 바뀌고, 목록이 다시 그려집니다. 동작에는 아무 문제가 없었습니다.

문제는 기획 담당자가 던진 한마디에서 드러났습니다. "이 조건으로 찾은 펜션 목록을 카톡으로 보내고 싶은데요." 링크를 복사해 보니 그냥 /stay였습니다. 필터가 걸린 상태는 내 브라우저 메모리에만 있었고, 받는 사람은 아무것도 걸리지 않은 전체 목록을 보게 됩니다.

관광 정보는 보내지기 위해 존재한다

이때 깨달은 것은 기술적인 문제가 아니었습니다. 콘텐츠의 성격을 잘못 읽은 것이었습니다. 관광 사이트의 정보는 대부분 누군가에게 보내집니다. 친구에게 보내고, 단톡방에 올리고, 메모에 저장합니다. 이런 콘텐츠에서 '내가 지금 보고 있는 화면'을 링크로 옮길 수 없다는 것은 기능이 하나 빠진 정도가 아니라, 사이트의 존재 이유 하나를 못 채우는 것입니다.

그래서 판단 기준을 하나 세웠습니다. 이 값이 링크에 담겨야 하는가? 담겨야 한다면 URL에, 아니라면 클라이언트 상태에 둡니다.

어디에이유
목록 필터 (지역·유형·기간)URL공유되고, 검색 결과로도 의미가 있다
정렬 순서URL'가격순으로 본 목록'을 보낼 수 있어야 한다
현재 페이지 / 커서URL뒤로 가기가 기대대로 동작해야 한다
다단계 폼의 현재 단계URL중간 이탈 후 복귀와 QA 링크
모달 열림 여부경우에 따라공유할 만한 모달이면 URL, 아니면 상태
사이드바 접힘클라이언트개인 취향이고 공유할 이유가 없다
입력 중인 폼 값클라이언트URL에 담으면 오히려 위험하다

옮기고 나서 따라온 것들

필터를 search params로 옮기는 작업 자체는 어렵지 않았습니다. 그런데 옮기고 나니 요청하지 않은 것들이 함께 해결됐습니다.

  1. 뒤로 가기가 사용자의 기대대로 동작했습니다. 그전에는 필터를 세 번 바꾸고 뒤로 가기를 누르면 이전 페이지로 나가 버렸습니다. URL이 바뀌니 뒤로 가기가 '이전 필터'로 돌아갑니다. 따로 구현한 게 아니라 브라우저가 원래 하던 일입니다.
  2. 검색 엔진이 색인할 수 있는 페이지 수가 늘었습니다. 필터 조합마다 고유 URL이 생기니 '남해 독일마을 펜션'처럼 구체적인 검색어로 들어올 수 있는 진입점이 만들어졌습니다.
  3. 서버 컴포넌트에서 데이터를 가져올 수 있게 됐습니다. 필터가 URL에 있으니 서버가 요청 시점에 조건을 알 수 있습니다. 클라이언트 상태였을 때는 일단 빈 목록을 그리고 마운트 후 다시 요청해야 했습니다.
  4. QA가 쉬워졌습니다. "필터 이렇게 걸고 스크롤 내리면 깨져요"라는 제보에 링크가 붙어 옵니다.

실제로 쓸 때의 주의점

1. 타이핑되는 값은 그대로 넣지 않는다

검색어 입력처럼 매 글자마다 바뀌는 값을 그대로 URL에 밀어 넣으면 히스토리가 글자 수만큼 쌓입니다. 뒤로 가기를 스무 번 눌러야 이전 페이지로 나가게 됩니다. 디바운스를 걸고, 히스토리를 쌓지 않는 방식으로 교체해야 합니다.

tsx검색어는 디바운스 후 replace — 히스토리를 쌓지 않는다
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const setQuery = useDebouncedCallback((value: string) => {
  const params = new URLSearchParams(searchParams);
  if (value) params.set("q", value);
  else params.delete("q");

  // push가 아니라 replace — 타이핑마다 히스토리가 쌓이지 않게
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 300);

반면 필터 버튼처럼 '한 번의 결정'인 조작은 push가 맞습니다. 뒤로 가기로 되돌릴 수 있어야 하기 때문입니다. 히스토리에 남길 가치가 있는 조작인지가 pushreplace를 가르는 기준입니다.

2. URL은 사용자가 고칠 수 있는 입력이다

tsZod로 params를 파싱하면 기본값과 검증이 한 번에 끝난다
import { z } from "zod";

const listParams = z.object({
  region: z.enum(["all", "north", "south"]).catch("all"),
  sort: z.enum(["recent", "price"]).catch("recent"),
  page: z.coerce.number().int().min(1).max(100).catch(1),
});

export type ListParams = z.infer<typeof listParams>;

// 잘못된 값이 와도 catch가 기본값으로 되돌린다 — 화면이 깨지지 않는다
export const parseListParams = (sp: Record<string, string | string[] | undefined>) =>
  listParams.parse(sp);

.catch()를 쓰면 잘못된 값이 와도 예외를 던지지 않고 기본값으로 되돌아갑니다. 목록 필터처럼 '틀렸으면 기본값으로 보여주면 되는' 경우에 잘 맞습니다. 반대로 결제 금액처럼 틀리면 진행하면 안 되는 값에는 .catch()를 쓰면 안 됩니다.

3. 파라미터 이름은 URL의 공개 API다

한번 사람들이 링크를 공유하기 시작하면 파라미터 이름을 바꾸기 어려워집니다. 이미 돌아다니는 링크가 전부 깨지기 때문입니다. 짧고 의미가 분명한 이름으로 처음에 정해 두는 편이 낫습니다. 내부 변수명이 selectedRegionCode라도 URL에서는 region이면 충분합니다.

그래서 무엇을 클라이언트 상태에 두는가

URL로 다 옮기라는 이야기가 아닙니다. 클라이언트 상태가 맞는 것도 분명히 있습니다. 사이드바 접힘, 다크 모드, 툴팁 표시 여부, 입력 중인 폼 값 같은 것들입니다. 공통점은 다른 사람에게 보내도 의미가 없다는 것입니다.

이 구분을 한번 세워 두고 나니, 새 화면을 만들 때 상태를 어디에 둘지 고민하는 시간이 거의 사라졌습니다. 질문이 하나로 줄었기 때문입니다. 이걸 링크로 보낼 일이 있는가?

상태를 어디에 둘 것인가는 기술 선택이 아니라 콘텐츠 성격에 대한 판단입니다.

이후 금 매입 서비스의 다단계 접수 폼을 만들 때도 같은 기준을 적용했습니다. 현재 단계는 URL에, 입력 값은 스토어에 뒀습니다. 사용자가 주소 검색을 하러 나갔다 돌아와도 이어서 진행할 수 있고, QA 담당자는 특정 단계를 링크로 지목할 수 있습니다. 같은 판단이 다른 도메인에서도 그대로 통했습니다.

이 글이 나온 작업