이미지 로딩 중...

getUserMedia로 카메라/마이크 접근하기 - 실전 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 20. · 2 Views

getUserMedia로 카메라/마이크 접근하기 - 실전 가이드

웹 브라우저에서 카메라와 마이크에 접근하는 방법을 배워봅니다. getUserMedia API를 사용하여 미디어 스트림을 가져오고, 권한을 관리하며, 실시간 화상 통화나 녹화 기능을 구현하는 방법을 단계별로 알아봅니다.


목차

  1. getUserMedia API 사용법
  2. 미디어 제약 조건 설정
  3. 카메라/마이크 권한 요청
  4. 비디오 엘리먼트에 스트림 연결
  5. 디바이스 목록 가져오기
  6. 에러 핸들링

1. getUserMedia API 사용법

시작하며

여러분이 화상 회의 앱을 만들거나 사진 촬영 기능을 웹사이트에 추가하려고 할 때, 어떻게 사용자의 카메라에 접근하는지 막막했던 적이 있나요? 예를 들어, 온라인 면접 서비스를 만들 때 사용자의 카메라와 마이크를 켜야 하는데, 어디서부터 시작해야 할지 고민이 되셨을 겁니다.

이런 문제는 실시간 미디어를 다루는 모든 웹 애플리케이션에서 반드시 마주치는 과제입니다. 잘못 구현하면 사용자에게 권한을 요청하지 못하거나, 카메라가 켜지지 않는 등의 문제가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 getUserMedia API입니다. 이 API를 사용하면 단 몇 줄의 코드로 브라우저를 통해 카메라와 마이크에 안전하게 접근할 수 있습니다.

개요

간단히 말해서, getUserMedia는 웹 브라우저가 사용자의 카메라나 마이크 같은 미디어 장치에 접근할 수 있게 해주는 JavaScript API입니다. 왜 이 API가 필요한지 실무 관점에서 생각해볼까요?

화상 통화, 실시간 스트리밍, 사진/비디오 녹화, 음성 인식 서비스 등 점점 더 많은 웹 서비스들이 사용자의 미디어 장치를 필요로 합니다. 예를 들어, Google Meet이나 Zoom 같은 화상 회의 서비스는 모두 이 기술을 기반으로 만들어졌습니다.

전통적인 방법을 생각해보세요. 기존에는 플래시나 별도의 플러그인을 설치해야 카메라에 접근할 수 있었다면, 이제는 순수 JavaScript만으로 브라우저 내장 기능을 통해 바로 사용할 수 있습니다.

이 API의 핵심 특징은 세 가지입니다: 첫째, 사용자의 명시적인 권한 동의를 반드시 받아야 합니다(보안). 둘째, Promise 기반으로 작동하여 비동기 처리가 깔끔합니다.

셋째, MediaStream 객체를 반환하여 다른 WebRTC API들과 쉽게 연동됩니다. 이러한 특징들이 왜 중요하냐면, 사용자 프라이버시를 보호하면서도 개발자가 강력한 미디어 기능을 쉽게 구현할 수 있게 해주기 때문입니다.

코드 예제

// 카메라와 마이크에 접근하는 기본 예제
async function startCamera() {
  try {
    // navigator.mediaDevices.getUserMedia()를 호출하여 미디어 스트림 요청
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,  // 비디오(카메라) 사용
      audio: true   // 오디오(마이크) 사용
    });

    // 비디오 엘리먼트에 스트림 연결
    const videoElement = document.getElementById('myVideo');
    videoElement.srcObject = stream;

    console.log('카메라와 마이크 접근 성공!');
  } catch (error) {
    // 에러 처리 (권한 거부, 장치 없음 등)
    console.error('미디어 접근 실패:', error);
  }
}

설명

이것이 하는 일: getUserMedia는 사용자의 미디어 장치(카메라, 마이크)에 접근 권한을 요청하고, 허용되면 실시간 미디어 데이터를 스트림 형태로 제공합니다. 코드를 단계별로 나누어 살펴볼까요?

첫 번째로, navigator.mediaDevices.getUserMedia() 메서드를 호출합니다. 이 메서드는 브라우저에게 "사용자의 카메라와 마이크를 사용하고 싶어요"라고 요청하는 것입니다.

여기서 navigator.mediaDevices는 브라우저가 제공하는 미디어 장치 관리자라고 생각하면 됩니다. 왜 이렇게 하냐면, 보안상의 이유로 웹 페이지가 마음대로 카메라에 접근할 수 없고, 반드시 공식적인 경로를 통해 요청해야 하기 때문입니다.

그 다음으로, 객체 형태로 제약 조건(constraints)을 전달합니다. { video: true, audio: true }는 "비디오와 오디오 둘 다 필요해요"라는 의미입니다.

이 메서드는 Promise를 반환하므로, async/await를 사용하여 결과를 기다립니다. 사용자가 권한을 허용하면 MediaStream 객체가 반환됩니다.

이 스트림은 실시간으로 흐르는 비디오/오디오 데이터의 묶음이라고 생각하면 됩니다. 마지막으로, 받은 스트림을 HTML video 엘리먼트의 srcObject 속성에 할당합니다.

이렇게 하면 비디오 엘리먼트가 카메라로부터 들어오는 영상을 실시간으로 화면에 보여주게 됩니다. try-catch 블록으로 감싼 이유는, 사용자가 권한을 거부하거나 카메라가 없는 경우 등의 에러 상황을 안전하게 처리하기 위함입니다.

여러분이 이 코드를 사용하면 사용자의 웹캠 영상을 실시간으로 웹 페이지에 표시할 수 있습니다. 실무에서의 이점으로는 첫째, 별도의 플러그인 없이 순수 JavaScript만으로 구현 가능하고, 둘째, 모든 모던 브라우저에서 지원되며, 셋째, 반환된 스트림을 녹화, 전송, 분석 등 다양한 용도로 활용할 수 있다는 점입니다.

