[SpringBoot] Spring Security : JWT Auth + Redis 적용하기 (Stateful vs Stateless에 대한 고민)
이전 포스팅에서 Spring Boot에서 JWT를 이용한 로그인 인증과 인가를 구현하였다.
프로젝트 진행에 따라서 유저가 로그인 했을 때의 모바일 기기의 Firebase Token을 저장하여(휴대폰 기기를 식별해주는 토큰이라고 생각하면 됨), Notification Server를 통하여 해당 기기에 푸시 알림 요청을 날려야 하는 로직을 개발할 필요가 생겼다.
따라서 로그인 시 발급된 JWT와 함께 Firebase Token을 저장해야 한다고 생각했다. 유저가 어떤 다른 API 요청을 했을 때, Firebase 푸시알림을 보내야 한다면, Redis에서 Token을 꺼내어 푸시알림을 보낼 수 있도록 인증정보(JWT)와 FCM Token를 Redis를 저장하는 과정을 작성해보려고 한다.
이번 포스팅에서는 Spring에서의 Redis를 사용하는 방법을 포스팅하면서, 인증과 인가에 Redis를 적용시켜 보도록 하겠다.
Redis란?
Redis는 메모리 기반의 데이터 스토어이다. 이러한 특징 때문에 Redis는 데이터를 메모리에 저장하고 조회하는 데 특화되어 있으며 매우 빠른 데이터 액세스 속도를 제공한다.
- 인메모리 데이터 저장: Redis는 데이터를 주로 메모리에 저장하므로 매우 빠른 데이터 액세스 속도를 제공한다. 이는 데이터베이스나 캐시로 사용할 때 특히 유용하다.
- 다양한 데이터 구조 지원: Redis는 단순한 키-값 저장소 뿐만 아니라 문자열, 리스트, 셋, 해시, 정렬된 셋 등 다양한 데이터 구조를 지원한다.
- 지속성 지원: Redis는 데이터를 메모리에 저장하면서도 디스크에 스냅샷을 저장하거나, 변경 로그를 파일에 기록하여 데이터의 지속성을 보장할 수 있다.
- 메시징 : Redis는 메시지 브로커로 사용할 수 있는 기능을 제공하여 발행자(Publisher)와 구독자(Subscriber) 간의 메시지 교환을 지원하며, 이를 통해 이벤트 기반 프로그래밍을 할 수 있다.
- 캐싱: Redis는 자주 액세스되는 데이터를 캐시로 사용하는 데 매우 효과적이다. 데이터를 메모리에 저장하므로 데이터베이스 액세스 비용을 줄이고 성능을 향상시킬 수 있다.
의존성 추가와 어플리케이션 설정
▶ build.gradle
dependencies {
//...
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
▶ application.yaml
spring:
#...
redis:
host: localhost
port: 6379
Spring boot에서 Redis 사용을 위해서, 패키지 의존성을 추가하고, 관련 환경변수 설정을 해 준다.
환경변수에는 host와 port를 설정해주며, Redis의 기본 포트는 6379이다.
Redis는 서버 컴퓨터에 설치가 필요하며, 직접 설치하거나 Docker를 통해 설치하는 방법 등을 통해서 설치해주자.
Config 파일 작성하기
▶ config/RedisConfig.java
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;
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
- RedisConnectionFactory: Redis 데이터베이스와의 연결을 생성하는 팩토리를 설정한다.
- RedisTemplate: Redis 데이터 조작을 위한 템플릿 클래스이다. 이 빈은 Redis에 데이터를 저장하고 읽는 등의 작업에 사용된다.
RedisTemplate<byte[], byte[]>은 바이트 배열을 키와 값의 데이터 형식으로 사용하겠다는 것을 나타낸다. - setConnectionFactory(redisConnectionFactory()): RedisTemplate에 RedisConnectionFactory를 설정하여 커넥션을 제공한다
Redis 데이터 자료구조 생성하기
▶ dto/shared/firebase/FCMToken.java
import lombok.Builder;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
import org.springframework.lang.Nullable;
@Data
@RedisHash(value = "FCMToken", timeToLive = 30)
public class FCMToken {
@Id
private String jwtToken;
@Indexed
private Long memberPK;
@Indexed
@Nullable
private String fireBaseToken;
@TimeToLive
private Long ttl;
@Builder
public FCMToken(String jwtToken, Long memberPK, String fireBaseToken, Long ttl) {
this.jwtToken = jwtToken;
this.memberPK = memberPK;
this.fireBaseToken = fireBaseToken;
this.ttl = ttl;
}
}
해당 클래스는 레디스에 저장 및 입출력할 데이터 자료구조를 작성해 준 내용이다.
필자는 프로젝트에서 인증 정보에 더해서 Firebase의 Token을 함께 저장해 줄 것이다.
@RedisHash는 Spring Data Redis 프레임워크에서 사용되는 어노테이션 중 하나로, 엔터티 클래스를 Redis 해시 형식의 데이터로 매핑한다. 주로 Spring Data Redis의 객체 매핑을 사용하여 Java 객체를 Redis 데이터베이스에 저장하고 검색하는 경우에 사용된다.
해당 어노테이션 사용을 통해서 해당 클래스의 인스턴스가 Redis 해시로 매핑되며 Java 객체와 Redis 데이터베이스 간에 데이터를 변환하고 저장할 수 있다.
Redis는 기본적으로 Key-Value 형태의 구조를 기본으로 하며, @Id 어노테이션이 부여된 필드가 엔터티의 주 키(PK) 역할을 한다, 이를 통해 특정 키 값을 사용하여 Redis에서 데이터를 조회하거나 저장할 수 있다.
엔터티 클래스의 필드들은 Redis 해시의 필드-값 쌍으로 매핑되고, 각 필드는 Redis 해시 필드 이름과 매핑되며, 해당 필드의 값을 Redis 데이터베이스에 저장한다.
@Indexed 어노테이션은 @Id 필드 이외에도 다른 Key를 통해 데이터를 조회하려면 사용해야 하며, 특정 필드 값을 통해 데이터를 빠르게 검색할 수 있고, 성능을 향상시키는 역할도 한다.
Redis에 저장된 데이터들은 만료 시간(TTL)을 설정할 수 있다. TTL이 지나면, 데이터가 자동으로 메모리에서 삭제된다. 이 때문에, 메모리 기반 데이터 스토리지라는 특성과 합쳐져 Redis는 캐싱 처리나, 일회성 데이터들을 다루는 데에 용이한 면이 있다.
@TimeToLive 어노테이션을 사용하여, 해당 필드를 TTL로 사용되도록 설정할 수 있다.
Repository 클래스
▶ AuthRepositoryWithRedis.java
@Repository
public interface AuthRepositoryWithRedis extends CrudRepository<FCMToken, String> {
Optional<List<FCMToken>> findAllByfireBaseToken(String fireBaseToken);
Optional<List<FCMToken>> findAllBymemberPK(Long memberPK);
}
JPA처럼, Redis와의 데이터 엑세스를 담당하는 Repository 클래스는 CrudRepository를 상속받아서, 기본적인 메서드들을 사용할 수 있다.
또한 Key값 뿐만 아니라, 다른 필드를 통해서도 Select를 하기 위해서 위의 두가지 메서드를 오버라이딩 시켜주었다.
로그인 시 인증 정보를 Redis에 저장
@Transactional
public TokenInfoDto login(String email, String password, String firebaseToken) {
// 1. Login Email/PW 를 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
// 2. 실제 검증 (사용자 비밀번호 체크)
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenInfoDto tokenInfo = jwtTokenProvider.generateToken(authentication);
// Redis : 이미 firebaseToken이 중복해서 존재한다면, 저장된 기존 인증정보 삭제
if(firebaseToken != null && !firebaseToken.isEmpty()){
Optional<List<FCMToken>> fbTokens = authRepositoryWithRedis.findAllByfireBaseToken(firebaseToken);
fbTokens.ifPresent(authRepositoryWithRedis::deleteAll);
}
// Redis : 인증 정보 Redis에 저장
Claims claims = jwtTokenProvider.parseClaims(tokenInfo.getAccessToken()); // exp 값을 가져오기
Long exp = (Long) claims.getExpiration().getTime(); // 반환값은 밀리초 단위의 타임스탬프
Date date = new java.util.Date(exp);
LocalDateTime expLocalDateTime = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
FCMToken token = FCMToken.builder()
.jwtToken(tokenInfo.getAccessToken())
.memberPK(claims.get("userId", Long.class))
.fireBaseToken(firebaseToken)
.ttl(Duration.between(LocalDateTime.now(), expLocalDateTime).getSeconds())
.build();
FCMToken save = authRepositoryWithRedis.save(token);
return tokenInfo;
}
이전 포스팅에서 작성했던 로그인 시, 인증 정보를 확인하고, 인증이 완료되었다면 JWT 토큰을 제공하는 메서드이다.
기존에는 서버에서 인증 정보를 관리할 수 없었지만, 이제 Firebase의 FCMToken을 인증 정보와 함께 저장해야 할 필요성이 있으므로, Redis를 적용시켜서, 로그인 시 인증 정보를 서버단에서도 관리할 수 있도록 변경하였다.
FCMToken 안의 firebaseToken은 모바일 기기의 식별번호로 사용되며, 이는 중복되어서는 안 된다. 만약, 현재 인증 목록에 요청으로 모바일 기기의 식별번호로 사용되던 것이 존재한다면 삭제해주고, 새로운 인증 정보를 Insert해 줄 것이다.
Insert할 때, FCMToken의 builder()를 통해서 Redis 캐시 객체를 생성해주고, 이를 save 메서드를 통해서 저장시켜주었다.
인가(Authorization) 시 Redis에 인증 정보가 있는 지 확인하기
/**
* [JWT 토큰 복호화]
* JWT 토큰을 복호화하여 토큰에 들어있는 정보를 반환
* @param [String accessToken]
* @return [Authentication]
*/
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
System.out.println(accessToken);
// Redis : Redis에 유저정보가 없으면 Error 반환
Optional<FCMToken> authInfo = authRepositoryWithRedis.findById(accessToken);
if (authInfo.isEmpty()) {
throw new RuntimeException("서버에 인증 정보가 존재하지 않습니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
CustomUserDetail userDetails = new CustomUserDetail(
claims.getSubject(),
"",
authorities,
(String)claims.get("nickname"),
Long.valueOf((Integer)claims.get("userId")));
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
마찬가지로 이전 포스팅에서 작성했던 인가 부분에서, Redis에 내용이 존재하지 않는다면, 인가를 허용하지 않도록 작성해주었다.
이로서 기존에 인증 정보를 서버단에서 관리하지 않고, 단지 클라이언트 측에서 주는 JWT의 유효성을 검증하는 역할해서 그쳤던 서버의 역할에서 어떤 유저가 현재 인증을 거쳤으며, 해당 정보가 없다면 인가를 시켜주지 않도록 관리할 수 있게 되었다.
Redis를 사용하면 세션 인증과 유사한 측면도 있지만 세션 인증과 달리 여러 개의 서버 간의 인증 상태를 Redis에서 공유할 수 있다는 장점이 있다. 또한 메모리를 효율적으로 사용하여 자원 사용의 효율성을 높일 수 있다.
다만 원래 JWT가 널리 사용되었던 이유 중 하나는 서버단에서 State-less한 상태, 즉 상태를 유지할 필요가 없다는 점이었다. 토큰은 클라이언트에 저장되므로 인증 정보를 직접 관리하지 않고, 일관된 상태를 유지하는 데에 들이는 노력이 필요하지 않았다.
Redis를 사용하여 다음과 같이 Authorization 과정에서 메모리의 인증 정보를 검사하는 로직이 들어가게 된다면, 또 다시 해당 리소스를 어떻게 일관성을 유지하게 할 것인가에 대한 노력이 필요하게 된다.
Stateful vs Stateless
Stateful
- 상태O (클라이언트의 상태유지(엑세스 정보 유지), Request량이 줄어들음, 요청속도 빠르고 성능이 좋음, 사용자 추적 가능)
- 그러나 클라이언트의 상태를 유지하기 매우 어려움(특히 Failure 시). 대부분의 서버는 Stateless를 채택함.
Stateless
- 상태X, HTTP 프로토콜은 Stateless, Request량 증가, 그렇지만 클라이언트 상태 관리 안해도 됨.
- 서버의 Crash에 대한 Tolerant(내성)이 상대적으로 강함
- Request 처리 시 필요한 정보는 클라이언트가 제공.
실제 서버는 Stateful/Stateless 속성을 병행(Hybrid)할 수도 있다.
필자가 Firebase Token을 인증 정보와 함께 저장하기 위해서 Redis를 사용하여 인증과 인가를 구현하게 된다면 아래의 선택의 기로에 빠지게 될 것이다.
1. (Stateless) Authorization 시에만 Redis에 필요한 정보들을 저장해 두고, Authentication에서는 기존 JWT 인증 방식만 따른다.
Firebase Token이 필요할 때 Redis를 JWT를 기반으로 조회하여 얻어낸다.
- 간단하고 빠른 인증 : 인증 프로세스가 간단하며, 서버는 인증과 관련된 State를 관리하지 않아도 된다.
- JWT의 장점 활용 : JWT의 Statelessness를 유지하므로 기존의 장점(데이터 크기 작음, 클라이언트에 저장, 확장 용이)을 그대로 활용할 수 있다.
- 기기 식별의 어려움 : Stateless 방식은 서버에서 상태를 유지하지 않고 사용자 세션을 추적하지 않지 때문에, 특정 기기를 식별하고 관리하기가 어려울 수 있다. 특정 기기에서의 동시 로그아웃 처리가 어려울 수 있다.
2. (Stateful) Authorization 시 Redis에 필요한 정보들을 저장해 두고, Authentication에서도 Redis의 인증정보의 유효성을 판단
Firebase Token이 필요할 때 Redis를 JWT를 기반으로 조회하여 얻어낸다.
- 기기 식별이 쉬움 : 서버에서 상태를 유지하므로 특정 기기를 쉽게 식별하고 관리할 수 있다. 동시 로그아웃 처리 또한 용이하다.
- 서버 부하 증가 : 서버 내 Redis에서 상태를 유지하므로 부하가 증가할 수 있다.
- Stateful : Stateful한 성격은 서버 확장을 어렵게 만들고 인증 상태의 일관성을 유지하는데 노력이 필요하다.
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] DI/IoC의 개념, Bean 등록 및 의존성 주입 방법들 (3) | 2023.12.02 |
---|---|
[SpringBoot] Multipart 파일 Upload & Download (0) | 2023.09.04 |
[SpringBoot] Spring Security JWT 인증과 인가 - (3) API 인가 (Authorization) (0) | 2023.08.14 |
[SpringBoot] Spring Security JWT 인증과 인가 - (2) 로그인 인증 (Authentication) (0) | 2023.08.14 |
[SpringBoot] Spring Security JWT 인증과 인가 - (1) 회원 가입 (0) | 2023.08.14 |