루프팩 3주차 과제는 ProductListPage 하나가 상품을 서버에서 가져오고, 필터·검색·정렬·페이지를 관리하고, URL과 localStorage에 동기화하고, 카드마다 할인율·배지를 계산하고, 그 결과를 그리드로 그리는 500줄짜리 컴포넌트였다. 어디서부터 손대야 할지 감이 안 잡혀서, 손으로 줄 세우기 전에 미리 만들어 둔 리뷰용 체크리스트(관심사 분류 → Hook 분리 후보 → 책임 과다 탐지 순으로 코드를 훑게 시키는 프롬프트)로 관심사부터 기계적으로 분류했다.
분류부터 하니 "몇 개를 하는지"가 숫자로 나왔다
체크리스트가 던진 질문은 단순했다. "이 컴포넌트는 ___한다"에 한 문장으로 답해봐라. 억지로 채워보니 이렇게 됐다.
상품을 서버에서 가져오고, 필터·검색·정렬·페이지 상태를 관리하며, URL과 localStorage에 동기화하고, 상품별 도메인 배지를 계산하고, 그리드·필터·페이지네이션 UI를 렌더한다.
접속사가 5개 붙는 순간 이미 결론이 났다. 책임 과다. 체크리스트가 뽑아준 목록을 정리하면 이랬다.
| 책임 | 위치 | 분리 단위 |
|---|---|---|
| 서버 데이터 페칭 + 상태 | fetch effect | useProducts |
| localStorage 동기화 (위시리스트/최근 본 상품) | state + effect 2벌 | useWishlist / useRecentlyViewed |
| 상품 도메인 규칙 (배지·할인율·하이라이팅) | map 콜백 내부 | productRules.ts + ProductCard |
| 페이지네이션 계산 + UI | 인라인 | Pagination |
| URL 쿼리 동기화 | effect (단방향) | 훅 추출로는 안 풀림 → 뒤에서 방향을 뒤집음 |
목록만 보면 "6개짜리 God Component를 6개로 쪼갠다"는 기계적인 작업처럼 보인다. 그런데 실제로 만들면서 확인한 건, 쪼개는 기준을 줄 수가 아니라 역할로 잡았을 때만 분리가 값을 한다는 것이었다. 그 확인이 세 군데서 각기 다른 모양으로 나왔다.
타입을 미리 안 뺀 결정 — YAGNI가 맞아떨어진 지점
첫 단계로 useProducts를 뺄 때, 계획서엔 이렇게 적어뒀다.
cross-boundary 타입(
Product,SortBy)과PAGE_SIZE는 fetch가 소유하므로 훅 파일에 두고 export한다. 별도types.ts는 만들지 않음 — 3번째 사용처 생기면 그때.
타입 전용 파일부터 만드는 게 습관적으로는 더 "정석"처럼 느껴진다. 하지만 이 시점엔 소비처가 훅 자신 하나뿐이었다. 파일을 미리 쪼개봐야 늘어나는 건 import 한 줄뿐이고, 정작 갈릴 책임은 없었다.
그리고 다음 단계에서 실제로 3번째 사용처가 생겼다. 도메인 규칙(productRules.ts)과 카드 컴포넌트(ProductCard.tsx)를 분리하면서 둘 다 Product 타입이 필요해졌고, 그제야 types.ts로 뺐다.
// productRules.ts, ProductCard.tsx가 생기며 3번째 소비처 도달 → types.ts로 승격
export type Product = { /* ... */ };
export type ProductListResponse = { products: Product[]; totalCount: number };
export type SortBy = 'latest' | 'popular' | 'price-asc' | 'price-desc';
미리 만들어뒀어도 결과물은 같았을 거다. 그런데 "3번째가 생기면"이라는 조건을 미리 적어둔 덕분에, 실제로 그 조건이 왔을 때 망설임 없이 옮길 수 있었다. 조건 없이 "나중에 필요하면 분리하겠다"는 다짐은 그 "나중"을 알아채기 어렵지만, 조건을 숫자로 박아두면 그 순간을 놓치지 않는다.
겉보기엔 같은 패턴인데, 반환값이 갈린 이유
두 번째 확인은 useWishlist와 useRecentlyViewed에서 나왔다. 원래 코드는 위시리스트와 최근 본 상품이 거의 판박이였다 — useState lazy initializer로 localStorage를 읽고, useEffect로 변경될 때마다 다시 쓴다. 그래서 공통 로직을 usePersistentState라는 훅 하나로 뽑는 건 자연스러웠다.
// localStorage와 동기화되는 state. read는 lazy init, write는 effect(외부 시스템 동기화).
export function usePersistentState<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
} catch {
return initial;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage 사용 불가 시 무시
}
}, [key, value]);
return [value, setValue];
}
여기까지는 흔한 "중복 제거" 리팩토링이다. 재미는 그 위에 얹은 두 훅에서 시작됐다. 겉으로 보면 위시리스트도, 최근 본 상품도 똑같이 "숫자 배열을 localStorage에 저장하는 것"이다. 그런데 훅 설계 원칙(state로 노출할 값과 ref처럼 렌더에 안 쓰이는 값을 구분하라는 체크리스트)에 맞춰 다시 짚어보니 렌더에서 값을 실제로 읽느냐가 갈렸다.
wishlist는 카드마다wishlist.includes(product.id)로 렌더에 직접 쓰인다 → state로 반환해야 한다.recentlyViewed는 "추가"만 하고 그 값 자체를 화면 어디서도 읽지 않는다 → 반환할 이유가 없다.
// 렌더에 쓰이므로(includes) state 유지
export function useWishlist() {
const [wishlist, setWishlist] = usePersistentState<number[]>(STORAGE_KEYS.wishlist, []);
const toggleWishlist = (productId: number) =>
setWishlist((prev) => (prev.includes(productId) ? prev.filter((id) => id !== productId) : [...prev, productId]));
return { wishlist, toggleWishlist };
}
// 값은 렌더에서 안 읽힘(write-only) — state 값은 미사용이라 _로 두고 갱신 함수만 노출
export function useRecentlyViewed() {
const [_recentlyViewed, setRecentlyViewed] = usePersistentState<number[]>(STORAGE_KEYS.recentlyViewed, []);
const addRecentlyViewed = (productId: number) =>
setRecentlyViewed((prev) => [productId, ...prev.filter((id) => id !== productId)].slice(0, 10));
return { addRecentlyViewed };
}
usePersistentState는 "localStorage와 동기화되는 값"이라는 하나의 역할만 안다. 그 값을 화면에 보여줄지, 그냥 기록만 할지는 그 위의 훅이 결정한다. 만약 두 훅을 기계적으로 똑같은 모양({ value, toggle })으로 맞췄다면, useRecentlyViewed 쪽은 아무도 안 읽는 _recentlyViewed가 호출부까지 새어나갔을 거다. 분리를 "복사해서 이름만 바꾸기"로 하면 이런 차이가 지워진다. 역할을 먼저 묻고 나서야 반환 모양이 달라진다는 걸 알았다.
다만 이건 절반의 해결이다. useRecentlyViewed는 반환값만 렌더에 안 새어나가게 막았을 뿐, 내부적으로는 usePersistentState를 그대로 재사용해서 여전히 useState로 값을 들고 있다. setRecentlyViewed가 호출될 때마다 — 즉 상품을 클릭할 때마다 — 이 훅을 호출한 ProductListPage는 아무도 그 값을 읽지 않는데도 리렌더된다. usePersistentState가 "state로 노출할지"는 호출부에 맡겼지만 "state로 들고 있을지" 자체는 강제한다는 걸 재사용하면서 놓친 거다. 진짜 write-only 값이라면 useRef + 직접 localStorage.setItem으로 리렌더 자체를 없앨 수 있었는데, 이번엔 거기까진 안 갔다.
분리해뒀더니, 나중에 들어온 수정이 제자리를 찾아갔다
과제를 절반쯤 진행했을 때, 과제 스타터 원본(upstream/main)에 수정 커밋 두 개가 올라왔다. 검색어에 정규식 특수문자((, * 등)가 들어가면 하이라이팅이 깨지는 버그 수정, 그리고 "재고 있는 것만" 필터를 서버 사이드로 바꾸는 목업 API 변경이었다. 병합하면서 이미 쪼개둔 파일 덕분에, 두 수정이 어디로 가야 하는지는 고민할 필요가 없었다.
// ProductCard.tsx — 하이라이팅 규칙의 집이므로 여기로
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const highlightMatch = (text: string, searchQuery: string) => {
if (!searchQuery) return <>{text}</>;
const parts = text.split(new RegExp(`(${escapeRegExp(searchQuery)})`, 'gi'));
// ...
};
// useProducts.ts — fetch 파라미터의 집이므로 여기로
if (inStockOnly) params.set('inStock', 'true');
500줄짜리 컴포넌트였다면 "이 수정을 어느 위치에 붙여야 기존 로직과 안 꼬이지" 하나만으로도 한참 뒤졌을 거다. 파일이 역할별로 갈려 있으니 수정도 역할을 따라 흩어졌고, 그게 원래 자리를 정확히 찾아갔다. 분리해두면 이후에 들어오는 변화도 정확한 좌표를 갖는다 — 이번 과제에서 가장 확신이 든 순간이다.
그런데 이 글을 쓰며 두 수정을 다시 훑다가 놓친 부분을 하나 발견했다. useProducts의 fetch effect는 inStockOnly를 파라미터로 쓰면서도 의존성 배열에는 넣지 않았다.
if (inStockOnly) params.set('inStock', 'true');
// ...
}, [category, minPrice, maxPrice, sortBy, searchQuery, page]); // inStockOnly 제거
// inStockOnly 제거라는 주석은 애초에 "재고 필터를 클라이언트 파생값으로 처리하겠다"던 초기 계획의 흔적이다. 그런데 서버 사이드 필터로 다시 바뀐 지금은 inStockOnly가 다시 fetch를 트리거해야 한다. 즉 지금 이 토글을 켜도 다른 필터를 함께 바꾸기 전까진 재요청이 일어나지 않는다. 분리는 수정을 정확한 파일로 보내주지만, 그 파일 안에서 값이 여전히 앞뒤가 맞는지는 따로 확인해야 한다는 걸 이번에 직접 확인했다.
이 글을 쓰는 시점까지 이 코드는 아직 안 고쳤다. 발견한 게 이 글을 정리하던 도중이었고, 회고 글이 코드 패치 노트가 되는 걸 원치 않아 이번엔 기록만 남기고 실제 수정은 다음 커밋으로 미뤄뒀다. "분리해서 좋았다"로 끝내지 않고 "분리해도 이런 건 안 잡힌다"까지 적어두는 게, 다음에 이 코드를 다시 볼 때(혹은 리뷰하는 사람에게) 더 정직하다고 판단했다.
쪼개도 안 풀리던 관심사 하나 — 방향을 뒤집었다
앞 표에서 URL 쿼리 동기화만 "뒤에서"로 미뤄뒀다. 나머지처럼 훅으로 쪼개면 될 줄 알았는데, 막상 손대보니 이건 쪼개기로 풀리는 문제가 아니었다.
원래 URL 동기화는 단방향이었다. 필터 state가 바뀌면 window.history.replaceState로 URL에 밀어넣는다. 반대 방향 — URL을 읽어 state를 채우는 복원 — 은 없었다. 그래서 새로고침하면 주소창엔 ?category=fashion&page=3이 그대로 남아 있는데 화면은 초기 상태로 돌아갔다. replaceState라 히스토리도 안 쌓여서 뒤로가기도 필터 단위로 복원되지 않았다.
이걸 useUrlSync 같은 훅으로 뽑아봐야, 옮겨지는 건 단방향 구조 그 자체다. 문제는 코드의 위치가 아니라 데이터가 흐르는 방향이었다.
그래서 판단을 바꿨다. 쪼개는 게 아니라 소유권을 옮긴다. URL을 단일 출처(single source of truth)로 두고, 필터 값은 전부 URL에서 파생시킨다. 이렇게 하면 category·page·sortBy… 필터 useState 7개가 통째로 사라진다 — 전부 파생값이 되니까, 프로젝트 규칙("파생 가능한 값의 useState 금지")에도 저절로 맞는다.
남은 문제는 "URL 변화를 어떻게 렌더에 반영하느냐"였다. History는 React 바깥의 상태다. React 19에서 외부 스토어를 구독하는 정석은 useSyncExternalStore다.
const CHANGE_EVENT = 'urlsearchparamschange';
function subscribe(callback: () => void) {
window.addEventListener(CHANGE_EVENT, callback);
return () => window.removeEventListener(CHANGE_EVENT, callback);
}
// 문자열 스냅샷 — 같은 search면 참조가 같아 무한 렌더가 안 난다
const getSnapshot = () => window.location.search;
export function useUrlSearchParams() {
const search = useSyncExternalStore(subscribe, getSnapshot);
const params = new URLSearchParams(search); // 파생값, 매 렌더 재계산 OK
const setParams = useCallback((next: URLSearchParams) => {
const qs = next.toString();
window.history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
window.dispatchEvent(new Event(CHANGE_EVENT));
}, []);
return [params, setParams] as const;
}
getSnapshot이 location.search 문자열을 반환하는 게 핵심이다. 여기서 URLSearchParams 객체를 새로 만들어 반환하면 매 렌더 참조가 달라져 무한 렌더에 빠진다. 문자열은 같은 쿼리면 값이 같으니 React가 "안 바뀌었다"고 판단한다.
여기서 한 번 걸렸다. useSyncExternalStore는 React 공식 문서가 "라이브러리 저자용, 앱 코드에서 남용하지 말라"고 못 박은 훅이다. 그래서 잠깐 망설였는데, 다시 보니 이 케이스는 문서가 말리는 상황이 아니었다. 문서가 경계하는 건 "감싸주는 라이브러리(react-router 등)가 이미 있는데 굳이 직접 구독하는 경우"다. 나는 라우터를 안 쓰기로 이미 정했으니 감쌀 게 없다. 오히려 이건 문서가 브라우저 API 구독의 정석 예시(useOnlineStatus)로 직접 드는 패턴과 똑같다. 대안인 useState+useEffect 구독은 같은 문서가 "Not ideal"이라 적어뒀고, 파생값 state 금지 규칙과도 부딪힌다.
그 위에 도메인 훅을 한 겹 얹어 파싱·직렬화를 몰아넣었다.
export function useProductListParams() {
const [params, setParams] = useUrlSearchParams();
// URL → 값 (전부 파생, state 아님)
const rawCategory = params.get('category') ?? 'all';
const category = isCategory(rawCategory) ? rawCategory : 'all';
const page = Number(params.get('page')) || 1;
// …
return {
category, page, /* … */
// 필터를 바꾸면 page를 1로 리셋 — 헬퍼에 내장
setCategory: (c: string) => patch((p) => { setDefault(p, 'category', c, 'all'); p.delete('page'); }),
};
}
ProductListPage에선 useState 7개와 URL 동기화 effect가 통째로 useProductListParams() 한 줄로 바뀌었다. 앞 Callout에서 짚었던 "state를 URL에 복사하던" 파생 복사 effect가 사라진 것도 덤이다 — 단일 출처가 되니 그 복사가 존재할 이유 자체가 없어졌다.
두 겹으로 나눈 건 라우터 대비이기도 하다. 나중에 react-router가 들어오면 아래층 useUrlSearchParams만 useSearchParams로 갈아끼우면 되고, 도메인 훅 useProductListParams는 그대로 산다.
이 관심사는 앞의 셋과 분리 방식이 달랐다. 훅·컴포넌트는 쪼개서 분리했고, URL은 소유권을 옮겨서 분리했다. 둘 다 "관심사 분리"라 부르지만, 하나는 코드를 나누는 일이었고 하나는 데이터가 어디서 흐르는지를 뒤집는 일이었다.
폴더에 넣는 것과 계층으로 가르는 것은 다르다
마지막은 흩어진 파일을 폴더로 정리하는 단계였다. components/, hooks/, service/, utils/ — 제일 기계적인 작업처럼 보였다. 파일 옮기고 import 경로만 고치면 끝날 줄 알았는데 두 군데서 멈췄다.
첫째는 이 폴더들을 어디에 둘까였다. src/hooks, src/components처럼 전역으로 끌어올리는 게 흔한 구조다. 근데 productList는 하나의 기능 모듈이고, 계층 폴더를 전역으로 올리면 그 기능 응집이 흩어진다. 다른 기능(market)과 실제로 공유되는 게 관측되기 전엔 기능 폴더 안에 둔다. 이건 앞에서 types.ts를 3번째 소비처가 생길 때까지 안 뺀 판단과 같은 결이다 — 공유가 실측되면 그때 src/shared/로 올린다.
둘째가 진짜였다. useProducts를 hooks/에 옮기면서 보니, 이 훅은 여전히 세 가지를 한다 — React state, effect 라이프사이클, 그리고 fetch + 쿼리 직렬화 + 응답 파싱. 앞 둘은 hook의 일이 맞는데 세 번째는 아니다. 그건 "API를 직접 호출하는 로직"이고, hook 계층이 아니라 service 계층의 일이다. 파일을 hooks/ 폴더에 넣는다고 그 파일이 hook의 역할만 하게 되는 건 아니었다. 폴더 이동은 위치를 바꿀 뿐, 그 안에 섞인 역할까지 갈라주진 않는다.
그래서 fetch 블록만 service/productApi.ts로 뺐다. React를 import하지 않는 순수 async 함수다.
export const PAGE_SIZE = 12;
export type ProductQuery = {
category: 'all' | Product['category'];
minPrice: number | '';
maxPrice: number | '';
inStockOnly: boolean;
sortBy: SortBy;
searchQuery: string;
page: number;
};
// 쿼리 직렬화 + fetch + 파싱을 한 곳에. API 접점 단일화
export function fetchProducts(query: ProductQuery): Promise<ProductListResponse> {
/* … */
}
이제 useProducts엔 state와 effect만 남고, effect 안에서 fetchProducts(query)를 부른다. 얻은 것 두 가지. 엔드포인트·쿼리 규칙이 productApi.ts 한 파일에 모여, 실제 백엔드로 갈아탈 때 여기만 고치면 된다. 그리고 직렬화 규칙(minPrice !== ''일 때만 파라미터에 넣는다 같은)을 React 없이 단위 테스트할 수 있게 됐다.
그런데 여기서 앞의 Callout이 다시 걸린다. fetch 로직을 통째로 옮기면서 그 안의 inStockOnly dep 누락 버그도 같이 옮겨진다. 이동은 순수 이동이라 옳은 코드도 틀린 코드도 그대로 데려간다. 위치를 정리한다고 정합성이 따라오진 않는다는 걸, 파일 하나 옮기면서 또 확인했다.
의존성은 위에서 아래로만 흐르게 못박았다. page → hooks → service → types, utils는 순수 함수라 아무것도 위로 import하지 않는다. service가 React를 모르는 것도 이 규칙의 일부다 — fetch와 직렬화만 알면 되니까. barrel(index.ts)은 안 만들었다. 파일이 몇 개 안 되는데 재노출 계층을 두면 유지비만 는다. 외부 소비처가 생기면 그때.
결국 남은 것
다섯 지점을 한 표로 다시 모으면 이렇다.
| 판단 | 근거 | 결과 |
|---|---|---|
types.ts를 미리 안 만듦 | 소비처 1곳뿐, 조건("3번째면") 명시 | 조건이 왔을 때 망설임 없이 분리 |
usePersistentState 위에 다른 반환 모양 | 렌더에서 값을 읽는지 여부가 다름 | 안 쓰이는 값이 호출부로 새지 않음 (단, 내부 useState는 그대로라 불필요한 리렌더는 남음) |
| 파일을 역할별로 분리 | 도메인 규칙/페칭/UI를 각자 파일에 | 외부 수정이 정확한 파일로 감 (단, 그 안의 정합성은 별도 확인) |
| URL을 쪼개는 대신 소유권을 URL로 넘김 | 단방향 동기화는 훅 추출로 안 풀림 | 필터 useState 7개가 파생값으로 사라짐 |
useProducts에서 fetch를 service로 뺌 | 폴더 이동 ≠ 계층 분리 | hook은 배선만, API 접점은 한 파일로 |
다섯 경우 모두 시작은 "이걸 나눌까 말까"가 아니라 "이 코드가 실제로 어떤 역할을 하는가"였다. 줄 수를 줄이는 걸 목표로 했으면 아무 경계로나 파일을 쪼개도 됐겠지만, 그러면 useRecentlyViewed가 안 쓰는 값을 반환하는 것 같은 어색함이 그대로 남았을 거다. 분리는 그 자체로 목적이 아니라, 역할을 정확히 나눴을 때 따라오는 결과라는 걸 이번에 코드로 확인했다.
그리고 뒤의 두 지점에서 알게 된 건, "분리"라는 한 단어가 실제로는 서로 다른 동작을 가리킨다는 거였다. 훅·컴포넌트는 쪼개서 나눴고, URL 관심사는 소유권을 옮겨서 나눴고, fetch는 계층을 추출해서 나눴다. 관심사마다 맞는 분리의 형태가 달랐다 — 어떤 건 파일을 가르는 일이었고, 어떤 건 데이터가 흐르는 방향을 뒤집는 일이었다. "어떻게 나눌까"보다 먼저 "이 관심사는 어떤 종류의 분리를 원하는가"를 물어야 했다.
동시에 표의 괄호와 Callout이 이번 회고의 한계이기도 하다. 반환 모양을 역할에 맞게 가른 것과 그 역할을 렌더 비용·의존성까지 끝까지 따라가는 것은 다른 일이었고, fetch를 service로 옮긴 것과 그 안의 inStockOnly 정합성을 맞추는 것도 다른 일이었다. 분리는 방향을 맞게 잡아줬지만, 그 안의 디테일까지 저절로 맞춰주진 않았다.