세션 방식으로 구현되어 있던 로그인 방식을
JWT로 전환하고 Redis 적용하는 과정을 기록해보고자 한다.
세션은 브라우저에 세션 ID를 저장하고 서버 메모리에 사용자 정보를 저장하는 stateful 방식이다.
이 구조는 두 가지 문제가 있다.
서버를 재시작하면 메모리에 저장된 세션이 모두 사라져 전체 로그아웃이 발생한다.
서버가 여러 대로 확장될 경우 서버마다 세션이 따로 존재하기 때문에 로드밸런서가 다른 서버로 요청을 보내면 로그인 상태가 유지되지 않는다.
JWT는 서버가 아무것도 저장하지 않는 stateless 방식이다.
토큰 자체에 사용자 정보가 담겨 있어 어느 서버로 요청이 가든 토큰 검증만으로 인증이 가능하다.
그리고 서버가 상태를 저장하지 않기 때문에 유저가 늘어나도 서버 메모리 부담이 없다.
현재는 단일 서버 환경이지만 실제 서비스 운영을 고려했을 때
확장성을 고려한 구조를 선택하고자 JWT로 전환하기로 했다.
JWT는 Access Token과 Refresh Token 두 가지로 구성된다.
짧은 토큰으로 보안을 챙기고 긴 토큰으로 편의성을 챙기는 역할 분리다.
토큰 저장 위치는 두 가지 공격을 기준으로 결정했다.
Access Token → 클라이언트 메모리
localStorage는 XSS에 취약하고, 쿠키는 CSRF 위험이 있다.
클라이언트 메모리도 XSS에 완전히 안전하진 않지만, XSS 방어는 토큰 저장 위치가 아닌 입력값 검증 등 별도 레이어에서 처리해야 할 문제다.
클라이언트 메모리에 저장하고 Authorization 헤더로 직접 전송하면 브라우저 자동 전송이 없어 CSRF를 원천 차단할 수 있다는 점에서 이 방식을 선택했다.
Refresh Token → HttpOnly 쿠키 + Redis
수명이 길어 탈취 시 피해가 크기 때문에 JS 접근을 차단하는 HttpOnly 쿠키에 저장하기로 했다.
CSRF 위험은 CORS 정책으로 공격자가 응답을 읽을 수 없어 방어된다.
하지만 쿠키는 브라우저가 들고 있어서 서버가 직접 삭제할 수 없다.
로그아웃해도 공격자가 복사해둔 토큰으로 재발급이 가능하다는 문제가 있기 때문에 Redis에 함께 저장하기로 했다.
재발급 요청했을 때 쿠키 값과 Redis 값을 비교하고, 로그아웃 시 Redis에서 삭제해서 토큰을 즉시 무효화할 수 있도록 했다.

액세스 토큰과 리프레시 토큰을 생성하는 코드이다.
두 토큰은 access/refresh 라는 타입명으로 구별하고 각각의 만료시간을 관리한다.
타입을 구별하는 이유는 Refresh Token으로 API를 호출하거나
Access Token으로 재발급을 요청하는 것을 방지하기 위해서이다.

모든 API 요청이 들어올 때마다 Access Token을 검증하는 필터이다.
우선 Redis 블랙리스트를 확인하여 로그아웃된 토큰인지 확인하고,
정상 토큰이면 CustomOAuth2User 객체에 사용자 정보를 담아 SecurityContext에 인증 정보를 저장한다.

Redis에 Refresh Token을 저장하고 삭제하는 로직이다.
만료시간은 14일로 설정했다.
독서기록 플랫폼 특성상 기록을 남기거나 모임이 있을 때만 접속하는 서비스라 매일 사용하지 않는 경우가 많다.
그래서 만료시간을 길게 잡아도 무방하다고 생각했고, 금융 서비스처럼 민감한 데이터를 다루지 않기 때문에 14일이 적절하다고 판단했다.
그리고 HttpOnly 쿠키만으로는 서버가 토큰을 직접 무효화할 수 없다.
쿠키는 클라이언트가 들고 있기 때문에 로그아웃을 해도 공격자가 복사해둔 토큰은 여전히 유효하기 때문이다.
이를 해결하기 위해 Redis에 함께 저장해 로그아웃 시 삭제함으로써 복사된 토큰도 재발급 요청에서 거부할 수 있도록 했다.



Access Token이 만료되었을 때 Refresh Token을 통해 재발급하고,
로그아웃 시 해당 Access Token을 블랙리스트에 등록하여 만료 전 탈취된 토큰이 사용되는 것을 방지한다.
Access Token은 stateless 특성상 서버가 직접 무효화할 수 없다.
그래서 로그아웃 시점에 남은 유효시간만큼 Redis에 블랙리스트로 등록하고, 이후 요청에서 해당 토큰이 감지되면 거부하는 방식으로 구현했다.
재발급 시에는 Refresh Token도 함께 새로 발급하는 Rotation 방식을 적용했다. Refresh Token은 HttpOnly 쿠키라 탈취 가능성이 낮지만, 탈취됐을 경우 공격자가 재발급을 시도하면 기존 토큰과 불일치로 감지되어 차단할 수 있다.
재발급 비용이 크지 않아 부담 없이 추가할 수 있었다.


JWT 기반 stateless 환경에서는 세션이 없기 때문에
OAuth2 로그인 시 생성되는 state 값을 저장할 공간이 없다.
스프링 시큐리티는 기본적으로 세션에 저장하는데
세션을 사용하지 않으면 콜백 시점에 state 값을 비교할 수 없어서 CSRF 방어가 불가능해진다.
이를 해결하기 위해 CookieOAuth2AuthorizationRequestRepository를 구현해 state 값을 HttpOnly 쿠키에 저장했다.
로그인 시작할 때 저장하고, 카카오 콜백이 오면 쿠키에서 꺼내 비교한 뒤 즉시 삭제한다.
TTL은 3분으로 설정해 로그인 완료 전 만료되지 않도록 했다.



JWT + OAuth2 + Redis를 조합해 stateless 환경에서 보안을 챙기는 인증 구조를 구현했다.
독서 모임/기록 플랫폼은 금융 서비스처럼 즉각적인 금전 피해가 발생하는 서비스는 아니지만,
개인의 독서 기록과 감상이 담긴 플랫폼인 만큼 개인정보 보호 측면에서 보안을 소홀히 할 수 없다고 판단했다.
Rotation을 적용해 탈취 감지까지 고려했지만,
자주 접속하는 유저의 경우 Refresh Token 만료시간이 사실상 의미없어지는 한계가 있다.
추후 접속 빈도에 따라 만료시간을 동적으로 조정하거나, IP/디바이스 검증을 추가하는 방향으로 개선을 해야할지 고민이 필요할 것 같다.