[식구하자_msa] 로그인 기능 리팩터링- Redis Access Token 재발급

이민우·2024년 5월 19일
2

🍀 식구하자_MSA

목록 보기
12/21
post-thumbnail

이번 포스팅에서는 refresh 토큰 재발급 관련 로직을 리팩터링했던 과정에 관해 포스팅 해볼려고 합니다!

🤷‍♂️ 기존 Access Token 재발급 과정의 문제점

리팩토링 이전에는 MySQL을 데이터베이스로 사용하여 refresh token을 저장하고, AccessToken이 만료되면 refresh token을 활용해 재발급 과정을 진행했습니다.

📌 MySQL에 저장할 경우의 문제점

AccessToken의 만료 시간을 30분으로 설정했기 때문에, 토큰 만료로 인한 401 에러 발생 시, 프론트엔드에서 accessToken을 서버로 보내 재발급을 요청합니다. 서버는 AccessToken으로 RefreshToken을 조회하고, 유효할 경우 새 AccessToken을 발급합니다.

프론트엔드에서 토큰을 헤더에 담아 인증 요청을 보냈을 때, 인증 실패 시 발생하는 쿼리 수를 살펴보겠습니다:

  • AccessToken에서 추출한 사용자 이름으로 회원을 조회하고 refreshToken 존재 여부를 확인하는 단일 쿼리로 해결됩니다.

하지만 이것이 최선의 선택일까요?

RefreshToken 역시 유효기간이 있습니다. 이 프로젝트에서는 14일의 유효기간을 설정하고, 만료 시 삭제하는 방식을 채택했습니다. RDB 사용 시, 스케줄러와 같은 프로그램을 만들어 RefreshToken을 만료시키는 방법을 사용해야 합니다.

단일 쿼리라도 이는 사용자당 30분마다 한 번씩 실행됩니다. 예를 들어:

  • 100명의 사용자2시간 동안 서비스를 이용한다면 총 400번의 쿼리가 실행됩니다.

사용자 수가 증가할수록, DB에 지속적이고 과도한 부하를 주는 작업이 될 수 있습니다.

Redis를 사용한 리팩토링

Redis 적용 시 얻을 수 있는 이점을 살펴보겠습니다:

  1. 자동 만료 처리: MySQL 사용 시 스케줄러로 데이터를 직접 삭제해야 하지만, Redis는 TTL(Time-To-Live) 기능으로 설정 시간 후 자동 삭제됩니다.

  2. 접근 횟수: 제가 설계한 로직에서 Redis 사용 시,

    • AccessToken으로 RefreshToken 조회 시 한 번
    • 새 AccessToken 값 업데이트 시 한 번
      총 두 번 접근합니다.
  3. 성능: 데이터베이스 접근 횟수는 한 번 늘었지만, Redis는 매우 빠른 읽기 속도를 자랑합니다. 캐싱용으로도 많이 사용되는 이유입니다.

  4. 부하 분산: MySQL은 다른 비즈니스 로직 처리를 위해 자주 사용되는데, RefreshToken 관리까지 맡기면 주기적인 요청으로 인한 부하가 우려됩니다.

이러한 이유로 Redis를 이용해 Refresh Token 관리 시스템을 리팩토링하기로 결정했습니다.

👨🏻‍💻 RefreshToken을 관리하는 코드

1. 의존성 추가

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

2. yml 설정

  redis:
    host: <public ip or localhost>
    port: 6379

3. redis config 작성 생략

4. RefreshToken 작성

@Getter
@AllArgsConstructor
@RedisHash(value = "jwtToken", timeToLive = 60 * 60 * 24 * 14)
public class RefreshToken implements Serializable {

    @Id
    private String id;

    @Indexed
    private String accessToken;

    private String refreshToken;

    public void updateAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }

}
  • RefreshToken 객체의 직렬화와 역직렬화가 지원되도록 마커 인터페이스인 Serializable 인터페이스를 구현했습니다.
  • @RedisHash는 Hash Collection을 명시하는 애너테이션입니다. RedisHash의 key는 value인 jwtToken과 @Id가 붙은 id 필드의 값을 합성하여 사용합니다.
  • timeToLive는 초단위로 언제까지 데이터가 존재할건지 정할 수 있습니다. 14일로 유효기간을 정했습니다.
  • @Indexed 애너테이션을 사용하면 JPA를 사용하듯이 findByAccessToken과 같은 질의가 가능해집니다.
  • RefreshToken을 찾을때 AccessToken 기반으로 찾을 것이므로, @Indexed 애너테이션을 붙여주었습니다.

