본문 바로가기

JavaScript

로그인 상태 유지에 대한 모든 것 : 세션과 JWT 차이 및 장단점🤔

출처: https://www.freepik.com/free-vector/website-user-login-page-template-design_13197748.htm

들어가며

브라우저에서 사용자의 로그인 상태를 유지하는 방법에는 크게 두 가지가 있습니다. 세션(Session)과 JWT(Json Web Token)입니다. 이 두 방식의 차이점은 무엇일까요? 그리고 SSR(서버 사이드 렌더링)CSR(클라이언트 사이드 렌더링) 환경에서 각각 어떤 방식이 적합할까요?

또한, 발행된 토큰을 브라우저에 저장할 때 어떤 제약이 있는지도 살펴보겠습니다. 쿠키Web Storage는 어떤 차이가 있으며, 각각의 보안 문제는 무엇인지 알아보겠습니다. 이 글은 DFS(개념을 하나씩 깊이 파고들어가는)방식으로 진행될 예정입니다.

 

Stateless HTTP

로그인 상태를 유지하는 기술이 왜 필요한지 이해하려면, 먼저 HTTP가 어떤 특성을 가지는지 살펴볼 필요가 있습니다. HTTP는 무상태(Stateless) 통신입니다. 무상태라고 직역하니 굉장히 어색한데, 이 뜻은 클라이언트와 서버 사이에서 오고 간 정보의 상태가 유지되지 않는다는 것을 뜻합니다. 즉, 서버는 각 요청이 독립적이므로, 클라이언트의 이전 요청에 대한 정보를 기억하지 않습니다.

 

예를 들어, 사용자가 로그인할 때 아이디와 비밀번호를 서버로 전송하면, 서버는 이 정보를 확인하고 맞으면 토큰을 발급합니다. 하지만, 서버는 이후 요청에서 사용자가 이미 로그인한 상태인지 기억하지 못합니다. 이는 HTTP가 상태를 유지하지 않는 통신 방식이기 때문입니다. 여기서 바로 문제가 발생합니다. 응답받은 토큰을 따로 저장해두지 않는다면, 다음 HTTP 통신에서 유저가 로그인된 상태인지 아닌지를 서버는 알 수가 없습니다. 로그인 통신의 결과에 대한 상태가 유지되지 않기 때문입니다.

 

로그인 유지- 사용자 식별 토큰

그래서 이 문제를 해결하려면, 로그인 상태에 대한 정보를 따로 저장해야 합니다. 로그인 통신에 대한 결과로 서버는 사용자를 식별할 수 있는 값을 보내주어야 합니다. 이를 통해 사용자는 아이디와 비밀번호를 다시 입력할 필요 없이 서버에 접근할 수 있게 됩니다. 그런데 이 때 어떤 값이 로그인 통신에 대한 식별값으로 오게될까요? 서버가 보내는 식별자는 대개 sessionIDJWT라는 두 가지 방식으로 제공될 수 있습니다. 각 방식을 하나씩 살펴보겠습니다

 

sessionID

먼저, 세션에 대해 알아보겠습니다. 세션은 클라이언트와 서버가 통신하는 동안 사용자의 상태를 유지하는 방법입니다. 로그인 성공 시, 서버는 사용자를 식별하는 sessionID를 발급합니다. 이 sessionID는 서버에서 사용자의 상태를 유지하는 데 사용되며, 이를 통해 브라우저는 서버와의 매 요청마다 해당 사용자임을 증명할 수 있게 됩니다.

 

세션을 쉽게 설명하자면 찜질방의 락커 키에 비유할 수 있습니다. 찜질방에 입장할 때 받은 락커 키로 여러 서비스를 이용할 수 있는 것처럼, sessionID는 사용자 요청을 식별하는 역할을 합니다. 찜질방처럼 "이제부터 브라우저에서 이 sessionID로 나에게 오는 요청은 다 너라고 생각할게!" 가 되는 것이죠.

 

파이썬으로 작성된 간단한 코드 예시를 한 번 보겠습니다.

