쿠키… 네트워킹 처리의 고민

notion image
회사에서 같이 일하던 동료에게 이상한 현상이 하나 발생했습니다.
같은 IP를 쓰고 있는데, 어떤 페이지에서는 로그인 후 쿠키가 잘 저장되고, 다른 페이지에서는 쿠키가 전혀 찍히지 않는 문제였습니다.
  • 프론트 A: http://192.168.123.123/
  • 프론트 B: http://192.168.123.123/ai
  • 백엔드 API: http://192.168.123.123:9100
두 프론트엔드는 모두 같은 IP를 사용하고 있고, API 서버만 포트 9100으로 분리된 구조였습니다. 프론트 A에서는 로그인 요청 후 리프레시 토큰 쿠키가 잘 저장되는데, 프론트 B에서는 같은 API에 같은 로그인 요청을 보내도 쿠키가 전혀 저장되지 않았습니다.
처음에 제가 의심한 건 이런 것들이었습니다.
  • SameSite 설정 문제인가?
  • Path/Domain 설정이 꼬인 건가?
그래서 서버 설정, 쿠키 옵션설정까지 하나씩 확인했지만, 결국 당장 문제를 해결한 결정적인 단서프론트 쪽 credentials 옵션이었습니다.

왜 프론트 B에서는 쿠키가 안 찍혔을까?

브라우저 입장에서 상황을 다시 보면 이렇게 정리할 수 있습니다.
  • http://192.168.123.123 (포트 80)
  • http://192.168.123.123:9100 (포트 9100)
호스트(IP)는 같지만, 포트가 다르면 origin은 서로 다릅니다. 그래서 192.168.123.123 → 192.168.123.123:9100 요청은 브라우저 기준으로 CORS 요청이 됩니다.
여기서 중요한 포인트가 하나 있습니다.

CORS + Set-Cookie + credentials

MDN의 에 이런 내용이 나옵니다:
Fetch API나 XHR 요청이 CORS를 사용할 때,
요청에 credentials가 포함되지 않으면 응답의 Set-Cookie 헤더는 브라우저에서 무시된다.
이걸 이번 사례에 그대로 적용해 보면:
  • 프론트 A
    • 로그인 요청을 보낼 때 이미 credentials: "include"를 사용하고 있음
    • 그래서 응답의 Set-Cookie정상적으로 브라우저에 저장됨.
  • 프론트 B
    • 대략 이런 식으로 요청을 보내고 있었다고 볼 수 있음:
    • 기본값인 credentials: "same-origin"에서는
      • 192.168.123.123 (80)192.168.123.123:9100 요청이 cross-origin이기 때문에,
    • 브라우저가 응답의 Set-Cookie 헤더를 그냥 무시해 버린다.
결국 우리가 겪었던 현상을 한 줄로 정리하면:
프론트 A는 credentials를 포함해서 요청을 보내 쿠키가 잘 저장됐고, 프론트 B는 credentials를 빼먹어서, Set-Cookie가 와도 브라우저가 무시해 버렸다.
이번 일을 겪으면서, “같은 IP라서 괜찮겠지”라고 생각했던 부분이 실제로는 origin 기준에서는 완전히 다른 요청이 될 수 있다는 점, 그리고 CORS 환경에서 쿠키를 주고받으려면 꼭 credentials까지 같이 봐야 한다는 점을 몸으로 깨닫게 되었습니다.
이제 여기서부터는
  • CORS가 정확히 뭘 기준으로 동작하는지,
  • credentials: "include" / "same-origin" / "omit"이 어떻게 다른지,
  • 쿠키의 SameSite, Domain, Path 설정이 여기에 어떻게 얽히는지
를 하나씩 풀어보려고 합니다.

CORS는 뭘 기준으로 동작하고 뭘까?

CORS가 보는 기준은 site가 아니라 origin입니다.
origin = scheme + host + port
예를 들어:
  • http://192.168.0.60 → origin: http + 192.168.0.60 + 80
  • http://192.168.0.60:9100 → origin: http + 192.168.0.60 + 9100
  • https://www.naver.com → origin: https + www.naver.com + 443
