Post

웹 인증 방식: Cookie, Session, JWT

쿠키/세션 방식과 한계, 그리고 JWT 구조 및 토큰 무효화 전략

웹 인증 방식: Cookie, Session, JWT

Session 인증

HTTP 프로토콜의 근본적인 특징인 무상태(Stateless)로 인해 HTTP는 각 요청을 이전 요청과 독립적인 트랜잭션으로 취급하여 상태를 기억하지 못한다. 이러한 문제를 해결하고 사용자를 식별하여 인증 상태를 지속하기 위해 쿠키(Cookie)와 세션(Session)을 사용한다.

Cookie와 Session

Cookie서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다.

  1. 사용자가 로그인을 성공하면, 서버는 응답 헤더의 Set-Cookie 필드에 인증 정보를 담아 브라우저로 전송한다.
  2. 브라우저는 이 Cookie 데이터를 저장한다.
  3. 이후 사용자가 보내는 같은 도메인에 대한 요청은 브라우저가 요청 헤더에 Cookie를 자동으로 포함해 서버에 전송한다.
  4. 서버는 요청에 포함된 Cookie를 확인하여 사용자를 식별하고 인증 상태를 유지한다.

Session

Session사용자의 정보를 서버 측에서 관리하는 방식이다. 중요 데이터는 서버에 저장하고, 클라이언트와는 고유 식별자 값만 주고받는다.

  1. 사용자가 로그인하면 서버는 사용자의 정보를 저장할 공간을 Session 저장소에 생성하고, 이 공간에 접근하기 위한 세션 ID(Session ID)를 발급한다.
  2. 서버는 이 Session ID를 Cookie에 담아 사용자 브라우저로 전송한다.
  3. 이후 사용자가 요청을 보낼 때 브라우저는 Session ID가 담긴 Cookie를 서버에 전송한다.
  4. 서버는 전달받은 Session ID를 이용해 자신의 Session 저장소에서 해당 사용자의 정보를 조회하여 요청을 처리한다.

Cookie와 Session의 한계

Cookie의 한계

서버는 Set-Cookie 헤더를 통해 브라우저에 데이터를 보내 저장시킬 수 있다. 브라우저는 이 Cookie를 보관해 동일한 도메인으로 요청을 보낼 때마다 Cookie 헤더에 담아 자동으로 전송한다. 이 방식 덕분에 서버는 요청마다 사용자를 식별할 수 있다.

하지만 Cookie는 몇 가지 본질적인 한계를 가진다. 우선 하나의 Cookie는 약 4KB로 크기가 제한되어 많은 양의 상태 정보를 담기을 수 없다. 또한 Cookie는 사용자 컴퓨터에 그대로 저장되므로 자바스크립트(document.cookie)를 통해 내용을 들여다보거나 수정하기 쉬워 보안에 취약하다. 만약 서버가 Cookie에 담긴 값을 그대로 사용한다면 해커가 권한을 임의로 상승시키는 등, 충분히 악용될 수 있다. 마지막으로 Secure 옵션이 없는 Cookie는 암호화되지 않은 평문 통신에서 탈취될 위험이 있다. 따라서 이러한 특성 때문에 Cookie는 데이터 저장소보다는 서버의 데이터를 찾아올 수 있는 단순 식별자로 사용해야 한다.

Session의 한계

Session은 Cookie의 단점을 보완하기 위해, 중요한 정보를 모두 서버 측 저장소에 보관하고 브라우저에는 그 정보에 접근할 수 있는, 예측 불가능한 Session ID만 Cookie 형태로 보낸다. Session 방식을 사용하면 브라우저에는 무의미한 식별자만 남으므로 Cookie가 조작되더라도 실제 데이터의 보안을 지킬 수 있다. 데이터 크기 제한 문제 역시 서버의 자원을 활용하므로 자연스럽게 해결된다.

그러나 Session은 서버의 자원을 소모하며, 서비스 규모가 커짐에 따라 여러 서버가 요청을 나눠 처리하는 스케일 아웃 환경에서는 새로운 문제가 생긴다. 사용자의 첫 요청을 처리한 서버 1에 Session이 생성되었더라도 다음 요청이 서버 2로 전달되면 해당 서버에는 Session 정보가 없어 인증이 풀리는 Session 불일치 문제가 발생한다.

Scale-Out과 Session 일관성

로드밸런서가 요청을 라운드로빈으로 배분하는 상황에서 로그인 직후 다음 요청이 다른 서버로 향하면, 해당 서버에 Session 상태가 없어서 다시 로그인하라는 메시지가 보이는 현상이 발생한다. 이를 해결하기 위한 전략은 크게 3가지가 있다.

