🔍 검색 키워드: s3 cors 에러, presigned url cors 실패, Access-Control-Allow-Origin 없음, s3 preflight 차단, s3 cors 설정 json, ETag 응답 헤더 못 읽음
요약: S3는 요청에 Origin 헤더가 있고 그 값이 버킷의 CORS 규칙과 맞을 때만 응답에 Access-Control-Allow-Origin을 붙인다. 규칙이 없거나 origin·메서드·헤더 중 하나라도 어긋나면 응답 상태 코드는 200이어도 브라우저가 결과를 차단한다. 버킷 CORS에 정확한 AllowedOrigins(스킴·호스트·포트까지), 업로드에 쓰는 AllowedMethods, 요청에 실어 보내는 AllowedHeaders를 넣으면 해결된다.
S3 CORS 에러는 왜 발생하나요?
브라우저는 다른 출처로 보내는 요청 중 단순 요청이 아닌 것(PUT이거나 Content-Type 같은 헤더를 직접 지정한 경우)에 대해 본 요청 전에 OPTIONS 프리플라이트를 먼저 보낸다. 그 응답에 Access-Control-Allow-Origin이 없으면 본 요청은 아예 보내지 않고 차단한다. presigned URL로 파일을 올릴 때 "서명은 맞는데 업로드가 안 된다"는 상황이 대부분 여기에 해당한다.
테스트 환경과 재현 방식
아래 재현은 실제 S3 버킷이 아니라, S3와 같은 응답 구조를 Node로 만든 로컬 서버에 브라우저와 curl로 요청한 것이다. 브라우저가 어떤 조건에서 차단하는지를 눈으로 확인하기 위한 것이며, S3의 CORS 설정 문법과 허용 값은 AWS 공식 문서를 근거로 했다.
- macOS 27.0.1 (Darwin 27.0.0, arm64)
node --version출력: v20.20.2 / 브라우저: Chrome- 페이지 출처 http://127.0.0.1:8732, API 출처 http://127.0.0.1:8731
재현 1. CORS 헤더가 없을 때 브라우저가 내는 메시지
페이지에서 다른 출처로 PUT을 보낸다.
fetch("http://127.0.0.1:8731/my-key.png", {
method: "PUT",
headers: { "content-type": "image/png" },
body: "x"
})
.then(r => console.log("ok", r.status))
.catch(e => console.log("FAILED:", e.message));직접 실행 결과 (Chrome 콘솔)
Access to fetch at 'http://127.0.0.1:8731/my-key.png' from origin
'http://127.0.0.1:8732' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.
Failed to load resource: net::ERR_FAILED그다음 서버에 "허용 출처 http://127.0.0.1:8732, 허용 메서드 PUT/GET/HEAD, 허용 헤더 content-type"을 넣고 같은 페이지를 다시 열었다.
직접 실행 결과
ok 200코드는 한 글자도 바꾸지 않았다. 바뀐 것은 서버가 응답에 붙이는 헤더뿐이다. S3에서 버킷 CORS 설정을 넣는 행위가 정확히 이것에 해당한다.
응답이 200인데 왜 브라우저만 막히나요?
이 질문이 가장 자주 나온다. 같은 프리플라이트를 curl로 보내 응답 헤더를 직접 비교해 보면 분명해진다.
$ curl -s -D - -o /dev/null -X OPTIONS \
http://127.0.0.1:8731/my-key.png \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type"직접 실행 결과 — CORS 설정이 없을 때
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e"직접 실행 결과 — CORS 설정이 맞을 때
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT, GET, HEAD
Access-Control-Allow-Headers: content-type, x-amz-acl
Access-Control-Max-Age: 3000
Access-Control-Expose-Headers: ETag
ETag: "d41d8cd98f00b204e9800998ecf8427e"둘 다 200 OK다. 차이는 Access-Control-* 헤더의 유무뿐이고, 그 판단과 차단은 전적으로 브라우저가 한다. curl은 CORS 정책을 적용하지 않으므로 터미널에서는 항상 성공한다. "curl로는 되는데 브라우저에서만 안 된다"면 서버·권한 문제가 아니라 CORS 설정 문제다.
Origin이 조금만 달라도 막히나요?
그렇다. 허용 출처를 https://app.example.com 하나만 등록한 서버에 네 가지 Origin으로 프리플라이트를 보내 봤다.
직접 실행 결과
Origin: https://app.example.com
-> Access-Control-Allow-Origin: https://app.example.com
Origin: http://app.example.com
-> 헤더 없음 (브라우저 차단)
Origin: https://app.example.com/
-> 헤더 없음 (브라우저 차단)
Origin: https://www.app.example.com
-> 헤더 없음 (브라우저 차단)스킴이 http냐 https냐, 끝에 슬래시가 붙었냐, www가 붙었냐에 따라 전부 다른 출처다. 포트도 마찬가지다. CORS 설정을 넣었는데도 계속 막힌다면 AllowedOrigins 값과 브라우저가 실제로 보내는 Origin 헤더를 글자 단위로 비교해 보는 것이 가장 빠르다. 개발자도구 Network 탭에서 OPTIONS 요청의 Request Headers에 찍힌 Origin이 정답이다.
S3 CORS 설정은 어떻게 작성하나요?
AWS 공식 문서에 따르면 S3 콘솔에서는 CORS 설정이 반드시 JSON이어야 하며, 한 버킷에 규칙을 최대 100개까지 넣을 수 있다. 각 요소의 의미는 다음과 같다.
- AllowedMethods — 공식 문서가 지정한 허용 값은 GET, PUT, POST, DELETE, HEAD 다섯 가지다. 그 외 값은 쓸 수 없다.
- AllowedOrigins — 허용할 출처. 와일드카드
*는 문자열 하나에 한 번만 쓸 수 있다(예:http://*.example.com). 전체 허용은*다. - AllowedHeaders — 프리플라이트의 Access-Control-Request-Headers로 들어오는 헤더 중 허용할 것. 요청한 헤더 이름이 이 목록과 맞아야 하며, S3는 허용된 헤더만 응답에 돌려준다. 여기도 와일드카드는 문자열당 최대 한 개이고
x-amz-*같은 형태가 가능하다. - ExposeHeaders — 자바스크립트에서 읽을 수 있게 노출할 응답 헤더.
- MaxAgeSeconds — 브라우저가 프리플라이트 응답을 캐시하는 시간(초).
presigned URL로 PUT 업로드를 하는 전형적인 설정은 다음과 같다.
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
"AllowedOrigins": ["https://app.example.com"],
"ExposeHeaders": ["ETag", "x-amz-request-id", "x-amz-id-2"],
"MaxAgeSeconds": 3000
}
]콘솔에서는 버킷 → 권한(Permissions) → "Cross-origin resource sharing (CORS)" → 편집에 이 JSON을 붙여 넣고 저장하면 된다. 공식 문서는 CORS를 켜도 ACL과 다른 접근 권한 정책은 그대로 적용된다고 명시하므로, CORS를 열었다고 권한이 열리는 것은 아니다.
업로드는 되는데 ETag를 못 읽는 경우
멀티파트 업로드처럼 응답 헤더의 ETag 값을 자바스크립트에서 읽어야 하는 경우가 있다. 브라우저는 기본적으로 일부 응답 헤더만 스크립트에 노출하므로, ETag를 읽으려면 ExposeHeaders에 넣어야 한다. 위 설정에서 "ExposeHeaders": ["ETag", ...]가 그 역할이다. 이 값이 빠져 있으면 업로드 자체는 200으로 성공하는데 response.headers.get("ETag")만 null이 나온다.
주의할 점과 한계
- 403 Forbidden이나 SignatureDoesNotMatch는 CORS 문제가 아니다. presigned URL의 만료, 서명에 포함된 헤더와 실제 요청 헤더의 불일치, 버킷 정책 쪽을 봐야 한다. CORS 에러는 반드시 브라우저 콘솔에 "blocked by CORS policy" 문구로 나타난다.
- MaxAgeSeconds 때문에 브라우저가 이전 프리플라이트 응답을 캐시하고 있을 수 있다. 설정을 바꿨는데 반영이 안 되는 것 같으면 캐시를 비우거나 시크릿 창에서 다시 시도한다.
- CloudFront를 앞에 두었다면 CORS 헤더가 캐시되거나 Origin 헤더가 전달되지 않을 수 있다. 이 경우 S3 설정만 고쳐서는 해결되지 않는다.
- 이 글의 재현은 로컬 Node 서버로 한 것이고, 실제 S3 버킷에 CORS를 적용해 재현하지는 않았다. S3 쪽 문법·허용 값·동작 설명은 모두 AWS 공식 문서 기준이다.
정리표
| 증상 | 원인 | 조치 |
| No 'Access-Control-Allow-Origin' header is present | 버킷에 CORS 설정이 없거나 규칙에 안 맞음 | AllowedOrigins에 정확한 출처 추가 |
| curl은 되는데 브라우저만 실패 | curl은 CORS를 적용하지 않음 | 응답의 Access-Control-* 헤더 유무를 확인 |
| Method PUT is not allowed by Access-Control-Allow-Methods | AllowedMethods에 PUT 없음 | AllowedMethods에 PUT 추가 |
| Request header field content-type is not allowed | AllowedHeaders에 해당 헤더 없음 | AllowedHeaders에 content-type 또는 * 추가 |
| 업로드는 200인데 ETag가 null | ExposeHeaders에 ETag 없음 | ExposeHeaders에 ETag 추가 |
| 설정을 바꿨는데 그대로 | 프리플라이트 응답이 캐시됨 | MaxAgeSeconds 확인 후 캐시 비우고 재시도 |
| 403 / SignatureDoesNotMatch | CORS가 아니라 서명·권한 문제 | presigned URL 만료와 서명 헤더 점검 |
자주 묻는 질문(FAQ)
AllowedOrigins에 *를 쓰면 다 해결되나요?
프리플라이트는 통과하지만 권장하지 않는다. 누구나 그 버킷에 브라우저로 요청을 보낼 수 있게 되고, 자격 증명을 함께 보내는 요청에서는 와일드카드가 그대로 받아들여지지 않는다. 운영 환경에서는 실제 사용하는 출처를 전부 나열하는 편이 안전하다. 공식 문서에 따르면 와일드카드는 문자열 하나에 한 번만 쓸 수 있으므로 https://*.example.com 같은 형태로 범위를 좁힐 수 있다.
CORS를 설정했는데 여전히 같은 에러가 납니다. 무엇을 확인해야 하나요?
개발자도구 Network 탭에서 OPTIONS 요청을 찾아 Request Headers의 Origin 값을 확인하고, 그 값이 AllowedOrigins에 있는 문자열과 완전히 같은지 비교한다. 직접 재현한 결과 스킴(http/https), www 유무, 끝의 슬래시, 포트가 하나라도 다르면 응답에 Access-Control-Allow-Origin이 붙지 않았다.
presigned URL은 서명이 맞는데 왜 CORS가 또 필요한가요?
서명과 CORS는 다른 층위의 문제이기 때문이다. 서명은 "이 요청을 S3가 받아들일지"를 결정하고, CORS는 "브라우저가 그 응답을 자바스크립트에 넘겨줄지"를 결정한다. 서명이 완벽해도 프리플라이트 응답에 허용 헤더가 없으면 브라우저는 본 요청을 보내지도 않는다.
업로드는 성공하는데 응답 헤더를 못 읽습니다. 왜 그런가요?
브라우저가 기본적으로 스크립트에 노출하는 응답 헤더가 제한되어 있기 때문이다. ETag처럼 추가로 읽어야 하는 헤더는 버킷 CORS 설정의 ExposeHeaders에 명시해야 한다. 이 값을 넣기 전에는 요청이 200으로 성공해도 해당 헤더만 읽히지 않는다.
관련 글: CORS 에러 완전 정복 — 원인부터 해결까지 (초보자~실무자) · Spring Security CORS 완벽 해결 — @CrossOrigin 설정해도 403이 사라지지 않는 이유
공식 문서: AWS — Elements of a CORS configuration · AWS — Configuring cross-origin resource sharing (CORS)
최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기