두 요청의 origin이 완전히 같으면 same-origin, 셋 중 하나라도 다르면 cross-origin이 됩니다. 이때부터 브라우저는 CORS 규칙을 적용하기 시작합니다.

브라우저가 입장에서 CORS는 “보안 필터”

브라우저 입장에서 보면 대략 이렇게 나눌 수 있습니다.
  • same-origin 요청
    • “내 사이트 안에서 자기 서버랑 통신”이므로 비교적 자유롭게 허용
  • cross-origin 요청
    • “다른 사이트 서버랑 통신”이기 때문에 서버가 명시적으로 허용하지 않으면, JS에서 응답을 읽지 못하게 막음
CORS는 요청을 막는 게 아니라, 응답을 JS에서 읽는 걸 제어하는 보안 장치입니다.
여기서 중요한 포인트는:
CORS는 “요청을 막는 장치”라기보다,
“응답을 JS에게 넘길지 말지 결정하는 장치”에 가깝다.
실제 동작을 일반화해서 정리하면:
  1. 브라우저가 fetch로 cross-origin 요청을 보냄
  1. 요청은 서버까지 실제로 감
  1. 서버가 응답을 돌려줌
  1. 응답 헤더에 Access-Control-Allow-Origin 등 CORS 허용 헤더가 없음
  1. 브라우저가 네트워크 레벨에서는 응답을 받았지만, JS 코드에 응답을 넘겨주지 않고 차단
  1. 콘솔에 CORS 에러가 출력됨
즉, 응답이 네트워크 레벨에서 “안 오는” 게 아니라, 브라우저가 “이건 너(자바스크립트)가 보면 안 돼” 하고 막는 구조입니다.
참고로, Content-Type이 특정 조건을 벗어나거나, 커스텀 헤더를 쓰는 등 preflight(OPTIONS) 조건을 만족하면 브라우저가 먼저 OPTIONS 요청을 보내서 서버가 허용하는지 확인하고, 이 단계에서 허용되지 않으면 아예 본 요청 자체를 보내지 않는 경우도 있습니다.

그럼 누군가 응답을 하이재킹할 수 있는거 아니야?

이 부분은 어떤 공격자를 상정하는지에 따라 나눠서 봐야 합니다.

1. 네트워크 공격자

공격자가 네트워크 구간을 장악하고 있다면:
  • HTTP 통신은 그대로 볼 수 있고, 조작도 가능합니다.
  • 이건 CORS 유무와 관계가 없습니다.
이 상황에서 우리를 지켜주는 건:
  • HTTPS(TLS) 로 트래픽을 암호화하는 것
  • CORS가 아니라 TLS/인증서의 영역입니다.
정리하면
“응답을 누군가 하이재킹할 수 있냐?” → 네트워크가 HTTP고, 공격자가 트래픽을 볼 수 있다면 CORS와 상관없이 원래부터 가능 → 이건 CORS 문제가 아니라 TLS(HTTPS)를 써야 하는 보안 이슈입니다.

2. 악성 웹사이트 / 다른 origin의 JS

CORS + Same-Origin Policy(SOP)가 진짜로 막으려는 건 이 쪽입니다.
  • 악성 사이트 evil.com이 사용자의 브라우저에서 JS를 실행하면서
    • https://bank.com/account 같은 민감한 API에 요청을 보내고
    • 응답을 읽어서 계좌 정보, 개인 정보를 훔치는 시나리오
이때 CORS/SOP는 다음과 같은 역할을 합니다.
  • 요청 자체는 어느 정도 갈 수 있어도,
  • 응답 바디/일부 헤더를 JS가 읽지 못하게 막는다.
그래서 evil.com의 JS는:
  • 요청이 성공했는지, 응답 내용이 뭔지, 계좌 정보가 뭔지 직접 읽을 수 없습니다.
  • 최소한 데이터 유출은 막을 수 있게 됩니다.
