BoostUs로그인

JWT 토큰을 통해 로그인을 어떻게 유지할 수 있을까

J003 강동훈10
0
0
2025-09-14
원문 보기
JWT 토큰을 통해 로그인을 어떻게 유지할 수 있을까 글의 썸네일 이미지

1분 요약

Goal

문제 해결 과정

기존 해결 방법

기존에 회원의 로그인을 유지하기 위해서 '쿠키-세션' 방식을 주로 사용해왔다.

사용자가 아이디와 비밀번호를 통해 로그인을 시도하면 서버는 세션에 사용자의 정보를 담은 세션 데이터의 ID를 반환하고 클라이언트는 이를 쿠키에 저장하는 방식이다.

로그인을 유지하기 위해 클라이언트는 '현재 로그인 되어 있다'라는 것을 증명하기 위해 '무언가''어딘가에' 저장해야 한다.

여기서 '어딘가에'에 해당하는 것을 쿠키로 선정한 이유는 보안적으로 안전하다고 평가받기 때문이다. 클라이언트에 저장할 수 있는 공간은 크게 세 가지로 구분할 수 있다.

  1. Cookie: 매 요청마다 서버에 전달되며 옵션에 따라 권한과 범위를 설정할 수 있다.
  2. Local Storage: 직접 제거하기 전까지 삭제되지 않으며 브라우저가 종료되어도 데이터가 남아있어 데이터를 관리하기 편하다.
  3. Session Storage: Local Storage와 비슷하지만 브라우저의 탭마다 각자의 저장소가 구분되며 브라우저가 종료되면 데이터가 제거된다.

클라이언트가 보안적으로 위협을 받을 수 있는 공격은 대표적으로 XSS와 CSRF가 존재한다.

이러한 공격에 대해 Local Storage나 Session Storage는 매 요청마다 의도적으로 개발자가 요청에 데이터를 담지않는다면,전달되지 않기 때문에 CSRF 공격은 방어할 수 있지만, 스크립트를 통해 간단하게 storage에 접근이 가능하기 때문에 XSS 공격으로부터 취약하다.

하지만 Cookie의 경우, samesite를 'strict | lax'로 설정하면 동일한 도메인에 대해서 안전적인 HTTP 메서드를 사용하는 경우에만 쿠키를 전송하기 때문에 CSRF 공격을 보안할 수 있다.

또한 httpOnly 값을 설정하면 document.cookie와 같이 스크립트를 통해 쿠키에 접근할 수 없기 때문에 XSS 공격에 대해 예방할 수 있다는 장점이 존재한다.

이러한 이유로 Web Storage를 사용한 것보다는 Cookie에 데이터를 저장(물론 보안적인 옵션을 모두 설정한 전제 하에)하는 것이 더 보안적으로 안전하다고 판단하였다.

JWT 방식 활용

기존에 사용하였던 방식인 "쿠키-세션" 방식은 한 가지 큰 불편한 점이 존재한다.

그것은 바로 *매 요청마다 세션을 생성하거나 세션에 접근하여 유효한지 검사해야한다는 것 *이다.

기존에 "쿠키-세션" 방식으로 로그인을 유지하기 위해 설계하였던 다이어그램이다. 세션을 활용하여 데이터를 저장하는 흐름만 간단히 살펴보자면 다음과 같다.

  1. 클라이언트가 아이디와 비밀번호를 통해 로그인을 시도한다.
  2. 서버는 DB를 통해 실제 존재하는 유저인지 아이디와 비밀번호에 대한 유효성 검사를 실시한다.
  3. 세션에 새로운 세션을 생성하여 유저 정보를 저장하고 session ID를 가져온다.
  4. 서버는 클라이언트의 쿠키로 session ID를 저장한다.

그리고 클라이언트가 만약 '권한이 필요한 요청'을 전달하였을 때의 흐름이다.

  1. 클라이언트는 권한이 필요한 API에 대해 쿠키에 저장된 session ID와 함께 요청한다.
  2. 서버는 쿠키에 저장된 session ID가 실제 세션에 저장되어있는 지 검사한다.
  3. 유효하다면 해당 API 를 실행시켜 응답한다.

현재 설계된 데이터 흐름 안에서만 살펴본다면 새로운 로그인을 시도할 때 세션을 생성한다는 점, 권한이 필요한 요청마다 세션에 접근하여 유효한 접근인 지 검사해한다는 문제점이 발생한다.

