본문 바로가기
팀 프로젝트/최종 프로젝트

JWT: Redis에서 RefreshToken & BlackList 관리하기

by pon9 2025. 2. 13.

왜 Spring security를 사용하지 않았는가?

1. spring security의 구조에 대한 이해가 필요하다. 학습곡선도 분명히 존재한다. spring security를 사용하더라도, 그저 ‘사용’만 한다면 보안측에서 허술한 건 당연하기에 상당한 시간이 필요할 것이다.

2. 구조에 대한 이해가 제대로 되지 않은 상태로 jwt 블랙리스트나, refresh token 저장 등을 하려면 개발에 소요되는 시간이 꽤나 지체될 것이다.

3. 구현하고 싶은 기능 중에 spring security에서 커스텀하려면 어려운 기능이 존재하는데,

  • JWT Payload 암호화(AES로 암호화된 Payload 처리하기)
  • JWT Key Rotation
  • Redis 기반 블랙리스트

모두 spring security에서 기본적으로 제공하진 않는 기능들이라 구조에 대한 이해도 하면서 + 구현까지 해야한다.

4. redis가 익숙하다.

 

하지만 redis에도 분명 단점이 존재한다.

1. redis는 메모리 기반 저장소라, 데이터가 많아지면(내가 구현하려는 기능들에서는, 동접자가 많아지면) 메모리 부족 문제가 발생할 수도 있다.

해결책: TTL을 짧게 사용하고, LRU정책을 이용해 오래된 데이터를 자동 삭제하고, DB와 조합해 일정 시간 후 데이터를 DB로 옮기자. 어떻게 뭘 옮길 수 있을지는 고민을 좀 해봐야겠다.

 

2. OAuth2연동이 어렵다. “모두 직접 구현해야 함”…

해결책: 안 쓰면 되는 거 아닌가? 어차피 우리 서비스는 google 등에서 제공하는 정보만으로는 가입을 하지 못한다. 오히려 OAuth를 사용하면 사용자 입장에서 회원가입을 두 번 하는 느낌이 들 것이다. 물론 로그인은 훨씬 편하겠지만..

 

Redisson vs Lettuce

Redis 클라이언트 라이브러리를 선택해야 했는데 Lettuce, Jedis, Redisson 세 가지가 있다. 

일단 Jedis는 구현은 간단하지만 동기 방식에다가 자동 재연결 기능이 없어서 논외로 두고,

Redisson과 Lettuce 의사결정이 필요했다.

 

Redisson: 고급 기능을 지원하며, 분산 락을 지원하고, 라이브러리 크기가 커서 상대적으로 느리다.

Lettuce: 빨라서 캐싱 등에 유리하고, 고급 기능을 자체 지원하진 않아서 pub/sub이나 stream을 사용하게 된다면 코드를 직접 구현해야 한다.

 

사실 내가 구현하려는 기능만 보면 Lettuce를 사용하는 것이 맞아서 아래 코드는 Lettuce기반(R API 하나도 안 씀)이지만

우리 팀 전체와 앞으로 구현할 기능을 생각해 봤을 땐 Redisson로 리팩토링 하는 게 적절하다 느꼈다.

리팩토링 진행할 때 같이 해야겠다..

 

구현한 기능

1. Redis에 저장된 RefreshToken을 발급해 AccessToken이 만료된 사용자 재로그인

2. RefreshToken을 이용해 AccessToken을 재발급하면, 새로운 RefreshToken 발급(재사용 방지)

3. 로그아웃 시 RefreshToken도 함께 삭제, 해당 사용자의 AccessToken은 BlackList 처리

AccessToken이 만료된 사용자의 RefreshToken을 이용해 요청 보내면

RefreshToken을 새로 발급하며, 새로운 AccessToken이 발급됨

해당 토큰을 이용해 로그아웃 하면

RefreshToken이 사라지며, BlackList에 AccessToken 등록

 

구현 과정

# jwt token secret keys
jwt:
  secret:
    key: ${jwt.key}
  token:
    prefix: ${jwt.prefix}
    refreshPrefix: ${jwt.refreshPrefix}
    blackListPrefix: ${jwt.blackListPrefix}
    expiration: ${jwt.expiration}
    refreshExpiration: ${jwt.refreshExpiration}
    blackListExpiration: ${jwt.blackListExpiration}

