조건부 문서 다운로드 추가

CloudflareBeginner
지금 연습하기

소개

문서 뷰어는 다음 몇 바이트만 필요하거나, 캐시된 사본이 아직 최신 상태인지 확인만 하면 되는 경우가 많습니다. 모든 요청마다 전체 파일을 다운로드하면 불필요한 작업이 발생합니다. 이 실습에서는 비공개 R2 스토리지를 사용하는 보호된 Worker 에 HTTP 검증자와 단일 바이트 범위 다운로드를 추가합니다.

먼저 Stream Documents Through a Worker 실습을 완료합니다. 이 실습은 Node.js 22.22.0, Wrangler 4.131.1 및 제공된 토큰 확인 모듈이 설치된 새 VM 에서 시작합니다. 새 버킷을 만들고 새 Worker 를 배포합니다. R2 구독과 학습 계정 권한은 미리 준비되어 있어야 합니다. 작업 및 스토리지 요금은 R2 pricing에서 확인합니다. 사용자 지정 도메인은 필요하지 않습니다. 합성 텍스트만 저장하며, 종료하기 전에 리소스를 정리합니다.

애플리케이션 버킷 연결

이 단계에서는 이 VM 을 인증하고 애플리케이션용 독립 비공개 버킷을 만듭니다. 디바이스 인증으로 학습 계정을 확인합니다. R2 버킷 관리는 해당 계정으로 제한된 별도의 API 토큰을 사용합니다.

아래 명령 구문에 사용할 Bash 를 시작한 다음, 준비된 프로젝트로 이동하여 도구를 확인합니다. 리소스 이름 변수의 값이 유지되도록 같은 터미널을 계속 사용합니다.

bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version

표시된 디바이스 코드를 자신의 브라우저에서 인증합니다. 동의하기 전에 학습 계정과 요청된 account 및 user 읽기 권한 범위를 확인합니다.

npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json

loggedIn: true인지 확인합니다. 계정이 하나만 표시되더라도 계정 이름을 확인합니다. 아래의 YOUR_ACCOUNT_ID를 해당 계정의 실제 32 자 ID 로 바꿉니다. openssl rand -hex 6은 무작위 16 진수 문자 12 개를 생성하므로 이 실습이 이전 실행과 충돌하지 않습니다. here-document 가 표준 구성 파일을 작성하며, 셸이 그 안의 변수 값을 치환합니다.

ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r03-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","main":"src/index.js","workers_dev":true,"compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON

버킷을 관리하려면 Cloudflare 프로필의 API Tokens 페이지를 열고 이 실습의 이름을 포함한 사용자 지정 토큰을 만듭니다. Account → Workers R2 Storage → Edit 권한을 부여하고, Account Resources를 저장해 둔 ID 의 학습 계정으로 제한합니다. 만료 기간은 짧게 설정합니다. 다른 계정이나 관련 없는 권한은 포함하지 않습니다. 이 관리 토큰은 생성과 삭제를 포함한 버킷 관리에 사용합니다. 이 실습에서 Worker 는 DOCUMENTS 바인딩을 통해 R2 객체에 접근합니다.

토큰을 한 번만 복사하여 이 숨겨진 VM 프롬프트에 입력합니다. umask 077은 파일 접근 권한을 사용자로 제한하고, read -s는 입력 내용을 화면에 표시하지 않습니다. 이 파일은 Wrangler 의 표준 토큰 변수를 사용하며 Git 에서 제외됩니다.

umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN

R2 관리 명령에만 --env-file=.env.management를 사용합니다. 일반 whoami 명령은 계속 VM 의 디바이스 인증을 확인합니다.

--env-file을 각 Wrangler 명령의 끝에 배치하여 파일 인수 목록에 명령 이름까지 포함되지 않게 합니다. 각 버킷을 만든 후 Wrangler 가 구성에 바인딩을 추가할지 물으면 n을 입력하고 Enter 를 누릅니다. 필요한 바인딩은 이미 구성에 포함되어 있습니다.

npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management

버킷 목록을 표시하고 정확히 생성된 이름을 찾습니다. 다른 버킷은 다른 작업에 속하므로 변경하지 않습니다.

npx wrangler r2 bucket list --env-file=.env.management

