web

쿠키 톺아보기 (2) — 생애주기, 브라우저 내부 구조, 자동 첨부의 부작용

쿠키가 클라이언트와 서버 각각의 어떤 시점에 읽히고 쓰이는지, 브라우저 내부에서 누가 쿠키를 관리하는지, 그리고 자동 첨부가 만들어내는 부작용과 완화 전략까지 정리합니다.

cookie http browser cdn performance cookie-jar

들어가며

이전 글에서는 쿠키의 동작 원리와 주요 옵션, 인증에 쓰이는 이유를 정리했다. 하지만 실제 개발을 하다 보면 옵션만 알아서는 풀리지 않는 질문들이 남는다.

이 글에서는 쿠키의 생애주기를 클라이언트와 서버 양쪽에서 추적하고, 쿠키를 실제로 보관·관리하는 브라우저 내부 컴포넌트를 살펴본 뒤, 자동 첨부가 만들어내는 부작용과 완화 전략까지 다룬다.

서버 관점 — 쿠키를 다루는 4가지 시점

서버 코드에서 쿠키와 마주치는 시점은 크게 네 가지다. 어느 시점에 무엇을 해야 하는지 명확히 구분해두면, 인증 미들웨어를 설계하거나 디버깅할 때 헤맬 일이 줄어든다.

시점 ①: 요청을 받을 때 (읽기)

브라우저가 보낸 요청의 Cookie 헤더를 파싱한다.

GET /api/user HTTP/1.1
Host: example.com
Cookie: sessionId=abc123; theme=dark

Node.js / Express 환경의 예시는 다음과 같다.

app.get('/api/user', async (req, res) => {
  const sessionId = req.cookies.sessionId;

  const user = await redis.get(`session:${sessionId}`);
  res.json(user);
});

읽기는 보통 두 가지 경로에서 일어난다.

시점 ②: 응답을 보낼 때 (쓰기)

Set-Cookie 헤더를 응답에 포함시켜 브라우저에 저장하도록 지시한다.

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);

  const sessionId = generateId();
  await redis.set(`session:${sessionId}`, user.id);

  res.cookie('sessionId', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 86400 * 1000,
  });

  res.json({ success: true });
});

실제로 응답에 실리는 헤더는 다음과 같은 형태다.

HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
Content-Type: application/json

쓰기가 일어나는 대표적인 상황은 네 가지다.

시점 ③: 쿠키 삭제

서버가 브라우저의 쿠키를 직접 지울 수 없으므로, 즉시 만료시키는 쿠키를 다시 내려보내는 방식으로 삭제한다.

app.post('/logout', (req, res) => {
  res.cookie('sessionId', '', {
    maxAge: 0,
    httpOnly: true,
  });
  res.json({ success: true });
});

여기서 자주 놓치는 함정이 있다. 쿠키 삭제 시에는 설정 당시와 동일한 Path, Domain을 명시해야 한다. 다르면 브라우저가 다른 쿠키로 인식해 삭제되지 않는다.

// 설정 시
res.cookie('sessionId', 'xxx', { path: '/api' });

// 삭제 시 — Path 반드시 동일해야 함
res.cookie('sessionId', '', { path: '/api', maxAge: 0 });

시점 ④: 미들웨어에서 일괄 처리

실무에서는 매 핸들러마다 쿠키를 읽지 않고, 미들웨어에서 한 번 읽어 요청 객체에 부착하는 패턴이 일반적이다.

app.use(async (req, res, next) => {
  const sessionId = req.cookies.sessionId;

  if (sessionId) {
    const user = await redis.get(`session:${sessionId}`);
    req.user = user;
  }

  next();
});

app.get('/api/profile', (req, res) => {
  if (!req.user) return res.status(401).end();
  res.json(req.user);
});

이 구조의 장점은 두 가지다. 인증 로직이 한 곳에 모이고, 핸들러는 req.user만 참조하면 되므로 책임이 명확해진다.

클라이언트 관점 — 5가지 시점

클라이언트 쪽은 더 단순하다. 대부분 브라우저가 자동으로 처리하고, 개발자가 직접 다룰 일은 제한적이다.

시점 ①: 응답 수신 시 — 자동 저장

