본문 바로가기
개발

[Spring] Redis 써서 JWT 토큰 캐시에 저장하기

by 주주병 2024. 11. 24.
728x90
반응형

캐시란?

클라이언트에게 필요한 정보를 주기 위해서 매번 메모리에 접근을 한다던가 혹은 서버에 접근을 해서 주면 리소스 낭비가 커집니다.

 

그렇기에 우리는 미리 메모리, DB의 데이터를 캐시라는 저장 공간에 저장해 놓은 다음에 클라이언트의 요구에 따라 해당 정보가 캐시에 있으면 캐시에서 꺼내서 주고 없는 경우에는 해당 정보를 클라이언트에게 제공한 뒤 캐시에 저장해 놓는 로직을 이용합니다.

 

Redis 세팅

Homebrew를 통해 Redis 설치

brew install redis

저는 brew로 패키지를 관리하게 brew에 redis를 설치해 줬습니다.

 

Redis 설치 확인

redis-server --version

 

Redis 백그라운드 실행 및 Redis client 접속

brew services start redis

# 접속 후 아래 명령어 입력
redis-cli

 

spring-boot-starter-data-redis 의존성 추가

Gradle에 redis dependency를 추가해 줍니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

 

Properties 추가

properties에 아래의 내용을 추가해 줍니다.

저는 테스트로 localhost를 사용하기 때문에 localhost를 사용하고 port는 brew 포트인 6379를 설정해 줬습니다.

# Redis Connection Settings
spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

 

 

RedisConfig 설정

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());

        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)) // 캐시 유효 시간 설정 (10분)
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues();

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(cacheConfig)
                .build();
    }
}

 

우리는 캐시를 사용할 때, 직접 개발자가 캐시에 넣어주는 방식과 스프링에서 캐싱을 해주는 두 가지 방식이 있습니다.

 

[직접 넣기]

 

위 코드에서 redisTemplate를 보시면 파라미터에 RedisConnectionFactory를 주입 받습니다.

이 Factory는 Redis와의 연결을 관리합니다. RedisTemplate은 Redis에서 데이터를 읽고 쓰는 데 필요한 도구입니다.

 

  • Key Serializer
    • StringRedisSerializer: Redis 키를 문자열로 직렬화합니다.
    • Redis는 모든 데이터를 바이트 배열로 처리하므로, 키와 값을 저장할 때 직렬화 방식이 필요합니다.
  • Value Serializer
    • GenericJackson2JsonRedisSerializer: 값을 JSON 형식으로 직렬화합니다.
    • 이 설정을 통해 Java 객체를 JSON으로 변환하여 Redis에 저장하고, 다시 읽을 때는 JSON을 Java 객체로 변환합니다.

 

 

[스프링이 자동으로 해주기]

 

스프링이 자동으로 캐시를 관리를 하게 만들어 주려면 캐시 매니저를 CacheManager의 설정을 해줘야 합니다.

RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)) // 캐시 유효 시간 설정 (10분)
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues();

 

따라서 이렇게 config를 설정하고

 

return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(cacheConfig)
                .build();

 

이렇게 캐시 매니저를 만들어 줘야 합니다.

그리고 제일 중요한 redisConfig를 보시면 제가 @EnableCaching를 달아놓을 걸 볼 수 있습니다.

 

  • @EnableCaching Spring 애플리케이션에서 캐싱 기능을 사용할 수 있도록 Spring Cache Abstraction을 활성화합니다.
  • 이를 통해 @Cacheable, @CacheEvict, @CachePut, @Caching 등의 캐싱 관련 어노테이션을 사용할 수 있습니다.

 

@EnableCaching

 

 

@EnableCaching이 없으면 다음과 같은 문제가 발생합니다:

 

  1. 캐싱 어노테이션이 작동하지 않음
    • @Cacheable, @CacheEvict 등의 어노테이션은 동작하지 않습니다.
    • 메서드를 호출할 때 항상 원래 로직이 실행되고, 캐시가 적용되지 않습니다.
  2. 캐시를 위한 CacheManager가 동작하지 않음
    • Spring은 캐싱을 활성화하기 위해 내부적으로 CacheManager를 필요로 합니다.
    • @EnableCaching이 없으면 CacheManager가 초기화되지 않아 캐시 동작이 실행되지 않습니다.

여기까지 오신 거면 이제 캐시에 관한 설정은 다 했습니다.

가장 중요한 과정이 남았습니다.

 

 

리프레쉬 토큰을 수동으로 캐시에 넣기

package autotradingAuthenticate.autotrading.board.jwt.service;

