Spring Security - Refresh Token을 Redis에 저장하여 Access Token 재발급기능 구현하기

TopOfTheHead·2026년 1월 28일

Spring OAuth

목록 보기
12/12
post-thumbnail

전체 과정

클라이언트 요청
  ↓
Access Token 만료
  ↓
/auth/refresh 요청 (Refresh Token 전달)
  ↓
Refresh Token 검증
  ├─ 서명/만료 검증
  ├─ Redis 저장 여부 확인
  ├─ 사용자 일치 여부 확인
  ↓
새 AT + (선택) 새 RefreshToken 발급
  ↓
Redis RefreshToken 갱신

클라이언트로그인JWT Token을 발급하는 경우 서버RedisRefresh Token를 저장
Access Token 재발급 end point : /auth/refresh

Access Token프론트엔드 단에서 Axios API를 통해 인증 헤더만료된 토큰을 교환하도록 로직을 작성.

  • 서버에서 Refresh TokenRedis에 저장하는 이유
    NoSQLKey - Value 자료구조로서 인메모리 DB 방식으로 빠르게 접근

    인메모리 DB 서버이므로 브라우저에 비해 서버 사이드에 있으므로 탈취가능성이 낮음

    Refresh Token비밀번호처럼 영구히 남는 데이터가 아니므로 인메모리 DB를 통해 설사 데이터만료되더라도 큰 문제가 발생하지 않음.

사전 설정

  • redis 설치
docker run --name redis-container -d -p 6379:6379 redis
  • Spring 설정
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:4.4.0'
custom:
  redis:
    host: ${redis.host:localhost}
    port: ${redis.port:6379}
  datasource:
    hikari:
      maximum-pool-size: 100

spring: datasource: hikari: maximum-pool-sizeAuto Configuration를 통해 생성 및 Spring Bean으로 등록될 DataSourceConnection Pool 내 최대 준비DB Connection 수를 지정
▶ 약 100개의 스레드로 통한 DB 트랜잭션을 각각 수행하므로 100으로 지정

spring: data: redisDB Connection 정보RedisConfiguration에서 RedisProperties를 통해 의존성주입되어 URI 설정 시 활용됨
환경변수로 값 설정

@Getter
@Setter
@ConfigurationProperties(prefix = "custom.redis")
public class RedisProperties {
    private String host;
    private String port;
}
  • RedisConfiguration file 생성
    RedissonClient를 활용하기위해 Spring Bean으로 등록

    RedissonClient에 설정될 URI 정보는 RedisProperties 객체를 통해 의존성 주입application.ymlspring: data: redis에서 작성한 host, port 정보를 읽어와서 URI로 작성 후 입력
    redis://localhost:6379
    RedisConnectionFactory 도 동일

    。각각 다른 Redis DB를 사용하도록 설정
@Configuration
@RequiredArgsConstructor
public class RedisConfiguration {
	// application.yml 에서 redis 관련 설정정보 가져오는 객체
	private final RedisProperties redisProperties;
	// RedissonClient 를 Spring Bean으로 등록
	// 이후 분산락에서 사용
	@Bean
	public RedissonClient redisClient() {
		Config config = new Config();
		// URI 설정
		String uri = String.format("redis://%s:%s", redisProperties.getHost(), redisProperties.getPort());
		config.useSingleServer().setAddress(uri);
		return Redisson.create(config);
	}
	// RedisConnectionFactory 를 return
	@Bean
    @Primary
    public RedisConnectionFactory authRedisConnectionFactory() {
        RedisStandaloneConfiguration config =
                new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());
        config.setDatabase(1); // 인증 토큰용
        return new LettuceConnectionFactory(config);
    }
    @Bean
    public RedisConnectionFactory cacheRedisConnectionFactory() {
        RedisStandaloneConfiguration config =
                new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());
        config.setDatabase(2); // 캐시용
        return new LettuceConnectionFactory(config);
    }
    @Bean
	@Qualifier("cache")
    public RedisTemplate<String,String> cacheRedisTemplate(){
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(cacheRedisConnectionFactory());
        return redisTemplate;
    }
}