실전 팁

💡 getUserMedia는 반드시 HTTPS 환경에서만 작동합니다. localhost는 예외로 허용되지만, 배포 시에는 반드시 SSL 인증서를 적용하세요. 이는 보안상의 이유로 브라우저가 강제하는 정책입니다.

💡 사용자가 권한을 거부할 수 있다는 점을 항상 염두에 두세요. 에러 처리를 통해 친절한 안내 메시지를 보여주고, 권한 요청 전에 왜 카메라가 필요한지 미리 설명하면 허용률이 높아집니다.

💡 스트림을 더 이상 사용하지 않을 때는 반드시 stream.getTracks().forEach(track => track.stop())을 호출하여 카메라를 종료하세요. 그렇지 않으면 카메라 LED가 계속 켜져 있어 사용자가 불안해할 수 있습니다.

💡 브라우저 호환성을 체크하세요. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) 조건으로 API 지원 여부를 먼저 확인하고, 지원하지 않는 브라우저에는 업그레이드 안내를 표시하세요.

💡 모바일 환경에서는 카메라가 여러 개일 수 있습니다(전면/후면). 나중에 배울 facingMode 제약 조건을 사용하여 원하는 카메라를 선택할 수 있습니다.


2. 미디어 제약 조건 설정

시작하며

여러분이 화상 면접 앱을 만들고 있는데, 사용자의 네트워크가 느려서 고화질 비디오를 전송하기 어려운 상황을 겪어본 적 있나요? 또는 프로필 사진 촬영 기능에서 후면 카메라가 아닌 전면 카메라를 사용하고 싶은데 방법을 몰라 고민했던 경험이 있으신가요?

이런 문제는 실시간 미디어를 다루는 모든 서비스에서 자주 발생합니다. 무조건 최고 화질로 스트림을 가져오면 네트워크 부담이 크고, 배터리 소모도 심하며, 모바일 데이터를 과도하게 사용하게 됩니다.

또한 상황에 맞지 않는 카메라가 선택되면 사용자 경험이 크게 떨어집니다. 바로 이럴 때 필요한 것이 미디어 제약 조건(Media Constraints) 설정입니다.

이를 통해 해상도, 프레임레이트, 카메라 방향 등을 세밀하게 제어하여 상황에 최적화된 미디어 스트림을 얻을 수 있습니다.

개요

간단히 말해서, 미디어 제약 조건은 getUserMedia에게 "어떤 품질의 비디오/오디오를 원하는지" 구체적으로 지시하는 설정 객체입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 사용자 환경이 동일하지 않기 때문입니다.

데스크톱에서는 고화질이 좋지만, 모바일 4G 환경에서는 저화질이 더 적합할 수 있습니다. 예를 들어, 화상 회의 서비스라면 네트워크 상태에 따라 720p, 480p, 360p 중 적절한 해상도를 선택해야 원활한 통화가 가능합니다.

기존에는 단순히 video: true로만 요청했다면, 이제는 video: { width: 1280, height: 720, frameRate: 30 } 같은 상세한 조건을 지정할 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다: 첫째, 이상적인 값(ideal)과 필수 값(exact)을 구분하여 유연하게 설정할 수 있습니다.

둘째, 해상도, 프레임레이트, 샘플레이트, 카메라 방향 등 다양한 속성을 제어 가능합니다. 셋째, 브라우저가 지원 가능한 범위 내에서 최선의 결과를 자동으로 선택합니다.

이러한 특징들이 중요한 이유는 다양한 디바이스와 네트워크 환경에서 최적의 성능을 보장하면서도, 사용자에게 일관된 경험을 제공할 수 있기 때문입니다.

코드 예제

// 상세한 미디어 제약 조건 설정 예제
async function startCameraWithConstraints() {
  const constraints = {
    video: {
      width: { min: 640, ideal: 1280, max: 1920 },  // 해상도: 최소, 이상적, 최대
      height: { min: 480, ideal: 720, max: 1080 },
      frameRate: { ideal: 30, max: 60 },  // 프레임레이트
      facingMode: 'user'  // 'user'(전면) 또는 'environment'(후면)
    },
    audio: {
      echoCancellation: true,  // 에코 제거
      noiseSuppression: true,  // 노이즈 제거
      sampleRate: { ideal: 48000 }  // 샘플레이트
    }
  };

  try {
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    document.getElementById('myVideo').srcObject = stream;

    // 실제로 적용된 설정 확인
    const videoTrack = stream.getVideoTracks()[0];
    console.log('적용된 설정:', videoTrack.getSettings());
  } catch (error) {
    console.error('제약 조건을 만족하는 장치를 찾을 수 없습니다:', error);
  }
}

설명

이것이 하는 일: 미디어 제약 조건은 getUserMedia에게 원하는 미디어 특성을 전달하여, 브라우저가 그에 맞는 최적의 스트림을 선택하도록 안내합니다. 코드를 단계별로 나누어 살펴보겠습니다.

첫 번째로, constraints 객체를 정의합니다. 이 객체는 video와 audio 두 섹션으로 나뉩니다.

video 섹션에서 width와 height는 단순한 숫자가 아니라 객체로 정의되어 있습니다. { min: 640, ideal: 1280, max: 1920 }의 의미는 "가능하면 1280px을 원하지만, 최소 640px은 되어야 하고, 최대 1920px까지 허용한다"는 뜻입니다.

브라우저는 이 범위 내에서 하드웨어가 지원하는 최선의 값을 선택합니다. 왜 이렇게 하냐면, 모든 카메라가 동일한 해상도를 지원하지 않기 때문에 유연성을 주기 위함입니다.

