Redis에 대한 간단한 CS지식과
Redis 설치 및 CLI 사용법을 알아보았었다.

오늘은 이제 나의 사용 목적인 프로젝트에 도입을 해보려한다.
JWT Refresh Token관리와 좋아요,조회수 등
빈번히 update가 일어나는 항목에 대해서 관리하는 차원에서
캐시서버인 Redis를 사용해보려한다.

그 중에서도 Redis 사용에 대한 코드와
JWT 토큰을 Redis에 어떻게 관리하고 재발급받는지에 대해
초점을 두고 작성했기에 많은 코드들이 생략되어 있다는점 참고 부탁드린다.

✅ 작업환경

Spring Boot 2.7.7
java 11 
macOS Ventura 13.1
로컬 환경에서 테스트 진행


📌 Redis 프로그램 작성

Spring Boot에서는 Spring Data Redis를 이용해
Lettuce, Jedis라는 두가지 오픈소스 Java 라이브러리를 사용할 수 있다고 한다.
Lettuce는 별도의 설정이 필요없고, Jedis는 의존성을 추가해줘야한다.

Gradle 의존성

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

Spring data Redis는 두가지 접근 방식을 제공하는데
하나는 Redis Template, 하나는 RedisRepository를 이용한 방식이다.
이번에 사용해본 방법은 Redis Template 방식이고

RedisConnectionFactory 인터페이스를 통해
LettuceConnectionFactory 생성하여 반환하는 Bean을 등록해야한다.

1). RedisConfig 클래스

@Configuration
public class RedisConfig {

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

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

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

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

LettuceConnectionFactory를 생성자를 만들어 반환할때
host, port 정보가 필요하다.

spring:
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379

로컬환경에서 테스트를 할 것이기 때문에
yml파일에 설정해주고 @Value 어노테이션으로 파싱하도록 했다.

그리고 setKeySerializer,setValueSerializer와 같은
RedisTemplete객체에 설정을 해주는 이유는

RedisTemplate를 사용할 때 Spring - Redis 간 데이터
직렬화,역직렬화 시 사용하는 방식이 JDK 직렬화 방식이기 때문에
문제는 없지만 redis-cli를 통해 접근할때 알아볼 수 없는 형태로 출력되기 때문에
설정을 진행 해준 것이다!


2). RedisDao 클래스

@Component
public class RedisDao {

    private final RedisTemplate<String, Object> redisTemplate;

    public RedisDao(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void setValues(String key, String data) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public String getValues(String key) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        return (String) values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }

    public void expireValues(String key, int timeout){
        redisTemplate.expire(key, timeout , TimeUnit.MILLISECONDS);
    }

    public void setHashOps(String key, Map<String,String> data) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.putAll(key, data);
    }

    public String getHashOps(String key, String hashKey) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        return values.hasKey(key, hashKey) ? (String)redisTemplate.opsForHash().get(key, hashKey) : new String();
    }

    public void deleteHashOps(String key, String hashKey) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.delete(key, hashKey);
    }

    public boolean validateValue(String value){
        if(value == null){
            return false;
        }
        return true;
    }
}

두번째로는 RedisDao라는 클래스를 만들어
스프링 컨테이너에 빈으로 등록해
DI 받아 사용하도록 클래스를 하나 만들어 주었다.

안에 메서드 내용들을 보면
기본적인 Key,Value를 설정하는 방법, 그리고 Get하여 가져오기
또한 소멸 시간설정(TTL)을 하여 Key를 만들기

Hash 자료구조로 Key,Value 만들기 정도 코드를 추가해두었다.
JWT사용할땐 기본적 Key,Value로 진행할 예정이고
다른 프로젝트에서 Hash 구조를 사용하기때문에 우선 추가해줬다.


📌 Redis를 통한 Refresh Token 관리

Redis를 이용한 JWT 활용