서버가 보낸 Set-Cookie 헤더를 보면 브라우저가 자동으로 쿠키 저장소에 기록한다. 개발자가 JS 코드로 별도 처리할 일은 없다.

[브라우저 쿠키 저장소]
Domain        Name        Value     Expires   Flags
example.com   sessionId   abc123    +1d       HttpOnly
example.com   theme       dark      Session

시점 ②: 요청 송신 시 — 자동 첨부

같은 도메인으로 요청을 보낼 때마다 브라우저가 저장된 쿠키 중 도메인·경로·옵션 조건이 맞는 것을 자동으로 Cookie 헤더에 담아 전송한다.

GET /api/user HTTP/1.1
Cookie: sessionId=abc123; theme=dark

자동 첨부 대상은 다음과 같다.

요청 종류자동 첨부 여부
<a href> 링크 클릭O
<form> 제출O
<img>, <script>, <link> 등 자원 로드O
fetch() / XMLHttpRequest (same-origin)O
fetch() (cross-origin)credentials: 'include' 옵션 필요
브라우저 주소창에 URL 입력O
새 탭에서 같은 사이트 열기O

cross-origin의 자동 첨부 정책은 별도의 큰 주제라 다음 글에서 자세히 다룬다.

시점 ③: JavaScript로 직접 읽기

HttpOnly가 아닌 쿠키는 document.cookie로 읽을 수 있다.

console.log(document.cookie);
// "theme=dark; lang=ko"
// (HttpOnly가 붙은 sessionId는 보이지 않음)

HttpOnly 쿠키는 JavaScript에서 존재 여부조차 알 수 없다. 이게 보안의 핵심이다.

읽기가 필요한 일반적인 상황은 두 가지다.

시점 ④: JavaScript로 직접 쓰기

document.cookie에 문자열을 할당하면 쿠키를 추가하거나 갱신할 수 있다.

document.cookie = 'theme=dark; path=/; max-age=86400; SameSite=Lax';

// 삭제 — 과거 시점으로 만료
document.cookie = 'theme=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';

document.cookie =는 기존 쿠키를 덮어쓰는 게 아니라 한 번에 하나씩 추가하는 방식이다. 여러 쿠키를 다루려면 여러 번 할당해야 한다.

시점 ⑤: 만료와 삭제

브라우저가 자동으로 쿠키를 정리하는 경우다.

트리거동작
Max-Age / Expires 도달자동 삭제
브라우저 종료 (Session Cookie)삭제
사용자가 브라우저 설정에서 삭제삭제
시크릿 모드 종료세션 전체 삭제

실전 흐름 — 로그인부터 로그아웃까지

지금까지의 시점들을 하나의 흐름으로 이어보자.

1. 사용자가 로그인 페이지에서 ID/PW 입력 후 제출
   [Client] POST /login (Cookie 헤더 없음)
   [Server] ① 인증 검증
            ② 세션 생성, Redis 저장
            ③ res.cookie('sessionId', 'abc', {HttpOnly,...})
   [Client] 응답 수신 → 브라우저가 자동으로 쿠키 저장

2. 사용자가 마이페이지로 이동
   [Client] 브라우저가 자동으로 Cookie 헤더 첨부
            GET /mypage
            Cookie: sessionId=abc
   [Server] ① 미들웨어가 req.cookies.sessionId 읽음
            ② Redis에서 세션 조회 → 사용자 정보 획득
            ③ 개인화된 HTML 응답

3. 마이페이지에서 API 호출 (fetch)
   [Client] fetch('/api/orders')
            → 브라우저가 자동으로 Cookie 헤더 첨부
   [Server] 위와 동일하게 세션 검증 후 데이터 반환

4. 사용자가 로그아웃
   [Client] POST /logout (Cookie 자동 첨부)
   [Server] ① Redis에서 세션 삭제
            ② res.cookie('sessionId', '', {maxAge: 0})
   [Client] 만료 쿠키 수신 → 브라우저가 저장소에서 제거

각 단계에서 누가 어떤 일을 하는지 명확하다. 핵심은 저장과 첨부는 브라우저가 자동으로, 읽기와 쓰기는 서버가 명시적으로 처리한다는 점이다.

SSR 환경에서의 차이점