@users_bp.route('/users/login', methods=['POST'])
def login():
    # ...
    try:
        # 유저가 입력한 아이디와 비밀번호가 유효한지 확인
        user_id, password = payload['user_id'], payload['password']
        verified = verify_user(user_id, password)
        if not verified:
            return render_template('./pages/login.html', error="아이디나 비밀번호가 일치하지 않습니다.", form_data=payload)
        # 유저가 맞다면 세션 발급
        session['user_id'] = user_id
        return redirect('/')
    except Exception as e:
        # ...

def verify_user(user_id, password):
    user = current_app.db.users.find_one({'user_id': user_id})
    if user:
        if bcrypt.checkpw(password.encode('utf-8'), user['password']):
            return True
    return False

 

코드를 보면, 사용자가 로그인에 성공하면 서버에서 세션이 발급됩니다. 이 세션은 일시적으로 유지되며 만료 시간이 지나면 자동으로 삭제됩니다. 그런데 여기서 또 궁금증이 하나 생깁니다. 분명 HTTP 요청은 Stateless해서 상태가 유지되기 어렵다고 했는데,

어떻게 서버에서 발급된 session이 브라우저에서 유지될 수 있을까요?

 

세션 유지 – 쿠키의 역할

그 비밀은 바로 쿠키(Cookie)에 있습니다. 세션ID는 쿠키를 통해 브라우저에 저장됩니다.

 

쿠키는 브라우저와 서버 간의 정보를 저장하고 주고받는 작은 데이터입니다.

 

서버에서 응답으로 쿠키를 설정하면, 이후 브라우저는 해당 쿠키를 만료시간까지 매 요청마다 해당 쿠키 정보를 서버로 넘겨줍니다. 서버는 이렇게 쿠키에 저장된 sessionID를 확인해 사용자가 누구인지 식별할 수 있게 됩니다.

 

그리고 쿠키에는 다양한 보안 속성이 있습니다. 예를 들어, Secure 속성을 사용하면 HTTPS에서만 쿠키가 전송되고, HTTPOnly 속성을 사용하면 자바스크립트로 쿠키에 접근할 수 없습니다. 이러한 속성들은 보안을 강화하는 데 도움을 줍니다. 개발자 도구의 Application에서 현재 블로그 도메인에 저장된 쿠키의 정보를 확인해보겠습니다.

 

 

 

위의 이미지는 제 티스토리에서 저장되는 쿠키를 확인해본 정보입니다. 이미지를 보면 다양한 속성이 존재하는데, 위에서 소개한 Secure와 HTTPOnly 속성이 체크되어 있는 것을 확인할 수 있습니다. SameSite는 같은 도메인에서만 쿠키를 전송하게 할 수 있는 옵션입니다. 쿠키는 이런 속성들덕에 클라이언트 쪽에서 값을 조작하기가 어렵기에, localStorage와 같은 웹 스토리지에 비해 안전합니다. 지금까지의 과정을 이미지로 정리하자면 아래와 같습니다.

 

 

JWT(Jason Web Token)

자, 그럼 이제 사용자 로그인을 유지시키는 두 번째 방법인 JWT에 대해 알아보겠습니다. JWT란 Jason Web Token의 약자로, JSON 형식으로 데이터(토큰)을 안전하게 전달하기 위한 웹 표준입니다. 주로 사용자 인증 권한 부여에서 사용되며, 서버와 클라이언트 간에 데이터를 안전하게 교환하기 위한 방식입니다. 어떻게 가능한걸까요? 일단, jwt가 어떻게 만들어지는지 먼저 살펴보겠습니다.

 

JWT의 구조

1. Header (헤더): JWT의 타입과 사용되는 해싱 알고리즘을 정의합니다. 암호화에 사용되는 해싱 알고리즘에 대한 설명과 코드 예시는 지난번 작성한 글인 이 곳에 자세히 정리되어 있습니다.

{
   "alg": "HS256",  // 해싱 알고리즘 (HMAC SHA-256)
   "typ": "JWT"     // 토큰 타입 (JWT)
}

 

2. Payload (페이로드): 페이로드에는 만료 시간, 사용자 정보, 기타 관련 데이터 등을 포함합니다.

 {
   "sub": "1234567890",
   "exp": "192039209", // unix 시간
   "name": "Hamtori Kim",
   "admin": false
 }

 