이러한 방법이 정말 최선일지?
확실하진 않다… 여러가지 레퍼런스 블로그들을 보면서
이번에 프로젝트를 하면서 적용해본 아키텍처를 그려보았다.

image

순서를 살펴 보자면

1). 로그인시 JWT 토큰을 발급 받는다.
-> AccessToken, RefreshToken을 발급 받음.


2). 발급받은 토큰을 Client에 전달 한다.
-> Access Token은 Header에 Authorization에 담는다.
-> Refresh Token은 쿠키에 담아 보내준다. 전달하기 전에 서버에서는 Refresh Token을 Redis 캐시서버에 Key/Value
형태로 소멸시간을 지정해 저장한다. (나는 2일 정도로 해두었다)

❗️생각해볼 만한 내용들
여기서 생각해보면 좋은 점이. Refresh Token을 클라이언트에 보내는 것이
정말 최신인가? 라는 생각이 든다. 이부분에 대해 고민을 많이해봤고 많이 찾아보았지만
결론적으로 내가 내린 생각은 해당 Refresh Token을 클라이언트에서 서버로
보내주지 않으면 Access Token으로만 재발급이 이루어지게되는데
Access Token을 탈취당할 경우 Redis에 있는 Refresh Token으로 재발급이 가능해지니
위험해 질 것 같다는 생각에 이르럿고, 취약점이 존재하지만 최소한 쿠키로 담아 보내
HttpOnly 옵션으로 XSS(Cross-Site Scripting) 공격을 막을 수기에 보내주기로 결정했다.


3). API 요청할때 인증을 위한 Access Token을 담아 요청한다.
-> 정상적으로 인증이 완료되면 해당 API를 수행한다.
-> 만약 Access Token이 만료되었다면? 서버에서는 만료되었다고
클라이언트측에 알려준다. (나와 같은경우는 토큰이 만료되었다고 JSON을 응답해줌)


4). 만료응답을 받은 클라이언트는 Access Token 재발행 API를 요청한다.
-> Access Token과 Refresh Token을 헤더에 담아서 Access Token 재발급을 요청한다.
-> 재발급 요청을 받은 서버는 Refresh Token을 Redis에서 찾는다.
-> 만약 Redis 캐시서버안에 Refresh Token이 소멸되지 않았다면
Access Token 재발급 시퀀스를 진행한다.
-> 만약 Refresh Token이 소멸되었다면, 예외처리하여 클라이언트에 알려준다.
(이후 로그아웃을 하든 다음 액션은 클라이언트쪽에 맡긴다.)


JWT Redis 저장, 재발행 코드

위와 같은 플로우로 일단 정리를 하였다.
해당 로직 구현은 아래에서 살펴보자.
모든 로직을 적어두진 않을 것이고, 중요하다고 생각하는
부분의 클래스 내용들만 적어볼 예정이다.

1). 로그인시 JWT 토큰 발급, Redis에 저장

JwtAuthenticationFilter 클래스

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final TokenProvider tokenProvider;
    private final RedisDao redisDao;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, TokenProvider tokenProvider, RedisDao redisDao) {
        this.authenticationManager = authenticationManager;
        this.tokenProvider = tokenProvider;
        this.redisDao = redisDao;
    }
    
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        ObjectMapper objectMapper = new ObjectMapper();

        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // ServletInputSteam을 LoginDto 클래스 객체로 역직렬화 (즉, JSON 객체꺼냄)
        log.info("# attemptAuthentication : loginDto.getEmail={}, login.getPassword={}",loginDto.getEmail(),loginDto.getPassword());

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws ServletException, IOException {

        AuthMember authMember = (AuthMember) authResult.getPrincipal();
        TokenDto tokenDto = tokenProvider.generateTokenDto(authMember);
        String accessToken = tokenDto.getAccessToken(); // accessToken 만들기
        String refreshToken = tokenDto.getRefreshToken(); // refreshToken 만들기

        tokenProvider.accessTokenSetHeader(accessToken,response); // AccessToken Header response 생성
        //tokenProvider.refreshTokenSetHeader(refreshToken,response); // RefreshToken Header response 생성
        tokenProvider.refreshTokenSetCookie(refreshToken,response); // RefreshToken Cookie로 설정
        Responder.loginSuccessResponse(response,authMember); // login 완료시 Response 응답 만들기

        // 로그인 성공시 Refresh Token Redis 저장 ( key = Refresh Token / value = Access Token )
        int refreshTokenExpirationMinutes = tokenProvider.getRefreshTokenExpirationMinutes();
        redisDao.setValues(refreshToken,accessToken, Duration.ofMinutes(refreshTokenExpirationMinutes));

        this.getSuccessHandler().onAuthenticationSuccess(request,response,authResult);
    }
}

