AI 감시 카메라(AI CCTV) 시스템을 직접 구축하다 보면 화면에 실제 영상이 아니라 “카메라 연결 대기 화면” 텍스트만 계속 뜨는 상황을 만나게 됩니다. 특히 WebSocket 기반 영상 스트리밍을 붙이고, 안 되니까 MJPEG 스트림으로 전환해봐도 여전히 같은 화면만 반복된다면 원인이 스트리밍 프로토콜 자체가 아니라 훨씬 더 앞단, 즉 브라우저의 카메라 권한 처리 로직에 있을 가능성이 큽니다.
이번 글에서는 실제 AI CCTV 카메라 프로젝트에서 발생한 웹소켓 영상 스트리밍 실패 사례를 근본 원인부터 최종 해결까지 전체 과정을 정리합니다. getUserMedia, Permissions API, Canvas와 Video 태그의 차이, FastAPI 서버와의 프레임 업로드/스트리밍 구조까지 바로 적용할 수 있는 구체적인 코드와 함께 다룹니다.
1. AI CCTV 카메라 왜 이 문제가 까다로운가
AI CCTV, 스마트 홈 모니터링, YOLO 기반 객체 감지 시스템처럼 브라우저에서 실시간 영상을 받아 서버로 전송하고 AI 분석까지 수행하는 구조는 다음과 같은 여러 계층이 겹쳐 있습니다.
- 브라우저 권한 계층: 카메라 장치에 접근할 수 있는가
- 캡처 계층: 영상을 어떤 방식(Canvas vs Video)으로 화면에 표시하는가
- 전송 계층: WebSocket 또는 MJPEG 중 어떤 방식으로 서버에 데이터를 보내는가
- 서버 처리 계층: FastAPI가 받은 프레임을 어떻게 저장하고 YOLO 감지에 활용하는가
문제는 이 네 계층 중 어디서 문제가 생겨도 사용자 화면에는 똑같이 “카메라 연결 대기” 화면만 뜬다는 점입니다. 그래서 로그를 잘못 해석하면 스트리밍 프로토콜(WebSocket ↔ MJPEG) 문제로 오인하기 쉽지만, 실제로는 그보다 훨씬 앞단인 카메라 권한 요청이 누락된 경우가 많습니다.
2. 문제 증상: AI CCTV 카메라 연결 대기 화면과 513 bytes 오류
실제 발생한 초기 증상은 다음과 같았습니다.
API: https://ai.qcai.kr
WS: wss://ai.qcai.kr
WS 연결됨
Canvas 크기: 843 x 474 (수시로 변경)
너무 작은 프레임: 513 bytes서버 로그에서는 AI(LLAMA 기반 비전 분석)가 다음과 같이 응답하고 있었습니다.
[LLAMA] 분석 완료 (길이: 44)
[LLAMA] 내용: 이미지에는 사람이나 사물이 감지되지 않았으며,
카메라 연결 대기 화면만 표시됨.사용자 화면에서 관찰된 증상은 세 가지로 정리됩니다.
- 검은 화면 또는 “카메라 연결 대기” 텍스트만 출력
- 실제 카메라 영상이 전혀 표시되지 않음
- AI 분석 결과가 “카메라 연결 대기 화면만 표시됨”으로 나옴
여기서 눈에 띄는 두 가지 이상 신호는 WebSocket 연결 자체는 성공( WS 연결됨)했다는 점과, 전송되는 프레임 크기가 513 bytes로 비정상적으로 작다는 점입니다. WebSocket 연결이 정상인데 실제 영상은 안 나온다는 것은, “연결” 문제가 아니라 “보내는 데이터의 내용” 문제라는 것을 암시합니다.
3. AI CCTV 카메라 근본 원인 분석: 왜 카메라 연결 대기 화면만
증상을 역추적하면 총 4가지 원인이 얽혀 있었습니다. 발견된 순서가 아니라, 문제가 발생한 논리적 흐름 순서로 재구성하면 다음과 같습니다. (가장 근본적인 원인 → 그로 인해 파생되는 증상 순)
원인 1 (가장 치명적): 카메라 권한 요청 누락
가장 근본적인 원인은 프론트엔드에서 navigator.mediaDevices.getUserMedia() 호출이 아예 없었다는 점입니다. 즉, 브라우저에 카메라 접근 권한을 요청하는 코드 자체가 빠져 있었습니다.
서버 로그: "카메라 연결 대기 화면만 표시됨"분석:
- 프론트엔드에서
navigator.mediaDevices.getUserMedia()호출이 없음 - 브라우저에 카메라 접근 권한을 요청하지 않음
- 서버는 실제 영상이 없으니 “카메라 연결 대기”라는 더미(dummy) 화면만 계속 전송
실제 흐름:
사용자 → 브라우저 (권한 없음) → 서버 (더미 프레임) → 사용자 (대기 화면)카메라 권한이 없으니 브라우저는 실제 영상 프레임을 얻을 방법이 없고, 이 상태가 그대로 서버까지 전달되어 서버는 “카메라가 연결되지 않았다”는 안내용 더미 이미지만 계속 만들어 보내는 구조였습니다.
원인 2: 서버가 더미 프레임을 전송 → 프레임 크기 513 bytes로 급감
원인 1의 직접적인 결과로, 서버의 latest_frame 변수가 실제 영상 데이터를 받지 못해 None 상태를 유지하고, 이 때문에 더미 프레임이 생성되어 전송되는 구조가 만들어졌습니다.
서버(latest_frame) → None → 더미 프레임 생성 → 513 bytes 전송프론트엔드 코드에는 다음과 같은 방어 로직이 있었습니다.
// WebSocket 수신 데이터 크기 확인
if (event.data.size < 500) {
console.warn('너무 작은 프레임:', event.data.size);
return; // 513 bytes → 처리 중단!
}분석:
- 정상적인 JPEG 이미지는 최소 20KB ~ 수백 KB 수준
- 513 bytes는 실제 영상이 아니라 더미 데이터 또는 메타데이터 수준의 용량
- 서버가 실제 영상 대신 “카메라 연결 대기” 화면만 전송하고 있었다는 확실한 증거
즉, 513 bytes라는 숫자 자체가 “실제 카메라 프레임이 아니라 안내 텍스트/더미 이미지”라는 강력한 신호였던 것입니다. 실무에서 WebSocket이나 HTTP로 이미지를 주고받을 때, 수신 데이터 크기가 일반적인 JPEG 압축 이미지 크기(수십 KB 이상)보다 극단적으로 작다면 “연결은 되었지만 내용이 비어 있다”는 신호로 해석하는 습관이 필요합니다.
원인 3: Canvas 크기 수시 변경으로 인한 렌더링 중단
여기에 더해, 화면 렌더링 계층에서도 별도의 문제가 있었습니다. Canvas 크기가 프레임마다 계속 바뀌는 문제입니다.
Canvas 크기: 843 x 474
Canvas 크기: 571 x 321
Canvas 크기: 527 x 296 ← 프레임마다 변경!분석:
setupCanvas()함수가 매 프레임마다 호출됨- Canvas 크기가 변경될 때마다 내부적으로 Context가 초기화됨
- 결과적으로 화면 깜빡임 및 렌더링 중단이 반복됨
문제가 된 코드는 다음과 같습니다.
// 프레임마다 Canvas 크기 재설정
function setupCanvas() {
const rect = container.getBoundingClientRect();
canvas.width = rect.width; // 매번 변경됨
canvas.height = rect.height;
}HTML <canvas> 요소는 width/height 속성이 바뀌면 캔버스 내부 비트맵 자체가 초기화(clear)되는 특성이 있습니다. 따라서 컨테이너 크기를 매 프레임마다 다시 측정해서 Canvas 크기를 갱신하는 구조는, 매번 그림을 그렸다가 지우는 것과 같은 결과를 만들어 깜빡임과 렌더링 중단을 유발합니다.
원인 4: WebSocket과 MJPEG 스트림 혼선
마지막으로, 스트리밍 프로토콜 설계 자체에서도 혼선이 있었습니다.
분석:
- WebSocket과 MJPEG을 동시에 사용하려다 두 방식이 충돌
- 서버는 WebSocket 스트림 자체는 지원하지만 실제 영상 데이터를 보내지 않는 상태
- 프론트는 WebSocket으로 연결은 했지만 Canvas 렌더링이 실패하는 상태
WebSocket은 클라이언트-서버 간 양방향(풀듀플렉스) 통신을 지속적인 하나의 연결로 유지하는 프로토콜이고, MJPEG 스트림은 HTTP 응답을 끊지 않고 JPEG 이미지를 연속으로 흘려보내는 단방향 스트리밍 방식입니다. 두 방식은 각각 장단점이 달라서, 하나의 파이프라인 안에서 두 가지를 동시에 어설프게 섞으면 “연결은 됐는데 아무것도 안 나온다”는 애매한 상태에 빠지기 쉽습니다.