결국:
CORS는 “네트워크 상의 패킷을 가로채는 공격”을 막는 게 아니라,
악성 웹페이지의 JS가 다른 origin의 민감한 응답을 읽어서 악용하는 것을 막는
브라우저 레벨의 보호 장치라고 볼 수 있습니다.

Credentials 옵션은 어떻게 쓸 수 있을까?

credentials 옵션은 요청에 쿠키/인증 정보를 실을지, 그리고 응답의 Set-Cookie를 브라우저가 반영할지를 함께 결정하는 설정입니다.
여기서 말하는 “credentials”에는 다음이 포함됩니다.
  • 쿠키(Cookie)
  • TLS 클라이언트 인증서
  • Authorization 같은 인증 헤더
fetch에서 사용할 수 있는 값은 세 가지입니다.
참고로 기본값은 "same-origin" 입니다. (예전에는 브라우저별로 차이가 있었지만, 지금은 스펙/MDN 기준으로 기본값이 same-origin으로 정리되어 있습니다.)

1. “omit” - “무자격” 요청

동작

  • 요청 보낼 때
    • 어떤 origin이든 쿠키, 세션, 인증 헤더를 전혀 보내지 않음
  • 응답 받을 때
    • 서버에서 Set-Cookie를 내려줘도 브라우저가 무시
즉, “이 요청에는 그 어떤 자격 증명도 절대 쓰지 않겠다”는 의미입니다.

언제 쓸까?

  • 완전 비공개 리소스를 가져올 때
    • 공개 이미지 CDN, 공개 문서 JSON 등
“혹시라도 이 요청에 쿠키가 실리면 안 된다” 같은 상황에서 사용되는 보안적으로 가장 보수적인 모드입니다.

2. “same-origin” - “내 출처에만 인증 허용”

same-origin은 현재 페이지의 origin과 요청을 보내는 URL의 origin이 같을 때만 자격 증명을 사용합니다. 이 값이 기본값입니다.

동작

  • 요청 보낼 때
    • 요청 URL의 origin이 현재 페이지의 origin과 같다면 → 쿠키 / 세션 / 인증 헤더를 보냄
    • origin이 다르면 (CORS 상황) → 쿠키 안 보냄
  • 응답 받을 때
    • same-origin이면 응답의 Set-Cookie반영
    • cross-origin이면 응답의 Set-Cookie무시
즉, “내 origin 서버와 통신할 때만 세션/로그인을 사용하겠다”는 기본적인 상황에 맞는 모드입니다.

3. “include” - “origin 상관없이 인증까지”

동작

  • 요청 보낼 때
    • same-origin이든 cross-origin이든 쿠키 / 인증 정보를 포함해서 보냄
  • 응답 받을 때
    • 서버가 CORS를 올바르게 설정했다면 (Access-Control-Allow-Origin/Access-Control-Allow-Credentials) → cross-origin 응답의 Set-Cookie저장
즉, origin이 달라도 인증된 컨텍스트를 유지하면서 요청을 보내는 모드입니다.
다만, 이 모드를 쓰면 서버도 CORS를 제대로 열어줘야 합니다.

쿠키가 전송되기 까지

브라우저가 어떤 요청에 어떤 쿠키를 붙일지 결정할 때, 개념적으로는 대략 이런 필터를 순서대로 거칩니다.
  1. 만료가 안 됐나?
      • Expires / Max-Age 기준으로 이미 만료된 쿠키는 후보에서 제외
  1. Domain이 매칭되는가?
      • 요청 호스트가 Domain 속성에 해당하는지
      • Domain 미지정이면 쿠키를 만든 호스트와 정확히 같아야 함 (host-only cookie)
  1. Path가 매칭되는가?
      • 요청 경로가 Path로 시작하는지(프리픽스 매칭)
  1. Secure인데 HTTPS 요청인가?
      • Secure 쿠키는 HTTPS 요청에만 전송
  1. SameSite 규칙에 맞는 컨텍스트인가?
      • “현재 탑레벨 사이트”와 “요청 URL의 사이트”를 비교해서 Strict / Lax / None 규칙에 맞는지 확인
  1. credentials 설정이 쿠키 허용 모드인가?
      • credentials: "omit"아예 안 씀
      • same-origin → cross-origin이면 안 씀
      • include → cross-origin에도 “보낼 자격이 있는 쿠키들”을 전부 시도
