
0. 들어가며
최근 공모전 프로젝트를 배포하면서 겪은 문제가 하나 있었습니다. 에러 내용은 위와 같습니다. 이 에러 다들 아시나요? 개발하면서 한 번쯤은 겪는 문제. 바로 CORS(Cross-Origin Resource Sharing) 에러입니다.
프론트엔드 서버는 Vercel로 잘 배포되어 있고, 백엔드 서버도 문제없이 잘 돌아가고, Swagger로도 API 호출 및 데이터 반환까지 잘 되는 것까지 확인했습니다. 그러나, 프론트엔드와 실제 API 연동을 하는 과정에서 데이터가 제대로 불러와지지 않아 개발자 도구를 열어보니 브라우저 콘솔에서 위와 같은 에러가 떠있는 상황인 거죠...
Access to XMLHttpRequest at 'https://api.sspots.site/api/v1/programs?pageSize=20'from origin 'https://www.sspots.site' 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.
백엔드도 살아 있고, API도 정상인데 브라우저가 요청을 막는다? 🤔 ‘내 서버는 멀쩡한데 왜 브라우저가 막을까?’
사실 지금까지는 SpringBoot 코드 단에서 이렇게 저렇게 해결은 잘 해왔지만, 왜 그렇게 코드를 작성하면 CORS 에러가 해결되는지에 대해서는 생각해 본 적이 없었습니다.
그래서 이번 글을 통해서 바로 이 CORS 에러의 정체를 밝히고, Spring Boot + Nginx + Vercel 배포 환경에서 발생했던 CORS 에러 해결 방법에 대해서 정리해보려고 합니다.
1. CORS란 무엇인가?
CORS(Cross-Origin Resource Sharing)는 브라우저가 특정 조건을 만족하는 “다른 출처(Origin)”로 요청하는 것을 제한하고, 서버가 이를 허용할지 결정하도록 하는 메커니즘입니다. 즉, 브라우저는 보안 정책인 SOP(Same-Origin Policy)를 기본적으로 따릅니다.
1-1. Same Origin Policy(SOP)
브라우저는 기본적으로 다른 출처로의 요청을 제한합니다. 그렇다면 같은 Origin 이란 무엇일까요 ?

위 사진과 같이 "프로토콜 + 도메인 + 포트" 이 세 가지가 모두 동일해야 브라우저는 이를 동일 출처(= 안전)로 판단합니다.
예를 들어
- https://example.com
- https://www.example.com
두 URL은 "www." 차이로 완전히 다른 Origin입니다. 즉, 모든 문자열이 정확하게 일치해야 같은 Origin으로 인정되는 것입니다.
그렇다면 같은 Origin인지, 아닌 지 판단하는 것이 왜 중요할까요?
SOP 정책이 없다면 악의적인 마음을 먹은 사용자가 CSRF(Cross-Site-Request Forgery)나 XSS(Cross-Site Scripting) 방법들을 활용해서 일반 사용자의 로그인 정보나 민감한 개인 정보들을 지정해 놓은 서버로 전송하여 악용할 수도 있게 됩니다. 이러한 나쁜 시도들을 막기 위해서 같은 Origin에서만 리소스를 공유할 수 있는 SOP 보안 정책이 정말 중요한 방어막 역할을 해주는 것이죠.
1-2. CORS(Cross-Origin Resource Sharing) 등장 배경
그러나, 현재 웹 기술과 서비스들이 많이 발전하면서 다른 도메인의 리소스를 활용하는 경우가 많아졌습니다. 이에 따라 기존에는 SOP 보안 정책으로 인해서 같은 출처의 리소스만 공유하는 것을 허용했지만, 다른 도메인의 리소스를 활용해야하는 상황이 늘어나면서 SOP 보안 정책을 완화하는 CORS 보안 정책이 등장하게 된 것이죠.
2. CORS는 어떻게 동작하는가?
웹 브라우저는 데이터 요청을 함에 있어 다음과 같이 생각합니다.
“지금 보고 있는 페이지(Origin A)에서, 다른 서버(Origin B)에 요청을 보내려는데…
이거 안전한 거 맞아? 서버가 진짜 허용하는 요청이야?”
그래서 다음과 같이 CORS 검사 방식이 3가지가 존재합니다.
- Simple Request (단순 요청)
→ 위험이 낮아 브라우저가 바로 요청을 보냅니다. - Preflight Request (사전 요청)
→ 위험성이 있으면 먼저 OPTIONS로 “허가 요청”을 보냅니다. - Credentialed Request (인증 기반 요청)
→ 쿠키/세션/Authorization 등 민감한 인증 데이터가 포함된 요청입니다.
이 3개는 서로 별개가 아니라, 요청의 특성에 따라 다르게 동작하는 CORS 검사 방식입니다.
2-1. 단순 요청(Simple Request)

