HTTP, CORS and Reverse Proxies
로컬에서는 되는데 배포하면 안 되는 요청은 대부분 코드가 아니라 경계에서 막힙니다. 브라우저 요청 하나는 스크립트가 정한 헤더, 브라우저가 지키는 출처 정책, 서버 앞에 선 프록시라는 세 경계를 차례로 지나고 경계마다 값을 정하는 주체가 다릅니다. 어느 경계에서 누가 결정권을 갖는지 알아 두면 증상을 봤을 때 고칠 곳이 스크립트인지 서버 설정인지 프록시인지 먼저 정해집니다.
헤더는 보내는 쪽이 아니라 역할이 정합니다
HTTP 헤더를 요청 헤더와 응답 헤더로만 나누면 Content-Type처럼 양쪽에 다 나오는 헤더를 설명할 수 없습니다. RFC 9110은 헤더를 요청 맥락(User-Agent, Referer), 응답 맥락(Allow, Location, Server), 표현 메타데이터(Content-Type, Content-Encoding, Content-Language, Content-Length, Content-Location)로 나누고 표현 헤더는 메시지가 아니라 실어 나르는 표현을 설명한다고 정의합니다. MDN도 같은 기준으로 요청, 응답, 표현, 페이로드 헤더로 묶습니다. RFC 2616에 있던 general header와 entity header 분류는 두 문서 모두 더 쓰지 않습니다. RFC 9110 Section 8, MDN HTTP headers
프론트엔드가 실제로 다루는 헤더는 많지 않습니다. 중요한 것은 그 값을 누가 정하는지입니다.
| 헤더 | 정하는 주체 | 프론트엔드가 보는 지점 |
|---|---|---|
Content-Type(요청) | 스크립트 | application/json으로 보내는 순간 CORS 사전 요청 대상이 됩니다 |
Accept, Accept-Language | 스크립트 또는 브라우저 기본값 | 서버가 응답 형식을 고르는 근거라 Vary와 짝입니다 |
Authorization | 스크립트 | 직접 넣는 헤더라 다른 출처에서는 사전 요청을 부릅니다 |
Cookie, Set-Cookie | 브라우저 | 스크립트가 쓰거나 읽을 수 없고 속성은 서버가 정합니다 |
Cache-Control, ETag | 서버 | 조건부 요청과 304 응답은 브라우저가 알아서 처리합니다 |
Content-Disposition | 서버 | 다운로드 여부와 파일 이름을 정합니다 |
Vary | 서버 | 캐시 키에 들어갈 요청 헤더를 알립니다 |
스크립트가 못 건드리는 헤더가 있다는 점이 첫 경계입니다. Fetch 표준은 Cookie, Host, Origin, Referer, Content-Length, Accept-Encoding, Connection과 Proxy-, Sec-로 시작하는 이름을 forbidden request header로 정해 브라우저만 채우게 합니다. 응답 쪽에서는 Set-Cookie가 forbidden response header라 response.headers로 읽을 수 없습니다. 요청에 Origin을 직접 넣어 CORS를 우회하거나 Set-Cookie를 읽어 토큰을 꺼내는 코드는 처음부터 성립하지 않습니다. Fetch Standard: forbidden request-header
쿠키 속성은 서버가 정하지만 결과는 화면에서 드러납니다. SameSite가 없는 쿠키를 Chrome은 80부터 Lax로 취급하고 Lax는 같은 사이트 요청과 안전한 메서드의 최상위 탐색에만 쿠키를 붙입니다. 다른 사이트의 API가 쿠키 인증을 쓰려면 SameSite=None; Secure가 필요하고 Secure는 https:에서만 받습니다. HttpOnly는 document.cookie를 막을 뿐 fetch()가 보내는 요청에는 쿠키가 붙습니다. 토큰을 어디에 둘지 고르는 기준은 OIDC 글의 마지막 절에서 다뤘습니다. MDN Set-Cookie, SameSite cookies explained
캐시 헤더에서는 no-cache의 뜻을 자주 틀립니다. no-cache는 저장은 허용하되 재사용 전에 원 서버와 검증하라는 뜻이고 저장 자체를 막는 것은 no-store입니다. 검증은 ETag와 If-None-Match로 이루어지며 값이 같으면 서버가 본문 없는 304로 답합니다. 같은 ETag를 If-Match에 실어 보내면 서버는 중간에 바뀐 문서를 412로 거절하므로 편집 화면의 덮어쓰기 방지에 쓸 수 있습니다. MDN Cache-Control, MDN ETag
다운로드는 Content-Disposition: attachment가 정합니다. 파일 이름은 ASCII용 filename과 RFC 5987 인코딩을 쓰는 filename*을 함께 보내야 한글 이름이 깨지지 않고 둘 다 있으면 filename*이 우선합니다. 다른 출처의 응답이라면 이 헤더는 기본으로 읽히지 않습니다. Fetch 표준이 스크립트에 기본으로 여는 응답 헤더는 Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma뿐이라 서버가 Access-Control-Expose-Headers: Content-Disposition을 더해야 합니다. MDN Content-Disposition, Fetch Standard: CORS-safelisted response-header name
async function downloadReport(id: string) {
const response = await fetch(`/api/reports/${id}/export`)
// fetch()는 404나 500에도 resolve 되므로 상태를 직접 봅니다
if (!response.ok) throw new Error(`export failed: ${response.status}`)
// 다른 출처라면 서버가 Access-Control-Expose-Headers로 열어 줘야 null이 아닙니다
const disposition = response.headers.get('content-disposition') ?? ''
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1]
const plain = /filename="?([^";]+)"?/i.exec(disposition)?.[1]
const name = encoded ? decodeURIComponent(encoded) : (plain ?? `report-${id}`)
const url = URL.createObjectURL(await response.blob())
const anchor = Object.assign(document.createElement('a'), { href: url, download: name })
anchor.click()
URL.revokeObjectURL(url)
}CORS는 브라우저가 막고 서버가 풉니다
출처는 scheme, host, port 세 가지로 정해집니다. https://store.example.com과 https://api.example.com은 다른 출처이고 http://와 https://도 다른 출처입니다. 동일 출처 정책은 다른 출처로 쓰기(링크, 폼 제출)와 삽입(<img>, <script>)은 대체로 허용하되 읽기를 막습니다. 그래서 CORS 오류는 요청을 못 보낸 것이 아니라 온 응답을 스크립트가 읽지 못하는 상태입니다. 서버 로그에는 200이 찍히고 네트워크 탭에도 응답이 보이지만 코드에는 TypeError 하나만 옵니다. MDN Same-origin policy
브라우저는 요청을 두 부류로 나눕니다. 아래 조건을 모두 만족하면 사전 요청 없이 바로 보내고 하나라도 벗어나면 OPTIONS로 먼저 묻습니다.
| 조건 | 사전 요청 없이 보내는 범위 |
|---|---|
| 메서드 | GET, HEAD, POST |
| 직접 넣은 헤더 | Accept, Accept-Language, Content-Language, Content-Type, 단일 범위의 Range뿐. 값은 128바이트 이하 |
Content-Type 값 | application/x-www-form-urlencoded, multipart/form-data, text/plain |
| 본문과 이벤트 | ReadableStream 본문 없음, XMLHttpRequest.upload에 리스너 없음 |
JSON API는 거의 항상 사전 요청을 부릅니다. Content-Type: application/json이 허용 목록 밖이고 Authorization은 직접 넣는 헤더이기 때문입니다. 사전 요청을 피하려고 text/plain으로 JSON을 보내는 방법은 서버 파서와 보안 검토를 함께 바꾸는 일이라 권하지 않습니다. 대신 서버가 Access-Control-Max-Age로 사전 요청 결과를 캐시하게 합니다. 기본값은 5초이고 Chromium은 7200초, Firefox는 86400초를 상한으로 둡니다. MDN CORS, MDN Access-Control-Max-Age
sequenceDiagram
participant S as 스크립트
participant B as 브라우저
participant A as api.example.com
S->>B: fetch(PUT, Content-Type: application/json, credentials: include)
B->>A: OPTIONS /items/1 (Origin, Access-Control-Request-Method, -Headers, 쿠키 없음)
A-->>B: 204 (Allow-Origin: 요청 출처, Allow-Methods, Allow-Headers, Allow-Credentials: true, Max-Age)
Note over B: 허용 범위를 확인하고 Max-Age 동안 기억
B->>A: PUT /items/1 (쿠키 포함)
A-->>B: 200 (Allow-Origin: 요청 출처, Allow-Credentials: true, Vary: Origin)
B-->>S: Response (Expose-Headers에 적힌 헤더만 읽힘)사전 요청과 본 요청의 응답은 서버가 정합니다. 자격 증명이 붙은 요청에는 규칙이 더 엄격합니다. Access-Control-Allow-Origin에 *를 쓸 수 없고 요청한 출처를 그대로 돌려줘야 하며 Allow-Headers, Allow-Methods, Expose-Headers도 * 대신 목록을 적어야 합니다. Access-Control-Allow-Credentials: true가 빠지면 브라우저는 200 응답도 버립니다. 사전 요청에는 쿠키가 붙지 않으므로 OPTIONS 경로를 인증 미들웨어 뒤에 두면 사전 요청이 401로 끝나고 본 요청은 시작도 못 합니다. 출처를 그대로 돌려주는 서버는 Vary: Origin을 함께 보내야 캐시가 다른 출처의 응답을 재사용하지 않습니다. MDN Access-Control-Allow-Origin
클라이언트 쪽 결정은 credentials 하나입니다. fetch()의 기본값은 same-origin이라 다른 출처에는 쿠키를 보내지도 받지도 않습니다. 쿠키 인증을 쓰는 다른 출처의 API라면 credentials: 'include'가 필요하고 그 응답의 Set-Cookie도 이 옵션이 있어야 저장됩니다. 쿠키 자체는 앞 절의 SameSite=None; Secure를 갖춰야 하므로 서버 설정과 클라이언트 옵션이 둘 다 맞아야 한 번에 됩니다. MDN Using Fetch
const api = (path: string, init: RequestInit = {}) =>
fetch(`https://api.example.com${path}`, {
...init,
// 기본값 same-origin은 다른 출처에 쿠키를 붙이지 않습니다
credentials: 'include',
headers: { 'content-type': 'application/json', ...init.headers },
})
try {
const response = await api('/me')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (error) {
// CORS로 막힌 경우 TypeError만 오고 이유는 콘솔에만 찍힙니다
if (error instanceof TypeError) console.warn('network or CORS failure', error)
throw error
}XMLHttpRequest와 비교하면 fetch()는 프로미스 기반이고 mode 옵션으로 출처 정책을 명시합니다. 기본값 cors는 CORS 절차를 따르고 no-cors는 응답을 불투명하게 만들어 상태가 0, 헤더와 본문이 비어 있습니다. 로그 전송처럼 응답을 읽을 필요가 없을 때만 쓸 수 있고 데이터를 받아야 하는 API에는 해결책이 아닙니다.
원인은 콘솔이 알려 줍니다. 브라우저는 보안상 실패 이유를 스크립트에 주지 않고 콘솔에 Reason: CORS header 'Access-Control-Allow-Origin' missing처럼 적습니다. Chrome DevTools의 네트워크 탭은 상태 열에 CORS error를 표시하고 사전 요청은 OPTIONS 행으로 따로 보입니다. 사전 요청 행이 4xx면 서버의 OPTIONS 처리를, 본 요청 행이 CORS error면 응답 헤더의 허용 목록을 봅니다. MDN CORS errors, Chrome DevTools Network reference
개발 프록시는 CORS를 푸는 게 아니라 숨깁니다
개발 서버의 프록시는 브라우저가 보낸 /api 요청을 개발 서버가 대신 백엔드로 보내고 응답을 돌려줍니다. 브라우저 입장에서는 요청과 응답이 모두 localhost:5173 한 출처에서 오가므로 CORS가 시작되지도 않습니다. Vite의 server.proxy는 http-proxy-3 위에서 동작하고 webpack dev server는 http-proxy-middleware를 씁니다. 두 설정 모두 개발 서버에만 적용되고 빌드 결과물에는 아무것도 남지 않습니다. Vite server options, webpack devServer.proxy
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
// Host 헤더를 대상 서버 기준으로 바꿉니다. 이름 기반 가상 호스트에 필요합니다
changeOrigin: true,
// 백엔드 경로에 /api 접두사가 없을 때만 지웁니다
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})배포하면 정적 파일은 CDN이나 웹 서버가, API는 다른 호스트가 응답하므로 숨겨졌던 출처 차이가 그대로 드러납니다. 개발 중에는 한 번도 보지 못한 사전 요청이 운영에서 처음 나타나는 이유입니다. 프록시를 켜는 조건도 설계의 일부입니다. SK AX GAS Position Play에서는 실행 모드를 백엔드 기준으로 나누고 실제 API 모드에서만 프록시를 켜 모킹 모드와 섞이지 않게 했습니다.
운영에서 출처 차이를 다루는 선택지는 셋입니다.
| 선택지 | 하는 일 | 맞는 조건 |
|---|---|---|
| 서버 CORS 설정 | API가 허용 출처와 헤더, 자격 증명을 응답에 적습니다 | 출처가 여럿이거나 API를 외부에도 여는 경우 |
| 게이트웨이 경로 통합 | 리버스 프록시가 /api를 백엔드로 넘겨 같은 출처로 만듭니다 | 사이트와 API를 같은 도메인 아래 둘 수 있고 쿠키 인증을 쓰는 경우 |
| BFF | 화면 전용 서버가 여러 백엔드를 묶어 같은 출처로 답합니다 | 응답 조합이나 토큰 보관을 브라우저 밖으로 빼야 하는 경우 |
쿠키 인증이면 두 번째 선택지가 가장 단순합니다. 같은 출처가 되면 SameSite=None도 credentials: 'include'도 필요 없고 사전 요청도 사라집니다. 대신 리버스 프록시가 한 단계 더 생기므로 그 설정이 다음 경계가 됩니다.
웹 서버와 애플리케이션 서버의 경계는 응답 코드로 정합니다
정적 자원을 웹 서버가, 렌더링과 API를 애플리케이션 서버가 맡는 분리는 Next.js에서도 그대로입니다. output: 'standalone' 빌드가 만드는 최소 서버는 public과 .next/static을 복사하지 않으며 문서는 이 폴더를 CDN이 맡는 편이 이상적이라고 적습니다. 컨테이너 하나로 띄우려면 두 폴더를 직접 복사해야 합니다. Next.js output
Nginx를 앞에 두면 설정 몇 줄이 요청의 모양을 바꿉니다. proxy_set_header의 기본값은 Host $proxy_host와 Connection close라 아무것도 적지 않으면 애플리케이션은 원래 호스트 이름을 모릅니다. location이 URI를 갖고 proxy_pass에도 URI가 있으면 일치한 부분이 치환되므로 끝의 슬래시 하나로 경로가 달라집니다. proxy_read_timeout 기본값은 60초이고 이 시간 안에 아무것도 오지 않으면 연결을 끊습니다. Nginx proxy module
server {
listen 443 ssl;
server_name www.example.com;
# 오류 페이지와 Server 헤더에서 버전을 뺍니다
server_tokens off;
# 정적 자원은 Nginx가 직접 답합니다. 빌드 산출물을 같은 컨테이너에 두는 경우입니다
location /_next/static/ {
alias /srv/app/.next/static/;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://app:3000;
# 기본값은 Host $proxy_host, Connection close 입니다
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# 앱에 닿지 못한 요청만 Nginx가 답합니다
error_page 502 503 504 /50x.html;
location = /50x.html { root /srv/static; internal; }
}X-Forwarded-For는 신뢰할 프록시가 붙인 값만 믿어야 합니다. $proxy_add_x_forwarded_for는 클라이언트가 보낸 헤더 뒤에 $remote_addr를 붙이므로 서버가 인터넷에 직접 노출돼 있으면 앞부분은 누구나 위조할 수 있습니다. 접근 제한이나 요청 제한에 이 값을 쓴다면 오른쪽에서 신뢰 프록시 수만큼만 세어 읽습니다. MDN X-Forwarded-For
오류 페이지가 이 경계를 가장 잘 드러냅니다. error_page는 여러 코드를 한 줄에 적을 수 있어 4xx와 5xx 묶음으로 정적 HTML 두 개를 두는 설정이 흔합니다. 그런데 프록시 뒤의 애플리케이션이 돌려준 404는 proxy_intercept_errors가 기본값 off인 동안 error_page가 바꾸지 못합니다. 두 설정을 겹치면 어떤 404는 앱 화면으로, 어떤 404는 Nginx 정적 페이지로 나가고 다국어 사이트라면 언어도 맞지 않습니다. LG Shield에서 Nginx 오류 페이지를 넣었다가 하루 만에 걷어낸 이유가 이것이었고 결론은 앱이 응답할 수 있는 오류는 앱의 오류 경계가 언어별로 답하고 앱에 닿지 못한 502, 503, 504만 Nginx가 답하는 분담이었습니다. 보안 심사가 요구한 서버 정보 비노출은 server_tokens off로 Server 헤더와 오류 페이지의 버전을 지우는 설정이라 server 블록에만 남겼습니다. Nginx core module: error_page, server_tokens
증상을 경계에 대응시키면 확인 순서가 나옵니다.
| 증상 | 먼저 볼 경계 | 확인할 것 |
|---|---|---|
| 로컬에서는 되고 배포에서 CORS 오류 | 개발 프록시 | 운영의 출처 구성과 서버의 Access-Control-* 응답 |
OPTIONS가 401이나 404 | 서버 라우팅 | 인증 미들웨어 앞에서 사전 요청을 처리하는지 |
| 쿠키가 저장되지 않음 | 쿠키 속성과 credentials | SameSite=None; Secure, credentials: 'include', Allow-Origin이 *가 아닌지 |
| 다운로드 파일 이름이 비어 있음 | 응답 헤더 노출 | Access-Control-Expose-Headers: Content-Disposition |
앱이 호스트 이름을 잘못 알거나 http로 링크를 만듦 | 리버스 프록시 | Host, X-Forwarded-Proto 전달 |
| 60초 근처에서 끊기는 긴 요청 | 리버스 프록시 | proxy_read_timeout과 상류 서버의 제한 |
| 오류 페이지가 화면마다 다름 | 웹 서버와 앱의 분담 | error_page 범위와 proxy_intercept_errors |
확인 방법과 한계
이 글의 규칙은 Fetch 표준, RFC 9110, MDN과 Nginx 공식 문서를 2026년 10월에 확인해 적었습니다. 브라우저별 세부는 글에 적은 버전과 함께 읽어야 합니다. SameSite 기본값은 Chrome 80 기준이고 Firefox와 Safari는 기본 동작이 달랐으며 사전 요청 캐시 상한도 브라우저마다 다릅니다. Nginx의 proxy_http_version 기본값은 1.29.7부터 1.1이고 그 전에는 1.0이라 keepalive나 WebSocket 프록시는 버전을 명시해야 합니다. 설정 예시는 판단을 설명하는 범위로 줄인 것이며 운영 설정을 그대로 옮기지 않았습니다. HTTP/2와 HTTP/3, TLS 핸드셰이크, Private Network Access는 다루지 않았습니다.
참고 자료
- RFC 9110: HTTP Semantics
- Fetch Standard
- MDN: HTTP headers
- MDN: Cross-Origin Resource Sharing (CORS)
- MDN: CORS errors
- MDN: Same-origin policy
- MDN: Using the Fetch API
- MDN: Set-Cookie, Cache-Control, ETag, Vary, Content-Disposition, Access-Control-Allow-Origin, Access-Control-Max-Age, X-Forwarded-For
- web.dev: SameSite cookies explained
- Chrome DevTools: Network features reference
- Vite: Server Options, webpack: DevServer proxy
- Next.js: output
- Nginx: ngx_http_proxy_module, ngx_http_core_module