
CORS 에러는 서버 로그보다 브라우저 콘솔에 먼저 빨간 줄로 떠서, 프론트만의 문제로 오해하기 쉽습니다. 빗장을 거는 쪽은 브라우저이고, 허용 헤더라는 열쇠는 서버가 내려주기 때문입니다.
이 글에서는 동일 출처 정책과 CORS, 프리플라이트(OPTIONS), Access-Control 헤더, 그리고 실무에서 흔한 오해를 순서대로 정리합니다.
📋 목차
- 동일 출처 정책은 왜 생겼을까 (1995년 이야기)
- CORS의 정체 - 보안이 아니라 빗장을 푸는 장치
- 단순 요청 vs 프리플라이트, OPTIONS가 갑자기 튀어나오는 이유
- Access-Control 헤더 한눈에 정리
- 쿠키(withCredentials)와 와일드카드가 안 먹는 이유
- CORS 에러, 현장에서 가장 많이 막히는 패턴들
- CORS는 누구 책임일까 - 프론트·백엔드 갈등의 진짜 원인
- CORS에 대한 흔한 오해 세 가지
- 다시 합쳐지는 프론트와 백엔드, 그리고 사라지는 CORS
- 자주 묻는 질문 (FAQ)
1. 동일 출처 정책은 왜 생겼을까 (1995년 이야기)
CORS를 이해하려면 그 위에 있는 동일 출처 정책(Same-Origin Policy)부터 봐야 합니다. 이건 넷스케이프가 자바스크립트를 세상에 내놓던 1995년 무렵 같이 들어온 규칙입니다. 두 URL의 프로토콜·도메인·포트가 모두 같아야 같은 출처로 치고, 하나라도 다르면 다른 출처로 봅니다. https와 http가 다르고, 같은 도메인이어도 포트가 8080이면 또 다른 출처죠.
브라우저가 이 빗장을 거는 이유는 단순합니다. 여러분이 은행 사이트에 로그인한 상태로 다른 탭에서 수상한 페이지를 열었다고 칩시다. 그 페이지의 자바스크립트가 마음대로 은행 API를 호출해서 잔액을 읽거나 송금을 시도하면 곤란하잖아요. 그래서 브라우저는 "다른 출처로 보내는 동적 요청의 응답은 스크립트가 못 읽게 막는다"는 원칙을 깔아둔 겁니다.
문제는 이 정책이 너무 빡빡했다는 점입니다. CORS가 표준이 되기 전까지는 다른 도메인의 API를 Ajax로 부르는 일 자체가 그냥 불가능했어요. 그래서 개발자들은 별 희한한 우회로를 다 썼습니다. JSONP로 script 태그를 악용하거나, 같은 도메인의 서버를 프록시로 세워서 대신 호출하게 하거나, 심한 경우 플래시의 crossdomain.xml까지 동원했죠. 지금 보면 다들 임시방편이었습니다.
2. CORS의 정체 - 보안이 아니라 빗장을 푸는 장치
이 답답함을 표준으로 풀자고 나온 게 CORS입니다. 2005년 무렵 첫 초안이 나왔고, 2009년에 지금의 이름인 Cross-Origin Resource Sharing으로 정리됐으며, 2014년 1월에 W3C 권고안이 됐습니다. 그런데 재미있게도 2017년부터 폐기 절차가 시작돼 2020년 6월 이 W3C 명세는 공식 은퇴했어요. 브라우저들이 실제 구현하는 동작과 명세가 어긋나기 시작했거든요. 지금 CORS는 WHATWG의 Fetch 표준 안에 통째로 녹아 있습니다.
여기서 가장 중요한 개념 하나. CORS는 동일 출처 정책을 더 빡빡하게 만드는 장치가 아니라, 반대로 그 빗장을 안전하게 풀어주는 장치입니다. 기본값은 여전히 "막기"이고, 서버가 "이 출처는 응답을 읽어도 좋다"는 신호를 헤더로 내려줄 때만 브라우저가 빗장을 풀어줍니다. 한마디로 opt-in 방식이죠.
📌 한 줄로 기억할 것
빗장은 브라우저가 겁니다. 열쇠는 서버가 쥐고 있습니다.
그래서 에러는 프론트 화면에 뜨지만,
그걸 풀 수 있는 사람은 백엔드입니다.
CORS 에러를 보고 프론트 코드만 들여다보면 답이 안 나오는 이유가 여기 있습니다.
Postman에서는 되는데 브라우저에서만 막힐 때
서버는 200과 본문을 정상적으로 돌려줘도, 브라우저가 Access-Control-Allow-Origin이 없으면 스크립트가 본문을 읽지 못하게 막습니다. API 창구의 요청·응답 그림이 필요하면 API 웨이터 비유를 먼저 보시면 흐름이 잡힙니다.
3. 단순 요청 vs 프리플라이트, OPTIONS가 갑자기 튀어나오는 이유
"저는 분명 GET 하나 날렸는데 네트워크 탭에 OPTIONS가 먼저 찍혀요." 이 질문, 정말 자주 받았습니다. 답은 단순 요청(simple request)과 프리플라이트의 구분에 있습니다.
브라우저는 옛날 form 태그로도 보낼 수 있었던 수준의 요청은 그냥 보냅니다. 메서드가 GET·HEAD·POST이고, 헤더가 안전 목록 안에 있고, Content-Type이 application/x-www-form-urlencoded나 multipart/form-data, text/plain 중 하나일 때죠. 이 조건을 다 만족하면 별도 절차 없이 바로 나갑니다.
그 조건을 하나라도 벗어나면 — 예를 들어 PUT이나 DELETE를 쓰거나, Content-Type을 application/json으로 잡거나, 커스텀 헤더를 하나 끼우면 — 브라우저가 본 요청을 보내기 전에 OPTIONS 메서드로 "이런 요청 보내도 되나요?" 하고 먼저 물어봅니다. 이게 프리플라이트입니다. 서버가 OK 헤더를 내려주면 그제서야 진짜 요청이 나가고, 거절하면 본 요청은 아예 출발도 못 합니다. JSON을 주고받는 요즘 API가 죄다 프리플라이트를 타는 이유가 이겁니다.
4. Access-Control 헤더 한눈에 정리
서버가 빗장을 풀 때 쓰는 열쇠가 바로 Access-Control 계열 응답 헤더입니다. 자주 쓰는 것들만 추려봤습니다.
| 헤더 | 역할 |
|---|---|
| Access-Control-Allow-Origin | 응답을 읽도록 허용할 출처를 지정. 실무에서는 사실상 단일 값이나 *만 동작 |
| Access-Control-Allow-Methods | 프리플라이트 응답에서 허용할 메서드(GET, POST, PUT 등)를 알려줌 |
| Access-Control-Allow-Headers | 클라이언트가 보낼 수 있는 커스텀 헤더 목록을 허용 |
| Access-Control-Allow-Credentials | 쿠키 같은 자격 증명을 실어 보낸 요청의 응답을 읽도록 허용. true 한 가지 값만 의미 있음 |
| Access-Control-Max-Age | 프리플라이트 결과를 몇 초 동안 캐시할지 지정. 브라우저별 상한이 있어 그 이상은 무시됨 |
| Access-Control-Expose-Headers | 스크립트가 읽을 수 있는 응답 헤더를 추가로 노출. 지정 안 하면 기본 안전 목록만 읽힘 |
여기서 헷갈리기 쉬운 게 Allow-Headers와 Expose-Headers입니다. 전자는 "요청에 이 헤더를 실어도 된다", 후자는 "응답에서 이 헤더를 읽어도 된다"로 방향이 정반대입니다. 응답에 커스텀 헤더를 담아 보냈는데 자바스크립트에서 안 읽힌다면 Expose-Headers를 빠뜨린 경우가 대부분이에요.
로컬 Nginx에서 본 프리플라이트 전후
nginx/1.26.3 리버스 프록시로 CORS 헤더 유무를 비교했습니다(실측 2026-10-19 KST). 리버스 프록시 역할은 Nginx 글과 같은 층입니다.
설정 없음 (127.0.0.1:19101):
curl -sS -D - -o /dev/null -X OPTIONS \
-H 'Origin: https://front.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type' \
http://127.0.0.1:19101/api/data
HTTP/1.1 204 No Content
Server: nginx/1.26.3
# Access-Control-* 없음
CORS 설정 있음 (127.0.0.1:19102):
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://front.example
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Custom
Access-Control-Max-Age: 600
# GET 본요청
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://front.example
5. 쿠키(withCredentials)와 와일드카드가 안 먹는 이유
CORS에서 사람을 가장 많이 울리는 지점이 자격 증명, 즉 쿠키입니다. 기본적으로 다른 출처로 가는 요청에는 쿠키가 실리지 않습니다. 세션 쿠키를 같이 보내려면 프론트에서 fetch의 credentials를 'include'로 잡거나 XMLHttpRequest의 withCredentials를 true로 켜야 합니다.
그런데 여기서 함정이 하나 더 있습니다. 쿠키를 실어 보내는 순간, 서버는 Access-Control-Allow-Origin에 *(와일드카드)를 쓸 수 없습니다. 반드시 요청한 출처를 정확히 명시해야 하고, Access-Control-Allow-Credentials: true도 같이 내려줘야 합니다. "그냥 * 깔면 다 되는 거 아니야?"가 안 통하는 대표적인 경우죠. 게다가 프리플라이트 OPTIONS 요청 자체에는 쿠키가 실리지 않으니, OPTIONS 단계에서 인증을 검사하도록 만들어두면 그것도 막힙니다.
💡 실무 팁
쿠키 인증을 쓰는 API라면 서버에서 요청 Origin 헤더 값을 읽어 화이트리스트와 대조한 뒤,
통과하면 그 값을 그대로 Allow-Origin에 돌려주는 방식이 깔끔합니다.
* 박아두고 나중에 인증 붙이려다 전부 갈아엎는 일이 의외로 많습니다.
6. CORS 에러, 현장에서 가장 많이 막히는 패턴들
이론은 이쯤 하고, 제가 실제로 자주 봤던 삽질들을 풀어보겠습니다. 2016년 전후로 시니어가 되면서 부산이나 제주 출장 가서 남의 서버를 들여다볼 일이 많았는데, CORS 문제는 거의 빠지지 않는 단골이었어요.
첫째, "localhost는 되는데 배포하면 터진다"입니다. 로컬에서는 프론트와 API가 같은 호스트라 동일 출처로 취급되다가, 배포 후 도메인이 갈리면서 그제서야 CORS가 발동하는 경우죠. 개발 단계에서 프록시 설정으로 가려져 있던 문제가 운영에서 민낯을 드러내는 겁니다.
둘째, 리다이렉트와 엮이는 경우. 인증이나 로그 수집 때문에 302로 한 번 튕긴 뒤 응답을 받는 구조였는데, 리다이렉트가 일어나면 브라우저가 Origin 헤더를 null로 바꿔버립니다. 그러면 서버 화이트리스트에 정확한 출처를 등록해놨어도 매칭이 안 돼서 막혀요. 예전에 302 리다이렉트와 쿠키, 캐싱이 한꺼번에 얽힌 문제를 며칠 붙잡고 있었는데, 알고 보니 범인이 이 null Origin이었던 적이 있습니다. 그땐 정말 답답했습니다.
셋째, 프리플라이트 캐시. Access-Control-Max-Age로 OPTIONS 결과가 캐시되는 동안 서버 헤더를 고쳐도 한동안 옛날 결과가 적용돼서, "분명 고쳤는데 왜 안 바뀌지?" 하고 엉뚱한 데를 뒤지게 됩니다. 헤더를 만진 뒤엔 캐시를 의심하는 게 빠릅니다.
7. CORS는 누구 책임일까 - "이건 내 문제 아니야"가 만든 갈등
제가 일을 시작한 2012년 무렵만 해도 크로스 도메인 호출은 JSONP나 프록시로 우회하는 게 흔했습니다. CORS가 W3C 권고안이 된 게 2014년이니, 그 전후로 브라우저들이 Fetch 표준으로 동작을 정리하고 옛날 우회로가 하나둘 막히던 시기였죠. 체감상으로는 "권장이던 게 어느 순간 강제가 된" 느낌이었습니다. 그러면서 현장에 묘한 갈등이 생기더군요.
프론트 개발자는 화면에 빨간 에러가 뜨니까 "CORS 났어요, 풀어주세요" 하고 요청합니다. 그런데 정작 자기 손으로는 못 풉니다. 열쇠가 서버에 있으니까요. 반대로 백엔드 개발자는 "내 코드는 멀쩡하고 응답도 잘 나가는데? 이건 내 문제 아니야"라고 합니다. 사실 둘 다 완전히 틀린 말은 아닙니다. 그런데 그 사이에서 아무도 책임지지 않으면 문제는 그냥 떠다닙니다.
저는 이 광경을 한참 지켜보면서 생각이 하나 굳어졌습니다. 프론트와 백엔드를 칼같이 나눠놓으면 안 되겠다는 것. CORS는 정확히 두 영역의 경계에 걸친 문제인데, 경계에 선 문제는 양쪽이 서로의 사정을 알아야 풀립니다. 프론트가 프리플라이트와 헤더의 의미를 알고, 백엔드가 브라우저가 왜 이러는지를 알면 5분이면 끝날 일이, 서로 모르면 며칠을 끕니다.
8. CORS에 대한 흔한 오해 세 가지
오래 쓰면서도 잘못 알고 있는 분이 많은 부분이라 정리해둡니다.
하나, CORS가 서버를 공격에서 지켜준다는 오해. 아닙니다. CORS는 어디까지나 브라우저 안에서 도는 스크립트가 응답을 읽을 수 있는지를 통제할 뿐입니다. curl이나 Postman, 다른 서버에서 보내는 요청에는 아무 효력이 없어요. 서버 보호는 인증·인가의 몫이지 CORS의 몫이 아닙니다.
둘, CORS가 CSRF를 막아준다는 오해. 이것도 아닙니다. 쿠키가 실린 다른 출처의 POST 요청은 CORS 헤더와 상관없이 서버까지 도달합니다. CORS는 그 응답을 스크립트가 못 읽게 할 뿐, 요청이 서버에 닿아 어떤 동작을 일으키는 것 자체는 막지 못합니다. CSRF 방어는 별도 토큰이나 SameSite 쿠키로 해야 합니다.
셋, Allow-Origin에 *를 깔면 안전하고 편하다는 오해. 앞서 봤듯 쿠키 인증과는 같이 못 쓰고, 민감한 데이터를 다루는 API에 무심코 *를 박아두면 의도하지 않은 출처에 응답을 다 열어주는 셈이 됩니다. 편한 만큼 위험도 같이 따라옵니다.
CORS는 CSRF를 대신하지 않습니다
쿠키가 실린 크로스 사이트 POST는 CORS와 무관하게 서버에 도달할 수 있습니다. CSRF는 토큰·SameSite로 따로 막습니다. CSRF 정리와 짝으로 두시면 오해가 줄어듭니다.
9. 다시 합쳐지는 프론트와 백엔드, 그리고 사라지는 CORS
흥미로운 건 요즘 흐름입니다. 2026년 현재 Next.js 같은 풀스택 메타 프레임워크가 자리를 잡으면서, 프론트와 백엔드가 한 코드베이스 안으로 다시 모이는 분위기예요. React 19의 서버 컴포넌트와 서버 액션, tRPC나 Hono 같은 RPC 스타일 도구가 퍼지면서 "백엔드 함수 하나가 곧 프론트의 호출 함수"가 되는 구조가 선호됩니다.
여기서 CORS 관점이 재미있어집니다. 프론트와 API가 같은 오리진에서 돌면 CORS 문제 자체가 안 생기거든요. 브라우저가 다른 출처라고 인식할 일이 없으니까요. BFF(Backend for Frontend) 계층을 두면 그 안에서 인증과 CORS 설정을 흡수해버리니, 프론트 개발자가 빨간 에러를 마주칠 일이 확 줄어듭니다.
물론 단서는 답니다. 규모가 큰 조직에서는 SSR 서버와 API 서버의 성격이 달라서 여전히 분리해 운영하고, 도메인별로 쪼개는 마이크로 프론트엔드도 멀쩡히 돌아갑니다. 모든 곳이 합쳐지는 건 아니에요. 다만 적어도 스타트업이나 중소 규모에서는 경계를 흐리는 도구가 표준에 가까워지고 있는 건 분명해 보입니다.
개인적으로는 묘한 기분이 듭니다. 예전에 "프론트와 백엔드를 나누면 안 되겠다"고 막연히 느꼈던 직관을, 이제는 도구가 알아서 따라와주는 셈이거든요. 확실하진 않지만, 이 흐름은 한동안 이어질 것 같습니다.
자주 묻는 질문 (FAQ)
CORS 에러는 프론트엔드가 고치나요, 백엔드가 고치나요?
에러는 프론트엔드 화면(브라우저 콘솔)에 뜨지만, 해결 권한은 대부분 백엔드에 있습니다. 서버가 Access-Control-Allow-Origin 같은 응답 헤더로 허용 신호를 내려줘야 브라우저가 빗장을 풀기 때문입니다. 프론트엔드는 요청 메서드·헤더·credentials를 맞추는 선에서 거들 수 있습니다.
CORS 에러는 어떻게 해결하나요?
서버 응답에 허용할 출처를 Access-Control-Allow-Origin으로 지정하고, 필요하면 Access-Control-Allow-Methods와 Allow-Headers를 함께 내려주는 것이 기본입니다. 쿠키 인증을 쓴다면 와일드카드(*) 대신 정확한 출처를 명시하고 Access-Control-Allow-Credentials를 true로 추가해야 합니다.
프리플라이트(OPTIONS) 요청은 왜 발생하나요?
단순 요청 조건(GET·HEAD·POST에 안전 목록 헤더)을 벗어나는 요청은 브라우저가 본 요청을 보내기 전에 OPTIONS로 먼저 허가를 묻기 때문입니다. JSON 본문을 보내거나 PUT·DELETE를 쓰거나 커스텀 헤더를 끼우면 프리플라이트가 발생합니다.
Access-Control-Allow-Origin에 와일드카드(*)를 써도 되나요?
공개 데이터라면 가능하지만, 쿠키 같은 자격 증명을 함께 보내는 요청에는 *를 쓸 수 없습니다. 이 경우 요청한 출처를 정확히 명시해야 하며, 민감한 API에 무심코 *를 두면 의도하지 않은 출처에까지 응답을 열어주게 됩니다.
CORS가 설정돼 있으면 CSRF 공격이 막히나요?
아닙니다. CORS는 브라우저 안의 스크립트가 응답을 읽는 것을 통제할 뿐, 쿠키가 실린 요청이 서버에 도달하는 것 자체는 막지 못합니다. CSRF는 SameSite 쿠키나 별도 토큰으로 따로 방어해야 합니다.
마무리
CORS는 미워할 대상이 아니라, 브라우저가 사용자를 지키려고 만들어낸 타협의 산물입니다. 동일 출처 정책이라는 빗장을 안전하게 푸는 약속이고, 그 약속을 모르면 빨간 에러 앞에서 무력해질 뿐입니다.
그리고 CORS는 프론트만의 문제도, 백엔드만의 문제도 아닙니다. 두 영역의 경계에 걸친 문제이고, 그래서 경계를 이해한 사람이 결국 가장 빨리 풉니다. 지금처럼 프론트와 백엔드가 다시 가까워지는 시대라면, 그 경계 감각은 앞으로 더 쓸모가 많아질 거라고 봅니다.
📚 함께 보면 좋은 글
CSRF란? 개념부터 방어까지 쉽게 정리
CSRF(크로스 사이트 요청 위조)는 웹 보안을 공부하다 보면 XSS와 함께 가장 먼저 마주치는 공격입니다. 그런데 이름이 낯설다 보니 "결국 세션을 훔치는 거 아니야?"라고 오해하기 쉽습니다. 사실
devwar.tistory.com
API 뜻, 식당 웨이터 비유로 끝내는 가장 쉬운 설명
API 뜻을 검색하면 '응용 프로그램 프로그래밍 인터페이스'라는 번역만 나오고, 몇 번을 읽어도 프로그램들이 어떻게 소통하는지 감이 잘 잡히지 않습니다. 추상적인 용어가 겹쳐 있어 데이터가
devwar.tistory.com
비밀번호 해시와 암호화 차이, 운영자도 내 비밀번호를 모르는 이유
비밀번호 해시와 암호화 차이를 한 줄로 말하면, 암호화는 되돌릴 수 있고 해시는 되돌릴 수 없다는 것입니다. 제대로 만든 서비스는 비밀번호를 해시로 저장하기 때문에, 운영자가 DB를 직접 열
devwar.tistory.com
웹 API 서버 디도스 방어에 스로틀링 기술이 필수인 숨은 이유가 있습니다
웹 프론트엔드 성능 최적화와 백엔드 서버 방어에 있어서 스로틀링(Throttling)은 선택이 아닌 필수 기술입니다. 트래픽 폭주로 인한 브라우저 렌더링 지연과 백엔드 서버 마비를 막으려면 이 개념
devwar.tistory.com
HTTP/1.1 vs HTTP/2 vs HTTP/3 차이 완벽 정리 (2026) - 실무에서 뭘 써야 할까
HTTP/1.1 vs HTTP/2 vs HTTP/3 차이를 구조·속도·보안 관점에서 비교하고, Apache가 HTTP/3를 지원하지 않는 이유와 CDN으로 HTTP/3를 적용하는 실무 방법까지 2026년 기준으로 정리합니다. 프로토콜 버전보다
devwar.tistory.com
'개발 개념 쉬운 설명' 카테고리의 다른 글
| 클린코드란 무엇인가, 스파게티 코드에서 예술이 되기까지 (0) | 2026.07.22 |
|---|---|
| API 뜻, 식당 웨이터 비유로 끝내는 가장 쉬운 설명 (0) | 2026.07.22 |
| SOLID 원칙, 저는 헌법처럼 씁니다 - 완벽하게 지키는 건 애초에 불가능합니다 (0) | 2026.07.13 |
| CQRS, 읽기와 쓰기를 굳이 나누는 이유 — 후배에게 먼저 외우게 하는 개념 (0) | 2026.07.09 |
| MVVM 패턴이란 무엇인가 - 데이터 바인딩의 탄생부터 단방향 흐름의 시대까지 (0) | 2026.07.07 |