| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- spring mongodb switch
- ipfs singletone
- java 1509
- spring mongodb
- kotiln const
- rabbitmq 싱글톤
- 안정해시
- 자바 백준 팩토리얼 개수
- Spring ipfs
- go
- 백준 연결요소 자바
- javav 1676
- kotiln const val
- 익명 객체 @transactional
- 백준 특정한 최단 경로
- ipfs bean
- Java Call By Refernce
- java 백준 1509
- java 1238
- nodejs rabbitmq
- 백준 1504 java
- spring mongoTemplate
- 백준 2252 줄세우기
- mongodb lookup
- java 파티
- kotiln functional interface
- Claude Intelij 연결
- java 팩토리얼 개수
- 자바 1676
- spring mongoTemplate switch
- Today
- Total
공부 흔적남기기
브라우저 웹캠 영상을 MJPEG로 스트리밍하기 (1) 본문
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