CORS 20년의 인지 부채: 개념 설명하는 사람들끼리도 댓글창에서 싸운다

CORS웹 보안HTTP크로스 오리진Same-Origin Policy

데이터 소스:HN + Lobsters · HN

새벽 2시. 로컬에서 Create React App을 띄웠다. 프론트엔드는 3000번 포트, 백엔드 API는 8000번 포트. 첫 fetch() 호출을 작성하고 저장하는 순간, Chrome 콘솔에 빨간 글씨가 뜬다: 「has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.」 구글에 ‘CORS error fix’를 검색한다. 첫 번째 Stack Overflow 답변이 알려준다: “백엔드에 Access-Control-Allow-Origin: * 추가하세요.” 시키는 대로 한다. 빨간 글씨가 사라진다. 세상은 평화롭다. 이 한 줄 설정이 실제로 무엇을 하는지, 서버 사이드 헤더가 왜 브라우저 동작을 통제하는지, 확실히는 모르겠지만 굳이 알고 싶지도 않다. 코드는 돌아가니까. 이건 지구상에서 매 초마다 발생하는 장면이다. 20년이 지났지만, CORS는 여전히 웹 개발에서 가장 ‘고쳐졌지만 이해되지 않은’ 보안 메커니즘이다. 2026년 6월, 2019년에 쓰인 「Developers don’t understand CORS」(개발자들은 CORS를 이해하지 못한다)라는 오래된 글이 Hacker News에서 353포인트, 251개 댓글로 재소환되었다. 댓글창 최고 득표 댓글 두 개가 정면으로 충돌했고, 그 아래 서브 스레드는 200층 넘게 이어졌다. 양측 모두 논거를 갖추고 인용을 곁들였지만, 끝내 서로를 설득하지 못했다. 더 흥미로운 것은, 비판의 칼날이 겨냥한 대상이 바로 그 입문 글 자체였다는 점이다. “Even TFA (The F*ing Article) seemingly doesn’t understand CORS.” 개념을 설명하는 사람이, 스스로도 혼동하고 있었다.

CORS가 실제로 하는 일

이 논쟁을 이해하려면 가장 기초적인 질문으로 돌아가야 한다. CORS는 무엇을 하는가. 필자가 기술 문헌과 명세로부터 재구성한 그림은 대략 이렇다. CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 브라우저가 구현한 프로토콜로, 특정 조건에서 동일 출처 정책(Same-Origin Policy, SOP)의 제한을 완화하기 위한 것이다. 맞다. 완화다. 강화가 아니다. SOP는 브라우저에 내장된 보안 기반이다. 기본적으로 example.com에서 로드된 JavaScript는 bank.com으로 요청을 보내고 응답을 읽을 수 없다. 이 기본 정책이 보호하는 것은 사용자다. 당신이 인터넷 뱅킹에 로그인해서 브라우저에 인증 쿠키가 저장되어 있다면, 당신이 열어본 다른 어떤 웹사이트도 몰래 당신의 뱅킹 데이터를 읽을 수 없다. CORS의 역할은 서버에게 “특정 다른 출처가 내 응답을 읽어도 된다”고 선언할 메커니즘을 제공하는 것이다. Access-Control-Allow-Origin 등의 응답 헤더를 통해 구현된다.

네이밍 자체가 이미 그 포지셔닝을 암시한다. 이것은 공유(Sharing)에 관한 것이지, 차단(Blocking)에 관한 것이 아니다. 그러나 바로 이 네이밍이 대규모 혼란의 진원지다. 개발자가 콘솔에서 「blocked by CORS」라는 에러를 보면, 직관적 반응은 “CORS가 나를 막고 있다”다. 실제로 요청을 막고 있는 것은 SOP다. CORS는 당신이 교차 출처 요청을 시도할 때 브라우저가 서버의 권한을 확인하기 위해 거치는 절차일 뿐이다. 서버가 권한을 부여하지 않았으면, 브라우저의 동작은 “응답을 읽지 못하게 한다”다(그리고 비단순 요청에 대해서는 아예 보내지도 않는다). 이 “못 하게 한다”가 CORS라는 이름으로 귀속되면서, 이름과 동작 사이의 엇갈림이 발생한다. 이것이 인지 부채의 첫 번째 원금이다.