Dashboard 에서 Storage & databases → R2 → Overview를 열고, 방금 확인한 버킷을 선택한 다음 비어 있는 객체 목록을 확인합니다. 버킷 설정에서 공개 개발 URL 과 사용자 지정 도메인은 비활성화된 상태로 둡니다. Dashboard 에 표시되는 버킷 이름으로 대상을 확인할 수 있으며, 이후 다운로드 검사가 저장된 바이트를 검증합니다.

Worker 스크립트 권한은 배포에 사용됩니다. KV 권한은 Wrangler 의 삭제 기록 관리에 사용되며, 이 실습에서는 KV 네임스페이스를 만들지 않습니다. R2 관리 토큰은 계정 범위가 지정된 별도의 자격 증명으로 유지됩니다.

조건부 및 부분 읽기 구현

이 단계에서는 R2 메타데이터를 사용하여 본문이 필요한지 결정합니다. ETag는 파일 버전 레이블처럼 작동합니다. 클라이언트에 이미 사본이 있으면 If-None-Match에 해당 레이블을 담아 파일이 변경되었는지 질의합니다. 일치하면 본문이 없는 304 Not Modified 응답을 반환하므로 같은 바이트를 다시 다운로드하지 않아도 됩니다. Range 요청을 사용하면 뷰어가 큰 파일의 일부를 가져오거나 중단된 다운로드를 재개할 수 있습니다. 이 요청은 포함 범위인 바이트 위치를 지정하고, 해당 조각을 설명하는 Content-Range 헤더와 함께 206 Partial Content 응답을 반환합니다.

다음 핸들러를 사용합니다. head()는 바이트를 읽지 않고 메타데이터만 읽습니다. 이후 get()에는 onlyIf.etagMatches가 포함되어 있으므로 두 호출 사이에 객체가 변경되면 오래된 메타데이터를 기준으로 객체를 반환하지 않습니다. 이 엔드포인트는 단일 범위와 ETag 기반 If-Range를 지원하며, 지원하지 않는 다중 범위 구문에는 400 을 반환합니다. If-Range ETag 가 다르면 전체 200 응답을 반환하여 클라이언트가 이전 사본을 새 사본으로 교체할 수 있게 합니다.

cat > src/index.js <<'JS'
import { authorized } from "./auth.js";
export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    if (path === "/health") return new Response("ok");
    if (!await authorized(request, env)) return new Response("Unauthorized", { status: 401 });
    if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
    if (path !== "/documents/report.txt") return new Response("Not found", { status: 404 });
    const key = path.slice(1);
    const metadata = await env.DOCUMENTS.head(key);
    if (!metadata) return new Response("Not found", { status: 404 });
    const headers = new Headers({ "ETag": metadata.httpEtag,
      "Last-Modified": metadata.uploaded.toUTCString(), "Accept-Ranges": "bytes",
      "Cache-Control": "private, no-store" });
    metadata.writeHttpMetadata(headers);
    // GET validators use weak comparison: W/"value" and "value" can match.
    const noneMatch = request.headers.get("If-None-Match");
    if (noneMatch && noneMatch.split(",").some(tag => tag.trim() === "*" || tag.trim().replace(/^W\//, "") === metadata.httpEtag))
      return new Response(null, { status: 304, headers });
    const since = Date.parse(request.headers.get("If-Modified-Since") || "");
    const uploadedSeconds = Math.floor(metadata.uploaded.getTime() / 1000) * 1000;
    if (!noneMatch && Number.isFinite(since) && uploadedSeconds <= since)
      return new Response(null, { status: 304, headers });
    let range = request.headers.get("Range");
    const ifRange = request.headers.get("If-Range");
    if (ifRange && ifRange !== metadata.httpEtag) range = null;
    let start = 0, end = metadata.size - 1;
    if (range) {
      const match = /^bytes=(\d*)-(\d*)$/.exec(range);
      // This endpoint supports exactly one range, not multipart ranges.
      if (!match || (!match[1] && !match[2]))
        return new Response("Invalid range", { status: 400 });
      if (!match[1]) { start = Math.max(0, metadata.size - Number(match[2])); }
      else { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end); }
      if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= metadata.size) {
        headers.set("Content-Range", `bytes */${metadata.size}`);
        return new Response("Range not satisfiable", { status: 416, headers });
      }
      headers.set("Content-Range", `bytes ${start}-${end}/${metadata.size}`);
    }
    // Do not mix a HEAD result with bytes from an object replaced in between.
    const object = await env.DOCUMENTS.get(key, { onlyIf: { etagMatches: metadata.etag },
      ...(range ? { range: { offset: start, length: end - start + 1 } } : {}) });
    if (!object) return new Response("Not found", { status: 404 });
    if (!("body" in object)) return new Response("Object changed; retry", { status: 412 });
    headers.set("Content-Length", String(range ? end - start + 1 : metadata.size));
    return new Response(object.body, { status: range ? 206 : 200, headers });
  }
};
JS

