Redis로 Refresh Token 검증 (Spring Boot)

2025. 10. 11. 17:23·Spring Boot

 

왜 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
'Spring Boot' 카테고리의 다른 글
  • 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot)
  • DDD의 페이징 로직 (Spring Boot)
  • 특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot)
  • Pre-Signed URL 도입 (Spring Boot)
kimfishes
kimfishes
kimfishes 님의 블로그 입니다.
  • kimfishes
    kimfishes 님의 블로그
    kimfishes
  • 전체
    오늘
    어제
    • 전체 (18) N
      • Infra (5)
        • AWS (0)
        • LogBack (4)
      • Spring Boot (13) N
        • LLM (4) N
      • 일상 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    LLM
    로깅
    실시간 알림 시스템
    cache stampede
    promtail
    UUID v7
    Qdrant
    pgvector
    ELK
    ollama
    loging
    Pre-Signed URL
    Redis
    traceId
    spring ai
    스프링 알림 시스템
    분산 락
    Spring boot
    Discord 알림 연동
    캐시 스탬피드
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
kimfishes
Redis로 Refresh Token 검증 (Spring Boot)
상단으로

티스토리툴바