빙응의 공부 블로그

[UniP]Redis를 통한 RefreshToken 관리하기 본문

Project/UniP

[UniP]Redis를 통한 RefreshToken 관리하기

빙응이 2024. 11. 6. 21:23

 

 

이번 게시글은 Redis와 RefreshToken에 대해 다루겠습니다. 

📝배경

이번에 졸업 작품 프로젝트에서 메인서버를 맡아 개발하게 되었습니다.

 

그래서 로그인을 개발하게 되어 두개의 토큰 방식(Access, Refresh)를 구현하였고 Refresh는 따로 데이터베이스에 저장하여 관리하게 되었다. 

 

여기서 데이터베이스에 Refresh Token을 관리하는 이유를 간략히 적어보자면... 

  • 보안성 강화 : 데이터베이스를 화이트 리스트로 사용하여 변조, 탈취된 Refresh Token에 대한 대응성 확보가 가능합니다.
  • 추적 및 접근 제어 : 로그아웃하거나 비정상적인 상황이 발생했을 때 데이터베이스 Refresh Token을 제거하여 추적이 가능합니다.

📝 왜 Redis인가? 

Redis를 사용하는 이유는 주로 성능편리한 데이터 관리 때문입니다. 특히 리프레시 토큰 관리나 세션 유지 등 짧은 시간 동안 유지되는 데이터빠른 접근이 필요한 데이터를 다룰 때 Redis가 많이 활용됩니다.

 

구체적인 이유는 다음과 같습니다.

  1. 빠른 속도와 낮은 지연 시간
    • Redis는 메모리 기반의 데이터 저장소로, 디스크 기반 데이터베이스보다 읽기/쓰기 속도가 훨씬 빠릅니다. 특히, 리프레시 토큰처럼 빠르게 접근해야 하는 데이터를 처리하는 데 유리합니다.
  2. TTL을 통한 자동 만료 기능
    • Redis는 각 키에 TTL(Time-To-Live)을 설정할 수 있어, 특정 시간이 지나면 자동으로 데이터가 삭제됩니다. 이를 통해 리프레시 토큰과 같은 만료가 필요한 데이터를 자동으로 관리할 수 있어 만료된 데이터를 수동으로 삭제하는 부담을 줄일 수 있습니다.
  3. 경량화된 데이터 관리
    • Redis는 간단한 데이터 구조(예: 문자열, 해시, 리스트, 집합 등)를 지원해 부담 없이 데이터를 저장하고 관리할 수 있습니다. 불필요한 복잡성을 줄이고 필요한 데이터만 저장할 수 있습니다.

 

지금 사용하는 MySQL의 경우 스케쥴러를 이용해서 매번 만료된 토큰을 삭제해줘야 합니다.

Redis는 자동으로 특정 시간이 지나면 데이터를 삭제하는 장점이 있고 매우 가벼워서 채택하게 되었습니다.

 

 

📝Redis 도입하기 

 

먼저 Redis의 의존성을 설정해주겠습니다.

	// Redis
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

 

이제 Redis를 사용하기 위해 설정을 해주도록 하겠습니다. 

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Refresh> redisTemplate() {
        RedisTemplate<String, Refresh> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // Key를 String으로 직렬화
        redisTemplate.setKeySerializer(new StringRedisSerializer());

        // Value를 JSON 형식으로 직렬화
        Jackson2JsonRedisSerializer<Refresh> serializer = new Jackson2JsonRedisSerializer<>(Refresh.class);
        redisTemplate.setValueSerializer(serializer);

        return redisTemplate;
    }
}

 

저는 Refresh로 memberId와 Token만 저장할 생각으로 위처럼 세팅하게 되었습니다.

Redis의 직렬화는 저장할 데이터를 정의하는 아주 중요한 코드입니다. 저 세팅을 통해 다양한 타입을 지원합니다. 

 

 

이제 RefreshToken을 저장할 Entity를 정의하겠습니다. 

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "refresh", timeToLive = 86400)
public class Refresh {

    @Id
    private Long id;

    private String token;

}

