diff --git a/keyword/chapter08/keyword.md b/keyword/chapter08/keyword.md new file mode 100644 index 00000000..3651d911 --- /dev/null +++ b/keyword/chapter08/keyword.md @@ -0,0 +1,1107 @@ +# Tanstack Query 입문 2편: useMutation과 Optimistic Update + +## Debounce 구글링 후 개념 정리 및 코드 작성해보기 🍠 + +### Debounce 개념 정리 🍠 + +### 개념 + +> **마지막 호출 이후 N ms가 지나야 함수가 실행된다.** + +이벤트가 연속으로 발생할 때, **"조용해진 뒤"에만 반응**하는 기법이다. + +타이머가 실행 중인데 또 호출되면? → 타이머를 리셋한다. + +마지막 호출로부터 지정한 시간이 지나야 비로소 함수가 실행된다. + +``` +[연속 이벤트 발생] + ↓ ↓ ↓ ↓ ↓ ← 타이머 계속 초기화 + ──── delay ────▶ 함수 실행 (딱 한 번) +``` + +--- + +### 구현 (vanilla JS) + +```jsx +function debounce(func, duration) { + let timeout; + + return function (...args) { + const effect = () => { + timeout = null; + return func.apply(this, args); + }; + + clearTimeout(timeout); // 이전 타이머 취소 + timeout = setTimeout(effect, duration); // 새 타이머 등록 + }; +} +``` + +**동작 흐름** + +1. 함수 호출 → `setTimeout` 등록 +2. delay 전에 또 호출되면 → `clearTimeout`으로 이전 타이머 취소 후 재등록 +3. delay 동안 추가 호출 없으면 → 함수 실행 + +--- + +### Leading / Trailing 옵션 + +기본 debounce는 **trailing** 방식 (마지막 이후 실행)이지만, `leading` 옵션을 켜면 **첫 호출 즉시 실행** 후 이후 호출을 무시할 수 있다. + +| 옵션 | 동작 | 사용 예시 | +| ------------------------- | ---------------------------------------------- | ------------------- | +| `trailing: true` (기본값) | 마지막 호출 이후 delay 지나면 실행 | 검색 자동완성 | +| `leading: true` | 첫 호출 즉시 실행, 이후 호출은 debounce | 버튼 중복 클릭 방지 | +| `maxWait` | 최대 대기 시간 설정 (이 시간 지나면 강제 실행) | 자동 저장 | + +```jsx +// lodash 기준 +_.debounce(func, 500, { leading: true, trailing: false }); +``` + +--- + +### Throttle과의 차이 + +| | Debounce | Throttle | +| ------------- | ------------------------------------------- | ------------------------------------ | +| **실행 시점** | 마지막 호출 후 N ms 뒤 | N ms마다 최대 1회 | +| **중간 상태** | 무시 | 일정 간격으로 반응 | +| **비유** | 과부하된 웨이터 (당신이 말하는 동안 기다림) | 스프링 공 기계 (준비되면 1개만 발사) | +| **사용 목적** | 최종 상태에만 반응 | 일정한 주기로 반응 | + +--- + +### 언제 쓸까? + +> **"중간 과정은 필요 없고 최종 결과에만 반응하고 싶을 때"** + +- **검색 자동완성** — 타이핑 중 매 keystroke마다 API 호출하면 서버 부하 폭발. 타이핑 멈춘 뒤 300~500ms 후 1번만 요청 +- **자동 저장** — 편집 중 계속 저장하면 DB trip 낭비. 입력이 멈출 때만 저장 +- **window resize 레이아웃 재계산** — 리사이즈 중 매번 계산하면 성능 저하. 리사이즈 끝난 후 계산 +- **무한 스크롤 API 호출** — 스크롤 중 매번 요청 방지 + +--- + +### React에서 사용할 때 + +#### ❌ 실수 — render마다 새 인스턴스 생성 + +```jsx +// 렌더링할 때마다 새 debounce 인스턴스가 생성됨 → 동작 안 함 +; + } +} +``` + +#### ✅ 올바른 방법 2 — 함수형 컴포넌트 (`useMemo`) + +```jsx +const debouncedSearch = useMemo( + () => debounce((value) => fetchResults(value), 500), + [], // 마운트 시 한 번만 생성 +); +``` + +#### ✅ 올바른 방법 3 — `useDebounce` 커스텀 훅 + +```tsx +import { useDebounce } from "use-debounce"; + +function SearchInput() { + const [text, setText] = useState(""); + const [debouncedValue] = useDebounce(text, 500); + + useEffect(() => { + if (debouncedValue) fetchResults(debouncedValue); // 500ms 후 API 호출 + }, [debouncedValue]); + + return setText(e.target.value)} />; +} +``` + +--- + +### 최적 delay 시간 + +> 케이스마다 테스트해야 한다. + +| 상황 | 권장 delay | +| --------------- | ----------- | +| 타이핑 (검색) | 300~500ms | +| resize / scroll | 100~200ms | +| 자동 저장 | 1000ms 이상 | + +너무 짧으면 → 성능 최적화 효과 없음 + +너무 길면 → UI가 느리게 느껴짐 + +--- + +### 주의사항 + +1. **동일한 함수 참조**를 유지해야 한다 — debounce/throttle은 같은 인스턴스가 계속 호출되어야 동작함 +2. 컴포넌트 **언마운트 시 타이머 정리** 필요 → `cancel()` 또는 cleanup 처리 +3. `AbortController`와 조합하면 **이전 API 요청 취소**도 가능 (레이스 컨디션 방지) +

