CORS 메커니즘과 웹 브라우저의 교차 출처 제한

웹 애플리케이션을 개발할 때, 특히 프런트엔드와 백엔드가 분리된 아키텍처에서 다음 오류 메시지를 한 번쯤 마주했을 것입니다.

Access to fetch at 'http://api.external-service.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

이 흔한 '교차 출처(CORS) 오류'는 마치 웹 브라우저가 특정 요청의 문을 걸어 잠근 것처럼 보입니다. 많은 개발자들이 처음에는 당황합니다. Postman이나 curl 명령으로는 문제없이 데이터를 가져올 수 있는데, 왜 브라우저에서만 이런 문제가 발생하는 걸까요? 코드에 오류가 있는 것일까요?

실제 문제는 애플리케이션 로직이 아닌, 브라우저에 내장된 동일 출처 정책(Same-Origin Policy) 때문입니다. 동일 출처 정책은 웹 브라우저가 사용자 보안을 위해 엄격하게 적용하는 규칙입니다. 핵심은 간단합니다. 특정 웹 페이지(예: http://localhost:3000에서 실행되는 프런트엔드 애플리케이션)는 기본적으로 동일한 출처를 가진 서버에만 리소스를 요청할 수 있습니다. 여기서 '동일한 출처'는 프로토콜(protocol), 도메인(domain), 포트(port) 세 가지 요소가 모두 일치해야 함을 의미합니다. 만약 이 중 하나라도 다르다면, 예를 들어 프런트엔드가 localhost:3000에 있고 API 서버가 api.external-service.com에 있거나, 포트가 3000에서 3001로 바뀌더라도, 브라우저는 이를 '교차 출처(Cross-Origin)' 요청으로 간주하여 보안 검사를 시작합니다.

그렇다면 Postman은 왜 문제가 없을까요? Postman은 브라우저와 독립적으로 동작하는 데스크톱 애플리케이션이기 때문에 브라우저의 동일 출처 정책에 제약을 받지 않습니다. Postman이 보내는 HTTP 요청은 Node.js나 Python 스크립트에서 보내는 요청과 본질적으로 같으며, 추가적인 보안 제한 없이 '날것의' HTTP 호출로 처리됩니다. 따라서 Postman에서는 정상적으로 작동하지만 브라우저에서 오류가 발생하는 경우, 이는 백엔드 서비스 자체는 문제가 없으며, 브라우저가 '중간자'로서 보안 정책을 적용하고 있다는 명확한 신호입니다.

이 점을 이해하는 것이 매우 중요합니다. 교차 출처 문제를 해결하기 위한 접근 방식은 비즈니스 로직 코드를 수정하는 것이 아니라, 브라우저의 보안 메커니즘을 '설득'하여 "이 교차 출처 요청은 허용된 것이니 진행해도 좋다"고 알려주는 것입니다. 이 '허가'를 브라우저와 서버 간에 소통하는 표준 프로토콜이 바로 CORS입니다.

CORS 메커니즘: 브라우저와 서버의 통신 규약

CORS는 '교차 출처 리소스 공유(Cross-Origin Resource Sharing)'의 약자로, W3C에서 제정한 표준 메커니즘입니다. 간단히 말해, CORS는 브라우저와 서버가 안전하게 통신할 수 있는 방법을 제공하여, 서버가 "어떤 다른 출처의 요청을 허용할 것인지"를 브라우저에 명확히 알릴 수 있도록 합니다.

1. 간단 요청과 프리플라이트 요청: 두 가지 접근 방식

CORS는 요청을 두 가지 범주로 나누어 처리하며, 이 두 가지를 이해하는 것이 교차 출처 문제 해결의 핵심입니다.

첫 번째: 간단 요청 (Simple Request)

간단 요청은 서버에 직접적인 방식으로 접근합니다. 다음 모든 조건을 동시에 만족하는 경우, 브라우저는 해당 요청을 간단 요청으로 처리합니다.

  • HTTP 메서드가 다음 중 하나일 경우: GET, POST, HEAD.
  • 요청 헤더가 다음 중 하나로 제한될 경우: Accept, Accept-Language, Content-Language, Content-Type (단, Content-Type의 값은 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 합니다).
  • ReadableStream 객체를 사용하지 않을 경우.

간단 요청의 경우, 브라우저는 요청을 즉시 전송합니다. 이때 브라우저는 요청 헤더에 Origin 필드를 자동으로 추가하여, 요청이 시작된 출처(예: http://localhost:3000)를 서버에 알립니다. 서버는 이 요청을 받으면 Origin 헤더 값을 확인하여 해당 출처가 허용 목록에 있는지 검사합니다. 허용되는 출처라면, 서버는 응답 헤더에 Access-Control-Allow-Origin: http://localhost:3000 (혹은 모든 출처를 허용하는 *)을 포함하여 응답합니다. 브라우저는 이 응답 헤더를 확인하여 OriginAccess-Control-Allow-Origin이 일치하면, 응답 결과를 프런트엔드 JavaScript 코드에 노출합니다. 이 과정은 개발자에게는 투명하게 이루어집니다.

다음 시퀀스 다이어그램으로 흐름을 이해할 수 있습니다.

브라우저 (http://frontend.com)         서버 (http://api.backend.com)
      |                                       |
      |--------- GET /data ----------------->|
      |  헤더: Origin: http://frontend.com      |
      |                                       |
      |<-------- 응답 데이터 ------------------|
      |  헤더: Access-Control-Allow-Origin: http://frontend.com |
      |                                       |

만약 서버가 Access-Control-Allow-Origin 헤더를 반환하지 않거나, 반환된 값이 Origin과 일치하지 않으면, 브라우저는 위에서 언급된 교차 출처 오류를 발생시킵니다. 이 경우, 실제 네트워크 요청은 성공했더라도(개발자 도구의 Network 탭에서 200 OK 상태 코드를 볼 수 있음) 브라우저는 응답 내용을 프런트엔드 JavaScript에 노출하지 않습니다.

두 번째: 프리플라이트 요청 (Preflight Request)

요청이 '간단 요청'의 조건을 만족하지 않는 경우, 예를 들어 PUT, DELETE와 같은 메서드를 사용하거나, Content-Type: application/json과 같이 특정 값을 설정하거나, Authorization, X-Custom-Header와 같은 사용자 정의 헤더를 추가하는 경우, 브라우저는 이를 '서버 데이터에 부작용을 일으킬 수 있는 비간단 요청'으로 판단합니다.

이러한 경우, 브라우저는 실제 요청을 직접 보내지 않고, 먼저 서버에 '탐색' 요청을 보냅니다. 이는 OPTIONS 메서드를 사용하는 '프리플라이트 요청(Preflight Request)'입니다. 이 프리플라이트 요청에는 Access-Control-Request-MethodAccess-Control-Request-Headers 헤더가 포함되어, "앞으로 PUT 메서드를 사용하고 X-Custom-Header를 포함할 계획"과 같이 실제 요청의 의도를 서버에 미리 알립니다. 서버는 이 OPTIONS 요청을 받고, 해당 출처(Origin), 메서드(Method), 헤더(Headers)에 대해 교차 출처 요청을 허용할지 여부를 응답 헤더(Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Max-Age 등)를 통해 브라우저에게 전달합니다. 서버가 이 프리플라이트 요청에 대해 긍정적인 응답을 보내면, 브라우저는 그제야 실제 본 요청을 서버로 전송합니다. 만약 프리플라이트 요청이 거부되거나 필요한 CORS 헤더가 없다면, 브라우저는 실제 요청을 보내지 않고 바로 에러를 발생시킵니다.

태그: CORS HTTP 웹보안 동일출처정책 프리플라이트요청

8월 1일 02:24에 게시됨