UsernamePasswordAuthenticationFilter를 상속받아
구현한 JwtAuthenticationFilter 필터 클래스이다. Spring Security에
FilterChain에 설정해두어 로그인 요청시 해당 필터를 거치게된다.

여기서 중점적으로 봐야할 코드는
successfulAuthentication메서드 부분이다.
UsernamePasswordAuthenticationFilter 필터에서는
인증된 객체를 Security Context Holder에 저장해주는 역할을한다.

나와 같은 경우는 해당 필터를 Override하였기 때문에
Security Context Holder에 객체저장하는건 JwtVerificationFilter라는
인증필터를 만들어 처리해주었고, 현재 위에 올려둔 필터클래스에서는
JWT 발행과 redisDao.setValues()메서드를 통해 Redis에

Key : refreshToken  
Value : accessToken  
소멸시간 : 2일

로 저장을 해주게 되었다. 위 플로우에서 얘기했던 1),2)번에 해당하는 내용이다.
이렇게 필터 구현 부분에서 JWT를 발행해 Redis에 저장해주게 되었다.


2). Token 만료시 JWT 재발행

위에서 로그인시 토큰 발급까지 해주었고
클라이언트에서 Access Token을 헤더에 담아 어떤
API를 요청하였을때, 토큰이 만료되었다고 서버에서 확인해서 알려주면
재발행 API를 클라이언트에서 보내기로 했었다.

❗️ Access Token 만료확인 코드는 너무 많기때문에 생략합니다.
필자는 JwtAuthenticationFilter 필터클래스를 만들어 OncePerRequestFilter
상속받아 API요청시 doFilterInternal를 Override받아 구현해 해당 필터에서
JWT 해독을하고, 정상적으로 해독하면 API가 실행될 것이고, 토큰에 문제가 있거나 만료가되었으면
예외처리하여 클라이언트 측에 JSON을 만들어 Body에 던져주었습니다.


MemberController 클래스

@Slf4j
@RestController
@Validated
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberService memberService;

    ...

    @GetMapping("/reissue")
    public ResponseEntity reissue(@CookieValue(value = "refreshToken", required = false) String refreshToken,
                                  HttpServletRequest request,
                                  HttpServletResponse response){

        memberService.reissueAccessToken(refreshToken,request,response);
        return new ResponseEntity("Refresh Token 재발급 완료!",HttpStatus.CREATED);
    }
}

재발행을 실행해주는 API이다. 해당 API는
위에서 얘기했던 JwtAuthenticationFilter에서 실행되지 않게
shouldNotFilter를 구현해 처리해줄 수 있다.
그렇게되면 AccessToken이 만료되었더라도, 해당 API는 실행이 된다.
해당 로직은 API 요청부분이고, 이제 중요한 Service로직을 확인해보자