import autotradingAuthenticate.autotrading.board.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    private final RedisTemplate<String, String> redisTemplate;
    private final long REFRESH_TOKEN_EXPIRATION = 1000 * 60 * 60 * 24 * 7; // 7일

    private final JwtUtil jwtUtil;

    // 리프레시 토큰 생성 및 저장
    public String generateAndStoreRefreshToken(String username) {
        String refreshToken = jwtUtil.createRefreshToken(username);
        redisTemplate.opsForValue().set("refreshToken:" + username, refreshToken, REFRESH_TOKEN_EXPIRATION, TimeUnit.MILLISECONDS);
        return refreshToken;
    }

    // 리프레시 토큰 가져오기
    public String getRefreshToken(String username) {
        return redisTemplate.opsForValue().get("refreshToken:" + username);
    }

    // 리프레시 토큰 유효성 검증
//    public Boolean validateRefreshToken(String token) {
//        return jwtUtil.validateRefreshToken(token);
//    }

    // 리프레시 토큰 삭제
    public void deleteRefreshToken(String username) {
        redisTemplate.delete("refreshToken:" + username);
    }
}

 

저의 코드를 보시는 거와 같이 리프레쉬 토큰을 관리하는 service를 따로 만들었습니다.

보통 jwt를 관리할 때 리프레쉬 토큰, 액세스 토큰 두 개를 만들어서 응답을 해줍니다.

그때 리프레쉬 토큰만 캐시에 담아두면 어떠한 이득이 있을까요?

 

리프레시 토큰은 액세스 토큰보다 민감한 정보를 포함하거나, 더 긴 수명을 가지기 때문에 보안 관리가 중요합니다.

 

이를 캐시에 저장하면 다음과 같은 이점이 있습니다:

  • 서버에서 관리:
    • 리프레시 토큰을 캐시(예: Redis)에 저장하면 서버 측에서 이를 중앙에서 관리할 수 있습니다.
    • 클라이언트에서 리프레시 토큰을 도난당하더라도 서버의 저장소에서 해당 토큰을 쉽게 무효화할 수 있습니다.
  • 단기간 저장:
    • 캐시는 주로 메모리에 저장되므로 데이터를 빠르게 액세스 할 수 있는 동시에, TTL(Time-To-Live)을 설정하여 필요 이상으로 오랫동안 저장하지 않도록 할 수 있습니다.
  • 사용 후 제거:
    • 리프레시 토큰은 한 번 사용되면 무효화해야 합니다. 캐시에서는 사용된 토큰을 삭제하기 쉽습니다.
    public String generateAndStoreRefreshToken(String username) {
        String refreshToken = jwtUtil.createRefreshToken(username);
        redisTemplate.opsForValue().set("refreshToken:" + username, refreshToken, REFRESH_TOKEN_EXPIRATION, TimeUnit.MILLISECONDS);
        return refreshToken;
    }

 

위 코드처럼 리프레쉬 토큰을 캐시에 담을 수 있습니다.

redis client에서 확인해 보시면 위 사진처럼 캐시에 저장된 걸 확인할 수 있습니다.

 

스프링의 @caching 활용

Spring의 Caching 기능은 AOP(Aspect-Oriented Programming) 기반으로 동작하며, 주로 캐시 추상화를 통해 애플리케이션에서 캐싱 로직을 간결하고 쉽게 구현할 수 있도록 도와줍니다.

 

@Cacheable, @CacheEvict, @CachePut, @Caching 등의 어노테이션을 사용하여 캐싱을 제어합니다.

@Caching은 위에서 한 것처럼 config 파일에 넣어줘도 되고 아래처럼 실행 클래스에 넣어줘도 됩니다.

package autotradingAuthenticate.autotrading;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.web.config.EnableSpringDataWebSupport;

@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class AutotradingApplication {

	public static void main(String[] args) {
		SpringApplication.run(AutotradingApplication.class, args);
	}

}

 

Spring Caching 주요 어노테이션

Spring의 캐싱 어노테이션은 다음과 같습니다.

 

@Cacheable

  • 캐시를 조회하고, 데이터가 없으면 메서드를 실행하여 결과를 캐시에 저장.
  • 주로 읽기 작업에 사용.

@CacheEvict

  • 캐시를 삭제하는 데 사용.
  • 주로 쓰기 작업(데이터 추가, 수정, 삭제 후 캐시 무효화)에 사용.

@CachePut

  • 메서드 실행 결과를 강제로 캐시에 저장.
  • 캐시 데이터를 갱신하거나 업데이트하는 데 사용.

@Caching

  • 여러 캐싱 작업을 조합하여 수행할 때 사용.
  • 예: 캐시에 저장하면서 특정 캐시를 삭제.