Astro, Next.js 같은 SSR 환경에서는 한 가지 특수성이 더 있다. 첫 HTML 요청 시점에 서버가 이미 쿠키를 읽어, 개인화된 HTML을 만들어 내려준다.

---
// Astro 서버 사이드: 페이지 렌더링 시점에 쿠키 읽기
const sessionId = Astro.cookies.get('sessionId')?.value;
const user = sessionId ? await getUserBySession(sessionId) : null;
---

<html>
  <body>
    {user ? <p>안녕하세요, {user.name}님</p> : <a href="/login">로그인</a>}
  </body>
</html>

전통적인 SPA(CSR)와 비교하면 차이가 분명하다.

방식쿠키를 읽는 시점
SPA(CSR)빈 HTML 로드 → JS 실행 → API 호출 시점에 서버가 쿠키 확인
SSR첫 HTML 요청 시점에 서버가 쿠키 확인 → 개인화된 HTML 전달

SSR에서 첫 화면이 빠르게 채워지는 이유 중 하나가 이 흐름이다. 단, 캐시 정책과 충돌할 수 있으므로 정적 페이지와 개인화 페이지를 구분해 설계해야 한다.

브라우저 내부 — 쿠키를 누가 관리하는가

여기서 한 가지 질문이 자연스럽게 떠오른다. document.cookie라는 JS 코드 한 줄을 호출했을 때, 브라우저 내부에서는 정확히 어떤 컴포넌트가 쿠키를 보관하고 반환하는가?

흔히 V8이 쿠키를 관리한다고 오해하기 쉽지만, 실제 주체는 다르다. 브라우저 내부 구조를 단순화하면 다음과 같다.

┌─────────────────────────────────────────────────────────┐
│                       Browser                           │
│                                                         │
│  ┌────────────────────────────────────────────────┐     │
│  │              Rendering Engine                  │     │
│  │                                                │     │
│  │  ┌──────────────────┐  ┌─────────────────┐     │     │
│  │  │  JS Engine (V8)  │  │   Web APIs      │     │     │
│  │  │  - JS 실행        │◀▶│   - document    │     │     │
│  │  │  - 메모리 관리      │  │   - fetch       │     │     │
│  │  └──────────────────┘  └────────┬────────┘     │     │
│  │                                 │              │     │
│  └─────────────────────────────────┼──────────────┘     │
│                                    │                    │
│  ┌─────────────────────────────────▼──────────────┐     │
│  │              Network Stack                     │     │
│  │  ┌──────────────────────────────────────────┐  │     │
│  │  │       Cookie Jar (쿠키 저장소)              │  │     │
│  │  │  - SQLite DB 파일로 저장                    │  │     │
│  │  │  - 도메인별 격리                             │  │     │
│  │  │  - HttpOnly, Secure, SameSite 정책 적용     │  │     │
│  │  └──────────────────────────────────────────┘  │     │
│  └────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────┘

각 컴포넌트의 역할을 정리해보자.

V8 — JavaScript 실행 엔진

V8은 순수한 JavaScript 엔진이다. ECMAScript 명세만 구현하므로, document, fetch, localStorage 같은 브라우저 객체는 알지 못한다.

const a = 1 + 2; // V8이 처리
const arr = [1, 2, 3]; // V8이 처리

document.cookie; // V8은 "외부에서 주입된 객체의 프로퍼티"로만 인식

Node.js가 V8을 그대로 가져다 쓰지만 documentcookie가 없는 이유가 여기에 있다. 브라우저 환경의 호스트 객체는 V8 외부에서 주입된다.

Web API — JS ↔ 브라우저 인터페이스

Web API는 JS에서 브라우저 기능을 사용할 수 있도록 만든 인터페이스다. document, fetch, CookieStore 등이 여기에 속한다. 실제 구현은 C++로 되어 있고, 네트워크 스택과 통신해 데이터를 주고받는다.

document.cookie 호출 시 내부 흐름은 다음과 같다.

[JS]   document.cookie


[V8]   객체의 getter 함수 호출


[Web API 바인딩(C++)]   네트워크 스택에 쿠키 요청


[Network Stack]   Cookie Jar에서 현재 도메인 쿠키 조회
                  → HttpOnly 쿠키는 필터링해서 제외