이 중 하나라도 탈락하면 해당 요청의 Cookie 헤더에 포함되지 않습니다.
만료, Secure 여부는 비교적 직관적인데, SameSite / Domain / Path는 케이스가 다양해서 조금 더 자세히 봐야 합니다.

1. Domain - “어느 호스트/서브도메인까지 이 쿠키를 보낼까?”

Domain“어떤 도메인(호스트)들에게 이 쿠키를 전송할지” 정의합니다.

Domain을 지정하지 않는 경우

Set-CookieDomain을 아예 쓰지 않으면, host-only 쿠키가 됩니다.
  • 쿠키를 만든 호스트(예: www.bank.com)에서만 전송
  • api.bank.com, server.bank.com 같은 서브도메인에는 전송되지 않음
즉:
  • www.bank.com에서 Domain 없이 설정한 쿠키 → 오직 www.bank.com으로의 요청에만 전송

Domain을 지정한 경우

Domain=.bank.com처럼 쓰면, 그 도메인과 서브도메인들에 전송됩니다.
  • Domain=.bank.com이면:
    • bank.com
    • www.bank.com
    • api.bank.com
    • server.bank.com
    • … 모두 전송 대상
주의: 보안상 이유로, 브라우저는 .com, .co.kr같은 퍼블릭 Suffix에는 쿠키를 설정할 수 없습니다. (.com 전체에 쿠키를 심는 “슈퍼 쿠키”는 막혀 있음)

언제 어떻게 사용할까?

  • 가능하면 Domain을 지정하지 않고(host-only) 필요한 호스트에서만 쓰는 걸 추천
  • 여러 서브도메인(api.bank.com, web.bank.com 등)에서 같이 써야 하는 세션이라면 그때만 Domain=.bank.com 같이 범위를 넓히는 편이 안전
Domain을 넓게 잡을수록 같은 도메인 계열의 다른 서비스에서도 쿠키를 읽거나 사용할 수 있으니, 꼭 필요할 때만 쓰는 게 좋습니다.

2. Path - “그 도메인 안에서 어느 URL 경로까지 보낼까?”

Path“요청 URL의 path가 어디부터 시작해야 이 쿠키를 붙일지”를 결정합니다.
예를 들어, 쿠키의 Path/shop인 경우 전송 대상:
  • /shop
  • /shop/
  • /shop/product/1
  • /shop/cart
다음과 같은 요청에는 전송되지 않습니다:
  • /
  • /admin
  • /profile
즉, Path는 프리픽스(prefix) 매칭입니다.
  • 서비스 전역에서 필요한 세션/로그인 쿠키 → 보통 Path=/
  • 특정 기능에서만 필요한 쿠키 → 해당 기능의 경로로 제한 (/shop, /admin, /upload 등)
이렇게 범위를 좁히면, 한 도메인 안에서도 서로 다른 기능끼리 쿠키를 덜 공유하게 되어, 보안/관리 측면에서 더 안전합니다.

3. SameSite - “같은 사이트가 아닐 때, 이 쿠키를 보낼까 말까”

SameSite“이 쿠키를 cross-site 요청에 포함할지 말지”를 제어하는 속성입니다.
가능한 값은 세 가지입니다.
  • SameSite=Strict
  • SameSite=Lax
  • SameSite=None (→ 반드시 Secure와 함께)
최근 브라우저들은 SameSite를 안 쓰면 사실상 Lax를 기본값으로 취급합니다.