한 명의 요청에 대해서는 큰 문제가 발생하지 않겠지만 만약 수많은 사용자가 동시에 여러 요청들을 날린다면 매 요청마다 DB I/O 작업으로 인한 서버 부하가 발생될 수 있다는 위험성을 마주하게 된다.

이렇게 서버가 클라이언트의 상태 데이터를 저장하는 Stateful한 상황에 대해 발생하는 문제점들을 해결하기 위해 등장한 해결책이 "JWT"이다.

JWT는 내부적인 방식에 의해 Signature를 만들기 때문에 만약 Signature에 의해 인증에 성공한다면 DB에 접속하지 않더라도 회원에 대한 인증을 보장받을 수 있으며 이로 인해 DB는 클라이언트의 상태를 저장하지 않는다는 측면에서 'Stateless'하다라고 표현할 수 있다.

JWT 구성 요소

header.payload.signature

xxxxx.yyyyy.zzzzz

  • xxxxx: base64UrlEncode(header)
  • yyyyy: base64UrlEncode(payload)
  • zzzzz: 선택된 알고리즘으로 (xxxxx.yyyyyy, secret) 조합을 해싱하여 signature 생성

이렇게 서버에서 생성된 하나의 JWT 토큰을 생성하여 클라이언트에 전달해주면 클라이언트는 해당 토큰을 저장함과 동시에 서버는 자신이 토큰을 생성하였다는 사실을 잊어버린다. (Stateless)

이 후, 클라이언트가 권한이 필요한 요청을 전달할 때, JWT를 함께 서버로 전달한다. JWT는 내부적으로 서버가 갖고 있는 Secret 값에 의해 생성된 Signature가 존재하기 때문에, 서버는 클라이언트로 전달받은 JWT 토큰을 자신의 Secret으로 검증하여 동일한 Signature가 나온다면 자신이 발급한 토큰이라는 것을 보장하며 사용자를 인증할 수 있다.

클라이언트는 JWT를 어디에 저장하나?

JWT 공식문서를 확인해보면

You also should not store sensitive session data in browser storage due to lack of security.

browser storage(local storage, session storage)는 보안상 위험하기 때문에 민감한 데이터에 대해 해당 저장소에는 저장하지 않는 것을 권장한다.

JWT 문서에서는 공식적으로 Authorization 헤더에 Bearer 구조를 사용하여 토큰을 서버 요청에 함께 전달하는 것을 권고한다. (토큰의 크기를 8KB 이하로 만드는 것 또한 추천)

Authorization: Bearer <token>

그럼 결국, 매 요청 헤더에 토큰을 저장하기 위해 클라이언트는 웹 토큰은 "어딘가에" 저장하여야 하는데, OWASP Cheetsheet에서 살펴보면 보안을 고려하여 Cookie에 httpOnly 를 설정하여 저장하는 것을 권장한다.

하지만 악성 스크립트를 통한 쿠키 탈취를 방지하기 위해 JS로 쿠키에 접근하는 것을 막는다는 것은 "개발자 또한 클라이언트에서 쿠키에 접근할 수 없다"는 의미이다. 그렇기 때문에 결국, 만약 쿠키에 access token을 저장한다면 클라이언트는 해당 토큰을 헤더에 담기 위해 서버로부터 매번 토큰을 데이터로 받아와서 처리하는 등의 추가적인 작업이 필요하며, 이러한 과정에서 access token을 쿠키에 담아 서버로 전달하게 된다. 하지만 이러한 흐름은 JWT에서 권장하는 '요청 헤더에 토큰을 담아 전달'하는 것에 대해 위반이라 생각하여 다른 방식을 고안하게 되었다.

두 번째 고려해볼 수 있는 점은 In-Memory 로 토큰을 저장하는 방식이다. 클라이언트에서 전역으로 토큰을 저장하면 간단하게 요청 헤더에도 포함시킬 수 있으며 CSRF와 같은 보안 문제에서도 안정성을 보인다.

하지만 여전히 XSS 공격을 통해 토큰이 탈취될 위험은 존재(스크립트를 통해 Store에 저장된 토큰에 접근)하기 때문에 access tokenrefresh token을 나눠 하게 된다.

access token를 in-memory에 저장하여 사용하더라도 짧은 만료 기간으로 XSS 공격에 대해 어느 정도 예방이 가능하며 만료된 토큰을 재발급하기 위해 refresh token을 쿠키에 담아 매 요청마다 함께 보낼 수 있도록 설정할 수 있다.