키는 id이며 TTL(제한 시간)은 24시간으로 설정해주었습니다. 

 

 

Spring에서 Redis에 저장하는 방법은 2가지 방법이 있습니다.
RedisTemplate
 Spring Data Redis Repository

 

RedisTemplate은 EntityManager와 비슷하고

Spring Data Redis Repository는 Spring Data JPA Repository와 비슷합니다. 

 

RedisTemplate은 더 다양한 데이터 구조와 커스텀 설정이 필요한 경우에 적합하며

Spring Data Redis Repository의 경우 CRUD 환경에서 사용하기 편합니다. 

 

저희는 간단한 저장, 삭제, 조회만 할 생각이기에 Spring Data Redis Repository를 사용하겠습니다. 

@Repository
public interface RefreshRepository extends CrudRepository<Refresh, Long> {
}

 

 

우리는 Refresh Token을 저장해야하는 것이기 때문에 해당 상황에서 사용됩니다.

  • 로그인 성공으로 Refresh Token이 발급되었을 때
  • Refresh Token으로 Access Token을 재요청할 때
  • 로그아웃을 진행했을 때

 

로그인 성공으로 Refresh Token이 발급되었을 때 
    // Refresh 객체를 Redis에 추가
    @Transactional
    public void addRefresh(Long id, String token) {
        Refresh refresh = Refresh.builder()
            .id(id)
            .token(token)
            .build();
        refreshRepository.save(refresh);
    }

 

로그인이 성공했을 때 memberId와 token을 통해 Redis에 저장해주었습니다.

해당 메소드는 아래 코드에서 사용합니다

public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshService refreshService;
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        AuthMember authUser = (AuthMember) authentication.getPrincipal();

        String accessToken = jwtTokenProvider.createAccessToken(authUser.getId(), authUser.getRoles());
        String refreshToken = jwtTokenProvider.createRefreshToken(authUser.getId(), authUser.getRoles());

		// Redis에 Refresh 토큰 추가
        refreshService.addRefresh(authUser.getId(), refreshToken);
        
        Map<String, String> token = ResponseUtil.createTokenMap(accessToken, refreshToken, authUser.isAuth());
        ResponseUtil.writeJsonResponse(response, token);
    }
}

 

 

사실 끝난게 아니라 1가지 설정을 더해줘야 합니다. 

 

Refresh Rotate

Refresh Rotate는 AccessToken을 요청하거나 재로그인 시에 데이터베이스에 남아있는 Refresh를 갱신하는 것을 말합니다. 해당 방식의 장점은

  • Refresh 토큰 교체로 보안성 강화
    • 전에 탈취 당해도 새로 바뀌기 때문에 보안성 강화
  • 로그인 지속 시간이 길어져 사용감 향상

그렇기에 저장 메소드 전에 기존 Refresh Token을 삭제해주었습니다.

    // Refresh 객체를 Redis에 추가
    @Transactional
    public void addRefresh(Long id, String token) {
        deleteById(id);
        Refresh refresh = Refresh.builder()
            .id(id)
            .token(token)
            .build();
        refreshRepository.save(refresh);
    }

 

Refresh Token으로 Access Token을 재요청할 때
    // Refresh Token 신규 발급
    public Map<String, String> refreshAccessToken(String refreshToken) {

        // 토큰 유효성 검사
        jwtTokenProvider.validateToken(refreshToken);

        // Redis에 저장된 리프레시 토큰과 비교
        Member member = jwtTokenProvider.getMember(refreshToken);
        Refresh refresh = refreshRepository.findById(member.getId())
            .orElseThrow(() -> new CustomException(OAuthErrorCode.INVALID_REFRESH_TOKEN));

        if (!refresh.getToken().equals(refreshToken)) {
            throw new CustomException(OAuthErrorCode.INVALID_REFRESH_TOKEN);
        }


        // 새 토큰 생성 및 갱신
        String newAccess = jwtTokenProvider.createAccessToken(member.getId(), member.getRoles());
        String newRefresh = jwtTokenProvider.createRefreshToken(member.getId(), member.getRoles());
		
        // Refresh Rotate
        addRefresh(member.getId(), newRefresh);

        return ResponseUtil.createTokenMap(newAccess, newRefresh, member.isAuth());
    }