✔ Simple Request의 조건 !
HTTP 메서드는 다음 중 하나여야 합니다.
- GET
- HEAD
- POST
그리고 헤더는 반드시 "기본적인 안전 헤더"만 포함해야 합니다.
- Accept
- Accept-Language
- Content-Language
- Content-Type (단, JSON 금지)
Content-Type이 Simple 범위여야 합니다.
- text/plain
- application/x-www-form-urlencoded
- multipart/form-data
즉, Content-Type이 application/json인 경우에는 단순 요청 범위에 해당하지 않으므로 Preflight 사전 요청이 발생합니다.
ex) Content-Type: application/json → Simple Request 아님 → Preflight 발생
2-2. Preflight Request (OPTIONS 사전 요청)

단순 요청의 조건을 만족하지 못한다면, 브라우저가 먼저 OPTIONS 요청을 보냅니다.
# Preflight 사전 요청 예시)
OPTIONS /api/data
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
이 요청과 함께 서버에게 물어봅니다.
"이 Origin에서 이 API 접근해도 되나요?"
만약, 별다른 문제가 없다면 서버가 다음과 같은 올바른 응답을 내려야 합니다.
# Preflight 응답 예시)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
이 응답이 없다면?
-> 브라우저가 본 요청을 아예 보내지 않습니다.
-> API가 응답을 주더라도 브라우저가 응답을 폐기합니다.
-> console 탭에 우리가 자주 보던 CORS 에러가 발생합니다.
2-3. Credentialed Request (쿠키/세션/Authorization 포함 요청)
Credentialed Request란, 요청에 다음 중 하나가 포함된 경우입니다.
- 쿠키(SessionID)
- Authorization 헤더(JWT, Basic Auth 등)
- HttpOnly Refresh Token
- credentials: include
- XSRF Token
즉, 인증 정보와 관련된 요청을 의미하므로 Preflight 사전 요청이 거의 항상 발생합니다. 그 이유는 다음과 같습니다.
- Authorization 헤더 사용
- Content-Type: application/json
- 쿠키 포함 요청은 기본적으로 민감
이 때문에 브라우저는 서버에게 물어봐야 합니다.
“내 쿠키 가져가도 되는 거 맞아요?”
따라서 Credentialed Request는 대부분 이런 흐름을 가집니다.
🚨 Credentialed Request의 특별 규칙
❌ 1) Access-Control-Allow-Origin: * 사용 금지
Credentialed Request에서 가장 많이 발생하는 에러입니다. 왜일까요?
- 쿠키/세션은 민감한 개인정보
- "*" = “전 세계 모든 사이트를 허용하겠다”
- 이런 모든 걸 허용하는 태도는 브라우저가 납득할 수 없다
브라우저가 강제로 요청을 차단하므로 반드시 특정 Origin을 명시해야 합니다.
지금까지의 내용을 모두 요약하는 플로우 차트는 다음과 같습니다.

3. 실제 프로젝트에서 겪은 문제 및 해결
3-1. 왜 이런 문제가 발생했을까?
Access to XMLHttpRequest at 'https://api.sspots.site/api/v1/programs? pageSize=20' from origin 'https://www.sspots.site' 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.
이제 공부한 내용을 토대로 에러 내용을 다시 살펴봅시다. "https://www.sspots.site" Origin으로부터 "https://api.sspots.site/api/v1/programs?pageSize=20" 요청 접근이 CORS 정책으로부터 막혔다. preflight 사전 요청을 보냈지만, "Access-Control-Allow-Origin" 헤더에 요청된 리소스가 존재하지 않는다.
원인을 분석해보자. 우선, 왜 POST 요청인데 preflight 사전 요청을 했는지부터 알아보자. 코드는 다음과 같이 작성되어 있었다.
@PostMapping
public ApiResponse<List<ProgramInfoResponse>> searchPrograms(
@RequestBody ProgramInfoRequest request,
@RequestParam("pageSize") Long pageSize,
@RequestParam(value = "lastProgramId", required = false) Long lastProgramId
) {
List<ProgramInfoResponse> responses = programService
.searchPrograms(request.toServiceRequest(), pageSize, lastProgramId);
return ApiResponse.success(responses);
}
즉, @RequestBody로 POST 요청을 받기 때문에 Request Header Content-Type 값이 "application/json"으로 되어있었을 것이다. 따라서, 브라우저는 Simple Request 조건을 만족하지 못했기 때문에 Preflight 사전 요청을 요구했던 것이다.

