PDF 뷰어 MCP App을 만들고, ngrok으로 외부에 노출해서 테스트하는 단순한 작업이었다. "서버 띄우고 ngrok 연결하면 끝이지 뭐." 그런데 CORS가 세 번 연속으로 다른 얼굴로 나타났다.

1타: Invalid PDF structure

PDF 뷰어가 잘 동작하길래 로컬 PDF 파일도 서빙해보기로 했다. Express에 /files 정적 경로를 추가하고, ngrok URL로 접근했다.

https://xxxx.ngrok-free.dev/files/ai-developer-career.pdf

결과: "PDF를 불러올 수 없습니다: Invalid PDF structure."

PDF 파일이 깨진 건가? 아니다. 브라우저에서 직접 열면 잘 된다. 문제는 ngrok이었다. ngrok 무료 플랜은 브라우저에서 접근하면 "이 사이트를 방문하시겠습니까?" 경고 HTML 페이지를 먼저 보여준다. PDF.js가 PDF를 요청했는데, ngrok이 HTML을 돌려준 거다. HTML을 PDF로 파싱하려니 당연히 "Invalid PDF structure".

해결: PDF.js의 getDocument() 호출 시 ngrok-skip-browser-warning 헤더를 포함하면 ngrok이 경고 페이지를 건너뛴다.

pdfjs.getDocument({
  url,
  httpHeaders: { 'ngrok-skip-browser-warning': '1' },
})

좋아, 해결했다. 다음 테스트로 넘어가자.

2타: Failed to fetch

이번에는 Cloudflare Pages에 PDF를 올려서 외부 도메인 테스트를 해봤다.

https://mcp-apps.pages.dev/ai-developer-career.pdf

결과: "PDF를 불러올 수 없습니다: Failed to fetch."

Cloudflare Pages는 기본적으로 Access-Control-Allow-Origin: *을 반환한다. CORS 문제가 아닌데? curl로 확인해도 헤더가 정상이다. 그런데 왜?

원인은 1타에서 추가한 ngrok-skip-browser-warning 헤더였다. 이 헤더를 모든 URL에 보내고 있었다.

브라우저는 Content-Type이나 Accept 같은 표준 헤더만 있으면 바로 요청을 보낸다(단순 요청). 하지만 비표준 커스텀 헤더가 하나라도 포함되면 **preflight(OPTIONS 요청)**를 먼저 보내서 서버에게 "이 헤더 써도 되나요?"를 물어본다.

PDF.js → Cloudflare Pages: OPTIONS "ngrok-skip-browser-warning 헤더 써도 될까요?"
Cloudflare Pages: "그런 헤더 모르는데요"
브라우저: preflight 실패 → 실제 요청 차단 → "Failed to fetch"

ngrok은 이 헤더를 알고 있으니 통과하지만, Cloudflare Pages는 모른다. Access-Control-Allow-Origin: *이 있어도 소용없다. preflight 단계에서 이미 막혔기 때문이다.

해결: ngrok URL일 때만 헤더를 보내도록 조건 분기.

const opts: Record<string, unknown> = { url };
if (url.includes('ngrok')) {
  opts.httpHeaders = { 'ngrok-skip-browser-warning': '1' };
}
pdfjs.getDocument(opts)

3타: CORS 차단 의도적으로 만들기

두 번이나 당하고 나니, CORS 에러가 실제로 어떻게 보이는지 직접 확인하고 싶어졌다. Express 서버에 CORS 헤더를 의도적으로 제거한 경로를 추가했다.

app.use('/no-cors-files', (_req, res, next) => {
  res.removeHeader('Access-Control-Allow-Origin');
  next();
}, express.static('public'));

결과:

  • /files/sample.pdf → ✅ 정상
  • /no-cors-files/sample.pdf → ❌ CORS 차단

이건 예상대로 동작했다. 이제 CORS 에러를 재현하고 싶을 때 언제든 테스트할 수 있다.

배운 것

하루 만에 CORS의 세 가지 얼굴을 봤다.

증상 실제 원인 교훈
Invalid PDF structure ngrok 경고 HTML을 PDF로 파싱 터널링 도구의 부수 효과를 알아야 한다
Failed to fetch 커스텀 헤더가 preflight를 유발 헤더 하나가 전체 요청 흐름을 바꾼다
CORS 차단 서버가 허용 헤더를 안 보냄 이것만이 진짜 CORS 문제다

세 개 중 "진짜" CORS 에러는 3번뿐이었다. 1번은 터널링 도구 이슈, 2번은 preflight 이슈. 에러 메시지만 보면 전부 "뭔가 차단됨"인데, 원인은 전부 달랐다.

MCP App처럼 iframe 안에서 외부 리소스를 fetch하는 구조에서는 이런 이슈가 겹겹이 쌓인다. "CORS 에러"라고 뭉뚱그리지 말고, 실제로 어느 단계에서 무엇이 차단됐는지를 정확히 봐야 한다.