스티키 세션(Sticky Session)은 한 사용자의 연결을 특정 서버에 고정해 문제를 우회한다. 구현이 단순하고 초기 단계에서 실용적이지만, 특정 서버로 부하가 집중되고 장애 시 Session이 유실된다. 세션 클러스터링(Session Clustering)은 서버 간에 Session을 복제하거나 동기화한다. 장애 내성이 향상되지만 동기화 오버헤드가 크고 서버 수에 비례해 성능 저하가 발생한다. 외부 세션 저장소(Session Storage)로서 Redis를 사용하면 모든 서버가 동일 저장소에서 Session을 조회하므로 일관성이 확보되고 확장성도 좋다. 반면 SPOF(Single Point of Failure)로 저장소 장애가 전체 Session에 영향을 미치며 운영과 비용 부담이 있다. 각 전략의 가용성, 일관성, 분할 내성 사이의 균형을 고려해 현재 상황에 적합한 분산 시스템을 적용한다. 일반적으로 초기 소규모 트래픽은 Sticky Session, 성장 단계에서는 Redis 같은 중앙 Session Storage, 초대규모 단계에서는 Redis Session Clustering이나 Session-less 인증 방식을 선택한다.


Session-less 인증

JWT

JWTJSON Web Token의 약자로, ‘상태가 없는(Stateless)’ 인증 방식이다. 이전 Session 방식과 달리 서버 부담이 주는, 구조적으로 유연한 기술로, jwt.io에서 JWT의 내부 구조를 확인할 수 있다. JWT의 구조를 이해하고, 서명의 중요성을 인지하며, 페이로드의 크기를 적절히 조절할 때 가장 효율적이고 안전하게 사용할 수 있다.

JWT 구조

JWT마침표(.)로 구분된 3개의 긴 문자열로 구성된 토큰이다. 헤더, 페이로드, 서명으로 구성되어 있으며, Base64로 인코딩되어 있다.

  • 헤더(Header): 토큰을 어떻게 해석하고 검증할지에 대한 정보가 담긴 부분이다. 예를 들어 서명 생성에 사용된 해시 알고리즘(alg)(HMAC SHA256 또는 RSA)과 토큰의 타입(typ) 등이 JSON 형태로 저장된다.
  • 페이로드(Payload): 토큰을 통해 실제로 전달하고자 하는 데이터가 담긴 부분이다. 사용자의 고유 ID, 닉네임, 권한 등급과 같은 정보(Claim)가 여기에 포함된다. 다만 페이로드는 암호화된 것이 아니라 단순히 인코딩된 것이므로 누구나 내용을 확인할 수 있기 때문에 민감한 정보는 담지 않아야 한다.
  • 서명(Signature): 토큰이 중간에 위변조되지 않았음을 증명하는 전자 서명이다. 헤더와 페이로드를 합친 후 서버만 아는 비밀 키(Secret Key)로 암호화하여 생성된다. 클라이언트가 토큰을 서버로 보내면 서버는 동일한 방식으로 서명을 다시 만들어보고 전달받은 서명과 일치하는지 검증한다. 만약 일치하지 않는다면 데이터가 중간에 변경되었다고 판단하고 요청을 거부한다. 이 서명을 통해 JWT의 무결성을 신뢰할 수 있다.

서명과 무결성

JWT는 무결성을 서명을 통해 보장한다. 만약 누군가 페이로드에 담긴 정보를 임의로 변경하면, 서명은 더 이상 유효하지 않게 된다. 예를 들어 일반 사용자 권한("role": "ROLE_USER")을 가진 토큰을 관리자 권한("role": "ROLE_ADMIN")으로 변경하는 경우를 생각할 수 있다.

jwt.io에서 페이로드의 내용을 직접 수정하면 인코딩된 JWT 문자열이 실시간으로 변경되는데, 서버가 가진 비밀 키(Secret Key)를 모르는 상태에서 페이로드를 수정하면, 기존의 서명은 유효성을 잃게 되고 “Invalid Signature“라는 메시지가 나타난다. 서버는 토큰을 받으면 자신이 가진 비밀 키로 서명을 다시 계산하여 전달받은 서명과 일치하는지 확인한다. 만약 두 서명이 일치하지 않으면 누군가 내용을 위조했다고 판단하여 해당 요청을 거부하는 것이다. 올바른 비밀 키를 사용해야만 서명이 유효해지며 토큰이 신뢰할 수 있는 출처에서 왔음이 증명된다.

페어로드 크기

페이로드는 다양한 정보를 담을 수 있는 유연한 공간이지만, 페이로드에 너무 많은 데이터를 넣으면 JWT 전체의 길이가 급격히 증가한다. 또한 길이가 길어진 토큰은 매 요청 시 HTTP 헤더에 포함되어 서버로 전송될 때 네트워크 트래픽에 부담을 줄 수 있다. 따라서 페이로드에는 필수적인 최소한의 정보만 담고, 상세한 정보는 별도 API 호출을 통해 조회하도록 설계해야 한다.


JWT 유출 방지

Access Token & Refresh Token

