쿠키 세션 vs JWT: 직접 만드는 로그인에 무엇을 고를까
서버 세션과 JWT의 차이를 무효화 가능성 중심으로 비교하고, 저장 위치·CSRF·만료 설계까지 어떤 상황에 어느 쪽이 맞는지 정리했습니다.
로그인을 직접 만들 때 첫 번째 결정
인증을 직접 구현하기로 했다면 가장 먼저 정해야 하는 것이 로그인 상태를 어떻게 유지하느냐입니다. 선택지는 크게 둘입니다. 서버가 세션을 기억하고 브라우저에는 식별자만 주는 방식, 그리고 서버가 서명한 토큰 자체에 정보를 담아 보내는 방식입니다.
인터넷에는 JWT를 기본값처럼 소개하는 자료가 많습니다. 하지만 일반적인 웹 애플리케이션이라면 서버 세션이 더 단순하고 안전한 경우가 많습니다. 왜 그런지 정리합니다.
서버 세션: 상태를 서버가 들고 있다
로그인에 성공하면 서버가 세션 레코드를 만들고, 브라우저에는 그 레코드를 가리키는 무작위 값만 쿠키로 보냅니다. 쿠키 안에는 아무 의미도 없습니다.
요청마다 서버는 쿠키의 값으로 세션을 조회해 누구인지 확인합니다. 저장 위치는 데이터베이스 테이블이든 Redis든 상관없습니다.
// 세션 토큰은 예측 불가능해야 하고, 저장은 해시로
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
await db.insert(sessions).values({ userId, tokenHash, expiresAt });
res.cookie("session", token, {
httpOnly: true, // JS 에서 읽을 수 없음
secure: true, // HTTPS 에서만 전송
sameSite: "lax", // 외부 사이트발 요청에는 실리지 않음
maxAge: 1000 * 60 * 60 * 24 * 14,
});
토큰 원본이 아니라 해시를 저장하는 이유는, 데이터베이스가 유출됐을 때 그 값으로 바로 로그인되지 않게 하기 위함입니다. 비밀번호를 해시로 저장하는 것과 같은 이유입니다.
장점은 통제권입니다. 강제 로그아웃이 행 하나 삭제로 끝납니다. 비밀번호를 바꾸면 그 사용자의 모든 세션을 즉시 무효화할 수 있고, 사용자에게 "다른 기기에서 로그아웃" 기능을 주기도 쉽습니다.
비용은 요청마다 조회가 필요하다는 점입니다. 다만 인덱스가 걸린 단일 행 조회라 대체로 무시할 수 있는 수준입니다.
JWT: 토큰 자체가 정보를 담는다
JWT는 사용자 정보와 만료 시각을 담고 서버가 서명한 문자열입니다. 서버는 서명만 검증하면 되므로 저장소를 조회하지 않습니다.
여기서 흔히 하는 오해가 있습니다. JWT는 암호화가 아니라 서명입니다. 페이로드는 누구나 디코딩해 읽을 수 있습니다. 그래서 민감한 정보를 담아서는 안 됩니다.
검증할 때 반드시 확인해야 하는 것들이 있습니다.
- 서명 검증을 실제로 수행하는가 (디코딩만 하고 넘어가는 실수가 흔합니다)
- 서버가 기대하는 알고리즘으로 고정했는가 (토큰이 지정한 알고리즘을 따라가면 안 됩니다)
exp만료를 확인하는가- 발급자·대상(
iss,aud)이 우리 것인가
결정적 차이는 무효화다
JWT의 성질은 "서버에 묻지 않아도 검증된다"는 것입니다. 그런데 이 장점이 그대로 단점이 됩니다. 서버에 묻지 않으니, 서버가 취소할 수도 없습니다.
발급한 토큰은 만료 시각까지 유효합니다. 계정을 정지시켜도, 비밀번호를 바꿔도, 토큰이 유출된 것을 알아도 그 토큰은 계속 통과합니다.
그래서 실무에서는 보완책을 씁니다. 액세스 토큰의 수명을 짧게(수 분~수십 분) 잡고, 별도의 리프레시 토큰으로 갱신하며, 리프레시 토큰은 서버에 저장해 취소 가능하게 만듭니다. 여기에 즉시 차단이 필요하면 폐기 목록까지 둡니다.
이 지점에서 한 번 짚어볼 만합니다. 결국 서버에 상태를 두게 됐다면, 처음부터 세션을 쓰는 것과 무엇이 다른가요? 구성은 더 복잡해졌고 통제력은 더 약합니다.
저장 위치: localStorage에 두지 않는다
JWT를 쓰기로 했더라도 브라우저 저장소에 넣는 선택은 다시 생각해봐야 합니다. localStorage는 JavaScript가 읽을 수 있으므로, XSS가 한 번 성공하면 토큰이 그대로 유출됩니다.
HttpOnly 쿠키는 JavaScript가 읽을 수 없습니다. 스크립트가 주입되더라도 토큰 값을 빼낼 수는 없습니다. 그래서 세션이든 JWT든 브라우저 클라이언트라면 HttpOnly 쿠키에 담는 것이 기본입니다.
단, 쿠키를 쓰면 CSRF를 함께 고려해야 합니다. 브라우저가 쿠키를 자동으로 실어 보내기 때문에, 외부 사이트가 유도한 요청에도 인증이 붙을 수 있습니다. SameSite=Lax가 상당 부분을 막아주지만, 상태를 바꾸는 요청은 반드시 GET이 아닌 메서드로 두고 필요한 경우 CSRF 토큰을 함께 검증해야 합니다.
선택 기준
서버 세션이 맞는 경우
- 브라우저에서 쓰는 일반적인 웹 애플리케이션
- 강제 로그아웃, 기기 목록, 계정 정지가 필요한 서비스
- 프론트와 백엔드가 같은 사이트에 있는 구조
JWT가 맞는 경우
- 서로 독립적인 여러 서비스가 같은 인증을 공유해야 하는 경우
- 쿠키를 쓸 수 없는 클라이언트(모바일 앱, CLI, 서버 간 호출)
- 제3자에게 제한된 권한을 위임해야 하는 경우
둘을 섞는 경우
- 웹은 세션 쿠키로, 공개 API는 별도 토큰으로 나누는 구성. 실제로 흔하고 합리적인 형태입니다.
JWT가 확장성에 더 좋다는 말은 맞나요?
"세션 조회가 부담이 된다"는 전제 자체가 대부분의 프로젝트에는 해당하지 않습니다. 인덱스가 걸린 단건 조회이고, 캐시를 두면 더 줄어듭니다. 실제로 문제가 되는 규모라면 그때는 이미 조회 최적화보다 다른 병목이 먼저 나타납니다. 인증 방식을 확장성 때문에 고르는 것은 대개 이른 최적화입니다.
세션 만료는 어떻게 잡는 게 좋나요?
절대 만료와 유휴 만료를 함께 두는 방식이 일반적입니다. 예를 들어 마지막 활동에서 2주가 지나면 만료되고, 발급 시점에서 90일이 지나면 활동과 무관하게 만료되는 식입니다. 유휴 만료만 두면 계속 쓰는 세션이 무한히 살아남고, 절대 만료만 두면 활발한 사용자도 주기적으로 끊깁니다.
소셜 로그인만 쓰면 이 고민이 없어지나요?
인증은 위임되지만 세션 유지는 여전히 우리 몫입니다. 소셜 제공자에게 사용자를 확인받은 뒤, 그 결과로 우리 서비스의 로그인 상태를 만들어야 하기 때문입니다. 즉 위 선택은 그대로 남습니다. 비밀번호 저장과 재설정 흐름을 직접 만들지 않아도 되는 것이 소셜 로그인의 이점입니다.
어느 쪽이든 확인은 필요하다
인증은 정상 경로가 아니라 예외 경로에서 뚫립니다. 만료된 세션, 조작된 토큰, 비밀번호 변경 직후의 기존 세션 같은 것들을 직접 시험해봐야 확인됩니다.
만든 서비스에 로그인이 붙어 있다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 여러 사람이 실제로 가입하고 로그인하는 과정에서, 혼자 테스트할 때는 지나쳤던 경로가 드러나는 경우가 많습니다.
