본문 바로가기

React

React 앱에서 화면 녹화(Screen Recording) 기능 구현

React 앱에 화면 녹화 기능을 넣으면 버그 리포트, 튜토리얼, 사용자 온보딩 등 다양한 시나리오에서 강력한 도구가 됩니다. 이 글은 getDisplayMedia와 MediaRecorder를 기반으로, 마이크와 시스템 오디오를 동시에 녹음하고, 일시정지/재개, 다운로드/업로드까지 가능한 실무용 구현 방법을 정리합니다.

1. 핵심 개념과 요구 사항

화면 녹화는 크게 두 단계입니다. 첫째, navigator.mediaDevices.getDisplayMedia로 화면(창/탭/전체 화면)과 선택적으로 시스템 오디오를 캡처합니다. 둘째, MediaRecorder로 캡처된 MediaStream을 파일로 인코딩합니다. HTTPS 환경(로컬호스트 제외)과 사용자 제스처(버튼 클릭 등)가 필요합니다. 브라우저마다 지원 코덱과 MIMETYPE이 다르므로 MediaRecorder.isTypeSupported 체크가 중요합니다.

2. 최소 구현 흐름

기본 흐름은 다음과 같습니다. 녹화 시작 버튼 클릭 시 getDisplayMedia를 호출해 스트림 확보, 필요한 경우 getUserMedia로 마이크 스트림 병합, MediaRecorder로 녹화 시작, ondataavailable로 청크 수집 후 stop에서 Blob 생성, URL 생성 및 다운로드 링크 제공입니다.

3. 재사용 가능한 React 훅 구현

마이크와 시스템 오디오를 동시에 녹음할 때는 Web Audio API(AudioContext)를 사용해 두 오디오 소스를 믹싱하는 것이 안전합니다. 아래 훅은 일시정지/재개, 미리보기, 오류, 경과 시간, 파일 Blob/URL까지 제공합니다.

import { useEffect, useRef, useState } from 'react';

export function useScreenRecorder(options = {}) {
  const { withMic = true, videoBitsPerSecond = 4_000_000, timeslice = 1000 } = options;

  const [isRecording, setIsRecording] = useState(false);
  const [isPaused, setIsPaused] = useState(false);
  const [blobUrl, setBlobUrl] = useState(null);
  const [error, setError] = useState(null);
  const [elapsedMs, setElapsedMs] = useState(0);

  const mediaRecorderRef = useRef(null);
  const chunksRef = useRef([]);
  const displayStreamRef = useRef(null);
  const micStreamRef = useRef(null);
  const mixedAudioStreamRef = useRef(null);
  const videoRef = useRef(null);
  const timerRef = useRef(null);
  const blobRef = useRef(null);

  function pickSupportedMimeType() {
    const candidates = [
      'video/webm;codecs=vp9,opus',
      'video/webm;codecs=vp8,opus',
      'video/webm',
      'video/mp4;codecs=h264,aac',
      'video/mp4'
    ];
    for (const t of candidates) {
      if (typeof window !== 'undefined' && window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) {
        return t;
      }
    }
    return '';
  }

  async function buildStream() {
    const displayStream = await navigator.mediaDevices.getDisplayMedia({
      video: { displaySurface: 'browser', frameRate: 30 },
      audio: true
    });
    displayStreamRef.current = displayStream;

    let micStream = null;
    if (withMic) {
      try {
        micStream = await navigator.mediaDevices.getUserMedia({
          audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
        });
      } catch (e) {
        // 마이크는 선택 사항입니다.
      }
    }
    micStreamRef.current = micStream;

    const hasSystemAudio = displayStream.getAudioTracks().length > 0;
    const hasMicAudio = micStream && micStream.getAudioTracks().length > 0;

    if (hasSystemAudio && hasMicAudio) {
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      const sysSource = ctx.createMediaStreamSource(displayStream);
      const micSource = ctx.createMediaStreamSource(micStream);
      const destination = ctx.createMediaStreamDestination();
      sysSource.connect(destination);
      micSource.connect(destination);
      mixedAudioStreamRef.current = destination.stream;
      const merged = new MediaStream([
        ...displayStream.getVideoTracks(),
        ...destination.stream.getAudioTracks()
      ]);
      return merged;
    }

    const merged = new MediaStream([
      ...displayStream.getVideoTracks(),
      ...(hasSystemAudio ? displayStream.getAudioTracks() : []),
      ...(hasMicAudio ? micStream.getAudioTracks() : [])
    ]);
    return merged;
  }

  function startTimer() {
    const startAt = Date.now();
    timerRef.current = setInterval(() => {
      setElapsedMs(Date.now() - startAt);
    }, 200);
  }

  function clearTimer() {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  }

  async function start(previewEl) {
    setError(null);
    setElapsedMs(0);
    if (blobUrl) {
      URL.revokeObjectURL(blobUrl);
      setBlobUrl(null);
    }

    const stream = await buildStream();
    const mimeType = pickSupportedMimeType();
    const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond });
    mediaRecorderRef.current = recorder;

    chunksRef.current = [];
    recorder.ondataavailable = (e) => {
      if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
    };
    recorder.onstop = () => {
      clearTimer();
      const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
      blobRef.current = blob;
      const url = URL.createObjectURL(blob);
      setBlobUrl(url);
      setIsRecording(false);
      setIsPaused(false);
      cleanupStreams();
    };
    recorder.onerror = (e) => {
      setError(e.error ? e.error.message : String(e));
    };

    const el = previewEl || videoRef.current;
    if (el) {
      el.srcObject = stream;
      el.muted = true;
      el.play().catch(() => {});
    }

    recorder.start(timeslice);
    setIsRecording(true);
    startTimer();
  }

  function pause() {
    const r = mediaRecorderRef.current;
    if (r && r.state === 'recording') {
      r.pause();
      setIsPaused(true);
    }
  }

  function resume() {
    const r = mediaRecorderRef.current;
    if (r && r.state === 'paused') {
      r.resume();
      setIsPaused(false);
    }
  }

  function stop() {
    const r = mediaRecorderRef.current;
    if (r && r.state !== 'inactive') {
      r.stop();
    }
  }

  function cleanupStreams() {
    [displayStreamRef.current, micStreamRef.current, mixedAudioStreamRef.current].forEach((s) => {
      if (s) s.getTracks().forEach((t) => t.stop());
    });
    displayStreamRef.current = null;
    micStreamRef.current = null;
    mixedAudioStreamRef.current = null;
    if (videoRef.current) videoRef.current.srcObject = null;
  }

  function getBlob() {
    return blobRef.current || (chunksRef.current.length ? new Blob(chunksRef.current) : null);
  }

  useEffect(() => {
    return () => {
      clearTimer();
      cleanupStreams();
      if (blobUrl) URL.revokeObjectURL(blobUrl);
    };
  }, []);

  return { start, stop, pause, resume, isRecording, isPaused, blobUrl, getBlob, elapsedMs, error, videoRef };
}