[Web API]   문자열로 가공해서 V8에 반환


[V8]   JS 코드에 문자열 전달

핵심은 Web API가 데이터를 가져오는 통로일 뿐, 직접 보관하지는 않는다는 점이다.

쿠키 데이터를 실제로 들고 있는 것은 브라우저의 네트워크 스택, 그중에서도 Cookie Jar라는 컴포넌트다.

Cookie Jar는 디스크에 SQLite 파일로 데이터를 보관한다. macOS의 Chrome 기준 위치는 다음과 같다.

~/Library/Application Support/Google/Chrome/Default/Cookies
~/Library/Application Support/Google/Chrome/Default/Network/Cookies

이 파일은 SQLite DB로, 직접 조회하면 다음과 같은 테이블을 볼 수 있다.

SELECT host_key, name, value, is_httponly, is_secure, samesite
FROM cookies;

-- host_key    | name      | value  | is_httponly | is_secure | samesite
-- example.com | sessionId | abc123 | 1           | 1         | 1 (Lax)
-- example.com | theme     | dark   | 0           | 0         | 1 (Lax)

보안을 위해 값은 OS 키체인으로 암호화되어 저장된다.

Cookie Jar가 담당하는 책임은 다음과 같다.

책임설명
저장Set-Cookie 응답 헤더를 파싱해서 DB에 저장
조회요청 시 도메인·경로·옵션 조건에 맞는 쿠키 선별
정책 적용HttpOnly, Secure, SameSite, Domain, Path 검증
만료 관리Expires / Max-Age 도달 시 자동 삭제
격리도메인별, 시크릿 모드별, 프로필별로 분리

누가 무엇을 하는가 — 정리

행위주체시점
쿠키 저장브라우저서버 응답 수신 시 (자동)
쿠키 첨부브라우저같은 도메인 요청 송신 시 (자동)
쿠키 읽기 (서버)서버요청 받을 때
쿠키 쓰기 (서버)서버응답 보낼 때
쿠키 읽기 (클라이언트)JSdocument.cookie 호출 시
쿠키 쓰기 (클라이언트)JSdocument.cookie = 호출 시
쿠키 만료/삭제브라우저Max-Age / Expires 도달 시 (자동)

왜 이런 구조인가

쿠키 저장소를 JS가 직접 다루지 못하게 분리한 데에는 분명한 이유가 있다.

보안. 만약 V8/JS가 쿠키 저장소를 직접 관리한다면, XSS 한 번에 모든 쿠키가 탈취된다. HttpOnly 같은 보호 장치 자체가 불가능해진다. 쿠키 저장소를 JS가 접근 불가능한 영역에 두고, Web API라는 좁은 창구로만 노출시킴으로써 격리 수준을 높였다.

자동 첨부의 일관성. 링크 클릭, 폼 제출, 이미지 로드, fetch — 이 모든 동작에 쿠키가 자동 첨부된다. JS가 관여하지 않는 동작에도 쿠키가 붙어야 하므로, 네트워크 스택 레벨에서 처리해야 한다.

프로세스 분리. Chrome은 사이트별로 별도 프로세스(V8 인스턴스)를 실행한다. 쿠키 저장소는 별도의 네트워크 서비스 프로세스에 두고 IPC로 통신한다. 한 사이트가 다른 사이트의 쿠키를 볼 수 없게 격리하기 위해서다.

자동 첨부의 부작용

여기서 한 가지 문제를 짚어야 한다. 쿠키가 모든 요청에 자동으로 첨부된다는 특성은 편리한 동시에, 인증과 무관한 요청에도 쿠키가 실려 나가는 결과를 낳는다. 이로 인해 네 가지 현실적인 부작용이 발생한다.

① 네트워크 오버헤드

GET /images/logo.png HTTP/1.1
Cookie: sessionId=abc; theme=dark; lang=ko; _ga=GA1.2.xxx;
        _gid=xxx; _fbp=fb.1.xxx; ab_test=variant_b; ...

