대용량 파일 점진적 암호화: 스트림 및 암호화
스트리밍 API를 사용하여 대용량 파일을 점진적으로 암호화하세요. 메모리 부족 없이 수 GB 파일을 청크 단위로 암호화.
점진적(스트리밍) 암호화는 전체 페이로드를 메모리에 로드하지 않고 파일을 청크 단위로 처리합니다. 브라우저에서 10GB 업로드의 경우, 이것이 앱이 작동하느냐 충돌하느냐의 차이입니다. 패턴은 다음과 같습니다. File.stream()으로 청크를 읽고, 고유한 논스를 사용해 AES-256-GCM으로 암호화하고, 암호문을 fetch의 ReadableStream 본문을 통해 업로드 스트림에 직접 파이프하고, 버퍼를 해제하고, 다음으로 이동합니다. 메모리는 파일 크기에 관계없이 4~16MB로 제한됩니다. libsodium의 crypto_secretstream_xchacha20poly1305는 잘림 감지를 포함한 적절한 스트리밍 AEAD 시맨틱을 추가합니다.
버퍼링 암호화가 실패하는 이유
FileReader.readAsArrayBuffer(file)로 10GB 파일을 읽으면 브라우저 메모리에 10GB를 할당합니다. 32GB RAM을 가진 데스크톱 Chrome에서는 작동할 수 있습니다. 탭당 400MB 메모리 제한을 가진 모바일 Safari에서는 완료 전에 충돌합니다. Firefox에서는 2GB를 초과하는 ArrayBuffer가 내부 한계에 도달해 RangeError를 발생시킵니다.
할당을 처리할 수 있는 하드웨어에서도 10GB를 붙들면 가비지 컬렉션이 막히고 심각한 페이징이 발생합니다. 올바른 답은 전체 버퍼를 할당하지 않는 것입니다.
스트리밍 패턴
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
두 가지 핵심 API가 있습니다. File.stream()은 파일 내용의 ReadableStream을 제공하고, ReadableStream 본문을 가진 fetch는 전체 본문을 버퍼링하지 않고 업로드를 스트리밍합니다. duplex: "half"는 Chrome 105+ 이후 스트리밍 요청 본문에 필요합니다. 4MB 청크 크기에서 메모리 사용량은 최대 12~16MB입니다.
스트림에서의 논스 관리
각 청크에는 고유한 논스가 필요합니다. 세 가지 방법이 있습니다.
카운터 기반: 96비트 논스에 청크 인덱스를 포함시킵니다. 상위 32비트를 랜덤 접두사로 설정하고(같은 키를 사용하는 파일 간 충돌 방지), 하위 64비트를 청크 인덱스로 설정합니다.
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
청크별 랜덤: crypto.getRandomValues(new Uint8Array(12)). 파일별 키에 안전하며, 생일 충돌은 약 2^48 청크에서 발생합니다. 논스를 각 청크의 암호문과 함께 저장해야 합니다.
파일별 신규 키의 경우, 카운터 기반 방식이 가장 단순하고 청크별 별도 논스 저장이 불필요합니다.
잘림 공격과 감지 방법
단순한 청크 AES-GCM의 심각한 취약점이 있습니다. 공격자가 뒷부분 청크를 삭제해도 살아남은 각 청크는 정상적으로 복호화됩니다. 감지를 위해서는 청크를 함께 묶어야 합니다.
방법 1: 모든 청크의 AAD에 총 청크 수를 포함시킵니다. 수신자는 수신된 수량이 일치하는지 확인합니다.
const aad = new TextEncoder().encode(JSON.stringify({
totalChunks,
fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
key,
chunk
);
방법 2: libsodium의 crypto_secretstream_xchacha20poly1305를 사용합니다. 청크를 암호학적으로 연결하고 수신자가 검증하는 TAG_FINAL 마커를 발행합니다. 복호화 시 pull 호출이 체인을 검증하고 누락된 꼬리 청크를 감지합니다. libsodium.js를 사용할 의향이 있다면 이것이 가장 깔끔한 방법입니다.
수신자 측 스트리밍 복호화
수신자 측의 대칭 패턴:
async function streamDecrypt(downloadURL, key, onChunk) {
const response = await fetch(downloadURL);
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // GCM 태그 포함
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 버퍼에 추가하고 완전한 청크가 되면 복호화
buffer = /* 새 버퍼 조립 */;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
const iv = makeNonce(chunkIndex++);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
onChunk(new Uint8Array(pt));
}
}
}
수신자 측에서 onChunk 콜백은 복호화된 바이트를 File System Access API로 직접 디스크에 쓰거나 브라우저 기본 다운로드를 위해 Blob으로 연결할 수 있습니다.
File System Access API로 디스크에 쓰기
매우 큰 다운로드의 경우 전체 복호화 결과를 Blob으로 로드하면 스트리밍의 목적이 사라집니다. File System Access API(Chrome 86+, OPFS를 통한 부분적 Safari 지원)를 사용하면 수신자가 로컬 파일을 선택하고 청크를 직접 쓸 수 있습니다.
const handle = await window.showSaveFilePicker({
suggestedName: "decrypted-file",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
청크가 즉시 디스크로 이동하기 때문에 메모리가 제한됩니다. UI는 실제 진행 상황을 표시합니다. 사용자는 다운로드 중간에 취소할 수 있습니다. Firefox는 아직 데스크톱에서 showSaveFilePicker를 지원하지 않습니다. 수백 MB 미만의 파일에는 메모리 내 Blob으로 대체하세요.
fetch를 통한 업로드 스트리밍
Chrome 105+와 Firefox 127+는 duplex: "half"로 스트리밍 요청 본문을 지원합니다. S3 호환 멀티파트 업로드의 경우, 각 파트는 별도 요청으로 업로드됩니다. 암호화된 스트림을 5~25MB 파트로 분할하고 최종 CompleteMultipartUpload 호출로 완료하세요. 이 방식은 모든 브라우저에서 작동하며 재시도 가능성을 무료로 제공합니다.
스트림 진행 상황 보고
처리된 바이트를 추적하세요:
let processed = 0;
const onChunk = (chunkSize) => {
processed += chunkSize;
updateProgressBar(processed / file.size);
};
진행 업데이트를 requestAnimationFrame으로 10~20Hz로 조절하세요. 100MB/s 처리 속도로 10GB 파일을 처리하면 초당 100번의 원시 이벤트가 발생하며, UI에는 훨씬 적은 업데이트로도 충분합니다.
10GB 파일 벤치마크
2024년 MacBook Pro(M3 Max)에 빠른 SSD 기준: File.stream()을 통한 디스크 읽기 2.5GB/s, Web Crypto를 통한 AES-256-GCM 1.7GB/s, 결합 파이프라인 1.1GB/s(직렬 체인으로 제한), 기가비트 이더넷 업로드 115MB/s(네트워크 바운드), 파일 크기에 관계없이 피크 메모리 14MB. 모바일 수치는 데스크톱의 30~50% 수준입니다. 10GB 파일은 기가비트에서 약 90초, 일반 가정용 연결에서 약 15분이 걸립니다. 암호화가 병목이 아니라 네트워크가 병목입니다.
오류 복구
10GB 업로드 중 네트워크 중단은 흔합니다. 전략:
- 멀티파트를 통한 재시도 가능 업로드: 각 파트가 독립적이므로 실패한 파트만 재업로드
- tus 프로토콜: Vimeo 등이 지원하는 오픈 재시도 가능 업로드 표준, 스트림 네이티브
- 소스 파일 핸들 열어두기:
File.slice가 반복 가능하면 마지막 성공 청크부터 재시작
핵심 요약
전체 파일을 할당하지 마세요. 청크 단위로 읽고, 암호화하고, 업로드하고, 각 청크를 처리 후 해제하세요. AAD나 스트리밍 AEAD로 청크를 암호학적으로 묶어 잘림을 방지하세요. 진행 상황 바를 반드시 표시하세요. 데스크톱만이 아닌 모바일에서도 테스트하세요.
hexatransfer.com에서 사용해 보세요 — 무료, 계정 불필요, 최대 10GB.
엔드투엔드 암호화로 대용량 파일을 안전하게 전송
엔드투엔드 암호화로 최대 10GB의 파일을 무료로 전송하세요. 계정이 필요하지 않습니다. 업로드 전에 브라우저에서 파일이 암호화되어 다른 사람은 읽을 수 없습니다.
파일 보내기