JWT를 인증에 사용할 때 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token) 두 토큰을 함께 사용해 토큰이 유출되더라도 피해를 줄이는 동시에 사용자의 편의성을 높인다.

  • Access Token: 사용자가 보호된(인증이 필요한) 자원에 접근(Access)할 때 사용하는 토큰이다. 사용자가 로그인하면 서버는 이 토큰을 발급하며, 사용자는 이후 요청마다 이 토큰을 헤더에 실어 보낸다. 일반적으로 보안을 위해 유효 기간을 짧게(30분, 1시간) 설정한다. 만약 이 토큰이 탈취되더라도 짧은 시간 안에 만료되므로 피해를 최소화할 수 있다.
  • Refresh Token: Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용하는 토큰이다. Refresh Token은 Access Token보다 긴 유효 기간(2주, 1달)을 가지며 보통 데이터베이스와 같이 안전한 곳에 저장된다. 사용자는 Access Token이 만료될 때마다 Refersh Token큰을 서버로 보내 새 Access Token을 받아와 매번 다시 인증하지 않아도 된다.

Access Token & Refresh Token 작동 방식

  1. 최초 로그인 및 토큰 발급
    1. 사용자가 아이디와 비밀번호로 서버에 로그인을 요청한다.
    2. 서버는 사용자 정보를 검증한 후, 인증이 성공하면 Access Token(유효기간이 짧음)과 Refresh Token(유효기간이 김)을 모두 생성해 두 토큰을 클라이언트에게 전달한다.
    3. 클라이언트는 Access Token은 메모리에, 보안이 중요한 Refresh Token은 HttpOnly Cookie나 안전한 내부 저장소에 각각 저장한다.
  2. API 요청과 인증 (Access Token 사용)
    1. 클라이언트는 보호된 자원에 접근하기 위해 API를 요청할 때, 요청 헤더의 Authorization 필드에 Access Token을 담아 보낸다.
    2. 서버는 전달받은 Access Token의 서명과 유효 기간을 검증한 후 요청을 처리하고 결과를 클라이언트에 응답한다.
  3. Access Token 만료와 자동 갱신
    1. Access Token의 유효 기간이 만료된 후 클라이언트가 만료된 토큰으로 다시 API를 요청한다.
    2. 서버는 토큰이 만료되었음을 확인하고 정상 응답 대신 401 Unauthorized 에러를 보낸다.
    3. 클라이언트는 401 에러를 받으면 저장해 두었던 Refresh Token토큰 재발급 API로 보낸다.
    4. 서버는 Refresh Token의 유효성을 검증한 뒤 새로운 Access TokenRefresh Token을 생성하여 클라이언트에 전달한다.
    5. 클라이언트는 새로 받은 토큰들로 기존 토큰을 교체하여 저장한 후 새로운 Access Token으로 이전에 실패했던 API 요청을 다시 시도하여 성공적으로 응답을 받는다.

JWT 무효화

JWT는 본질적인 구조로 인해 토큰 무효화(Invalidation)의 문제가 있다. JWT는 발급된 후 서버에 상태를 저장하지 않는 ‘무상태’를 전제로 한다. 이는 한번 발급된 토큰은 유효 기간이 만료될 때까지 계속 유효하다는 의미이다. 만약 사용자가 로그아웃을 하거나 관리자가 특정 사용자를 강제로 접속 해제시키고 싶어도 이미 발급된 Access Token을 서버에서 제거할 방법이 원칙적으로는 없다. 만약 토큰이 탈취된다면 유효 기간이 끝날 때까지 악용될 수 있는 심각한 보안 문제로 이어진다.

Access Token 무효화

Access Token은 유효 기간이 짧게 설정되어 있어 탈취되더라도 악용할 수 있는 시간이 제한적이다. 기본적으로는 토큰이 만료될 때까지 기다리는 것이 일반적이다.

Refresh Token 무효화

Refresh Token은 유효 기간이 길고 새로운 Access Token을 발급받을 수 있기 때문에 서버는 즉시 해당 Refresh Token을 무효화해야 한다. 사용자가 ‘모든 기기에서 로그아웃’ 버튼을 누르거나, 이상 활동이 감지되면 서버는 저장소에서 해당 Refresh Token을 삭제한다. 이렇게 되면 공격자가 Refresh Token을 가지고 있더라도 서버에 검증할 대상이 사라졌으므로 새로운 Access Token을 발급받을 수 없게 된다.

Access Token과 Refresh Token 모두 무효화

Refresh Token 같은 경우 저장소에 바로 삭제하는 방식으로 즉시 해당 토큰을 무효화한다. 아직 유효 기간이 남은 Access Token을 즉시 차단하기 위해 서버는 무효화된 토큰 목록(Denylist 또는 Blacklist)을 Redis와 같은 빠른 인메모리 저장소에 기록한다. 이는 서버는 요청이 들어올 때마다 토큰이 이 목록에 있는지 확인하여 유효성을 검증하는 방식으로, 결국 서버에 상태를 저장헤 JWT의 장점인 ‘무상태’을 일부 포기하는 절충안이다.

This post is licensed under CC BY 4.0 by the author.