개발자
[JWT+Redis]Redis를 이용해 최신 JWT만 이용하게 하기 + accessToken 만료시 재발급 + JWT 로그아웃 - 1 본문
[JWT+Redis]Redis를 이용해 최신 JWT만 이용하게 하기 + accessToken 만료시 재발급 + JWT 로그아웃 - 1
GoGo개발 2023. 7. 29. 01:38JWT를 구현하는 중에 문제가 생겼다. JWT는 만료시킬 수 없어 재사용하거나 계속 발급해주는 방법이있는데 재사용은 보안문제가 있어 로그인 API접근시 마다 새로운 토큰을 발급해주는 방법을 사용했었다.
나는 accessToken과 refreshToekn을 모두 재발급 해주는데 accessToken만료시에도 refreshToken 검증후 accessToken을 재발급 해주는데 이때 refreshToken도 함께 재발급이 된다.
그렇다면 계속해서 발급하는 토큰들 어떻게 되는거지? 유효시간이 만료되서 사용불가능하게 되지않으면 이전에 발급한 토큰들로도 계속 로그인이 가능하다. accessToken의 경우 유효시간이 짧지만 refreshToken의 경우는 유효기간이 길어 문제가 될 수 있다.
그렇다고 refreshToken을 재사용하면 보안이 약해지고 DB에 저장해놓고 쓰자니 규모가 커지면 DB에 매번 접근하다보면 속도가 저하되고 DB에 부하가 올 수 있다. 그래서 찾아보니 Redis를 많이 이용하고 있었다.
그래서 이제부터 Redis에 대해 알아보고 Redis를 이용해 최신토큰만 이용하게 하고, 로그아웃 하는 방법을 알아보겠다.
내가 원하는 최신토큰을 이용하기위한 flow는 아래와 같다.
하지만 이를 구현하기 위해서는 일단 Redis에 대해서 먼저 알아봐야하기 때문에 이번 포스팅에서는 Redis에 대해 먼저알아 보겠다
Redis란?
Key, Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터 베이스 관리 시스템 (DBMS)이다.
Redis는 Memcached와 비슷한 캐시 시스템으로서 동일한 기능을 제공하면서 영속성, 다양한 데이터 구조와 같은 부가적인 기능을 지원하고 있다. 레디스는 모든 데이터를 메모리에 저장하고 조회한다. 즉, 인메모리 데이터베이스이다. 데이터베이스, 캐시, 메세지 브로커로 사용되며 인메모리 데이터 구조를 가진 저장소이다.
Redis는 Remote Dictionary Server의 약자로 외부에서 사용 가능한 Key-Value 쌍의 해시 맵 형태의 서버라고 생각할 수 있다. 그래서 별도의 쿼리 없이 Key를 통해 빠르게 결과를 가져올 수 있다.
또한, 디스크에 데이터를 쓰는 구조가 아니라 메모리에서 데이터를 처리하기 때문에 작업 속도가 상당히 빠르다.
Redis를 한 줄로 정의하라고 하면, Redis는 고성능 키-값 저장소로서 String, list, hash, set, sorted set 등의 자료 구조를 지원하는 NoSQL 라고 할 수 있을
것 같다.
그렇다면 데이터 베이스가 있는데 왜 Redis를 쓰는 걸까?
데이터 베이스는 데이터를 물리 디스크에 직접 쓰기 때문에 서버에 문제가 발생하여 다운되더라도 데이터가 손실되지 않는. 하지만 매번 디스크에 접근해야 하기 때문에 사용자가 많아질수록 부하가 많아져서 느려질 수 있다.
일반적으로 서비스 운영 초반이거나 규모가 작은, 사용자가 많지 않은 서비스의 경우에는 WEB - WAS - DB 의 구조로도 데이터 베이스에 무리가 가지 않는다.
하지만 사용자가 늘어난다면 데이터 베이스가 과부하 될 수 있기 때문에 이때 캐시 서버를 도입하여 사용한다.
그리고 이 캐시 서버로 이용할 수 있는 것이 바로 Redis 이다.
인메모리 데이터 구조 저장소를 알아보기 위해 캐시 서버(Cache Server)에 대해 알아보자
Chache 란?
Cache란 나중에 요청할 결과를 미리 저장해둔 후 빠르게 서비스해 주는 것을 의미한다. 즉, 미리 결과를 저장하고 나중에 요청이 오면 그 요청에 대해서 DB 또는 API를 참조하지 않고 Cache를 접근하여 요청을 처리하는 기법이다.
즉 캐시는 한번 읽어온 데이터를 임의의 공간에 저장하여 다음에 읽을 때는 빠르게 결괏값을 받을 수 있도록 도와주는 공간다.
이러한 특징 때문에 같은 요청이 여러 번 들어오는 경우 매번 데이터 베이스를 거치는 것이 아니라 캐시 서버에서 첫 번째 요청 이후 저장된 결괏값을 바로 내려주기 때문에 DB의 부하를 줄이고 서비스의 속도도 느려지지 않는 장점이 있다.
chche 사용 구조
클라이언트가 웹 서버에 요청을 보내면, 웹 서버는 데이터를 DB에서 가져 오기 전에 캐시에 데이터가 있는지 확인하고, 있다면 바로 클라이언트에게 저장된 데이터를 반환한다. 이를 Cache Hit라고 한다.
반대로 캐시 서버에 데이터가 없으면 DB에 데이터를 요청하여 원하는 데이터를 조회한 후 그 데이터를 클라이언트에게 제공하는데, 이를 Cache Miss라고 한다.
위 flow에서 캐시를 어떻게 사용하느냐에 따라서 캐시서버는 look aside cache와 write back 패턴으로 나뉜다.
Look Aside Cache (Lazy Loading)
1. 클라이언트가 데이터를 요청
2. 웹서버는 데이터가 존재하는지 Cache 서버에 먼저 확인
3. Cache 서버에 데이터가 있으면 DB에 데이터를 조회하지 않고 Cache 서버에 있는 결과값을 클라이언트에게 바로 반환 (Cache Hit)
4. Cache 서버에 데이터가 없으면 DB에 데이터를 조회하여 Cache 서버에 저장하고 결과값을 클라이언트에게 반환 (Cache Miss)
look aside cache는 캐시를 한 번 접근하여 데이터가 있는지 판단한 후, 있다면 캐시의 데이터를 사용하고 없으면 실제 DB 또는 API를 호출한다. 대부분의 캐시를 사용한 개발이 해당 프로세스를 따른다.
Write Back
1. 웹서버는 모든 데이터를 Cache 서버에 저장
2. Cache 서버에 특정 시간 동안 데이터가 저장됨
3. Cache 서버에 있는 데이터를 DB에 저장
4. DB에 저장된 Cache 서버의 데이터를 삭제
write back은 주로 쓰기 작업이 굉장히 많아서, INSERT 쿼리를 일일이 날리지 않고 한꺼번에 배치 처리를 하기 위해 사용한다. 예를 들어 영어 듣기 평가를 온라인으로 진행하는 서비스가 있을 때, 여러 학생이 동시에 제출 버튼을 누르면서 DB에 갑작스럽게 엄청난 쓰기 요청이 몰리게 되면 DB 서버가 죽을 수도 있다. 이때 write back 기반의 캐시를 사용하면 캐시 메모리에 데이터를 저장해 놓고, 이후 DB 디스크에 업데이트 해 주면 안전하게 쓰기 작업을 이행할 수 있는 것이다.
DB에서 디스크를 접근하는 횟수가 줄어들기 때문에 성능 향상을 기대할 수 있지만, DB에 데이터를 저장하기 전에 캐시 서버가 죽으면 데이터가 유실된다는 문제점이 있다.
캐시서버에 대해 알아봤다면 다시 Redis로 돌아가 봅시다.
또다른 Redis의 특징으로는 Single Threaded라는 것이다.
: 한 번에 하나의 명령만 처리할 수 있다. 그렇기 때문에 중간에 처리 시간이 긴 명령어가 들어오면 그 뒤에 명령어들은 모두 앞에 있는 명령어가 처리될 때까지 대기가 필요하다.
(하지만 get, set 명령어의 경우 초당 10만 개 이상 처리할 수 있을 만큼 빠릅니다.)
Redis 사용시 주의사항
- 서버에 장애가 발생했을 경우 그에 대한 운영 플랜이 꼭 필요하다.
: 인메모리 데이터 저장소의 특성상, 서버에 장애가 발생했을 경우 데이터 유실이 발생할 수 있기 때문이다. - 메모리 관리가 중요하다.
- 싱글 스레드의 특성상, 한 번에 하나의 명령만 처리할 수 있다. 처리하는데 시간이 오래 걸리는 요청, 명령은 피해야 한다.
레디스는 지속성을 보장하기 위해 데이터를 DISK에 저장할 수 있다. 서버가 내려가더라도 DISK에 저장된 데이터를 읽어서 메모리에 로딩을 한다.
데이터를 DISK에 저장하는 방식은 크게 두 가지 방식이 있다.
- RDB(Snapshotting) 방식
- 순간적으로 메모리에 있는 내용을 DISK에 전체를 옮겨 담는 방식
- AOF (Append On File) 방식
- Redis의 모든 write/update 연산 자체를 모두 log 파일에 기록하는 형태
Redis의 개념에 대해서는 여기까지하고 이제 Redis를 적용해서 최신토큰을 이용 할 수 있게 만들어보겠다.
이 포스팅은 SpringSecurity+JWT 포스팅에 이어서 내용이 추가된다.
프로젝트에 적용하기전 Redis를 설치해야하는데 나는 window라서 파일을 직접 다운받아 설치했다.
최신 토큰을 이용하는 Flow는 포스팅 맨위를 참조하자.
Redis설치를 완료했으면 의존성 추가를 해준다.
다음 yml에 위와같이 설정을 추가해준다. 6379는 redis 기본 포트이다.
이제부터 습니다체로 쓰겠습니다.
package com.workFlow.WFrefactoring.security.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.host}")
private String host;
//RedisProperties 따로 만들었다면
//private final RedisProperties redisProperties;
//lett
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, String> redisTemplate(){
// redisTemplate를 받아와서 set, get, delete를 사용
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
// setKeySerializer, setValueSerializer 설정
// redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
그런뒤 redis를 사용하기위해 redisConfig class를 만들어 줍니다.
Lettuce를 이용해서 redis를 사용할 수 있게끔 만들어줍니다
이 부분부터는 jwt를 발급을 완료하고 이어지는 부분이기 때문에 이전 포스팅을 알아야 이해하기 편합니다.
아래 코드는 jwtProvider의 토큰 발급(generate Token) 메소드안에 추가되었습니다.
//redis에 accessToken 저장
redisTemplate.opsForValue().set(
"ATK"+authoritiesName,
accessToken,
accessTokenExpirationTime,
TimeUnit.MILLISECONDS
);
토큰을 발급해줄때 redis에 발급해준 token을 함께 저장해 줍니다. key 값은 저는 사용자 ID를 사용했고 value값으로 acceesToken 값을 넣어줬습니다. 여기서 authoritiesName이 사용자 ID값인데요. 여기서 authoritiy는 이전 포스팅에서 EmployeeLoginService에서 시큐리티에서 검증을 마친 사용자 값입니다.
//redis에 refreshToken 저장
redisTemplate.opsForValue().set(
"RTK"+authoritiesName,
refreshToken,
refreshTokenExpirationTime,
TimeUnit.MILLISECONDS
);
refreshToken도 저장해줍니다.
토큰을 저장할 때는 꼭 유효기간도 함께 저장해주도록 합니다. 저는 accessToken은 30분 refreshToken은 14일을 추가해 주었습니다.
이제 토큰을 발급 해줄때마다 redis에 토큰을 저장해주기 때문에 가장 최근에 발급된 토큰이 redis에 저장되어있습니다.
이제 Redis를 확인해볼까요?
redis 의 키를 보니 accessToken, refreshToken이 모두 정상적으로 들어가있습니다
accessToken value값을 확인해보니 acceeToken이 정상적으로 잘 들어가있습니다.
ttl 명령어를 입력하면 토큰의 유효기간도 확인이 가능합니다
이제 최신토큰을 사용하기 위해 redis에 해당 토큰이 있는지 한번 더 확인하는 작업만 해주면 됩니다.
jwtFilter에 추가해줄건데요 token 유효성검사를 하는 메소드에 아래의의 코드를 추가해주겠습니다.
String ATK = (String)redisTemplate.opsForValue().get("ATK"+authentication.getName());
if(!ATK.equals(accessToken)){
throw new CheckTokenException("최신 토큰을 사용하세요");
}
redis에서 로그인한 사용자의 ID로 키를 입력해놨기 때문에 해당 키가 redis에 존재하면 최신 토큰입니다.
accessToken이 유효할 경우 코드는 아래와 같아집니다.
if(StringUtils.hasText(accessToken) && jwtTokenProvider.vaildateToken(accessToken) ){
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
// 가장 최근 토큰인지 확인
String ATK = (String)redisTemplate.opsForValue().get("ATK"+authentication.getName());
if(!ATK.equals(accessToken)){
throw new CheckTokenException("최신 토큰을 사용하세요");
}
SecurityContextHolder.getContext().setAuthentication(authentication);
}
springSecurity도 함께 섞여있어 헷갈리신다면 jwt+SpringSecurity 포스팅을 보고 오시는 것을 추천합니다!
자 이제, redis를 이용해서 최신토큰만 사용할 수 있도록 만들었는데요 accessToken만료시에는 어떻게 해야할까요?
accessToken이 만료되었다는 건 redis에도 토큰 만료기간이 함께 들어가있기 때문에 redis에도 저장된 값이 없어지겠죠?
아래는 accessToken 만료시 redis를 이용한 재발급 flow 입니다.
이를 코드로 구현한 부분을 함께 볼까요?
else if(StringUtils.hasText(refreshToken) && !jwtTokenProvider.vaildateToken(accessToken) && jwtTokenProvider.vaildateToken(refreshToken)){
//검증 통과시 refreshToken에서 권한정보 가져오기
Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
// 가장 최근 토큰인지 확인
String RTK = (String)redisTemplate.opsForValue().get("RTK"+authentication.getName());
if(!RTK.equals(refreshToken)){
throw new CheckTokenException("최신 refresh 토큰을 사용하세요");
}
//accessToken,refreshToken 모두 재발급(refreshTokeb 발급마다 redis에 저장해줘서 따로 update 로직 필요X)
TokenDto tokenDto = jwtTokenProvider.generateToken(authentication);
//토큰 내려주기
JwtTokenProvider.setToken(tokenDto);
//SecurityContext 에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
accessToken 만료시 위의 로직으로 오게 되는데요. 먼저 refresToken 을 검증해줍니다.(존재하는지, 유효한 토큰인지 등)
그런뒤 accessToken 과 똑같은 방식으로 토큰에서 권한정보를 가져오고, 최신 토큰인지 확인해줍니다.
마찬가지로 redis에 refreshTokeb 값이 존재한다면 최신토큰입니다. 그런뒤 토큰 재발급을 해주는데요.
이는 맨처음 토큰재발급 메소드를 말하는 것이기 때문에 토큰을 재발급하면서 재발급된 토큰을 Redis에 다시 저장해줍니다. 이제 방금 재발급된 acceeToken,refreshToken들이 최신토큰이 되는 거겠죠? 방금까지 acceessToken을 검증하기위해 사용했던 refreshTokeb은 지금 재발급된 refreshToken으로 대체되어 더이상 사용이 불가능 합니다.
reids를 확인해보면 "ATK"+사용자ID로 key값은 동일하지만 value값이 달라진 것을 확인할 수 있습니다.
자 이렇게 Redis를 이용하여 acceessToken,refreshToken을 최신 토큰값만 이용할 수 있게 했고, accessToken 만료시 재발급까지 해주었습니다. 이제 로그아웃이 남았는데요
포스팅이 길어진 관계로 로그아웃은 다음 포스트로 이어서 쓰겠습니다.
참고블로그
https://wildeveloperetrain.tistory.com/21
https://devlog-wjdrbs96.tistory.com/374
https://steady-coding.tistory.com/586
https://sol-devlog.tistory.com/22
'개발자 > workflow 리팩토링 프로젝트(SpringBoot,JPA,MySQL)' 카테고리의 다른 글
[SpringBoot]멀티모듈에서 개발 환경별로 DB 분리하기 (0) | 2023.09.08 |
---|---|
[JWT+Redis]Redis를 이용해 최신 JWT만 이용하게 하기 + accessToken 만료시 재발급 + JWT 로그아웃 - 2 (0) | 2023.08.01 |
[JWT]JWT를 왜 쓰는걸까?/토큰과 세션방식의 차이점 (0) | 2023.07.13 |
[JWT]Spring Security + JWT 로그인 구현하기 (0) | 2023.07.02 |
[Spring Security] 스프링 시큐리티 동작 원리 - 1 (0) | 2023.07.01 |