본문 바로가기
IT

CORS 에러 완전 정리 — 교차 출처 차단 원인·프리플라이트·서버별 해결

by 샤나엘 2026. 6. 28.
반응형

CORS 에러 완전 정리 — 교차 출처 차단 원인·프리플라이트·서버별 해결

프론트엔드에서 API를 호출했더니 콘솔에 빨갛게 "blocked by CORS policy"가 뜬다. 분명 서버는 정상이고 Postman으로는 잘 되는데 브라우저에서만 막힌다. CORS는 웹 개발자라면 누구나 한 번은 부딪히는 벽이다. 핵심을 먼저 말하면, CORS 에러는 서버가 죽은 게 아니라 브라우저가 "이 응답을 자바스크립트가 읽어도 되는지" 확인하지 못해 가로막은 것이다. 이 글은 CORS가 왜 존재하는지, 오리진과 프리플라이트가 무엇인지, 그리고 서버에서 어떻게 푸는지를 코드와 함께 정리한다.

 

CORS 에러 완전 정리

이 글의 구성

 

🔎CORS란 무엇인가
🧩에러가 뜨는 진짜 이유
🔄프리플라이트(OPTIONS) 동작
💻실제 에러 메시지와 확인
🛠서버별 해결 방법
⚖️흔한 오해와 함정
💬자주 묻는 질문 5가지

🔎 CORS란 무엇인가

CORS는 Cross-Origin Resource Sharing, 교차 출처 자원 공유다. 이름은 거창하지만 출발점은 단순한 규칙 하나다. 브라우저는 기본적으로 같은 출처(same-origin)끼리만 자유롭게 데이터를 주고받게 한다. 이걸 동일 출처 정책(Same-Origin Policy)이라 부른다. CORS는 이 빗장을 "정해진 조건에서만" 풀어 주는 표준이다.

 

출처(origin)는 세 가지로 정해진다. 프로토콜(http/https), 호스트(도메인), 포트다. 셋 중 하나라도 다르면 다른 출처다. 예를 들어 https://app.example.com에서 https://api.example.com을 부르면, 도메인이 달라 교차 출처다. 포트만 8080으로 달라도 교차 출처다. 이때 브라우저는 서버가 "그쪽에서 읽어도 돼"라고 허락하는 헤더를 보냈는지 확인하고, 없으면 자바스크립트가 응답을 읽지 못하게 막는다.

핵심 — 막는 주체는 브라우저다

CORS는 서버가 아니라 브라우저가 강제하는 규칙이다. 그래서 같은 요청을 curl이나 Postman으로 보내면 멀쩡히 응답이 온다. 거기엔 동일 출처 정책이 없기 때문이다. 브라우저 안의 자바스크립트만 이 규칙에 묶인다. CORS 에러를 "서버가 죽었나" 하고 의심하면 방향이 틀어진다.


🧩 에러가 뜨는 진짜 이유

CORS 에러의 가장 흔한 오해는 "요청이 서버에 도달조차 못 했다"는 생각이다. 실제로는 많은 경우 요청이 서버에 닿고, 서버가 응답까지 한다. 다만 그 응답에 "이 출처가 읽어도 된다"는 허락 헤더(Access-Control-Allow-Origin)가 없어서, 브라우저가 응답을 자바스크립트에 넘기지 않고 막는 것이다.

 

즉 CORS 에러는 "통신 실패"가 아니라 "읽기 권한 거부"에 가깝다. 서버 로그를 보면 200으로 정상 응답한 기록이 남아 있는데 프론트에서만 에러가 나는 이유가 여기에 있다.

에러가 나는 대표 상황

01서버 응답에 Access-Control-Allow-Origin 헤더가 아예 없다 (가장 흔함).
02허용 출처가 내 프론트 도메인과 다르게 설정돼 있다.
03프리플라이트(OPTIONS) 요청에 서버가 제대로 응답하지 않는다.
04쿠키·인증을 보내는데 Allow-Credentials 설정이 빠졌거나 출처가 *로 돼 있다.

🔄 프리플라이트(OPTIONS) 동작

CORS에는 두 종류의 요청이 있다. 단순 요청(simple request)과 프리플라이트가 필요한 요청이다. 이 구분을 알면 "왜 OPTIONS 요청이 하나 더 날아가지?" 하는 의문이 풀린다.

 

단순 요청은 GET·POST·HEAD에, 헤더가 평범하고, Content-Type이 application/x-www-form-urlencoded·multipart/form-data·text/plain 중 하나인 경우다. 이건 브라우저가 바로 본 요청을 보내고, 응답 헤더로 허용 여부를 따진다.

 

그 조건을 벗어나면, 예를 들어 PUT·DELETE 메서드나 application/json 본문, Authorization 같은 커스텀 헤더를 쓰면, 브라우저는 본 요청 전에 OPTIONS 메서드로 "예비 질문"을 먼저 보낸다. 이게 프리플라이트다. "나 이런 메서드와 헤더로 요청하려는데 괜찮아?"라고 묻고, 서버가 허용 메서드·헤더를 응답하면 그제야 본 요청을 보낸다.

