CORS만으로는 부족했다 — Next.js에서 외부 API와 HttpOnly 쿠키로 인증하기

작성일: 2026년 6월 5일 PM 05:07
조회수: 17

thumbnail

로그인은 되는데, 새로고침하면 로그인이 풀렸습니다. 분명 백엔드에 CORS도 다 열어줬는데 말이죠. DevTools를 열어보니 Set-Cookie 옆에 작은 노란 경고 삼각형이 떠 있었습니다. 이 글은 그 경고가 무슨 의미였는지, 그리고 "CORS를 맞췄으니 끝"이라고 생각했던 게 왜 착각이었는지에 대한 기록입니다.

상황

구조는 흔한 형태였습니다.

  • 프론트엔드: Next.js (App Router), 개발은 http://localhost:3000
  • 백엔드: 별도 외부 API 서버 (https://api.example.com/insight, Express + Cloudflare)
  • 인증: access token은 메모리(axios 헤더), refresh token은 HttpOnly 쿠키

로그인 요청을 보내면 백엔드가 Set-Cookie로 refresh token을 내려줍니다. access token은 메모리에 들고 있다가 만료되면 POST /auth/refresh로 갱신하는, 이른바 silent refresh 패턴입니다.

문제는 새로고침이었습니다. 페이지를 새로고침하면 메모리의 access token이 날아가고, 첫 요청이 401을 받으면 인터셉터가 /auth/refresh를 호출해 세션을 복구해야 합니다. 그런데 그 refresh 요청에 쿠키가 실리지 않았습니다. 백엔드는 쿠키 없는 요청을 받고 401을 던졌고, 로그인은 그대로 풀렸습니다.

CORS는 이미 맞춰둔 상태였습니다. Access-Control-Allow-Origin도, Access-Control-Allow-Credentials: true도 다 있었습니다. 프론트도 withCredentials: true였고요. 그런데 왜 안 됐을까요?

CORS를 맞췄는데 왜 부족했나

여기서 제가 헷갈렸던 핵심은 이겁니다. CORS와 "쿠키가 요청에 실리는지"는 서로 다른 레이어입니다.

  • CORS는 "브라우저가 이 cross-origin 응답을 JS에게 읽게 허용할 것인가"를 결정합니다. 자격증명을 포함한 요청이라면 Access-Control-Allow-Credentials: true + 정확한 origin이 있어야 응답을 쓸 수 있습니다.
  • 하지만 쿠키가 그 요청에 애초에 붙느냐는 CORS가 아니라 쿠키의 SameSite 속성이 결정합니다.

제 쿠키는 이렇게 내려오고 있었습니다.

Set-Cookie: refreshToken=...; HttpOnly; SameSite=Lax; Path=/insight/auth; Max-Age=2592000

SameSite=Lax가 범인이었습니다. SameSite=Lax 쿠키는 cross-site subresource 요청(즉, JS가 보내는 XHR/fetch)에는 첨부되지 않습니다. same-site 요청과, 주소창으로 이동하는 top-level navigation(GET)에서만 실립니다.

그런데 제 환경은 localhost(site = localhost)와 api.example.com(site = example.com)으로 명백한 cross-site였습니다. 그러니 새로고침 때 프론트가 JS로 보내는 POST /auth/refresh는 cross-site XHR이고, 브라우저는 SameSite=Lax 쿠키를 빼고 보냅니다. 백엔드는 쿠키 없는 refresh를 받습니다. 끝이죠.

DevTools의 그 경고 삼각형이 정확히 이 의미였습니다. "이 쿠키는 SameSite=Lax라서 cross-site 컨텍스트에서는 전송되지 않는다."

흥미로운 비대칭이 하나 있습니다. 로그인 응답에서 쿠키를 저장하는 것은 (경고와 함께) 허용되는데, 이후 cross-site XHR에서 그 쿠키를 전송하는 것은 막힙니다. 그래서 "로그인은 되는데 새로고침은 안 되는" 증상이 나왔던 겁니다.

정리하면 — CORS는 응답을 통과시키는 문이고, SameSite는 쿠키를 실어주는 문입니다. 저는 앞문만 열고 뒷문은 잠가둔 채 "왜 안 들어오지" 하고 있었던 셈입니다.

백엔드는 쿠키를 어떻게 설정해야 하나

그러면 백엔드에 뭘 요청해야 할까요. 목표는 dev(localhost)와 배포(도메인 일치) 양쪽에서 같은 설정 하나로 동작하면서 보안 정석을 지키는 것이었습니다.

속성이유
HttpOnlyJS 접근 차단 (XSS 방어)
Secure(흔히 누락)refresh 토큰 필수. http localhost는 secure context로 취급돼 dev에서도 동작
SameSiteLaxFE/API가 same-site(또는 same-origin)이면 Lax로 충분. None은 3rd-party 쿠키라 폐지 수순
Domain설정 안 함 (host-only)명시하면 프록시·서브도메인 상황에서 도메인 불일치로 쿠키가 버려짐
Path/auth 같은 좁은 경로refresh 쿠키를 auth 엔드포인트로만 한정 — 노출 최소화

핵심은 Secure를 빼먹지 말 것, 그리고 **Domain을 명시하지 말 것(host-only 유지)**입니다. host-only 쿠키는 요청이 가는 호스트에 자동으로 묶이므로, 같은 origin으로 가든 같은 상위 도메인의 서브도메인으로 가든 알아서 동작합니다. 더 안전하고 더 유연하죠.

한 가지 함정이 있습니다. Secure 쿠키는 secure context에서만 저장·전송되는데, Chrome/최신 Firefox는 http://localhost를 secure context로 취급하므로 dev에서도 문제없습니다. 다만 Safari나 LAN IP(192.168.x.x로 모바일 실기기 테스트)는 secure context가 아니라 깨질 수 있습니다. 전 브라우저 패리티가 필요하면 dev를 https로 띄우면 됩니다(next dev --experimental-https).

Next.js에서의 best practice — 우선순위

cross-site 쿠키 문제를 푸는 방법은 사실 여러 개이고, 명확한 우선순위가 있습니다.

  1. FE와 API를 같은 상위 도메인에 배치 (app.x.com + api.x.com, 쿠키 Domain host-only, SameSite=Lax). 프록시 hop이 없고 레이턴시가 가장 낮습니다. 외부 API 토폴로지의 정석입니다.
  2. 엣지/게이트웨이 레벨 reverse proxy (Cloudflare Worker·nginx가 x.com/api → BE 매핑). same-origin이면서 앱 서버를 안 거칩니다.
  3. Next.js rewrites 프록시. 동작하지만 "앱 서버를 프록시로 쓰는" 가장 약한 same-origin 옵션입니다. 서버리스 배포면 모든 API 호출이 함수 invocation이 된다는 비용이 있습니다.
  4. SameSite=None; Secure + CORS credentials. cross-site에서 당장은 되지만 3rd-party 쿠키라 브라우저 privacy 정책에 취약합니다. 권장하지 않습니다.

가장 깔끔한 형태를 더 밀어붙이면 BFF 패턴이 있습니다. Next 서버가 세션을 쥐고 서버사이드로 API와 통신해서 브라우저 JS에는 토큰이 아예 안 내려가는 구조죠. 프록시를 쓰는 순간 이미 BFF의 절반까지 와 있는 셈입니다.

저는 인프라를 건드릴 수 없고 프론트만으로 즉시 풀어야 하는 상황이었습니다. 그래서 3번 — Next rewrites 프록시를 택했습니다. best practice냐고 물으면 정직하게는 "맞는 방향의 차선책"입니다. 1·2번이 더 낫지만, 주어진 제약 안에서는 합리적인 stopgap이라고 판단했습니다.

제가 해결한 방법

핵심 아이디어는 브라우저가 외부 API를 직접 부르지 않게 하는 것입니다. 브라우저는 same-origin인 /insight/*로만 호출하고, Next 서버가 그걸 실제 백엔드로 프록시합니다. 그러면 브라우저 입장에서는 모든 게 same-origin이라 SameSite 제약도, CORS도 사라집니다.

next.config.js에 rewrites를 추가했습니다.

js
async rewrites() {
  // 브라우저는 /insight/*로 호출(same-origin) → Next가 실제 백엔드로 프록시.
  // cross-site SameSite·CORS 제약 회피. target 미설정 시 비활성.
  const target = process.env.API_PROXY_TARGET;
  if (!target) return [];
  return [{ source: "/insight/:path*", destination: `${target}/:path*` }];
}

그리고 axios의 base URL을 외부 절대 URL에서 /insight로 바꿨습니다.

bash
NEXT_PUBLIC_API_URL=/insight
API_PROXY_TARGET=https://api.example.com/insight

여기서 한 가지 비자명한 결정이 있었습니다. 왜 흔히 쓰는 /api가 아니라 /insight를 base로 잡았을까요?

쿠키의 Path=/insight/auth 때문입니다. 프록시로 same-origin을 만들어도, 브라우저가 보는 경로가 쿠키 Path와 맞지 않으면 여전히 쿠키가 안 실립니다. 만약 base를 /api로 잡으면 refresh 요청은 /api/auth/refresh가 되는데, 쿠키 Path는 /insight/auth라 매칭이 안 됩니다. base를 /insight로 두면 refresh가 /insight/auth/refresh가 되어 Path와 정확히 맞물립니다. 백엔드 쿠키를 한 줄도 안 고치고 통과시키는 트릭이죠.

(부수적으로, 이 프로젝트의 MSW mock이 NEXT_PUBLIC_API_URL === "/api"일 때만 켜지도록 되어 있어서, /insight를 쓰면 mock과도 자연히 충돌하지 않습니다.)

흐름을 그림으로 보면 이렇습니다.

mermaid

CSP도 자동으로 정리됐습니다. 외부 origin으로 나가는 호출이 사라지니 connect-src'self'로 좁혀졌습니다. CORS 설정 자체가 더 이상 필요 없어진 것은 덤입니다(브라우저↔Next는 same-origin, Next↔API는 server-to-server라 CORS 영역 밖이니까요).

남는 이야기

이 프록시는 어디까지나 stopgap입니다. 정직하게 짚어두면 이렇습니다.

  • 백엔드 쿠키는 따로 고쳐야 합니다. 프록시는 전송 계층의 막힘만 뚫었을 뿐이고, Secure 누락 같은 건 그 자체로 고쳐야 할 보안 항목입니다.
  • 배포 때 FE/API 도메인을 일치시키면 프록시를 떼어낼 수 있습니다. 그게 우선순위 1번이고, 현재 프록시는 거기까지의 다리입니다.
  • 그래서 설정을 코드에 하드코딩하지 않고 env 레이어에만 뒀습니다. 전환은 환경변수 한 줄로 끝나야 하니까요.

결국 이 삽질에서 가장 크게 남은 건 한 문장입니다. CORS가 열렸다고 쿠키가 실리는 게 아니다. 둘은 다른 문이고, cross-site 환경에서 HttpOnly 쿠키로 인증한다면 진짜 싸워야 할 상대는 CORS가 아니라 SameSite와 origin 경계였습니다.

0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!