공부 흔적남기기

브라우저 웹캠 영상을 MJPEG로 스트리밍하기 (1) 본문

카테고리 없음

브라우저 웹캠 영상을 MJPEG로 스트리밍하기 (1)

65살까지 코딩 2026. 9. 30. 20:07
728x90
반응형

MediaDevices.getUserMedia()와 Canvas를 이용한 간단한 웹캠 스트리밍

웹캠 영상을 노트북에서 모바일 브라우저로 실시간 전송하는 프로젝트를 시작했다.

최종적으로는 단순한 영상 스트리밍뿐만 아니라 영상 녹화 및 최대 3일간의 저장까지 구현해보는 것이 목표다.

처음부터 H.264나 WebRTC 같은 기술을 사용하기보다는, 영상 스트리밍의 기본적인 동작을 이해하기 위해 가장 단순한 MJPEG 방식부터 구현해봤다.

전체 구조는 다음과 같다.

웹캠
  ↓
getUserMedia()
  ↓
MediaStream
  ↓
<video>
  ↓
Canvas
  ↓
JPEG
  ↓
HTTP POST
  ↓
NestJS
  ↓
MJPEG Stream
  ↓
모바일 브라우저

1. 프로젝트 구성

프로젝트는 React와 NestJS를 분리해서 구성했다.

mk-webcam-stream/
├── media-server/     # NestJS
└── web-client/       # React + TypeScript

처음에는 별도의 미디어 서버 솔루션을 사용하지 않고, 직접 NestJS 서버에서 프레임을 받아 스트리밍해보는 것을 목표로 했다.


2. 웹캠 접근하기

브라우저에서 웹캠에 접근하기 위해 MediaDevices.getUserMedia()를 사용했다.

const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    width: { ideal: 2560 },
    height: { ideal: 1440 },
  },
  audio: false,
});

getUserMedia()는 사용자의 카메라와 마이크 같은 미디어 입력 장치에 접근하기 위한 브라우저 API다.

이번 프로젝트에서는 영상만 필요하기 때문에:

audio: false

로 설정했다.


3. MediaStream을 video에 연결하기

getUserMedia()의 결과는 MediaStream이다.

이를 <video> 요소의 srcObject에 연결했다.

const videoRef = useRef<HTMLVideoElement>(null);

그리고:

if (videoRef.current) {
  videoRef.current.srcObject = stream;
}

React에서는 다음과 같이 사용할 수 있다.

<video
  ref={videoRef}
  autoPlay
  muted
  playsInline
/>

여기서 중요한 점은 일반적인 src 속성이 아니라 srcObject를 사용한다는 것이다.

MediaStream
    ↓
video.srcObject
    ↓
<video>

이제 브라우저에서 웹캠 화면을 실시간으로 확인할 수 있다.


4. 웹캠 종료 처리

웹캠을 사용한 뒤에는 반드시 MediaStream의 트랙을 종료해야 한다.

return () => {
  stream?.getTracks().forEach((track) => track.stop());
};

이렇게 하지 않으면 페이지를 이동하거나 컴포넌트를 제거한 이후에도 카메라가 계속 사용되는 문제가 발생할 수 있다.

실제로 개발하면서 웹캠을 사용하는 페이지가 여러 개 실행된 상태에서 다음과 같은 에러도 경험했다.

NotReadableError: Device in use

다른 프로그램이나 브라우저 탭에서 카메라를 사용하고 있으면 발생할 수 있는 에러였다.


5. 웹캠 영상에서 JPEG 만들기

웹캠 영상을 다른 브라우저로 보내기 위해서는 프레임 데이터를 추출해야 한다.

가장 간단한 방법 중 하나가 Canvas를 이용하는 것이다.

const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");

웹캠 영상의 크기에 맞춰 Canvas 크기를 설정한다.

canvas.width = video.videoWidth;
canvas.height = video.videoHeight;

그리고 현재 <video>의 화면을 Canvas에 그린다.