그 다음으로, facingMode: 'user'는 전면 카메라를 사용하겠다는 의미입니다. 모바일에서 셀카를 찍을 때 필수적인 설정이죠.

'environment'로 바꾸면 후면 카메라가 선택됩니다. audio 섹션에서는 echoCancellation: true로 에코 제거 기능을 활성화합니다.

이는 화상 통화에서 울림 현상을 방지하는 매우 중요한 설정입니다. noiseSuppression은 배경 소음을 줄여주어 목소리가 더 명확하게 전달되도록 합니다.

마지막으로, stream.getVideoTracks()[0].getSettings()를 호출하여 실제로 적용된 설정을 확인합니다. 여러분이 요청한 ideal 값과 실제로 적용된 값이 다를 수 있기 때문에, 이를 확인하는 것이 디버깅에 매우 유용합니다.

예를 들어, 1280x720을 요청했지만 카메라가 지원하지 않으면 640x480으로 대체될 수 있습니다. 여러분이 이 코드를 사용하면 사용자의 환경에 맞는 최적의 미디어 품질을 자동으로 선택할 수 있습니다.

실무에서의 이점으로는 첫째, 네트워크 대역폭을 절약하여 끊김 없는 스트리밍이 가능하고, 둘째, 배터리 소모를 줄일 수 있으며, 셋째, 다양한 디바이스에서 일관된 사용자 경험을 제공할 수 있습니다.

실전 팁

💡 ideal 값을 사용하는 것이 exact보다 훨씬 안전합니다. exact를 사용하면 해당 값을 정확히 지원하지 않는 장치에서는 에러가 발생하지만, ideal은 최대한 근접한 값을 선택해줍니다.

💡 모바일 웹앱을 만들 때는 facingMode를 반드시 설정하세요. 기본값은 환경에 따라 달라질 수 있어 예상치 못한 카메라가 선택될 수 있습니다. 셀카 앱이라면 'user', QR 코드 스캐너라면 'environment'를 사용하세요.

💡 화상 회의 앱에서는 echoCancellation, noiseSuppression, autoGainControl을 모두 true로 설정하는 것이 좋습니다. 이 세 가지 오디오 처리 기능이 통화 품질을 크게 향상시킵니다.

💡 사용자의 네트워크 속도에 따라 동적으로 해상도를 조정하는 기능을 구현하세요. navigator.connection API로 네트워크 상태를 감지하여, 느린 연결에서는 낮은 해상도를 요청하도록 할 수 있습니다.

💡 getSettings() 메서드로 실제 적용된 값을 확인하고, 이를 사용자에게 표시하면 투명성이 높아집니다. "현재 720p로 스트리밍 중입니다" 같은 정보를 UI에 보여주면 사용자가 품질을 이해하기 쉽습니다.


3. 카메라/마이크 권한 요청

시작하며

여러분이 처음 화상 채팅 앱을 만들었을 때, 사용자들이 권한 요청 팝업을 보고 당황하거나 거부 버튼을 누르는 경우가 많았던 경험이 있나요? 갑작스럽게 "카메라 접근을 허용하시겠습니까?"라는 메시지가 뜨면 사용자는 "왜 필요한지"도 모른 채 거부하게 됩니다.

이런 문제는 권한 요청 UX를 제대로 설계하지 않았을 때 흔히 발생합니다. 권한이 거부되면 앱의 핵심 기능을 사용할 수 없게 되고, 한 번 거부된 권한을 다시 요청하기는 매우 어렵습니다.

사용자는 브라우저 설정에 직접 들어가서 수동으로 권한을 변경해야 하는데, 대부분의 사용자는 이 방법을 모릅니다. 바로 이럴 때 필요한 것이 적절한 권한 요청 전략입니다.

사용자에게 왜 권한이 필요한지 먼저 설명하고, 거부되었을 때 어떻게 대처할지 준비하며, 권한 상태를 체크하는 방법을 알아야 합니다.

개요

간단히 말해서, 카메라/마이크 권한 요청은 getUserMedia를 호출하는 순간 브라우저가 자동으로 띄우는 허용/거부 팝업을 통해 이루어집니다. 왜 이 개념이 필요한지 실무 관점에서 살펴보면, 사용자 프라이버시 보호가 최우선이기 때문입니다.

악의적인 사이트가 몰래 카메라를 켤 수 없도록, 브라우저는 반드시 사용자의 명시적인 동의를 요구합니다. 예를 들어, 화상 면접 플랫폼에서는 면접 시작 전에 "카메라와 마이크 테스트를 위해 권한이 필요합니다"라고 안내한 후 권한을 요청하면 허용률이 크게 높아집니다.

기존에는 권한 상태를 미리 확인할 방법이 없었다면, 이제는 Permissions API를 사용하여 현재 권한 상태를 조회하고, 거부된 경우 사용자에게 안내 메시지를 보여줄 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다: 첫째, getUserMedia 호출 시 자동으로 권한 팝업이 표시됩니다.

둘째, 사용자의 선택(허용/거부)은 브라우저에 저장되어 같은 도메인에서 재방문 시 기억됩니다. 셋째, Permissions API로 사전에 권한 상태를 체크할 수 있습니다.

이러한 특징들이 중요한 이유는 사용자 경험을 해치지 않으면서도 보안을 유지하고, 권한 거부 상황에 대응할 수 있는 방법을 제공하기 때문입니다.

코드 예제

