브라우저에서 파일을 업로드하거나 외부 리소스를 불러올 때 콘솔에 붉은 글씨로 Access to fetch has been blocked by CORS policy라는 에러가 뜨는 경험은 웹 개발자라면 누구나 한 번쯤 겪는다. 특히 바이브 코딩으로 빠르게 프로젝트를 만들 때, 로컬 개발 환경에서는 문제없이 작동하던 기능이 배포 직후 CORS 에러로 멈추는 상황은 매우 흔하다.
CORS(Cross-Origin Resource Sharing)는 단순한 설정값이 아니다. 브라우저가 사용자를 보호하기 위해 30년 가까이 유지해 온 동일 출처 정책(Same-Origin Policy) 위에 세워진 보안 메커니즘이며, 이 구조를 이해하지 못하면 Cloudflare R2 직접 업로드, Vercel 서버리스 payload 우회 같은 실전 아키텍처를 제대로 구성할 수 없다.
이 문서에서는 CORS가 탄생한 배경부터, R2 버킷에 CORS를 설정하는 구체적 방법, Vercel 서버리스 4.5MB 제한을 presigned URL로 우회할 때 사이트 주소와 localhost를 AllowedOrigins에 등록해야 하는 원리까지 하나의 흐름으로 정리한다.
1. CORS의 탄생 배경과 동일 출처 정책
1.1 동일 출처 정책이 만들어진 이유
-
1995년 Netscape Navigator 2.0에 JavaScript가 처음 탑재되면서, 스크립트가 브라우저 안에서 다른 웹페이지의 데이터에 접근할 수 있는 가능성이 열렸다. 예를 들어 악의적인 사이트 A가 사용자의 브라우저를 통해 은행 사이트 B의 계좌 정보를 읽어올 수 있다면 심각한 보안 문제가 된다.
-
이 위험을 차단하기 위해 Netscape는 동일 출처 정책(Same-Origin Policy, SOP) 을 도입했다. 핵심 원칙은 간단한데, 한 출처(Origin)에서 로드된 스크립트는 다른 출처의 리소스에 자유롭게 접근할 수 없다는 것이다.
-
여기서 출처(Origin) 는 세 가지 요소의 조합으로 정의된다. 바로 스킴(프로토콜), 호스트(도메인), 포트 세 가지다.
https://example.com:443과http://example.com:80은 스킴이 다르므로 서로 다른 출처이고,https://example.com과https://api.example.com은 호스트가 다르므로 역시 다른 출처다. -
SOP는 지금도 모든 주요 브라우저(Chrome, Firefox, Safari, Edge)에서 기본 보안 정책으로 작동하고 있으며, 이 정책이 없으면 웹은 사실상 안전하지 않다.
1.2 그런데 왜 다른 출처 접근이 필요해졌는가
-
웹이 발전하면서 프론트엔드와 백엔드가 분리되고, CDN에서 이미지를 로드하고, 외부 API를 호출하는 구조가 보편화되었다.
https://myapp.com에서https://api.myapp.com이나https://cdn.example.com의 리소스를 불러와야 하는 상황이 일상이 된 것이다. -
SOP만 존재하면 이런 정당한 교차 출처 요청까지 전부 차단된다. 이 문제를 해결하기 위해 2006년경부터 W3C에서 표준화 작업이 시작되었고, 그 결과물이 바로 CORS(Cross-Origin Resource Sharing) 다.
-
CORS는 SOP를 대체하는 것이 아니라, SOP 위에서 서버가 명시적으로 허용한 출처에 한해 교차 출처 접근을 가능하게 하는 확장 메커니즘이다. 서버가 HTTP 응답 헤더를 통해 허용 여부를 브라우저에 알려주는 구조이므로, 허용 권한은 항상 서버 쪽에 있다.
핵심 포인트: CORS는 브라우저의 보안 정책(SOP)을 완화하는 메커니즘이다. 서버가 응답 헤더로 특정 출처를 허용하지 않으면 브라우저는 응답 데이터를 JavaScript에 전달하지 않는다. 이 구조는 서버가 아닌 브라우저가 강제하는 것이며, curl이나 Postman 같은 도구에서는 CORS가 적용되지 않는다.
2. CORS의 작동 원리와 핵심 헤더
2.1 단순 요청과 사전 요청(Preflight)
-
브라우저가 교차 출처 요청을 보낼 때, 요청의 종류에 따라 두 가지 방식으로 처리된다. 첫 번째는 단순 요청(Simple Request) 으로, GET이나 POST 메서드를 사용하고 특별한 커스텀 헤더가 없는 경우다. 이때 브라우저는 요청을 바로 보내고, 서버 응답의
Access-Control-Allow-Origin헤더를 확인해서 JavaScript의 응답 접근 허용 여부를 결정한다. -
두 번째는 사전 요청(Preflight Request) 으로, PUT, DELETE 같은 메서드를 사용하거나,
Content-Type: application/json같은 비표준 헤더를 포함하는 경우에 발생한다. 브라우저는 실제 요청을 보내기 전에 OPTIONS 메서드로 서버에 먼저 물어본다. 이 서버에 PUT 요청을 보내도 되는지, 이 헤더를 포함해도 되는지 확인하는 것이다. -
서버가 OPTIONS 요청에 대해
Access-Control-Allow-Methods: PUT과Access-Control-Allow-Headers: Content-Type같은 헤더를 응답으로 돌려주면, 브라우저는 비로소 실제 PUT 요청을 전송한다. 사전 요청이 실패하면 실제 요청은 아예 전송되지 않는다.
2.2 CORS 응답 헤더 정리
| 헤더 이름 | 역할 | 예시 값 |
|---|---|---|
| Access-Control-Allow-Origin | 허용할 출처 지정 | https://myapp.com 또는 * |
| Access-Control-Allow-Methods | 허용할 HTTP 메서드 | GET, PUT, POST, DELETE |
| Access-Control-Allow-Headers | 허용할 요청 헤더 | Content-Type, Authorization |
| Access-Control-Expose-Headers | JavaScript에서 읽을 수 있는 응답 헤더 | ETag, Content-Length |
| Access-Control-Max-Age | Preflight 응답 캐시 시간(초) | 3600 |
| Access-Control-Allow-Credentials | 쿠키·인증 정보 포함 허용 | true |
이 중에서 가장 중요한 것은 Access-Control-Allow-Origin이다. 이 헤더가 요청을 보낸 출처와 일치하지 않으면, 나머지 헤더가 아무리 올바르게 설정되어 있어도 브라우저는 응답을 차단한다.
2.3 와일드카드(*)와 특정 출처 지정의 차이
-
Access-Control-Allow-Origin: *는 모든 출처를 허용한다는 의미다. 공개적으로 읽기만 가능한 CDN 이미지나 공용 API에는 적합하지만, 인증 정보(쿠키, Authorization 헤더)를 포함하는 요청에서는 사용할 수 없다. 브라우저가 와일드카드와Access-Control-Allow-Credentials: true를 동시에 허용하지 않기 때문이다. -
파일 업로드처럼 특정 사이트에서만 접근해야 하는 경우,
https://myapp.vercel.app이나http://localhost:3000처럼 정확한 출처를 지정해야 한다. 이렇게 하면 허가받지 않은 도메인에서의 요청을 차단할 수 있어 보안성이 높아진다. -
OWASP에서도 민감한 데이터를 다루는 엔드포인트에서는 와일드카드 대신 화이트리스트 방식으로 출처를 명시하도록 권장하고 있다.
핵심 포인트: Preflight 요청은 브라우저가 실제 요청 전에 서버에 허가를 구하는 절차다. PUT이나 커스텀 헤더를 사용하는 업로드 요청은 반드시 Preflight를 거치며, 서버의 CORS 설정에 해당 메서드와 헤더가 포함되어 있어야 한다.
3. Vercel 서버리스 payload 제한과 R2 직접 업로드 구조
3.1 Vercel 서버리스 함수의 4.5MB 벽
-
Vercel에 배포한 Next.js나 기타 프레임워크의 API 라우트는 서버리스 함수(Serverless Function)로 실행된다. 이 서버리스 함수에는 요청 본문(request body) 크기 4.5MB 라는 하드 제한이 존재한다. 이 제한을 초과하면 HTTP 413 FUNCTION_PAYLOAD_TOO_LARGE 에러가 발생한다.
-
로컬 개발 환경에서는 이 제한이 적용되지 않기 때문에, 10MB 이미지를 업로드해도 잘 작동한다. 그런데 Vercel에 배포하면 갑자기 업로드가 실패하는 상황이 벌어진다. 바이브 코딩 시 AI가 로컬에서 테스트한 코드를 그대로 배포하면 높은 확률로 만나게 되는 함정이다.
-
이 제한은 Vercel 인프라의 구조적 특성에서 온 것이며, Pro 플랜으로 업그레이드해도 변경되지 않는다. Vercel 공식 문서에서도 대용량 파일은 서버리스 함수를 거치지 않고 스토리지에 직접 업로드하라고 안내하고 있다.
3.2 Presigned URL을 이용한 직접 업로드 패턴
-
해결 방법의 핵심은 클라이언트(브라우저)가 Vercel 서버리스 함수를 경유하지 않고, R2 버킷에 직접 파일을 업로드하는 것이다. 이때 사용하는 것이 Presigned URL(사전 서명된 URL) 이다.
-
전체 흐름은 다음과 같다. 먼저 클라이언트가 서버리스 함수에 presigned URL 생성을 요청한다. 이 요청에는 파일 본문이 포함되지 않으므로 4.5MB 제한과 무관하다. 서버리스 함수는 R2의 S3 호환 API를 사용해 presigned URL을 생성하고 클라이언트에 반환한다. 클라이언트는 이 URL로 R2 버킷에 직접 PUT 요청을 보내 파일을 업로드한다.
-
이 패턴에서 서버리스 함수가 처리하는 데이터는 presigned URL 문자열 하나뿐이므로, 100MB 파일이든 1GB 파일이든 서버리스 함수의 payload 제한과는 완전히 무관하게 업로드가 가능하다.
| 항목 | 서버 경유 업로드 | Presigned URL 직접 업로드 |
|---|---|---|
| 파일 경로 | 브라우저 → Vercel 함수 → R2 | 브라우저 → R2 직접 |
| Vercel payload 제한 | 4.5MB 적용 | 적용되지 않음 |
| 서버 부하 | 파일 크기만큼 메모리 사용 | URL 생성 비용만 발생 |
| CORS 설정 필요 여부 | 불필요(같은 출처) | 필수(교차 출처) |
| 대용량 파일 지원 | 불가(4.5MB 초과 시 실패) | R2 단일 객체 최대 5GB 지원 |
핵심 포인트: Presigned URL 직접 업로드는 Vercel 서버리스 4.5MB 제한을 우회하는 표준 패턴이다. 하지만 브라우저가 R2 버킷 URL로 직접 요청을 보내는 구조이므로, 교차 출처 요청이 되어 R2 버킷에 CORS 설정이 반드시 필요하다.
4. R2 CORS 설정이 필요한 이유와 구체적 방법
4.1 왜 R2에 CORS를 설정해야 하는가
-
Presigned URL 자체는 인증과 권한을 URL 쿼리 파라미터에 포함하고 있어서, 누가 이 URL을 가지고 있든 해당 작업(PUT, GET 등)을 수행할 수 있다. 그런데 presigned URL이 유효해도 CORS가 설정되어 있지 않으면 브라우저에서는 업로드가 실패한다.
-
이유는 앞서 설명한 브라우저의 SOP 때문이다.
https://myapp.vercel.app에서 실행되는 JavaScript가https://my-bucket.account-id.r2.cloudflarestorage.com으로 PUT 요청을 보내면, 이것은 완전히 다른 출처에 대한 요청이다. 브라우저는 먼저 OPTIONS Preflight 요청을 보내고, R2 서버가 올바른 CORS 헤더를 응답하지 않으면 실제 PUT 요청 자체를 전송하지 않는다. -
curl이나 Postman, 또는 서버 사이드 코드에서 presigned URL로 업로드하면 CORS 에러가 발생하지 않는다. CORS는 오직 브라우저에서만 강제되는 정책이기 때문이다. 바이브 코딩 시 서버 코드에서 테스트할 때는 문제가 없다가, 프론트엔드에서 호출하면 갑자기 실패하는 원인이 바로 이것이다.
4.2 R2 CORS 설정 항목별 역할
Cloudflare R2 대시보드에서 버킷의 Settings 탭으로 이동하면 CORS Policy 섹션에서 JSON 형식으로 정책을 입력할 수 있다. 각 항목의 의미는 다음과 같다.
| R2 CORS 필드 | 대응하는 응답 헤더 | 설정 시 고려사항 |
|---|---|---|
| AllowedOrigins | Access-Control-Allow-Origin | 요청을 보내는 사이트의 출처를 정확히 입력. 경로(/) 포함 불가 |
| AllowedMethods | Access-Control-Allow-Methods | 업로드는 PUT, 다운로드는 GET, 삭제는 DELETE 포함 |
| AllowedHeaders | Access-Control-Allow-Headers | Content-Type 필수. 커스텀 헤더 사용 시 해당 헤더명 추가 |
| ExposeHeaders | Access-Control-Expose-Headers | JavaScript에서 ETag 등을 읽으려면 명시 필요 |
| MaxAgeSeconds | Access-Control-Max-Age | Preflight 캐시 시간. 3600(1시간) 권장 |
-
AllowedOrigins에는 스킴 + 호스트 + 포트까지만 입력한다.
https://myapp.vercel.app은 유효하지만,https://myapp.vercel.app/또는https://myapp.vercel.app/upload는 잘못된 형식이다. 경로가 포함되면 R2가 해당 출처를 인식하지 못한다. -
AllowedMethods에 PUT을 빠뜨리면 presigned URL로 업로드할 때 Preflight에서 실패한다. 업로드와 다운로드를 모두 처리하려면 GET과 PUT을 함께 포함해야 한다.
-
MaxAgeSeconds를 설정하면 브라우저가 Preflight 응답을 캐시해서, 같은 출처에서 반복 요청 시 매번 OPTIONS 요청을 보내지 않는다. 네트워크 비용과 응답 지연을 줄일 수 있으며, 최대값은 86400(24시간)이지만 브라우저에 따라 2시간으로 제한되기도 한다.
5. 사이트 주소와 localhost를 모두 등록해야 하는 이유
5.1 개발 환경과 프로덕션 환경은 서로 다른 출처
-
로컬에서 Next.js 개발 서버를 실행하면 주소는 보통
http://localhost:3000이다. 배포된 사이트 주소는https://myapp.vercel.app이다. 이 두 주소는 스킴(http vs https), 호스트(localhost vs myapp.vercel.app), 포트(3000 vs 443) 모두 다르므로 완전히 다른 출처다. -
R2 CORS 정책의 AllowedOrigins에
https://myapp.vercel.app만 등록하면, 배포된 사이트에서는 정상 작동하지만 로컬 개발 환경에서는 CORS 에러가 발생한다. 브라우저가http://localhost:3000에서 보낸 요청의 Origin 헤더를 R2가 확인했을 때, 허용 목록에 해당 출처가 없기 때문이다. -
반대로
http://localhost:3000만 등록하면, 로컬에서는 잘 되지만 배포 후에 CORS 에러가 발생한다. 따라서 두 출처를 모두 AllowedOrigins 배열에 포함시켜야 개발과 프로덕션 환경 모두에서 정상 작동한다.
5.2 바이브 코딩에서 자주 발생하는 CORS 실수
-
AI에게 파일 업로드 기능을 요청하면, 대부분 서버를 경유하는 업로드 코드를 생성한다. 로컬에서는 4.5MB 제한이 없으므로 잘 작동하지만, Vercel 배포 후 대용량 파일에서 413 에러가 발생한다. 이때 presigned URL 패턴으로 전환하면 이번에는 CORS 에러를 만나게 된다.
-
CORS 에러를 해결하기 위해 AI에게 물어보면, 종종
AllowedOrigins: ["*"]로 모든 출처를 허용하라는 답변이 나온다. 이 설정은 당장은 작동하지만, 누구든 presigned URL만 획득하면 어떤 사이트에서든 업로드가 가능해지므로 보안상 좋지 않다. 특히 presigned URL 생성 엔드포인트에 인증이 부실한 경우, 외부에서 버킷을 남용할 수 있다. -
또 하나 흔한 실수는 AllowedOrigins에 포트 번호를 빠뜨리는 것이다.
http://localhost와http://localhost:3000은 다른 출처다. 포트가 기본값(http는 80, https는 443)이 아니라면 반드시 포트 번호까지 명시해야 한다. -
커스텀 도메인을 사용하는 경우도 주의해야 한다.
https://myapp.com과https://www.myapp.com은 호스트가 다르므로 별도의 출처다. 두 주소 모두에서 접근한다면 AllowedOrigins에 둘 다 포함해야 한다.
5.3 실전 R2 CORS 설정 구성 예시
Cloudflare R2 대시보드에서 버킷의 Settings > CORS Policy로 이동한 뒤, 다음과 같은 구조로 JSON을 입력한다.
| 순서 | 필드 | 값 (업로드용 예시) |
|---|---|---|
| 1 | AllowedOrigins | https://myapp.vercel.app, http://localhost:3000 |
| 2 | AllowedMethods | PUT, GET, HEAD |
| 3 | AllowedHeaders | Content-Type |
| 4 | ExposeHeaders | ETag |
| 5 | MaxAgeSeconds | 3600 |
AllowedOrigins 배열에 프로덕션 도메인과 로컬 개발 주소를 함께 나열하는 것이 핵심이다. 개발이 완료된 후에는 localhost를 제거하고 프로덕션 도메인만 남기는 것이 보안상 바람직하다.
Wrangler CLI를 사용하는 경우에는 JSON 파일을 만들어 npx wrangler r2 bucket cors set BUCKET_NAME --file cors.json 명령으로 적용할 수 있다. 설정 적용 후 반영까지 최대 30초가 걸릴 수 있으므로, 테스트 시 약간의 여유를 두는 것이 좋다.
핵심 포인트: 로컬 개발 환경(http://localhost:3000)과 배포 환경(https://myapp.vercel.app)은 서로 다른 출처이므로, R2 CORS AllowedOrigins에 두 주소를 모두 등록해야 한다. 와일드카드(*)보다 명시적 출처 지정이 보안상 안전하며, 개발 완료 후에는 localhost를 제거하는 것을 권장한다.
6. 바이브 코딩 시 CORS 관련 점검 목록
6.1 배포 전 확인해야 할 항목
-
서버리스 함수를 경유하는 업로드인지, 클라이언트 직접 업로드인지 확인한다. 파일이 서버리스 함수 본문으로 전달되는 구조라면 4.5MB를 초과하는 파일에서 반드시 실패한다.
-
R2 버킷의 CORS 정책에 프로덕션 도메인이 포함되어 있는지 확인한다. 로컬에서만 테스트하고 프로덕션 도메인을 빠뜨리면, 배포 후 모든 업로드가 실패한다.
-
AllowedMethods에 실제 사용하는 메서드가 모두 포함되어 있는지 확인한다. 업로드에 PUT을 쓰면서 AllowedMethods에 GET만 있으면 Preflight에서 거부된다.
-
AllowedHeaders에 요청에서 사용하는 헤더가 포함되어 있는지 확인한다.
Content-Type은 파일 업로드 시 거의 필수적으로 사용되는 헤더다. -
브라우저 개발자 도구의 Network 탭에서 OPTIONS 요청과 실제 요청을 분리해서 확인한다. OPTIONS 요청이 200 OK를 반환하는지, 응답 헤더에 올바른
Access-Control-Allow-Origin이 포함되어 있는지 살펴보면 문제 지점을 빠르게 찾을 수 있다.
6.2 CORS 에러 발생 시 디버깅 순서
-
브라우저 콘솔의 에러 메시지를 정확히 읽는다. No 'Access-Control-Allow-Origin' header is present라면 AllowedOrigins 설정 문제이고, Method not allowed라면 AllowedMethods 문제이고, Request header field is not allowed라면 AllowedHeaders 문제다.
-
R2 CORS 정책 변경 후 최대 30초 까지 전파 지연이 있을 수 있으므로, 설정 변경 직후 테스트가 실패해도 잠시 기다렸다가 재시도한다.
-
curl로 같은 요청을 보내서 성공하는지 확인한다. curl에서는 성공하고 브라우저에서만 실패한다면, 이는 CORS 문제가 맞으며 서버 자체의 인증·권한 문제가 아니다. 단, curl 테스트 시
-H 'Origin: https://myapp.vercel.app'헤더를 포함해야 CORS 응답 헤더를 확인할 수 있다.
7. 마무리
위에서 살펴본 R2 CORS 설정과 Vercel 서버리스 우회 업로드의 핵심 내용을 정리하면 다음과 같다.
핵심 요약:
- CORS는 1995년에 도입된 동일 출처 정책(SOP) 위에서 작동하는 브라우저 보안 메커니즘이며, 서버가 응답 헤더로 허용 출처를 명시해야 교차 출처 요청이 가능하다
- Vercel 서버리스 함수에는 요청 본문 4.5MB 하드 제한이 있어, 대용량 파일은 presigned URL을 통해 R2에 직접 업로드하는 패턴이 표준적이다
- 브라우저에서 R2로 직접 업로드하면 교차 출처 요청이 되므로, R2 버킷에 CORS 정책을 반드시 설정해야 한다
- AllowedOrigins에는 프로덕션 도메인과 로컬 개발 주소를 모두 포함해야 두 환경 모두에서 작동한다
- 와일드카드(*) 대신 명시적 출처 지정이 보안상 안전하며, 개발 완료 후 localhost는 제거하는 것이 좋다
- PUT 업로드는 Preflight(OPTIONS) 요청을 거치므로 AllowedMethods와 AllowedHeaders까지 빠짐없이 설정해야 한다
R2 직접 업로드를 구현할 때는, presigned URL 생성 로직과 R2 CORS 정책 설정을 하나의 세트로 취급하는 것이 실수를 줄이는 방법이다. 바이브 코딩으로 빠르게 프로토타입을 만들더라도, CORS 구조를 이해하고 있으면 배포 후 발생하는 에러를 스스로 진단하고 해결할 수 있다.