단계 요청 헤더 응답 헤더(서버)
프리플라이트(OPTIONS) Access-Control-Request-Method / -Headers Access-Control-Allow-Methods / -Headers
본 요청 Origin Access-Control-Allow-Origin

프리플라이트에서 막히는 경우가 의외로 많다. 서버가 OPTIONS 메서드를 처리하지 않아 404·405를 돌려주거나, 허용 헤더 목록에 Authorization을 안 넣었으면 본 요청은 시작도 못 한다. 그래서 CORS 디버깅은 OPTIONS 응답부터 보는 게 빠르다.


💻 실제 에러 메시지와 확인

브라우저 콘솔에 찍히는 메시지는 원인을 꽤 정확히 알려준다. 대표적인 문구다.

# 허용 헤더 자체가 없음 (가장 흔함)
Access to fetch at 'https://api.example.com' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present.

# 쿠키를 보내는데 출처가 * 로 돼 있음
The value of the 'Access-Control-Allow-Origin' header must not
be the wildcard '*' when the request's credentials mode is 'include'.

서버가 어떤 CORS 헤더를 주는지는 curl로 프리플라이트를 흉내 내 보면 바로 보인다. Origin과 요청 메서드를 실어 OPTIONS를 보낸다.

# 프리플라이트 흉내 — 응답의 Allow-* 헤더 확인
curl -i -X OPTIONS https://api.example.com/data \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST'

# ✓ 정상이면 응답에 이런 헤더가 보인다
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, DELETE

개발자도구 네트워크 탭에서도 본 요청 앞에 OPTIONS 요청이 따로 있는지, 그 응답에 Allow 헤더가 붙었는지 확인할 수 있다.


🛠 서버별 해결 방법

CORS는 응답 헤더로 푸는 문제이므로, 고칠 곳은 거의 항상 서버다. 대표 환경별 설정을 본다. Node.js Express에서는 cors 미들웨어가 가장 간단하다.

// Express — cors 미들웨어
const cors = require('cors');

app.use(cors({
  origin: 'https://app.example.com',  // 특정 출처만 허용
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true  // 쿠키 허용 시
}));

Nginx에서는 add_header로 헤더를 붙이고, OPTIONS 프리플라이트를 따로 처리해 준다.

# Nginx — CORS 헤더 + OPTIONS 처리
# 주의: 헤더를 OPTIONS 블록 "안"에도 직접 넣어야 한다.
# 바깥 add_header 만 두고 return 204 하면 헤더가 빠지는 함정.
location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin https://app.example.com always;
        add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE' always;
        add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
        add_header Access-Control-Max-Age 86400 always;
        add_header Content-Length 0;
        return 204;  # 프리플라이트는 본문 없이 204
    }

    add_header Access-Control-Allow-Origin https://app.example.com always;
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE' always;
    add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
    # ... 본 요청 처리 ...
}

쿠키·토큰 같은 인증을 함께 보낸다면 규칙이 하나 더 붙는다. 서버는 Access-Control-Allow-Credentials를 true로 줘야 하고, 이때 Allow-Origin뿐 아니라 Allow-Methods·Allow-Headers도 *를 쓸 수 없다. 인증 모드에서는 *가 와일드카드가 아니라 글자 그대로 해석되기 때문이다. 반드시 구체적인 값을 지정해야 한다. 프론트도 fetch에 credentials: 'include'를 넣어야 쿠키가 실린다. 한 가지 더, Authorization 헤더는 인증 여부와 무관하게 Allow-Headers의 *에 포함되지 않으므로 항상 이름을 명시해야 한다.

단계별 해결 순서

 

01콘솔 메시지를 읽는다. Allow-Origin 없음인지, credentials·헤더 문제인지 알려준다.
02curl -X OPTIONS로 서버가 주는 Allow-* 헤더를 직접 확인한다.
03서버에 Allow-Origin·Methods·Headers를 정확히 추가하고 OPTIONS를 처리한다.
04인증을 쓰면 Allow-Credentials: true + 구체 출처(별표 금지) + 프론트 credentials: include.
05서버를 못 고치는 개발 단계라면 프록시(dev server proxy)로 같은 출처처럼 우회한다.

⚖️ 흔한 오해와 함정

CORS는 개념이 헷갈려 잘못 알기 쉽다. 자주 빠지는 함정을 정리했다.

오해 실제
프론트 코드로 고칠 수 있다 응답 헤더 문제라 서버에서 고친다. 프론트는 프록시 우회만 가능
CORS가 서버를 보호한다 서버 보호가 아니라, 브라우저 안 사용자 데이터를 지키는 장치
*로 다 열면 편하다 인증 요청엔 * 사용 불가, 보안상 구체 출처 지정 권장
OPTIONS는 내가 안 보냈다 프리플라이트는 브라우저가 자동 생성. 서버가 처리해야 함

