Devliner

Cloudflare R2 Presigned URL로 브라우저 직접 업로드 구현하기

Cloudflare R2 Presigned URL로 브라우저 직접 업로드 구현하기 대표 이미지

목표와 한 줄 답

서버를 거치지 않고 브라우저에서 Cloudflare R2로 파일을 직접 업로드하려면 백엔드에서 Presigned URL을 생성해 클라이언트에 전달하고, 클라이언트는 해당 URL로 PUT 요청을 보내면 된다. 이 방식은 서버 대역폭을 절약하고 업로드 속도를 높이며, URL에 만료 시간과 권한을 제한해 보안을 유지할 수 있다.

핵심 항목

1. 백엔드 Presigned URL 생성

준비

  • R2 버킷과 API 토큰(Admin Read & Write 또는 Object Read & Write 권한)
  • AWS SDK(예: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner) 또는 호환 라이브러리
  • 백엔드 엔드포인트(Node.js, Python 등)

단계

  1. S3Client 인스턴스 생성 시 endpoint를 https://<ACCOUNT_ID>.r2.cloudflarestorage.com으로 설정
  2. PutObjectCommand로 업로드할 객체 키와 메타데이터 지정
  3. getSignedUrl 함수로 만료 시간(expiresIn, 초 단위)을 설정해 Presigned URL 생성
  4. 생성된 URL을 JSON 응답으로 클라이언트에 반환

주의점

  • expiresIn을 너무 길게 설정하면 URL이 유출됐을 때 위험하므로 업로드 예상 시간보다 약간 여유만 둔다(예: 300~3600초)
  • Presigned URL 생성 시 지정한 Content-Type과 클라이언트 업로드 시 헤더가 일치해야 한다

완료 기준

  • 백엔드 엔드포인트 호출 시 유효한 Presigned URL이 반환되고, URL에 X-Amz-Signature와 X-Amz-Expires 쿼리 파라미터가 포함된다

2. R2 버킷 CORS 설정

준비

  • Cloudflare 대시보드 접근 권한 또는 Wrangler CLI
  • 클라이언트가 요청할 도메인 목록

단계

  1. Cloudflare 대시보드에서 R2 버킷 선택 후 Settings 탭 이동
  2. CORS Policy 섹션에서 Add CORS Policy 클릭
  3. AllowedOrigins에 클라이언트 도메인 입력(예: https://example.com, 개발 시 http://localhost:3000)
  4. AllowedMethods에 PUT 추가
  5. AllowedHeaders에 Content-Type, Content-Length 등 업로드 시 사용할 헤더 추가
  6. ExposeHeaders에 ETag 추가(업로드 완료 후 응답 헤더 읽기 위해)

주의점

  • AllowedOrigins에 *를 사용하면 모든 도메인에서 접근 가능하므로 프로덕션에서는 구체적인 도메인만 명시한다
  • Presigned URL은 CORS 정책과 독립적이지만, 브라우저가 preflight OPTIONS 요청을 보낼 수 있으므로 CORS 설정이 필수다

완료 기준

  • 브라우저 개발자 도구 네트워크 탭에서 OPTIONS preflight 요청이 200 응답을 받고, Access-Control-Allow-Origin 헤더가 올바르게 반환된다

3. 클라이언트 업로드 구현

준비

  • 백엔드로부터 받은 Presigned URL
  • 업로드할 File 객체(input type="file" 또는 Blob)
  • fetch 또는 XMLHttpRequest API

단계

  1. 백엔드 엔드포인트를 호출해 Presigned URL 요청
  2. 응답으로 받은 URL에 PUT 메서드로 파일 데이터를 body에 담아 요청
  3. Content-Type 헤더를 Presigned URL 생성 시 지정한 값과 동일하게 설정
  4. 응답 상태 코드가 200이면 업로드 성공

주의점

  • PUT 요청 시 body에는 File 객체를 직접 전달하고, FormData로 감싸지 않는다(Presigned URL은 multipart/form-data가 아닌 바이너리 직접 업로드를 가정)
  • Content-Type이 일치하지 않으면 403 SignatureDoesNotMatch 오류가 발생한다

완료 기준

  • PUT 요청 응답이 200이고, ETag 헤더가 반환되며, R2 버킷에서 업로드된 객체를 확인할 수 있다

전체 실행 순서

  1. R2 버킷 생성 및 API 토큰 발급
  2. 백엔드에 AWS SDK 설치 및 S3Client 설정
  3. Presigned URL 생성 엔드포인트 구현
  4. R2 버킷에 CORS 정책 추가
  5. 클라이언트에서 파일 선택 UI 구현
  6. 파일 선택 시 백엔드로 URL 요청
  7. 받은 Presigned URL로 PUT 요청 전송
  8. 업로드 완료 후 응답 상태 확인

주의점과 실패하기 쉬운 지점

Presigned URL 만료

URL 생성 후 expiresIn 시간이 지나면 403 AccessDenied 오류가 발생한다. 대용량 파일이나 느린 네트워크 환경을 고려해 충분한 여유를 두되, 보안을 위해 필요 이상으로 길게 설정하지 않는다.

Content-Type 불일치

Presigned URL 생성 시 PutObjectCommand에 ContentType을 지정했다면, 클라이언트 PUT 요청의 Content-Type 헤더가 정확히 일치해야 한다. 불일치 시 SignatureDoesNotMatch 오류가 반환된다.

CORS preflight 실패

AllowedHeaders에 클라이언트가 전송하는 모든 커스텀 헤더를 포함하지 않으면 preflight OPTIONS 요청이 실패하고 브라우저가 실제 PUT 요청을 차단한다. Content-Type, Content-Length, Authorization 등 필요한 헤더를 모두 명시한다.

FormData 사용 오류

Presigned URL은 바이너리 직접 업로드를 전제로 하므로, body에 File 객체를 그대로 전달해야 한다. FormData로 감싸면 multipart/form-data 형식이 되어 서명 불일치로 업로드가 실패한다.

권한 부족

API 토큰에 Object Read & Write 권한이 없으면 Presigned URL 생성은 성공하지만 실제 업로드 시 403 오류가 발생할 수 있다. 토큰 권한을 확인하고 필요 시 재발급한다.

완료 확인 체크리스트

  • [ ] 백엔드 엔드포인트가 유효한 Presigned URL을 반환한다
  • [ ] URL에 X-Amz-Signature, X-Amz-Expires 파라미터가 포함되어 있다
  • [ ] R2 버킷 CORS 정책에 클라이언트 도메인과 PUT 메서드가 추가되어 있다
  • [ ] 브라우저 개발자 도구에서 OPTIONS preflight 요청이 200 응답을 받는다
  • [ ] 클라이언트 PUT 요청의 Content-Type이 Presigned URL 생성 시 지정한 값과 일치한다
  • [ ] PUT 요청 응답 상태 코드가 200이고 ETag 헤더가 반환된다
  • [ ] R2 버킷 대시보드 또는 API로 업로드된 객체를 확인할 수 있다
  • [ ] 만료 시간 이후 동일 URL로 업로드 시도 시 403 오류가 발생한다

참고 자료

  • Cloudflare
  • R2
  • Presigned

관련 글

전체 보기