목차
- 소개
- ResizablePanel: Observer 패턴 + Throttle
- Player: Debounce로 효율적인 이벤트 처리
- Search: 사용자 입력에 최적화된 Debounce
0. 소개
현재 진행중인 프로젝트는 Spotify에 가사 퀴즈 기능을 추가한 애플리케이션이다. 이번 프로젝트에서는 Frontend에서 다양한 최적화 기법들을 적용해 봤는데, 이것들을 시리즈로 정리해보려고 한다. 이번 글은 이 시리즈의 첫 번째 글로, 프로젝트 곳곳에 적용된 Debounce와 Throttle 기법을 다뤄보고자 한다.
Optimization Series (업데이트 시 링크 추가 예정)
(1) Debounce & Throttle
(2) Caching strategy
(3) Optimistic update using Cache
(4) Suspense in SSR, Code splitting
(5) Memoization (useMemo, useCallback)
Throttle은 짧은 시간 동안 여러 번 발생하는 이벤트를 일정한 간격으로 제한적으로 실행하는 기법이다. 반면 Debounce는 짧은 시간 동안 여러 번 발생하는 이벤트를 하나로 묶어서 처리하는 기법이다. 이 두 가지를 잘 활용하면 불필요한 연산을 줄이고, 성능 최적화에 큰 도움을 줄 수 있다. 이번 글에서 ResizablePanel, Player, 그리고 Search 기능에 어떻게 적용했는지 소개해보겠다.
Throttle | Debounce | |
동작 방식 | 지정된 간격마다 실행 | 이벤트가 끝난 후 일정 시간 동안 실행 지연 |
실행 횟수 제한 | 일정 간격으로 꾸준히 실행 | 마지막 이벤트 후 한 번만 실행 |
적용 | ResizablePannel(창 크기 변경) | Player, Search |
1. ResizablePanel: Observer 패턴 + Throttle
Observer로 패널 상태를 관찰해야 하는 상황
https://www.npmjs.com/package/react-resizable-panels
패널 크기를 조절할 수 있는 기능을 구현하기 위해 react-resizable-panels 라이브러리를 사용했다. 해당 라이브러리에서는 isCollapsed() 메소드를 통해 패널의 collapsed 상태를 boolean 값으로 확인할 수 있다. 이를 활용하기 위해 ResizeObserver를 통해 패널 상태를 지속적으로 관찰해서 접힌 상태로 전환되었는지 감지하였다. (ResizeObserver는 타겟 요소의 크기가 실제로 변화할 때만 콜백 함수를 실행한다.)
패널이 접힌 상태(collapsed)가 되면, rightPanelState를 null로 업데이트하여, 화면에 표시되던 초록불 표시를 모두 끄도록 처리했다.
const resizeObserver = new ResizeObserver(() => {
const collapsed = ref.current?.isCollapsed()
if (collapsed) setRightPanelState(null)
})
문제: 빈번한 콜백 호출로 인한 성능 부담
기본적으로 ResizeObserver는 크기가 변경될 때마다 콜백을 실행한다. 예를 들어, 사용자가 패널 크기를 조절하기 위해 드래그하는 경우에, 마우스가 움직이는 매 프레임마다 이벤트가 발생할 수 있는 것이다. 브라우저의 기본 프레임 속도(60fps 기준)로 계산하면, 최대 초당 60번 콜백 함수가 실행될 수 있다. 이로 인해 다음과 같은 문제가 발생한다.
- 불필요한 연산으로 인해 이벤트 루프가 과도하게 점유된다.
- 콜백 내부 로직이 복잡해질 경우, 성능이 저하될 수 있다,
해결: Throttle을 활용한 콜백 호출 제한
이를 방지하기 위해 Throttle을 활용해 ResizeObserver의 콜백 호출 빈도를 200ms 간격으로 제한했다. 이렇게 하면, 최대 초당 5번(1000ms ÷ 200ms)만 실행하도록 줄일 수 있다.
const handleResize = throttle(() => {
const collapsed = ref.current?.isCollapsed()
console.log('called') // 200ms 간격으로 실행
if (collapsed) {
setRightPanelState(null)
}
}, 200)
const resizeObserver = new ResizeObserver(handleResize)
커밋 기록
2. Player: Debounce로 효율적인 이벤트 처리
SeekBar는 이런식으로 클릭을 통해 트랙의 position을 바꿀 수도 있지만,
다음과 같이 드래그로 이동할 수도 있다.
Debounce 미적용 | Debounce적용 | |
Drag (1s) | 최대 60회 (60fps 기준) | 최대 5회 |
Click | 1회 (즉시 처리) | 동일 |
Debounce 적용 전에는 drag 시 player.seek()가 매 프레임 호출되는데, 이로 인해 Too Many Requests 에러가 났다.
const SeekBar = () => {
const { player, currentTrack } = usePlaybackStore()
const [position, setPosition] = useState(0)
const handleSeek = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newPosition = parseInt(event.target.value, 10)
setPosition(newPosition)
if (player) {
player.seek(newPosition) // 슬라이더 이동 시 매번 호출
}
},
[player],
)
return (
...
)
}
- 이벤트가 투머치하게 발생해 서버에 과부하를 일으킴.
- Too Many Requests 에러로 인해 플레이어가 제대로 동작하지 않음.
해결: Debounce 적용
const SeekBar = () => {
const { player, currentTrack } = usePlaybackStore()
const [position, setPosition] = useState(0)
const debouncedSeek = useMemo(
() =>
debounce((newPosition: number) => {
if (player) {
player.seek(newPosition) // 호출 빈도 제한 (300ms)
}
}, 300),
[player],
)
useEffect(() => {
return () => {
debouncedSeek.cancel() // 컴포넌트 unmount 시 cleanup
}
}, [debouncedSeek])
const handleSeek = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newPosition = parseInt(event.target.value, 10)
setPosition(newPosition) // 로컬 상태는 즉시 업데이트
debouncedSeek(newPosition) // Debounce를 통해 호출
},
[debouncedSeek],
)
return (
...
)
}
로컬 상태는 즉시 업데이트함으로써 UI는 부드럽게 동작하되, debounce를 통해 슬라이더가 움직이는 동안 발생하는 이벤트 호출을 제어하고, 최종적인 값만 player.seek()로 전달하도록 최적화했다. Too many requests 에러도 해결되었다.
3. Search: 사용자 입력에 최적화된 Debounce
Debounce를 적용하기 딱인 곳이 또 있는데, 바로 Search 입력 필드이다. 사용자가 검색창에 입력을 할 때, 입력할 때마다 검색 요청을 보내는 경우를 생각해보자. 이때 발생할 수 있는 문제점은 다음과 같다.
1. 요청이 너무 자주 일어남
사용자가 빠르게 입력하면, 입력한 각 문자마다 API 호출이 발생한다. 예를 들어 "Debounce"라는 단어를 입력할 경우, "D", "De", "Deb", ..., "Debounce"까지 총 8번의 요청 발생한다. 한글의 경우 더 심하다. "ㄷ", "디", "딥", "디바", "디방", "디바우", ..., "디바운스"까지 세기도 힘들다.
2. 불필요한 서버 부하, 사용자 경험 저하
중복되거나 불완정한 요청으로 인해 서버 리소스가 낭비된다. 이로 인해 서버 응답이 지연되거나, 최종 결과가 아닌 중간 결과가 UI에 표시된다.
해결: Debounce를 통한 최적화
Debounce를 적용하면, 사용자가 입력을 멈춘 후 일정 시간이 지난 뒤에만 검색 요청이 실행된다.
1. Debounce를 적용한 검색 함수 -> useMemo
debounce를 사용하여 검색 요청 호출을 제한했다. 이때 useMemo를 통해 debouncedSearch가 재생성되지 않도록 처리했다.
const debouncedSearch = useMemo(
() =>
debounce((searchTerm: string) => {
onSearch(searchTerm)
}, 300), // 300ms 지연
[onSearch],
)
지연 시간 동안 추가 입력이 발생하면, 이전 호출은 취소되고 새로운 호출로 대체된다. debounce는 호출될 때마다 새로운 함수 객체를 반환한다. 따라서 의존성이 변경되지 않는 한, 같은 함수 객체를 재사용해야 한다. 이를 위해 useMemo를 사용해서 debouncedSearch 함수가 재생성되지 않고 동일한 참조를 유지하도록 처리했다.
2. 검색 요청 처리 함수 -> useCallback
여기에서 API 호출이 이루어지는데, 중요한건 useCallback으로 감싸야 한다는 것이다.
const onSearch = useCallback((searchTerm: string) => {
console.log('Search >> ', searchTerm)
}, [])
만약 onSearch에 useCallback 처리를 해주지 않으면, 매 렌더링마다 새로운 onSearch 함수가 생성된다. 그러면 debouncedSearch도 새로 생성되고, 이전에 생성된 debounce 함수와의 참조가 끊어져 정확한 데이터 참조를 유지할 수 없게 된다. 이를 방지하기 위해 useCallback으로 함수 참조를 고정했다.
보통 useCallback은 불필요한 리렌더링 방지 목적으로 많이 사용되는데, 이 경우에는 정확한 데이터 참조를 유지하는 데 중요한 역할을 한다. 정리해보면, onSearch를 useCallback으로 고정함으로써 debouncedSearch는 항상 최신 상태의 onSearch를 참조하게 되고, 이를 통해 debounce 로직이 리렌더링의 영향을 받지 않고 안정적으로 동작하게 된다.
3. 입력 이벤트 처리 함수
사용자가 검색창에 입력할 때마다 handleChange 함수가 호출된다. 로컬 상태는 즉시 업데이트하고, debouncedSearch를 통해 검색 요청을 제한했다.
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setValue(value) // 로컬 상태 업데이트
console.log('Value >> ', value)
debouncedSearch(value) // Debounce를 통해 요청
},
[debouncedSearch],
)
'Projects,Activity > Spotify' 카테고리의 다른 글
GraphQL 적응기 - RESTful 마인드셋에서 벗어나기 위한 몸부림 (4) | 2024.12.07 |
---|---|
Apollo client의 캐시가 Next.js를 만나면: SSR, RSC + Next.js 15의 바뀐점 (2) | 2024.12.04 |
쿠키가 도착하지 않는 이유- Express, Next.js, Browser 간 쿠키 전달 (1) | 2024.11.19 |
Spotify API와 자체 DB를 통합한 인증 시스템 - JWT+JWT vs Session+JWT (0) | 2024.11.06 |