4. 해결 방안: getUserMedia부터 서버 업로드까지 4단계 구현
원인 분석 결과를 바탕으로, 실제로 적용한 해결책은 아래 4단계입니다. 권한 요청 → 로컬 영상 표시 → 서버 전송 → 권한 상태 저장 순서로, 실제 구현 흐름과 동일하게 정리했습니다.
해결 1: getUserMedia로 실제 카메라 접근 권한 요청
가장 먼저 해야 할 일은 브라우저에 실제로 카메라 접근 권한을 요청하는 것입니다.
// 실제 카메라 권한 요청
async function requestCameraPermission() {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 640 },
height: { ideal: 480 }
},
audio: false
});
localVideo.srcObject = stream;
hasCameraPermission = true;
}효과:
- 브라우저가 카메라 권한 요청 팝업을 표시
- 사용자가 “허용”을 클릭하면 실제 카메라가 활성화됨
- 한 번 허용하면 브라우저가 그 상태를 저장하므로, 다음 접속부터는 다시 묻지 않음
참고로 getUserMedia()는 보안상의 이유로 HTTPS(또는 localhost)와 같은 보안 컨텍스트에서만 정상 동작합니다. 실제 배포 환경에서는 반드시 SSL 인증서가 적용된 도메인(https://ai.qcai.kr와 같은 형태)에서 서비스해야 카메라 권한 요청 자체가 정상적으로 동작합니다.
해결 2: 로컬 Video 태그로 실시간 영상 표시
Canvas로 실시간 영상을 직접 그리는 대신, <video> 태그에 스트림을 바로 연결해서 화면에 표시하도록 변경했습니다.
html
<!-- 로컬 카메라 영상 표시 -->
<video id="localVideo" autoplay playsinline muted></video>javascript
// Video 태그에 스트림 연결
localVideo.srcObject = stream;
localVideo.style.display = 'block';효과:
- Canvas 대신 Video 태그를 사용해 훨씬 부드러운 영상 출력
- 브라우저의 하드웨어 가속을 그대로 활용해 성능이 최적화됨
transform: scaleX(-1)을 적용해 셀카처럼 미러링 처리 가능
해결 3: Canvas로 프레임 캡처 후 서버로 전송
화면에는 Video 태그로 부드럽게 보여주면서, 실제 서버 전송용 프레임은 Canvas로 일정 주기마다 캡처해서 JPEG으로 인코딩한 뒤 업로드하는 방식으로 분리했습니다.
javascript
// 프레임 캡처 및 서버 업로드
async function uploadFrame() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.translate(w, 0);
ctx.scale(-1, 1); // 미러링
ctx.drawImage(video, 0, 0, w, h);
const blob = await canvas.toBlob(resolve, 'image/jpeg', 0.7);
// FormData로 서버 전송
}효과:
- Video → Canvas → JPEG → 서버 업로드로 이어지는 명확한 파이프라인 확립
- 실제 카메라 영상이 서버로 정상 전송됨
- 이후 AI 분석에서 “사람”이 정상적으로 감지되기 시작
해결 4: 권한 상태 저장 (한 번 허용하면 다시 안 물어보기)
매번 페이지에 들어올 때마다 권한을 다시 요청하면 사용자 경험이 나빠지므로, Permissions API로 현재 권한 상태를 먼저 확인하고 이미 허용된 경우 자동으로 카메라를 시작하도록 처리했습니다.
javascript
// 권한 상태 확인 (Permissions API)
async function checkCameraPermission() {
const result = await navigator.permissions.query({ name: 'camera' });
if (result.state === 'granted') {
hasCameraPermission = true;
permissionOverlay.classList.add('hidden');
startCamera(); // 자동 시작
return true;
} else if (result.state === 'denied') {
// 권한 거부됨 → 오버레이 표시
permissionOverlay.classList.remove('hidden');
return false;
}
}효과:
Permissions API로 권한 상태(granted,denied,prompt등)를 사전에 조회- 이미 허용된 상태라면 권한 팝업 없이 자동으로 카메라 시작
- 거부된 상태라면 안내 오버레이를 표시해서 사용자가 다시 권한을 허용하도록 유도
이 네 가지 해결책은 순서대로 적용해야 자연스럽게 이어집니다. 즉, 권한을 먼저 확보(해결 1, 4)하고, 확보된 스트림을 화면에 보여준 뒤(해결 2), 그중 일부를 캡처해서 서버로 보내는(해결 3) 구조입니다.

