프론트엔드와 백엔드를 따로 개발하다 보면 한 번쯤 다음과 같은 오류를 만나게 된다.
Access to fetch at 'http://localhost:8080/...'
from origin 'http://localhost:3000'
has been blocked by CORS policy
React나 Next.js는 localhost:3000, Spring Boot는 localhost:8080으로 실행하는 경우가 많기 때문에 CORS 문제를 자주 만나게 된다.
처음에는 단순히 "백엔드 서버가 요청을 막은 건가?"라고 생각했는데, CORS를 제대로 이해하려면 먼저 Origin과 브라우저의 Same-Origin Policy를 알아야 한다.
이번 글에서는 CORS가 무엇이고, 왜 필요한지, 실제 요청 과정은 어떻게 되는지 정리해보려고 한다.
CORS는 Cross-Origin Resource Sharing의 약자다.
쉽게 말하면,
브라우저에서 다른 Origin의 서버에 요청할 때, 서버가 해당 Origin의 접근을 허용했는지 확인하는 방식이다.
예를 들어 다음과 같이 프론트엔드와 백엔드가 있다고 해보자.
Frontend
http://localhost:3000
Backend
http://localhost:8080
프론트엔드에서 다음과 같이 요청한다.
fetch("http://localhost:8080/api/users");
둘 다 localhost인데 왜 CORS가 발생할까?
두 주소의 Origin이 다르기 때문이다.
Origin은 다음 세 가지로 결정된다.
Protocol + Host + Port
예를 들어
http://localhost:3000
은 다음과 같이 구성된다.
Protocol = http
Host = localhost
Port = 3000
이 세 가지 중 하나라도 다르면 다른 Origin이다.
http://example.com:80/users
http://example.com:80/products
경로는 다르지만 다음 값이 모두 같다.
Protocol = http
Host = example.com
Port = 80
따라서 같은 Origin이다.
포트가 다른 경우:
http://localhost:3000
http://localhost:8080
프로토콜이 다른 경우:
http://example.com
https://example.com
호스트가 다른 경우:
https://www.example.com
https://api.example.com
모두 서로 다른 Origin이다.
따라서 실제 서비스를 배포해서
Frontend
https://www.example.com
Backend
https://api.example.com
처럼 구성해도 Host가 다르기 때문에 Cross-Origin 요청이 된다.
CORS를 이해하려면 먼저 SOP(Same-Origin Policy)를 알아야 한다.
SOP는 브라우저의 보안 정책이다.
쉽게 말하면,
다른 Origin에서 가져온 데이터에 JavaScript가 마음대로 접근하지 못하도록 제한하는 정책이다.
그렇다면 왜 이런 제한이 필요할까?
내가 다음 은행 사이트에 로그인했다고 생각해보자.
https://bank.com
로그인한 상태이기 때문에 브라우저에는 은행 사이트와 관련된 인증 정보가 존재할 수 있다.
그 상태에서 악성 사이트에 접속했다고 해보자.
https://evil.com
만약 아무런 제한이 없다면 evil.com의 JavaScript가 다음과 같은 요청을 할 수도 있다.
fetch("https://bank.com/api/account");
그리고 응답을 마음대로 읽을 수 있다면 사용자의 중요한 정보가 노출될 수 있다.
그래서 브라우저는 기본적으로 다른 Origin의 응답에 JavaScript가 마음대로 접근하지 못하도록 제한한다.
이것이 Same-Origin Policy다.
evil.com
↓
bank.com
Origin이 다름
↓
브라우저가 접근을 제한
SOP가 무조건 다른 Origin의 접근을 막아버리면 정상적인 웹 서비스를 만드는 것도 어려워진다.
실제 웹 서비스에서는 프론트엔드와 백엔드가 다른 Origin을 사용하는 경우가 많다.
Frontend
https://www.myapp.com
Backend
https://api.myapp.com
그래서 서버가 브라우저에게
"www.myapp.com에서 오는 요청은 허용해도 돼."
라고 알려줄 방법이 필요하다.
그 방법이 CORS다.
즉 다음과 같이 기억하면 된다.
SOP
= 기본적으로 Cross-Origin 접근을 제한
CORS
= 서버가 특정 Origin은 허용한다고 알려주는 방법
다시 로컬 개발 환경을 생각해보자.
Frontend
http://localhost:3000
Backend
http://localhost:8080
프론트엔드에서 다음 요청을 보낸다.
fetch("http://localhost:8080/api/users");
브라우저는 요청에 Origin 정보를 포함한다.
GET /api/users
Origin: http://localhost:3000
이 말은 서버에게
"이 요청은 localhost:3000에서 시작됐습니다."
라고 알려주는 것이다.
서버가 해당 Origin을 허용한다면 응답에 다음과 같은 헤더를 넣는다.
Access-Control-Allow-Origin: http://localhost:3000
브라우저는 이 응답을 확인한다.
Frontend
localhost:3000
↓
GET /api/users
Origin:
http://localhost:3000
↓
Spring Boot
localhost:8080
↓
Access-Control-Allow-Origin:
http://localhost:3000
↓
Browser
"localhost:3000은 허용되어 있네"
↓
JavaScript가 응답 사용 가능
이것이 가장 기본적인 CORS 동작이다.
여기서 중요한 점이 있다.
CORS 정책을 실제로 검사하고 적용하는 주체는 브라우저다.
많이 하는 오해가
CORS Error
=
서버가 요청 자체를 받지 못했다.
라고 생각하는 것이다.
항상 그런 것은 아니다.
서버가 정상적으로 요청을 받고 다음 응답까지 만들었을 수도 있다.
HTTP/1.1 200 OK
{
"name": "Kim"
}
하지만 적절한 CORS 헤더가 없다면 브라우저가 응답을 JavaScript에 노출하지 않을 수 있다.
Browser
↓
서버에 요청
↓
Server
↓
200 OK 응답
↓
Browser
↓
CORS 검사
↓
허용되지 않은 Origin
↓
JavaScript가 응답을 읽는 것을 차단
따라서 CORS는 기본적으로 브라우저의 보안 정책과 관련된 문제다.
백엔드를 개발하다 보면 이런 상황이 자주 발생한다.
Postman:
Postman
↓
Spring Boot
↓
200 OK
그런데 React에서 요청하면:
React
↓
Spring Boot
↓
CORS Error
왜 이런 차이가 발생할까?
Postman은 브라우저가 아니기 때문이다.
Same-Origin Policy는 기본적으로 웹 브라우저가 적용하는 보안 정책이다.
따라서 다음과 같이 이해하면 된다.
Postman → API
CORS 브라우저 제한 X
Backend → Backend
CORS 브라우저 제한 X
Browser JavaScript → API
CORS 적용 O
그래서
"Postman에서는 API가 잘 되는데 프론트에서는 안 돼요."
라는 상황이 발생한다면 CORS 설정을 확인해볼 필요가 있다.
CORS를 공부하다 보면 OPTIONS라는 HTTP Method를 자주 보게 된다.
OPTIONS /api/users
이것을 Preflight Request라고 한다.
쉽게 말하면 브라우저가 실제 요청을 보내기 전에 서버에게
"내가 이런 요청을 보내려고 하는데 허용돼?"
라고 미리 물어보는 것이다.
프론트에서 다음 요청을 보낸다고 해보자.
fetch("http://localhost:8080/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer abc..."
},
body: JSON.stringify({
name: "Kim"
})
});
브라우저는 조건에 따라 바로 POST 요청을 보내지 않고 먼저 다음 요청을 보낼 수 있다.
OPTIONS /api/users
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
쉽게 해석하면 다음과 같다.
브라우저:
"localhost:3000에서 왔는데요."
"POST 요청 보내도 돼요?"
"Authorization 헤더도 사용할 건데 괜찮아요?"
"Content-Type 헤더도 사용할게요."
서버가 허용한다면 다음과 같이 응답한다.
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
브라우저는 이를 확인한 후 실제 요청을 보낸다.
Browser
│
│ OPTIONS
│ "POST 보내도 돼?"
↓
Server
│
│ "응, 허용돼."
↓
Browser
│
│ 실제 POST 요청
↓
Server
│
│ 실제 응답
↓
Browser
즉,
Preflight
=
실제 요청 전에 권한을 미리 확인하는 요청
이라고 생각하면 쉽다.
모든 Cross-Origin 요청이 OPTIONS를 먼저 보내는 것은 아니다.
브라우저가 정한 Simple Request 조건을 만족하면 Preflight 없이 바로 요청할 수 있다.
반면 실제 개발에서 자주 사용하는 다음과 같은 요청은 Preflight가 발생할 수 있다.
PUT
DELETE
Authorization 헤더
Content-Type: application/json
특히 JWT 인증을 구현하면 다음 헤더를 자주 사용한다.
Authorization: Bearer eyJ...
그래서 Spring Security + JWT 프로젝트를 만들다 보면 Network 탭에서 OPTIONS 요청을 자주 볼 수 있다.
CORS에서는 몇 가지 중요한 HTTP 헤더가 사용된다.
어떤 Origin의 접근을 허용할지 지정한다.
Access-Control-Allow-Origin: http://localhost:3000
의미는 다음과 같다.
localhost:3000에서 오는 Cross-Origin 요청 허용
모든 Origin을 허용하려면 다음처럼 사용할 수도 있다.
Access-Control-Allow-Origin: *
하지만 인증 정보와 함께 사용할 때는 제약이 있으므로 무조건 *를 사용하는 것은 좋지 않다.
어떤 HTTP Method를 허용할지 지정한다.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
즉 서버가 해당 Origin에서 이런 요청들을 허용한다는 의미다.
클라이언트가 어떤 요청 헤더를 사용할 수 있는지 지정한다.
Access-Control-Allow-Headers: Authorization, Content-Type
JWT를 사용한다면 특히 Authorization 헤더가 중요하다.
쿠키와 같은 Credential을 Cross-Origin 요청에서 사용할 수 있도록 허용할 때 사용한다.
Access-Control-Allow-Credentials: true
프론트에서도 상황에 따라 다음과 같이 설정할 수 있다.
fetch("https://api.example.com/me", {
credentials: "include"
});
여기서 중요한 점은 Credential을 허용하면서
Access-Control-Allow-Origin: *
처럼 모든 Origin을 wildcard로 허용할 수는 없다는 것이다.
허용할 Origin을 명확하게 지정해야 한다.
Spring에서는 여러 방법으로 CORS를 설정할 수 있다.
가장 간단하게 특정 Controller에 적용하려면 @CrossOrigin을 사용할 수 있다.
@CrossOrigin(origins = "http://localhost:3000")
@RestController
public class UserController {
@GetMapping("/api/users")
public List<User> users() {
// ...
}
}
이렇게 하면 해당 Controller에 대해 localhost:3000의 Cross-Origin 요청을 허용할 수 있다.
API가 많아지면 Controller마다 @CrossOrigin을 작성하는 것보다 전역으로 관리하는 것이 편하다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods(
"GET",
"POST",
"PUT",
"DELETE"
)
.allowedHeaders("*");
}
}
위 설정은 대략 다음 의미다.
/api/**
localhost:3000 허용
GET 허용
POST 허용
PUT 허용
DELETE 허용
Spring Security를 사용하고 있다면 CORS 설정을 Security Filter Chain과 적절하게 연결해야 한다.
왜냐하면 실제 요청 전에 들어오는 Preflight 요청도 Spring Security를 거칠 수 있기 때문이다.
예를 들어 브라우저가 다음 요청을 보냈다고 해보자.
OPTIONS /api/users
그런데 Spring Security가 이를 일반 인증 요청처럼 처리해서
인증 정보가 없네?
→ 401 Unauthorized
를 반환해버리면 실제 POST 요청까지 진행하지 못할 수 있다.
Browser
↓
OPTIONS
↓
Spring Security
↓
401 Unauthorized
↓
실제 POST 요청을 보내지 못함
그래서 개발자 입장에서는
"CORS 설정했는데 왜 계속 CORS Error가 발생하지?"
라고 생각할 수 있다.
실제로는 Preflight 요청이 Security 단계에서 막힌 것일 수도 있다.
Spring Security를 사용한다면 CORS 설정과 Security Filter Chain의 관계도 같이 확인해야 한다.
처음에는 CORS도 보안이고 JWT도 보안이라서 같은 역할처럼 느껴질 수 있다.
하지만 서로 완전히 다른 문제를 해결한다.
"어떤 Origin의 브라우저 JavaScript가
이 응답에 접근할 수 있는가?"
"이 사용자는 누구인가?"
예를 들어 JWT를 검사하는 것이다.
"이 사용자가 이 기능을 사용할 권한이 있는가?"
예를 들어 USER, ADMIN 등의 권한을 검사한다.
전체적으로 보면 다음과 같다.
Request
↓
CORS
"허용된 Origin인가?"
↓
Authentication
"JWT가 유효한가?"
↓
Authorization
"이 사용자가 권한이 있는가?"
↓
Controller
따라서 CORS를 허용했다고 해서 API 보안이 끝난 것이 아니다.
이 부분이 특히 중요하다.
서버에서 다음과 같이 설정했다고 해보자.
Access-Control-Allow-Origin: https://my-site.com
그렇다고 해서 my-site.com 이외의 모든 곳에서 서버에 HTTP 요청 자체를 보낼 수 없다는 의미는 아니다.
공격자가 브라우저가 아닌 자신의 서버에서 직접 요청할 수도 있다.
공격자 서버
↓
HTTP Request
↓
우리 API
CORS는 브라우저가 적용하는 정책이기 때문에 이것까지 막아주는 것은 아니다.
따라서 실제 API를 보호하려면 별도의 보안이 필요하다.
JWT
OAuth2
Session
Authorization
IAM
Security Group
WAF
API Key
...
즉,
CORS 허용 여부
≠
API 인증/인가
이다.
내가 Next.js와 Spring Boot로 서비스를 만든다고 생각해보자.
개발 환경에서는 다음과 같이 실행할 수 있다.
Next.js
http://localhost:3000
Spring Boot
http://localhost:8080
포트가 다르기 때문에 서로 다른 Origin이다.
Browser
↓
Next.js :3000
↓
fetch()
↓
Spring Boot :8080
따라서 Spring에서 localhost:3000을 허용하도록 CORS 설정을 해야 한다.
배포 후에는 다음과 같이 구성할 수도 있다.
Frontend
https://www.myapp.com
Backend
https://api.myapp.com
이 경우에도 Host가 다르기 때문에 서로 다른 Origin이다.
따라서 Cross-Origin 요청이라면 적절한 CORS 설정이 필요하다.
전체 과정을 한 번에 보면 다음과 같다.
Frontend
http://localhost:3000
│
│ API 요청
↓
Browser
│
│ Origin이 다른지 확인
↓
Cross-Origin
│
│ 필요한 경우
↓
OPTIONS Preflight
│
↓
Spring Boot :8080
│
│ CORS 허용 정보 응답
↓
Browser
│
│ Origin / Method / Header 확인
↓
허용됨
│
│ 실제 요청
↓
Spring Boot
│
│ 실제 응답
↓
Browser
│
↓
Frontend JavaScript에서 응답 사용
CORS를 공부하면서 가장 중요하다고 생각한 내용을 정리하면 다음과 같다.
Protocol + Host + Port
하나라도 다르면 다른 Origin이다.
브라우저가 기본적으로 다른 Origin의 리소스에 JavaScript가 마음대로 접근하지 못하게 제한하는 정책이다.
서버가 브라우저에게
"이 Origin은 접근을 허용해도 된다."
라고 알려주는 방법이다.
브라우저가 실제 요청 전에 OPTIONS 요청을 보내
"이 Method와 Header를 사용해도 돼?"
라고 확인하는 과정이다.
CORS는 브라우저가 적용하는 정책이다.
그래서 Postman이나 서버 간 통신에서는 같은 방식의 CORS 문제가 발생하지 않는다.
그리고
CORS ≠ Authentication
CORS ≠ Authorization
이다.
CORS를 허용했다고 API 자체가 안전해지는 것도 아니고, CORS를 제한했다고 외부에서 API 요청 자체를 보낼 수 없게 되는 것도 아니다.
결국 CORS를 한 문장으로 정리하면,
Same-Origin Policy로 인해 제한되는 Cross-Origin 접근을 서버가 선택적으로 허용할 수 있도록 하는 HTTP 기반 메커니즘이다.
처음에는 단순히 @CrossOrigin을 붙이면 해결되는 에러라고 생각했지만, 실제로는 브라우저 → Origin → SOP → CORS → Preflight → 서버 응답의 흐름을 이해하는 것이 핵심이었다.