두 가지 ‘맞는 말’이 200층을 쌓았다

HN 토론에서 가장 핵심적인 분기는 두 개의 댓글로 압축된다. 첫 번째는 사용자 muvlon(최고 득표, 17시간 전 게시)의 것으로, 요지는 이렇다: 이 글 자체가 CORS를 이해하지 못했다. CORS는 요청을 막지 않으며, 기본 제한을 완화할 뿐이다. 어떤 웹사이트의 JavaScript든 당신의 localhost:19421로 요청을 보낼 수 있고, Access-Control-Allow-Origin 헤더는 응답을 읽을 수 있느냐를 결정할 뿐이다. 요청 자체는 어떻게 해서든 보내진다. 두 번째는 사용자 stymaar(12시간 전)의 직접적 반박이다: 아니다, 당신 말이 틀렸다. GET 같은 안전한 메서드에서는 요청이 실제로 보내지지만, GET은 멱등성을 가져야 하므로 응답을 못 읽는 것만으로도 보호는 충분하다. PUT, DELETE, Content-Type이 application/json인 POST, Authorization 헤더를 포함한 요청 등 비멱등 요청에 대해서는 브라우저가 OPTIONS preflight를 먼저 보내고, preflight 응답에 올바른 CORS 헤더가 없으면 실제 요청은 애초에 보내지도 않는다.

두 사람 다 허튼소리를 하는 게 아니다. 각자 자신이 설정한 시나리오 안에서는 옳다. muvlon이 다루는 범위는 ‘단순 요청(simple requests)‘이다. preflight를 트리거하지 않는 요청들: GET, HEAD, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain인 POST, 그리고 안전한 표준 헤더 집합. 이 요청들은 서버로 보내지고, 서버가 처리하고, 응답도 돌아온다. 브라우저는 단지 그 응답을 JavaScript에 넘기지 않을 뿐이다. stymaar가 그리는 것은 ‘비단순 요청’의 세계다. PUT, DELETE, PATCH, Content-Type이 application/json인 POST, Authorization 헤더가 붙은 요청 등. 이것들은 OPTIONS preflight를 먼저 유발하고, preflight가 통과되지 않으면 실제 요청은 전송되지 않는다.

공학적 판단은 이렇다. 양측 모두 자신의 컨텍스트 안에서는 타당하지만, 각자 자신의 논리를 전체 진리인 양 말하고 있다. muvlon의 “the requests happen in any case”라는 문장은 전역 진술로는 틀렸다. 비단순 요청에 대해서는 preflight 실패가 실제로 요청 전송을 막는다. stymaar가 preflight 메커니즘을 근거로 원문 저자를 변호하는 방식에도 누락이 있다. 그는 Zoom 시나리오가 로컬 localhost 서버를 포함하며, 공격 표면은 GET류 단순 요청에서 비롯된다는 점을 간과했다. 원문 저자의 표현 “only Javascript running on the zoom.us domain can talk to the localhost webserver”는 실제로 정밀하지 않다. 어떤 웹사이트든 이 localhost 서버와 “대화할” 수 있다(단순 요청을 보낼 수 있다). 다만 권한이 부여된 웹사이트만 응답을 읽을 수 있을 뿐이다. 만약 localhost 서버가 파괴적 작업을 GET 엔드포인트에 걸어두었다면, Access-Control-Allow-Origin은 요청을 막지 못한다. 응답이 읽히는 것만 막을 뿐이다. 그리고 파괴적 GET 요청은, 보내진 순간 이미 보내진 것이다.

