루프팩 5주차 과제는 상품 목록의 상태를 세 갈래로 나눠 설계하는 것이었다. 검색·카테고리·정렬·페이지는 URL 상태, 상품 데이터와 캐시는 서버 상태, 장바구니·위시리스트는 클라이언트 상태. 도구는 각각 nuqs, TanStack Query, Zustand로 정해져 있었다.
도구가 정해져 있으니 쉬울 줄 알았는데, 만들다 보니 세 상태 전부에서 같은 질문 앞에 멈췄다.
이 값을 어느 경계에서 다룰 것인가.
보정을 parser에서 할지 응답 이후에 할지, 상태 분기를 컴포넌트 안에 둘지 경계로 밀어낼지, store를 페이지별로 둘지 모듈 전역에 둘지. 매번 "무엇을 하느냐"는 정해져 있었고, 정작 고민은 "어디서 하느냐"였다. 그리고 그 위치 선택이 사용자가 실제로 겪는 화면을 갈랐다.
이 글은 그 위치 결정들의 기록이다. 특히 ?page=0 하나가 어떻게 사용자를 못 빠져나오는 에러 화면에 가두는지가 출발점이었다.
?page=0은 어떻게 사용자를 에러 화면에 가두는가
?page=0 같은 URL은 누가 일부러 치지 않아도 생긴다. 링크를 잘못 복사하거나, 페이지네이션 경계에서 버튼을 한 번 더 누르거나, 예전 링크를 공유받거나. 처음엔 "서버가 잘못된 값 걸러주겠지, 에러 나면 에러 화면 보여주면 되지"라고 생각했다.
그 "에러 화면 보여주면 되지"가 함정이었다. 무효한 page를 요청이 나간 뒤에 고치자고 두면 이런 사슬이 돈다.
?page=0으로 진입 → 요청 파라미터에page=0이 실린다- 서버는
page를 양의 정수로만 받는다 → 400을 반환한다
// src/app/api/products/route.ts
const isPositiveInteger = (value) => value !== null && /^[1-9]\d*$/.test(value);
// page=0 은 여기서 걸려 400
- 내 retry 정책은 4xx를 재시도하지 않는다 — 어차피 똑같이 실패할 요청이니까
// src/app/api/products/productQueries.ts
retry: (failureCount, error) =>
failureCount < 3 && !(error instanceof ApiError && error.status < 500),
- 재시도 없이
useSuspenseQuery가 즉시 throw → ErrorBoundary가 잡는다 - 이때
ProductListSection은 렌더되지 않는다. 그런데 페이지를 고쳐줄 보정 코드는 바로 그 컴포넌트 안의 effect에 있다:
// src/productList/ProductListSection.tsx
useEffect(() => {
const corrected = resolvePageOverflow(filters.page, productList.totalCount, productList.pageSize);
if (corrected !== null) correctPage(corrected);
}, [filters.page, productList.totalCount, productList.pageSize, correctPage]);
- 컴포넌트가 안 그려지니 이 effect도 안 돈다 → 사용자는 URL을 손으로 고치지 않는 한 에러 화면에서 못 빠져나온다.
"에러는 ErrorBoundary가 처리한다"는 원칙 자체는 맞다. 문제는 검증을 응답 이후에 두는 순간, 검증이 필요한 바로 그 화면이 안 그려진다는 것이었다. 보정 코드를 아무리 잘 짜도, 그 코드가 사는 컴포넌트가 에러로 대체되면 실행될 기회조차 없다.
판정에 응답이 필요한가로 경계를 나눴다
그래서 질문을 이렇게 정리했다. 이 값이 무효한지 판정하는 데 서버 응답이 필요한가?
| 구분 | 판정에 필요한 것 | 처리 경계 | URL 정정 |
|---|---|---|---|
하한 page < 1 | 값 자체 (즉시 안다) | parser | 안 함 |
상한 page > totalPages | 응답의 totalCount | 응답 이후 | 함 (replace) |
하한은 응답이 필요 없다. URL 값만 보면 즉시 무효인 걸 안다. 그래서 요청이 나가기 전, parser 단계에서 첫 페이지로 clamp한다:
// src/productList/productListFilters.ts
const parseAsPageNumber = createParser({
parse: (value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) return null;
return Math.max(FIRST_PAGE, parsed); // 0·-1 → 1
},
serialize: (value) => String(Math.round(value)),
});
?page=0은 요청에 실리기 전에 1이 된다. 400이 애초에 발생하지 않으니, 앞의 막다른 사슬 자체가 성립하지 않는다. 지금 코드에선 그 사슬이 안 돈다 — parser가 막기 때문이다. 이 글은 "왜 하필 parser에서 막았나"의 기록인 셈이다.
상한은 응답이 있어야만 안다. totalPages는 totalCount를 받아야 계산된다. ?page=999는 양의 정수라 서버가 200 + 빈 배열을 준다 — 화면이 정상적으로 그려지므로 보정 effect도 돈다. 여기선 응답 이후 보정이 유일하게 가능한 지점이다:
// src/productList/resolvePageOverflow.ts
export function resolvePageOverflow(page, totalCount, pageSize): number | null {
if (totalCount === 0) return null; // 0건이면 보정할 페이지 자체가 없음
const totalPages = Math.ceil(totalCount / pageSize);
if (page <= totalPages) return null;
return totalPages;
}
같은 "page를 유효 범위로 되돌린다"는 규칙인데, 하나는 요청 전에 하나는 응답 후에 한다. 판정 시점이 다르니 처리 위치도 갈린 것이다.
그 보정을 히스토리에 남길 것인가: push vs replace
경계를 나누고 나니 두 번째 질문이 따라왔다. 상한 보정으로 ?page=999를 ?page=3으로 바꿀 때, 이걸 뒤로가기 기록에 남겨야 하나?
push로 하면 히스토리가 망가진다. ?page=999 → 보정으로 ?page=3이 새 기록으로 쌓이고, 사용자가 뒤로가기를 누르면 ?page=999로 돌아가 또 보정이 돈다. 뒤로가기가 고장 난 것처럼 보인다.
그래서 규칙을 한 줄로 정했다. 사용자가 한 동작은 push, 시스템이 바로잡은 건 replace.
// src/productList/hooks/useProductListFilters.ts
const [filters, setFilters] = useQueryStates(productListParsers, { history: 'push', clearOnDefault: false });
// 사용자 동작: 기본 push — 뒤로가기로 복원되어야 하니까
const setPage = useCallback((v) => void setFilters({ page: v }), [setFilters]);
// 시스템 보정: replace — 탐색 기록에 남기지 않는다
const correctPage = useCallback((v) => void setFilters({ page: v }, { history: 'replace' }), [setFilters]);
상한 보정은 사용자가 하지 않은 동작이다. 탐색 기록에 남을 이유가 없다. replace가 이 의미를 그대로 표현한다. 여기서도 결정은 "무엇을 하느냐(page를 바꾼다)"가 아니라 "그 변경을 히스토리의 어느 자리에 두느냐"였다.
대가를 숨기지 않은 선택: key 리셋과 포커스 상실
같은 기준을 검색창에도 적용했다. 뒤로/앞으로로 URL의 q가 바뀌면 입력창도 그 값으로 따라가야 한다 — URL이 원본이니까. 흔한 실수는 이걸 useEffect로 controlled state에 밀어넣는 것인데, 이건 프로젝트가 금지하는 "effect로 파생값 복사"다. 대신 React 공식 권장대로 key로 리셋했다:
// src/productList/ProductListSection.tsx
<SearchInput key={filters.q} defaultValue={filters.q} onSubmit={setQuery} />
q가 바뀌면 SearchInput이 remount되어 최신 값으로 초기화된다. effect가 없다.
그런데 이 선택엔 대가가 있다. 검색을 제출하면 filters.q가 바뀌므로, 입력창도 함께 remount되어 포커스가 사라진다. 연속 검색이 끊긴다. 이건 아직 못 푼 트레이드오프로 남겨뒀다 — 뒤로가기 동기화를 얻는 대신 포커스를 내준 셈이다. key를 어디에 걸었느냐(입력창 자체)가 동기화와 포커스를 동시에 결정했고, 지금은 동기화 쪽을 택했다. 숨기기보다 "여기까지가 지금의 한계"라고 적어두는 게 맞다고 봤다.
서버 상태: query options가 "돌아왔을 때 그대로"를 만든다
URL만 위치 게임이 아니었다. useSuspenseQuery에 넘기는 옵션 두 개 — staleTime과 retry — 자체가 사용자가 겪는 흐름을 설계하는 지점이었다.
// src/app/api/products/productQueries.ts
list: (filters) => {
const query = toProductListQuery(filters);
return queryOptions({
queryKey: [...productQueries.all(), 'list', query],
queryFn: () => getProductList(query),
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) =>
failureCount < 3 && !(error instanceof ApiError && error.status < 500),
});
},
staleTime 5분 — 상세 다녀와도 목록이 흔들리지 않게.
목록에서 상품을 누르면 상세로 갔다가 뒤로 돌아온다. 이 왕복이 잦다. 여기서 최신 데이터를 다시 받는 것보다, 보고 있던 목록이 그대로 있는 것이 탐색 경험엔 더 중요하다고 봤다. staleTime이 짧으면 돌아올 때마다 캐시가 만료돼 재요청이 몰리고, 그 사이 순서·구성이 바뀌면 스크롤 위치도 엉뚱한 데를 가리킨다. 5분이면 그 왕복 동안 캐시를 그대로 재사용해 순서·스크롤이 안정적으로 복원된다. (API 요청 절약은 여기선 부수 효과다. 결정을 민 건 "돌아왔을 때 그대로".)
이 기준이라 홈과 목록의 신선도를 다르게 뒀다. 전역 default staleTime은 20초로 두고, 목록만 5분으로 오버라이드했다. 홈은 별도 지정 없이 전역 20초를 그대로 쓴다 — 홈은 개인화 추천이라 신선도가 중요하고, 목록은 탐색 중 안정성이 더 중요했기 때문이다. 같은 서버 데이터라도 화면이 요구하는 경험이 다르면 캐시 유효 기간도 갈린다.
retry — 안 되는 요청에 사용자를 기다리게 하지 않는다.
TanStack Query 기본값은 실패 원인을 안 가리고 3회 재시도(exponential backoff)한다. 그런데 ?page=0처럼 서버 검증에서 400 나는 요청은 재시도해도 똑같이 400이다. 기본값을 그대로 두면 사용자는 어차피 실패할 요청의 backoff만큼 로딩 화면을 멍하니 본다. 그래서 상태 코드로 갈랐다 — 4xx(요청 자체가 잘못됨)는 즉시 실패시켜 바로 에러 화면을 보여주고, 5xx·네트워크(일시적 오류)만 재시도해 조용히 자동 복구를 노린다.
그리고 이 retry 정책이 앞의 ?page=0 사슬의 한 고리였다. "4xx는 재시도 안 함"이 바로 "400 → 즉시 throw → 에러 화면"을 만든 조건이고, 그래서 애초에 그 화면에 갇히지 않도록 page 하한을 parser에서 막은 것이다. 옵션 하나(retry)의 판단이 다른 결정(parser 보정)의 이유가 됐다.
서버 상태: 로딩·에러·빈 결과를 같은 화면으로 뭉개지 않기
목록은 세 가지로 실패하거나 비어 있을 수 있다 — 불러오는 중, 못 불러옴, 결과 0건. 처음엔 셋을 isLoading/isError 분기로 한 컴포넌트 안에서 처리할 뻔했다. 그러면 세 상태가 결국 비슷한 "뭔가 안 나오는 화면"으로 수렴한다.
기준은 같았다. 이 세 표현은 정체가 달라서, 처리하는 경계도 달라야 한다.
| 표현 | 정체 | 경계 |
|---|---|---|
| 로딩 | query 파생(isPending) | Suspense fallback |
| 에러 | query가 throw | ErrorBoundary fallback |
| 빈 결과 | 성공했는데 length === 0 | 컴포넌트 렌더 중 분기 |
로딩·에러는 컴포넌트 밖 경계로 밀어내고, 빈 결과만 컴포넌트 안에 둔다. 세 화면이 서로 다른 문구로 갈라진다:
// src/app/products/page.tsx
<ErrorBoundary fallback={<main><p>상품 목록을 불러오지 못했습니다.</p>...</main>}>
<Suspense fallback={<main><p>상품 목록을 불러오는 중입니다...</p></main>}>
<ProductListSection />
</Suspense>
</ErrorBoundary>
// src/productList/ProductListSection.tsx — 빈 결과는 렌더 중 분기
if (products.length === 0) {
return (
<div>
<p>검색 결과가 없습니다.</p>
<p>다른 검색어나 카테고리를 선택해 보세요.</p>
</div>
);
}
경계를 컴포넌트 밖으로 뺀 덕에, ProductListSection은 로딩·에러 상태를 직접 분기하지 않는다. 데이터가 온다는 전제로만 그려진다. 이것도 "어디에 두느냐"의 문제였다 — 상태 분기를 컴포넌트 안에 둘지, 경계로 밀지.
클라이언트 상태: store를 어디에 두느냐가 화면 간 일치를 만든다
완료조건 중 하나가 **"홈에서 담은 상품이 목록에서도 담긴 상태로 보인다"**였다. 명백한 UX 요구다. 그런데 이걸 만족시키는 결정은 자료구조(Set)가 아니라 store를 어디에 두느냐였다.
모듈 전역 싱글톤으로 두면, 홈과 목록이 같은 인스턴스를 본다 → 한쪽에서 토글하면 다른 쪽이 자동으로 같은 상태가 된다. Context나 페이지별 인스턴스였다면 화면마다 store가 갈려 일치가 깨진다.
// src/app/store/cart/cartStore.ts — 모듈 스코프 = 앱 전체가 한 인스턴스
export const useCartStore = createIdSetStore();
그리고 무엇을 구독하느냐로 리렌더 범위를 갈랐다. 헤더는 개수만, 상품 버튼은 자기 상품 포함 여부만 구독한다 — 서로의 변경에 끌려 리렌더되지 않게:
// 헤더: 개수(number)만 — 다른 상품 토글엔 안 흔들림
const cartCount = useCartStore((state) => state.ids.size);
// 상품 버튼: "이 productId가 들었나"(boolean)만
const isInCart = useCartStore((state) => state.ids.has(productId));
state.ids 전체를 구독했으면 아무 상품이나 토글할 때마다 헤더·모든 버튼이 리렌더된다. store를 어디에 두느냐가 화면 간 일치를, 구독 지점을 어디까지 좁히느냐가 반응성을 결정했다.
대조군: 이건 UX 문제가 아니었다
오해를 막으려 한 문단 붙인다. 이 과제에서 내린 결정이 전부 UX 기준이었던 건 아니다. 오히려 코드량으로는 다수가 정합성·성능 문제였다.
q 검색어를 요청 경계에서 trim().toLocaleLowerCase('ko')로 정규화한 것, 카테고리 표시 이름을 client 하드코딩에서 서버 응답으로 옮긴 것, 장바구니를 Set<string>으로 든 것 — 이건 정합성(SSOT·캐시 key 안정성)이나 성능 문제였지 UX가 아니다. 예를 들어 "?q= 나이키와 ?q=나이키가 같은 캐시 엔트리로 합쳐진다"는 건 사용자가 체감하는 경험이 아니라 요청 유효성·캐시 정합성의 문제다. gcTime을 기본값으로 둔 것도 메모리 판단이지 UX가 아니다.
이걸 굳이 나누는 이유는, UX로 판단한 것과 정합성으로 판단한 것을 뭉뚱그리면 둘 다 흐려지기 때문이다. "전부 사용자를 위해서"라고 묶는 순간, 정작 진짜 UX였던 결정(에러 화면 회피, 뒤로가기 복원)의 무게도 같이 가벼워진다.
한 장으로 다시
| 상태 | 결정 | "위치" 기준 |
|---|---|---|
| URL | page 하한 / 상한 보정 | parser(요청 전) vs 응답 후 |
| URL | 보정을 기록에 남길지 | push(사용자) vs replace(시스템) |
| URL | 검색창 ↔ URL 동기화 | effect 대신 key 리셋 (+ 포커스 상실 감수) |
| 서버 | staleTime / retry | 캐시 유효 기간 / 실패 확정 시점 |
| 서버 | 로딩·에러·빈결과 | 컴포넌트 밖 경계 vs 안 분기 |
| 클라이언트 | 화면 간 담김 상태 일치 | 모듈 전역 store(위치) + id 단위 selector(구독 범위) |
배운 건 하나다. "값을 1로 보정한다"까지는 누구나 생각한다. 그걸 어느 경계에서 하느냐가 사용자가 에러 화면에 갇히느냐, 뒤로가기가 깨지느냐, 돌아왔을 때 목록이 그대로냐를 가른다. 상태 설계에서 위치는 구현 디테일이 아니라 사용자가 겪는 화면 그 자체였다.
아직 못 푼 것도 남겨둔다 — 무효한 category·sort 값의 URL 정정, 검색창 포커스 상실, 전환 중 이전 목록 유지(startTransition). 다음 사이클에서 이어서 판다.