배운 내용을 토대로 536줄짜리 Page 컴포넌트 하나를 받았습니다. 해당 컴포넌트를 적절하게 트레이드 오프를 따져가며 나눠본 경험을 기록합니다.
상품 목록 페이지였는데 UI, 비즈니스 로직, API 호출, 포맷팅, 도메인 규칙이 전부 한 파일에 들어 있었습니다. 대부분은 동작했습니다. 문제는 동작이 아니라 정렬 조건 하나를 바꾸려면 536줄을 전부 읽어야 한다는 점이었습니다.
리팩토링 요구사항은 컴포넌트를 components / hooks / services / utils 레이어로 가르는 것이었습니다.
그런데 막상 시작해보니 어려운 건 어떻게 나누냐가 아니었습니다.
"어디까지 분리해야 하지?" 와 "이 분리는 맞는 분리일까?" 라는 판단의 기준이 어려웠습니다.
기준부터 세우기
파일을 쪼개는 것 자체는 쉽습니다. 로직을 훅 파일로 옮기고 JSX를 컴포넌트 파일로 옮기면 됩니다. 하지만 그렇게만 하면 큰 컴포넌트가 큰 훅으로 이름만 바꿔 옮겨갈 뿐이라는 생각이 들었습니다.
그래서 분리를 시작하기 전에 기준을 하나 세웠습니다.
“이 분리가 무엇을 좋게 하나?” 를 한 문장으로 답하지 못하면 분리하지 않는다.
그리고 컴포넌트 안의 상태를 먼저 세 가지로 분류했습니다.
- 서버 상태 — API 응답 데이터, 로딩, 에러
- 클라이언트 상태 — 필터 조건, 페이지 번호, 보기 모드, 위시리스트
- 파생값 — 전체 페이지 수, 필터링된 목록처럼 다른 상태에서 계산 가능한 값
536줄을 이 기준으로 훑어보니 문제가 선명해졌습니다. 서버 상태를 컴포넌트가 useState와 fetch로 직접 관리하고 있었고, 파생 가능한 값이 별도 state로 존재했고, 클라이언트 상태 열두어 개가 성격 구분 없이 나열되어 있었습니다.
이 분류가 생각보다 많은 결정을 대신 해주었습니다. 서버 상태는 전용 훅이 가져가고 파생값은 state로 만들지 않고 계산합니다. 남는 클라이언트 상태만 성격별로 묶으면 훅의 경계가 자연스럽게 그어졌습니다.
전체를 어떻게 갈랐나
분리를 마친 뒤의 구조는 다음과 같습니다.
productList/
ProductListPage.tsx 훅 호출과 JSX 조립만 담당
types.ts Product 도메인 모델
dto.ts API 요청/응답 타입
components/ 렌더링만 담당하는 컴포넌트 9개
hooks/ 상태와 로직을 담당하는 커스텀 훅 7개
services/ api 함수
utils/ 순수함수 6개
레이어마다 하나씩, 실제 코드가 어떻게 옮겨갔는지 남겨봅니다.
hooks — 서버 상태를 컴포넌트에서 꺼내기
훅은 일곱 개가 나왔습니다. 각 훅을 한 문장으로 설명할 수 있는지를 분리 단위의 검증으로 삼았습니다. 설명에 그리고가 두 번 들어가면 아직 덜 나눈 것으로 봤습니다.
예를 들면, usePaginationScrollTop: 현재 페이지 번호를 관리한다. 그리고 페이지가 바뀔 때 스크롤이 위로 올라간다. 같은 케이스는 덜 나눈 것입니다.
useProducts— 상품 목록 서버 상태를 관리한다useProductFilters— 서버 조회 필터 상태를 관리하고 URL에서 초기값을 복원한다usePagination— 현재 페이지 번호를 관리한다useScrollToTop— 값이 바뀌면 스크롤을 최상단으로 올린다useDebouncedValue— 값이 안정될 때까지 반영을 지연한다useWishlist/useRecentlyViewed— 위시리스트와 최근 본 상품을 localStorage와 동기화하며 관리한다
AS-IS — 페이지가 fetch, 로딩, 에러를 전부 직접 관리하고 있었습니다.
const [products, setProducts] = useState<Product[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const fetchProducts = async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/products?${params.toString()}`)
if (!res.ok) throw new Error(`API 호출 실패 (status: ${res.status})`)
const data = await res.json()
setProducts(data.products)
} catch (err) {
setError(err as Error)
} finally {
setIsLoading(false)
}
}
fetchProducts()
}, [category, minPrice, maxPrice, sortBy, searchQuery, page])TO-BE — 서버 상태는 전용 훅이 소유하고, 페이지는 결과만 받습니다. 에러 시 전체 새로고침 대신 같은 조건으로 재요청할 수 있도록 refetch도 함께 노출했습니다.
const { products, totalCount, isLoading, error, refetch } = useProducts({
category,
sort: sortBy,
q: debouncedSearchQuery,
page,
size: PAGE_SIZE,
minPrice: debouncedMinPrice,
maxPrice: debouncedMaxPrice,
inStock: inStockOnly,
})페이지는 이제 fetch가 어떻게 이뤄지는지 모릅니다. 조회 조건을 넣으면 서버 상태가 나온다는 계약만 남았습니다.
components — 이벤트 파싱은 컴포넌트가 흡수하기
컴포넌트는 렌더링만 담당하게 했습니다. 인풋 이벤트에서 값을 파싱하는 것까지가 컴포넌트의 몫이고, 그 값으로 무엇을 할지는 페이지가 정합니다.
AS-IS — 페이지 핸들러가 DOM 이벤트를 직접 받아 파싱까지 담당했습니다.
const handleMinPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
setMinPrice(v === "" ? "" : Number(v))
handlePageChange(1)
}TO-BE — 파싱은 입력 컴포넌트의 몫으로 내리고, 페이지에는 값만 도착합니다. Number('') === 0 이라서 빈 입력이 “0원 필터”로 둔갑하는 함정도 파싱 함수가 소유합니다.
// PriceFilter.tsx - 컴포넌트가 이벤트를 값으로 바꿔서 올려보낸다
return <input value={min} onChange={(e) => onMinChange(parseNumberInput(e.target.value))} />
// ProductListPage.tsx - 페이지는 값의 세계만 다룬다
const handleMinPriceChange = (value: number | "") => {
setMinPrice(value)
handlePageChange(1)
}페이지 핸들러의 시그니처가 ChangeEvent에서 값으로 바뀌었습니다. 페이지는 더 이상 e.target.value를 모릅니다.
services — fetch 구현을 한 곳으로 모으기
AS-IS — endpoint와 쿼리 조립이 페이지의 useEffect 안에 박혀 있었습니다.
const params = new URLSearchParams({
category,
sort: sortBy,
q: searchQuery,
page: String(page),
})
if (minPrice !== "") params.set("minPrice", String(minPrice))
const res = await fetch(`/api/products?${params.toString()}`)TO-BE — 공통 fetch 래퍼와 api 함수로 분리했습니다. service 파일만 봐도 어떤 api인지 보입니다.
// api/apiClient.ts - baseUrl 결합, res.ok 검사, json 파싱만 담당
export const apiClient = new ApiClient("/api")
// services/products/getProductList.ts - 이 파일이 곧 API 스펙
export const getProductList = (params: ProductListGetFetchParams) =>
apiClient.get<ProductListResponse>(`/products?${getProductListQueryParams(params)}`)쿼리 조립은 순수함수(getProductListQueryParams)에 위임해서, api 함수에는 “무엇을 어디로 요청하나”만 남겼습니다.
utils — 도메인 규칙을 순수함수로 빼기
도메인 규칙(할인율 계산, 뱃지 노출 조건, 무료배송 판정)은 카드 렌더 안에 인라인으로 흩어져 있던 것을 전부 순수함수로 빼냈습니다.
AS-IS — 뱃지 노출 조건이 카드 렌더 루프 안에 boolean과 매직넘버로 흩어져 있었습니다.
products.map((product) => {
const discountRate = product.originalPrice
? Math.round((1 - product.price / product.originalPrice) * 100)
: 0
const isAlmostSoldOut = product.stock > 0 && product.stock <= 5
const isSoldOut = product.stock === 0
const isHot = discountRate >= 30
const isBest = product.rating >= 4.5 && product.reviewCount >= 100
// ...JSX에서 6개 boolean으로 분기
})TO-BE — 이 상품에 어떤 뱃지를 보여주나라는 판단을 순수함수 하나로 모았습니다. 서로 배타적인 재고 상태는 boolean 2개 대신 단일 상태로 분류했습니다.
// utils/getProductBadges.ts
const getStockStatus = (stock: number): StockStatus =>
stock === 0 ? "soldout" : stock <= ALMOST_SOLD_OUT_STOCK ? "almost" : "ok"
export const getProductBadges = (product: Product, now = Date.now()): ProductBadgeInfo[] => {
// 할인, NEW, 특가, BEST 판정 후 뱃지 목록 반환
}// ProductCard.tsx - 카드는 결과만 그린다
{
getProductBadges(product).map((badge) => (
<span key={badge.type} className={`badge badge-${badge.type}`}>
{badge.label}
</span>
))
}매직넘버들은 규칙을 소유한 파일 안에 상수로 이름을 붙였고, 카드는 판단 없이 목록을 렌더링만 합니다.
이렇게 갈라내고 나니 페이지 컴포넌트에는 훅 호출과 JSX 조립, 그리고 필터가 바뀌면 1페이지로 되돌린다 같은 훅 사이의 교차 규칙만 남았습니다. 536줄이 189줄이 되었습니다.
분리가 버그를 고쳤다
이번 리팩토링에서 가장 기억에 남는 순간은 분리 자체가 버그를 해결했을 때였습니다.
원본 코드는 로딩 상태를 페이지 최상단에서 early return으로 처리하고 있었습니다.
if (isLoading && products.length === 0) {
return <div className="loading">로딩 중...</div>
}언뜻 보면 관용적인 코드입니다. 그런데 검색창에 타이핑을 하면 이상한 일이 벌어졌습니다. 글자를 입력할 때마다 로딩 화면으로 전환되면서 검색창 자체가 사라졌다 나타나고, 그때마다 입력 포커스가 날아갔습니다.
원인은 early return이 페이지 전체를 교체한다는 점이었습니다. 로딩 중에는 필터도 검색창도 전부 언마운트됩니다. controlled input이 언마운트되면 DOM 노드가 파괴되니 포커스도 함께 사라집니다.
처음에는 포커스를 복구하는 코드를 넣을까 생각했지만, 이건 증상을 가리는 패치라는 생각이 들었습니다. 진짜 문제는 로딩이라는 상태가 실제로 영향을 주는 범위는 결과 영역뿐인데, 페이지 전체가 그 상태에 먹히고 있다는 것이었습니다.
그래서 로딩, 빈 결과, 에러 상태의 표현을 결과 영역 컴포넌트(ProductGrid)가 소유하도록 분리했습니다.
// 페이지 - early return이 사라지고 헤더는 항상 렌더된다
<SearchSortBar ... />
<ProductGrid products={products} isLoading={isLoading} error={error} ...>
{products.map((product) => <ProductCard key={product.id} ... />)}
</ProductGrid>검색창은 더 이상 언마운트되지 않고 포커스는 유지됩니다. 별도의 포커스 복구 코드 없이, 상태 표현의 소유자를 바로잡는 것만으로 버그가 사라졌습니다.
상태 표현은 그 상태가 실제로 영향을 주는 영역이 소유한다.
분리 근거를 생각하면서 리팩토링 하다보니 왜 나누는가에 답하는 과정에서 버그의 원인이 같이 드러나게 되었습니다.
첫 분리에서 만난 순환 참조
API 레이어를 분리하면서 DTO 타입을 fetcher 파일 안에 같이 두었습니다. 한 API에 한 파일, 타입도 그 옆에. 평소에 하던 방식이라 자연스러웠습니다.
그런데 조회 파라미터를 쿼리 문자열로 정제하는 util 함수를 만들면서 문제가 생겼습니다.
AS-IS — service는 util의 함수를 쓰고, util은 service 파일 안의 타입을 씁니다. 서로가 서로를 import하는 순환 참조가 생긴 것입니다.
getProductList (service) ──▶ getProductListQueryParams (util) // 함수를 가져다 씀
getProductListQueryParams (util) ──▶ getProductList (service) // 타입을 가져다 씀
고민 끝에 타입을 별도 파일로 빼면서 이렇게 정리했습니다.
타입은 로직이 아니라 계약이다. 그러니 service와 util 둘 다 내려다볼 수 있는 위치에 둔다.
TO-BE — 도메인 모델과 API 계약을 나눠서, service와 util 둘 다 아래 방향으로만 참조하는 위치에 두었습니다.
// types.ts - Product 도메인 모델. 아무것도 import하지 않는다
export type Product = {
/* ... */
}
// dto.ts - API 요청/응답 계약. 도메인만 참조한다
export interface ProductListGetFetchParams {
/* ... */
}
export type ProductListResponse = { products: Product[]; totalCount: number }순환이 사라졌고 파일을 나눈 이유를 한 문장으로 답할 수 있게 되었습니다.
나누지 않은 것, 만들었다 지운 것
이번 리팩토링에서 가장 많이 배운 부분은 만든 코드가 아니라 만들지 않기로 한 결정이었습니다.
URL 동기화 effect는 훅으로 빼지 않았습니다. 필터 상태가 바뀔 때마다 URL 쿼리에 반영하는 useEffect인데, 페이지를 조립만 하는 컴포넌트로 만들고 싶어서 이것도 훅으로 빼려고 했습니다. 그런데 빼고 난 모습을 그려보니 로직이 한 줄도 바뀌지 않고 파일만 이동한 형태였습니다. 테스트가 쉬워지는 것도, 재사용처가 생기는 것도 아니었습니다. 얻는 것은 파일 이름 하나뿐이었습니다. 옮겨서 무언가 좋아져야 분리인데 이건 그냥 이관이었습니다.
공통 훅 하나는 만들었다가 지웠습니다. 위시리스트와 최근 본 상품 훅이 localStorage 동기화 로직을 똑같이 들고 있어서 useLocalStorageState라는 공통 훅으로 뽑았는데, 뽑고 나서 보니
사용처가 딱 두 곳이었습니다. 두 번 반복된 코드를 위해 추상화 하나를 만든 셈입니다. 세 번째 사용처가 생기기 전까지는 이르다고 판단하고 되돌렸습니다.
Context 도입도 접었습니다. 에러 재시도를 위해 서버 상태를 Context에 올리는 그림을 먼저 그렸는데, refetch가 필요한 곳을 세어보니 한 곳이었고 props로 전달해도 한 단계였습니다. Context가 푸는 문제는 전달인데 전달 거리가 한 단계라면 풀 문제가 없는 것이었습니다.
세 결정 모두 결이 같았습니다.
오버엔지니어링은 설계가 틀린 게 아니라, 아직 없는 요구를 가정한 것이었습니다.
그래서 지울 때 그냥 지우지 않고 다시 만들 조건을 함께 적어두었습니다. localStorage 상태가 세 번째로 생기면 공통 훅으로, refetch 소비자가 여러 곳이 되면 Context로. 트리거를 적어두니 지우는 결정이 불안하지 않았습니다.
고민은 계속된다
다 끝내고 나서도 확신이 서지 않는 결정이 남아 있습니다.
- 대칭 때문에 분리한 컴포넌트가 있습니다.
재고 필터는 체크박스 하나짜리입니다. 분리해서 얻는 게 거의 없는데, 필터 패널의 다른 컨트롤(카테고리, 가격)만 분리하니 혼자 페이지에 남아 따로 노는 모양이 되었습니다. 일관성을 위해
분리하긴 했지만 근거는 약한데 대칭 때문에 분리하고 싶어지는 이 패턴을 어떻게 다뤄야 하는지는 아직 답을 못 내렸습니다. 아래와 같이 말이죠.
<section className="filter-panel">
<CategoryFilter value={category} onChange={handleCategoryChange} />
<PriceFilter
min={minPrice}
max={maxPrice}
onMinChange={handleMinPriceChange}
onMaxChange={handleMaxPriceChange}
/>
{/* 이 부분만 컴포넌트가 아니게 된다 */}
<div className="filter-group">
<label>옵션</label>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => handleInStockToggle(e.target.checked)}
/>
재고 있는 것만
</label>
</div>
<button className="reset-button" onClick={handleResetFilters}>
필터 초기화
</button>
</section>- URL을 아는 코드가 세 곳에 흩어져 있습니다.
URL에서 초기값을 읽는 곳이 두 곳, URL에 쓰는 effect가 한 곳입니다. 도메인 훅은 자기 URL 키를 직접 읽어도 되지만 범용 훅은 URL을 몰라야 한다고 판단해서 생긴 비대칭인데, URL 쿼리 키를 바꾸려면 세 파일을 열어야 하는 구조가 된 것도 사실입니다. 지금 규모에서는 감수했지만 키가 하나라도 바뀌는 날이 오면 분리된 코드를 하나하나 찾아가서 바꾸는 상황이 발생합니다. 그 때는 합쳐야할까? 라는 고민을 다시 하게될 것 같습니다.
회고
리팩토링을 마치고 세어보니 1개였던 파일이 26개가 되어 있었습니다. 그런데 이번에 배운 것은 파일을 늘리는 방법이 아니었습니다.
일주일 동안의 결정을 돌아보면 분리를 가른 기준은 결국 세 개였습니다.
- 형태 — JSX를 반환하면 컴포넌트, React 훅을 쓰면 훅, 순수 계산이면 util
- 상태의 유형 — 서버 / 클라이언트 / 파생값. 파생값은 state로 만들지 않는다
- 변경의 이유 — 함께 변하고 함께 리셋되는 것끼리 묶고, 변경 이유가 다르면 가른다
그리고 이 세 기준을 통과한 분리도 마지막 질문 하나를 넘어야 했습니다. 이 분리가 무엇을 좋게 하는가. 여기에 답이 없으면 형태가 그럴듯해도 이관일 뿐이었습니다.
분리는 파일을 쪼개는 일이 아니라, 한 번에 한 가지만 읽게 만드는 일이라고 느꼈습니다.