Preflight의 미적분

Preflight 메커니즘 자체는 더 많은 간과되기 쉬운 디테일을 감추고 있다. HN 토론에서 누군가는 POST 요청의 Content-Type을 text/plain으로 설정하면 preflight를 우회할 수 있다고 지적했다. text/plain은 ‘단순 요청’ 화이트리스트에 포함되어 있기 때문이다. 공격자는 다음과 같은 폼을 구성할 수 있다:

<form action="https://victim.com/api" method="POST" enctype="text/plain">
  <input name='{"key":"value", "ignore":"' value='"}'>
</form>

서버에 도달하는 내용은 {"key":"value", "ignore":"="}가 된다. 기형적인 JSON처럼 보이지만, 백엔드가 Content-Type 헤더를 엄격히 확인하지 않고 바로 JSON.parse를 시도한다면, 이 요청은 preflight 방어선을 뚫는다. 여러 차례 침투 테스트에서 이 기법을 성공적으로 사용했다고 주장하는 사용자가 댓글창에 직접 등장하기도 했다. 이것은 순수한 이론적 추론이 아니다. 서버가 Content-Type 검증을 하지 않는다면, text/plainmultipart/form-data의 단순 POST가 임의의 페이로드를 실어 나를 수 있다. 유사하게, Content-Type 검사가 정확한 일치가 아닌 접두사 매칭만 수행한다면, multipart/form-data; boundary=application/json 같은 헤더 값도 우회 통로가 될 수 있다.

이런 엣지 케이스들이 말해주는 것은 하나다. CORS의 보안 모델은 “요청이 서버에 도달할 수 있느냐”로도, “응답을 읽을 수 있느냐”로도 단순화할 수 없다. 그것은 분기 트리다. 단순 요청과 비단순 요청은 서로 다른 경로를 밟고, 서로 다른 경로 위의 보호 경계는 서로 다르다. 어느 한쪽 경로의 규칙을 전역 규칙으로 일반화하는 순간, 인지적 오차가 발생한다. 게다가 이 분기 트리는 계속해서 새 가지를 뻗고 있다. Sec-Fetch-* 헤더, SameSite 쿠키 속성, Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy. 각 층위마다 CORS 위에 새로운 의미론을 덧씌우며, 원래도 단순하지 않던 멘탈 모델을 더욱 파악하기 어렵게 만든다.

왜 개념 설명하는 사람마저 틀리는가

Chris Foster의 원문은 2019년 7월에 게재되었으며, 핵심 사례는 Zoom의 로컬 웹서버 취약점이었다. Zoom은 사용자 머신에서 localhost:19421을 리슨하는 웹서버를 구동하고 있었다. 사용자가 Zoom 링크를 클릭하면, 웹페이지가 이 로컬 서버에 요청을 보내 네이티브 클라이언트를 열도록 하는 구조였다. CORS를 우회하기 위해 Zoom은 AJAX 대신 이미지를 로드했고, 이미지 치수를 통해 상태 코드를 인코딩했다. Foster의 제안은: 이 로컬 웹서버가 Access-Control-Allow-Origin: https://zoom.us를 설정해야 한다는 것이었다. 그래야 “zoom.us의 JavaScript만이 로컬 서버와 통신할 수 있다”는 것이다.

Foster의 전반부 판단(Zoom의 방식이 안전하지 않다)은 맞지만, 후반부 표현(“zoom.us만 통신할 수 있다”)은 기술적으로 중의적이다. 엄밀히 말해 Access-Control-Allow-Origin은 다른 웹사이트가 localhost로 단순 요청을 보내는 것을 막을 수 없다. 다만 다른 웹사이트의 JavaScript가 응답을 읽는 것만 막을 수 있다. 로컬 웹서버가 GET 엔드포인트에 민감한 작업을 노출시켰다면, CORS 헤더만으로는 충분하지 않다.