@CacheConfig

  • 클래스 수준에서 공통 캐시 설정을 지정.

 

문제

저는 spring security를 바탕으로 로그인을 구현했기에 @Cacheable를 이용해서 userDetails에 대한 정보를 캐시에 넣어보고 싶었습니다.

 

근데 실패했습니다. 로그인에 실패하는 현상이 발생했습니다. 그래서 결국은 수동으로 넣는 방식으로 해결했네요.

@Cacheable 사용 시 Security Context와 같이 사용할 경우, 동기화 문제가 발생하는 것 같습니다.

 

내가 생각한 문제:

  • @Cacheable로 반환된 객체는 단순히 캐시 데이터이며, 이를 Spring Security 컨텍스트에 수동으로 연결하지 않는 한 인증 흐름에서 일관성이 보장되지 않습니다.
  • 캐시가 반환하는 데이터는 스레드 독립적이지 않기 때문에 ThreadLocal 기반 SecurityContext와 충돌합니다.

이 두 가지의 이유가 아닐까? 생각을 했었는데 아니었습니다.

 

 

첫 번째 문제

package autotradingAuthenticate.autotrading.board.member.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomUserDetails implements UserDetails, Serializable {

    private String username;

    private String password;

    @Builder.Default
    private List<String> roles = Collections.emptyList();

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                .map(role -> (GrantedAuthority) () -> role)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return true;
    }
}

 

이 코드에서 원래는 getPassword위에 아래의 애노테이션이 달려 있었습니다.

@JsonIgnore

 

이게 문제였습니다.. 이게 달려 있어서 비밀번호가 캐시에서 가져올 때 null로 넘어왔던 겁니다.

 

두 번째 문제

package autotradingAuthenticate.autotrading.board.member.service;


import autotradingAuthenticate.autotrading.board.member.dto.CustomUserDetails;
import autotradingAuthenticate.autotrading.board.member.entity.Member;
import autotradingAuthenticate.autotrading.board.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

import java.util.List;
@Service
@RequiredArgsConstructor
public class CachedUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

//    @Override
//    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//        // 1. 캐시에서 조회
//        CustomUserDetails cachedUser = getCachedUserDetails(username);
//        if (cachedUser != null) {
//            return cachedUser;
//        }
//
//        // 2. DB에서 사용자 조회
//        Member member = memberRepository.findByUsername(username)
//                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
//
//        // 3. CustomUserDetails 생성
//        CustomUserDetails userDetails = CustomUserDetails.builder()
//                .username(member.getUsername())
//                .password(member.getPassword())
//                .roles(List.of(member.getRole().name()))
//                .build();
//
//        // 4. 캐시에 저장
//        cacheUserDetails(username, userDetails);
//
//        return userDetails;
//    }
//
//    private CustomUserDetails getCachedUserDetails(String username) {
//        return cacheManager.getCache("userDetailsCache") != null ?
//                cacheManager.getCache("userDetailsCache").get(username, CustomUserDetails.class) : null;
//    }
//
//    private void cacheUserDetails(String username, CustomUserDetails userDetails) {
//        if (cacheManager.getCache("userDetailsCache") != null) {
//            cacheManager.getCache("userDetailsCache").put(username, userDetails);
//
//            CustomUserDetails cachedData = cacheManager.getCache("userDetailsCache").get(username, CustomUserDetails.class);
//            if (cachedData != null) {
//                System.out.println("Cached data retrieved: " + cachedData.getPassword());
//            } else {
//                System.out.printpackage autotradingAuthenticate.autotrading.board.member.service;
//
//

    @Cacheable(value = "userDetailsCache", key = "#p0")
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 캐시 로직
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return CustomUserDetails.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(List.of(member.getRole().name()))
                .build();
    }
}

 

위에 주석이 처리된 부분은 수동으로 캐시에 넣어주는 코드입니다. 

아래의 주석이 아닌 코드는 @Cacheable를 사용해서 스프링이 자동으로 캐시에 넣어주는 코드입니다. @Cacheable가 저는 작동이 안 됐을까요?

 

바로 아래의 코드에서 key = "username"으로 기존에 썼는데 이걸 인식을 못했던 것입니다.

@Cacheable(value = "userDetailsCache", key = "#p0")

 

이렇게 #p0로 대체해주니 바로 적용이 됐습니다.

이 코드의 의미는 매개변수로 넘어오는 파라미터 중 첫 번째 파라미터를 키 값으로 사용한다는 의미입니다.

이것 때문에 1주를 고생했습니다.

728x90
반응형