+ +
+ +### Debounce 코드 작성 🍠 + +### 기본 구현 + +```jsx +function debounce(func, delay) { + let timeout; + + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +} +``` + +### 사용 예시 + +```jsx +const handleInput = debounce((value) => { + console.log("검색어:", value); +}, 500); + +document.querySelector("input").addEventListener("input", (e) => { + handleInput(e.target.value); +}); +``` + +--- + +### Leading 옵션 추가 + +> 첫 호출은 즉시 실행하고, 이후 연속 호출은 무시하고 싶을 때 + +```jsx +function debounce(func, delay, { leading = false } = {}) { + let timeout; + + return function (...args) { + // leading이고 타이머가 없으면 즉시 실행 + if (leading && !timeout) { + func.apply(this, args); + } + + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + + // trailing 실행 (leading만 true면 여기선 실행 안 함) + if (!leading) { + func.apply(this, args); + } + }, delay); + }; +} +``` + +#### 사용 예시 + +```jsx +// 버튼 첫 클릭만 즉시 처리, 연속 클릭 무시 +const handleSubmit = debounce( + () => { + console.log("제출!"); + }, + 1000, + { leading: true }, +); + +button.addEventListener("click", handleSubmit); +``` + +--- + +### Leading + Trailing 옵션 모두 지원 + +```jsx +function debounce(func, delay, { leading = false, trailing = true } = {}) { + let timeout; + let isLeadingCalled = false; + + return function (...args) { + if (leading && !timeout) { + func.apply(this, args); + isLeadingCalled = true; + } else { + isLeadingCalled = false; + } + + clearTimeout(timeout); + timeout = setTimeout(() => { + if (trailing && !isLeadingCalled) { + func.apply(this, args); + } + timeout = null; + }, delay); + }; +} +``` + +| 설정 | 동작 | +| ------------------------------------ | ------------------------------------ | +| `{ leading: false, trailing: true }` | 기본값. 마지막 호출 후 delay 뒤 실행 | +| `{ leading: true, trailing: false }` | 첫 호출 즉시 실행, 이후 무시 | +| `{ leading: true, trailing: true }` | 첫 호출 즉시 + 마지막 호출 후도 실행 | + +--- + +### cancel / flush 메서드 추가 + +> lodash처럼 debounce 취소 또는 즉시 실행을 외부에서 제어하고 싶을 때 + +```jsx +function debounce(func, delay) { + let timeout; + let lastArgs; + + function debounced(...args) { + lastArgs = args; + clearTimeout(timeout); + timeout = setTimeout(() => { + func.apply(this, lastArgs); + timeout = null; + }, delay); + } + + // 대기 중인 타이머 취소 + debounced.cancel = function () { + clearTimeout(timeout); + timeout = null; + }; + + // 즉시 실행 (대기 중이면 바로 실행 후 타이머 제거) + debounced.flush = function () { + if (timeout) { + func.apply(this, lastArgs); + debounced.cancel(); + } + }; + + // 타이머 대기 중 여부 + debounced.isPending = function () { + return !!timeout; + }; + + return debounced; +} +``` + +#### 사용 예시 + +```jsx +const debouncedSave = debounce(saveData, 1000); + +input.addEventListener("input", (e) => { + debouncedSave(e.target.value); +}); + +// 페이지 떠나기 전 강제 저장 +window.addEventListener("beforeunload", () => { + debouncedSave.flush(); +}); + +// 취소 버튼 +cancelBtn.addEventListener("click", () => { + debouncedSave.cancel(); +}); +``` + +--- + +### React 커스텀 훅 — `useDebounce` + +#### 값(value) debounce + +```tsx +import { useState, useEffect } from "react"; + +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); // 클린업: 타이머 취소 + }, [value, delay]); + + return debouncedValue; +} +``` + +#### 사용 예시 + +```tsx +function SearchInput() { + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 500); + + useEffect(() => { + if (debouncedQuery) { + fetchSearchResults(debouncedQuery); // 500ms 후 API 호출 + } + }, [debouncedQuery]); + + return ( + setQuery(e.target.value)} + placeholder="검색어 입력..." + /> + ); +} +``` + +--- + +### React 커스텀 훅 — `useDebouncedCallback` + +#### 함수(callback) debounce + +```tsx +import { useCallback, useRef } from "react"; + +function useDebouncedCallback any>( + func: T, + delay: number, +) { + const timeoutRef = useRef | null>(null); + + const debounced = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + func(...args); + }, delay); + }, + [func, delay], + ); + + const cancel = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + return { debounced, cancel }; +} +``` + +#### 사용 예시 + +```tsx +function Form() { + const { debounced: debouncedSave, cancel } = useDebouncedCallback( + (value: string) => saveToServer(value), + 1000, + ); + + return ( + <> +