그러나 이것은 Foster 한 사람의 문제가 아니다. HN 댓글 스레드 전체가 CORS를 논하면서도, 논자들 간의 견해 차이는 그들이 Foster와 갖는 견해 차이보다 작지 않다. 누군가는 CORS가 어떤 요청도 막을 수 없다고 단언한다. 누군가는 preflight가 바로 요청을 막기 위한 것이라고 반박한다. 제3자가 나서서 text/plain POST와 폼 요청이 preflight를 우회한다고 지적한다. 제4자가 추가로, 요청이 보내지더라도 CORS 헤더가 없으면 응답을 읽을 수 없으니 GET류 작업에 대한 보호는 완전하다고 덧붙인다. 단, 서버가 쓰기 작업을 GET에 걸지 않는다는 전제하에. 각 층위의 반박은 윗층의 불완전성을 드러내며, 최종 결과는 250개의 댓글 이후에도 합의가 형성되지 않은 상태다.

필자는 하나의 패턴을 관찰했다. CORS의 인지적 어려움은 단순히 그것이 복잡해서만이 아니다. 그것은 개발자가 세 가지 개념을 동시에 이해해야만 올바르게 모델링할 수 있기 때문이다. 브라우저의 SOP 기반, SOP의 완화 메커니즘으로서의 CORS, 그리고 HTTP 메서드의 안전성과 멱등성 규약. 이 세 가지는 각각 브라우저 아키텍처, 웹 보안 프로토콜, RESTful 디자인이라는 서로 다른 영역에 속한다. 대부분의 개발자는 이 중 한두 개에만 익숙하다. 누군가 ‘SOP + CORS’만으로 모델링하면, “요청이 막혔다”는 결론에 도달하기 쉽다. 브라우저 레벨의 전체 효과가 그렇게 보이기 때문이다. 누군가 ‘HTTP 의미론’만으로 모델링하면, 그는 서버가 요청을 받았고 응답을 반환한 것을 보며 “요청이 분명히 보내졌는데?”라고 생각한다. 두 모델은 서로 다른 층위에서 각자 옳지만, 동일한 명사 ‘CORS’에 투사될 때 충돌한다.

시대의 균열

댓글창의 한 관찰이 특히 흥미롭다. 이것은 세대 차이의 문제일 수 있다는 것이다. CORS가 존재하기 전부터 웹 개발을 시작한 프로그래머라면, 당신은 SOP만 존재하고 합법적인 교차 출처 요청이 불가능했던 시대를 겪었다. JSONP가 어떤 식으로 해킹이었는지, <img> 태그와 <script> 태그는 왜 교차 출처가 가능한데 XHR은 안 되는지를 안다. CORS가 등장했을 때 당신이 본 것은 SOP에 열린 하나의 문이었다. CORS는 해결책이었다. 그러나 CORS가 이미 존재하는 상태에서 웹 애플리케이션을 쓰기 시작한 세대에게, 당신이 처음 마주한 교차 출처 에러는 “blocked by CORS”라고 적혀 있었다. 당신의 직관은 CORS가 당신의 작업을 방해하고 있다고 말한다. CORS는 문제다.