// 권한 확인 후 요청하는 모범 예제
async function requestCameraPermission() {
  // 먼저 Permissions API로 현재 권한 상태 확인
  try {
    const permissionStatus = await navigator.permissions.query({ name: 'camera' });

    if (permissionStatus.state === 'granted') {
      console.log('이미 권한이 허용되어 있습니다');
      return startCamera();
    } else if (permissionStatus.state === 'denied') {
      // 권한이 거부된 경우 사용자에게 안내
      showPermissionDeniedMessage();
      return;
    }

    // 권한이 'prompt' 상태(아직 묻지 않음)인 경우
    console.log('권한을 요청합니다...');
    const stream = await navigator.mediaDevices.getUserMedia({ video: true });
    document.getElementById('myVideo').srcObject = stream;

  } catch (error) {
    // 사용자가 거부했거나 다른 에러 발생
    if (error.name === 'NotAllowedError') {
      console.error('사용자가 권한을 거부했습니다');
      showPermissionDeniedMessage();
    } else {
      console.error('에러 발생:', error);
    }
  }
}

function showPermissionDeniedMessage() {
  alert('카메라 권한이 필요합니다. 브라우저 설정에서 권한을 허용해주세요.');
}

설명

이것이 하는 일: 권한 요청 프로세스는 현재 권한 상태를 먼저 확인하고, 필요한 경우에만 사용자에게 권한을 요청하며, 거부된 상황에 대한 대응책을 제공합니다. 코드를 단계별로 나누어 살펴볼까요?

첫 번째로, navigator.permissions.query({ name: 'camera' })를 호출하여 현재 카메라 권한 상태를 조회합니다. 이 메서드는 'granted'(허용됨), 'denied'(거부됨), 'prompt'(아직 묻지 않음) 중 하나의 상태를 반환합니다.

왜 이렇게 먼저 확인하냐면, 이미 허용된 권한이라면 다시 팝업을 띄울 필요가 없고, 이미 거부된 권한이라면 다시 getUserMedia를 호출해도 바로 에러가 발생하기 때문입니다. 그 다음으로, 상태에 따라 분기 처리를 합니다.

granted 상태라면 바로 카메라를 시작하고, denied 상태라면 사용자에게 "브라우저 설정에서 권한을 변경해주세요"라는 안내 메시지를 보여줍니다. prompt 상태일 때만 실제로 getUserMedia를 호출하여 권한 팝업이 나타나도록 합니다.

이 시점에서 사용자가 허용 버튼을 누르면 스트림을 받고, 거부 버튼을 누르면 NotAllowedError가 발생합니다. 마지막으로, catch 블록에서 에러를 처리합니다.

error.name을 확인하여 권한 거부(NotAllowedError)인지, 장치가 없는지(NotFoundError), 다른 문제인지 구분할 수 있습니다. 각 에러에 맞는 적절한 메시지를 사용자에게 보여주는 것이 중요합니다.

예를 들어, 권한 거부라면 "설정에서 변경하는 방법"을 안내하고, 장치가 없다면 "카메라를 연결해주세요"라고 안내해야 합니다. 여러분이 이 코드를 사용하면 권한 요청 거부율을 크게 낮출 수 있습니다.

실무에서의 이점으로는 첫째, 불필요한 팝업을 줄여 사용자 경험이 향상되고, 둘째, 거부된 경우 복구 방법을 제시하여 사용자가 포기하지 않게 하며, 셋째, 권한 상태를 미리 알고 있어 UI를 동적으로 조정할 수 있습니다.

실전 팁

💡 getUserMedia를 호출하기 전에 사용자에게 "화상 통화를 시작하려면 카메라 권한이 필요합니다"라는 설명을 먼저 보여주세요. 버튼을 클릭했을 때 권한을 요청하면 맥락이 명확해져 허용률이 높아집니다.

💡 권한이 거부된 경우, 브라우저별로 다른 안내를 제공하세요. Chrome은 주소창 왼쪽 아이콘, Firefox는 페이지 정보 버튼에서 권한을 변경할 수 있습니다. 스크린샷을 포함한 안내 페이지를 준비하면 더 좋습니다.

💡 Permissions API의 change 이벤트를 리스닝하면 권한 상태가 변경될 때 실시간으로 감지할 수 있습니다. permissionStatus.onchange = () => { ... }로 구현하여 사용자가 설정을 바꾸면 즉시 반영하세요.

💡 HTTPS가 아닌 환경에서는 권한 요청 자체가 불가능합니다. 개발 중에는 localhost를 사용하고, 배포 시에는 반드시 SSL 인증서를 적용하세요. Let's Encrypt로 무료로 발급받을 수 있습니다.

💡 테스트 시에는 시크릿 모드를 활용하세요. 일반 모드에서는 권한 상태가 캐시되어 있어 초기 사용자의 경험을 테스트하기 어렵습니다. 시크릿 모드는 항상 깨끗한 상태에서 시작됩니다.


4. 비디오 엘리먼트에 스트림 연결

시작하며

여러분이 getUserMedia로 스트림을 성공적으로 받았는데, 화면에 아무것도 보이지 않아 당황했던 경험이 있나요? 콘솔에는 에러도 없는데 비디오가 재생되지 않는 상황은 초보 개발자들이 자주 겪는 문제입니다.

이런 문제는 스트림을 받는 것과 그것을 실제로 화면에 표시하는 것이 별개의 작업이라는 점을 이해하지 못해서 발생합니다. 스트림 객체는 그 자체로는 눈에 보이지 않는 데이터 흐름일 뿐입니다.

이를 사용자가 볼 수 있게 만들려면 HTML video 엘리먼트와 연결해야 합니다. 바로 이럴 때 필요한 것이 비디오 엘리먼트와 스트림을 올바르게 연결하는 방법입니다.

srcObject 속성을 사용하고, 자동 재생을 설정하며, 적절한 속성들을 추가하여 완벽한 비디오 플레이어를 만들 수 있습니다.

개요

간단히 말해서, 비디오 엘리먼트에 스트림 연결은 MediaStream 객체를 HTML video 엘리먼트의 srcObject 속성에 할당하여 실시간 영상을 화면에 표시하는 작업입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 사용자는 자신의 얼굴이나 화면이 보여야만 "카메라가 제대로 작동하고 있다"고 느끼기 때문입니다.

