브라우저의 기본 오디오 컨트롤을 그대로 쓰면 간단하지만, 제품 요구사항에 맞춘 UI/UX와 접근성, 트래킹, 반응형 대응을 위해 커스텀 오디오 플레이어가 필요할 때가 많습니다. 이 글은 React로 실전형 오디오 플레이어 컴포넌트를 빠르게 구현하고, 접근성(A11y)과 SEO/AEO까지 챙기는 방법을 다룹니다.
1. 요구사항 정리
필수 기능: 재생/일시정지, 현재 시간/전체 길이 표시, 시크(진행 바), 볼륨/음소거, 재생 속도, 키보드 조작, 모바일 대응입니다.
접근성: 키보드로 모든 조작 가능, 명확한 aria-label, 포커스 이동 시 의도치 않은 재생 방지입니다.
성능/안정성: 이벤트 정리, 로딩 상태/버퍼링 표시, 에러 처리, 브라우저 자동재생 정책 대응입니다.
2. 최소 구현 (핵심 원리 파악)
import React, { useEffect, useRef, useState } from 'react';
function MiniAudioPlayer({ src }) {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
const a = audioRef.current;
if (!a) return;
if (a.paused) {
a.play().catch(() => {});
} else {
a.pause();
}
};
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
a.addEventListener('play', onPlay);
a.addEventListener('pause', onPause);
return () => {
a.removeEventListener('play', onPlay);
a.removeEventListener('pause', onPause);
};
}, []);
return (
<div>
<audio ref={audioRef} src={src} preload="metadata" />
<button onClick={toggle} aria-label={playing ? '일시정지' : '재생'}>
{playing ? 'Pause' : 'Play'}
</button>
</div>
);
}
export default MiniAudioPlayer;
핵심: audio 요소를 ref로 제어하고, play/pause 이벤트로 UI 상태를 동기화합니다.
3. 실전용 오디오 플레이어 컴포넌트
import React, { useEffect, useMemo, useRef, useState } from 'react';
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const fmt = (s) => {
if (!isFinite(s)) return '00:00';
const t = Math.max(0, Math.floor(s));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const sec = t % 60;
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
const ss = String(sec).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
};
export default function AudioPlayer({
src,
title = 'Audio',
cover,
preload = 'metadata',
initialVolume = 1,
rates = [0.75, 1, 1.25, 1.5]
}) {
const audioRef = useRef(null);
const containerRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(clamp(initialVolume, 0, 1));
const [isMuted, setIsMuted] = useState(false);
const [rate, setRate] = useState(1);
const [bufferedPct, setBufferedPct] = useState(0);
const [canPlay, setCanPlay] = useState(false);
const [error, setError] = useState(null);
const progressPct = useMemo(() => (duration ? (currentTime / duration) * 100 : 0), [currentTime, duration]);
// Sync audio props
useEffect(() => {
const a = audioRef.current;
if (!a) return;
a.volume = volume;
}, [volume]);
useEffect(() => {
const a = audioRef.current;
if (!a) return;
a.muted = isMuted;
}, [isMuted]);
useEffect(() => {
const a = audioRef.current;
if (!a) return;
a.playbackRate = rate;
}, [rate]);
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const onLoaded = () => {
setDuration(a.duration || 0);
setError(null);
};
const onTime = () => setCurrentTime(a.currentTime || 0);
const onProgress = () => {
try {
if (a.buffered && a.buffered.length > 0 && isFinite(a.duration) && a.duration > 0) {
const end = a.buffered.end(a.buffered.length - 1);
setBufferedPct(clamp((end / a.duration) * 100, 0, 100));
}
} catch (_) {
// ignore
}
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onCanPlay = () => setCanPlay(true);
const onWaiting = () => setCanPlay(false);
const onEnded = () => setIsPlaying(false);
const onError = () => setError('오디오를 재생할 수 없습니다. 파일 또는 네트워크를 확인하세요.');
a.addEventListener('loadedmetadata', onLoaded);
a.addEventListener('timeupdate', onTime);
a.addEventListener('progress', onProgress);
a.addEventListener('play', onPlay);
a.addEventListener('pause', onPause);
a.addEventListener('canplay', onCanPlay);
a.addEventListener('waiting', onWaiting);
a.addEventListener('ended', onEnded);
a.addEventListener('error', onError);
return () => {
a.removeEventListener('loadedmetadata', onLoaded);
a.removeEventListener('timeupdate', onTime);
a.removeEventListener('progress', onProgress);
a.removeEventListener('play', onPlay);
a.removeEventListener('pause', onPause);
a.removeEventListener('canplay', onCanPlay);
a.removeEventListener('waiting', onWaiting);
a.removeEventListener('ended', onEnded);
a.removeEventListener('error', onError);
};
}, []);
const togglePlay = () => {
const a = audioRef.current;
if (!a) return;
if (a.paused) {
a.play().catch(() => {
// 브라우저 정책으로 실패할 수 있음 (사용자 제스처 필요)
});
} else {
a.pause();
}
};
const onSeek = (e) => {
const a = audioRef.current;
if (!a || !duration) return;
const value = Number(e.target.value);
const t = clamp((value / 100) * duration, 0, duration);
a.currentTime = t;
setCurrentTime(t);
};
const skip = (sec) => {
const a = audioRef.current;
if (!a || !duration) return;
const t = clamp((a.currentTime || 0) + sec, 0, duration);
a.currentTime = t;
};
const onVolumeChange = (e) => {
const v = clamp(Number(e.target.value), 0, 1);
setVolume(v);
if (v > 0 && isMuted) setIsMuted(false);
};
const toggleMute = () => setIsMuted((m) => !m);
const onKeyDown = (e) => {
// Container에 포커스 됐을 때만 작동
switch (e.key) {
case ' ': // Space
case 'Enter':
e.preventDefault();
togglePlay();
break;
case 'ArrowRight':
e.preventDefault();
skip(5);
break;
case 'ArrowLeft':
e.preventDefault();
skip(-5);
break;
case 'ArrowUp':
e.preventDefault();
setVolume((v) => clamp(v + 0.05, 0, 1));
break;
case 'ArrowDown':
e.preventDefault();
setVolume((v) => clamp(v - 0.05, 0, 1));
break;
case 'm':
case 'M':
e.preventDefault();
toggleMute();
break;
default:
break;
}
};
const styles = {
container: {
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: 12,
display: 'grid',
gridTemplateColumns: '48px 1fr auto',
gap: 12,
alignItems: 'center'
},
cover: { width: 48, height: 48, borderRadius: 6, objectFit: 'cover', background: '#f3f4f6' },
title: { fontSize: 14, fontWeight: 600, margin: 0 },
barWrap: { display: 'flex', flexDirection: 'column', gap: 6 },
bars: { position: 'relative', height: 8 },
progressBg: { width: '100%', height: 8, borderRadius: 999, background: '#e5e7eb' },
buffered: { position: 'absolute', left: 0, top: 0, height: 8, borderRadius: 999, background: '#d1d5db' },
progress: { position: 'absolute', left: 0, top: 0, height: 8, borderRadius: 999, background: '#3b82f6' },
range: { width: '100%', marginTop: -8, background: 'transparent' },
meta: { display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#6b7280' },
controls: { display: 'flex', gap: 8, alignItems: 'center' },
btn: { padding: '6px 10px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 6, cursor: 'pointer' },
select: { padding: '6px 8px', border: '1px solid #e5e7eb', borderRadius: 6, background: 'white' }
};
return (
<div
ref={containerRef}
style={styles.container}
tabIndex={0}
onKeyDown={onKeyDown}
role="group"
aria-label={`${title} 오디오 플레이어`}
>
{cover ? (
<img src={cover} alt="오디오 커버 이미지" style={styles.cover} width={48} height={48} />
) : (
<div aria-hidden="true" style={styles.cover} />
)}
<div style={styles.barWrap}>
<p style={styles.title}>{title}</p>
<div style={styles.bars}>
<div style={styles.progressBg} aria-hidden="true" />
<div style={{ ...styles.buffered, width: `${bufferedPct}%` }} aria-hidden="true" />
<div style={{ ...styles.progress, width: `${progressPct}%` }} aria-hidden="true" />
<input
type="range"
min="0"
max="100"
step="0.1"
value={progressPct}
onChange={onSeek}
aria-label="재생 위치"
style={styles.range}
/>
</div>
<div style={styles.meta}>
<span>{fmt(currentTime)}</span>
<span>{duration ? fmt(duration) : '--:--'}</span>
</div>
</div>
<div style={styles.controls}>
<button onClick={togglePlay} style={styles.btn} aria-label={isPlaying ? '일시정지' : '재생'}>
{isPlaying ? '❚❚' : '►'}
</button>
<button onClick={() => skip(-10)} style={styles.btn} aria-label="10초 되감기">↺ 10s</button>
<button onClick={() => skip(10)} style={styles.btn} aria-label="10초 앞으로">10s ↻</button>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="sr-only">볼륨</span>
<button onClick={toggleMute} style={styles.btn} aria-pressed={isMuted} aria-label={isMuted ? '음소거 해제' : '음소거'}>
{isMuted ? '🔇' : '🔊'}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={onVolumeChange}
aria-label="볼륨 조절"
/>
</label>
<select
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
aria-label="재생 속도"
style={styles.select}
>
{rates.map((r) => (
<option key={r} value={r}>{r}x</option>
))}
</select>
</div>
<audio ref={audioRef} src={src} preload={preload} aria-hidden="true" />
{!canPlay && !error && (
<span role="status" aria-live="polite" style={{ position: 'absolute', left: -9999 }}>
로딩 중...
</span>
)}
{error && (
<span role="alert" style={{ color: '#dc2626', fontSize: 12 }}>
{error}
</span>
)}
</div>
);
}
4. 사용 방법
import React from 'react';
import AudioPlayer from './AudioPlayer';
export default function PodcastEpisode() {
return (
<div>
<h2>에피소드 12. 리액트 성능 최적화</h2>
<AudioPlayer
src="https://cdn.example.com/audio/episode-12.mp3"
title="에피소드 12 - 리액트 성능 최적화"
cover="https://cdn.example.com/covers/episode-12.jpg"
preload="metadata"
initialVolume={0.8}
rates={[0.75, 1, 1.25, 1.5, 2]}
/>
</div>
);
}
5. 접근성(A11y)과 키보드 조작
키보드: Space/Enter 재생 토글, 방향키 좌우 5~10초 이동, 상하 볼륨, M 음소거를 제공합니다. 컨테이너에 tabIndex를 주고 onKeyDown을 처리합니다.
스크린리더: 모든 버튼에 명확한 aria-label을 부여하고, 진행 바/볼륨 슬라이더에 aria-label을 제공합니다. 시각적 요소(커버 이미지)에는 적절한 alt를 제공합니다.
라이브 영역: 로딩 상태는 aria-live=polite로 전달하고, 오류는 role=alert로 알려줍니다.
6. 모바일/브라우저 이슈 대처
자동재생 정책: 대부분의 브라우저는 사용자 제스처 없이 오디오 자동재생을 막습니다. play()는 Promise를 반환하므로 catch로 실패를 무시하거나 안내 메시지를 띄웁니다.
iOS: 잠금화면/제어 센터 연동은 메타데이터(Media Session API)를 함께 설정하면 좋습니다. 커스텀 필요 시 navigator.mediaSession을 활용합니다.
프리로드: preload=metadata로 첫 페인트 지연을 줄이고, 플레이 버튼 클릭 시 네트워크를 사용합니다. 짧은 효과음 등은 preload=auto를 고려합니다.
스트리밍: HLS(예: .m3u8)는 hls.js로 미지원 브라우저에 폴리필을 적용하고, 지원 시 native HLS로 재생합니다.
7. SEO/AEO 체크리스트와 구조화 데이터
검색/답변엔진 최적화: 페이지 내에 오디오의 제목, 설명, 썸네일, 길이를 명시하고, AudioObject 구조화 데이터를 추가합니다. 제목과 본문에 키워드(예: 팟캐스트, 오디오 재생, React 오디오 플레이어)를 자연스럽게 포함합니다.
// 페이지 <head> 또는 컴포넌트 내 dangerouslySetInnerHTML로 주입
{
"@context": "https://schema.org",
"@type": "AudioObject",
"name": "에피소드 12 - 리액트 성능 최적화",
"description": "리액트 렌더링 비용을 줄이는 실전 팁을 다룹니다.",
"thumbnailUrl": "https://cdn.example.com/covers/episode-12.jpg",
"contentUrl": "https://cdn.example.com/audio/episode-12.mp3",
"duration": "PT42M35S",
"uploadDate": "2026-03-01",
"encodingFormat": "audio/mpeg"
}
오디오 상세 페이지 URL은 짧고 의미 있게 구성하고, 플레이어 근처에 텍스트 설명(쇼노트/타임라인)을 제공합니다. 이는 AEO 관점에서 답변 정확도와 풍부한 결과 노출에 유리합니다.
8. 유지보수 팁
상태 최소화: isPlaying, currentTime, duration 등 필수만 로컬 상태로 두고, 나머지는 audio 속성과 동기화합니다.
이벤트 정리: useEffect에서 addEventListener/cleanup을 철저히 하여 메모리 누수를 방지합니다.
테마/반응형: 스타일 객체를 props로 주입하거나 className을 노출해 디자인 시스템과 통합합니다.
분리: 재생 로직을 useAudioPlayer 훅으로 분리하면 동일 로직으로 미니/확장 플레이어를 쉽게 구성할 수 있습니다.
트래킹: 재생 시작, 25/50/75/완료 구간 도달 이벤트를 커스텀 이벤트로 발행해 분석 도구에 연동합니다.
'React' 카테고리의 다른 글
| React 앱에서 이미지 필터 효과 적용하기 (0) | 2026.05.30 |
|---|---|
| React와 D3.js를 결합한 데이터 시각화 기법 (0) | 2026.05.29 |
| React 앱에서 테마 변경 기능 다중 지원하기 (0) | 2026.05.29 |
| React에서 사용자 프로필 편집 페이지 제작하기 (0) | 2026.05.29 |
| React로 차트 애니메이션 직접 구현하기 (0) | 2026.05.28 |