1 / 3
같이 바뀌어야 하는 처리는 묶어버린다
아무도 틀리지 않았다. 다들 가장 가까운 코드를 성실하게 따라 썼을 뿐이다.
1년 전
관리자용 상품 목록 화면. 처음엔 상태가 두 개뿐이었다. 검색어와 페이지.
검색어가 바뀌면 페이지를 1로 돌린다. 두 줄이면 충분했다.
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
function onSearch(q: string) {
setQuery(q);
setPage(1);
}그 뒤 1년 동안
요청이 하나씩 쌓였다. 카테고리, 정렬, 가격 범위, 재고 있음만 보기, 그리고 여러 상품을 골라 한 번에 비공개로 돌리는 일괄 선택.
매번 다른 사람이, 매번 "금요일까지"였다. 상태는 여덟 개가 됐고, 바꾸는 코드는 네 개 파일에 흩어졌다.
규칙도 같이 쌓였다. 필터가 바뀌면 결과가 달라지니까 페이지를 1로, 선택도 해제. 정렬은 결과가 같으니까 페이지만 1로, 선택은 유지. 이 차이는 각 파일을 짠 사람의 머릿속에만 있었다.
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [sort, setSort] = useState<Sort>("newest");
const [minPrice, setMinPrice] = useState<number | null>(null);
const [maxPrice, setMaxPrice] = useState<number | null>(null);
const [inStock, setInStock] = useState(false);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);// 필터: 페이지 1 + 선택 해제
<CategorySelect
onChange={(c) => {
setCategory(c);
setPage(1);
setSelected([]);
}}
/>// 정렬: 페이지 1, 선택은 유지
<Dropdown
onChange={(v) => {
setSort(v);
setPage(1);
}}
/>// URL에서 복원할 때도 같은 규칙을 다시 구현
useEffect(() => {
const q = parse(location.search);
if (q.category !== category) {
setCategory(q.category);
setPage(1);
setSelected([]);
}
// ...
}, [location.search]);브랜드 필터
새로 온 개발자는 성실했다. 바닥부터 짜지 않고 기존 코드를 찾아봤다. 드롭다운 하나로 값을 고르는 컴포넌트, 가장 비슷해 보이는 건 SortSelect.tsx였다. 그걸 복사했다.
정렬과 필터의 규칙이 다르다는 건 어디에도 적혀 있지 않았다. 리뷰어도 "기존 패턴 잘 따랐네요" 하고 승인했다.
// SortSelect.tsx를 복사
<Dropdown
onChange={(b) => {
setBrand(b);
setPage(1);
}}
/>
// 선택은 해제되지 않는다월요일 아침
운영 담당자가 할인이 끝난 A 브랜드 상품 30개를 선택했다. 그리고 확인할 게 있어 브랜드를 B로 바꿨다. B 브랜드 상품 5개를 골라 "선택 항목 비공개"를 눌렀다.
35개가 비공개가 됐다. 화면에 보이지 않던 A 브랜드 30개까지. 그중엔 오늘 오전 광고가 걸린 상품도 있었다.
왜 이렇게 됐나
틀린 코드를 쓴 사람은 없었다. 다들 가장 가까운 코드를 따라 썼다. 문제는 따라 쓸 코드가 두 종류였고, 어느 쪽을 따라야 하는지가 여덟 개 상태와 네 개 파일 사이 어디에도 없었다는 것이다.
상태가 두 개일 때는 규칙이 눈에 보인다. 여덟 개가 되면 규칙은 코드가 아니라 사람의 기억에 산다. 그리고 사람은 바뀐다.
터지기 전에
"결과가 바뀌는 변경"과 "결과가 같은 변경"을 각각 함수 하나로 묶는다. 페이지를 몇으로 할지, 선택을 지울지는 부르는 쪽이 아니라 그 함수가 정한다.
이제 브랜드 필터를 추가하는 사람은 changeFilter를 부르기만 하면 된다. 페이지 초기화도 선택 해제도 따라온다. URL 복원도 같은 함수를 부른다. 잘못 따라 쓸 코드가 아예 없다.
리듀서든, 스토어든, 함수 하나든 형태는 상관없다. 프레임워크도 상관없다. 중요한 건 같이 일어나야 하는 처리를 한 덩어리로 묶어서, 쪼개서 부를 수 없게 만드는 것이다.
export type ListState = {
filters: Filters;
sort: Sort;
page: number;
selected: string[];
};
// 결과가 바뀐다 → 페이지·선택 초기화
export function changeFilter(s: ListState, patch: Partial<Filters>): ListState {
return { ...s, filters: { ...s.filters, ...patch }, page: 1, selected: [] };
}
// 결과는 같다 → 선택 유지
export function changeSort(s: ListState, sort: Sort): ListState {
return { ...s, sort, page: 1 };
}<Dropdown
onChange={(brand) => setList((s) => changeFilter(s, { brand }))}
/>useEffect(() => {
setList((s) => changeFilter(s, parse(location.search)));
}, [location.search]);