로고 이미지 한 장을 받기 위해 매번 수 KB의 쿠키 헤더가 함께 나간다. 페이지당 정적 자원이 수십~수백 개라면, 같은 쿠키가 그만큼 반복 전송된다. HTTP/2의 HPACK과 HTTP/3의 QPACK이 헤더 압축을 도와주지만, 모바일·저속 네트워크에서는 여전히 체감 가능한 비용이다.

② CDN 캐시 무효화

대부분의 CDN은 Cookie 헤더가 포함된 요청을 사용자별로 다를 수 있는 응답으로 간주한다. 결과적으로 캐시를 우회하거나, 사용자별로 따로 캐시한다.

GET /static/main.css
Cookie: sessionId=user-A-xxx   → CDN: "사용자별로 다를 수 있으니 캐시 못 함"

캐시 히트율이 떨어지면 오리진 서버 부하가 늘고, TTFB도 증가한다. 자동 첨부의 부작용 중에서 가장 크게 작용하는 부분이다.

③ 서버 파싱 비용

모든 요청에서 쿠키 문자열을 파싱하고, 세션 저장소(Redis 등)를 조회한다. 정작 인증이 필요 없는 엔드포인트에서도 마찬가지다.

④ 요청 헤더 크기 제한

대부분의 서버는 헤더 총량에 8KB 제한을 둔다. 쿠키가 누적되면 431 Request Header Fields Too Large 응답으로 사이트 전체가 동작하지 않게 되는 경우도 있다.

완화 전략

자동 첨부 자체를 막을 수는 없지만, 영향을 줄일 수 있는 전략은 여러 단계가 있다.

① Path 옵션으로 전송 범위 좁히기

가장 먼저 적용할 수 있는 단순한 방법이다.

Set-Cookie: sessionId=xxx; Path=/api; HttpOnly; Secure

/api 경로에만 전송되고, /static, /images로 가는 요청에는 첨부되지 않는다.

요청 경로쿠키 첨부
/api/userO
/api/postsO
/images/logo.pngX
/static/main.cssX

② 쿠키리스 도메인 분리

정적 자원만 별도 도메인/서브도메인에서 서빙하는 방법이다. 쿠키는 도메인 단위로 격리되므로, 정적 자원 도메인에는 쿠키 자체가 존재하지 않게 된다.

[메인]    https://example.com           → 쿠키 있음
[정적]    https://static.example.com    → 쿠키 없음
[CDN]     https://cdn.example.net       → 완전 분리된 도메인

Amazon, GitHub 같은 대형 서비스가 사용하는 패턴이다. GitHub는 github.com 메인 트래픽과 github.githubassets.com 정적 자원을 분리한다.

③ Domain 설정 최소화

Domain을 명시하지 않으면 쿠키를 설정한 도메인에만 전송된다.

Set-Cookie: sessionId=xxx; Domain=.example.com   → 모든 서브도메인에 전송
Set-Cookie: sessionId=xxx                        → 현재 도메인만

굳이 서브도메인 공유가 필요 없다면 Domain을 생략하는 편이 안전하고 효율적이다.

④ 쿠키 값 자체를 가볍게 유지

Set-Cookie: user={"id":42,"name":"홍길동","email":"..."}      // 비권장
Set-Cookie: sessionId=abc123                                   // 권장

식별자만 쿠키에 담고, 나머지는 서버 측 세션 저장소(Redis)에서 조회한다. 쿠키 본문이 작을수록 모든 요청의 비용이 줄어든다.

⑤ CDN의 쿠키 무시 설정

CloudFront, Cloudflare 등은 특정 경로의 쿠키를 무시하도록 설정할 수 있다.

[CDN 규칙]
경로: /static/*
→ Forward Cookies: None  (쿠키 무시하고 캐시 키 생성)

오리진까지 쿠키를 전달하지 않으니, 같은 자원에 대해 모든 사용자가 동일한 캐시를 공유한다.

어디까지 적용할지

규모에 따라 신경 쓸 강도가 다르다.

규모권장 적용 범위
개인 프로젝트 / 소규모 사이트Path=/api 정도만 적용해도 충분
트래픽이 있는 서비스+ 쿠키리스 도메인 분리 검토
대규모 / 글로벌 서비스+ CDN 캐시 정책 정교화, 도메인 전략 필수

마무리

쿠키의 생애주기와 내부 동작을 정리하면 다음과 같다.