4. 데모 컴포넌트 예시

아래 컴포넌트는 훅을 사용해 미리보기, 일시정지/재개, 정지 및 다운로드를 제공합니다. 미리보기 비디오는 반드시 muted 상태여야 에코가 발생하지 않습니다.

import React from 'react';
import { useScreenRecorder } from './useScreenRecorder';

export default function ScreenRecorderDemo() {
  const { start, stop, pause, resume, isRecording, isPaused, blobUrl, elapsedMs, error, videoRef } = useScreenRecorder({ withMic: true });

  const onStart = async () => {
    try {
      await start();
    } catch (e) {
      alert('시작 실패: ' + (e.message || e));
    }
  };

  return (
    <div>
      <video ref={videoRef} style={{ width: '100%', border: '1px solid #eee' }} playsInline></video>
      <div style={{ marginTop: 8 }}>
        {!isRecording && <button onClick={onStart}>녹화 시작</button>}
        {isRecording && !isPaused && <button onClick={pause}>일시정지</button>}
        {isRecording && isPaused && <button onClick={resume}>재개</button>}
        {isRecording && <button onClick={stop}>정지</button>}
      </div>
      <div style={{ marginTop: 8 }}>
        <span>경과: {(elapsedMs / 1000).toFixed(1)}s</span>
      </div>
      {error && <p style={{ color: 'red' }}>{String(error)}</p>}
      {blobUrl && (
        <div>
          <video src={blobUrl} controls style={{ width: '100%', marginTop: 8 }}></video>
          <a href={blobUrl} download={'recording.webm'}>다운로드</a>
        </div>
      )}
    </div>
  );
}

5. 파일 저장과 서버 업로드

다운로드는 blobUrl을 a 태그의 href로 연결하면 됩니다. 서버 업로드는 FormData로 Blob을 전송합니다. Safari에서 mp4로 녹화되는 경우 확장자를 동적으로 바꾸는 것이 좋습니다.

async function uploadRecording(url, blob, filename = 'recording.webm') {
  const form = new FormData();
  form.append('file', blob, filename);
  const res = await fetch(url, { method: 'POST', body: form });
  if (!res.ok) throw new Error('업로드 실패');
  return await res.json();
}

6. 오류 처리와 권한 전략

사용자가 공유를 취소하거나, 시스템 오디오를 허용하지 않거나, 마이크 권한을 거부하는 경우가 있습니다. 각 예외 상황에서 옵셔널 처리와 대체 메시지를 제공해야 합니다. getDisplayMedia는 사용자 제스처가 있어야 호출 가능하며, 탭 오디오를 캡처하려면 크롬에서 탭을 선택해야 합니다. 전체 화면을 선택하면 시스템 오디오가 캡처되지 않을 수 있습니다.

