diff --git a/keyword/Chapter_08/debounce.md b/keyword/Chapter_08/debounce.md new file mode 100644 index 00000000..059ea27d --- /dev/null +++ b/keyword/Chapter_08/debounce.md @@ -0,0 +1,97 @@ +# **`Debounce`** 개념 정리 + + `Debounce`는 이벤트가 연속적으로 발생할 때, + + **마지막 이벤트가 발생한 뒤 일정 시간이 지나면 딱 한 번만 실행**되도록 만드는 기법이다. + + 예를 들어 사용자가 검색창에 글자를 입력할 때마다 API 요청을 보내면: + + - `r` 입력 → 요청 + - `re` 입력 → 요청 + - `rea` 입력 → 요청 + - ... + + 처럼 너무 많은 요청이 발생한다. + + 이때 `Debounce`를 적용하면 사용자가 입력을 멈춘 뒤 일정 시간(예: 300ms)이 지나고 나서 한 번만 요청을 보낸다. + + 즉, **불필요한 함수 실행을 줄여 성능을 최적화**하는 방식이다. + + ### 동작 방식 + + 1. 이벤트 발생 + 2. 타이머 시작 + 3. 지정 시간 안에 이벤트가 또 발생하면 기존 타이머 취소 + 4. 다시 타이머 시작 + 5. 마지막 이벤트 이후 일정 시간이 지나면 함수 실행 + + ### 주 사용 사례 + + - 검색 자동완성 + - 입력값 검증 + - 자동 저장(auto-save) + - resize 이벤트 최적화 + + ### Throttle과의 차이 + + | 구분 | Debounce | Throttle | + | --- | --- | --- | + | 실행 시점 | 이벤트가 멈춘 뒤 실행 | 일정 시간마다 실행 | + | 목적 | 마지막 결과만 처리 | 중간 과정도 주기적으로 처리 | + | 사용 예시 | 검색창 입력 | 스크롤 이벤트 | + + Debounce는 + + “사용자가 행동을 끝냈을 때 실행”에 가깝고, + + Throttle은 + + “계속 발생하더라도 일정 주기로 실행”에 가깝다. + +# **`Debounce`** 코드 작성 + + ```jsx + function debounce(func, delay) { + let timeoutId; + + return function (...args) { + // 기존 타이머 제거 + clearTimeout(timeoutId); + + // 새로운 타이머 등록 + timeoutId = setTimeout(() => { + func.apply(this, args); + }, delay); + }; + } + ``` + + ### 사용 예시 + + ```jsx + constsearch=debounce((keyword) => { + console.log("검색 요청:",keyword); + },300); + + input.addEventListener("input", (e) => { + search(e.target.value); + }); + ``` + + ### 코드 흐름 설명 + + ```jsx + clearTimeout(timeoutId); + ``` + + 기존에 예약된 실행이 있다면 취소한다. + + ```jsx + timeoutId=setTimeout(...) + ``` + + 새로운 실행 예약을 만든다. + + 즉, 이벤트가 계속 발생하면 이전 예약은 계속 취소되고 + + 마지막 이벤트 이후 `delay` 시간이 지나야 함수가 실행된다. \ No newline at end of file diff --git a/keyword/Chapter_08/throttling.md b/keyword/Chapter_08/throttling.md new file mode 100644 index 00000000..01680e77 --- /dev/null +++ b/keyword/Chapter_08/throttling.md @@ -0,0 +1,90 @@ +# **`Throttling`** 개념 정리 🍠 + + ## `Throttling` 개념 정리 + + `Throttle`은 이벤트가 연속적으로 발생하더라도 + + **일정 시간 간격마다 한 번씩만 함수가 실행되도록 제한하는 기법**이다. + + 예를 들어 스크롤 이벤트는 사용자가 화면을 움직이는 동안 수십~수백 번 발생한다. + + 이때 이벤트마다 함수를 실행하면: + + - 성능 저하 + - 불필요한 연산 증가 + - 렌더링 부담 + + 문제가 생길 수 있다. + + `Throttle`을 적용하면 지정한 시간 간격(예: 300ms)마다 한 번씩만 실행되므로 + + 과도한 함수 호출을 줄일 수 있다. + + ### 동작 방식 + + 1. 이벤트 발생 + 2. 함수 실행 + 3. 일정 시간 동안 추가 호출 무시 + 4. 시간이 지나면 다시 실행 가능 + + 즉, + + - 이벤트는 계속 발생하지만 + - 함수는 정해진 주기마다 실행된다. + + ### 주 사용 사례 + + - scroll 이벤트 + - resize 이벤트 + - 마우스 이동(mousemove) + - 버튼 연속 클릭 방지 +# **`Throttling`** 코드 작성 🍠 + + ```jsx + function throttle(func, delay) { + let lastCall = 0; + + return function (...args) { + const now = Date.now(); + + // 마지막 실행 이후 delay가 지났는지 확인 + if (now - lastCall >= delay) { + lastCall = now; + func.apply(this, args); + } + }; + } + ``` + + ### 사용 예시 + + ```jsx + consthandleScroll=throttle(() => { + console.log("스크롤 이벤트 실행"); + },300); + + window.addEventListener("scroll",handleScroll); + ``` + + ### 코드 흐름 설명 + + ```jsx + constnow=Date.now(); + ``` + + 현재 시간을 저장한다. + + ```jsx + if (now-lastCall>=delay) + ``` + + 마지막 실행 시점으로부터 `delay` 이상 지났는지 확인한다. + + ```jsx + lastCall=now; + ``` + + 함수가 실행되면 마지막 실행 시간을 갱신한다. + + 즉, 이벤트가 아무리 많이 발생해도 + 설정한 시간 간격마다 함수가 한 번씩만 실행된다. \ No newline at end of file diff --git a/mission/Chapter06/movie/src/hooks/useDebounce.ts b/mission/Chapter06/movie/src/hooks/useDebounce.ts new file mode 100644 index 00000000..aa770c4d --- /dev/null +++ b/mission/Chapter06/movie/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' + +export 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 +} diff --git a/mission/Chapter06/movie/src/hooks/useLps.ts b/mission/Chapter06/movie/src/hooks/useLps.ts index 81ac64dd..a005abe0 100644 --- a/mission/Chapter06/movie/src/hooks/useLps.ts +++ b/mission/Chapter06/movie/src/hooks/useLps.ts @@ -12,15 +12,12 @@ export function useLps(order: SortOrder = 'desc') { params: { order, limit: LIMIT, - // pageParam이 undefined이면 커서 없이 첫 페이지 요청 ...(pageParam !== undefined && { cursor: pageParam }), }, }) return data.data }, - // 첫 요청은 커서 없이 시작 initialPageParam: undefined as number | undefined, - // 다음 커서가 있으면 반환, 없으면 undefined → fetchNextPage 중단 getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined, staleTime: 1000 * 60 * 5, @@ -28,6 +25,30 @@ export function useLps(order: SortOrder = 'desc') { }) } +export function useSearchLps(debouncedQuery: string, order: SortOrder = 'desc') { + const trimmed = debouncedQuery.trim() + return useInfiniteQuery({ + queryKey: ['lps', 'search', trimmed, order], + queryFn: async ({ pageParam }) => { + const { data } = await api.get('/v1/lps', { + params: { + search: trimmed, + order, + limit: LIMIT, + ...(pageParam !== undefined && { cursor: pageParam }), + }, + }) + return data.data + }, + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + enabled: trimmed.length > 0, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + }) +} + export function useLp(lpId: number) { return useQuery({ queryKey: ['lps', 'detail', lpId], diff --git a/mission/Chapter06/movie/src/hooks/useSidebar.ts b/mission/Chapter06/movie/src/hooks/useSidebar.ts new file mode 100644 index 00000000..703e97b7 --- /dev/null +++ b/mission/Chapter06/movie/src/hooks/useSidebar.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' + +const LG = 1024 // Tailwind lg 브레이크포인트 + +export function useSidebar() { + // 데스크탑이면 기본 열림, 모바일이면 기본 닫힘 + const [isOpen, setIsOpen] = useState(() => window.innerWidth >= LG) + + const open = () => setIsOpen(true) + const close = () => setIsOpen(false) + const toggle = () => setIsOpen((prev) => !prev) + + // 화면이 모바일 너비로 바뀌면 자동으로 닫기 + useEffect(() => { + const mq = window.matchMedia(`(max-width: ${LG - 1}px)`) + const handleChange = (e: MediaQueryListEvent) => { + if (e.matches) setIsOpen(false) + } + mq.addEventListener('change', handleChange) + return () => mq.removeEventListener('change', handleChange) + }, []) + + // ESC 키로 닫기 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false) + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + + return { isOpen, open, close, toggle } +} diff --git a/mission/Chapter06/movie/src/hooks/useThrottle.ts b/mission/Chapter06/movie/src/hooks/useThrottle.ts new file mode 100644 index 00000000..415d49e0 --- /dev/null +++ b/mission/Chapter06/movie/src/hooks/useThrottle.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef } from 'react' + +export function useThrottle( + fn: (...args: Args) => void, + interval: number, +): (...args: Args) => void { + const lastCalledRef = useRef(0) + const timerRef = useRef | null>(null) + // 항상 최신 fn을 참조하되, useCallback 의존성에는 포함하지 않음 + const fnRef = useRef(fn) + fnRef.current = fn + + // interval 변경 또는 언마운트 시 대기 중인 trailing 타이머 정리 + useEffect(() => { + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [interval]) + + return useCallback( + (...args: Args) => { + const now = Date.now() + const remaining = interval - (now - lastCalledRef.current) + + if (remaining <= 0) { + // 쿨다운 경과 → 즉시 실행 (leading) + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastCalledRef.current = now + fnRef.current(...args) + } else { + // 쿨다운 중 → 마지막 호출을 trailing으로 예약 + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + } + timerRef.current = setTimeout(() => { + lastCalledRef.current = Date.now() + timerRef.current = null + fnRef.current(...args) + }, remaining) + } + }, + [interval], + ) +} diff --git a/mission/Chapter06/movie/src/layout/Layout.tsx b/mission/Chapter06/movie/src/layout/Layout.tsx index da0ab4cc..498275a0 100644 --- a/mission/Chapter06/movie/src/layout/Layout.tsx +++ b/mission/Chapter06/movie/src/layout/Layout.tsx @@ -1,27 +1,44 @@ -import { useState } from 'react' import { Outlet } from 'react-router-dom' import Header from './Header' import Sidebar from './Sidebar' +import { useSidebar } from '../hooks/useSidebar' const Layout = () => { - const [sidebarOpen, setSidebarOpen] = useState(false) + const { isOpen, close, toggle } = useSidebar() return (
-
setSidebarOpen((v) => !v)} /> +
- + {/* + 데스크탑 전용 스페이서: 사이드바와 동일한 너비로 레이아웃 공간을 차지해 + main 콘텐츠를 오른쪽으로 밀어냄. 모바일에서는 숨김. + */} +