왜 Refresh Token을 사용하는가
- JWT Access Token의 경우 토큰 자체에 인증 정보를 담고 있어 유출 위험이 있으므로, 5 ~ 15분 등 짧은 유효 기간을 가짐
- AccessToken만 사용하면 짧은 유효 시간에 따라 자동 로그아웃되어 재로그인을 자주 해야 하는 불편함 발생
- 이러한 문제를 해결하기 위해 Access Token이 만료될 시 Refresh Token을 이용해 Access Token을 재발급
- Refresh Token은 며칠 ~ 일주일 등 긴 유효 기간을 가지므로 사용자는 로그인 상태를 오래 유지 가능해 편의성을 얻게 된다.
예시 : 사용자는 앱을 껐다 켜도 백그라운드에서 토큰이 자동 갱신되어 재로그인 화면이 뜨지 않음
구현한 동작 방식
- 첫 로그인 성공 시 AccessToken은 Header, RefreshToken은 HTTP-Only 쿠키에 보관
- AccessToken 시간 만료 시 서버에 Token 재요청을 보내게 된다. 이때 RefreshToken이 만료되지 않아야 하며 유효하다면 새 토큰을 발급해 주게 됨
- 사용자가 재발급을 요청했을 때 RefreshToken이 만료 된 경우 Access Token, Refresh Token 모두 삭제하고 로그인 페이지로 이동하게 하여 재로그인 유도
Redis를 활용한 리팩토링
- 기존 RefreshToken이 유효한지 아닌지 서버가 가진 RefreshToken으로 검증하는 방식에서 검증 시간을 줄이고 속도를 높이기 위해 Redis 활용
- 바로 서버가 가진 Token을 확인하지 않고 Redis에 있는 RefreshToken을 통해 검증하게 되며, Redis가 동작하지 않을 시에 서버에 있는 RefreshToken을 통해 검증하게 된다. (fallback)
- 인메모리 방식인 Redis를 사용하면 속도가 빠르다는 장점이 있지만, Redis 장애를 대비하는 로직 필요
왜 자주 사용하는 AccessToken을 Redis에 사용하지 않고 RefreshToken만 Redis에 저장하는 방식을 썼는지
- JWT는 서명(secret key)을 포함하고 있어 AccessToken은 stateless, 즉 자체적으로 검증할 수 있으므로 서버는 유효기간, 서명만 확인해도 됨
- 또한, AccessToken은 탈취가 되더라도 짧은 시간만 악용 가능
- RefreshToken의 경우 재발급 시 서버에서도 검증하는 용도로 사용되며, "사용자가 실제 로그인 상태인가?" 확인
- 로그아웃 시 RefreshToken 삭제 (Redis에서 삭제)
- 탈취당했을 경우 서버에서 강제로 만료 처리 가능
- AccessToken를 Redis와 서버에 저장 후 이미 발급된 AccessToken을 막아 강제 로그아웃 시키는 블랙리스트 방식으로 사용하는 경우도 존재
코드
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
RedisConfig
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.password}")
private String password;
@Bean
@Primary
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setPassword(password);
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(2)) // 명령 하나가 완료되길 기다리는 최대 시간 (2초)
.clientOptions(ClientOptions.builder()
.autoReconnect(true) // 연결이 끊겼을 때 자동 복구 시도
.build()).build();
return new LettuceConnectionFactory(config, clientConfig);
}
@Bean
@Primary
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// String 직렬화
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
RefreshTokenRedis
- RedisTemplate을 바로 사용하지 않고 Refresh Token 전용 저장소 계층을 따로 만들어 사용
@Repository
public class RefreshTokenRedis {
private final RedisTemplate<String,String> redisTemplate;
private static final String REFRESH_TOKEN_KEY = "refresh_token";
public RefreshTokenRedis(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void saveToken(String userId, String refreshToken, Duration duration) {
redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY + userId, refreshToken, duration);
}
public String getToken(String userId) {
return redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY + userId);
}
public void deleteToken(String userId) {
redisTemplate.delete(REFRESH_TOKEN_KEY + userId);
}
public boolean findToken (String userId) {
return redisTemplate.hasKey(REFRESH_TOKEN_KEY + userId);
}
public void saveToken(String userId, RefreshToken refreshToken, Duration duration) {
redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY+userId, refreshToken.getRefreshToken(), duration);
}
}
UserService
- signup() - 이메일 중복 검증 후, 비밀번호를 암호화하여 새로운 회원을 DB에 저장
- login() - 이메일·비밀번호 검증 후 AccessToken & RefreshToken 발급 및 Redis·DB에 저장, 쿠키로 RT 전달
- reissue() - 쿠키의 RefreshToken 검증 후 AccessToken 및 RefreshToken 재발급 (회전 처리)
- RefreshToken이 만료 된 경우 재로그인 유도
- logout() - Redis 및 DB의 토큰 삭제 후 로그아웃 처리
- deleteUser() - 비밀번호·이메일 검증 후 사용자 계정 및 관련 토큰 완전 삭제
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRedis refreshTokenRedis;
//private final BlackListRepository blackListRepository;
private final UserContextService userContextService;
private final RefreshTokenRepository refreshTokenRepository;
//암호화 후 db에 회원가입 정보 저장
//BaseResponse로 지정한 내용에 http 상태 코드를 수정 후 다시 ResponseEntity로 감싸서 보냄
@Transactional
public void signup(SingUpRequest dto) {
if (userRepository.findByEmail(dto.email()).isPresent()) {
throw new BusinessException(EMAIL_ALREADY_EXISTS); //409
}
User user = UserMapper.toUser(dto, passwordEncoder);
userRepository.save(user);
}
//로그인
@Transactional
public void login(LoginRequest dto, HttpServletResponse response) {
User user = userRepository.findByEmail(dto.email())
.filter(u -> passwordEncoder.matches(dto.password(), u.getPassword()))
.orElseThrow(() -> new BusinessException(LOGIN_USER_NOT_FOUND));
String key = String.valueOf(user.getId());
//Redis & DB에 refreshToken 삭제
deleteTokenRedis(key);
refreshTokenRepository.deleteByUser(user);
String accessToken = jwtTokenProvider.issueAccessToken(user.getId(), user.getRole(), user.getEmail());
String refreshToken = jwtTokenProvider.issueRefreshToken(user.getId(), user.getRole(), user.getEmail());
refreshTokenRepository.save(RefreshToken.builder().refreshToken(refreshToken).user(user).build());
Duration ttlTime = ttlTime(refreshToken);
saveTokenRedis(key, refreshToken, ttlTime);
// http only 쿠키 방식으로 refresh Token을 클라이언트에게 줌
response.setHeader("Authorization", "Bearer " + accessToken);
CookieUtils.setRefreshTokenCookie(response, refreshToken, ttlTime);
}
// AccessToken 만료 시 RefreshToken으로 AccesssToken을 재발급하는 코드
@Transactional
public void reissue(HttpServletRequest request, HttpServletResponse response, long userId) {
// 쿠키에서 refreshToken 추출
String refreshToken = userContextService.extractRefreshTokenFromCookie(request);
if (refreshToken == null || refreshToken.isBlank()) throw new BusinessException(INVALID_REFRESH_TOKEN);
// 토큰 유효성 확인 및 정보 추출 (사용자에 대한 권한이 아닌 토큰에 대한 유효성만 검사를 하므로 밑 부분처럼 추가 검사들이 필요합니다.)
if (!jwtTokenProvider.validate(refreshToken)) {throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); } //401
//요청을 한 사람이 기존에 회원가입이 되어 있는 사용자가 맞는지 검사
if (userRepository.findById(userId).isEmpty()) {throw new BusinessException(ErrorCode.LOGIN_USER_NOT_FOUND); } //404 반환//
//해당 user에 대한 정보가 있다면 User 객체로 가져옴
User user = userRepository.findById(userId).get();
//Redis, DB에 토큰 확인
String serverToken = getTokenRedis(String.valueOf(user.getId()));
Optional<RefreshToken> dbToken;
if (serverToken == null ) {
dbToken = refreshTokenRepository.findRefreshTokenByUser(user);
if (dbToken.isEmpty()) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); //401 //401 반환
}
serverToken = dbToken.get().getRefreshToken();
}
//위에서 가져온 user 토큰 정보와 클라이언트가 요청으로 가져온 refreshToken이 같은지 다른지 확인해 위조 가능성을 체크
if (serverToken == null || !serverToken.equals(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
}
//Redis & DB에 refreshToken 삭제
deleteTokenRedis(String.valueOf(user.getId()));
refreshTokenRepository.deleteByUser(user);
/*RefreshToken이 유효하지만 accessToken 재발급 용도로 사용 후
RefreshToken이 노출되었을 수 있기 때문에, 사용 후에는 새로운 것으로 갱신하는 것이 안전하다 */
String newAccessToken = jwtTokenProvider.issueAccessToken(user.getId(), user.getRole(), user.getEmail());
//새로 발급한 refreshToken Redis에 다시 저장
String newRefreshToken = jwtTokenProvider.issueRefreshToken(user.getId(), user.getRole(), user.getEmail());
Duration ttlTime = ttlTime(newRefreshToken);
saveTokenRedis(String.valueOf(user.getId()), newRefreshToken, ttlTime);
refreshTokenRepository.save(RefreshToken.builder().refreshToken(newRefreshToken).user(user).build());
response.setHeader("Authorization", "Bearer " + newAccessToken);
CookieUtils.setRefreshTokenCookie(response, newRefreshToken, ttlTime);
}
// 만료 = Redis의 TTL로 자연스럽게 만료됨
@Transactional
public void logout(String accessToken, HttpServletRequest request, HttpServletResponse response, long userId) {
User user = userRepository.findById(userId).orElseThrow(() -> new BusinessException(LOGIN_USER_NOT_FOUND));
//redis에 먼저 있는지 확인
String serverToken = getTokenRedis(String.valueOf(user.getId()));
Optional<RefreshToken> dbToken = refreshTokenRepository.findRefreshTokenByUser(user);
if (serverToken == null && dbToken.isPresent()) {
serverToken = dbToken.get().getRefreshToken();
if (serverToken == null) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); //401 //401 반환
}
}
String refreshTokenFromCookie = userContextService.extractRefreshTokenFromCookie(request);
if (serverToken == null || !serverToken.equals(refreshTokenFromCookie)) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
}
//Redis & DB refreshToken 삭제
deleteTokenRedis(String.valueOf(user.getId()));
dbToken.ifPresent(refreshTokenRepository::delete);
// 쿠키 삭제 처리
CookieUtils.deleteRefreshTokenCookie(response);
}
@Transactional
public void deleteUser(String accessToken, DeleteUserRequest deleteUserRequest
,HttpServletRequest request, HttpServletResponse response, long userId) {
//토큰 구조 먼저 확인
if (accessToken.startsWith("Bearer ")) {
accessToken = accessToken.substring(7);
}
if (!jwtTokenProvider.validate(accessToken)) {throw new BusinessException(ErrorCode.INVALID_ACCESS_TOKEN);}
// 1. 유저 정보 추출
User user = userRepository.findById(userId).orElseThrow(() -> new BusinessException(LOGIN_USER_NOT_FOUND));
// 2. DB에서 유저 조회
User tokenUser = userRepository.findById(userId).orElseThrow(() -> new BusinessException(USER_NOT_FOUND));
//이메일 비교
if (!deleteUserRequest.email().equals(tokenUser.getEmail())) {throw new BusinessException(ErrorCode.LOGIN_USER_NOT_FOUND); } // 404
//redis에 먼저 있는지 확인
String serverToken = getTokenRedis(String.valueOf(user.getId()));
Optional<RefreshToken> dbToken = refreshTokenRepository.findRefreshTokenByUser(user);
if (serverToken == null && dbToken.isPresent()) {
serverToken = dbToken.get().getRefreshToken();
if (serverToken == null) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); //401 반환
}
}
String refreshToken = userContextService.extractRefreshTokenFromCookie(request);
if (serverToken == null || !serverToken.equals(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
}
//비밀번호 비교
if (!passwordEncoder.matches(deleteUserRequest.password(), tokenUser.getPassword())) {
throw new BusinessException(ErrorCode.LOGIN_USER_NOT_FOUND); //404
}
//refresh token DB에서 삭제
deleteTokenRedis(String.valueOf(user.getId()));
dbToken.ifPresent(refreshTokenRepository::delete);
//유저 삭제
userRepository.findById(userId).ifPresent(userRepository::delete);
// 쿠키 삭제 처리
CookieUtils.deleteRefreshTokenCookie(response);
}
public String getTokenRedis(String userId) {
try {
return refreshTokenRedis.getToken(userId);
} catch (Exception e) {
log.warn("Redis GET 실패 (DB 조회), userId={}, 에러 코드 DB: {}", userId, e.getMessage());
return null;
}
}
public void saveTokenRedis(String userId, String token, Duration ttl) {
try {
refreshTokenRedis.saveToken(userId, token, ttl);
} catch (Exception e) {
log.warn("Redis SAVE 실패 (DB 저장), userId={}, 에러 코드 {}", userId, e.getMessage());
}
}
public void deleteTokenRedis(String userId) {
try {
refreshTokenRedis.deleteToken(userId);
} catch (Exception e) {
log.warn("Redis DELETE 실패, userId={}, 에러 코드 {}", userId, e.getMessage());
}
}
private Duration ttlTime(String jwt) {
long millis = jwtTokenProvider.getExpiration(jwt).getTime() - System.currentTimeMillis();
return millis <= 0 ? Duration.ZERO : Duration.ofMillis(millis);
}
//user id값으로 user 객체 반환
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
//가져온 객체가 없으면 에러, 있으면 user 반환
public User getById(Long id) {
return findById(id).orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_USER_NOT_FOUND));
}
// user 객체를 UserDetail로 변환
public UserDetail getDetails(Long id) {
User findUser = getById(id);
return UserDetail.UserDetailsMake(findUser);
}
public void userMoreInfo(String accessToken, UserInfoRequest userInfoRequest, HttpServletResponse response)
throws IOException {
//토큰 구조 먼저 확인
if (accessToken.startsWith("Bearer ")) {
accessToken = accessToken.substring(7);
}
if (!jwtTokenProvider.validate(accessToken)) {throw new BusinessException(ErrorCode.INVALID_ACCESS_TOKEN);}
TokenBody tokenBody = jwtTokenProvider.parseJwt(accessToken);
// 유저 정보 추출
User user = userRepository.findById(tokenBody.getUserId()).orElseThrow(() -> new BusinessException(LOGIN_USER_NOT_FOUND));
user.updateRole(Role.USER);
user.updatePhoneNumber(userInfoRequest.phoneNumber());
response.sendRedirect("/extra-info");
}
}
Redis의 진짜 가치가 뭔지
1. 세션성 데이터의 저장소 분리
- User 같은 영속 데이터 → DB
- RefreshToken 같은 세션 데이터 → Redis
세션 데이터 -> 사용자가 로그인해서 서비스와 “연결된 상태”를 유지하기 위해 서버가 잠시 들고 있는 데이터
2. TTL 자동 관리
- DB는 “만료됐지만 남아 있는 데이터”가 계속 쌓이기 쉬운데 Redis는 TTL로 자연스럽게 사라짐
Redis가 잘 맞는 상황
- refresh token을 세션 데이터로 분리하고 싶다
- TTL 자동 만료가 필요하다
- 로그아웃/재발급 처리를 빠르게 하고 싶다
- Redis를 이미 다른 곳에도 쓰고 있다
'Spring Boot' 카테고리의 다른 글
| 🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결 (1) | 2026.01.19 |
|---|---|
| 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot) (0) | 2025.12.06 |
| DDD의 페이징 로직 (Spring Boot) (0) | 2025.12.02 |
| 특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot) (0) | 2025.11.17 |
| Pre-Signed URL 도입 (Spring Boot) (0) | 2025.08.31 |