context?.drawImage(
  video,
  0,
  0,
  canvas.width,
  canvas.height
);

이제 Canvas에는 현재 웹캠 프레임이 이미지 형태로 그려져 있다.


6. Canvas를 JPEG로 변환

Canvas에 그려진 이미지를 JPEG Blob으로 변환했다.

canvas.toBlob(
  (blob) => {
    if (!blob) {
      return;
    }

    // JPEG 데이터
  },
  "image/jpeg",
  0.7
);

마지막 0.7은 JPEG 품질이다.

0.0 → 낮은 품질 / 작은 파일
1.0 → 높은 품질 / 큰 파일

실제 테스트에서는 640×480 영상에 품질 0.7 정도를 사용했을 때 JPEG 한 장이 대략 28~29KB 정도 나왔다.


7. JPEG 프레임을 서버로 전송

생성한 Blob을 NestJS 서버로 전송했다.

await fetch("/frame", {
  method: "POST",
  headers: {
    "Content-Type": "image/jpeg",
  },
  body: blob,
});

이렇게 하면 서버에서는 HTTP 요청의 Body에 JPEG 바이너리가 들어온다.

전체적인 흐름은 다음과 같다.

<video>
   ↓
Canvas
   ↓
drawImage()
   ↓
toBlob()
   ↓
JPEG Blob
   ↓
HTTP POST
   ↓
NestJS

8. NestJS에서 JPEG 받기

NestJS에서는 /frame API를 만들고 요청 Body의 JPEG 데이터를 받도록 했다.

개념적으로는 다음과 같은 구조다.

@Post("frame")
async receiveFrame(@Req() req: Request) {
  const chunks: Buffer[] = [];

  for await (const chunk of req) {
    chunks.push(Buffer.from(chunk));
  }

  const frame = Buffer.concat(chunks);

  // 최신 JPEG 프레임 저장
}

JPEG는 결국 바이너리 데이터이기 때문에 Node.js에서는 Buffer로 처리할 수 있다.


9. 최신 프레임 저장

처음 구현에서는 복잡한 저장소 없이 서버 메모리에 가장 최신 JPEG 한 장을 가지고 있도록 했다.

개념적으로:

let latestFrame: Buffer | null = null;

그리고 /frame으로 JPEG가 들어오면:

latestFrame = frame;

으로 교체한다.

즉 서버에는 항상:

현재 가장 최신 JPEG

하나만 존재한다.


10. MJPEG란?

이제 이 JPEG들을 모바일 브라우저에서 계속 보여줘야 한다.

여기서 사용한 것이 MJPEG(Motion JPEG) 방식이다.

MJPEG는 말 그대로 여러 개의 JPEG 이미지를 연속적으로 전송하는 방식이다.

일반적인 영상 코덱처럼 프레임 간 차이를 압축하는 것이 아니라:

JPEG
JPEG
JPEG
JPEG
JPEG
...

각 프레임을 독립적인 이미지로 전송한다.


11. multipart/x-mixed-replace

MJPEG 스트리밍에서는 HTTP의 multipart/x-mixed-replace Content-Type을 사용한다.

응답 Header는 다음과 같이 설정했다.

res.writeHead(200, {
  "Content-Type": "multipart/x-mixed-replace; boundary=frame",
  "Cache-Control": "no-cache",
  Connection: "keep-alive",
});

여기서 중요한 것이 boundary다.

각 JPEG 프레임을 구분하기 위한 경계값이다.


12. JPEG 프레임 전송

각 프레임은 대략 다음과 같은 형태로 전송한다.

--frame
Content-Type: image/jpeg

[JPEG 바이너리]

--frame
Content-Type: image/jpeg

[JPEG 바이너리]

--frame
...

NestJS에서는:

res.write(
  `--frame\r\n` +
  `Content-Type: image/jpeg\r\n` +
  `Content-Length: ${frame.length}\r\n\r\n`
);

res.write(frame);
res.write("\r\n");

이런 방식으로 JPEG를 계속 이어 붙인다.