실제로 해당 요청에 대한 Request Headers 내용을 보면, Content-Type 값이 application/json이라는 것을 쉽게 확인할 수 있었다.
3-1. 문제를 해결해보자.
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns(
FRONT_LOCAL_SERVER,
FRONT_PROD_SERVER,
BACK_API_SERVER,
BACK_PROD_SERVER,
BACK_LOCAL_SERVER
)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Authorization")
.allowCredentials(true)
.maxAge(3600);
}
얼핏 보면 잘 작성했다고 생각했지만, BACK_PROD_SERVER 값에 "https://sspots.site"라고 적혀있었다. 브라우저의 Origin인 "https://www.sspots.site"와 "www." 부분이 달랐고, CORS 보안 정책으로 허용해 둔 Origin도 아니었기 때문에 CORS 검증에 실패하여 브라우저가 요청을 차단한 것이다. 결과적으로는 브라우저 Origin과 일치하게끔 www. 문자열을 추가해 줌으로써 해당 CORS 에러는 해결할 수 있었다.
다만, 공부를 제대로 하고, 위 코드를 다시 보니 불필요한 요소들까지도 CORS 허용 Origin 목록에 넣었다는 것을 확인할 수 있었다. 일단 CORS 에러를 해결하기 위해서 접근할 수 있는 모든 URL을 허용 Origin 목록에 추가한 것이다. 이 부분을 해결하면서 갑자기 유저의 요청에서부터 서버의 응답을 받기까지의 흐름에 대해서 잘 이해가 가지 않는 부분을 발견했다.
4. 헷갈렸던 개념 (feat. 프론트엔드 서버)
CORS 에러를 해결하면서 이런 궁금증이 생겼습니다.
“프론트는 Vercel에 배포되는데, Vercel 배포 주소인 "https://frontend-uqgq.vercel.app"는 CORS에 왜 필요 없나요?”
프론트 파일은 Vercel에서 제공되지만, API 요청은 항상 “사용자 브라우저”가 합니다. 즉, CORS에서 검사하는 Origin은 ‘Vercel 주소’가 아니라 사용자가 접속한 페이지의 주소(www.sspots.site)이다. 따라서 CORS 허용 목록에 넣어야 하는 주소는 단 두 가지입니다.
- 로컬 개발 환경 -> http://localhost:5173
- 운영 환경 Origin -> https://www.sspots.site
5. Nginx & Vercel & SpringBoot 전체 흐름 정리
유저의 요청부터 백엔드 서버 응답까지 전체 흐름을 도식화하면 다음과 같습니다.

6. 정리하며
이번 CORS 에러를 통해서 정말 많은 것을 배운 것 같다. 사용자의 요청부터 백엔드 서버의 응답까지의 플로우를 이해할 수 있었고, CORS 에러가 발생하는 이유와 어떤 Origin을 허용해야 에러를 해결할 수 있는지 완벽하게 이해했다. 내용들을 정리해 보면 다음과 같다.
1. Origin 비교는 "프로토콜 + 도메인 + 포트"가 모두 일치해야 한다.
- https://sspots.site ≠ https://www.sspots.site
- 브라우저는 둘을 절대로 동일하게 보지 않는다.
2. 브라우저는 SOP 때문에 교차 출처 요청을 기본적으로 막는다.
- 그러므로 서버의 CORS 설정이 반드시 필요하다.
3. CORS는 SOP의 예외를 적용하는 화이트리스트다.
- 백엔드가 허용하지 않은 Origin은 무조건 차단된다.
4. 따라서, 백엔드에서 정확한 Origin을 등록해야 한다
- 이번 문제는 백엔드 설정 실수로 발생한 CORS 에러였다.
7. Reference
'SpringBoot' 카테고리의 다른 글
| [SpringBoot] @Value 어노테이션에 대해서 (1) | 2025.12.26 |
|---|