우선 필요한 환경설정을 추가해준다. secretKey는 당연하고 prefix나 ttl이 코드에 그대로 보이는 건 그닥 좋지않다 생각해 모두 가려줬다.

처음으로 application.yml을 사용해보았는데, config import설정을 통해 민감정보를 관리하는 것이 굉장히 편한 것 같다.

public String createRefreshToken(Long userId) {
    return generateJwt(userId, null, null,
        securityProperties.getToken().getRefreshPrefix(),
        securityProperties.getToken().getRefreshExpiration());
}

refreshToken에는 보안상의 이유로 id값만 넣는걸로 했다. 

@Component
@RequiredArgsConstructor
public class RedisRefreshTokenService {

    private final StringRedisTemplate redisTemplate;
    private final JwtSecurityProperties jwtSecurityProperties;

    public void createRefreshToken(Long userId, String refreshToken) {
        long expiration = jwtSecurityProperties.getToken().getRefreshExpiration();
        String key = getKey(userId);
        //set -> 원래 값을 수정함 -> 기존 refreshToken 덮어씌워짐
        redisTemplate.opsForValue().set(key, refreshToken, expiration, TimeUnit.MILLISECONDS);
    }
}

redis와 상호작용 하는 service 코드의 createRefreshToken 메서드다.

일단 데이터는 단순 String으로 저장하였는데, refreshToken을 새로 생성할 때 데이터가 중복되면 안 되니까 '사용자 아이디 + refreshToken임을 나타내는 값'을 key로, value는 토큰으로두고 opsForValue().set을 사용해 덮어씌워지도록 했다.

 

블랙리스트는 반대로 한 사용자가 여러 개의 블랙리스트를 가질 수도 있으므로 토큰을 key로, value를 블랙리스트임을 명시하는 값으로 두어 관리했다.

public String refreshAccessToken(String refreshToken) {
    if (!StringUtils.hasText(refreshToken)) {
        throw new CustomException(ErrorCode.TOKEN_NOT_FOUND);
    }
    //refreshToken 접두어 제거(claims 위함)
    refreshToken = jwtUtil.substringToken(refreshToken);

    Claims claims;

    try {
        claims = jwtUtil.extractClaims(refreshToken);
    } catch (Exception e) {
        throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
    }

    Long userId = Long.parseLong(claims.getSubject());

    String storedRefreshToken = redisRefreshTokenService.getRefreshToken(userId);

    if (storedRefreshToken == null) {
        log.error("Refresh Token not found in Redis for userId: {}", userId);
        throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
    }

    //storedToken 도 접두어 제거 한 상태로 비교해야 함
    if (!refreshToken.equals(jwtUtil.substringToken(storedRefreshToken))) {
        log.error("Refresh Token mismatch for userId: {}", userId);
        throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
    }

    //refreshToken 도 새로 발급 -> 재사용 막음
    String newRefreshToken = jwtUtil.createRefreshToken(userId);
    redisRefreshTokenService.createRefreshToken(userId, newRefreshToken);

    User user = userRepository.findById(userId)
        .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

    return jwtUtil.createToken(userId, user.getEmail(), user.getRole());
}

RefreshToken을 이용해 AccessToken을 재발급 받는 부분이다.

제일 힘들었던 부분인데, 로직은 되게 별 거 아닌데 어느 부분에서는 Prefix를 제거한 상태로 비교해야 하고 어떤 부분은 Prefix를 포함한 상태로 비교해야해서.. 좀 꼬였다. 그래서 현재 해당 메서드를 호출할 때 Prefix사용유/무를 로직을 작성해보면

파랑이 포함, 빨강이 제거인데.. 이거 어제 공부한 Red-Black Tree도 아니고 ㅋㅋㅋㅋ

아무튼 되게 복잡해서 리팩토링의 중요성을 크게 느꼈다.

 

후기

구현 자체는 쉬운 편이었으나 코드의 구석구석을 모두 수정하느라 살짝 애먹었다.

이후 Payload 암호화도 진행하려 했으나, 암호화와 복호화라는게.. 여기저기 모두를 고쳐야 해서 일단 리팩토링 먼저 진행 예정이다.

SOLID의 중요성을 새삼 느꼈다.

 

그리고 payload 암호화쪽을 좀 보니까 key rotation까지는 불필요하다 느껴져서 버전3쯤에 시간이 정말정말 남으면 해봐야겠다.