인증 토큰 용 Redis1번 DB, 캐싱 용 Redis2번 DB를 사용하도록 설정

Redis 캐싱을 사용하는 경우 Redis에서 각각 다른 DB( 0 ~ 15 )를 사용하도록 구성
@RedisHash@Primary가 선언된 RedisConnectionFactory 객체를 우선적으로 주입되어 해당 RedisConnectionFactory에 설정된 DB에서 수행

Redis발급Refresh Token 저장할 Entity를 선언
。선언된 Entity Class 구조대로 Redis에 저장됨.

import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.annotation.Id;
@RedisHash(value = "refreshToken", timeToLive = 604800)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Builder
public class RefreshToken {
    @Id
    private String refreshToken;
    private String email;
    public RefreshToken(String refreshToken, String email){
        this.refreshToken = refreshToken;
        this.email = email;
    }
}
  • @RedisHash(value = "KeyNamespace명", timeToLive = TTL(Sec))
    Redis Lettuce를사용하여 Redis에 저장할 Entity임을 지시하는 어노테이션

    value = "KeyNamespace명" 속성 : Redis에서 Key namespace가 설정

  • Redis에서 저장되는 경우
    value = "resfreshToken", @Id 필드 : abc123 , email 필드 : wjdtn747@naver.com인 경우
// key
refreshToken:abc123 :
// value
{
    "@Id필드명":"abc123",
    "email":"wjdtn747@naver.com"
}

▶ 다음 형식으로 저장됨.

Refresh Token 저장 시 key : {키명}:{RefreshToken} / value : { "refreshToken" : "토큰값" } 형식으로 저장
▶ 이후 RefreshToken를 기반으로 refreshTokenRepository.findById(refreshToken)를 통해 해당 Redis DBKey Namespace 내부에 value : { refreshToken:{토큰값} , ... }을 가진 Key가 있는지 확인 후 화이트리스트 방식을 수행

@RedisHash(timeToLive = 604800)를 지정하여 Refresh TokenTTL을 설정
▶ 해당 refreshTokenKey명으로 Entity를 통해 저장 시 7일수명으로 설정

  • @Id
    。선언된 필드Redis Key 값으로 설정됨
    null인 경우 랜덤값으로 설정

    @Id 필드RefreshToken로 설정
    ▶ 이후 재발급 시 클라이언트에서 전송한 쿠키에 포함된 RefreshToken를 통해 해당 RefreshToken을 찾아야하므로.

    JPA@Id가 아닌 import org.springframework.data.annotation.Id;@Id를 선언해야한다.

    @RedisHash(value = "keynamespace")@Id Long id필드값"id"인 경우
    Redis 자료구조에서는 keynamespace:id키값으로 저장

RefreshToken Repository 생성
CrudRepository<>를 확장하여 생성
JpaRepository<>를 선언 시 JPARedis 가 동시에 해당 Entity를 참조하려 하면서 오류 발생하므로

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    default RefreshToken findByIdOrThrow(String refreshToken){
        return findById(refreshToken).orElseThrow(
                ()-> new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED)
        );
    }
}

클라이언트에서 전송한 RefreshToken을 통해 @ID 필드를 기반으로 Redis에서 저장된 RefreshToken을 찾는 메서드 작성
RefreshToken 클래스@Id 필드 = RefreshToken인 이유

JWT 발급을 하는 단계에서 다음 RefreshToken Repository를 통해 Redis에 저장하는 코드 추가

private String issueRefreshToken(
            String email
    ){
        String issuedRefreshToken = Jwts.builder()
                .subject(jwtProperties.getPayload().getSubjectRefreshToken())
                .claim("email", email)
                .issuedAt(new Date())
                .expiration(new Date(new Date().getTime() + jwtProperties.getValidations().getRefresh()))
                .signWith(getSecretKey())
                .compact();
        refreshTokenRepository.save(
                RefreshToken.builder()
                        .refreshToken(issuedRefreshToken)
                        .email(email)
                        .build()
        );
        return issuedRefreshToken;
    }

