[재능교환소] 프로젝트를 진행하면서 겪었던 이슈 상황들을 정리한 블로그 포스팅 목록 중, 첫 번째 게시물에서는 JWT를 이용해 AccessToken과 RefreshToken을 발급받아 회원가입 및 로그인 로직을 완성했었다.
AccessToken은 Http 헤더에 담아 인증 로직을 구현하였고, RefreshToken은 쿠키에 담았다.
AccessToken이 만료되면 쿠키에 담긴 RefreshToken이 RDB에 있는 RefreshToken과 일치하는지 확인한 후, 유효기간이 남아있다면 AccessToken을 재발급 받았다.
이렇게 기존에는 RDB에 RefreshToken을 저장했지만, 이를 Redis로 옮겨 저장한 과정을 포스팅하려고 한다.
RDB 대신 Redis에 RefreshToken을 저장하는 이유는 다음과 같다.
Redis는 메모리 기반의 데이터 저장소로, 데이터 접근 속도가 매우 빠르다.
RefreshToken과 같은 빈번한 읽기/쓰기가 발생하는 데이터는 빠른 응답 속도가 중요하기 때문에, 디스크 기반의 RDB보다 메모리 기반의 Redis가 더 적합하다.
Redis는 분산 처리가 용이하여 수평적 확장이 가능하다.
대규모 트래픽을 처리해야 하는 시스템에서는 Redis를 사용하여 RefreshToken 저장소를 확장함으로써 더 많은 요청을 효율적으로 처리할 수 있다.
Redis는 TTL(Time-To-Live) 기능을 통해 각 키의 유효기간을 쉽게 설정하고 관리할 수 있다.
RefreshToken의 유효기간을 설정하고, 자동으로 만료되도록 하여 만료된 토큰을 별도로 삭제하지 않아도 된다.
RefreshToken은 주로 단순한 키-값 구조를 가지며, 이러한 구조는 Redis의 특성과 잘 맞아떨어진다.
복잡한 관계형 데이터보다는 단순한 구조의 데이터를 저장하고 관리하기에 적합하다.
Redis는 주로 캐시로 사용되지만, RefreshToken과 같은 세션 데이터를 저장하는 데도 효과적이다.
이렇게 하면 RDB에 대한 부하를 줄이고, 트래픽을 Redis로 분산시켜 전체 시스템의 성능을 향상시킬 수 있다.
이러한 이유들로 인해, RefreshToken을 RDB 대신 Redis에 저장하는 것이 더 효율적이고 적합한 선택이라고 생각했다.
들어가기에 앞서 직전 포스팅인 [재능교환소] 조회수 중복 방지 (Redis, 쿠키) 에서 레디스를 썼다.
이때 썼던 레디스는 RedisTemplate
방식을 사용하여 구현하였다.
RedisTemplate
와 RedisRepository
가 어떻게 다른지 알아보자.
RedisTemplate은 Redis의 다양한 데이터 구조에 대해 세밀한 제어를 제공하는 Spring의 핵심 클래스이다.
이를 사용하면 Redis에서 데이터를 읽고 쓰는 작업을 프로그래밍 방식으로 수행할 수 있다.
다음은 예시 코드이다.
RedisTemplateService
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class RedisTemplateService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void save(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
public Object find(String key) {
return redisTemplate.opsForValue().get(key);
}
public void delete(String key) {
redisTemplate.delete(key);
}
}
RedisTemplateService는 RedisTemplate을 사용하여 Redis에 데이터를 저장하고, 읽고, 삭제하는 방법을 보여준다.
opsForValue()는 문자열 값을 처리하기 위한 작업을 제공한다.
RedisRepository는 Spring Data Redis에서 제공하는 인터페이스로, Redis에서 저장소를 쉽게 생성할 수 있도록 한다.
JPA와 유사하게 리포지토리 인터페이스를 정의하고, 기본적인 CRUD 작업을 자동으로 제공받을 수 있다.
다음은 예시코드이다.
User
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@RedisHash("User")
public class User {
@Id
private String id;
private String name;
// getters and setters
}
UserRepository
@Repository
public interface UserRepository extends CrudRepository<User, String> {
}
UserService
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public void saveUser(User user) {
userRepository.save(user);
}
public User findUserById(String id) {
return userRepository.findById(id).orElse(null);
}
public void deleteUser(String id) {
userRepository.deleteById(id);
}
}
User는 Redis에 저장될 엔티티이다.
UserRepository는 CrudRepository를 확장하여 기본적인 CRUD 기능을 제공한다.
UserService는 이 리포지토리를 사용하여 사용자 데이터를 저장, 조회, 삭제하는 방법을 보여준다.
RedisTemplate
Redis와의 상호작용에 대해 세밀한 제어를 제공한다.
Redis의 다양한 데이터 구조 (예: 해시, 리스트, 세트 등)를 직접 다룰 수 있다.
RedisRepository
고수준의 추상화를 제공하여, 엔티티 기반의 저장소 접근을 가능하게 한다.
RedisRepository는 간단한 CRUD 작업이 쉽게 수행할 수 있다.
JPA 스타일의 저장소 접근 방식에 익숙한 개발자에게 편리하다.
레디스 설치 및 설정은 이전 포스팅에서 알아보았으니 이는 생략한다.
먼저 RefrestToken
을 만들기 위한 엔티티를 생성하자.
package place.skillexchange.backend.user.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@RedisHash(value = "refresh", timeToLive = 180) //(실제 배포 환경에선 2주로 설정)
public class Refresh {
@Id
private String userId;
@Indexed
private String refreshToken;
}
여기서 주의할 점은 @Id 어노테이션이다.
java.persistence.id가 아닌 org.springframework.data.annotation.Id 를 import해야 함을 잊지말자.
@RedisHash의 value 값(redis keyspace)에 특정한 값을 넣어줌으로써 추후 해당 데이터에 대한 key가 생성될 때 prefix를 지정할 수 있다.
@TimeToLive 어노테이션을 통해 TTL을 적용할 수 있다.
현재 실제 배포환경이 아닌 개발 환경임으로 TTL을 2주가 아닌 3분으로 구현하였다.
@Id 어노테이션을 통해 prefix:구분자 형태(keyspace:@id)로 데이터에 대한 키를 저장하여 각 데이터를 구분할 수 있다.
@Indexed 어노테이션을 통해 secondary indexes를 적용할 수 있다.
@Indexed는 특정 필드를 인덱싱하여 검색 성능을 향상시키는 데 사용된다.
이는 검색 작업이 빈번한 필드에 적용하는 것이 유리하다.
해당 엔티티에선 @Id를 통해 객체를 고유하게 식별하고, @Indexed를 통해 특정 필드로 효율적인 검색을 수행할 수 있다.
package place.skillexchange.backend.user.repository;
import org.springframework.data.repository.CrudRepository;
import place.skillexchange.backend.user.entity.Refresh;
public interface RefreshRepository extends CrudRepository<Refresh, String> {
Refresh findByRefreshToken(String refreshToken);
}
CrudRepository를 상속받아 Repository를 interface 형태로 만든다.
RefreshToken이 필요한 때를 정리해보면 다음과 같다.
- 로그인 할 때 AccessToken과 RefreshToken을 발급 받는다.
Spring Security 필터 체인에서 UsernamePasswordAuthenticationFilter 필터 전에 수행되는 AuthFilterService 필터 부분에서 RefreshToken 정보가 필요하다.
AuthFilterService 필터는 로그인한 유저만 접근할 수 있는 엔드포인트로 요청이 들어왔을 때 중요한 역할을 한다.
이 필터는 요청을 처리하는 과정에서 AccessToken의 유효성을 검사하고, 만약 AccessToken이 만료되었다면, 쿠키에 담긴 RefreshToken의 일치 여부를 확인한 후 AccessToken을 재발급한다.
먼저 로그인할 때 RefreshToken 발급 과정을 살펴보자.
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final UserRepository userRepository;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final RefreshRepository refreshRepository;
...(중략)
/**
* 로그인
*/
@Override
public UserDto.SignUpInResponse login(UserDto.SignInRequest dto, HttpServletRequest request,
HttpServletResponse response) {
try {
//authenticationManager가 authenticate() = 인증한다.
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getId(),
dto.getPassword()
)
);
} catch (AuthenticationException ex) {
// 잘못된 아이디 패스워드 입력으로 인한 예외 처리
throw UserIdLoginException.EXCEPTION;
}
//유저의 아이디 및 계정활성화 유무를 가지고 유저 객체 조회
User user = userRepository.findByIdAndActiveIsTrue(dto.getId());
if (user == null) {
throw UserIdLoginException.EXCEPTION;
}
//accessToken 생성
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
//RefreshToken 생성 (이미 있어도 덮어쓰기 가능)
Refresh redis = Refresh.builder()
.refreshToken(UUID.randomUUID().toString())
.userId(user.getId())
.build();
refreshRepository.save(redis);
Cookie cookie = new Cookie("refreshToken", redis.getRefreshToken());
cookie.setHttpOnly(true);
response.addCookie(cookie);
return new UserDto.SignUpInResponse(user, 200, "로그인 성공!");
}
...
}
불필요한 로직은 생략하였다.
여기서 확인할 부분은 RefreshToken 생성, 응답으로 보낼 때 Cookie 추가 부분이다.
//RefreshToken 생성 (이미 있어도 덮어쓰기 가능)
Refresh redis = Refresh.builder()
.refreshToken(UUID.randomUUID().toString())
.userId(user.getId())
.build();
refreshRepository.save(redis);
Cookie cookie = new Cookie("refreshToken", redis.getRefreshToken());
cookie.setHttpOnly(true);
response.addCookie(cookie);
Refresh 엔티티에 @Id는 uerId에 선언하고, refreshToken은 @Indexed을 선언했었다.
그렇다면 로그인 요청을 보내고, 성공한다면 RefreshToken은 어떤 형태로 Redis에 저장될까?
TTL을 180, 즉 3분으로 설정했음으로 3분 뒤엔 1),2),4)가 삭제되어야 한다.
그런데 이게 무슨 일인가!
삭제가 되긴 했는데 "refresh:alswl3359"
만 삭제되고, 나머지는 그대로이다.
원인은 @Indexed 기능이 Redis에서 자체적으로 지원하는 기능이 아니라, Spring Data Redis에서 제공하는 추가 기능이기 때문이다.
따라서 Redis의 TTL이 만료된 데이터를 자동으로 삭제하는 기능에, Spring Data Redis의 @Indexed로 생성된 보조 인덱스까지 삭제하는 과정이 포함되어있지 않아 삭제가 되지 않는 것이다.
Redis의 Key Space Notifications 기능을 활용하면, TTL이 만료되는 시점에 이벤트를 감지하고, 보조인덱스를 삭제할 수 있다.
RedisConfig
@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfig {
...
}
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
를 Redis Config 부분에 명시해 주면 Redis의 Key Space Notifications 기능이 활성화된다.
Spring Data Redis는 이 Key Space Notifications 기능을 사용하여 TTL 만료 이벤트를 감지하고, 이를 통해 @Indexed로 지정된 보조 인덱스를 함께 삭제된다.
- enableKeyspaceEvents 옵션이 ON_STARTUP으로 설정되면, Spring Data Redis는 애플리케이션을 시작할 때 Redis의 Key Space Notifications 기능을 활성화시킨다.
- 이후, Redis에 저장된 데이터의 TTL이 만료되면, Redis는 해당 데이터를 삭제하고 이에 관한 알림을 발생시킨다.
- Spring Data Redis는 이 알림을 감지하고, 해당 데이터와 연결된 보조 인덱스(즉, @Indexed가 붙은 객체)를 자동으로 삭제한다.
설정 후 다시 실행해보면 정상적으로 보조 인덱스 값들도 삭제가 되는 것을 확인할 수 있다.
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthFilterService extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
private final RefreshRepository refreshRepository;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
//Authorization 이름을 가진 헤더의 값을 꺼내옴
final String authHeader = request.getHeader("Authorization");
String jwt;
//authHeader가 null이고, Bearer로 시작하지 않다면 체인 내의 다음 필터를 호출
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
return;
}
// authHeader의 `Bearer `를 제외한 문자열 jwt에 담은
jwt = authHeader.substring(7);
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//accessToken이 만료되었다면
if (jwtService.isTokenExpired(jwt)) {
//쿠키의 refreshToken과 db에 저장된 refreshToken의 만료일을 확인하고 accessToken 재발급 / 만료되면 재로그인 exception
handleExpiredToken(request, response);
} else {
//accessToken이 만료되지 않았다면 유효한지 검증
authenticateUser(jwt, request, response);
}
}
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
}
private void handleExpiredToken(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String refreshToken = extractRefreshTokenFromCookie(request);
if (refreshToken != null) {
Refresh refresh = refreshRepository.findByRefreshToken(refreshToken);
if (refresh != null) {
User user = userRepository.findWithAuthoritiesById(refresh.getUserId()).orElseThrow(() -> UserNotFoundException.EXCEPTION);
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
user.getId(),
"",
true,
true,
true,
true,
user.getAuthorities()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
private String extractRefreshTokenFromCookie(HttpServletRequest request) {
// 쿠키에서 refreshToken 가져오기
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private void authenticateUser(String jwt, HttpServletRequest request, HttpServletResponse response) {
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
jwtService.extractUsername(jwt),
"",
true,
true,
true,
true,
jwtService.getAuthorities(jwt)
);
//UsernamePasswordAuthenticationToken 대상을 생성 (사용자이름,암호(=null로 설정),권한)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//헤더에 accessToken 유효하므로 동일하게 설정
response.setHeader("Authorization", "Bearer " + jwt);
}
}
위 코드에서 확인 할 부분은 handleExpiredToken()
메서드 부분이다.
해당 메서드는 AccessToken의 유효성을 검사하고, 만약 AccessToken이 만료되었다면, 쿠키에 담긴 RefreshToken의 일치 여부를 확인한 후 AccessToken을 재발급해주는 역할을 한다.
private void handleExpiredToken(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String refreshToken = extractRefreshTokenFromCookie(request);
if (refreshToken != null) {
Refresh refresh = refreshRepository.findByRefreshToken(refreshToken);
if (refresh != null) {
User user = userRepository.findWithAuthoritiesById(refresh.getUserId()).orElseThrow(() -> UserNotFoundException.EXCEPTION);
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
user.getId(),
"",
true,
true,
true,
true,
user.getAuthorities()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
위에서 RefreshRepository를 만들면서 findByRefreshToken() 메서드를 만들었다.
이 부분은 refreshToken을 매개변수로 받아 refreshRepository에서 Refresh 엔티티를 조회하고, 해당 Refresh가 존재하면 userId로 User 엔티티를 찾아 새로운 accessToken을 생성하는 로직이다.
RDB에서 실행되었던 부분을 Redis로 변경, 실행이 잘 되는 것을 확인할 수 있었다.
조회수 중복 방지 로직을 구현할 때 RedisTemplate을 사용했고, RefreshToken 저장소로 Redis를 활용하기 위해 RedisRepository 방식을 사용하였다. 이를 통해 다양한 방식으로 Redis를 활용해볼 수 있었다.
Redis를 사용함으로써 세션 관리 및 인증 토큰 처리의 효율성이 크게 향상되었다.
또한, Redis의 간편한 확장성과 관리 용이성 덕분에 시스템 유지보수 작업도 더 수월해졌다.
이번 경험을 통해 Redis의 다양한 활용 가능성을 실감할 수 있었고, 향후 프로젝트에서도 Redis를 적극적으로 활용할 계획이다 👩💻
Redis) 싱글벙글 Refresh Token을 Redis에 저장하고 사용해보자