들어가며
이 문제는 저에게 항상 골칫거리였습니다. 녹화 중에 새로고침을 하거나 캠 연결이 끊기면, 촬영한 영상이 그대로 사라지는 문제가 있었어요. 중요한 영상 기록이 남아 있지 않게 되었다면, 사용자의 PC문제일지라도 개발자의 잘못이라고 생각합니다. 😭 이를 해결하기 위해 여러 방안을 고민해왔습니다…. 완벽한 해결책을 찾지는 못했지만, 프론트에서 스토리지를 활용해 영상 손실을 최소화하고 최대한 복구할 수 있도록 고민한 과정을 공유하려고 합니다.
현재 녹화 방식의 문제점
프로그램 오류 발생 시 정확한 확인을 위해 녹화 영상을 검토하는 경우가 많습니다. 하지만 현재 녹화 방식은 특정 트리거에 의해 영상이 녹화되고 저장되기 때문에, 저장 과정에서 문제가 발생하면 영상이 통째로 사라지게 가장 큰 문제였습니다. 게다가 네트워크 불안정이나 프로그램 튕김 현상으로 인해 영상이 손실되는 경우도 많았습니다. 이러한 문제를 반드시 개선해야 했지만, 쉽지 않은 과제였습니다.
일부 회사에서는 서버에서 실시간 스트리밍 방식으로 영상을 저장해 손실을 줄이는 방식을 사용하고 있었습니다. 하지만 우리 시스템은 사용자의 로컬 환경에서 녹화를 진행하는 구조였기 때문에 영상 손실에 취약했습니다. 서버에서 실시간으로 영상을 저장하는 방법은 안정성이 높았지만, 높은 비용이 들어 현실적으로 어려운 선택지였어요.
영상 데이터를 일정 단위로 쪼개어 서버에 저장하는 방법도 고려했지만, 트래픽 부담을 줄여야 했고, 영상 확인 과정이 복잡해질 우려가 있어 적절한 해결책이 아니라고 판단했습니다.
결국 비용을 최소화하면서도 영상을 최대한 복원할 수 있는 방법을 고민했고, 응시자의 스토리지를 활용하는 방안을 떠올리게 되었습니다.
본격적인 해결 과정을 알아보기 전에, 먼저 비디오 데이터에 대해 간략히 살펴보겠습니다.
비디오 데이터
비디오 데이터는 단순히 이미지가 빠르게 넘어가는 것이 아니라, 효율적으로 저장하고 전송할 수 있도록 압축된 데이터입니다.
비디오 파일이 만들어 지는 과정
비디오는 프레임이라는 여러 장의 사진이 빠르게 연속되며 만들어집니다. 하지만 비디오 파일을 그냥 저장하면 파일 크기가 너무 커서 다루기 어려워집니다. 그래서 우리는 압축 기술(코덱)을 사용해서 파일 크기를 줄입니다.
프레임
비디오는 프레임이라고 불리는 정지된 이미지들이 빠르게 연속되며 만들어집니다.
예를 들어 1초에 30장의 사진이 재생되면 자연스러운 영상이 됩니다.
FPS는 Frames Per Second으로 초당 프레임 수를 나타냅니다.
- 1초에 30장의 사진이 재생되면 30 FPS라고 합니다.
- 24~30 FPS → 일반적인 영화, 방송 콘텐츠
- 60 FPS 이상 → 게임, 스포츠 영상, 실시간 스트리밍
코덱
코덱은 비디오 데이터를 효율적으로 저장하고 압축하는 기술입니다.
비디오 파일이 너무 크면 저장과 전송이 어렵기 때문에, 코덱을 사용해 데이터를 압축(인코딩)하고해제(디코딩)합니다.
주요 코덱 종류
H.264: 가장 많이 사용되는 표준 코덱 (유튜브, 스트리밍 플랫폼에서 주로 사용)H.265 (HEVC): H.264보다 높은 압축률을 제공하여 고화질 영상 저장 가능VP8 / VP9: 구글이 개발한 웹 최적화 코덱 (WebM 포맷에서 사용)AV1: 차세대 오픈소스 코덱, 고효율 압축 제공
비디오 파일 포맷
비디오 파일은 단순한 영상이 아니라, 영상 + 소리 + 자막을 담고 있는 컨테이너입니다.
즉, 파일 형식에 따라 저장되는 방식이 달라지기 때문에 비디오 파일을 담는 그릇이라고 생각하면 됩니다.
MP4→ 가장 대중적인 포맷, 모든 기기에서 재생 가능WebM→ 웹 최적화 포맷, 빠른 로딩MKV→ 고화질 지원, 여러 오디오·자막 포함 가능
인코딩 & 디코딩
비디오는 용량이 크기 때문에 저장·전송을 위해 압축(인코딩)하고, 재생할 때 다시 복원(디코딩)해야 합니다.
- 인코딩(Encoding): 비디오 파일을 압축해 크기를 줄이는 과정
- 디코딩(Decoding): 압축된 비디오를 원래대로 복원하는 과정
인코딩: 영상을 압축하는 과정
녹화된 원본 영상은 크기가 커서 그대로 저장·전송하기 어렵습니다. 코덱을 이용해 불필요한 데이터를 제거하고 크기를 줄일 수 있습니다.
손실 압축: 화질을 일부 희생하고 용량을 줄임 (MP4, WebM)비손실 압축: 화질 유지, 파일 크기 큼 (ProRes 등)
예) 원본 영상(10GB) → H.264 코덱으로 인코딩 → 1GB로 압축(MP4 저장)
디코딩: 영상을 해독하여 재생하는 과정
압축된 비디오는 바로 재생할 수 없으므로, 비디오 플레이어가 코덱을 사용해 원래 영상으로 복원해야 합니다.
예) MP4/WebM 파일 불러오기 → 코덱이 압축을 해제하고 프레임 복원 → 화면에 영상 출력
비디오 데이터를 정리하자면
- 비디오는 프레임(정지된 이미지들)이 빠르게 연속되며 만들어짐
- FPS가 높을수록 영상이 더 부드러움
- 코덱은 비디오 데이터를 압축하고 해제하는 기술
- 포맷은 비디오 파일을 담는 그릇과 같다 (MP4, WebM 등)
- 인코딩은 파일을 압축하는 과정, 디코딩은 파일을 다시 복원하는 과정
현재 녹화 방식의 문제점과 데이터 손실 원인
기존의 녹화 방식은 MediaRecorder나 RecordRTC를 사용해 녹화를 시작하고, 모든 녹화가 끝난 시점에 stopRecording()을 호출하여 하나의 '완성된' 비디오 파일(Blob)을 받아 서버에 업로드하는 식이었습니다.
이 방식의 가장 큰 문제는, stopRecording()이 호출되기 전(녹화 중)에 브라우저가 새로고침 되거나 종료되면 메모리에 있던 모든 녹화 데이터가 그대로 증발한다는 것입니다.
현재 방식에서 개선 방안 고민
새로고침 후에도 복원할 수 있도록 브라우저에 데이터 임시 저장 기능이 필요했고, 스토리지를 활용한 해결 접근 방식 아이디어를 얻었습니다.
아이디어 방향
→ 녹화 데이터를 10초마다 조각으로 저장
→ 스토리지를 활용하여 임시 저장
→ 녹화 종료 후, 저장된 조각을 합쳐서 재생 가능하도록 구현
일단 비디오를 저장하기 위해서는 데이터로 변환해야하는데…
Base64로 변환?
비디오 데이터 저장을 위해 단순하게 Base64로 변환하여 저장한다면 된다고 생각했어요. 하지만 `Base64 저장방식`은 적절하지 못했고, 이로 인해 발생할 수 있는 오류들이 있었습니다.
- 1.atob() 디코딩 에러 (Base64 → 바이너리 변환 문제)
- Base64로 데이터를 저장하면 텍스트 문자열로 변환되기 때문에, 재사용하기 위해서는 디코딩 과정이 필요합니다. (이는 속도 저하의 원인이 됩니다.)
- Base64는 문자열이 깨지면 원본 데이터를 복원할 수 없습니다.
- 스토리지에서 저장한 후 불러올 때, 일부 문자열이 잘리거나 변형되면 atob() 디코딩 오류 발생하게 됩니다.
- 2.Base64 변환으로 인한 파일 크기 증가 문제
- Base64는 파일 크기를 약 33% 증가시켜 대용량 비디오 데이터 저장 시 비효율적입니다.
이러한 문제들은 Base64 대신 ArrayBuffer사용하여 해결하였습니다. ArrayBuffer를 저장하면, 불필요한 디코딩 과정 없이 바로 Blob으로 변환 가능하였습니다.
ArrayBuffer로 변환?
ArrayBuffer는 고정된 크기의 원시 바이너리 데이터(0과 1로만 이루어진 데이터)를 담는 메모리 버퍼(데이터를 임시로 저장하는 메모리 공간)입니다. ArrayBuffer 자체는 직접 읽거나 쓸 수 없고, TypedArray(예: Uint8Array)나 DataView를 통해서만 데이터에 접근할 수 있습니다. 바이너리 데이터를 바이트 단위로 정밀하게 조작해야 할 때 사용되며, 파일 처리, 네트워크 통신, WebGL 등에서 활용됩니다.
let buffer = new ArrayBuffer(8); // 8바이트 크기의 빈 메모리 공간 생성
let view = new Uint8Array(buffer); // 숫자로 조작 가능하도록 변환
view[0] = 255; // 첫 번째 바이트에 255 저장
console.log(view); // Uint8Array(8) [255, 0, 0, 0, 0, 0, 0, 0]Base64 vs ArrayBuffer 비교
| 저장 방식 | 저장 형태 | 장점 | 단점 |
|---|---|---|---|
| Base64 | 문자열 | 텍스트 기반 → 어디서나 사용 가능 | 변환 과정에서 파일 크기가 약 33% 증가 |
| ArrayBuffer | 바이너리 데이터 (바이트 배열) | 원본 크기를 유지 → 용량 최적화 | 문자열처럼 다루기 어려움 |
아니? 'Blob' 그대로! 변환하자!
Blob은 바이너리 데이터를 파일처럼 다룰 수 있게 해주는 JavaScript 객체입니다. URL을 생성하여 다운로드하거나 미디어 재생에 사용할 수 있으며, IndexedDB 같은 스토리지에도 저장 가능해요.
let blob = new Blob(["Hello, Blob!"], { type: "text/plain" }); // 텍스트 데이터를 Blob으로 저장
let link = document.createElement("a");
link.href = URL.createObjectURL(blob); // Blob을 URL로 변환
link.download = "example.txt"; // 파일로 다운로드 가능
document.body.appendChild(link);
link.click();
document.body.removeChild(link);처음에는 Blob을 ArrayBuffer나 Base64로 변환해서 저장해야 하나 고민했습니다. 하지만 다행히도 IndexedDB는 Blob 객체를 변환 없이 원본 그대로 저장할 수 있습니다.
// Blob을 변환 없이 { sessionId, blob, timestamp } 객체로 묶어 저장
const chunkData = { sessionId, blob, timestamp: Date.now() };
const request = store.add(chunkData);세션 스토리지를 활용한 녹화 데이터 저장 - 첫 번째 방법 ⭐️
녹화 중 새로고침을 하거나 브라우저가 튕기면 데이터가 사라지는 문제를 해결하기 위해, 먼저 세션 스토리지를 활용하여 녹화 데이터를 저장하는 방안을 시도해 보았습니다.
첫 번째 개선안, 주요 로직 정리
RecordRTC를 사용하여 녹화 데이터를 10초마다 자동으로 저장
recorder = new RecordRTC(mediaStream, {
type: "video",
mimeType: "video/webm",
timeSlice: 10000, // timeSlice: 1000 옵션 사용하여 10초마다 저장
ondataavailable: (blob) => saveChunkToStorage(blob),
});녹화 데이터를 조각 단위로 세션 스토리지에 저장
const saveChunkToStorage = async (blob) => {
const reader = new FileReader();
reader.readAsArrayBuffer(blob);
reader.onloadend = () => {
let chunks = JSON.parse(sessionStorage.getItem("recordedChunks") || "[]");
chunks.push([...new Uint8Array(reader.result)]); // ArrayBuffer 변환 후 저장
sessionStorage.setItem("recordedChunks", JSON.stringify(chunks));
};
};녹화된 조각을 합쳐서 하나의 영상으로 변환 후 재생
const playRestoredVideo = () => {
const storedData = JSON.parse(
sessionStorage.getItem("recordedChunks") || "[]",
);
if (!storedData.length) {
alert("⚠ 저장된 녹화 데이터가 없습니다!");
return;
}
const restoredBlobs = storedData.map((data) =>
arrayBufferToBlob(new Uint8Array(data)),
);
const finalBlob = new Blob(restoredBlobs, { type: "video/webm" });
const videoURL = URL.createObjectURL(finalBlob);
document.getElementById("restoredVideo").src = videoURL;
document.getElementById("restoredVideo").play();
};ArrayBuffer → Blob 변환
const arrayBufferToBlob = (buffer) => {
return new Blob([buffer], { type: "video/webm" });
};세션 스토리지를 활용하여 세가지가 개선되었습니다.
- 1.새로고침 후에도 녹화 데이터 유지 가능
- 2.실시간으로 녹화 데이터를 저장하여 데이터 손실 방지
- 3.서버 없이 로컬에서 빠르게 녹화 및 복원 가능
하지만 세션 스토리지는 브라우저를 닫으면 데이터가 삭제되는 점과, 한정된 용량으로 대용량 비디오 데이터를 저장하는데는 적합하지 않았습니다.
Uncaught QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'recordedChunks' exceeded the quota. at reader.onloadend1분도 안돼 저장 실패 에러가 발생하였기 때문에 다른 방법을 찾아야 했습니다.
IndexedDB를 활용한 녹화 데이터 저장 - 두 번째 방법 ⭐️⭐️
세션 스토리지를 활용하여 데이터 손실을 줄이는 첫 번째 개선 작업을 진행했지만, 세션 스토리지는 브라우저를 닫으면 데이터가 사라지는 단점과 용량 제한의 문제를 개선하기 위해 indexdDB를 사용하여 개선하였습니다.
IndexedDB와 세션 스토리지의 저장 용량 차이
- IndexedDB는 세션 스토리지보다 훨씬 많은 데이터를 저장할 수 있습니다. GB단위로 대용량 비디오 저장 가능합니다.
- 세션 스토리지는 브라우저 탭을 닫으면 데이터가 사라지며, 용량도 작기 때문에 대용량 데이터를 저장하기 어렵습니다.
두 번째 개선안, 주요 로직 정리
RecordRTC의 timeSlice 옵션을 사용하면 녹화 스트림을 일정한 시간 간격(예: 10초)으로 잘라 ondataavailable 이벤트를 통해 작은 Blob 조각으로 받을 수 있습니다.
// 코드의 startScreenRecordingHandler 부분
recorderScreen = RecordRTC(stream, {
type: "video",
timeSlice: 10000, // 10초마다 청크 생성
mimeType: "video/webm;codecs=vp9",
ondataavailable: (blob) => saveChunkToStorage(blob, "screen"), // 생성 즉시 저장
});IndexedDB 를 활용하여 '자동 복구 시스템' 구축하기
단순히 청크를 저장하는 것만으로는 부족했습니다. 이 조각난 데이터들을 '어떻게', '언제' 다시 합치고 서버로 보낼지 관리하는 시스템이 필요했습니다.
1. 뒤섞인 청크 문제: '세션 ID'로 관리하기
만약 사용자가 녹화 중 새로고침을 2~3번 반복하면, IndexedDB에는 여러 녹화 시도의 청크가 모두 뒤섞여 버릴 겁니다.
이를 해결하기 위해 '녹화 세션(Session)' 개념을 도입했습니다.
- 1.녹화가 시작될 때마다
generateSessionId()로 고유한 세션 ID를 생성합니다. - 2.이 세션 ID를
localStorage에도 저장하여, 새로고침 후에도 현재 세션 ID를 기억하게 합니다. - 3.IndexedDB에
screenChunks저장소 외에sessions라는 별도의 저장소를 만듭니다. - 4.
sessions저장소:{ sessionId, roomIdx, isActive, timestamp }형태의 '현재 진행 중인 녹화 정보'를 저장합니다. - 5.
screenChunks저장소: 모든 청크를 저장할 때{ sessionId, blob, timestamp }형태로 저장하여, 각 청크가 어떤 세션에 속하는지 명확히 태그를 붙입니다.
2. 저장된 데이터, 이젠 '자동으로 업로드'
블로그의 초기 아이디어는 '복구해서 재생하기'였지만, 최종 코드는 여기서 한발 더 나아갔습니다. 사용자가 직접 무언가를 할 필요 없이, 시스템이 알아서 복구하고 서버로 업로드합니다.
사용자가 페이지에 다시 접속하면(새로고침 또는 재접속), startRecord 함수는 새 녹화를 시작하기 전에 가장 먼저 영상 복구 함수를 실행합니다.
자동 복구 프로세스
- 1.
getAllActiveSessions를 호출해 IndexedDB의sessions저장소에서 아직 처리가 완료되지 않은 '활성 세션' 목록을 가져옵니다. - 2.발견된 각 세션 ID로
restoreScreenChunks(sessionId)를 호출합니다. - 3.이 함수는
screenChunks저장소에서 해당 세션 ID를 가진 모든 청크를 시간순으로 정렬하여 하나의Blob파일로 합칩니다. - 4.
uploadScreen함수가 이 복구된Blob을 S3 서버로 업로드합니다. - 5.업로드가 성공하면,
cleanupSession을 호출해 IndexedDB에서 사용된 청크와 세션 정보를 완전히 삭제합니다. (만약 업로드가 실패하면? 데이터는 삭제되지 않고 다음 접속 시 다시 복구를 시도합니다.)
3. 업로드마저 실패한다면?: 'Fallback 서버' 2차 방어막
여기서 끝이 아닙니다. 만약 자동 복구된 영상을 S3에 업로드하는 과정에서 네트워크 오류나 S3의 일시적 장애가 발생하면 어떡할까요?
uploadScreen 함수 내에는 이중 안전장치가 있습니다.
try {
// 1순위: AWS S3로 업로드 시도
await awsApi.retryUploadURLtoS3(preSignedUrl, file);
// ... (서버 기록)
// 성공 시 청크와 세션 삭제
await chunkStorage.clearChunks("screen", targetSessionId);
} catch (awsError) {
try {
// 2순위: S3 실패 시 Fallback 서버로 업로드 시도
await uploadToFallbackServer(blob, type);
// Fallback 성공 시에도 청크와 세션 삭제
await chunkStorage.clearChunks("screen", targetSessionId);
} catch (fallbackError) {
// Fallback마저 실패하면?
// 청크를 보존하여 다음 접속 시 재시도 (throw new Error)
console.error(`${type} fallback 업로드도 실패:`, fallbackError);
throw new Error(`${type} 모든 업로드 방식 실패`);
}
}S3 업로드가 실패하면, 데이터를 즉시 폐기하지 않고 미리 준비해둔 2차 서버로 업로드를 재시도합니다. 이 2차 업로드마저 실패해야만 에러를 발생시키고, 데이터는 다음 시도를 위해 IndexedDB에 보존됩니다.
마치며
처음에는 '세션 스토리지'라는 단순한 아이디어로 시작했지만, 용량 문제에 부딪히며 'IndexedDB'를 도입하게 되었습니다. 여기서 그치지 않고, "어떻게 하면 더 안정적으로 데이터를 지킬 수 있을까?"를 고민한 결과,
- 1.
sessionId를 기반으로 한 체계적인 세션 관리 - 2.단순 저장을 넘어선 자동 복구 및 업로드 로직
- 3.S3 실패 시를 대비한 Fallback 서버 2차 방어
이렇게 다층 방어 시스템을 클라이언트 단에서 구축할 수 있었습니다.
불가능할 것 같았던 아이디어가 현실이 되어 재밌는 경험이었어요. 클라이언트에서도 충분히 많은 작업을 수행할 수 있다는 점이 정말 흥미로웠습니다.
물론 사용자의 IndexedDB 저장 용량 한계나 브라우저 권한 문제 등은 여전히 존재하지만, 현재 구조는 영상 손실을 방지하기 위한 현실적인 최선의 방안이라고 생각합니다. 좋은 의견이 있다면 언제든지 편하게 공유해주시면 감사하겠습니다 :)