화상 회의 앱에서 상대방 얼굴뿐만 아니라 내 얼굴도 미리보기로 보여주는 것이 일반적인 UX입니다. 예를 들어, Zoom의 가상 배경 설정 화면에서는 실시간으로 내 모습을 보면서 배경을 선택할 수 있죠.

기존에는 src 속성을 사용했지만, 이제는 srcObject를 사용하는 것이 표준입니다. src는 파일 URL용이고, srcObject는 MediaStream, MediaSource, Blob 같은 객체용입니다.

이 개념의 핵심 특징은 세 가지입니다: 첫째, srcObject는 실시간 스트림을 지연 없이 표시합니다. 둘째, autoplay 속성을 추가하면 별도의 play() 호출 없이 자동으로 재생됩니다.

셋째, muted 속성으로 에코를 방지할 수 있습니다(자기 목소리가 스피커로 나오는 것 방지). 이러한 특징들이 중요한 이유는 사용자가 클릭 한 번으로 바로 영상을 볼 수 있고, 피드백 루프를 방지하여 깔끔한 경험을 제공하기 때문입니다.

코드 예제

// 비디오 엘리먼트에 스트림을 연결하는 완전한 예제
async function setupVideoStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });

    // HTML에서 비디오 엘리먼트 가져오기
    const videoElement = document.getElementById('localVideo');

    // 스트림을 비디오 엘리먼트에 연결
    videoElement.srcObject = stream;

    // 비디오가 로드되면 자동으로 재생
    videoElement.onloadedmetadata = () => {
      videoElement.play();
      console.log('비디오 재생 시작!');
    };

    // 나중에 정리할 수 있도록 스트림 저장
    window.currentStream = stream;

  } catch (error) {
    console.error('스트림 설정 실패:', error);
  }
}

// 스트림 정리 함수
function stopVideoStream() {
  if (window.currentStream) {
    window.currentStream.getTracks().forEach(track => track.stop());
    document.getElementById('localVideo').srcObject = null;
  }
}

설명

이것이 하는 일: 비디오 엘리먼트 연결 프로세스는 스트림 데이터를 받아서 HTML 비디오 태그와 연결하고, 적절한 재생 설정을 하여 사용자에게 실시간 영상을 보여줍니다. 코드를 단계별로 나누어 살펴보겠습니다.

첫 번째로, document.getElementById('localVideo')로 HTML에서 비디오 엘리먼트를 찾습니다. HTML에는 <video id="localVideo" autoplay muted playsinline></video> 같은 태그가 있어야 합니다.

autoplay는 자동 재생, muted는 소리 끄기(자기 목소리 에코 방지), playsinline은 모바일에서 전체화면이 아닌 인라인 재생을 의미합니다. 왜 이렇게 하냐면, 특히 모바일 Safari에서 playsinline이 없으면 강제로 전체화면 모드로 전환되기 때문입니다.

그 다음으로, videoElement.srcObject = stream으로 스트림을 연결합니다. 이 순간부터 비디오 엘리먼트는 카메라로부터 들어오는 실시간 데이터를 받기 시작합니다.

하지만 자동으로 재생되지 않을 수 있으므로, onloadedmetadata 이벤트 리스너를 추가합니다. 이 이벤트는 비디오의 메타데이터(해상도, 길이 등)가 로드되었을 때 발생하며, 이 시점에 play()를 호출하면 확실하게 재생이 시작됩니다.

마지막으로, window.currentStream = stream으로 스트림을 전역 변수에 저장합니다. 이렇게 하는 이유는 나중에 사용자가 "카메라 끄기" 버튼을 눌렀을 때, 저장된 스트림의 모든 트랙을 stop()으로 종료할 수 있기 때문입니다.

getTracks()는 비디오 트랙과 오디오 트랙을 배열로 반환하며, forEach로 각각을 중지시킵니다. srcObject를 null로 설정하는 것도 잊지 마세요.

이렇게 해야 완전히 정리됩니다. 여러분이 이 코드를 사용하면 사용자가 자신의 카메라 영상을 실시간으로 볼 수 있습니다.

실무에서의 이점으로는 첫째, 사용자가 카메라 각도나 조명 상태를 미리 확인할 수 있고, 둘째, 가상 배경이나 필터 같은 효과를 적용할 때 즉시 결과를 볼 수 있으며, 셋째, 적절한 정리 로직으로 메모리 누수를 방지할 수 있습니다.

실전 팁

💡 반드시 video 태그에 muted 속성을 추가하세요. 자기 마이크 소리가 스피커로 나오면 피드백(하울링)이 발생하여 귀가 아플 수 있습니다. 로컬 프리뷰는 항상 음소거해야 합니다.

💡 모바일 환경에서는 playsinline 속성이 필수입니다. 이게 없으면 iOS Safari에서 비디오가 자동으로 전체화면으로 전환되어 UX가 망가집니다. 인라인 재생을 원한다면 꼭 추가하세요.

💡 비디오 엘리먼트에 CSS로 transform: scaleX(-1)을 적용하면 좌우 반전되어 거울처럼 보입니다. 전면 카메라 사용 시 사용자들은 거울에 익숙하므로 이렇게 반전하는 것이 자연스럽습니다.

💡 스트림을 사용하지 않을 때는 반드시 정리하세요. 페이지를 떠나거나 컴포넌트가 언마운트될 때 getTracks().forEach(track => track.stop())을 호출하지 않으면 카메라 LED가 계속 켜져 있습니다.

💡 비디오 엘리먼트의 width와 height는 CSS가 아닌 속성으로 설정하는 것이 좋습니다. <video width="640" height="480">처럼 설정하면 비율이 고정되어 왜곡이 방지됩니다. CSS로만 하면 aspect-ratio를 추가로 관리해야 합니다.


