📌 Redis에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.
카풀 서비스 프로젝트 개발 중에 로그아웃 기능이 필요했다.
이 프로젝트에서는
Redis를 사용하여 외부 캐시 JWT 로그아웃을 구현하였고,
외부환경에 구속받지 않고 테스트하기 위해 Embedded Redis도 사용하였다.
이번 포스팅에서 Redis로 로그아웃 기능을 어떻게 구현하는지 살펴볼 것이다 !
그 전에 여기서 사용되는 몇가지 개념들에 대해 설명해보자면,
✔️ Spring Data Redis
- Spring Data 프로젝트의 하위 프로젝트로서, Redis를 Spring 애플리케이션에 쉽게 통합할 수 있도록 도와주는 라이브러리
- Redis 클라이언트 라이브러리인 Jedis, Lettuce 등을 활용하여 Redis와 상호 작용하기 위해 더 높은 수준의 추상화와 기능을 제공하는 프레임워크
⠀
✔️ Lettuce- Java Redis 클라이언트 라이브러리
- Redis와 상호 작용하는 비차단, 스레드 안전, 고성능 방식을 제공
- 현재 (Spring Boot 2.0.2) Spring Data Redis에서 공식지원하는 Client
⠀
✔️ Embedded Redis- 내장 Redis
- 로컬 개발 환경이나 테스트 환경에서 Redis를 쉽게 실행할 수 있도록 도와주는 도구
- 이를 사용하면 외부 Redis 서버를 설치하고 구성할 필요 없이 애플리케이션 내에서 Redis를 실행 가능
세션 관리
➜ 로그아웃 기능은 사용자 세션 관리의 일부로 사용되는데,
Redis를 사용할 경우 메모리에 세션 데이터를 효율적으로 저장/조회가 가능하고
로그아웃 요청 시 해당 세션을 빠르게 제거가 가능
캐싱
➜ 로그아웃 요청 시 Redis를 사용하여 사용자 세션 정보를 캐싱 가능
➜ 데이터베이스에 대한 부하를 줄이고 응답 시간을 단축 가능
분산 환경 지원
➜ Redis는 분산 환경에서의 데이터 공유 및 동기화를 지원하기 때문에
여러 서버 또는 인스턴스에서 동시에 실행되는 애플리케이션의 경우, 사용자 세션 데이터의 공유/동기화 가능
TTL(Time-To-Live) 기능
➜ Redis로 로그아웃 세션 정보에 대해 일정 시간 후에 자동으로 만료되도록 TTL을 설정이 가능
➜ 사용자 세션 데이터의 메모리 점유를 최소화하고, 만료된 세션 데이터를 자동으로 정리할 수 있음
Blacklist 관리
➜ Set이나 List와 같은 데이터 구조를 활용하여 로그아웃된 사용자의 Access Tocken을 블랙리스트에 추가하여 로그인 시 검증 과정에서 블랙리스트에 있는지 확인하여 로그인 거부 가능
➜ 이를 통해 세션을 유지하지 않고도 로그아웃한 사용자를 빠르게 인식 가능
👉 우리 프로젝트에서는 결정적으로 한 번 로그인 한 Access Tocken을 Blacklist에 넣고,
로그아웃은 되었지만 Access Tocken의 유효기간이 남아있을 때 탈취당하여 로그인되는 것을 방지하기 위해 Redis를 사용하였다.
Redis 사용을 위함
Embedded Redis 사용을 위함
Redis 캐시를 사용하기 위해 cache 타입을 redis로 정해줌
Redis 연결에 필요한 host, port 번호, password를 지정해줌
3-1. RedisRepositoryConfig
클래스
Redis와의 연결 정보를 설정하고, Redis 데이터를 저장/조회하는 데 사용되는 RedisTemplate 객체를 생성하는 역할
Redis를 캐시로 사용하기 위한 설정도 함께 해줌
@Configuration
@EnableRedisRepositories // Redis를 사용한다고 명시해주는 애너테이션
public class RedisRepositoryConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Value(value = "${spring.redis.password}")
private String redisPassword;
@Autowired
private Environment environment;
// LettuceConnectionFactory 객체를 생성하여 반환하는 메서드
// 이 객체는 Redis Java 클라이언트 라이브러리인 Lettuce를 사용하여 Redis 서버와 연결해 줌
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// RedisStandaloneConfiguration를 통해 redis 접속 정보(host, port 등)를 갖고 있는 객체를 생성
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisHost);
redisStandaloneConfiguration.setPort(redisPort);
// profile이 prod(배포환경)가 맞다면, redis password 설정
Arrays.stream(environment.getActiveProfiles()).forEach(profile -> {
if (profile.equals("prod")) {
redisStandaloneConfiguration.setPassword(redisPassword);
}
});
// Redis 설정정보를 LettuceConnectionFactory에 담아서 반환
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
// Redis 작업을 수행하기 위해 RedisTemplate 객체를 생성하여 반환하는 메서드
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
// Redis를 캐시로 사용하기 위한 CacheManager 빈 생성
@Bean
public CacheManager cacheManager() {
// RedisCacheManagerBuilder를 사용하여 RedisConnectionFactory를 설정하고, RedisCacheConfiguration 구성
RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
// Redis의 Key와 Value의 직렬화 방식 설정
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.prefixCacheNameWith("cache:") // Key의 접두사로 "cache:"를 앞에 붙여 저장
.entryTtl(Duration.ofMinutes(30)); // 캐시 수명(유효기간)을 30분으로 설정
builder.cacheDefaults(configuration);
return builder.build(); // cacheDefaults를 설정하여 만든 RedisCacheManager 반환
}
}
💡 RedisConnectionFactory 인터페이스 하위 클래스에는
LettuceConnectionFactory
,JedisConnectionFactory
두 가지가 존재하는데,
⠀
성능 상 Lettuce가 Jedis에 비해 몇배 이상의 성능과 하드웨어 자원 절약이 가능하므로
우리 프로젝트에서도 Lettuce를 사용했다.
⠀
[ 참고 - Jedis 보다 Lettuce 를 쓰자 ]
RedisStandaloneConfiguration
➜ single node에 redis를 연결하기 위한 설정 정보를 가지고 있는 기본 클래스
RedisTemplate
➜ Redis 데이터를 저장하고 조회하는 기능을 하는 클래스
⠀
3-2. LocalRedisConfig
클래스
@Slf4j
@Profile("!prod") // profile이 prod(배포환경)이 아닐 경우에만 활성화
// -> 로컬 환경에서만 Embedded Redis를 사용하고, 실제 배포 환경에서는 외부 Redis 서버를 사용하기 때문
@Configuration
public class LocalRedisConfig {
@Value("${spring.redis.port}") // yml에 넣어둔 port 번호를 가져옴
private int redisPort;
private RedisServer redisServer;
// Redis 서버를 실행시키는 메서드
// 해당 클래스가 로딩될 때, startRedis() 메서드가 자동으로 실행돼서 Embedded Redis를 실행함
@PostConstruct
public void redisServer() throws IOException {
int port = isRedisRunning() ? findAvailablePort() : redisPort; // Redis 서버가 실행 중인지 확인
// 실행 중이라면 사용 가능한 다른 포트를 찾아서 port 변수에 할당하고,
// 실행 중이 아니라면 redisPort 변수의 값을 사용
// 현재 시스템이 ARM 아키텍처인지 확인
if (isArmArchitecture()) {
// ARM 아키텍처가 맞다면, RedisServer 클래스를 사용하여 Redis 서버를 생성
System.out.println("ARM Architecture");
redisServer = new RedisServer(Objects.requireNonNull(getRedisServerExecutable()), port);
// getRedisServerExecutable() - ARM 아키텍처에서 Redis Server를 실행할 때 사용할 Redis Server 실행 파일을 가져오는 메서드
// ( 가져올 파일이 없는 경우 예외를 던짐 )
} else {
// ARM 아키텍처가 아니라면, RedisServer.builder()를 사용하여 Redis 서버를 생성
redisServer = RedisServer.builder()
.port(port)
.setting("maxmemory 128M")
.build();
}
// 위에서 생성한 Redis 서버 객체를 실행
redisServer.start();
}
// PreDestroy 애너테이션으로 해당 클래스가 종료될 때 stopRedis() 메서드가 자동으로 실행되어 Embedded Redis를 종료함
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
// Embedded Redis가 현재 실행중인지 확인
private boolean isRedisRunning() throws IOException {
return isRunning(executeGrepProcessCommand(redisPort));
}
❗ Redis는 M1의 ARM 프로세서 아키텍처에서 실행되는 것을 지원하지 않음 !
⠀
Embedded Redis는 애플리이션이 실행될 때 자동으로 시작되고, 애플리케이션이 종료될 때 Redis도 종료되는데,
Redis가 ARM 프로세서 아키텍처에서 실행되지 않기 때문에 M1에서 Embedded Redis를 실행할 수 없었다.
⠀
우리 프로젝트에서 RestDocs 사용을 위해서는 Test 빌드가 필수였는데
Embedded Redis를 실행할 수 없으니 Test도 빌드가 되지 않았다.
⠀
👉 위의 if문에서 Arm 아키텍처라면,
Redis 실행파일과 port 번호를 넣은 RedisServer를 생성함으로써 문제를 해결하였다 !
⠀📌 관련 에러 내용은 아래 포스팅을 참고해주세요.
But, 여기까지 작성하면 Test 환경에서 Redis를 테스트 할 때 아래와 같은 문제가 생긴다.
❗ 전역 테이스를 할 때 각 테스트 클래스마다 Redis를 띄우게 되는데,
한 테스트 클래스의 Redis 서버가 죽기 전에 다음 테스트 클래스를 실행하려면
이전 Redis 서버가 아직 살아있어 port 충돌로 인해 테스트가 실패하게 된다.
따라서 LocalRedisConfig
에 해당 포트가 미사용 중일 때만 사용하고,
사용 중이면 그 외 다른 포트를 사용할 수 있도록 아래 설정을 추가 해주어야 한다 !
// 현재 PC/서버에서 사용가능한 포트 조회
public int findAvailablePort() throws IOException {
for (int port = 10000; port <= 65535; port++) {
Process process = executeGrepProcessCommand(port);
if (!isRunning(process)) {
return port;
}
}
throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535");
}
// 해당 port를 사용중인 프로세스 확인하는 sh 실행
private Process executeGrepProcessCommand(int port) throws IOException {
String OS = System.getProperty("os.name").toLowerCase();
System.out.println("OS: " + OS);
System.out.println(System.getProperty("os.arch"));
if (OS.contains("win")) {
log.info("OS is " + OS + " " + port);
String command = String.format("netstat -nao | find \"LISTEN\" | find \"%d\"", port);
String[] shell = {"cmd.exe", "/y", "/c", command};
return Runtime.getRuntime().exec(shell);
}
String command = String.format("netstat -nat | grep LISTEN|grep %d", port);
String[] shell = {"/bin/sh", "-c", command};
return Runtime.getRuntime().exec(shell);
}
// 해당 Process가 현재 실행중인지 확인
private boolean isRunning(Process process) {
String line;
StringBuilder pidInfo = new StringBuilder();
try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
while ((line = input.readLine()) != null) {
pidInfo.append(line);
}
} catch (Exception e) {
}
return !StringUtils.isEmpty(pidInfo.toString());
}
private boolean isArmArchitecture() {
return System.getProperty("os.arch").contains("aarch64");
}
private File getRedisServerExecutable() throws IOException {
try {
//return new ClassPathResource("binary/redis/redis-server-linux-arm64-arc").getFile();
return new File("src/main/resources/binary/redis/redis-server-linux-arm64-arc");
} catch (Exception e) {
throw new IOException("Redis Server Executable not found");
}
}
}
@Component
@RequiredArgsConstructor
public class RedisUtils {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisTemplate<String, Object> redisBlackListTemplate;
public void set(String key, Object o, int minutes) {
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public boolean delete(String key) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
public void setBlackList(String key, Object o, Long milliSeconds) {
redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
redisBlackListTemplate.opsForValue().set(key, o, milliSeconds, TimeUnit.MILLISECONDS);
}
public Object getBlackList(String key) {
return redisBlackListTemplate.opsForValue().get(key);
}
public boolean deleteBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
}
public boolean hasKeyBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
}
public void deleteAll() {
redisTemplate.delete(Objects.requireNonNull(redisTemplate.keys("*")));
}
}
Redis와 상호작용하기 편리한 메서드들은 만든 클래스
Ex. 값 설정, 값 가져오기, 값 삭제, 키의 존재 여부 확인, 모든 키 삭제 등
기본적으로 get, set, delete, hasKey만 있어도 되지만, 로그아웃을 위해서는 blacklist 관련 메서드도 생성함
❗ 단순히 Access Tocken, Refresh Tocken 제거 방식으로 구현한다면,
Access Tocken의 짧은 유효기간 사이에 이를 탈취당할 경우 로그아웃을 하였더라도 사용을 할 수 있는 문제가 발생한다 !
⠀⠀
따라서 Access Tocken을 Blacklist로 저장하여
로그아웃된 Access Tocken은 바로 만료시키는 기능을 구현하는 것이 좋다.
⠀⠀
Blacklist 등록은 RedisTemplate에다 등록하려는 Access Token, object 값, 유효시간을 넣어준 후,
Access Token을 받을때마다 Blacklist에 존재하는지 확인하면 된다.
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
redisTemplate.setValueSerializer()
➜ RedisTemplate를 사용할 때 Spring - Redis 간 데이터 직렬화, 역직렬화 시 사용하는 방식이 Jdk 직렬화 방식이므로 동작에는 문제가 없지만 redis-cli을 통해 직접 데이터를 보려고 할 때 알아볼 수 없는 형태로 출력되기 때문에 적용한 설정
new Jackson2JsonRedisSerializer<>(o.getClass())
➜ Redis에 객체를 저장할 때 직렬화해주기 위한 Serializer
( 여러 Serializer이 존재하지만 우리 프로젝트에서는 Jackson2JsonRedisSerializer를 사용하였다. )
참고 - Class Jackson2JsonRedisSerializer< T >
참고 - Spring RedisTemplate Serializer 구현체 종류
✔️ 직렬화 ( Serialize )
- Java 시스템 내부에서 사용되는 Object 또는 Data를 외부의 Java 시스템에서도 사용할 수 있도록
데이터를 Byte 형태로 변환하는 것- JVM의 메모리(힙/스택)에 상주되어있는 객체 데이터를 Byte 형태로 변환하는 것
✔️ 역직렬화 ( Deserialize )
- Byte로 변환된 Data를 원래대로 Object나 Data로 변환하는 것
- 직렬화된 Byte 형태의 데이터를 객체로 변환하여 JVM의 메모리에 상주시키는 형태
✔️ 직렬화 방법
opsForValue()
( 위 코드에서 사용 )
➜ String을 쉽게 Serialize/Deserialize 해주는 인터페이스opsForList()
➜ List를 쉽게 Serialize/Deserialize 해주는 인터페이스opsForSet()
➜ Set을 쉽게 Serialize/Deserialize 해주는 인터페이스opsForZSet()
➜ ZSet을 쉽게 Serialize/Deserialize 해주는 인터페이스opsForHash()
➜ Hash를 쉽게 Serialize/Deserialize 해주는 인터페이스
[참고] https://devlog-wjdrbs96.tistory.com/375
✔️ Access Token
➜ 접근에 관여하는 토큰
➜ 유효기간이 짧음
⠀
✔️ Refresh Token
➜ Access Tocken의 재발급에 관여하는 토큰
➜ 유효기간이 Access Tocken에 비해 긺
⠀
✔️ 간단한 토큰 기반 로그인 / 로그아웃 과정
- 사용자가 로그인하면,
서버는 사용자를 확인하고 로그인을 성공시키면서 클라이언트에게 위 두 Tocken을 동시에 발급하는데,
서버는 DB에 Refresh Tocken을 저장하고 캐시나 메모리에 Access Tocken을 저장,
클라이언트는 Access Tocken과 Refresh Tocken을 쿠키, 세션 혹은 WebStorage에 저장한 후,
보안과 관련된 요청이 있을 때 Access Tocken을 헤더에 담아 서버에 보냄
⠀
만약 로그인이 되어있는 중 보안과 관련된 요청 시에 Access Tocken이 만료되었다면,
클라이언트는 재발급을 위해 서버에 Refresh Tocken을 보내고
서버는 보내진 Refresh Tocken을 DB에 있는 것과 비교하여 일치한다면 다시 Access Tocken을 재발급함
⠀- 사용자가 로그아웃하면,
Access Tocken은 Blacklist에 추가하고
Refresh Tocken은 저장소에서 삭제하여 사용이 불가능하도록 함
새로 로그인 요청이 들어오면 서버에서 두 토큰을 다시 생성하여 클라이언트에게 보내주고
위의 과정을 다시 처음부터 거치게 됨
⠀📌 Jwt 기반 로그인에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.
5-1 logout Controller 클래스
@PostMapping("/logout")
public ResponseEntity logout(HttpServletRequest request , HttpServletResponse response){
refreshService.logout(request, response);
return ResponseEntity.ok().build();
}
➜ 로그아웃 요청을 받는 Controller
⠀
5-2 logout Service 클래스
public void logout(HttpServletRequest request, HttpServletResponse response) {
AuthToken accessToken = authTokenProvider.convertAuthToken(getAccessToken(request));
//Access Token 검증
if (!accessToken.validate()) throw new CustomLogicException(ExceptionCode.TOKEN_INVALID);
String userEmail = accessToken.getTokenClaims().getSubject();
long time = accessToken.getTokenClaims().getExpiration().getTime() - System.currentTimeMillis();
//Access Token blacklist에 등록하여 만료시키기
//해당 엑세스 토큰의 남은 유효시간을 얻음
redisUtils.setBlackList(accessToken.getToken(), userEmail, time);
//DB에 저장된 Refresh Token 제거
refreshTokenRepository.deleteById(userEmail);
}
}
➜ Controller에서 넘어와 로직 수행
➜ Access Tocke 검증 후 일치한다면, DB에 저장된 Refresh Tocken을 삭제하고 Blacklist에 Access Tocken을 등록
public class JwtVerificationFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
private final RedisUtils redisUtils;
public JwtVerificationFilter(AuthTokenProvider tokenProvider, RedisUtils redisUtils) {
this.tokenProvider = tokenProvider;
this.redisUtils = redisUtils;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String tokenStr = HeaderUtil.getAccessToken(request);
AuthToken token = tokenProvider.convertAuthToken(tokenStr);
// 로그인 요청 시 들어온 Access Tocken이 Blacklist에 들어있는 Tocken인지 확인하는 검증
// ( 로그아웃 된 토큰인지 아닌지 )
if (token.validate() && !redisUtils.hasKeyBlackList(tokenStr)) {
Authentication authentication = null;
try {
authentication = tokenProvider.getAuthentication(token);
} catch (CustomLogicException e) {
ErrorResponder.sendErrorResponse(response, HttpStatus.BAD_REQUEST);
}
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String tokenStr = HeaderUtil.getAccessToken(request);
return tokenStr == null; // (6-2)
}
}
➜ 로그인 요청이 들어왔을 때, JwtVerificationFilter
를 거쳐서 Tocken의 유효성을 검증하고,
유효한 Tocken이라면 SecurityContext에 인증 정보를 저장
여기까지 한다면 Redis를 활용한 JWT 기반 로그아웃 기능이 완성이다 !!
[ 참고한 사이트 ]
- [스프링 시큐리티] redis를 이용한 jwt 로그아웃 만들기
- [Redis] Spring Boot와 Redis 연동
- Spring Boot 에서 Redis 사용하기
이 외 사이트는 중간 중간에 적어놓았습니다.