세대 차이는 실재한다. 그러나 더 깊은 문제는 CORS의 문서, 교육, 에러 메시지가 설계 단계에서부터 이미 인지적 편향을 내포하고 있다는 점이다. 브라우저 콘솔의 에러 메시지는 “blocked by CORS”라고 쓴다. “blocked by Same-Origin Policy due to missing CORS authorization”이 아니다. MDN 문서는 완전한 메커니즘을 설명하지만, 대부분의 개발자는 전체 문서를 읽지 않는다. Stack Overflow에서 문제를 고쳐주는 첫 번째 답변을 찾으면 거기서 멈춘다. HN 댓글창에서 한 사람이 아니라 여러 사람이 이렇게 인정했다: “나는 CORS 문제를 만날 때마다 다시 배우고, 배우고 나서 또 잊는다.” 한 CTO라고 밝힌 댓글 작성자는, 자기 회사 사용자들이 CORS 문제로 대량의 지원 요청을 해온다고 말하면서 이렇게 관찰했다. 이제는 진짜 이해할 필요가 없어졌다는 것이다. Claude와 GPT가 CORS 에러를 고칠 수 있으니까. 에러 메시지를 LLM에 던져넣으면 된다. 그러자 다른 사람이 즉시 반박했다. 최근에 겪은 CORS 에러는 Claude, Copilot, 그리고 다른 시니어 엔지니어까지 3중 방어선을 뚫고 나서야 해결되었다는 것이다. 입문 글 쓰는 사람과 읽는 사람끼리도 싸우고 있는 마당에, 혼란스러운 학습 데이터에서 배운 LLM의 답변이 과연 얼마나 신뢰할 수 있겠는가.

이 빚은 청산되지 않을 것이다

CORS 설계자가 직면했던 것은 거의 불가능에 가까운 과제였다. 20년간 축적된 웹의 역사적 부채와 호환성을 유지하면서, 브라우저의 교차 출처 상호작용에 안전한 권한 부여 메커니즘을 제공해야 한다는 것. HTML <form>의 교차 출처 능력은 CORS가 등장하기 20년도 더 전부터 존재해 왔다. 단순히 막아버리면 인터넷 전체가 깨진다. CORS가 선택한 것은 절충 노선이었다. ‘단순 요청’은 하위 호환성을 유지하고, ‘비단순 요청’에는 preflight를 도입한다. 이 선택은 당시로서 실용적이었지만, 복잡성을 프로토콜의 본체 안으로 내재화시켰다. 개발자는 어떤 요청이 단순하고 어떤 것이 아닌지, 어떤 헤더는 안전하고 어떤 것은 아닌지, OPTIONS 요청이 왜 발생하며 그것과 실제 요청은 어떤 관계인지를 이해해야 한다. 20년이 지난 지금, 그 위에 SameSite, Sec-Fetch, COEP, COOP 등 새로운 층위가 쌓이며 복잡성은 증가만 했다.

필자는 CORS의 인지 부채가 웹 플랫폼 자체의 진화 방식에 뿌리를 두고 있다고 보는 쪽이다. 하위 호환성은 경직된 제약이고, 단계적 진화만이 유일하게 실행 가능한 경로였으며, 각 단계의 타협은 후속 개발자가 추가로 학습해야 할 개념적 부채를 남겼다. 이 빚은 청산되기 어렵다. 이미 브라우저와 수십억 개의 웹페이지 DNA 속에 새겨져 있으니까.

HN의 그 댓글 스레드는 CORS 문제의 종착역이 되지는 못할 것이다. 그러나 그것은 훌륭한 단면이다. 기술 커뮤니티에서 이 주제에 가장 관심이 많은 사람들조차 모여서 이틀 동안 격렬하게 토론한 끝에도, 가장 기초적인 사실에 대해 합의에 이르지 못할 수 있다는 것을 보여주었으니까. 이 사람들조차 통일하지 못한다면, 평범한 개발자가 CORS의 모든 디테일을 정확히 파악하기를 기대하는 것은 현실적이지 않을 것이다.


본 글은 Chris Foster의 원문 및 Hacker News 토론 스레드에 대한 기술적 분석을 바탕으로 작성되었다. 필자는 CORS 명세의 원저자나 브라우저 엔진 개발자가 아니며, 기술 메커니즘에 대한 해석은 공개된 표준 문서와 커뮤니티 논의를 읽고 이해한 결과로서 오차를 포함할 수 있다. 본문의 기술적 서술에 오류가 있다면 WHATWG Fetch Standard와 MDN Web Docs를 기준으로 삼기 바란다.