MemberSerivce 클래스

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class MemberService {
    private final MemberRepository memberRepository;
    private final CustomBeanUtils<Member> beanUtils;
    private final PasswordEncoder passwordEncoder;
    private final CustomAuthorityUtils authorityUtils;
    private final TokenProvider tokenProvider;
    private final RedisDao redisDao;

    ...

    public void reissueAccessToken(String refreshToken, HttpServletRequest request, HttpServletResponse response){

        if(refreshToken == null){
            throw new BusinessLogicException(ExceptionCode.COOKIE_REFRESH_TOKEN_NOT_FOUND);
        }

        String accessToken = tokenProvider.resolveToken(request);
        String redisAccessToken = redisDao.getValues(refreshToken);

        // Refresh Token이 Redis에 존재할 경우 Access Token 생성
        if(redisDao.validateValue(redisAccessToken) && accessToken.equals(redisAccessToken)){
            log.info("# RefreshToken을 통한 AccessToken 재발급 시작");
            Claims claims = tokenProvider.parseClaims(refreshToken); // Refresh Token 복호화
            String email = claims.get("sub", String.class); // Refresh Token에서 email정보 가져오기
            Member member = findVerifiedMember(email); // DB에서 사용자 정보 찾기
            AuthMember authMember = AuthMember.of(member.getId(), member.getEmail(), member.getRoles());
            TokenDto tokenDto = tokenProvider.generateTokenDto(authMember); // Token 만들기
            int refreshTokenExpirationMinutes = tokenProvider.getRefreshTokenExpirationMinutes();
            redisDao.setValues(refreshToken, tokenDto.getAccessToken(), Duration.ofMinutes(refreshTokenExpirationMinutes));
            tokenProvider.accessTokenSetHeader(tokenDto.getAccessToken(),response);

        } else if(!redisDao.validateValue(redisAccessToken)){
            throw new BusinessLogicException(ExceptionCode.REFRESH_TOKEN_NOT_FOUND);
        } else {
            throw new BusinessLogicException(ExceptionCode.TOKEN_IS_NOT_SAME);
        }
    }
}

다른 서비스 로직들은 지우고 표현해두었다.
reissueAccessToken메서드라는 부분을 만들어 검증 로직을 작성했다.

첫번째로는 쿠키에 RefreshToken이 제대로 담겨왔는지를 확인한다.

그리고는 이제 헤더에 담긴 AccessToken을 꺼내고
쿠키로 전달받은 RefreshToken으로 redisDao.getValues메서드를 이용해
Redis에서 AccessToken을 꺼낸다. (key=refreshToken, value=accessToken으로 저장했으니까!)

이제 if(redisDao.validateValue(redisAccessToken) && accessToken.equals(redisAccessToken))
비교문을 통해 이 두개의 AccessToken을 비교해 일치하는지 확인하고
만약 꺼내왔을때 소멸이 된 상태라면 redisDao.validateValue(redisAccessToken)
null값이 찍혀있게되어 둘 중하나라도 조건이 만족하지 못하면 예외처리가 될 것이다.

즉! Refresh Token이 존재하고(소멸되지 않고)
해당 Refresh Token에 매칭되는 Access Token을 들고왔을때만
Access Token을 재발행 한다음에 다시 클라이언트에 전송해준다는 서비스 로직이다.


📌 정리

이렇게 여러가지 레퍼런스를 찾아보면서
우선 내가 생각하는대로 정리하여 올려보았다.
이 방법이 틀릴수도 있고, 어느정도 타협이 될 수도 있다고 생각한다.

앞으로도 토큰을 사용한다면 보안에 대해 좀더 깊게 공부를 해봐야할 것 같다.
또한, 이 글의 본목적은 코드를 전부 리뷰한다기 보다는

어떠한 흐름으로 작업을 했는지와 Redis를 이용하여
토큰을 비교하는 부분에 초점을 두고 작성했기에
Spring Security에 대한 내용을 어느정도 선수지식으로 가지고 있어야하기에
조금 불친절한 설명이 될 수도 있을 것 같다는 생각이든다.

그래도 참고차 올려보고
내공부를 위한 기록이기도하여 정리해보았다 📚




✨ 참고 블로그 Redis Jpa 사용 레퍼런스, Redis 사용방법 2가지 레퍼런스