diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 074b9ce..46d3883 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -46,6 +46,9 @@ export default defineConfig({ text: '훅 설계', items: [ { text: '개요', link: '/custom-hooks/' }, + { text: '커스텀 훅 추출 기준', link: '/custom-hooks/extraction-criteria' }, + { text: '훅 합성 패턴', link: '/custom-hooks/composition' }, + { text: '훅의 책임 범위', link: '/custom-hooks/responsibility-scope' }, ], }, ], diff --git a/docs/custom-hooks/composition.md b/docs/custom-hooks/composition.md new file mode 100644 index 0000000..e60c35f --- /dev/null +++ b/docs/custom-hooks/composition.md @@ -0,0 +1,90 @@ +# 작은 훅을 조합해 유스케이스 구성하기 + +React와 Toss의 공개 라이브러리를 보면, 훅 합성의 목적은 "더 잘게 쪼개기"보다 "호출부를 더 단순하게 만드는 것"에 가깝습니다. `overlay-kit`은 반복적인 상태 관리와 이벤트 처리를 감추고, `use-funnel`은 복잡한 단계 흐름을 하나의 분명한 인터페이스로 다룹니다. + +## 규칙: 작은 훅은 관심사별로 나누고, 조합 훅은 한 가지 사용자 흐름을 설명해야 합니다 + +작은 훅은 폼 상태, 서버 요청, 온라인 상태, 스크롤 잠금처럼 한 관심사만 다룹니다. 조합 훅은 이런 훅을 묶어 `게시글 작성`, `결제 진행`, `회원가입 흐름`처럼 한 가지 사용자 흐름을 설명할 때만 의미가 있습니다. + +- `useState` 하나마다 훅을 만드는 방식은 합성이라기보다 잘게 쪼개는 데 가깝습니다 +- 단계 전환, 뒤로 가기 흐름, 라우터 연동이 함께 움직인다면 `use-funnel`처럼 흐름 자체를 다루는 훅이 더 잘 맞을 수 있습니다 +- 같은 훅 안에 도메인 상태와 화면 전용 상태를 별 고민 없이 섞지 마세요 + +## 규칙: 조합 훅은 읽기 쉬운 계약을 만들고, 화면 부수 효과는 대체로 호출부에 남기세요 + +페이지 단위 훅 자체가 문제는 아닙니다. 다만 조합 훅이 라우팅, 토스트, 모달 닫기까지 모두 숨기기 시작하면 호출부는 무엇이 일어나는지 알기 어려워집니다. 반대로 `form`과 `mutation`을 한 객체에 담아 되돌려주는 정도라면 합성의 이점도 크지 않습니다. 조합 훅은 내부 도구를 그대로 노출하기보다, 호출부가 바로 쓸 수 있는 목적 단위의 동작을 만드는 편이 낫습니다. + +:::tabs +== Bad +```tsx +function useCreatePostPage() { + const form = usePostForm() + const mutation = useCreatePostMutation() + + return { form, mutation } +} +``` + +== Good +```tsx +function useCreatePostForm() { + const form = usePostForm() + const mutation = useCreatePostMutation() + + const submit = form.handleSubmit(async (values) => { + await mutation.mutateAsync(values) + }) + + return { + form, + submit, + isSubmitting: mutation.isPending, + } +} + +function CreatePostPage() { + const router = useRouter() + const { form, submit, isSubmitting } = useCreatePostForm() + + const handleSubmit = async () => { + await submit() + toast.success('게시글이 등록되었어요.') + router.push('/posts') + } + + return ( + + ) +} +``` +::: + +## 규칙: 조합 훅이 세터와 플래그만 늘린다면 경계를 다시 보세요 + +좋은 조합 훅은 호출부에서 "무엇을 하는지"를 바로 읽히게 만듭니다. `overlay-kit`이 반복적인 이벤트 처리와 상태 전이를 목적에 맞는 API로 바꾸는 것처럼, 조합 훅도 내부 훅을 한곳에 모아 다시 내보내는 데서 끝나면 안 됩니다. + +- `usePageState()`처럼 지나치게 넓은 이름은 책임을 흐립니다 +- 호출부가 세터와 내부 플래그를 전부 알아야 한다면 훅 경계가 너무 얕습니다 +- `form`, `query`, `mutation`을 그대로 되돌려주기만 한다면 합성의 이점이 약합니다 +- 반환값이 지나치게 많다면 훅을 다시 나누거나, 반대로 더 높은 수준의 인터페이스가 맞는지 검토하세요 + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +|----------|----------| +| `useState` 하나마다 훅을 만드는 식의 과도한 분해 | 관심사 단위로 다시 묶기 | +| 페이지 이름만 달고 모든 로직을 담은 거대한 훅 | 작은 훅으로 나누고 유스케이스 단위로 조합 | +| 내부 훅을 그대로 모아 되돌려주기만 하는 조합 훅 | 목적 단위의 동작과 파생 상태로 계약 재설계 | +| 조합 훅이 라우팅/토스트까지 수행 | 화면 부수 효과는 호출부로 이동 | +| 단계 전환과 뒤로 가기 흐름을 직접 조립하는 복잡한 화면 | 흐름 전용 조합 훅 또는 전용 라이브러리 검토 | +| 반환값이 너무 많아 다시 분해해서 사용 | 조합 경계 축소 또는 더 높은 수준의 인터페이스 검토 | + +## 참고 자료 + +- [React 공식 문서 - Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) +- [toss/overlay-kit](https://github.com/toss/overlay-kit) +- [toss/use-funnel](https://github.com/toss/use-funnel) diff --git a/docs/custom-hooks/extraction-criteria.md b/docs/custom-hooks/extraction-criteria.md new file mode 100644 index 0000000..dc54915 --- /dev/null +++ b/docs/custom-hooks/extraction-criteria.md @@ -0,0 +1,97 @@ +# 커스텀 훅 추출 기준 세우기 + +React 공식 문서는 커스텀 훅을 상태를 다루는 로직을 나누어 쓰는 방법으로 설명합니다. 그래서 훅을 뽑을지 말지는 "중복이 있느냐"보다 "역할이 더 또렷해지느냐"로 판단하는 편이 낫습니다. + +## 규칙: Hook과 Effect가 한 가지 목적을 이룰 때만 추출하세요 + +다음 조건이 함께 맞을 때 커스텀 훅의 가치가 커집니다. + +- 실제로 Hook을 호출합니다 +- 상태, 파생값, 이벤트, Effect가 한 가지 맥락으로 묶입니다 +- 이름을 붙였을 때 호출부가 더 또렷해집니다 + +반대로 `useState` 하나를 감싸거나, 한 컴포넌트 안에서만 잠깐 쓰는 로컬 로직을 무리하게 빼면 추상화만 남습니다. + +## 규칙: Hook을 쓰지 않는 함수는 일반 함수로 두세요 + +커스텀 훅은 Hook을 호출하는 함수입니다. `sort`, `map`, `filter` 같은 순수 계산만 하는 함수에 `use` 접두사를 붙이면 React 로직이 숨어 있는 것처럼 보여 호출부를 오히려 헷갈리게 만듭니다. + +:::tabs +== Bad +```tsx +function useSortedProducts(products: Product[]) { + return [...products].sort((a, b) => a.price - b.price) +} +``` + +== Good +```tsx +function getSortedProducts(products: Product[]) { + return [...products].sort((a, b) => a.price - b.price) +} +``` +::: + +## 규칙: Effect 기반 훅은 생명주기보다 동기화 대상을 이름에 드러내세요 + +Effect는 DOM, 브라우저 API, 구독, 네트워크처럼 React 바깥과 동기화할 때만 씁니다. 이런 로직이 여러 곳에서 반복된다면 `useMount`처럼 시점만 말하는 이름보다, 무엇과 동기화하는지 드러나는 훅이 더 낫습니다. + +:::tabs +== Bad +```tsx +function useMount(callback: () => void) { + useEffect(() => { + callback() + }, []) +} +``` + +== Good +```tsx +function useBodyScrollLock(locked: boolean) { + useEffect(() => { + if (!locked) { + return + } + + const original = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = original + } + }, [locked]) +} +``` +::: + +## 규칙: Effect가 필요 없다면 훅도 다시 생각하세요 + +렌더링용 계산이나 클릭 이후의 처리라면 Effect보다 계산식과 이벤트 핸들러를 우선합니다. 한 컴포넌트 안에서만 쓰이고 주변 상태와 강하게 결합된 Effect라면, 굳이 커스텀 훅으로 감추지 않고 컴포넌트 안의 `useEffect`로 두는 편이 더 읽기 쉽습니다. + +- 계산 로직은 렌더링 중 계산합니다 +- 사용자 동작 뒤에 일어나는 처리는 이벤트 핸들러에서 다룹니다 +- 훅 추출은 Effect를 숨기기보다 동기화 목적을 분명하게 만드는 선택이어야 합니다 + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +|----------|----------| +| `useState` 하나만 감싼 훅 | 컴포넌트 안에 두거나 목적이 드러나는 단위로 다시 묶기 | +| Hook을 호출하지 않는 `useX` 함수 | 일반 함수로 변경 | +| `useMount`, `useEffectOnce` 같은 lifecycle 훅 | 동기화 대상이 드러나는 훅으로 변경 | +| 계산용 로직을 Effect로 동기화 | 렌더링 중 계산 또는 이벤트 핸들러로 이동 | +| 한 컴포넌트에만 묶인 Effect를 무리하게 훅으로 추출 | 컴포넌트 안의 `useEffect`로 유지 | + +## 체크리스트 + +- 이름을 붙였을 때 호출부가 더 읽기 쉬워지나요? +- 상태, 이벤트, 파생값, Effect가 한 가지 목적 아래 묶이나요? +- Hook을 호출하지 않는 일반 함수를 억지로 훅으로 만들고 있지 않나요? +- Effect가 정말 외부 시스템 동기화인가요? + +## 참고 자료 + +- [React 공식 문서 - Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) +- [React 공식 문서 - You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [React 공식 문서 - Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) diff --git a/docs/custom-hooks/index.md b/docs/custom-hooks/index.md index 7dbb0e8..e8bbea4 100644 --- a/docs/custom-hooks/index.md +++ b/docs/custom-hooks/index.md @@ -1,9 +1,37 @@ # 훅 설계 (Custom Hooks) -커스텀 훅은 컴포넌트의 로직을 재사용 가능한 단위로 분리하는 핵심 도구입니다. +이 문서는 React 공식 문서와 Toss의 공개 라이브러리에서 공통으로 드러나는 기준을 바탕으로, 커스텀 훅을 언제 만들고 어떤 경계로 설계할지 정리합니다. + +커스텀 훅은 중복을 감추는 편의 함수가 아닙니다. 상태와 Effect가 얽힌 로직을, 목적이 보이는 인터페이스로 정리하는 설계 도구입니다. + +## 이 문서가 답하는 질문 + +- 언제 훅으로 추출하고, 언제 일반 함수나 컴포넌트 내부 로직으로 두어야 하나요? +- 언제 작은 훅을 조합하고, 언제 큰 훅이 필요한가요? +- 한 훅은 어디까지 책임지고, 어디서 멈춰야 하나요? ## 다루는 내용 -- **커스텀 훅 추출 기준**: 언제 훅으로 분리해야 하는지 판단하는 기준을 세웁니다 -- **훅 합성(Composition) 패턴**: 작은 훅을 조합하여 복잡한 로직을 구성합니다 -- **훅의 책임 범위**: 하나의 훅이 담당해야 할 적절한 범위를 정합니다 +훅 설계 문서는 커스텀 훅을 어떤 기준으로 추출하고, 작은 훅을 어떻게 조합하며, 한 훅의 책임 범위를 어디까지 둘지에 집중합니다. 특히 아래 판단을 빠르게 내리기 위한 기준을 다룹니다. + +- **커스텀 훅 추출 기준**: 일반 함수와 커스텀 훅을 구분하고, 언제 훅으로 분리할지 판단합니다 +- **훅 합성(Composition) 패턴**: 여러 훅을 묶어 더 읽기 쉬운 인터페이스를 만드는 기준을 다룹니다 +- **훅의 책임 범위**: 한 훅이 몇 가지 역할까지 맡아도 되는지 경계를 정합니다 + +다음 내용은 다른 토픽에서 다룹니다. + +- **상태 관리**: 상태를 어디에 둘지, 파생 상태를 어떻게 다룰지, 서버 상태와 클라이언트 상태를 어떻게 나눌지 +- **컴포넌트 설계**: 컴포넌트 책임 경계, Compound Components, Headless UI 같은 UI 구조 설계 +- **Props/인터페이스**: Props 네이밍, `children`/render props/slot 선택 기준, 외부 API를 컴포넌트 인터페이스에 맞추는 방법 + +## 지침 목록 + +- [커스텀 훅 추출 기준 세우기](./extraction-criteria) - 훅으로 추출할지, 일반 함수나 컴포넌트 내부 로직으로 둘지를 판단합니다 +- [작은 훅을 조합해 유스케이스 구성하기](./composition) - 여러 훅을 조합해 언제 더 읽기 쉬운 인터페이스를 만들 수 있는지 정합니다 +- [한 훅의 책임 범위 정하기](./responsibility-scope) - 한 훅이 담당해야 할 변화 이유와 반환 계약의 경계를 정합니다 + +## 참고 기준 + +- [React 공식 문서 - Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) +- [toss/overlay-kit](https://github.com/toss/overlay-kit) +- [toss/use-funnel](https://github.com/toss/use-funnel) diff --git a/docs/custom-hooks/responsibility-scope.md b/docs/custom-hooks/responsibility-scope.md new file mode 100644 index 0000000..bf31bf8 --- /dev/null +++ b/docs/custom-hooks/responsibility-scope.md @@ -0,0 +1,125 @@ +# 한 훅의 책임 범위 정하기 + +좋은 훅은 이름과 반환값만 보고도 무엇을 하고 무엇을 하지 않는지 대략 감이 와야 합니다. React 공식 문서도 커스텀 훅은 상태를 같이 쓰는 수단이 아니라, 상태를 다루는 로직을 나누어 쓰는 방법이라고 설명합니다. + +## 규칙: 훅은 상태를 공유하는 도구가 아니라 로직을 나누는 방법입니다 + +- 같은 훅을 두 번 호출해도 두 호출은 서로 독립적으로 동작합니다 +- 여러 컴포넌트가 같은 값을 공유해야 한다면 기준이 되는 상태를 어디에 둘지 먼저 정해야 합니다 +- 상태 공유 문제를 훅 추출로 해결하려 하면 동기화 버그가 생기기 쉽습니다 + +여러 컴포넌트가 같은 상태를 봐야 한다면 훅 추출보다 상태 끌어올리기, Context, 외부 스토어 중 무엇이 맞는지 먼저 판단하세요. + +## 규칙: 훅 이름과 반환 계약이 예측 가능해야 합니다 + +훅 이름만 보고도 어떤 값을 돌려주고 어떤 부수 효과를 감출지 대략 예상할 수 있어야 합니다. 데이터를 가져오는 훅이 화면 이동까지 해버리거나, 반환값이 지나치게 많아 호출부가 무엇을 믿어야 할지 모르게 만들면 설계가 흔들립니다. 다만 `useUserQuery`처럼 라이브러리에서 익숙한 계약을 그대로 따르는 경우에는, 이름이 충분히 구체적이라면 전체 결과 객체를 반환해도 괜찮습니다. + +:::tabs +== Bad +```tsx +function useUser() { + const router = useRouter() + const query = useQuery({ + queryKey: ['user'], + queryFn: fetchUser, + }) + + useEffect(() => { + if (query.isError) { + router.replace('/login') + } + }, [query.isError, router]) + + return query.data +} +``` + +== Good +```tsx +function useUserQuery() { + return useQuery({ + queryKey: ['user'], + queryFn: fetchUser, + }) +} + +function ProfilePage() { + const router = useRouter() + const userQuery = useUserQuery() + + useEffect(() => { + if (userQuery.isError) { + router.replace('/login') + } + }, [userQuery.isError, router]) +} +``` +::: + +## 규칙: 한 훅에는 한 가지 주된 변경 이유만 남기세요 + +폼 상태, 서버 요청, 라우팅, 토스트는 자주 함께 등장하지만 항상 같은 이유로 바뀌는 것은 아닙니다. 이런 로직이 한 훅에 섞이면 수정 범위가 넓어지고, 호출부는 숨겨진 부수 효과를 따라가야 합니다. 페이지 단위 훅을 만드는 것 자체가 문제는 아니지만, 그 안에서 숨기는 책임의 수는 신중하게 제한해야 합니다. + +:::tabs +== Bad +```tsx +function useCheckoutPage() { + const form = useForm() + const router = useRouter() + + const submit = async (values: OrderFormValues) => { + await submitOrder(values) + toast.success('주문이 완료되었어요.') + router.push('/complete') + } + + return { form, submit } +} +``` + +== Good +```tsx +function useCheckoutForm() { + return useForm() +} + +function useSubmitOrder() { + return useMutation({ + mutationFn: submitOrder, + }) +} + +function CheckoutPage() { + const router = useRouter() + const form = useCheckoutForm() + const submitOrderMutation = useSubmitOrder() + + const handleSubmit = form.handleSubmit(async (values) => { + await submitOrderMutation.mutateAsync(values) + toast.success('주문이 완료되었어요.') + router.push('/complete') + }) +} +``` +::: + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +|----------|----------| +| 상태 공유 문제를 훅 추출로 해결하려 함 | 상태 끌어올리기, Context, 외부 스토어 검토 | +| 훅 이름과 실제 동작이 다름 | 이름과 책임을 다시 맞추기 | +| 훅 하나가 `fetch`, `form`, `navigation`, `toast`를 모두 담당 | 변화 이유별로 분리 | +| 반환값이 너무 많아 호출부에서 다시 분해 | 계약 축소 또는 작은 훅으로 분리 | + +## 체크리스트 + +- 이 훅은 상태를 같이 쓰기 위한 것인가, 로직을 나누기 위한 것인가? +- 이 훅을 한 문장으로 설명할 수 있나요? +- 반환값을 처음 보는 사람도 역할을 추측할 수 있나요? +- 다른 이유로 바뀌는 로직이 한 훅에 섞여 있지 않나요? + +## 참고 자료 + +- [React 공식 문서 - Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) +- [React 공식 문서 - Sharing State Between Components](https://react.dev/learn/sharing-state-between-components)