React 공식문서에서 useRef는 "렌더링에 필요하지 않은 값을 참조할 수 있는 React Hook"이라고 정의되어 있다.
대부분의 개발자들은 useRef를 다음 두 가지 용도로만 사용한다.
- DOM 요소에 직접 접근 (예: input에 focus 주기)
- 렌더링과 무관한 값 저장
하지만 실제 현업에서는 훨씬 다양한 역할을 한다.
최근 웹 기반 비디오 에디터를 개발하면서, 오디오 재생 기능 구현 과정에서 여러 버그를 마주쳤다. 특히 useRef를 적절하게 사용함으로써 버그들을 해결하였는데, 이번 경험을 통해 배운 useRef의 다양한 활용법을 공유하고자 한다.
현재 담당하고 있는 서비스는 컷(클립) 단위로 영상을 편집하는 웹 기반의 비디오 에디터다.
기본적으로 다음 3가지 기능을 제공하는데, 편집이 완료된 결과물을 영상으로 추출하기 전 확인할 수 있도록 미리보기 기능을 구현하는데 있어 여러 버그가 발생하였다.
- 각 컷마다 이미지, 텍스트, 음성(TTS), BGM 추가 가능
- 여러 컷을 순차적으로 재생하며 미리보기 제공
- 최종적으로 하나의 영상으로 추출
useRef 활용 사례 1. 리렌더링 없이 객체 참조 하기
처음에 Audio 객체를 useState로 관리하였다.
// ❌ 문제가 있는 코드
const [voiceAudio, setVoiceAudio] = useState<HTMLAudioElement | null>(null);
const [bgmAudio, setBgmAudio] = useState<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// 컷이 바뀔 때마다 새로운 Audio 객체 생성
const playVoice = (url: string) => {
const audio = new Audio(url);
setVoiceAudio(audio); // ❌ 리렌더링 발생!
audio.play();
};
문제점
- 컷이 바뀔 때마다 Audio 객체가 변경되며 불필요한 리렌더링 발생
- 리렌더링으로 인해 재생 중이던 오디오가 간헐적으로 끊기는 현상
- 컷 갯수만큼 리렌더링이 발생하며 성능 저하
// ✅ 개선된 코드
const voiceRef = useRef<HTMLAudioElement | null>(null);
const bgmRef = useRef<HTMLAudioElement | null>(null);
const isPlayingRef = useRef(false);
// 리렌더링 없이 오디오 객체 생성 및 재생
const playVoice = (url: string) => {
voiceRef.current = new Audio(url);
voiceRef.current.play();
// ✅ 리렌더링 없이 즉시 재생!
};
const playBgm = (url: string, volume: number = 0.3) => {
bgmRef.current = new Audio(url);
bgmRef.current.loop = true;
bgmRef.current.volume = volume;
bgmRef.current.play();
};
각 컷에 포함된 음성, bgm을 각각 useRef로 관리하고 이를 참조할 수 있도록 하여 불필요한 리렌더링과 오디오 끊김 현상을 해결했다.
💡 핵심 포인트
DOM이 아닌 외부 객체(Audio, Video, WebSocket, Canvas Context 등)는 ref로 관리하는게 좋다.
UI에 직접 표시되지 않는 객체는 리렌더링을 유발할 필요가 없다.
useRef 활용 사례 2. 비동기 로직에서 최신 상태 즉시 참조하기
// ❌ 문제가 있는 코드
const [isPlaying, setIsPlaying] = useState(false);
const playAllClips = async () => {
setIsPlaying(true);
for (let i = 0; i < clips.length; i++) {
// 1초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
// ❌ 이 시점의 isPlaying은 1초 전의 값!
if (isPlaying) {
console.log('재생 중'); // 정지 버튼을 눌렀는데도 true
}
await playClip(clips[i]);
}
};
const stopAudio = () => {
setIsPlaying(false); // 비동기로 업데이트됨
};
문제점
React의 useState는 비동기적으로 업데이트 된다. 또한 JavaScript의 클로저(Closure) 특성상, setTimeout 내부에서 참조하는 isPlaying은 함수가 생성된 시점의 값을 캡처한다.
결과적으로 사용자가 정지 버튼을 눌러도 이미 예약된 콜백 내에서는 이전 값(true)을 계속 참조하게 된다.
// ✅ 개선된 코드
const [isPlaying, setIsPlaying] = useState(false); // UI 표시용
const isPlayingRef = useRef(false); // 로직 제어용
const playAllClips = async () => {
isPlayingRef.current = true; // ✅ 즉시 업데이트
setIsPlaying(true); // UI 업데이트
for (let i = 0; i < clips.length; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
// ✅ 항상 최신 값 참조 가능
if (!isPlayingRef.current) {
console.log('정지 버튼으로 중단됨');
break; // 즉시 중단!
}
await playClip(clips[i]);
}
isPlayingRef.current = false;
setIsPlaying(false);
};
const stopAudio = () => {
isPlayingRef.current = false; // ✅ 동기적으로 즉시 반영
setIsPlaying(false); // UI 업데이트
// 모든 비동기 콜백에서 최신 값 확인 가능
};
정지 버튼 클릭 시 즉시 재생이 중단되도록 하여 사용자 경험을 개선하였다.
(기존: 최대 1초 지연 → 개선 후: 다음 클립 전환 시점에 즉시 중단)
💡 핵심 포인트
비동기 로직(setTimeout, Promise, async/await)에서 즉시 접근해야 하는 값은 ref로 관리해야 한다.
useState는 비동기 업데이트 + 클로저 캡처로 인해 과거 값을 참조하는 문제가 발생한다.
패턴: 이중 상태 관리
- useState: UI 표시용 (버튼 활성화/비활성화, 로딩 스피너 등)
- useRef: 로직 제어용 (비동기 흐름 제어, 조건 체크)
useRef 활용 사례 3. 내부 플래그 관리: 자동 vs 수동 구분하기
영상 재생 중에는 자동으로 컷이 순차 선택된다. 하지만 사용자가 직접 다른 컷을 클릭하면 재생을 중단해야 했다.
// ❌ 문제가 있는 코드
const [selectedId, setSelectedId] = useState<string | null>(null);
// 재생 중 자동으로 컷 선택
const playAllClips = async () => {
for (const clip of clips) {
setSelectedId(clip.id); // 자동 선택
await playClip(clip);
}
};
// 사용자가 컷을 선택하면 재생 중단
useEffect(() => {
stopAudio(); // ❌ 자동 선택에도 중단됨!
}, [selectedId]);
문제점
- 자동 선택과 사용자 선택을 구분할 방법이 없음
- 재생 중 자동으로 컷이 바뀔 때마다 재생이 중단됨
// ✅ 개선된 코드
const [selectedId, setSelectedId] = useState<string | null>(null);
const isAutoSelectingRef = useRef(false); // 리렌더링 없는 플래그
const playAllClips = async () => {
for (const clip of clips) {
// 자동 선택 플래그 활성화
isAutoSelectingRef.current = true;
setSelectedId(clip.id);
// React의 상태 업데이트가 완료될 때까지 대기
await new Promise(resolve => setTimeout(resolve, 0));
// 플래그 해제
isAutoSelectingRef.current = false;
await playClip(clip);
}
};
useEffect(() => {
// ✅ 자동 선택이 아닐 때만 중단
if (!isAutoSelectingRef.current) {
console.log('사용자가 직접 선택 → 재생 중단');
stopAudio();
}
}, [selectedId]);
ref 를 통해 컷 선택에 대한 분기처리를 통해 자동 선택 시 재생 중단 문제를 완전히 해결하였다.
내부 플래그를 ref로 관리함으로써 상태로 플래그를 관리했을 때 발생하는 불필요한 리렌더링과 복잡한 의존성 관리를 예방하였고 사용자 의도에 맞는 UI를 구현하였다.
왜 `await new Promise(resolve => setTimeout(resolve, 0));`이 필요할까?
React의 상태 업데이트는 배치(batch) 처리된다. setSelectedId 호출 직후에는 아직 useEffect가 실행되지 않을 수 있다. setTimeout(..., 0)은 이벤트 루프의 다음 틱까지 대기하여 React의 상태 업데이트가 완전히 처리되도록 보장한다.
💡 핵심 포인트
UI에 표시할 필요 없는 내부 플래그는 ref로 관리를 권장한다.
useState는 비동기 업데이트와 리렌더링으로 인해 타이밍 제어가 어렵고 불필요한 성능 저하를 유발한다.
일반적인 플래그 사용 사례
- 자동 vs 수동 구분
- 초기화 완료 여부
- 드래그 중 여부
- API 호출 중복 방지
useRef 활용 사례 4: 타이머 관리 - 메모리 누수 방지와 정확한 시간 추적
복잡한 재생 시스템에서는 다양한 타이머를 동시에 관리해야 한다. 여러 개의 setTimeout과 setInterval을 어떻게 안전하게 관리할 수 있을까?
시나리오 1: 다중 setTimeout 관리 (클립 전환 효과)
각 클립을 재생할 때 자연스러운 전환을 위해 앞뒤로 여유 시간이 필요했다.
// ❌ 문제가 있는 코드
const playAllClips = async () => {
for (const clip of clips) {
// 앞 여유 1초
await new Promise(resolve => setTimeout(resolve, 1000));
// 음성 재생 3초
await playVoice(clip);
// 뒤 여유 1초
await new Promise(resolve => setTimeout(resolve, 1000));
}
};
// 사용자가 중간에 정지 버튼을 누르면?
// → 이미 예약된 setTimeout들은 계속 실행됨!
// → 10개 클립 = 30개의 setTimeout이 메모리에 남아있음
문제점
- 재생 중지 후에도 1~2초 뒤 갑자기 다음 클립 재생
- 재생/중지 반복 시 메모리 사용량 지속 증가
- 최악의 경우: 수백 개의 타이머가 동시 실행
// ✅ 개선된 코드: 배열로 모든 타이머 추적
const pendingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const isPlayingRef = useRef(false);
const playAllClips = async (clips: Clip[]) => {
// 1. 새 재생 시작 - 배열 초기화
pendingTimeoutsRef.current = [];
isPlayingRef.current = true;
for (const clip of clips) {
// 재생 중단 체크
if (!isPlayingRef.current) break;
// 2. 모든 setTimeout ID를 배열에 저장
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(resolve, 1000);
pendingTimeoutsRef.current.push(timeoutId); // 즉시 추가
});
if (!isPlayingRef.current) break;
await playVoice(clip);
if (!isPlayingRef.current) break;
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(resolve, 1000);
pendingTimeoutsRef.current.push(timeoutId);
});
}
// 재생 완료 후 정리
pendingTimeoutsRef.current = [];
};
const stopAllAudio = () => {
isPlayingRef.current = false;
// 3. 대기 중인 모든 타이머 한 번에 정리
if (pendingTimeoutsRef.current.length > 0) {
console.log(`정리할 타이머: ${pendingTimeoutsRef.current.length}개`);
pendingTimeoutsRef.current.forEach(clearTimeout);
pendingTimeoutsRef.current = [];
}
// 오디오 정리...
};
시나리오 2: 단일 setInterval 관리 (재생 시간 표시)
재생 중인 영상의 현재 시간을 실시간으로 표시해야 했다.
// ❌ 문제가 있는 코드
const [currentTime, setCurrentTime] = useState(0);
let intervalId; // 전역 변수는 위험!
const startTimer = () => {
intervalId = setInterval(() => {
setCurrentTime(prev => prev + 1);
}, 1000);
};
// 문제: 빠른 재생/정지 반복 시
// → 이전 interval을 정리하지 못함
// → 여러 interval이 동시 실행
// → 시간이 2배, 3배 속도로 증가!
// ✅ 개선된 코드: ref로 단일 interval 안전하게 관리
const [currentPlayTime, setCurrentPlayTime] = useState(0);
const playTimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isPlayingRef = useRef(false);
const startTimeTracking = (startTime: number) => {
// 1. 기존 interval 반드시 정리 (중복 방지)
if (playTimeIntervalRef.current) {
clearInterval(playTimeIntervalRef.current);
playTimeIntervalRef.current = null;
}
// 2. Date.now() 기반으로 정확한 시간 계산
const trackingStartTime = Date.now();
playTimeIntervalRef.current = setInterval(() => {
// 3. Guard clause로 안전하게
if (!isPlayingRef.current) {
clearInterval(playTimeIntervalRef.current!);
playTimeIntervalRef.current = null;
return;
}
// 실제 경과 시간 계산 (setInterval 지연 보정)
const elapsed = (Date.now() - trackingStartTime) / 1000;
setCurrentPlayTime(startTime + elapsed);
}, 1000);
};
const stopTimeTracking = () => {
if (playTimeIntervalRef.current) {
clearInterval(playTimeIntervalRef.current);
playTimeIntervalRef.current = null;
}
setCurrentPlayTime(0); // 시간 초기화
};
// 사용 예시
const playAllClips = async () => {
isPlayingRef.current = true;
startTimeTracking(0); // 0초부터 시작
for (const clip of clips) {
if (!isPlayingRef.current) break;
await playClip(clip);
}
stopTimeTracking();
isPlayingRef.current = false;
};
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
stopTimeTracking();
stopAllAudio();
};
}, []);
useRef가 아닌 useState로 관리한다면?
// ❌ useState로 타이머 ID를 관리하면?
const [timeoutIds, setTimeoutIds] = useState<NodeJS.Timeout[]>([]);
// 문제 1: 비동기 업데이트로 인한 타이밍 이슈
const playClips = () => {
for (let i = 0; i < 5; i++) {
const id = setTimeout(() => {}, 1000);
setTimeoutIds(prev => [...prev, id]); // 비동기 업데이트
}
// 즉시 중단하려고 하면?
stopAll(); // 이 시점에 timeoutIds는 아직 []
};
// 문제 2: 불필요한 리렌더링
// 타이머 ID가 변경될 때마다 컴포넌트 리렌더링 발생
// → 성능 저하
// ✅ useRef의 장점
const timeoutIdsRef = useRef<NodeJS.Timeout[]>([]);
// 1. 동기적 즉시 업데이트
timeoutIdsRef.current.push(id); // 바로 추가
// 2. 즉시 접근 가능
console.log(timeoutIdsRef.current.length); // 항상 최신 값
// 3. 리렌더링 없음
// UI와 무관한 내부 상태이므로 성능 최적화
왜 Date.now() 기반으로 계산할까?
setInterval은 정확히 100ms마다 실행되지 않는다. 브라우저의 이벤트 루프 특성상 약간의 지연이 누적될 수 있다. Date.now()를 기준으로 계산하면 실제 경과 시간을 정확히 반영할 수 있다.
// ❌ 단순 카운터 방식의 문제
let seconds = 0;
setInterval(() => {
seconds += 1; // 이론상 100ms마다 0.1초 증가
}, 1000);
// 실제: 브라우저 탭 전환, CPU 부하 등으로 지연 발생
// 1분 후 실제 시간: 60초
// 카운터 표시 시간: 58.7초 (누적 오차!)
// ✅ Date.now() 기반 계산
const startTime = Date.now();
setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
// 실제 경과 시간을 정확히 반영
}, 100);
💡 핵심 포인트
1. 다중 타이머 (setTimeout 배열)
- 여러 개의 독립적인 타이머 추적
- 일괄 정리로 메모리 누수 방지
- ref 배열로 즉시 추가/제거
2. 단일 타이머 (setInterval)
- 기존 타이머 정리 후 새로 시작
- Date.now() 기반 정확한 시간 계산
- Guard clause로 안전한 실행
타이머 ID는 ref에 저장하고, 새 interval 시작 전 반드시 기존 것을 정리해야 한다.
NodeJS.Timeout 타입을 사용하면 코드 일관성을 유지할 수 있다.
언제 useRef를 사용해야 할까?
다음 중 하나라도 해당되면 useRef를 고려하는게 좋다.
✅ 외부 객체 참조
- Audio, Video, WebSocket, Canvas Context 등
- DOM이 아닌 객체이지만 리렌더링이 필요 없는 경우
✅ 비동기 로직에서 즉시 접근
- setTimeout, Promise, async/await 내부에서 최신 값 참조
- 클로저 캡처 문제 회피
✅ UI에 표시할 필요 없는 플래그
- 자동 vs 수동 구분
- 초기화 완료 여부
- 드래그 중 여부
✅ 비동기 작업 ID 관리
- setTimeout, setInterval, requestAnimationFrame ID
- 배열로 관리하여 일괄 정리
✅ 즉시 업데이트가 필요한 배열/객체
- 빠르게 추가/제거되는 데이터
- race condition 방지
비디오 에디터 프로젝트를 진행하며, 단순해 보였던 오디오 재생 기능에서 수많은 버그를 마주쳤다.
"정지 버튼을 눌렀는데 1초 후에 갑자기 재생되는" 황당한 버그부터 "자동 선택 시 재생이 중단되는" 로직 버그까지 의도치 않은 수 많은 동작들이 발생했다.
이 모든 문제의 근본 원인은 상태 관리 방식의 선택이었다.
useState는 강력한 도구지만, 모든 상황에 적합한 것은 아니다. 특히 비동기 로직, 외부 객체, 타이머 관리에서는 useRef가 훨씬 적합한 선택이다.
이 글이 여러분의 프로젝트에서 비슷한 문제를 해결하는 데 도움이 되길 바란다.
'React' 카테고리의 다른 글
| granularity of error boundaries ⎯ fault tolerance by. Brandon Dail (0) | 2024.06.04 |
|---|---|
| React | Redux 사용하기 (feat. 리액트를 다루는 기술) (0) | 2022.03.21 |
| React | 리덕스 키워드 정리 (0) | 2022.03.18 |
| React | 구글 리캡챠(reCaptcha) v2 사용하기 (0) | 2022.02.25 |
| React | 카카오 맵 API 사용하여 지도 띄우기 (0) | 2022.02.23 |