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
97 changes: 97 additions & 0 deletions keyword/Chapter_08/debounce.md
Original file line number Diff line number Diff line change
@@ -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` 시간이 지나야 함수가 실행된다.
90 changes: 90 additions & 0 deletions keyword/Chapter_08/throttling.md
Original file line number Diff line number Diff line change
@@ -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;
```

함수가 실행되면 마지막 실행 시간을 갱신한다.

즉, 이벤트가 아무리 많이 발생해도
설정한 시간 간격마다 함수가 한 번씩만 실행된다.
17 changes: 17 additions & 0 deletions mission/Chapter06/movie/src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)

return () => {
clearTimeout(timer)
}
}, [value, delay])

return debouncedValue
}
27 changes: 24 additions & 3 deletions mission/Chapter06/movie/src/hooks/useLps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,43 @@ 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,
gcTime: 1000 * 60 * 10,
})
}

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<LpListResponse>('/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],
Expand Down
33 changes: 33 additions & 0 deletions mission/Chapter06/movie/src/hooks/useSidebar.ts
Original file line number Diff line number Diff line change
@@ -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)

// 화면이 모바일 너비로 바뀌면 자동으로 닫기
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 단순히 false로 고정했는데, matchMedia로 화면 크기 변화까지 감지해서 자동으로 닫히도록 한 부분이 반응형 UX를 꼼꼼하게 챙긴 것 같아서 너무 좋은 것 같아요 ! !

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 }
}
50 changes: 50 additions & 0 deletions mission/Chapter06/movie/src/hooks/useThrottle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useRef } from 'react'

export function useThrottle<Args extends unknown[]>(
fn: (...args: Args) => void,
interval: number,
): (...args: Args) => void {
const lastCalledRef = useRef<number>(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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)
Comment on lines +36 to +45
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엘리 코드를 정말 잘 짜셨구 고민을 많이 하신 게 보입니닷 ㅎㅎ

한 가지 추가하자면 지금 이 코드에서 호출이 유입되는 빈도가 interval 경계선 근처에서 요동치게 되면, 기존 타이머를 지우고 새 타이머를 세팅하는 과정에서 remaining 계산의 기준점이 되는 lastCalledRef 가 제때 갱신되지 않아 실행 시점이 뒤로 밀리거나, 타이머가 비효율적으로 계속 재생성됩니다!

이 부분을 수정해 보면 좋을 것 같아요!

}
},
[interval],
)
}
43 changes: 30 additions & 13 deletions mission/Chapter06/movie/src/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen flex-col bg-black">
<Header onMenuClick={() => setSidebarOpen((v) => !v)} />
<Header onMenuClick={toggle} />
<div className="relative flex flex-1 overflow-hidden">
<Sidebar isOpen={sidebarOpen} />
{/*
데스크탑 전용 스페이서: 사이드바와 동일한 너비로 레이아웃 공간을 차지해
main 콘텐츠를 오른쪽으로 밀어냄. 모바일에서는 숨김.
*/}
<div
aria-hidden="true"
className={`
hidden lg:block shrink-0 h-full
transition-[width] duration-300 ease-in-out
${isOpen ? 'w-36' : 'w-0'}
`}
/>

{/* 모바일에서만 backdrop 표시 */}
{sidebarOpen && (
<div
className="absolute inset-0 z-10 bg-black/40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* 사이드바: 항상 absolute. 데스크탑은 스페이서가 공간 확보, 모바일은 오버레이 */}
<Sidebar isOpen={isOpen} onClose={close} />

<main className="flex-1 overflow-y-auto">
{/* 모바일 backdrop: 모바일에서만 사이드바 열릴 때 표시 */}
<div
onClick={close}
aria-hidden="true"
className={`
absolute inset-0 z-10 bg-black/40 lg:hidden
transition-opacity duration-200
${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
/>

{/* 모바일에서 사이드바 오버레이 중 배경 스크롤 방지 */}
<main className={`flex-1 overflow-y-auto ${isOpen ? 'max-lg:overflow-hidden' : ''}`}>
<Outlet />
</main>
</div>
Expand Down
Loading