Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
],
Expand Down
90 changes: 90 additions & 0 deletions docs/custom-hooks/composition.md
Original file line number Diff line number Diff line change
@@ -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 (
<PostEditor
form={form}
onSubmit={handleSubmit}
disabled={isSubmitting}
/>
)
}
```
:::

## 규칙: 조합 훅이 세터와 플래그만 늘린다면 경계를 다시 보세요

좋은 조합 훅은 호출부에서 "무엇을 하는지"를 바로 읽히게 만듭니다. `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)
97 changes: 97 additions & 0 deletions docs/custom-hooks/extraction-criteria.md
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 32 additions & 4 deletions docs/custom-hooks/index.md
Original file line number Diff line number Diff line change
@@ -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)
125 changes: 125 additions & 0 deletions docs/custom-hooks/responsibility-scope.md
Original file line number Diff line number Diff line change
@@ -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<OrderFormValues>()
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<OrderFormValues>()
}

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)