요청한 시작 위치는 0 부터 세는 인덱스입니다. bytes=-3과 같은 접미사 범위는 마지막 세 바이트를 의미합니다. 객체 크기를 초과하는 시작 위치에는 Content-Range: bytes */SIZE 헤더와 함께 416 을 반환합니다. 조건부 검사를 범위 선택보다 먼저 수행합니다. 두 헤더가 모두 있으면 If-None-Match 검사를 날짜 검사보다 먼저 수행합니다.

로컬 애플리케이션 시크릿을 만들고 번들을 확인합니다.

umask 077
printf "ACCESS_TOKEN=%s\n" "$(openssl rand -hex 24)" > .dev.vars
npx wrangler deploy --dry-run

로컬 전체 본문과 범위 본문 비교

이 단계에서는 로컬 스토리지만 초기화하고 실제 HTTP 헤더를 확인합니다. 두 객체가 같은 키를 사용하더라도 로컬 객체는 이후의 원격 객체와 별개입니다.

npx wrangler r2 object put "$BUCKET/documents/report.txt" --local --file document.txt --content-type text/plain
npx wrangler dev --ip 127.0.0.1 --port 8787 > dev.log 2>&1 &
DEV_PID=$!

dev.log에서 준비 완료 메시지가 나타날 때까지 기다린 다음, 합성 애플리케이션 시크릿을 로드합니다.

cat dev.log
set -a
source .dev.vars
set +a

전체 응답 헤더와 본문을 별도의 파일에 저장합니다. -D 옵션은 헤더를 파일에 기록합니다.

curl -fsS -D full.headers -H "Authorization: Bearer $ACCESS_TOKEN" http://127.0.0.1:8787/documents/report.txt -o full.txt
cmp document.txt full.txt
cat full.headers

상태 코드가 200 이고, 저장된 콘텐츠 유형과 큰따옴표로 묶인 ETag, Accept-Ranges: bytes가 있는지 확인합니다. 아래의 작은따옴표 안에 큰따옴표를 포함한 정확한 ETag 를 복사하여 ETAG에 입력합니다.

ETAG='"COPY_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" http://127.0.0.1:8787/documents/report.txt

본문이 없는 304 응답인지 확인합니다. 최신 검증자를 사용하면 전체 전송을 피할 수 있지만 버킷이 공개되는 것은 아닙니다.

curl -sS -D range.headers -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" http://127.0.0.1:8787/documents/report.txt -o range.txt
head -c 5 document.txt > expected-range.txt
cmp expected-range.txt range.txt
cat range.headers

206 응답과 Content-Range: bytes 0-4/SIZE가 반환되고, 정확히 일치하는 다섯 바이트가 저장되었는지 확인합니다. 이제 처리할 수 없는 시작 위치를 요청합니다.

curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" http://127.0.0.1:8787/documents/report.txt

416 응답, bytes */SIZE 헤더 및 Range not satisfiable 메시지가 있는지 확인합니다. 플랫폼 검사는 이러한 읽기 작업을 독립적으로 다시 수행합니다.

원격 조건부 전송 확인

이 단계에서는 원격 테스트 객체를 별도로 준비하고 핸들러를 배포합니다. 먼저 로컬 서버를 중지한 다음, 명시적인 --remote 옵션을 사용하여 같은 합성 파일을 업로드합니다.