(refresh token을 쿠키에 담는 이유는 정해진 것은 아니지만 가장 안전하다고 생각하는 저장소라 판단하였기 때문이다.)

멀리 멀리 돌아온 로그인 설계

다이어그램 링크: 로그인 설계

그래서 결과적으로 다음과 같은 결론이 나왔다.

  1. 토큰에는 사용자의 민감한 정보를 담아서는 안된다.
  2. access token은 클라이언트 In-memory에 저장한다.
  3. refresh token은 쿠키 samesite, httypOnly, (배포 환경이라면 secure)를 설정하여 저장한다.

access token에는 사용자 식별자와 아이디를 저장할 것이고 refresh token에는 사용자 식별자만 저장할 것이다.

(아이디 또한 민감한 정보에 해당하긴 하지만 간단한 예시를 위한 개발적 허용이라 생각하였다. 😅)

이를 기반으로 설계한 로그인 데이터 흐름이다.

  1. 사용자가 아이디와 비밀번호를 통해 로그인 요청을 보내온다.
  2. Service 에서는 유효성 검사를 실행하고 아이디를 통해 DB에서 회원 정보를 조회한다.
  3. 전달받은 비밀번호와 해싱된 비밀번호를 비교하여 일치하는 지 검사한다.
  4. 일치할 경우, access tokenrefresh Token을 생성하여 반환한다.
  5. access token은 json 응답으로, refresh token은 cookie 설정으로 HTTP 응답을 전달한다.

다음은 권한이 필요한 요청을 처리하는 데이터 흐름이다.

  1. 전달받은 access token은 클라이언트에서 전역 Store에 저장한다.
  2. API 요청을 담당하는 Request에서는 Store에 저장된 access token을 Authorization 헤더에 담아 요청을 전송한다.
  3. 요청이 백엔드 서버의 Router에 거치기 전, 권한을 확인하는 AuthGuard 미들웨어를 먼저 거치게 된다.
  4. AuthGuard 미들웨어에서는 헤더에 담긴 토큰에 대해 유효성 검사를 실행한다.
    1. 헤더로 전달되었는가
    2. 헤더에 토큰이 존재하는가
    3. 토큰이 만료되지 않았는가
    4. 토큰이 유효한가
  5. 이러한 과정을 거쳐 토큰이 유효하다면 Router로 보내 API를 실행시킨다.

멀리 돌아왔으니 다시 정리를 해보자.

  1. 서버만 알고 있는 secret을 통해 토큰을 발급한다.
  2. 클라이언트는 access tokenrefresh token을 안정성과 사용성을 고려하여 적절한 저장소에 저장한다.
  3. 권한이 필요한 요청에 대해 Authorization 헤더에 토큰을 담아 명시적으로 토큰을 서버에 전달한다.
  4. 매 요청마다 서버는 secret 값을 통해 토큰의 유효성을 검사하면 되기 때문에 DB I/O의 부담이 줄어든다.

토큰 재발급

토큰을 발급하고 이를 통해 권한이 필요한 요청을 전달할 수 있게 되었다. 하지만 여기서 끝이 나면 안된다.

토큰이 만료되었다면 이를 어떻게 다시 발급해줄 것인지에 대해서도 고려해보아야 한다. refresh token의 용도는 결국 access token을 재발급해주기 위함에 있기 때문에 토큰을 재발급 또한 설계하여야 한다.

토큰의 유효성을 검사하는 곳은 서버의 AuthGuard에 해당한다. 해당 레이어를 거치며 토큰이 유효하지 않거나, 만료되었을 경우 에러를 응답한다.

그러면 클라이언트는 간단히 서버에서 받은 에러를 감지하여 access tokenrefresh token을 다시 발급받아서 저장해주면 된다.