3. Signature (서명): 위에서 만들어진 헤더, 페이로드, 그리고 비밀 키(salt key)를 함께 사용하여 생성됩니다. 토큰이 변경되지 않았는지 확인하는 데 사용되며, 클라이언트 측에서 데이터를 변조하는 것을 방지합니다.

HMACSHA256(
   base64UrlEncode(header) + "." + base64UrlEncode(payload),
   secret
)

 

이제 JWT가 어떻게 이루어져 있는지 알게 되었습니다. JWT를 통해 만료 시간도 알 수 있고, 이름, 유저의 관리자 여부 대한 인가 정보도 확인할 수 있습니다. 만약 여기에 더 많은 데이터를 포함시킨다면, 더 많은 정보를 알 수 있게 되겠죠. 그렇기에 JWT에는 사용자의 민감한 정보를 담아서는 안됩니다. JWT 자체는 암호화되지 않기에 누구나 토큰만 있다면 정보를 확인할 수 있기 때문입니다. 토큰의 서명 부분의 암호화는 데이터가 변조되지 않았음은 확인할 수 있는 용도이지, JWT 자체는 암호화되지 않습니다. JWT는 이렇게 자체적으로 필요한 정보를 모두 포함하고 있어 서버에서 별도의 세션DB를 유지할 필요가 없습니다.

 

그럼 이 토큰은 어디에 저장하는게 좋을까요?

 

브라우저에 데이터를 저장 가능한 공간으로는 쿠키(cookie)외에 웹스토리지(webStorage)가 있습니다. localStoragesessionStorage가 바로 웹스토리지입니다. 아래에 localStorage, sessionStorage, 그리고 쿠키의 차이점을 표로 정리해보겠습니다.

 

항목 localStorage sessionStorage cookie
저장 위치 브라우저 브라우저 브라우저
데이터 만료 시점 영구적 (삭제되지 않는 한 저장됨) 브라우저 탭 종료 시 삭제 만료 기간 설정 가능 (만료 시 삭제됨)
데이터 용량 제한 약 5~10MB (브라우저마다 상이) 약 5~10MB (브라우저마다 상이) 약 4KB
서버와의 통신 서버와 자동으로 전송되지 않음 서버와 자동으로 전송되지 않음 매 HTTP 요청 시 서버로 자동 전송
데이터 접근 범위 동일한 도메인 내 모든 탭, 창에서 접근 가능 세션 내(탭 또는 창)에서만 접근 가능 도메인과 경로에 따라 설정 가능
보안 HTTPS 사용 시 암호화되지 않음 HTTPS 사용 시 암호화되지 않음 SecureHttpOnly 속성으로 보안 강화
사용 목적 장기적인 데이터 저장 (사용자 설정, 캐시 등) 페이지 세션 동안 임시 데이터 저장 사용자 인증, 세션 ID, 트래킹 정보 저장

 

위의 표를 보면 localStorage와 sessionStorage에 저장된 데이터는 서버에 자동으로 전송되지 않음을 알 수 있습니다. 그렇다면 편하게 쿠키에 JWT를 저장하면 되는걸까요? 결론은 어렵습니다. 이유는 무엇일까요?

 

쿠키의 보안 속성 때문입니다. HTTPOnly과 같은 속성으로 JS의 조작을 막고 있기 때문에 클라이언트에서 접근하기 어렵습니다. JWT는 클라이언트 측에서 매 HTTP 요청마다 헤더에 토큰을 함께 보내주어야 하기에, 클라이언트에서 접근이 가능해야 합니다. 그래서 JWT는 클라이언트에서 접근하기 용이한 webStorage에 저장하게 됩니다. localStorage는 탭을 닫아도 데이터가 유지되는 반면, sessionStorage는 탭을 닫으면 데이터가 삭제되기에 재로그인이 필요해집니다. 서비스의 상황에 맞는 스토리지를 선택하면 되겠죠?

 

로그인 성공 시, localStorage에 서버로부터 받은 토큰을 저장하는 코드입니다.