5. 디바이스 목록 가져오기

시작하며

여러분이 화상 회의 중에 "내 무선 이어폰으로 소리가 안 들려요"라는 사용자 불만을 받은 적이 있나요? 또는 노트북에 여러 개의 카메라(내장 카메라, 외장 웹캠)가 연결되어 있는데 사용자가 원하는 카메라를 선택하지 못해 불편을 겪는 상황을 본 적이 있으신가요?

이런 문제는 사용자에게 디바이스 선택권을 주지 않았을 때 발생합니다. 대부분의 사용자는 여러 개의 오디오/비디오 입출력 장치를 가지고 있습니다.

노트북 내장 카메라, USB 웹캠, 블루투스 이어폰, 외장 마이크 등 선택지가 많은데, 개발자가 임의로 하나만 선택하면 사용자 경험이 크게 떨어집니다. 바로 이럴 때 필요한 것이 enumerateDevices() API입니다.

이를 통해 사용자의 모든 미디어 디바이스 목록을 가져오고, 사용자가 직접 선택할 수 있는 UI를 제공하여 최적의 경험을 만들 수 있습니다.

개요

간단히 말해서, enumerateDevices()는 사용자 컴퓨터에 연결된 모든 카메라, 마이크, 스피커 목록을 배열로 반환하는 메서드입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프로 유튜버는 고가의 외장 마이크를 사용하고, 게이머는 특정 헤드셋을 선호하며, 재택근무자는 특정 웹캠을 사용하고 싶어 하기 때문입니다.

Google Meet, Zoom, Discord 같은 모든 전문 화상 서비스는 반드시 디바이스 선택 기능을 제공합니다. 예를 들어, 회의 시작 전 설정 화면에서 "카메라: Logitech C920", "마이크: AirPods Pro"처럼 선택할 수 있죠.

기존에는 브라우저가 자동으로 선택한 기본 디바이스만 사용했다면, 이제는 모든 디바이스를 나열하고 사용자가 원하는 것을 선택할 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다: 첫째, 세 종류의 디바이스를 구분합니다(videoinput: 카메라, audioinput: 마이크, audiooutput: 스피커).

둘째, 각 디바이스는 고유한 deviceId를 가지고 있어 특정 디바이스를 지정할 수 있습니다. 셋째, label(디바이스 이름)은 권한이 허용된 후에만 표시됩니다.

이러한 특징들이 중요한 이유는 사용자가 자신의 환경에 맞는 최적의 하드웨어를 선택하여 최상의 품질을 얻을 수 있기 때문입니다.

코드 예제

// 모든 미디어 디바이스를 가져와서 선택 UI 만들기
async function listMediaDevices() {
  try {
    // 먼저 권한을 얻어야 디바이스 이름(label)을 볼 수 있음
    await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

    // 모든 디바이스 목록 가져오기
    const devices = await navigator.mediaDevices.enumerateDevices();

    // 디바이스 종류별로 분류
    const videoDevices = devices.filter(d => d.kind === 'videoinput');
    const audioInputs = devices.filter(d => d.kind === 'audioinput');
    const audioOutputs = devices.filter(d => d.kind === 'audiooutput');

    console.log('카메라:', videoDevices);
    console.log('마이크:', audioInputs);
    console.log('스피커:', audioOutputs);

    // HTML select 엘리먼트에 옵션 추가
    const cameraSelect = document.getElementById('cameraSelect');
    videoDevices.forEach(device => {
      const option = document.createElement('option');
      option.value = device.deviceId;
      option.text = device.label || `카메라 ${videoDevices.indexOf(device) + 1}`;
      cameraSelect.appendChild(option);
    });

  } catch (error) {
    console.error('디바이스 목록 가져오기 실패:', error);
  }
}

설명

이것이 하는 일: enumerateDevices()는 시스템에 연결된 모든 미디어 장치 정보를 배열로 반환하며, 각 장치의 종류, ID, 이름을 제공하여 사용자가 선택할 수 있게 합니다. 코드를 단계별로 나누어 살펴보겠습니다.

첫 번째로, getUserMedia를 먼저 호출합니다. 이게 왜 필요하냐면, 보안상의 이유로 미디어 권한이 허용되기 전에는 디바이스의 label(이름)이 빈 문자열로 표시되기 때문입니다.

권한 없이 enumerateDevices를 호출하면 "카메라 1", "카메라 2" 같은 일반적인 이름만 보이고, "Logitech C920", "FaceTime HD Camera" 같은 실제 이름은 보이지 않습니다. 따라서 먼저 권한을 받는 것이 중요합니다.

그 다음으로, navigator.mediaDevices.enumerateDevices()를 호출하면 MediaDeviceInfo 객체들의 배열이 반환됩니다. 각 객체는 { deviceId, kind, label, groupId } 속성을 가집니다.

kind는 'videoinput', 'audioinput', 'audiooutput' 중 하나입니다. filter() 메서드로 종류별로 분류하면 카메라만, 마이크만, 스피커만 따로 관리할 수 있습니다.

이렇게 분류하는 이유는 UI에서 "카메라 선택", "마이크 선택" 드롭다운을 각각 만들기 위함입니다. 마지막으로, HTML select 엘리먼트에 동적으로 option을 추가합니다.

device.label이 있으면 실제 디바이스 이름을 표시하고, 없으면 "카메라 1", "카메라 2" 같은 대체 이름을 만듭니다. device.deviceId를 option의 value로 설정하는 것이 매우 중요합니다.

나중에 사용자가 특정 디바이스를 선택했을 때, 이 deviceId를 getUserMedia의 제약 조건에 전달하면 정확히 그 디바이스를 사용할 수 있습니다. 여러분이 이 코드를 사용하면 사용자가 자신의 모든 미디어 장치를 확인하고 선택할 수 있습니다.