특히 "CORS는 서버를 공격으로부터 막아 준다"는 오해가 흔하다. CORS는 오히려 동일 출처 정책을 완화하는 장치다. 서버는 curl·다른 서버에서 얼마든지 접근할 수 있고, CORS가 지키는 건 브라우저 안에서 사용자의 자격 증명으로 다른 사이트가 몰래 요청을 읽어가지 못하게 하는 부분이다. 보안 경계를 서버 보호로 착각하면 설정을 엉뚱하게 한다.

CORS 에러는 서버가 죽은 게 아니라 브라우저가 응답을 못 읽게 막은 것이다. 그래서 답은 거의 항상 서버 응답 헤더에 있다.

 

— Postman은 되는데 브라우저만 막힌다면


💬 자주 묻는 질문 5가지

Q1Postman은 되는데 브라우저만 막혀요.

정상입니다. CORS는 브라우저만 강제하는 규칙이라, Postman·curl에는 동일 출처 정책이 없어 그대로 통과합니다. 서버가 죽은 게 아니라 응답 헤더가 빠진 것이니, 서버에 Access-Control-Allow-Origin을 추가하면 됩니다.

Q2프론트엔드에서 CORS를 고칠 수 없나요?

근본적으로는 못 고칩니다. 허용은 서버 응답 헤더로 결정되기 때문입니다. 다만 개발 단계에서는 dev 서버 프록시로 요청을 같은 출처처럼 우회하거나, 운영에서 같은 도메인 뒤로 API를 붙이는 식으로 교차 출처 자체를 없앨 수 있습니다.

Q3OPTIONS 요청이 왜 하나 더 날아가나요?

프리플라이트입니다. PUT·DELETE나 application/json, Authorization 같은 비단순 요청은 브라우저가 본 요청 전에 OPTIONS로 "이렇게 보내도 돼?"를 먼저 묻습니다. 서버가 허용 메서드·헤더로 답해야 본 요청이 진행됩니다.

Q4그냥 * 로 다 열면 안 되나요?

단순 공개 API라면 가능하지만, 쿠키·토큰 같은 인증을 보낼 때는 *를 쓸 수 없습니다. Allow-Credentials: true와 함께 쓰려면 반드시 구체적인 출처를 지정해야 합니다. 보안상으로도 허용 출처는 필요한 도메인만 명시하는 편이 안전합니다.

Q5헤더를 추가했는데도 계속 막혀요.

프리플라이트 응답을 확인하세요. 본 요청엔 헤더가 붙었는데 OPTIONS 응답에는 빠진 경우가 흔합니다. Allow-Headers에 Authorization 같은 실제 사용 헤더가 들어 있는지, OPTIONS가 200·204로 정상 응답하는지 curl로 점검합니다. 캐시된 프리플라이트가 남아 있으면 강력 새로고침도 시도하세요.


📌 결론

CORS 에러는 브라우저가 "이 교차 출처 응답을 자바스크립트가 읽어도 되는지" 확인하지 못해 막는 현상이다. 서버가 죽은 게 아니라, 응답에 허락 헤더가 없는 것이다. 그래서 해결의 무게중심은 늘 서버 응답 헤더에 있다.

실무 원칙은 다음과 같다.

🌐 출처 개념을 잡는다. 프로토콜·호스트·포트 중 하나라도 다르면 교차 출처이고, 브라우저가 허락 헤더를 따진다.

🔑 서버에서 헤더를 정확히 준다. Allow-Origin·Methods·Headers를 맞추고, 비단순 요청은 OPTIONS 프리플라이트까지 처리한다.

🛠 인증과 함정을 챙긴다. 쿠키를 쓰면 Allow-Credentials: true + 구체 출처(별표 금지) + 프론트 credentials: include, 개발 단계는 프록시로 우회한다.

CORS 해결 체크리스트

 

01콘솔 메시지로 원인 분류 — Allow-Origin 없음·credentials·헤더.
02curl -X OPTIONS로 서버 Allow-* 헤더 직접 확인.
03서버에 Allow-Origin·Methods·Headers 추가, OPTIONS 처리.
04인증 시 Allow-Credentials: true + 구체 출처 + 프론트 include.
05서버 수정 불가한 개발 단계는 dev 프록시로 우회.

본 글은 브라우저 CORS 정책과 일반적 해결 방법을 정리한 자료다. 보안 설정 변경은 영향 범위를 확인한 뒤 신중히 적용한다.

 

#CORS #CORS에러 #교차출처 #SameOriginPolicy #프리플라이트 #preflight #AccessControlAllowOrigin #브라우저보안 #웹개발 #Express #Nginx #API #프론트엔드 #백엔드 #CORS해결

반응형

댓글