7. 브라우저 호환성과 MIMETYPE 선택

크롬/엣지는 video/webm;codecs=vp8,opus 또는 vp9,opus를 잘 지원합니다. 파이어폭스도 webm 녹화는 가능하지만 시스템 오디오 캡처는 제한적입니다. 사파리는 MediaRecorder가 video/webm을 지원하지 않고 video/mp4(H.264/AAC)를 반환하는 경우가 있어 교차 재생 호환성이 떨어질 수 있습니다. 따라서 MediaRecorder.isTypeSupported로 우선순위 배열을 검사해서 선택하는 방식을 권장합니다. 결과 파일을 재생하거나 업로드할 때 MIME 타입에 맞는 확장자를 사용하고, 다른 브라우저에서 재생이 안 되면 서버에서 트랜스코딩하는 전략을 고려합니다.

8. 성능과 품질 튜닝

videoBitsPerSecond를 2~6Mbps 범위로 조절해 파일 크기와 품질을 균형 있게 맞춥니다. frameRate는 30fps가 무난하며, 저사양 디바이스에선 24fps로 낮추면 CPU 부담을 줄일 수 있습니다. MediaRecorder.start(timeslice)를 사용해 1초 간격 청크를 받으면 메모리 사용량을 낮추고 장시간 녹화 안정성을 높일 수 있습니다. 미리보기 video 엘리먼트는 muted, playsInline 속성을 사용해 에코와 자동 재생 문제를 방지합니다.

긴 녹화의 경우 ondataavailable로 전달되는 청크를 바로 업로드하는 스트리밍 업로드(예: Service Worker 또는 백엔드 분할 업로드)를 적용하면 중간 손실을 줄일 수 있습니다. 탭이 백그라운드로 가면 프레임레이트가 제한될 수 있으므로 중요한 녹화는 화면을 켠 상태로 진행하고, 가능하면 Wake Lock API 사용을 고려합니다.

9. UX, 보안, 프라이버시

녹화 중 UI에 붉은 점 또는 배지를 표시하고, 민감 정보가 포함되지 않도록 안내합니다. 탭 전환 시 녹화 대상이 바뀔 수 있음을 명시합니다. 정지 시 모든 트랙을 즉시 stop하여 마이크가 계속 켜져 있지 않게 합니다. 보안 헤더로 Permissions-Policy: display-capture=(self)를 설정해 외부 도메인의 무단 사용을 제한할 수 있습니다. 앱 내에서 권한 거부 시 재시도 버튼과 도움말 링크를 제공하면 이탈을 줄일 수 있습니다.

10. Next.js/SSR 환경 주의

SSR 환경에서는 window나 MediaRecorder가 없습니다. 'use client' 파일에서만 훅을 사용하고, typeof window !== 'undefined' 가드로 런타임 의존 코드를 보호합니다. 다이내믹 임포트(dynamic import)로 클라이언트 전용 컴포넌트를 분리하면 안전합니다.

11. 테스트 체크리스트

기본 녹화 시작/정지, 일시정지/재개, 마이크 온/오프, 탭/창/전체 화면 각각 테스트, 시스템 오디오 포함 녹화, 파일 재생 호환성(사파리 vs 크롬), 장시간 녹화 안정성, 네트워크 단절 시 업로드 재시도, 메모리 누수(객체 URL revoke 및 트랙 stop) 등을 점검합니다.

12. 자주 묻는 질문

장시간 녹화 파일 크기는 어느 정도인가요? 비트레이트 4Mbps 기준으로 대략 분당 30MB 수준입니다. 텍스트 위주의 화면은 더 작고, 애니메이션이 많으면 더 큽니다.

일시정지/재개가 불안정할 때는 MediaRecorder 상태를 확인하고, timeslice를 늘리거나 pause 직전 청크 버퍼를 비우는 방법을 시도합니다.

시스템 오디오와 마이크 볼륨을 다르게 조절하려면 AudioContext에서 GainNode를 각각 연결해 조절하면 됩니다. 에코가 심하면 헤드셋 사용을 안내하고 echoCancellation 옵션을 켭니다.

사파리에서 webm이 재생되지 않는 문제는 무엇인가요? 사파리는 webm 녹화/재생 지원이 제한적입니다. isTypeSupported로 mp4를 선택하고, 다운로드 파일 확장자를 mp4로 저장하거나 서버에서 webm을 mp4로 트랜스코딩하는 방식을 권장합니다.

요약하면, getDisplayMedia와 MediaRecorder 조합에 MIME 타입 탐지, 오디오 믹싱, 메모리 관리, 브라우저별 예외 처리를 더하면 실무에서도 안정적인 화면 녹화를 제공할 수 있습니다.