kill "$DEV_PID"
wait "$DEV_PID" 2>/dev/null || true
npx wrangler r2 object put "$BUCKET/documents/report.txt" --remote --file document.txt --content-type text/plain --env-file=.env.management
npx wrangler deploy
npx wrangler secret bulk .dev.vars

배포된 URL 을 BASE_URL에 입력합니다. 새 배포가 전파되는 동안에는 상태가 변경될 수 있으므로, health 요청에서 ok가 반환될 때까지 확인합니다. 필요하면 최대 1 분 동안 읽기 요청을 다시 시도합니다.

BASE_URL=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
curl -i "$BASE_URL/health"
curl -fsS -D remote.headers -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL/documents/report.txt" -o remote.txt
cmp document.txt remote.txt
cat remote.headers

기억해 둔 로컬 값이 아니라 remote.headers에 있는 원격 ETag 를 사용합니다. 조건부 요청과 부분 요청을 다시 실행합니다.

ETAG='"COPY_REMOTE_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" "$BASE_URL/documents/report.txt"

본문이 없는 304 응답, fixture 의 처음 다섯 바이트가 포함된 206 응답, 올바른 크기 경계가 포함된 416 응답인지 확인합니다. Dashboard 에서 정확한 Worker 바인딩과 버킷 객체를 확인합니다. 버킷의 공개 URL 과 사용자 지정 도메인은 비활성화된 상태로 둡니다. HTTP 헤더와 본문 비교 결과가 범위 요청을 입증하는 기준입니다.

비공개 R2 버킷에 연결된 Worker DOCUMENTS 바인딩

이 예시에서는 DOCUMENTS 가 정확한 비공개 버킷에 연결되어 있습니다. 생성된 이름의 접미사는 달라집니다.

비공개 Standard 버킷의 합성 보고서

객체 행은 report.txt 의 유형을 text/plain, 스토리지 클래스를 Standard, 크기를 41 B 로 표시하며 Public Access 는 Disabled 로 유지됩니다. 생성된 이름과 날짜는 예시입니다. 집계 값인 Bucket Size 는 갱신 지연으로 0 B 로 남을 수 있습니다. 객체 행과 바이트 비교로 파일의 존재를 확인합니다. HTTP 헤더와 본문 비교로 조건부 요청 및 범위 요청의 동작을 확인합니다.

원격 애플리케이션과 버킷 삭제

이 단계에서는 인증이 유지되는 동안 이 실습에서 만든 Worker 와 객체만 삭제합니다. Worker 를 삭제해도 비공개 버킷은 자동으로 삭제되지 않습니다.

npx wrangler delete

생성된 Worker 이름이 정확한지 확인합니다. 업로드한 객체 하나를 명시적으로 삭제한 다음 버킷을 삭제합니다.

BUCKET=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).r2_buckets[0].bucket_name")
npx wrangler r2 object delete "$BUCKET/documents/report.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management

원격에서 만든 객체는 documents/report.txt 하나뿐입니다. 다른 객체가 있다면 이 버킷이 정확한 대상인지 확인하고 소유권을 파악한 후 삭제합니다.

Dashboard 에서 Worker 목록과 버킷 목록을 새로 고치고 플랫폼 정리 검사를 실행합니다. 인증 또는 네트워크 오류가 발생한 경우 삭제 성공으로 간주하지 않습니다.

남은 자격 증명 정리

이 단계에서는 프로필의 API Tokens 페이지에서 이 실습의 관리 토큰을 폐기하고, 로컬 애플리케이션 시크릿을 삭제한 다음 VM 인증을 종료합니다. 이전 정리 검사가 성공한 후에만 수행합니다.

rm .env.management .dev.vars
unset ACCESS_TOKEN
npx wrangler logout
npx wrangler whoami --json || true

loggedIn: false인지 확인합니다. 관리 토큰 폐기는 Dashboard 에서 별도로 수동으로 수행해야 합니다. 로컬 파일만 삭제해도 토큰이 폐기되는 것은 아닙니다. 일반 Dashboard 로그인과 다른 실습의 토큰은 그대로 유지합니다.

요약

조건부 응답에 R2 메타데이터를 사용하고, 단일 바이트 범위를 스트리밍하며, 처리할 수 없는 요청을 다루고, 비공개 다운로드 서비스를 정리합니다.