최근에 가족과 친구들을 위한 간단한 사진 공유 앱을 만들게 되었습니다.
주변에서 근황을 놓치고 싶지 않은 일이 있었는데, 그걸 위해 클라우드 서비스에 의존하는 건 별로 마음에 들지 않았거든요.
대신 Next.js로 작은 프로그레시브 웹 앱을 만들기로 결정했습니다. 몇 시간 만에 첫 버전을 완성했는데, 사진 디렉터리를 지정하면 그리드 형태의 썸네일과 섀도우박스 형태의 전체 크기 이미지로 렌더링하는 기본적인 자체 호스팅 앱이었습니다. 새 사진이 디렉터리에 추가되면 푸시 알림까지 받을 수 있도록 설정해 두기도 했습니다.
그러다가, 이제 끝이라고 생각하고 이 프로젝트를 대성공으로 결론 내리려는 순간, 문득 한 가지가 생각났습니다. 비디오를 완전히 잊고 있었다는 점이었습니다! "음, 문제없어,"라고 생각했습니다, "그냥 <video> 태그를 쓰면 되잖아?"
<video> 요소
<video> 요소는 컨트롤 표시, 자동 재생 같은 여러 옵션을 지원합니다. <img> 요소와 마찬가지로 <video> 요소에도 src 속성을 지정하여 삽입할 동영상의 URL을 명시할 수 있습니다.
모든 브라우저가 모든 비디오 형식을 지원하는 것은 아니기 때문에, <video> 요소는 일반적으로 단일 src 속성 대신 <source> 요소 목록을 자식으로 생성합니다. 브라우저는 지원하는 첫 번째 소스 유형을 찾을 때까지 이 소스 목록을 자동으로 순회합니다. 따라서 AV1 형식을 지원하는 사용자에게는 선명하고 용량이 작은 AV1 비디오를 제공하고, 그 외 사용자에게는 더 널리 지원되는 WEBM 또는 MP4 인코딩으로 대체할 수 있습니다.
MDN 문서에서 예시를 들어 설명해 보겠습니다.
<video controls width="250">
<source src="/shared-assets/videos/flower.webm" type="video/webm" />
<source src="/shared-assets/videos/flower.mp4" type="video/mp4" />
</video>
브라우저 개발자 도구에서 네트워크 탭을 확인하면 여기서 무슨 일이 벌어지는지 볼 수 있습니다. 사용하는 브라우저에 따라 서로 다른 요청이 이루어지는 것을 확인할 수 있습니다. 현재 저는 WEBM을 지원하는 Zen/Firefox를 사용 중이라 WEBM 동영상만 요청합니다(최근 버전의 주요 브라우저 모두 마찬가지일 것입니다). AV1 소스를 포함하면 구형 iPhone 및 macOS 기기에서는 이를 건너뛰고 WEBM 파일로 대체됩니다.
범위 요청
네트워크 탭을 아주 주의 깊게 살펴보셨다면 몇 가지 흥미로운 점을 발견하셨을 수도 있습니다. 첫째, flower.webm 요청에 대한 응답 코드는 표준적인 200 성공/OK 응답이 아닌 206이었습니다. 브라우저에 따라서는 이 비디오 파일에 대한 요청이 여러 번 발생했고, 그 모든 요청이 206 응답으로 돌아오는 모습을 보셨을 수도 있습니다. 또한 Accept-Ranges: bytes 및 Content-Range: bytes 0-554057/554058과 같은 추가 헤더도 있습니다.
이것들이 합쳐져 HTTP 범위 요청을 구성합니다. 이름에서 알 수 있듯이, 범위 요청은 클라이언트가 전체 리소스 대신 서버로부터 특정 바이트 범위를 요청할 수 있게 합니다. 이는 특히 비디오에 유용합니다. 비디오 파일은 길이가 임의적일 수 있고(따라서 용량이 클 수 있음) 클라이언트는 거의 항상 이를 조각(chunks) 단위로 로드하기 때문입니다.
처음에는 저의 작은 사진 공유 앱에 범위 요청을 지원할 생각이 전혀 없었습니다. 분명 유용하지만 Next.js에는 기본 지원이 없고, 직접 구현하는 것도 달갑지 않았습니다. 게다가 공유할 계획이었던 모든 동영상은 비교적 작아서 길이가 최대 1분에 불과했습니다. 로딩에 몇 초는 걸리겠지만 참을 수 없는 정도는 아니었죠. 동영상 파일을 200 응답 코드로 직접 제공하는 엔드포인트를 추가하고 커밋한 뒤, 작은 사이드 프로젝트를 마쳤다는 사실에 스스로를 칭찬했습니다.
import { FileHandle, open } from "node:fs/promises";
import { join } from "node:path";
import { createReadableStreamFromReadable } from "@remix-run/node";
interface Props {
params: Promise<{ album: string; file: string }>;
}
export async function GET(request: Request, props: Props) {
const { album, file: filename } = await props.params;
const rootDir = process.env.ROOT_DIR!;
const filepath = join(rootDir, album, filename);
let file: FileHandle;
try {
file = await open(filepath);
} catch {
return new Response(null, { status: 404 });
}
return new Response(createReadableStreamFromReadable(file.createReadStream()), {
headers: { "Content-Type": "video/mp4" },
});
}
그리고 제 휴대폰에서 앱을 테스트했습니다.
사파리 브라우저가 거부합니다
알고보니 사파리는 비디오 소스가 HTTP 범위 요청을 반드시 지원해야 했습니다. 사파리는 처음 두 바이트에 대한 요청을 보내고, 올바른 HTTP 범위 헤더가 포함된 응답을 받지 못하면 다음 소스로 넘어갑니다. 전체 목록을 다 확인해도 적절한 범위 응답을 받지 못하면, 아예 비디오를 렌더링하지 않습니다!
일반적으로 이미지나 동영상 같은 정적 자산은 CDN을 통해 제공되거나 적어도 적절한 정적 파일 서버를 사용합니다. 이런 환경에서는 모두 범위 요청을 지원하기 때문에, 개발자가 그 동작 방식까지 신경 써야 할 일은 거의 없습니다. 하지만 제가 직접 하드웨어에 자체 호스팅할 목적으로 앱을 구축 중이었기 때문에 CDN이나 별도의 정적 파일 서버에 의존할 수 없었습니다. 또한 사용자가 제공한 에셋을 제공해야 했기 때문에 HTTP 범위 요청을 지원하는 Next.js의 public 폴더 규칙에도 의존할 수 없었습니다.
좋습니다. 그럼, 저는 이걸 해야하고, 아마 재미있을 것 같습니다!
직접 해결하기
다행히 MDN에는 HTTP 범위 요청에 관한 비교적 유용한 가이드가 있습니다. 먼저 요청 헤더를 확인하여 범위 요청을 받았는지 살펴봐야 합니다. 가장 중요한 것은 Range 헤더로, 클라이언트가 범위 요청을 하고 있음을 나타내는 신호이며 클라이언트가 요청하는 바이트 범위를 포함합니다. 기술적으로 Range 헤더는 향후 바이트 이외의 단위를 지원할 수 있지만, 현재로서는 바이트가 유일한 등록된 단위입니다.
Range header 예시Range: bytes=0-554057
주의해야 할 몇 가지 다른 헤더도 있습니다. 범위 요청은 모바일 앱이 백그라운드로 전환될 때 중단된 대용량 다운로드가 앱 재실행 시 재개되는 것처럼, 재개 가능한 다운로드에도 흔히 사용됩니다. 이 시나리오에서는 클라이언트가 요청하는 리소스가 이미 일부 다운로드한 것과 정확히 동일한지 확인할 수 있어야 합니다. 그렇지 않으면 파일이 손상될 수 있습니다. 이를 지원하기 위해 클라이언트는 If-Range 헤더를 사용하여 ETag(일반적으로 리소스 내용의 해시) 또는 마지막 수정 타임스탬프를 지정할 수 있습니다. 서버는 이 검증자가 전송하려는 리소스와 일치하는지 확인할 수 있습니다. 일치하지 않으면 200 상태 코드와 함께 리소스 전체를 처음부터 응답할 수 있습니다.
If-Range header 예시 (ETag 사용)If-Range: "67cf03ca-8744a"
그러면 먼저 이러한 헤더를 확인하고 이를 활용하여 부분 응답 또는 전체 응답으로 응답할지 결정하는 것부터 시작해 보겠습니다.
// import { FileHandle, open } from "node:fs/promises";
// import { join } from "node:path";
// import { createReadableStreamFromReadable } from "@remix-run/node";
// interface Props {
// params: Promise<{ album: string; file: string }>;
// }
// export async function GET(request: Request, props: Props) {
// const { album, file: filename } = await props.params;
// const rootDir = process.env.ROOT_DIR!;
// const filepath = join(rootDir, album, filename);
const ifRange = request.headers.get("If-Range")?.valueOf();
const range = request.headers.get("Range")?.valueOf();
const rangesString = range?.replace("bytes=", "");
const rangeStrings = rangesString?.split(",");
// let file: FileHandle;
// try {
// file = await open(filepath);
// } catch {
// return new Response(null, { status: 404 });
// }
const stats = await file.stat();
const lastModified = new Date(stats.mtime).toISOString();
const etagBase = `${stats.mtime.valueOf()}-${stats.size}`;
const etag = `"${createHash("md5").update(etagBase).digest("hex")}"`;
const partialResponse =
range?.startsWith("bytes=") &&
// We're only supporting single ranges for now
rangeStrings?.length === 1 &&
(!ifRange || ifRange === etag || ifRange === lastModified);
if (partialResponse) {
// TODO
}
// return new Response(createReadableStreamFromReadable(file.createReadStream()), {
// headers: { "Content-Type": "video/mp4" },
// });
// }
이제 이 바이트 범위를 파싱하고 해당 바이트만 포함하도록 스트림을 업데이트해야 합니다. 바이트 범위는 시작점이나 끝점을 생략할 수 있으며, 파싱 후 NaN 값을 확인하여 이를 처리할 수 있습니다.
// import { FileHandle, open } from "node:fs/promises";
// import { join } from "node:path";
// import { createReadableStreamFromReadable } from "@remix-run/node";
// interface Props {
// params: Promise<{ album: string; file: string }>;
// }
// export async function GET(request: Request, props: Props) {
// const { album, file: filename } = await props.params;
// const rootDir = process.env.ROOT_DIR!;
// const filepath = join(rootDir, album, filename);
// const ifRange = request.headers.get("If-Range")?.valueOf();
// const range = request.headers.get("Range")?.valueOf();
// const rangesString = range?.replace("bytes=", "");
// const rangeStrings = rangesString?.split(",");
// let file: FileHandle;
// try {
// file = await open(filepath);
// } catch {
// return new Response(null, { status: 404 });
// }
// const stats = await file.stat();
// const lastModified = new Date(stats.mtime).toISOString();
// const etagBase = `${stats.mtime.valueOf()}-${stats.size}`;
// const etag = `"${createHash("md5").update(etagBase).digest("hex")}"`;
let start = 0;
let end = stats.size - 1;
// const partialResponse =
// range?.startsWith("bytes=") &&
// // We're only supporting single ranges for now
// rangeStrings?.length === 1 &&
// (!ifRange || ifRange === etag || ifRange === lastModified);
// if (partialResponse) {
const firstRangeString = rangeStrings[0];
const [startString, endString] = firstRangeString.trim().split("-") as [string, string];
try {
const parsedStart = parseInt(startString.trim(), 10);
if (!Number.isNaN(parsedStart)) {
start = parsedStart;
}
const parsedEnd = parseInt(endString.trim(), 10);
if (!Number.isNaN(parsedEnd)) {
end = parsedEnd;
}
} catch {
// If the ranges weren't valid, then leave the defaults
}
// }
// return new Response(
createReadableStreamFromReadable(file.createReadStream({ start, end })),
{
// headers: { "Content-Type": "video/mp4" },
// },
// );
// }
file.createReadStream({ start, end }) 호출에서 많은 작업을 수행합니다. 이 호출은 시작 오프셋부터 끝 오프셋까지 파일의 바이트를 반환하는 Node.js ReadStream(Readable 인터페이스를 확장함)을 생성합니다. 편리하게도 이 스트림은 Range 헤더와 동일한 범위 의미론(start와 end 모두 포함)을 가지므로, 해당 값들을 그대로 전달하기만 하면 됩니다.
이제 구현은 거의 완성되었습니다! 마지막 단계는 클라이언트에 올바른 헤더와 상태를 전송하여, 그들이 실제로 청크 또는 재개 가능한 다운로드를 구현할 수 있도록 하는 것입니다.
먼저, 표준 HTTP 요청을 수신한 경우에도 응답에 Accept-Ranges 헤더를 포함하도록 업데이트해야 합니다. 이를 통해 클라이언트는 향후 범위 요청을 사용할 수 있는지 여부를 판단할 수 있습니다(예: 대용량 파일 다운로드가 중단된 경우). 다음으로, 범위 요청에 응답할 때는 요청된 범위가 전체 리소스를 포함하더라도 기본 성공 코드인 200 대신 206 상태 코드를 반환해야 합니다. 또한 Content-Range 헤더를 포함시켜야 합니다. 이 헤더는 응답에서 전송되는 바이트 범위를 명시하며, 요청의 Range 헤더에서 지정한 범위에 대한 수락 확인 역할을 합니다. 마지막으로 Last-Modified 및 ETag 헤더를 추가하여 클라이언트가 두 검증자 중 하나로 If-Range 조건을 구현할 수 있도록 합니다.
// import { FileHandle, open } from "node:fs/promises";
// import { join } from "node:path";
// import { createHash } from "node:crypto";
// import { createReadableStreamFromReadable } from "@remix-run/node";
// interface Props {
// params: Promise<{ album: string; file: string }>;
// }
// export async function GET(request: Request, props: Props) {
// const { album, file: filename } = await props.params;
// const rootDir = process.env.ROOT_DIR!;
// const filepath = join(rootDir, album, filename);
// const ifRange = request.headers.get("If-Range")?.valueOf();
// const range = request.headers.get("Range")?.valueOf();
// const rangesString = range?.replace("bytes=", "");
// const rangeStrings = rangesString?.split(",");
// let file: FileHandle;
// try {
// file = await open(filepath);
// } catch {
// return new Response(null, { status: 404 });
// }
// const stats = await file.stat();
// const lastModified = new Date(stats.mtime).toISOString();
// const etagBase = `${stats.mtime.valueOf()}-${stats.size}`;
// const etag = `"${createHash("md5").update(etagBase).digest("hex")}"`;
// let start = 0;
// let end = stats.size - 1;
// const partialResponse =
// range?.startsWith("bytes=") &&
// // We're only supporting single ranges for now
// rangeStrings?.length === 1 &&
// (!ifRange || ifRange === etag || ifRange === lastModified);
// if (partialResponse) {
// const firstRangeString = rangeStrings[0];
// const [startString, endString] = firstRangeString.trim().split("-") as [string, string];
// try {
// const parsedStart = parseInt(startString.trim(), 10);
// if (!Number.isNaN(parsedStart)) {
// start = parsedStart;
// }
// const parsedEnd = parseInt(endString.trim(), 10);
// if (!Number.isNaN(parsedEnd)) {
// end = Math.min(parsedEnd, stats.size - 1);
// }
// } catch {
// // If the ranges weren't valid, then leave the defaults
// }
// }
if (start > stats.size - 1) {
return new Response(null, {
status: 416,
headers: { "Content-Range": `bytes */${stats.size}` },
});
}
// // return new Response(createReadableStreamFromReadable(file.createReadStream({ start, end })), {
status: partialResponse ? 206 : 200,
headers: {
"Content-Type": "video/mp4",
"Content-Length": `${end - start + 1}`,
"Accept-Ranges": "bytes",
"Last-Modified": new Date(stats.mtime).toString(),
Etag: etag,
...(partialResponse && {
"Content-Range": `bytes ${start}-${end}/${stats.size}`,
}),
},
// // });
// // }
이제 기본 <video> 요소만으로 모든 주요 브라우저에서 동영상이 재생됩니다! 기술적으로는 멀티레인지 요청을 구현하지 않았지만, 실제로 이를 사용할 만한 사례를 찾거나 생각해내지 못했습니다(다행히 브라우저들은 파일 다운로드나 동영상 버퍼링에 이를 활용하지 않는 것 같습니다). 만약 이를 지원하려면, MDN의 206 부분 콘텐츠 상태 문서에 설명된 대로 여러 개의 읽기 가능한 스트림을 연결하고(Deno 표준 라이브러리에 훌륭한 concatReadableStreams 구현체가 있습니다), 멀티파트 응답 구분자로 결합해야 합니다. 현재로서는 다중 범위 요청을 무시하고 비범위 요청처럼 처리합니다. 다중 범위 요청을 지원하지 않는 서버에 대해서는 일반적으로 허용 가능한 동작이라고 생각합니다.
수정: 416 조건을 잘못 구현했다는 점을 지적해 주신 u/Pesthuf 님께 감사드립니다! 사양서의 관련 부분은 실제로 다음과 같이 명시하고 있습니다(강조는 제가 추가함).
GET 요청에서 유효한 바이트 범위 사양은 다음 중 하나에 해당할 경우 충족 가능합니다.
- 선택된 표현의 현재 길이보다 작은 첫 번째 위치(first-pos)를 가진 정수 범위(int-range)이거나
- 0이 아닌 접미사 길이(suffix-length)를 가진 접미사 범위(suffix-range)입니다.
처음에는 end-pos가 현재 길이보다 작은지 확인했는데, 이는 범위가 만족 가능하기 위한 필수 조건이 아닙니다.
'개발 > 번역' 카테고리의 다른 글
| [번역] 왜 타입스크립트는 당신을 구해주지 못하는가 (1) | 2025.12.03 |
|---|---|
| [번역] 상태 기반 렌더링 vs 시그널 기반 렌더링 (2) | 2025.11.06 |
| [번역] CSS 길이 단위 이해하기 (1) | 2025.09.02 |
| [번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가 (10) | 2025.08.11 |
| [번역] 리액트의 개방-폐쇄 원칙: 확장 가능한 컴포넌트 만들기 (3) | 2025.06.29 |