async function login(username, password) {
    try {
        const response = await fetch('https://example.com/api/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ username, password })
        });
        if (response.ok) {
            const data = await response.json();
            // accessToken을 localStorage에 저장
            localStorage.setItem('accessToken', data.accessToken);
        }
        // ... 
    } catch (error) {
        // ...
    }
}

 

그런데 여기서 또 문제가 발생합니다. 클라이언트에서 JS로 접근가능하다는 뜻은, 개발자 도구를 통해서도 누구나 해당 토큰에 접근이 가능하다는 뜻이 됩니다. 이로 인해 발생할 수 있는 대표적인 보안 문제는 아래와 같습니다.

 

[1] XSS(크로스 사이트 스크립팅) 공격:

- 악성 스크립트를 삽입하는 공격입니다. 해당 스크립트가 localStorage에 저장된 토큰에 접근해 탈취할 수 있습니다. 이렇게 탈취된 토큰은 해당 사용자를 사칭하여 서버에 요청을 보내거나 권한을 악용할 수 있습니다.

 

[2] CSRF(크로스 사이트 요청 위조) 공격:

- 사용자의 권한으로 특정 요청을 서버에 보내는 공격입니다. 해당 사용자의 권한을 이용하여 특정 권한의 요청을 보낼 수 있습니다.

 

그렇다면 어떻게 이 문제를 해결할 수 있을까요? 토큰을 쿠키에 저장하자니 클라이언트에서 접근이 불가하고, 그렇다고 웹스토리지에 저장하자니 탈취의 위험이 커집니다. 서버에서 발급한 sessionID같은 경우, 탈취가 된다면 언제든지 서버에서 해당 sessionID를 무효화시켜버릴 수 있지만, JWT는 생성시 설정한 만료 기간까지는 유효하며, 중간에 무효화시킬 수 없습니다. 바로 이 지점에서 AccessToken과 RefreshToken이 나뉘어질 필요가 생겨났습니다.

 

AccessToken은 사용자의 인증 상태를 유지하는 데 사용되며, 짧은 유효 기간을 가집니다. 매 API 요청마다 헤더를 통해 서버로 토큰을 보내야하는데, 바로 이 토큰을 사용자 식별용으로 사용합니다. 짧은 유효 기간을 가진다면 탈취가 된다고 하더라도 피해가 다소 제한적이겠죠? 그리고 RefreshToken을 만들어 AccessToken이 만료되었을 때 새로운 AccessToken을 발급받도록 합니다. 그리고 이 RefreshToken을 HTTPOnly 쿠키에 저장함으로써, 자바스크립트가 해당 토큰에 접근하지 못하게 합니다. 이렇게 AccessToken이 탈취되더라도 유효 기간이 짧아 피해를 최소화 시킬 수 있고, RefreshToken은 안전한 쿠키 저장 방식을 통해서 더 장기적인 로그인 유지가 가능하게 됩니다.

 

코드를 통해 살펴보겠습니다.

async function fetchApi() {
    // 로컬스토리지에서 accessToken 접근
    let accessToken = localStorage.getItem('accessToken');
    // 특정 api 요청
    const response = await fetch('https://example.com/api/protected', {
        method: 'GET',
        headers: {
            'Authorization': `Bearer ${accessToken}`, // Authorization에 추가
            'Content-Type': 'application/json'
        }
    });

    // AccessToken이 만료된 경우
    if (response.status === 401) {
        // AccessToken 갱신을 위해 refreshAccessToken 함수 호출
        const refreshed = await refreshAccessToken();

        if (refreshed) {
            // 다시 API 요청 시도
            return fetchProtectedResource();
        } else {
            throw new Error('인증 실패');
        }
    }
    return response.json();
}

async function refreshAccessToken() {
    try {
        const response = await fetch('https://example.com/api/refresh-token', {
            method: 'POST',
            credentials: 'include', // HTTPOnly 쿠키 전송
        });

        if (response.ok) {
            const data = await response.json();
            // 새로 발급받은 accessToken을 localStorage에 저장
            localStorage.setItem('accessToken', data.accessToken);
            return true;
        } else {
            // RefreshToken이 만료되거나 유효하지 않음
            console.warn('Failed to refresh access token');
            logout();
            return false;
        }
    } catch (error) {
        // ...
    }
}

 

