cookie, session, token의 차이를 설명해 주세요
- 면접 출제
- ★
- 예상 시간
- 7분
cookie는 인증 방식이라기보다 browser의 저장 및 요청 전송 메커니즘입니다. session 방식에서는 server가 session storage에 로그인 상태를 저장하고, client는 session id를 cookie로 가지고 있다가 요청마다 보냅니다. token 방식에서는 server가 발급한 access token 같은 값을 client가 저장하고, 요청할 때 Authorization header 등에 담아 보냅니다.
session은 server에서 상태를 관리하기 쉬운 대신 저장소가 필요하고, token은 stateless하게 검증하기 쉬운 대신 탈취와 만료 관리, refresh 전략을 신경 써야 합니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
cookie는 왜 자동으로 요청에 포함되나요?
browser가 cookie의 domain, path, secure, sameSite 조건을 보고 해당 요청에 자동으로 실어 보낼 수 있기 때문입니다. 이 자동 전송 특성 때문에 편하지만 CSRF 위험도 함께 고려해야 합니다.
browser는 cookie를 저장할 때 어떤 도메인/경로가 이 cookie를 받을 자격이 있는지를 함께 기록해 둡니다. 이후 요청을 보낼 때마다 요청 대상 URL이 이 조건(domain, path)과 맞는지, 그리고 secure(HTTPS에서만), sameSite(같은 사이트 요청인지) 같은 제약을 통과하는지를 확인해서 통과하면 자동으로 Cookie header에 실어 보냅니다. localStorage처럼 JS가 직접 읽어서 붙이는 과정이 없습니다.
이 자동 전송이 왜 CSRF 위험으로 이어지나요?
공격자의 사이트에서 은행 사이트로 요청을 보내도 browser는 은행 도메인의 cookie를 조건에 맞으면 그대로 실어 보내기 때문에, 서버 입장에서는 요청이 사용자의 정상 행동인지 공격자가 유도한 것인지 cookie만으로는 구분할 수 없습니다.
SameSite=Strict나 Lax로 다른 사이트에서 오는 요청에는 cookie를 안 실어 보내게 제한하거나, CSRF token으로 요청이 실제 페이지에서 시작됐는지 검증하는 방식으로 막습니다.
HttpOnly cookie는 JavaScript로 읽을 수 없어 XSS로 token을 직접 훔치는 위험을 줄일 수 있습니다. 다만 이건 자동 전송 자체를 막는 게 아니라 document.cookie로 값을 읽어가지 못하게 하는 것이라 CSRF와는 별개의 방어입니다.
SameSite 속성의 Strict, Lax, None은 어떻게 다른가요?
Strict는 그 쿠키를 발급한 사이트에서 시작된 요청에만 쿠키를 실어 보내고, Lax는 다른 사이트에서 왔어도 페이지 이동(GET) 요청까지는 허용합니다. None은 크로스 사이트 요청에도 항상 실어 보내며, 반드시 Secure와 함께 써야 합니다.
Strict는 가장 엄격합니다. 이메일에 걸린 링크로 은행 사이트에 처음 들어가는 순간처럼, 다른 사이트에서 시작된 요청이면 그게 단순 페이지 이동이어도 쿠키가 붙지 않습니다. 그래서 그 첫 요청에서는 로그인이 풀린 것처럼 보일 수 있습니다.
Lax는 대부분 브라우저의 기본값입니다. 링크 클릭이나 주소창 입력처럼 사용자가 직접 페이지를 이동하는 GET 요청에는 다른 사이트에서 왔어도 쿠키를 실어 보내지만, 공격자 사이트가 몰래 자동 제출하는 폼(POST)이나 숨겨진 이미지 태그 요청에는 실어 보내지 않습니다. CSRF 공격은 보통 이런 자동 제출 형태라서, Lax만으로도 별도 CSRF token 없이 상당 부분 방어가 됩니다.
None은 크로스 사이트 iframe이나 제3자 결제 위젯처럼 의도적으로 크로스 도메인 쿠키가 필요한 경우에만 씁니다. 이때는 Secure(HTTPS 전용) 속성을 함께 지정해야 브라우저가 쿠키를 허용합니다.
session 방식은 확장성이 나쁜가요?
반드시 나쁘다고 할 수는 없습니다. 다만 server가 session 상태를 저장해야 하므로 여러 server instance에서 공유 session storage가 필요할 수 있습니다.
server instance가 하나뿐이면 문제가 없습니다. 하지만 트래픽이 늘어 로드밸런서 뒤에 여러 instance를 두면, 로그인 요청을 받은 instance와 이후 요청을 받는 instance가 다를 수 있습니다. session이 각 instance의 메모리에만 있다면 다른 instance는 이 session을 모르므로 로그인이 풀린 것처럼 보입니다.
JWT 기반 token 방식이 "확장성이 좋다"고 말할 때 비교 대상이 바로 이 지점입니다. server가 별도 저장소 없이 서명만 검증하면 되므로 이 문제 자체가 없다는 뜻입니다.
그럼 여러 instance에서 session을 어떻게 공유하나요?
sticky session으로 같은 사용자의 요청을 항상 같은 instance로 고정하거나, Redis 같은 중앙 저장소에 session을 두어 모든 instance가 같은 곳을 조회하게 합니다.
sticky session은 로드밸런서가 같은 사용자의 요청을 항상 같은 instance로 보내게 고정하는 방식입니다. 구현은 간단하지만 그 instance가 죽으면 session도 함께 사라지고, instance 간 트래픽이 불균등해질 수 있습니다.
중앙 저장소 방식은 Redis 같은 곳에 session을 두어, 어떤 instance가 요청을 받든 같은 저장소를 조회하므로 instance가 달라도 문제가 없습니다. 대신 매 요청마다 네트워크 호출이 하나 추가되고, 그 저장소 자체가 병목이나 단일 장애점이 되지 않도록 관리해야 합니다.
JWT는 server가 상태를 전혀 안 가져도 되나요?
access token 검증 자체는 stateless하게 할 수 있습니다. 하지만 refresh token 관리, logout, 강제 만료, 탈취 대응까지 생각하면 server-side 상태가 필요해질 수 있습니다.
access token 검증이 stateless한 이유는 서명 때문입니다. server가 비밀키로 서명해서 발급한 token은, 나중에 그 서명을 다시 계산해 비교하는 것만으로 위조 여부를 판단할 수 있습니다. DB를 조회할 필요가 없으니 이 부분만 보면 정말 상태가 필요 없습니다.
문제는 "이미 발급한 token을 지금 무효로 만들고 싶을 때" 생깁니다. 사용자가 로그아웃하거나, 비밀번호가 유출돼 강제로 세션을 끊어야 하는 상황에서, JWT는 만료 시간이 되기 전까지는 서명만으로 계속 유효하다고 판정됩니다. server가 "이 token은 이제 무효"라고 개입할 방법이 stateless 검증만으로는 없습니다.
그래서 실무에서는 access token의 수명을 짧게(수 분~수십 분) 두고, 수명이 긴 refresh token은 server가 DB에 저장해 관리하는 하이브리드 구조를 씁니다. refresh token으로 access token을 재발급할 때마다 DB를 확인해 무효화 여부를 반영할 수 있고, 탈취가 의심되면 해당 refresh token을 DB에서 지워 재발급을 막을 수 있습니다. 결국 "완전히 상태가 없는 인증"이 아니라, 상태를 확인하는 빈도를 access token 검증에서 refresh 시점으로 미룬 것에 가깝습니다.
token은 어디에 저장하는 게 안전한가요?
정답이 하나로 고정되지는 않습니다. localStorage는 JavaScript로 접근 가능해 XSS에 취약하고, cookie는 CSRF를 고려해야 합니다.
localStorage에 저장하면 같은 origin에서 실행되는 어떤 JS 코드든 localStorage.getItem으로 token을 읽을 수 있습니다. 공격자가 XSS로 악성 스크립트 한 줄을 페이지에 끼워 넣는 데 성공하면, 그 스크립트가 token을 읽어 외부로 그대로 전송할 수 있습니다. 애초에 읽지 못하게 막을 방법이 없다는 게 약점입니다.
HttpOnly cookie에 저장하면 JS가 아예 읽을 수 없어 이 경로가 막힙니다. 하지만 cookie는 조건만 맞으면 요청에 자동으로 실리기 때문에, 공격자의 페이지에서 유도한 요청에도 실려 보내지는 CSRF 문제가 남습니다. 이건 SameSite 속성이나 CSRF token으로 따로 막아야 합니다.
즉 XSS와 CSRF는 서로 다른 공격이고, localStorage와 cookie는 각각 한쪽에는 강하고 다른 쪽에는 약합니다. 그래서 서비스의 위협 모델에 따라 HttpOnly Secure SameSite cookie, 짧은 access token, refresh token rotation 같은 조합을 선택합니다. 특히 access token의 수명을 짧게 두면 설령 어느 한쪽 공격이 성공해도 탈취된 token이 쓸모 있는 시간 자체가 줄어듭니다.
SSR 환경에서 새로고침 시 로그인 상태가 깜빡이는 건 왜 그런가요?
localStorage나 클라이언트 상태(Zustand 등)는 브라우저에서만 접근 가능해서, 서버가 HTML을 만드는 시점에는 로그인 여부를 알 수 없기 때문입니다. 서버는 일단 "모른다"는 전제로 HTML을 만들고, 브라우저에서 JS가 로드된 뒤에야 로그인 상태를 확인하고 화면을 바꾸면서 깜빡임이 생깁니다.
"브라우저가 로그인 유효성을 알고 있으면 된다"는 답은 반쪽짜리입니다. 문제는 브라우저가 아는지 여부가 아니라, 첫 화면을 그리는 주체가 서버라는 데 있습니다. 브라우저 상태를 아무리 빨리 조회해도 그 조회는 JS가 로드된 이후에나 가능하고, 서버는 그 이전에 이미 "모른다"는 전제로 HTML을 만들어 보낸 뒤입니다. 그래서 조회 위치를 로컬 스토리지에서 메모리(리액트 상태)로 옮겨도 깜빡임은 줄지 않습니다. 오히려 메모리는 새로고침하면 사라지는 휘발성 문제로 되돌아가는 방향입니다. 진짜 해법은 "브라우저가 더 빨리 알기"가 아니라 "서버도 처음부터 알기"입니다.
그럼 어떻게 줄일 수 있나요?
로그인 시점에 서버가 Set-Cookie로 토큰을 내려주고, 이후 요청마다(새로고침 포함) 브라우저가 이 쿠키를 자동으로 실어 보내면, 서버가 쿠키를 읽어 로그인 상태가 반영된 HTML을 처음부터 만들 수 있습니다.
localStorage는 값이 있어도 HTTP 요청에 자동으로 실리지 않습니다. JS가 직접 읽어서 수동으로 헤더에 붙여야만 서버로 전달됩니다. 반면 쿠키는 브라우저가 해당 도메인으로 가는 모든 요청에 자동으로 붙여줍니다. 그래서 아직 JS가 실행되지 않은 새로고침 시점의 최초 요청에도 쿠키는 이미 실려 있고, SSR을 쓰는 서버 컴포넌트나 미들웨어는 이 쿠키를 읽어 인증 상태가 반영된 HTML을 처음부터 만들 수 있습니다.
정리하면 흐름은 이렇습니다. 로그인 시점에 서버가 인증을 확인하고 Set-Cookie 응답으로 토큰을 내려주면 브라우저가 저장해둡니다. 이후 어떤 요청을 보내든(새로고침 포함) 브라우저가 이 쿠키를 자동으로 실어 서버로 다시 보내고, 서버는 쿠키를 읽어 로그인 여부를 판단한 뒤 그 상태가 반영된 HTML을 내려줍니다. 이러면 클라이언트에서 다시 인증 여부를 조회해서 화면을 바꾸는 과정 없이 처음부터 개인화된 화면이 보입니다.
이 방법은 SSR에서만 되나요, CSR에서는 안 되나요?
순수 CSR(서버가 빈 껍데기 HTML만 주는 구조)에서는 서버가 애초에 개인화된 HTML을 만들 방법이 없어서 첫 화면부터 로그인 상태를 반영하는 것 자체가 불가능합니다. 다만 CSR에서도 깜빡임 자체는 로딩 상태로 막을 수 있습니다.
앞의 쿠키 기반 해법은 SSR을 전제로 합니다. 순수 CSR은 서버가 빈 껍데기 HTML만 주는 구조라, 쿠키를 읽어도 그걸 반영해 HTML을 다르게 만들어줄 서버 렌더링 단계 자체가 없습니다. 그래서 CSR에서는 이 해법을 그대로 쓸 수 없습니다.
다만 CSR에서도 깜빡임 자체는 막을 수 있습니다. 인증 여부가 확인되기 전까지는 로딩 상태로 감싸서 아무것도 단정하지 않다가, 확인이 끝난 뒤 한 번만 최종 상태로 렌더링하면 "틀린 화면 → 맞는 화면"으로 바뀌는 깜빡임 없이 "로딩 → 맞는 화면"이라는 자연스러운 지연만 남습니다. 문제를 없애는 게 아니라 사용자에게 보이는 방식을 바꾸는 접근입니다.
서버 컴포넌트에서 쿠키를 어떻게 읽나요?
next/headers의 cookies()로 현재 요청의 쿠키를 읽어 검증하고, 그 결과에 따라 다른 JSX를 반환합니다.
import { cookies } from "next/headers";
export default async function Page() {
const token = cookies().get("token")?.value;
const user = token ? await verifyToken(token) : null;
return user ? <div>{user.name}님 환영합니다</div> : <LoginButton />;
}요청이 들어와 서버 컴포넌트가 렌더링을 시작하면 cookies()로 토큰을 읽고 검증(JWT 디코드, DB 세션 조회 등)해서 로그인 여부와 사용자 정보를 얻고, 그 결과에 따라 다른 JSX를 반환합니다. 이게 그대로 HTML로 렌더링돼 나갑니다.
다만 서버 컴포넌트에서는 쿠키를 읽기만 할 수 있고, 쓰기(설정/삭제)는 Server Action이나 Route Handler, 미들웨어에서 해야 합니다.
미들웨어는 언제, 어떻게 쓰나요?
미들웨어는 요청이 실제 페이지에 도달하기 전에 실행되며, 로그인 여부에 따라 통과시키거나 리다이렉트합니다.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("token");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};미들웨어는 프로젝트 루트의 middleware.ts에 정의하고, 요청이 실제 페이지/라우트에 도달하기 전에 먼저 실행됩니다. 이 시점에도 쿠키는 이미 요청 헤더에 실려 있어 읽을 수 있습니다. 조건에 따라 통과(NextResponse.next()), 리다이렉트(NextResponse.redirect()), 다른 내용 표시(NextResponse.rewrite()) 중 하나를 선택하고, matcher로 적용 경로를 지정합니다.
미들웨어는 기본적으로 Edge Runtime에서 실행됩니다. 일반 Node.js보다 제약이 많은 가벼운 실행 환경이라, 무거운 DB 조회는 부담스럽고 보통 "토큰이 있냐 없냐"처럼 가벼운 판단만 여기서 하고, 실제 사용자 정보 조회 같은 무거운 작업은 서버 컴포넌트 쪽에 맡깁니다.
둘의 역할은 다릅니다. 미들웨어는 렌더링 자체를 막거나 우회시키는 문지기 역할로 "이 페이지에 들어올 수 있냐 없냐"를 가장 먼저 판단합니다. 서버 컴포넌트는 이미 들어오는 게 허용된 페이지 안에서 "이 사용자에게 뭘 보여줄지" 세부적으로 판단합니다. 실전에서는 둘을 같이 씁니다. 미들웨어가 비로그인 사용자의 접근 자체를 막고, 서버 컴포넌트는 로그인한 사용자의 개인화된 데이터를 화면에 반영하는 식으로 역할을 나눕니다.
부가 설명
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Laxserver가 session id를 cookie로 내려주면 browser는 조건에 맞는 요청에 cookie를 자동으로 포함할 수 있습니다.
Authorization: Bearer eyJhbGciOi...반면 token 방식은 client가 header에 직접 넣어 보내는 경우가 많습니다.
cookie와 token은 서로 배타적인 개념이 아닙니다. token을 cookie에 저장할 수도 있고, session id도 cookie에 저장할 수 있습니다. Cookie는 저장/전송 수단이고, session/token은 인증 상태를 표현하고 검증하는 방식입니다.
보안 관점에서는 XSS, CSRF, token 만료, refresh token 저장 위치 같은 질문으로 이어질 수 있습니다.
한 줄 정리
cookie는 저장/전송 수단이고, session과 token은 인증 상태를 유지하고 검증하는 방식에 가깝습니다.