안녕하세요, 자바파커입니다.
"쿠키는 클라이언트에 저장하고, 세션은 서버에 저장한다."
면접 준비할 때 다들 이 한 줄로 외우셨을 겁니다. 틀린 말은 아닌데, 이 한 줄로는 설명이 안 되는 장면이 있습니다.
서버를 재시작했더니 사용자가 전부 로그아웃됐습니다. 그런데 브라우저를 열어 보면 쿠키는 그대로 있습니다. 쿠키가 남아 있는데 왜 로그인이 풀렸을까요?
결론부터 말씀드리면, 쿠키는 손님이 들고 다니는 번호표이고, 세션은 서버가 들고 있는 장부입니다. 로그인 정보는 번호표가 아니라 장부에 있습니다. 장부가 비면, 번호표를 아무리 내밀어도 "누구세요?"가 됩니다.
말로만 하면 감이 안 와서, 제 PC에서 작은 서버와 크롬(Chromium 151)으로 직접 재 봤습니다. 이 장면은 영상으로도 정리했습니다.
쿠키 세션 차이 한눈에 보기
| 쿠키 | 세션 | |
|---|---|---|
| 어디에 있나 | 브라우저 | 서버(메모리, Redis, DB 등) |
| 무엇이 들어 있나 | 이름=값 한 쌍 (보통 세션 ID 하나) | 사용자 정보, 장바구니 등 실제 상태 |
| 언제 오가나 | 요청마다 자동으로 서버에 따라간다 | 오가지 않는다. 서버 안에 있다 |
| 크기 | 쿠키 하나에 약 4KB까지 (실측: 4,096바이트) | 서버 저장소 용량만큼 |
| 누가 볼 수 있나 | 브라우저 사용자 (HttpOnly 가 아니면 자바스크립트도) | 서버만 |
| 비유 | 손님이 들고 있는 진동벨 번호표 | 카운터의 주문 장부 |
둘은 서로 대체하는 관계가 아닙니다. 세션 방식은 쿠키를 써서 동작합니다. 쿠키에는 번호만 넣고, 그 번호로 서버의 장부를 찾습니다.
직접 재 봤습니다
실험 방법
Node 기본 http 모듈로 작은 서버를 띄우고, Playwright로 실제 크롬을 띄워 로그인부터 재시작까지 했습니다. 외부 네트워크 없이 127.0.0.1에서만 돌았고, 3회 모두 결과가 같았습니다.
// 세션 방식: 쿠키엔 번호만, 정보는 서버 메모리(장부)에
if (req.url === "/login-session") {
const sid = crypto.randomBytes(16).toString("hex")
sessions.set(sid, user) // 장부에 적기
res.setHeader("Set-Cookie", `sid=${sid}; Path=/; HttpOnly`)
}
// 쿠키에 다 담기: 사용자 정보를 통째로 쿠키에
if (req.url === "/login-cookie") {
res.setHeader(
"Set-Cookie",
`user=${Buffer.from(JSON.stringify(user)).toString("base64url")}; Path=/`
)
}
// 내 정보: 장부에 번호가 있으면 200, 없으면 401
if (req.url === "/me") {
res.statusCode = sessions.get(cookies.sid) ? 200 : 401
}사용자 정보는 { id, name, role, cart(3개), theme, lang } 6개 필드짜리 작은 객체입니다.
결과
| 측정 | 결과 |
|---|---|
세션 방식 쿠키 sid=… |
36바이트, HttpOnly |
사용자 정보를 쿠키에 담은 user=… |
167바이트, HttpOnly 아님 |
| 쿠키 하나(이름+값) 4,096바이트 | 저장됨 |
| 쿠키 하나(이름+값) 4,097바이트 | 오류 없이 버려짐 |
| 서버 재시작 뒤 브라우저 쿠키 | 그대로 있음 (같은 sid) |
서버 재시작 뒤 /me |
401 |
쿠키는 그대로인데 왜 401일까 — 장부가 비었다
이 실험의 서버는 세션을 메모리의 Map 에 저장했습니다. 프로세스가 재시작되면 메모리는 새로 시작하니, 장부가 빈 공책이 됩니다.
브라우저는 그런 사정을 모릅니다. 들고 있던 sid=03c8… 쿠키를 그대로 보냅니다. 서버는 장부에서 그 번호를 찾지 못하니 "누구세요?", 즉 401 Unauthorized 를 돌려줍니다.
같은 일이 서버를 여러 대로 늘렸을 때도 생깁니다. 로그인은 1번 서버 장부에 적혔는데, 다음 요청이 2번 서버로 가면 거기 장부엔 그 번호가 없습니다.
그래서 실무에서는 세션을 서버 밖의 공용 저장소(Redis 같은)에 둡니다. Node의 express-session 문서도 기본 메모리 저장소(MemoryStore)는 운영 환경용으로 만들어지지 않았다고 분명히 적어 두었습니다.
그럼 정보를 쿠키에 다 담으면 안 되나
장부를 없애고 정보를 쿠키에 다 담으면 재시작 문제는 사라집니다. 대신 세 가지가 따라옵니다.
- 매 요청마다 통째로 간다. 실측에서 작은 객체 하나에 167바이트였습니다. 이미지·CSS·API 요청 모두에 이 바이트가 붙습니다.
- 누구나 열어 볼 수 있다. base64는 암호가 아니라 포장입니다. 개발자 도구에서 그대로 풀립니다. HttpOnly 를 안 붙이면 페이지의 자바스크립트도 읽습니다.
- 4KB 벽이 있다. 크롬은 이름+값이 4,096바이트를 넘는 쿠키를 오류 없이 버렸습니다. 정보가 조금씩 늘다가 어느 날 로그인이 안 되는 이유가 됩니다.
실무에서 "쿠키에 담는" 방식은 보통 서명된 쿠키나 JWT 입니다. 서명이 있으면 위조는 막을 수 있지만, 암호화하지 않는 한 내용은 여전히 읽힙니다. 그래서 비밀번호나 개인정보는 넣지 않습니다.
쿠키에 꼭 붙이는 속성 세 가지
세션 ID를 담는 쿠키라면 이 셋은 기본으로 붙입니다.
- HttpOnly: 자바스크립트가 못 읽게 막습니다. XSS로 세션을 훔쳐 가는 것을 줄입니다.
- Secure: HTTPS 에서만 보냅니다.
- SameSite: 다른 사이트에서 온 요청에 쿠키를 붙일지 정합니다. CSRF를 줄입니다.
면접에서 쿠키 세션 차이를 답하는 법
한 줄 답변
"쿠키는 브라우저가 저장해 요청마다 자동으로 보내는 작은 데이터이고, 세션은 서버가 사용자 상태를 저장하는 방식입니다. 보통 쿠키에는 세션 ID만 넣고, 실제 정보는 서버의 세션 저장소에서 찾습니다."
꼬리 질문에 대비할 세 가지
- "서버를 재시작하면 왜 로그아웃되나요?" → 세션을 서버 메모리에 저장했기 때문입니다. 쿠키는 남아도 장부가 비어 그 ID를 모릅니다. Redis 같은 바깥 저장소로 해결합니다.
- "서버가 여러 대면요?" → 같은 문제입니다. 공용 세션 저장소를 쓰거나, 같은 사용자를 같은 서버로 보내는 스티키 세션을 씁니다.
- "JWT랑 세션은 뭐가 달라요?" → JWT는 정보를 토큰 자체에 담아 서버 저장소가 필요 없습니다. 대신 토큰이 커지고, 발급한 뒤에는 서버가 마음대로 무효화하기 어렵습니다.
자주 묻는 것
브라우저를 닫으면 쿠키도 사라지나요?
Expires나 Max-Age가 없는 쿠키는 브라우저를 닫으면 사라집니다(세션 쿠키). 둘 중 하나가 있으면 그 시간까지 남습니다.
쿠키 4KB는 모든 브라우저가 같나요?
RFC 6265는 브라우저가 쿠키 하나당 최소 4,096바이트를 지원하라고 권합니다. 이 글의 4,096/4,097 경계는 Chromium 151에서 직접 잰 값이고, 세부 규칙은 브라우저마다 다를 수 있습니다.
세션 ID 길이 36바이트는 표준인가요?
아닙니다. 이 실험에서 16바이트 난수를 hex로 쓴 결과입니다. 프레임워크마다 길이가 다르고, 서명이 붙으면 더 길어집니다. 중요한 건 길이가 아니라 추측할 수 없을 만큼 무작위인가입니다.
정리
- 쿠키 = 들고 다니는 번호표, 세션 = 서버의 장부. 세션 방식은 쿠키에 번호만 넣는다.
- 실측: 세션 쿠키 36바이트, 정보를 쿠키에 담으면 167바이트가 요청마다 따라감, 쿠키는 4,097바이트부터 버려짐.
- 서버 재시작 뒤 쿠키는 그대로인데 401: 메모리 장부가 비었기 때문. 공용 저장소(Redis 등)로 해결.
- 정보를 쿠키에 담을 땐 서명·크기·노출을 같이 생각한다.
HTTP 응답 코드를 이어서 보시려면 HTTP 상태 코드 정리(401 vs 403 포함)도 함께 읽어 보세요.
여러분 서비스는 세션을 어디에 저장하고 계신가요? 재시작했을 때 로그인이 풀렸던 경험이 있으면 댓글로 들려주세요.
