"이게 화면 그리는 코드야, 데이터 처리하는 코드야?"
회원가입 컴포넌트(SignupForm.tsx)를 열었는데 스크롤이 끝도 없이 내려갔습니다.
useState만 10개, useEffect 3개, 각종 핸들러 함수들...
정작 return <form>...</form> JSX 부분은 파일 맨 끝에 쳐박혀 있었습니다.
"여기서 이메일 중복 체크 로직이 어디 있지?"
찾으려면 Ctrl+F를 눌러야 했습니다.
디자이너가 "버튼 색깔 바꿔주세요"라고 하면, 로직 사막을 헤치고 UI 오아시스를 찾아야 했죠.
처음엔 뭐가 이해가 안 갔나? (Hook은 재사용할 때만 쓰는 거 아니야?)
저는 Custom Hook을 "여러 컴포넌트에서 쓰이는 공통 로직(예: useWindowSize)"을 만들 때만 쓰는 건 줄 알았습니다.
회원가입 로직은 SignupForm에서만 쓰이니까, 굳이 훅으로 분리할 필요가 없다고 생각했죠.
하지만 Hook의 진짜 가치는 재사용성이 아니라 "관심사의 분리(Separation of Concerns)"에 있다는 걸 몰랐습니다. 재사용 안 해도 됩니다. 단지 코드를 읽기 좋게 나누기 위해서라도 써야 합니다.
어떤 포인트에서 이해가 됐나? (주방장과 지배인 비유)
이걸 "레스토랑 업무 분담"에 비유하니 이해가 됐습니다.
- Before (Fat Component): 지배인이 요리도 하고, 서빙도 하고, 계산도 합니다. 정신없어서 주문 실수합니다.
- After (Custom Hook):
- Custom Hook (주방장): 요리(로직, 데이터 처리)만 전담합니다. "파스타 나왔습니다!" 하고 결과물(데이터)만 내줍니다.
- Component (지배인): 서빙(UI 렌더링)만 전담합니다. 주방장이 준 파스타를 손님 식탁(화면에)에 놓기만 합니다.
지배인은 주방에서 칼질을 어떻게 하는지 몰라도 됩니다.
그냥 usePasta()를 호출하면 파스타가 나온다는 것만 알면 됩니다.
해결 과정 - 로직 추출하기
1단계 - 덩어리 찾기
SignupForm에서 서로 연관된 상태와 함수들을 찾습니다.
(이메일, 비밀번호, 폼 제출 핸들러, 유효성 검사 등)
2단계 - useSignupLogic.ts 만들기
JSX(UI)를 제외한 모든 것을 잘라내서(Cut) 새 파일에 붙여넣습니다(Paste).
// useSignupLogic.ts
import { useState } from 'react';
export function useSignupLogic() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async () => {
// ... 복잡한 API 호출 및 에러 처리 ...
};
// 컴포넌트가 써야 할 것만 리턴
return {
form: { email, password },
actions: { setEmail, setPassword, handleSubmit },
isValid: email.includes('@') && password.length > 8
};
}
3단계 - 컴포넌트 다이어트
이제 원래 컴포넌트는 UI만 남습니다.
// SignupForm.tsx
import { useSignupLogic } from './useSignupLogic';
export function SignupForm() {
// 로직은 훅에게 위임 (주문하면 요리 나옴)
const { form, actions, isValid } = useSignupLogic();
return (
<form onSubmit={actions.handleSubmit}>
<input
value={form.email}
onChange={e => actions.setEmail(e.target.value)}
/>
<button disabled={!isValid}>Sign Up</button>
</form>
);
}
500줄짜리 코드가 50줄로 줄어들었습니다.
이제 디자이너가 와서 수정해달라고 하면, 바로 JSX를 고치면 됩니다.
백엔드 로직이 바뀌면 useSignupLogic만 열면 됩니다.
깊이 파고들기 - Headless UI 패턴
이 패턴을 극단적으로 밀고 나가면 Headless UI가 됩니다.
Radix UI나 React Table (TanStack Table)이 대표적입니다.
라이브러리는 기능(Hook)만 제공하고, 스타일(UI)은 네가 알아서 입히라는 겁니다.
// TanStack Table 예시
const { getHeaderGroups, getRowModel } = useReactTable({...});
return (
<table>
{/* 내가 원하는 스타일로 자유롭게 렌더링 */}
{getHeaderGroups().map(...)}
</table>
)
이 방식은 디자인 제약이 없으면서도 복잡한 기능을 쉽게 가져다 쓸 수 있는 최상의 유연함을 제공합니다.
Application: 테스트 용이성
로직이 UI와 섞여있으면 테스트하기 힘듭니다. (render(<SignupForm />) 하고 DOM 엘리먼트 찾아서 클릭하고...)
하지만 훅으로 분리하면 renderHook을 써서 로직만 따로 테스트할 수 있습니다.
// useSignupLogic.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
test('비밀번호가 짧으면 isValid가 false여야 함', () => {
const { result } = renderHook(() => useSignupLogic());
act(() => {
result.current.actions.setPassword('123');
});
expect(result.current.isValid).toBe(false);
});
UI 없이도 비즈니스 로직을 완벽하게 검증할 수 있습니다.
Hook Composition (훅이 훅을 낳고) 뜯어보기
Custom Hook의 진짜 강력함은 "다른 Hook을 조립해서 새로운 Hook을 만들 때" 나옵니다.
乐高(LEGO) 블록처럼요.
useUser()와 usePermissions()가 있다면, 이 둘을 조합해서 useAdminAction()을 만들 수 있습니다.
function useAdminAction() {
const user = useUser();
const { canDelete } = usePermissions();
const deleteUser = async (targetId: string) => {
if (!user.isAdmin || !canDelete) {
throw new Error("권한이 없습니다");
}
await api.deleteUser(targetId);
};
return { deleteUser };
}
UI 컴포넌트는 user.isAdmin이 뭔지, canDelete가 뭔지 알 필요가 없습니다.
그냥 useAdminAction이 시키는 대로 deleteUser만 호출하면 됩니다.
로직의 계층(Layer)이 생기는 겁니다.
제네릭(Generic)으로 만능 훅 만들기 더 알아보기
가끔은 타입 안전성을 유지하면서 재사용성을 극대화해야 합니다. 리스트를 선택하는 로직은 어디서나 쓰입니다. 이걸 제네릭으로 만들어봅시다.
function useSelection<T>(initialItems: T[]) {
const [selected, setSelected] = useState<T[]>([]);
const toggle = (item: T) => {
setSelected(prev =>
prev.includes(item)
? prev.filter(i => i !== item) // 이미 있으면 제거
: [...prev, item] // 없으면 추가
);
};
return { selected, toggle };
}
// 사용
const { selected: selectedUsers, toggle } = useSelection<User>(users);
const { selected: selectedTags, toggle } = useSelection<string>(tags);
이렇게 하면 User 객체든 String이든 상관없이 "선택하고 끄는 로직"을 100% 재사용할 수 있습니다.
TypeScript Generic을 훅에 적용하면 생산성이 눈에 띄게 올라갑니다.
9. Case Study: 쇼핑몰 "메가 필터" 로직 탈출기
실제 현업에서 겪은 일입니다.
쇼핑몰 상품 목록 페이지(ProductList.tsx)가 터지기 일보 직전이었습니다.
상황
필터 조건이 20개가 넘었습니다.
(카테고리, 가격 범위, 브랜드, 색상, 사이즈, 평점, 당일배송 여부...)
URL 쿼리 파라미터(?category=top&price_min=1000...)와 동기화도 해야 했습니다.
useEffect가 서로 꼬여서 "뒤로 가기"를 누르면 필터가 꼬이는 버그가 발생했습니다.
해결: useProductFilter
이 거대한 덩어리를 useProductFilter.ts로 뜯어냈습니다.
- State: 모든 필터 상태 관리
- Sync: URL 쿼리 파라미터 양방향 동기화 (State
<->URL) - Action:
setFilter,resetFilter,applyFilter
// ProductList.tsx (Refactored)
function ProductList() {
// UI는 쿼리 파라미터 파싱 로직을 전혀 모름!
const { filters, updateFilter } = useProductFilter();
const { data } = useProducts(filters);
return (
<Layout>
<Sidebar>
{/* 그냥 함수만 넘겨주면 됨 */}
<CategoryFilter value={filters.category} onChange={updateFilter} />
<PriceFilter value={filters.price} onChange={updateFilter} />
</Sidebar>
<Main>
{data.map(product => <ProductCard product={product} />)}
</Main>
</Layout>
);
}
결과적으로 ProductList.tsx는 "레이아웃 배치"에만 집중하게 되었습니다.
필터 로직이 아무리 복잡해져도(예: "가격 필터 변경 시 브랜드 필터 초기화") 컴포넌트는 영향을 받지 않습니다.
Optimistic UI (낙관적 업데이트) 훅 제대로 파보기
Custom Hook을 쓰면 "낙관적 업데이트" 같은 고급 패턴도 쉽게 재사용할 수 있습니다. "좋아요" 버튼을 누르면 서버 응답을 기다리지 않고 즉시 하트가 빨개지는 기능입니다.
function useOptimisticMutation<T>(
key: string,
mutationFn: (data: T) => Promise<void>
) {
const queryClient = useQueryClient();
return useMutation({
onMutate: async (newData) => {
// 1. 진행 중인 쿼리 취소
await queryClient.cancelQueries([key]);
// 2. 이전 값 저장 (스냅샷)
const previousData = queryClient.getQueryData([key]);
// 3. UI 즉시 업데이트 (가짜 데이터 주입)
queryClient.setQueryData([key], newData);
return { previousData };
},
onError: (err, newData, context) => {
// 4. 에러 나면 롤백
queryClient.setQueryData([key], context.previousData);
},
mutationFn,
});
}
이 15줄짜리 복잡한 로직을 컴포넌트에 직접 짠다고 생각해보세요. 끔찍합니다.
훅으로 만들어두면 useOptimisticMutation('likes', likeApi) 한 줄로 끝납니다.
11. Anti-Pattern: Hook Hell (과도한 추상화)
훅이 좋다고 해서 너무 잘게 쪼개면 Hook Hell이 열립니다.
// ❌ 투머치 추상화
const { data } = useData();
const { sorted } = useSort(data);
const { filtered } = useFilter(sorted);
const { paginated } = usePagination(filtered);
const { formatted } = useFormat(paginated);
이렇게 하면 디버깅할 때 데이터가 어디서 변했는지 추적하기가 불가능합니다. (Command + Click을 5번 해야 원본 코드가 보임).
적당한 추상화 레벨을 찾는 것이 중요합니다. 보통 "한 페이지에 필요한 로직을 하나의 통합 훅으로 묶는 것(Facade Pattern)"이 좋습니다.
// ✅ Facade 패턴
const { products, actions } = useProductListLogic(); // 내부에서 filter, sort 등 다 처리
12. FAQ: 자주 묻는 질문
Q: 단순히 useEffect 하나 있는 것도 훅으로 빼야 하나요?
A: 아니요. 하지만 그 useEffect가 비즈니스 로직(데이터 페칭, 이벤트 리스너 등)이라면 빼는 게 좋습니다. UI 로직(스크롤 위치 변경, 포커스 등)이라면 컴포넌트에 둬도 됩니다.
Q: 파일이 너무 많아지지 않나요? A: 많아집니다. 하지만 "하나의 거대한 파일(Monolith)"보다 "여러 개의 작은 파일(Micro-modules)"이 훨씬 관리하기 쉽습니다. 파일 찾기는 IDE(Cmd+P)가 해주니까요.
Q: 훅 내부에서 상태를 공유하고 싶어요. A: Custom Hook은 상태 로직을 재사용하는 것이지, 상태 값(State Value) 자체를 공유하는 게 아닙니다. (호출할 때마다 새로운 state가 생성됨). 값을 공유하려면 Context API나 Recoil, Zustand 같은 전역 상태 관리 라이브러리를 써야 합니다.