API 호출 중에 accessToken이 만료된다면 refreshAccessToken 함수를 호출합니다. 그리고 이 요청으로 서버는 cookie에 저장된 refreshToken의 정보를 확인한 후, 유효한 refreshToken이라면 새로 발급한 accessToken을 응답으로 돌려줍니다. cookie는 따로 작업을 해주지 않아도 서버에서 들어온 요청으로 확인할 수 있기에, 클라이언트에서 따로 추가적으로 작업해줄 것은 없습니다. 애초에 HTTPOnly, Secure 속성때문에 접근도 불가하구요!

 

하지만 만약 프론트와 백의 도메인이 다르다면, credentials: 'include' 설정이 필요합니다. 이 속성은 HTTPOnly 쿠키를 서버로 전송하기 위함인데, 다른 도메인 간 요청시 해당 속성을 추가하지 않으면 쿠키가 서버로 전송되지 않고, CORS 이슈가 발생합니다.

 

이렇게 지금까지 JWT를 활용하여 로그인 유지를 하는 방법까지 알아보았습니다. 모든게 100% 완벽한 보안은 없습니다. 이 방법도 악의적 해커를 좀 더 귀찮게 만드는 것일 뿐, accessToken에 대한 탈취 위험이 줄어들긴 했지만 여전히 존재하기 때문입니다. 빅테크 회사들은 무언가 다른 방법이 있을까 현직자 분에게 물어보기도 하고, 의견을 구해보았는데 이와 같은 방식으로 로그인 유지를 한다고 답변을 받았습니다. access와 refresh를 나누는 방식으로 진행하되 access에 너무 많은 정보를 담지 않도록 주의해야할 것 같습니다.

 

Session vs JWT

자 이제 우리는 로그인을 유지킬 수 있는 대표적인 두 가지 방법에 대해 알아보았습니다. 그렇다면 두 개 중 어떤 로그인 방식을 사용해야할까요? 이는 내가 내가 어떤 서비스를 개발중인가에 따라 다를 수 있습니다. 각각의 장단점을 표로 비교해보겠습니다.

 

항목 서버 세션 방식 JWT 방식
상태 유지 방식 서버에 세션 저장. 서버에서 상태를 유지함. 클라이언트가 JWT를 저장하고 관리함.
저장 위치 서버 메모리 또는 DB에 저장 클라이언트 측 localStorage, sessionStorage 또는 쿠키에 저장
확장성(Scalability) 확장성 낮음: 세션 상태를 서버에 저장하므로 부하 발생 확장성 높음: 상태가 클라이언트에 저장되어 서버에 부하 없음
서버 부하 사용자 수가 많을수록 서버 부하 증가 서버는 상태를 저장하지 않으므로 부하 적음
보안 세션ID는 서버에서 관리, 세션 탈취 위험 있음 토큰 자체에 정보 포함. 탈취 시 위험, 하지만 서명으로 변조 방지 가능
만료 및 갱신 서버가 만료 시간 및 갱신을 직접 관리함 토큰에 만료 시간 내장. 클라이언트가 관리, refreshToken 필요
사용자의 로그아웃 서버에서 세션을 제거하여 즉시 로그아웃 가능 JWT는 클라이언트에서 삭제해야 함. 로그아웃 처리 복잡
무결성 검증 서버에서 세션을 관리하므로 직접적으로 검증 가능 JWT에 서명이 있어 무결성 검증 가능
전송 데이터 크기 세션ID만 클라이언트에 전송, 데이터 크기 작음 JWT는 페이로드 포함, 상대적으로 크기가 큼
CORS 환경 쿠키 설정 및 credentials 옵션 필요 자체적으로 CORS와 관련된 문제 없음, JWT 자체 전송 가능
적용 복잡성 비교적 간단, 서버에서 관리 클라이언트에서 관리하므로 적용 복잡도 높음

 

세션 방식이 서버 부하가 많다는 것은 서버가 토큰의 관리 주체가 되기 때문입니다. 세션DB에 모든 사용자들의 sessionID를 저장해야하기 때문에, 서버가 다운되어버리면 정보가 다 날라가버리기도 하죠. 이렇게 각각의 서버에 모든 정보를 저장하고, 접속 클라이언트(웹, 모바일)에 따른 제한도 있기에 확장성까지 낮아지게 됩니다.

 