“site”의 기준이 뭘까?

SameSite에서 말하는 “site”는 대략 “등록 가능한 도메인 + scheme(https/http)”입니다.
  • https://bank.comhttps://server.bank.com
    • 도메인 계열이 같고, 둘 다 https → 같은 사이트
  • https://bank.comhttps://evil.com
    • 도메인 다름 → 다른 사이트
  • http://bank.comhttps://bank.com
    • scheme 다름 → “schemeful same-site” 기준에서는 다른 사이트
즉, 서브도메인은 같은 사이트, 하지만 http/https가 다르면 다른 사이트로 취급하는 방향으로 스펙이 정리되고 있습니다.

SameSite=Strict

가장 강력한 모드입니다.
  • 같은 사이트에서의 요청에만 쿠키 전송
  • 다른 사이트에서 오는 어떤 요청에도 쿠키가 붙지 않음
    • 예: 다른 도메인에서 <img src="https://bank.com/...">로 요청해도 쿠키 X
    • 다른 사이트의 <form>https://bank.com/transfer로 POST를 날려도 쿠키 X
CSRF 방어 측면에서 가장 강력하지만, 로그인 상태 유지 등 UX 측면에서 제약이 커서
진짜 민감한 쿠키에만 가끔 쓰입니다.

SameSite=Lax

대부분의 브라우저에서 기본값으로 취급하는 모드입니다.
대략적인 동작:
  • same-site 요청 → 평소처럼 쿠키 전송
  • 다른 사이트에서 오는 “일반적인 서브 요청” (이미지, XHR, fetch 등) → 쿠키 전송 안 함
  • 다른 사이트에서의 “탑레벨 GET 이동” (링크 클릭 등) → 제한적으로 쿠키를 전송
즉,
  • 링크를 클릭해서 https://bank.com으로 이동하는 정도의 흐름에서는 세션이 유지되지만,
  • 다른 사이트에서 백그라운드 요청(XHR/fetch)으로 내 API를 때리는 시나리오에서는 쿠키가 붙지 않도록 막아 줍니다.
그래서 “일반적인 웹 서비스 세션 쿠키”는 Lax가 기본값으로 많이 사용됩니다.

SameSite=None; Secure

SameSite=None은 말 그대로:
“이 쿠키를 cross-site 요청에도 보내도 괜찮다”
는 뜻입니다. 하지만 현대 브라우저에서는 반드시 Secure와 함께 사용해야 합니다.
그렇지 않으면:
  • 브라우저가 이 쿠키를 아예 무시하고 설정하지 않음(Invalid 포맷으로 취급)
그래서 SameSite=None은 사실상:
  • HTTPS 환경에서만 쓸 수 있는 cross-site 쿠키 모드
라고 이해하는 게 편합니다.

언제 어떻게 써야할까?

  • 민감한 세션 쿠키
    • 가능하면 SameSite=Lax 또는 Strict
    • None은 정말 cross-site가 필수인 경우(SSO, 일부 위젯 등)에만 사용
  • 제3자 서비스, SSO, iframe 등에 필요한 쿠키
    • 다른 사이트에 embed되거나, 여러 도메인을 넘나드는 인증 흐름이 필수라면 → SameSite=None; Secure
  • On-prem HTTP(비 SSL) 환경
    • SameSite=None을 쓰려면 무조건 Secure 필요 → HTTP에서는 브라우저가 아예 무시하기 때문에 사실상 사용 불가
    • 이 상태에서 HTTP + 포트만 다른 서버로 cross-origin + 쿠키 조합을 쓰려고 하면,
      • SameSite
      • Secure
      • CORS
      • credentials
이 네 가지가 서로 꼬이면서 이상한 버그를 만들기 쉽습니다.
이런 이유로, nginx 등으로 origin(도메인/포트)을 통일해서 HTTPS로 올린 뒤 웹 앱과 API를 리버스 프록시 뒤에 두는 구조가 더 깔끔한 해법으로 많이 쓰입니다.