실무에서의 이점으로는 첫째, 고품질 외장 장비를 가진 사용자가 그것을 활용할 수 있고, 둘째, 내장 카메라가 고장난 경우 외장 웹캠으로 전환할 수 있으며, 셋째, 블루투스 이어폰 사용자가 원하는 오디오 출력을 선택할 수 있습니다.

실전 팁

💡 enumerateDevices()를 호출하기 전에 반드시 getUserMedia로 권한을 먼저 받으세요. 그렇지 않으면 label이 빈 문자열이어서 "카메라 1, 카메라 2"처럼 구분이 안 되고, 사용자가 어떤 것이 무엇인지 알 수 없습니다.

💡 디바이스 목록은 동적으로 변할 수 있습니다. devicechange 이벤트를 리스닝하여 USB 카메라를 연결하거나 블루투스 이어폰을 연결했을 때 실시간으로 목록을 업데이트하세요. navigator.mediaDevices.ondevicechange = listMediaDevices 형태로 구현할 수 있습니다.

💡 선택한 deviceId를 localStorage에 저장하면 다음 방문 시 사용자의 선호 디바이스를 자동으로 선택할 수 있습니다. 단, deviceId는 브라우저 세션마다 바뀔 수 있으므로, label과 함께 저장하여 매칭하는 것이 더 안전합니다.

💡 스피커 선택 기능(audiooutput)은 Chrome과 Edge에서만 지원됩니다. Firefox와 Safari는 아직 지원하지 않으므로, setSinkId() 메서드 사용 전에 if ('setSinkId' in HTMLMediaElement.prototype)로 확인하세요.

💡 모바일 환경에서는 디바이스 수가 적으므로 선택 UI를 조건부로 표시하세요. 카메라가 하나뿐이라면 선택 드롭다운을 숨기고 자동으로 사용하는 것이 더 깔끔한 UX입니다.


6. 에러 핸들링

시작하며

여러분이 화상 회의 앱을 런칭했는데, 사용자들이 "작동하지 않아요"라는 막연한 피드백만 남기고 떠나버린 경험이 있나요? 콘솔을 확인해보니 NotFoundError, NotAllowedError, NotReadableError 같은 다양한 에러들이 발생하고 있는데, 사용자에게는 아무런 안내도 보여주지 않았던 것이죠.

이런 문제는 getUserMedia가 실패하는 다양한 이유들을 제대로 처리하지 않았을 때 발생합니다. 카메라가 없는 데스크톱, 권한을 거부한 사용자, 다른 앱이 카메라를 사용 중인 경우, 하드웨어 오류 등 수많은 실패 시나리오가 있습니다.

각각에 맞는 적절한 에러 메시지와 해결 방법을 제시하지 않으면 사용자는 그냥 포기합니다. 바로 이럴 때 필요한 것이 체계적인 에러 핸들링입니다.

각 에러 타입을 구분하고, 사용자가 이해할 수 있는 언어로 설명하며, 해결 방법을 제시하여 사용자가 스스로 문제를 해결할 수 있게 도와야 합니다.

개요

간단히 말해서, getUserMedia 에러 핸들링은 미디어 접근이 실패했을 때 발생하는 다양한 에러들을 구분하고, 각각에 맞는 친절한 안내를 사용자에게 제공하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 에러 메시지 하나가 사용자 유지율을 크게 좌우하기 때문입니다.

"Error: Permission denied"라고만 보여주면 사용자는 어떻게 해야 할지 모릅니다. 하지만 "카메라 권한이 거부되었습니다.

브라우저 주소창 왼쪽의 카메라 아이콘을 클릭하여 권한을 허용해주세요"라고 안내하면 80% 이상의 사용자가 해결할 수 있습니다. 예를 들어, Zoom은 에러 상황마다 스크린샷과 함께 단계별 해결 방법을 보여줍니다.

기존에는 단순히 catch 블록에서 console.error만 찍었다면, 이제는 error.name을 확인하여 NotAllowedError, NotFoundError, NotReadableError 등을 구분하고 각각 다른 메시지를 보여줄 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다: 첫째, 에러 타입이 표준화되어 있어 일관되게 처리할 수 있습니다.

둘째, 각 에러는 명확한 원인을 나타내므로 적절한 해결책을 제시할 수 있습니다. 셋째, 사용자 친화적인 메시지로 변환하면 문제 해결률이 크게 향상됩니다.

이러한 특징들이 중요한 이유는 기술적 에러를 일반 사용자가 이해하고 해결할 수 있는 형태로 변환하여, 서비스 이탈을 막고 사용자 만족도를 높이기 때문입니다.

코드 예제