4. repository 코드 작성

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

    Optional<RefreshToken> findByAccessToken(String accessToken);
}
  • AccessToken이 만료되면 , AccessToken기반으로 RefreshToken을 찾기 위한 findByAccessToken을 작성했습니다.

5. service code 작성

@Service
@RequiredArgsConstructor
@Transactional
public class RefreshTokenService {

    private final RefreshTokenRepository repository;
    private final JwtTokenUtil jwtUtil;

    public void saveTokenInfo(String username, String accessToken, String refreshToken) {
        repository.save(new RefreshToken(username, accessToken, refreshToken));
    }

    public void removeRefreshToken(String accessToken) {
        RefreshToken token = repository.findByAccessToken(accessToken)
                .orElseThrow(IllegalArgumentException::new);

        repository.delete(token);
    }
    public String republishAccessToken(String accessToken) {
        // 액세스 토큰으로 Refresh 토큰 객체를 조회
        Optional<RefreshToken> refreshToken = repository.findByAccessToken(accessToken);

        // RefreshToken이 존재하고 유효하다면 실행
        if (refreshToken.isPresent() && jwtUtil.isJwtValid(refreshToken.get().getRefreshToken())) {
            // RefreshToken 객체를 꺼내온다.
            RefreshToken resultToken = refreshToken.get();
            // 권한과 아이디를 추출해 새로운 액세스토큰을 만든다.
            String newAccessToken = jwtUtil.generateAccessToken(resultToken.getId());
            // 액세스 토큰의 값을 수정해준다.
            resultToken.updateAccessToken(newAccessToken);
            repository.save(resultToken);
            // 새로운 액세스 토큰을 반환해준다.
            return newAccessToken;
        }

        return null;
    }
}

토큰 재발급 로직

  • AccessToken으로 refreshToken을 조회
  • RefreshToken이 현재 존재하고, RefreshToken이 만료되지 않았다면 재발급을 수행합니다.

6. controller 코드 작성

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final RefreshTokenService tokenService;

    @PostMapping("/token/logout")
    public void logout(@RequestHeader("Authorization") final String accessToken) {
        // 엑세스 토큰으로 현재 Redis 정보 삭제
        tokenService.removeRefreshToken(accessToken);
    }

    @PostMapping("/token/refresh")
    public ResponseEntity<TokenResponseStatus> refresh(@RequestHeader("Authorization") final String accessToken) {

        String newAccessToken = tokenService.republishAccessToken(accessToken);
        if (StringUtils.hasText(newAccessToken)) {
            return ResponseEntity.ok(TokenResponseStatus.addStatus(200, newAccessToken));
        }
        return ResponseEntity.badRequest().body(TokenResponseStatus.addStatus(400, null));
    }

}
  • AccessToken으로 RefreshToken을 찾을 수 없거나 RefreshToken이 만료 되었을경우 400번 에러

📌 참고

보통 security+jwt를 사용하게 되면, 인증을 위한 필터를 사용할텐데 refresh 토큰 재발급을 위해 accessToken을 받고 필터를 수행하면 토큰의 값은 있지만 토큰이 유효하지 않을 수 있기 때문에 시큐리티 필터에서 해당 url을 건너뛰게 설정해줘야 합니다!!

저는 msa를 사용하기 위해, spring cloud gateway를 사용하기 때문에 게이트웨이 설정에서 해당 url path를 적어줘서 인증 필터를 제외시켜줬습니다.

        - id: plant-service
          uri: lb://PLANT-SERVICE
          predicates:
            - Path=/plant-service/token/refresh
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/plant-service/(?<segment>. *) , /$\{segment}

참고


https://velog.io/@j3beom/Spring-Security-JWT-Redis-Refresh-Token-%ED%99%9C%EC%9A%A9-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89-2-DB-%ED%99%9C%EC%9A%A9-RDBMS
https://velog.io/@ch4570/OAuth-2.0-JWT-Spring-Security%EB%A1%9C-%ED%9A%8C%EC%9B%90-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-Refresh-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89
https://inkyu-yoon.github.io/docs/Language/SpringBoot/RefreshToken

profile
백엔드 공부중입니다!

0개의 댓글

관련 채용 정보