13. 모바일 브라우저에서 보기

MJPEG의 재미있는 점은 별도의 비디오 플레이어 없이도 <img> 태그로 볼 수 있다는 것이다.

<img src="http://localhost:3000/stream" />

브라우저는 HTTP 응답을 계속 받으면서 boundary를 기준으로 JPEG 이미지를 계속 교체한다.

결과적으로 사용자 입장에서는:

JPEG 1
 ↓
JPEG 2
 ↓
JPEG 3
 ↓
JPEG 4
 ↓
...

가 빠르게 교체되면서 영상처럼 보인다.


14. 전체 구조

최종적으로 구현한 구조는 다음과 같다.

┌──────────────────────────┐
│       Laptop Browser     │
│                          │
│  Webcam                  │
│    ↓                     │
│  getUserMedia()          │
│    ↓                     │
│  MediaStream             │
│    ↓                     │
│  <video>                 │
│    ↓                     │
│  Canvas                  │
│    ↓                     │
│  JPEG Blob               │
└────────────┬─────────────┘
             │
             │ HTTP POST
             ▼
┌──────────────────────────┐
│       NestJS Server      │
│                          │
│  /frame                  │
│    ↓                     │
│  latestFrame             │
│    ↓                     │
│  /stream                 │
│    ↓                     │
│  multipart/x-mixed       │
└────────────┬─────────────┘
             │
             │ HTTP Stream
             ▼
┌──────────────────────────┐
│      Mobile Browser      │
│                          │
│        <img>             │
│                          │
│      MJPEG Video         │
└──────────────────────────┘

15. JPEG 전송량 확인

실제 테스트에서 640×480 해상도, JPEG quality 0.7 기준으로 한 프레임이 약 28~29KB 정도였다.

약 10FPS로 전송한다고 하면:

28.5KB × 10
= 285KB/s

이를 비트 단위로 바꾸면:

285KB × 8
≈ 2.28Mbps

한 시간 동안 계속 전송한다고 가정하면:

285KB × 60 × 60
≈ 1,026,000KB
≈ 약 1GB

정도가 된다.

물론 실제 JPEG 크기는 화면의 복잡도, 해상도, 품질 설정 등에 따라 계속 달라진다.


16. MJPEG의 한계

MJPEG는 구현이 매우 단순하다는 장점이 있다.

하지만 각 프레임을 JPEG로 독립적으로 압축하기 때문에 영상 스트리밍에는 비효율적인 부분이 있다.

예를 들어 카메라가 거의 움직이지 않는 상황에서도:

Frame 1 → JPEG 전체
Frame 2 → JPEG 전체
Frame 3 → JPEG 전체
Frame 4 → JPEG 전체

를 계속 전송해야 한다.

프레임 간 변화가 거의 없어도 이전 프레임을 활용하지 않는다.

이 때문에 동일한 영상을 더 적은 데이터로 전송하기 위해서는 H.264 같은 영상 코덱을 사용하는 것이 적합하다.


17. 다음 단계 — H.264

MJPEG 구현을 완료한 뒤 다음으로 WebCodecs API의 VideoEncoder를 이용한 H.264 인코딩을 구현했다.

구조는 다음과 같이 변경된다.

기존 MJPEG

Webcam
 ↓
VideoFrame
 ↓
Canvas
 ↓
JPEG
 ↓
HTTP
 ↓
Server


H.264

Webcam
 ↓
VideoFrame
 ↓
VideoEncoder
 ↓
H.264 EncodedVideoChunk
 ↓
WebSocket
 ↓
Server

MJPEG에서는 매 프레임을 JPEG로 압축했다면 H.264에서는 KEY Frame과 DELTA Frame을 이용해 프레임 간 정보를 활용한다.

다음 글에서는 브라우저의 WebCodecs VideoEncoder를 사용해 웹캠 영상을 H.264로 직접 인코딩하는 과정을 정리해볼 예정이다.

https://github.com/minkik715/mk-webcam-stream

728x90
반응형