본문 바로가기

React

React에서 오디오 플레이어 컴포넌트 만들기

브라우저의 기본 오디오 컨트롤을 그대로 쓰면 간단하지만, 제품 요구사항에 맞춘 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/완료 구간 도달 이벤트를 커스텀 이벤트로 발행해 분석 도구에 연동합니다.