해당처럼 해주었습니다. addRefresh 메소드를 재사용해서 만들었습니다.

 

 

로그아웃을 진행했을 때

 

JWT 방식에서 로그아웃 기능을 통해 추가적인 JWT 탈취 시간을 줄일 수 있습니다.

  • 로그아웃 버튼 클릭시
    • 프론트엔드 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버 측 로그아웃 경로로 Refresh 토큰 전송
    • 백엔드 : 로그아웃 로직을 추가하여 Refresh DB에서 해당 Refresh 삭제

해당 방법을 통해 어느정도 JWT 탈취에 대한 면역력을 기를 수 있습니다. 

    @PostMapping("/logout")
    @Operation(summary = "로그아웃", description = "로그아웃을 위해 저장소의 Refresh Token 삭제")
    public ResponseEntity<?> logoutDeleteToken(@AuthenticationPrincipal AuthMember authMember) {
        refreshService.deleteById(authMember.getId());
        return ResponseEntity.ok(ResponseDto.of("로그아웃 성공",null));
    }

 

 

 

 

📝 테스트

Redis를 전체 조회한 결과입니다. 

 

    // 모든 Refresh 객체 조회
    public List<Refresh> get() {
        List<Refresh> refreshList = new ArrayList<>();
        refreshRepository.findAll().forEach(refreshList::add); // 모든 Refresh 객체 추가
        return refreshList;
    }

 

재발급도 성공적으로 구성했습니다.

 

 


Redis를 이용해서 Refresh 토큰을 저장하여 데이터베이스 경량화 + 유효 기간 자동 관리를 구현하였습니다.

 

위와 같은 경험을 통해서  Refresh Token을 효율적으로 관리하는 방법을 구현하게 되었습니다. JWT 기반 인증 방식에서 Refresh Token을 Redis에 저장함으로써, 데이터베이스의 부담을 줄이고 토큰의 만료 관리도 자동화할 수 있었습니다.

 

주요 장점은 다음과 같습니다:

  1. 성능 향상: Redis는 메모리 기반으로 동작하기 때문에 디스크 기반의 데이터베이스보다 빠르게 데이터에 접근할 수 있습니다. 이를 통해 토큰 관리에 걸리는 시간과 서버 부하를 최소화할 수 있었습니다.
  2. TTL(자동 만료): Redis의 TTL 기능을 사용하여 만료된 Refresh Token을 자동으로 삭제할 수 있었으며, 별도로 스케줄러나 관리 프로세스를 두지 않아도 되어 유지보수가 용이해졌습니다.
  3. 보안성 강화: Refresh Token의 관리 위치를 Redis로 변경하여 보안을 강화했습니다. 예를 들어, Refresh Token이 탈취되거나 유출되었을 경우, 데이터베이스에서 해당 토큰을 삭제하거나 갱신할 수 있어 보안상 유리합니다. 또한, Refresh Rotate 기법을 통해 사용자가 토큰을 재요청할 때마다 기존의 토큰을 갱신하여 보안을 한층 강화했습니다.
  4. 간편한 관리: Spring Data Redis Repository를 활용해 CRUD 작업을 간편하게 처리할 수 있었으며, 불필요한 복잡성을 줄이고 필요한 데이터만 효율적으로 관리할 수 있었습니다.
  5. 로그아웃 시 Refresh Token 삭제: 로그아웃 시 Refresh Token을 삭제함으로써, 클라이언트가 서버 측에서 Refresh Token을 무효화할 수 있게 되어 탈취된 토큰에 대한 보안 위험을 최소화할 수 있었습니다.

이와 같이 Redis를 활용한 Refresh Token 관리 방식은 보안성뿐만 아니라 성능에서도 매우 효과적인 해결책이었습니다. 앞으로도 이러한 토큰 관리 방식을 적극적으로 적용하여, 더욱 효율적이고 안전한 시스템을 구축할 수 있을 것입니다.