하지만 이러한 점에 대해서도 두 가지 문제점을 고려하여 이 부분에 대해 중점적으로 설계하였다.

  1. In-Memory 방식은 새로고침을 하게 되면 데이터가 휘발된다는 문제점이 존재한다.
  2. 토큰 만료로 요청이 거절되다면, 클라이언트는 토큰을 재발급받아 이전 실패한 요청에 대해 재요청을 시도해야 한다.

  1. POST /api/auth/refresh 요청을 통해 쿠키에 담겨있는 refreshToken을 함께 서버에 전달한다.
  2. 이는 권한이 필요한 요청이 아니기 때문에 AuthGuard를 통과한다.
  3. Router에서는 쿠키에 저장된 refreshToken을 추출하여 Service에 전달한다.
  4. Service는 refreshToken에 대한 유효성 검사를 진행한다.
    1. 토큰이 함께 전달되었는 지
    2. 토큰이 유효한 지
    3. 토큰에 담긴 회원 식별자가 존재하는 회원인 지
  5. 유효성 검사를 통과할 경우, accessTokenrefreshToken을 다시 생성하여 응답한다.
  6. Router는 다시 accessToken은 json 응답으로, refreshToken은 cookie 설정으로 HTTP 응답을 전달한다.
  7. 클라이언트에서는 이전에 실패한 요청에 대해 저장해두었다가 새로운 토큰을 헤더에 담아 자동으로 재요청을 보낸다.

새로고침에 대한 문제 해결

토큰 재발급 API는 실제 요청 시, 토큰이 만료되었을 때 사용되지만 반대로 새로고침을 통해 토큰이 휘발되었을 경우에도 사용할 수 있다.

설계를 토대로 access token은 In-Memory에, refresh token은 cookie에 저장된 상태이다.

토큰 재발급 API는 기본적으로 만료된 access token에 대해 AuthGuard에서 거절된 상태에서 요청이 진행되기 때문에, 쿠키에 저장된 refresh token을 통해 새롭게 발급해주면 된다.

새로고침 또한, 메모리에 저장된 access token만 휘발된 상태며 쿠키의 refresh token은 유지되기 때문에 이를 통해 새로운 토큰들을 재발급 받을 수 있다.

그렇다면 클라이언트에서는 새로고침을 감지하여 매번 Store에서 토큰 재발급 API를 요청하여 전역 상태 데이터를 관리해주어야 한다.

만약, 토큰 재발급 API를 요청하였는데 refresh token이 유효하지 않거나 만료되었다면 어떻게 해결해야할까?

access token처럼 refresh token도 재발급시켜줘야 하나?

맞는 말이다. 하지만 조금 이상하다고 생각하였다. 조금 돌아서 생각해보면 각 토큰들은 사용자의 첫 로그인 때 발급받으며 매 새로고침마다 새로 발급된다.

access token의 유효 기간은 30분 내외이기 때문에 로그인 이후, 30분 동안 새로고침없이 사용자가 충분히 사용할 수 있다. 그렇기에 access token의 만료는 자연스러운 현상이라 할 수 있다.

반면, refresh token의 유효 기간은 넉넉히 30일로 잡았다. 근데 어떤 사용자가 30일 이전에 시도한 로그인으로부터 새로고침없이 30일동안 서비스를 사용 중이다? 딱봐도 비정상적인 유저에 해당한다. (아닐 수도 있지만,,)

그러한 경우에는 정중히 요청을 거절하며 🙏 로그아웃 처리와 함께 로그인 페이지로 이동시켜 재로그인을 부탁한다.

거절된 요청 재시도

실제 서비스를 이용하는 유저 입장에서는 로그인 유지가 어떻고 토큰이 뭐고에 대해 아무 것도 알 수가 없다. 유저가 중요하게 생각하는 것은 오직 "클릭했는데 왜 안돼?"이다.

이러한 관점에서 다시 생각해본다면,

  1. 클라이언트에서 권한이 필요한 요청을 전송한다.
  2. 서버는 만료된 토큰이라고 거절한다.
  3. 클라이언트는 토큰을 재발급하여 유효한 토큰을 다시 헤더에 추가하였다.
  4. 이제 다음 요청부터는 권한이 필요한 요청에 대해 다시 요청이 가능하다.

클라이언트와 서버 입장에서는 적절한 데이터 흐름을 거치며 네트워크 통신을 완료하였다. 하지만 사용자의 관점에서는

  1. 마이페이지를 클릭하였다.
  2. 아무 일도 일어나지 않았다.
  3. 아 이거 버그네

실제 아무 일이 일어나는 것은 아니지만 표면상으로 어떠한 일이 발생되지 않았기 때문에 사용자 입장에서는 언짢을 수 밖에 없다.

이러한 문제에 대해 "토큰 만료로 거절된 요청에 대해서는 재요청" 을 시도하여 아무런 문제가 없는 척을 해야 한다.