5. Before / After 비교: 무엇이 어떻게 바뀌었나
| 항목 | Before (실패) | After (성공) |
|---|---|---|
| 카메라 접근 | 없음 (더미 화면) | getUserMedia 사용 |
| 영상 표시 | Canvas (깜빡임) | Video 태그 (부드러움) |
| 서버 전송 | MJPEG 스트림 (513 bytes) | Canvas 캡처 (20KB+) |
| 권한 관리 | 수동 요청 | Permissions API 자동 |
| AI 분석 결과 | “카메라 연결 대기” | 실제 사람 감지 |
| 최종 화면 | 검은 화면 | 실시간 카메라 영상 |
표에서 볼 수 있듯, 단순히 스트리밍 프로토콜을 WebSocket에서 MJPEG으로 바꾸는 것만으로는 문제가 해결되지 않았습니다. 핵심은 애초에 카메라 권한을 정상적으로 확보했는가, 그리고 캡처한 영상 데이터가 실제로 의미 있는 크기(20KB 이상)로 서버에 도달했는가였습니다.
6. 최종 솔루션 전체 코드 구조
프론트엔드 (front.html)
html
<!-- 1. 권한 요청 오버레이 -->
<div class="permission-overlay">
<button onclick="requestCameraPermission()">카메라 허용</button>
</div>
<!-- 2. 로컬 카메라 영상 -->
<video id="localVideo" autoplay playsinline muted></video>
<!-- 3. 서버 업로드 (Canvas) -->
<script>
async function uploadFrame() {
// Video → Canvas → JPEG → FormData → Server
}
</script>서버 (integrated_server.py)
python
# 1. 프레임 업로드 수신
@app.post("/api/camera/upload_frame")
async def upload_frame(file: UploadFile):
global latest_frame
frame = cv2.imdecode(...)
latest_frame = frame # 실제 카메라 영상 저장
# 2. MJPEG 스트림 전송 (사각형 표시)
@app.get("/api/camera/stream")
async def camera_stream():
# latest_frame에 YOLO 감지 결과 표시
# MJPEG 스트림으로 전송이 구조에서 핵심은 프론트엔드가 POST /api/camera/upload_frame으로 실제 카메라 프레임을 계속 밀어 넣어주고, 서버는 전역 변수 latest_frame에 최신 프레임을 저장한 뒤, YOLO 감지 결과를 그려서 GET /api/camera/stream으로 MJPEG 스트림을 다시 내보내는 업로드-처리-재배포(pull-push) 구조라는 점입니다. WebSocket으로 모든 것을 처리하려던 기존 구조보다 훨씬 단순하고 디버깅하기 쉬운 형태입니다.
7. 핵심 교훈과 추천 아키텍처
핵심 교훈
1) WebSocket은 생각보다 복잡하다 바이너리 데이터 처리, 프레임 파싱, 재연결 처리 등을 직접 구현해야 하는 부담이 크며, 이번 사례처럼 단순 영상 스트리밍 목적이라면 MJPEG 스트림이 더 간단하고 안정적인 대안이 될 수 있습니다.
2) 카메라 권한은 선택이 아니라 필수다 getUserMedia 호출 없이는 실제 카메라 영상을 얻을 방법이 없습니다. 여기에 더해 Permissions API로 권한 상태를 관리하는 로직이 반드시 필요합니다.
3) Canvas와 Video는 역할이 다르다 Canvas는 프레임을 자유롭게 조작할 수 있지만 매 프레임 다시 그려야 하므로 성능 부담이 있고, Video는 브라우저 하드웨어 가속 덕분에 훨씬 부드럽게 재생됩니다. 따라서 화면 표시는 Video, 서버 전송용 캡처는 Canvas로 역할을 분리하는 조합이 최적의 결과를 만듭니다.
4) 디버깅에서는 로그의 “숫자”에 주목해야 한다 서버 로그와 프론트 콘솔을 동시에 확인하는 습관이 중요합니다. 특히 “카메라 연결 대기”라는 메시지나 513 bytes처럼 비정상적으로 작은 데이터 크기는 곧 더미 데이터가 흘러가고 있다는 신호입니다.
추천 아키텍처
[사용자 브라우저]
↓ getUserMedia (권한 요청)
[로컬 Video 태그] → 실시간 영상 표시
↓ Canvas 캡처 (200ms 간격)
[JPEG Blob] → FormData
↓ POST /api/camera/upload_frame
[서버 (FastAPI)]
↓ YOLO 감지 (사각형 표시)
[MJPEG 스트림] → /api/camera/stream
↓ img 태그 (MJPEG)
[사용자 브라우저] → AI 분석 결과 표시8. 체크리스트로 정리하는 카메라 연결 대기 화면 해결법
AI CCTV, 스마트 홈 카메라, 웹캠 기반 실시간 영상 분석 시스템을 구축하면서 “카메라 연결 대기” 화면만 나온다면 아래 순서로 점검해보시기 바랍니다.
navigator.mediaDevices.getUserMedia()가 실제로 호출되고 있는가- 서비스가 HTTPS(또는 localhost) 환경에서 실행되고 있는가
- 브라우저 콘솔에서 수신/전송되는 프레임 크기가 비정상적으로 작지 않은가(정상 JPEG은 최소 수십 KB)
- Canvas 크기를 매 프레임마다 재설정하고 있지 않은가
- 영상 표시(Video)와 서버 전송(Canvas 캡처)의 역할이 분리되어 있는가
Permissions API로 권한 상태를 저장하고 재사용하고 있는가- WebSocket과 MJPEG 중 하나의 방식으로 전송 경로가 명확히 통일되어 있는가
이 체크리스트를 하나씩 따라가면서 점검하면, 웹소켓 영상 스트리밍 오류든 MJPEG 전환 후에도 반복되는 카메라 연결 대기 화면 문제든 대부분 근본 원인을 빠르게 찾아낼 수 있습니다. 결국 핵심은 화려한 스트리밍 프로토콜 선택이 아니라, 브라우저 카메라 권한이라는 가장 기초적인 단계를 제대로 확보하는 것이었습니다.