// 모든 에러 타입을 처리하는 완전한 예제
async function handleGetUserMedia() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    document.getElementById('myVideo').srcObject = stream;
    showSuccessMessage('카메라와 마이크가 정상적으로 연결되었습니다!');

  } catch (error) {
    console.error('getUserMedia 에러:', error);

    // 에러 타입별로 사용자 친화적인 메시지 생성
    let userMessage = '';
    let solutions = [];

    switch(error.name) {
      case 'NotAllowedError':
        userMessage = '카메라/마이크 권한이 거부되었습니다.';
        solutions = [
          '브라우저 주소창 왼쪽의 카메라 아이콘을 클릭하세요',
          '권한 설정을 "허용"으로 변경하세요',
          '페이지를 새로고침한 후 다시 시도하세요'
        ];
        break;

      case 'NotFoundError':
        userMessage = '카메라 또는 마이크를 찾을 수 없습니다.';
        solutions = [
          '카메라와 마이크가 컴퓨터에 연결되어 있는지 확인하세요',
          '노트북의 경우 내장 카메라가 활성화되어 있는지 확인하세요',
          '장치 관리자에서 드라이버가 정상인지 확인하세요'
        ];
        break;

      case 'NotReadableError':
        userMessage = '카메라/마이크에 접근할 수 없습니다.';
        solutions = [
          '다른 앱(Zoom, Skype 등)이 카메라를 사용 중이면 종료하세요',
          '카메라를 다시 연결해보세요',
          '컴퓨터를 재시작해보세요'
        ];
        break;

      case 'OverconstrainedError':
        userMessage = '요청한 미디어 설정을 지원하지 않습니다.';
        solutions = [
          '더 낮은 해상도로 다시 시도하세요',
          '디바이스가 요청한 제약 조건을 지원하는지 확인하세요'
        ];
        break;

      case 'SecurityError':
        userMessage = '보안 오류가 발생했습니다.';
        solutions = [
          '이 사이트는 반드시 HTTPS로 접속해야 합니다',
          '주소창을 확인하여 https://로 시작하는지 확인하세요'
        ];
        break;

      default:
        userMessage = '알 수 없는 오류가 발생했습니다.';
        solutions = [
          '페이지를 새로고침하여 다시 시도하세요',
          '브라우저를 업데이트해보세요',
          '다른 브라우저로 시도해보세요'
        ];
    }

    // 사용자에게 에러와 해결 방법 표시
    showErrorMessage(userMessage, solutions);
  }
}

function showErrorMessage(message, solutions) {
  const errorDiv = document.getElementById('errorMessage');
  errorDiv.innerHTML = `
    <h3>${message}</h3>
    <p>해결 방법:</p>
    <ul>
      ${solutions.map(s => `<li>${s}</li>`).join('')}
    </ul>
  `;
  errorDiv.style.display = 'block';
}

설명

이것이 하는 일: 에러 핸들링 시스템은 getUserMedia 실패 시 발생하는 다양한 에러를 분류하고, 각 에러의 원인을 사용자가 이해할 수 있는 언어로 설명하며, 구체적인 해결 단계를 제시합니다. 코드를 단계별로 나누어 살펴보겠습니다.

첫 번째로, try-catch 블록으로 getUserMedia 호출을 감쌉니다. 성공하면 try 블록이 실행되고, 실패하면 catch 블록으로 넘어갑니다.

catch 블록에서 error 객체를 받는데, 이 객체의 name 속성이 에러 타입을 나타냅니다. console.error로 개발자용 로그를 남기는 것도 잊지 마세요.

나중에 사용자 문의가 왔을 때 로그를 보면 원인을 파악하기 쉽습니다. 그 다음으로, switch 문으로 error.name을 구분합니다.

NotAllowedError는 사용자가 권한을 거부했거나, 브라우저 정책에 의해 차단된 경우입니다. 이때는 "권한 설정을 바꾸는 방법"을 안내합니다.

NotFoundError는 카메라나 마이크가 물리적으로 존재하지 않거나 감지되지 않을 때 발생합니다. "장치를 연결하세요"라고 안내하는 것이 적절합니다.

NotReadableError는 장치가 있지만 접근할 수 없을 때인데, 주로 다른 앱이 이미 사용 중이거나 하드웨어 오류입니다. "다른 앱을 종료하세요"라고 안내합니다.

마지막으로, OverconstrainedError는 요청한 제약 조건을 만족하는 디바이스가 없을 때 발생합니다. 예를 들어, 4K 해상도를 요청했는데 카메라가 1080p까지만 지원하는 경우입니다.

"더 낮은 해상도로 시도하세요"라고 안내합니다. SecurityError는 HTTP 사이트에서 접근했을 때 발생하므로 "HTTPS를 사용하세요"라고 명확히 알려줍니다.

각 에러마다 단계별 해결 방법(solutions 배열)을 제공하여, 사용자가 순서대로 시도할 수 있게 합니다. 여러분이 이 코드를 사용하면 사용자 대부분이 스스로 문제를 해결할 수 있습니다.

실무에서의 이점으로는 첫째, 고객 지원 문의가 크게 줄어들고, 둘째, 사용자 이탈률이 감소하며, 셋째, 서비스 신뢰도가 향상됩니다. 특히 비기술적인 사용자들이 "이 서비스는 친절하고 잘 만들어졌다"고 느끼게 됩니다.

실전 팁

💡 에러 메시지는 항상 "무엇이 잘못되었는지"와 "어떻게 고칠 수 있는지" 두 가지를 포함해야 합니다. "에러 발생"만 보여주면 사용자는 무력감을 느끼고 떠납니다. 구체적인 행동 지침을 주세요.

💡 브라우저별로 권한 설정 위치가 다르므로, navigator.userAgent로 브라우저를 감지하여 맞춤형 안내를 제공하세요. Chrome은 "주소창 왼쪽 아이콘", Firefox는 "페이지 정보 버튼"처럼 다릅니다.

💡 스크린샷이나 애니메이션 GIF를 함께 제공하면 해결률이 2배 이상 높아집니다. "브라우저 설정에서 권한 변경" 같은 말보다 실제 화면 캡처를 보여주는 것이 훨씬 효과적입니다.

💡 에러를 분석 도구(Google Analytics, Sentry 등)로 전송하여 어떤 에러가 가장 많이 발생하는지 추적하세요. NotAllowedError가 많다면 권한 요청 UX를 개선해야 한다는 신호입니다.

💡 중요한 서비스라면 "고객 지원 채팅" 버튼을 에러 메시지에 함께 표시하세요. 사용자가 스스로 해결하지 못했을 때 즉시 도움을 요청할 수 있는 경로를 제공하면 이탈을 막을 수 있습니다.


#WebRTC#getUserMedia#MediaStream#MediaDevices#VideoAudio#WebRTC,미디어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.