。이후 로그인하여 JWT 토큰 생성 및 Redis에서 조회 시 다음처럼 저장됨을 확인가능.


refreshToken:wjdtn747@naver.com :
refreshToken : @RedisHash(value="refreshToken")접두사
wjdtn747@naver.com : RefreshToken 클래스에서 @Id 필드에 해당

Refresh TokenString이 아니므로 출력되지 않음.
HGETALL 키명 입력 시 출력

ACCESS TOKEN 재발급
클라이언트만료Access Token을 전송 시 401 UnAthorize를 반환하며, 이에 대응하여 클라이언트/api/auth/refresh를 호출

JWT 로그인 로직 작성에서 구현한 로직에 따라 Cookie에 포함된 Refresh Token을 가져오는 로직 사용

  • 서비스 레이어에서 Refresh Token을 추출하는 메서드 작성
    Cookie에서 찾은 RefreshToken를 기반으로 Redis DB에 저장된 RefreshToken을 검색 후 존재 시 Access Token을 재발급 후 컨트롤러로 반환
    화이트리스트 방식
public KeyPair refreshToken(String refreshToken){
        // NPE 검증
        PreConditions.validate(
                Strings.isNotBlank(refreshToken),
                ErrorCode.TOKEN_NOT_FOUND
        );
		//
        // 토큰 유효성 검증
        tokenProvider.validate(refreshToken);
		//
        // 토큰 화이트리스트 검증
        RefreshToken foundedRT = refreshTokenRepository.findById(refreshToken)
                .orElseThrow(
                        () -> new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED)
                );
		//
        // 토큰 재발급
        refreshTokenRepository.delete(foundedRT);
        User foundedUser = userRepository.findByEmailOrThrow(foundedRT.getEmail());
        return tokenProvider.issueKeyPair(
                foundedUser.getEmail(),
                foundedUser.getRole()
        );
    }
  • 컨트롤러 레이어에서 HttpRequest를 받아서 서비스레이어에서 생성한 Access Token을 반환하는 로직 작성
    HttpRequest에서 Cookie를 추출하는 메서드 작성
    JWT 로그인 로직에서 설정한 Cookie명을 활용하여 해당 Refresh Token을 찾는다.
	private final AuthService authService;
    private final JwtProperties jwtProperties;
    private static final String REFRESH_TOKEN_HEADER_NAME = "RT";
    @PostMapping("/refresh")
    public ResponseEntity<ApiResult<IssueTokenPairResponse>> refresh(
            HttpServletRequest request,
            HttpServletResponse response
    ){
        String refreshToken = resolveRefreshToken(request);
        KeyPair keyPair = authService.reissueToken(refreshToken);
        Cookie cookie = new Cookie(REFRESH_TOKEN_HEADER_NAME, keyPair.refreshToken());
        cookie.setMaxAge(jwtProperties.getValidations().getRefresh().intValue() / 1000);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        response.addCookie(cookie);
        return ApiResult.data(
                SuccessCode.TOKEN_RE_ISSUE_COMPLETE,
                new IssueTokenPairResponse(
                        keyPair.accessToken()
                )
        );
    }
    private String resolveRefreshToken(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        if ( cookies.length != 0 ){
            for(Cookie cookie : cookies){
                if(REFRESH_TOKEN_HEADER_NAME.equals(cookie.getName())){
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

클라이언트Access Token이 만료되더라도 브라우저에서 RefreshToken을 가진 유효한 Cookie를 포함하는 경우, 해당 API를 호출하여 AccessToken을 발급받을 수 있다.

  • JWT 필터에서 해당 /api/auth/refresh를 거치지않도록 작성하기
	@Override
	protected void doFilterInternal(
		HttpServletRequest request,
		HttpServletResponse response,
		FilterChain chain
	) throws ServletException, IOException{
		String uri = request.getRequestURI();
		//
		if (uri.startsWith("/api/auth/")) {
			chain.doFilter(request, response);
			return;
		}


▶ 이후 RefreshToken을 포함한 쿠키를 기반으로 Access Token이 새로 생성됨을 확인 가능

profile
공부기록 블로그

0개의 댓글