반면, jwt는 클라이언트에 저장되기에 서버에 부하가 없고 사용자가 많아질수록 유리합니다. 웹, 모바일 등 다양한 클라이언트에 따른 확장성도 높구요. 그러나 위에서 함께 살펴보았듯이, jwt는 만료 시간 전에 토큰을 무효화시킬 수 없기에 세션 방식보다 보안 수준이 떨어지게 됩니다. 내가 개발하는 서비스의 유저 규모는 어느정도 되는지, 어떤 방식이 적합할지 생각해보고 최선의 선택을 하면 좋을 것 같습니다.

 

그러나 이외에도 로그인 유지 방식 결정 시, 고려해볼 수 있는게 존재합니다. 바로 렌더링 방식입니다.

 

SSR(Server Side Rendering)

서버 사이드 렌더링에서는 서버 세션 방식이 더 적합합니다. 서버에서 페이지를 렌더링하므로, 사용자의 상태를 서버에서 관리하는 것이 일관성 있고 쉽기 때문입니다. 클라이언트가 따로 존재하는게 아니기에, 서버에서 바로 세션을 확인하고 필요한 데이터를 렌더링할 수 있습니다. 물론 JWT도 사용 가능하지만, 서버에서 매번 토큰을 확인하고 데이터를 다시 인증해야 하므로 SSR 환경에서는 다소 복잡할 수도 있습니다.

 

CSR(Client Side Rendering)

클라이언트 사이드 렌더링에서는 반대로 세션 상태를 유지하려면 매번 서버와 통신이 필요하므로, 세션 방식이 CSR에서는 다소 비효율적일 수 있습니다. 특히, 여러 서버나 CDN을 사용하는 경우 세션 동기화가 필요합니다. 각각의 서버에 세션id가 저장되기 때문이죠. 그렇기에 클라이언트 사이드의 경우, 서버 상태에 의존하지 않고 확장성이 높은 JWT 방식이 더 적합합니다. 클라이언트 측에서 토큰을 저장하고 인증을 관리할 수 있어, 비동기적으로 여러 API 요청을 처리하는 CSR에 유리하기 때문입니다.

 

글을 마무리 하며

Netflix는 세션 방식을 사용하고 있습니다. 왜일까요? 접속 유저 수를 관리하기 위함입니다. 특정 계정에 접속할 수 있는 사용자 수를 초과할 경우, 서버에서 즉시 sessionID를 무효화하여 더 이상 서비스를 사용할 수 없게 하고, 재로그인을 요구합니다. JWT 방식에서는 이미 발행된 토큰을 중간에 무효화할 수 없기 때문에, 이러한 실시간 제어가 어려웠을 것입니다. Netflix는 이러한 정책적 요구 사항을 반영하기 위해 세션 방식을 채택한 것입니다.

 

반면, 많은 최신 웹 서비스들은 JWT 방식을 선호합니다. 그 이유는 다양한 클라이언트에서의 확장성을 제공하기 때문입니다. 사용자는 데스크탑, 모바일, 태블릿 등 여러 기기를 통해 웹서비스에 접속하는데, JWT는 이러한 비동기적이고 분산된 환경에서 특히 유리합니다. 서버 상태에 의존하지 않고 클라이언트에서 직접 인증 상태를 관리하기 때문에, 각기 다른 플랫폼에서 원활하게 로그인 상태를 유지할 수 있습니다.

 

이처럼 세션과 JWT 방식은 각각의 장점과 단점이 있으며, 서비스의 요구사항에 따라 적합한 방법을 선택하는 것이 중요합니다. 접속 관리와 실시간 제어가 중요한 서비스라면 세션 방식이 유리할 수 있고, 확장성과 유연성이 필요한 분산 환경에서는 JWT가 더 적합할 수 있습니다. 서비스의 특성에 맞춘 선택이 결국 사용자 경험과 시스템의 효율성을 높이는게 중요한 부분이라 생각합니다.

반응형