본문 바로가기
Project

[리팩토링] Redis와 함께하는 인증 구현

by newny 2024. 3. 14.
반응형

Intro


saving이라는 예산 관리 프로그램을 처음 기획했을 때는 JWT 인증 방식으로만 구현했었다. JWT 인증 방식으로 구현했을 때의 장점은 토큰 방식의 인증이므로 무상태가 유지된다는 것, 유저가 자신의 아이디 또는 서버에서 발급된 아이디(엔티티 아이디)를 가지고 있지 않아도 JWT 하나로 해결된다는 점이 좋았다. 그렇기에 JWT를 다루는 건 더 조심스러워야 한다고 생각했는데, 인증을 한 개의 토큰으로만 하려고 하니 여러 가지 문제가 생겼다. 그 문제점을 해결하려 Redis를 이용하여 Access 토큰, Refresh 토큰을 이용한 인증구현으로 리팩토링 하게 되었다.
 
 
 

단일 토큰 인증방식의 문제점


토큰 탈취 시 발생 위험

단일 토큰만으로 인증을 하게 됐을 때 생기는 문제점은 토큰 탈취이다. 하나의 토큰만으로 인증을 하기 때문에 토큰의 만료기간이 짧지 않은데 그로 인해 토큰이 탈취되었을 경우 해당 토큰이 만료될 때까지 불법적 사용자가 사용자의 개인 정보를 탈취하거나 악의적인 활동을 수행할 수 있다. 따라서 토큰의 만료 기간을 최대한 줄이는 것이 좋은데 그렇게 되면 아래와 같은 상황이 발생한다.
 

잦은 로그인으로 인한 사용자의 번거로움

토큰의 만료 기간을 줄이게 되면 토큰 탈취로 인한 위험성이 조금은 줄어들 것이다. 하지만 그로 인해 사용자가 자주 로그인을 해야 하는 상황이 생긴다. 웹 사이트의 궁극적인 목적은 해당 사이트가 제공하는 서비스나 콘텐츠를 통해 사용자에게 가치를 제공하는 것인데, 잦은 로그인으로 인해 번거로움을 느낀 사용자가 해당 사이트를 이용하지 않는 경우가 발생할 수 있다.
 
 
 

단일 토큰 인증 방식의 문제점을 해결하기 위한 두 개의 토큰을 활용한 인증


탈취 위험을 줄이기 위한 Access 토큰 적용

Access 토큰도 마찬가지로 JWT와 같지만 단일 토큰을 사용했을 때보다 만료기간을 훨씬 작게 할 수 있다. JWT 같은 경우는 최소 1일~7일 정도로 설정하는 반면 Access 토큰의 경우에는 30분~1일 정도로 만료기간을 매우 짧게 설정하여 사용하는 토큰이다. 이로서 토큰 탈취로 인한 위험성을 조금은 줄일 수 있다.
 

유저의 잦은 로그인을 해소하기 위한 Refresh 토큰 도입

Access 토큰의 만료기간이 매우 짧기 때문에 유저는 굉장히 자주 로그인 시도를 해야 할 것이다. 이것을 줄이기 위한 방법이 Refresh 토큰이다. Refresh 토큰은 만료 기간이 굉장히 긴 토큰으로, 대부분 2주 정도의 만료기간으로 설정한다.
유저의 API 요청 시 헤더에 Access 토큰이 함께 보내지게 되는데 해당 토큰이 만료되었을 경우 클라이언트에서 Access 토큰과 Refresh 토큰을 함께 보낸다. 이때에 서버에서는 Access 토큰의 유저정보 + Refresh 토큰을 Redis에 저장되어 있는 유저정보 + Refresh 토큰을 비교한다. 두 개의 정보가 일치하면 유저에게 새로운 Access 토큰을 발급하고 해당 Access 토큰으로 원래 요청했던 API로 다시 접근한다.
방금 설명했던 프로세스들은 모두 유저가 모르게 실행된다. 그렇기 때문에 두 가지 토큰을 이용한 인증 방식은 프론트엔드와 백엔드의 협의가 있어야만 가능하다. 자세한 프로세스는 이전에 올렸던 [리팩토링] Access Token, Refresh Token 인증 프로세스 순서도를 참고하면 될 것 같다.
 
 
 

Refresh 토큰 관리를 위한 Redis의 도입


토큰의 가장 큰 장점은 '무상태성'이라고 생각한다. Refresh 토큰의 경우 사용자가 Access 토큰을 재발급받기 위한 요청 시에 서버로 보내지게 되는데 해당 Refresh 토큰이 유효한 토큰인지 확인하려면 서버의 어떠한 공간에 저장되어 비교할 수 있어야 한다. 하지만 디스크 공간에 저장이 되는 순간부터 무상태 인증이 깨지게 된다. 따라서 외부 저장소에 Refresh 토큰을 저장하면 서버는 세션 상태를 유지하지 않으면서도 클라이언트의 인증 정보를 처리할 수 있다. 외부 저장소로 Redis를 선택한 이유는 다음과 같다.
 

Refresh Token 저장을 위한 외부 저장소로 Redis를 선택한  이유 

refresh 토큰의 잦은 생성과 무효화

만약 디스크 저장 기반의 데이터베이스에 refresh 토큰을 저장하게 된다면 해당 토큰이 만료될 때 토큰을 삭제하는 로직이 추가가 된다. 또한 refresh 토큰의 경우 업데이트를 자주 해야 하므로 디스크 저장 기반의 데이터베이스와는 더욱 어울리지 않는다. Redis를 사용하게 되면 토큰의 만료 시 자동 삭제 기능을 이용할 수 있다. 그러므로 개발자가 토큰 만료를 직접 관리하지 않아도 된다. 또한 접근이 빠르고 쉽기 때문에 refresh 토큰의 특성을 생각했을 때 Redis가 적합하다고 생각했다.
 

중요한 정보인가에 대한 판단

과연 refresh 토큰이 디스크에 저장이 돼야 할 만큼 중요한 정보인가에 대한 생각을 해보았다. refresh 토큰 존재의 궁극적 이유는 결국 access 토큰을 재발급받기 위한 도구일 뿐이라고 생각하니 그다지 중요한 데이터라고 생각이 들지는 않았다. access 토큰처럼 사용자 정보를 가지고 있지도 않을뿐더러, 로그인할 때마다 새로 발급된다. 그렇기 때문에 인메모리 데이터베이스인 Redis가 적합하다고 판단했다.
 
결론적으로 refresh 토큰의 특성에 맞는 데이터베이스, refresh 토큰이 중요한 정보인가에 대한 판단, 인메모리 데이터베이스라는 장점 등의 이유로 Redis를 선택하게 되었다.
 
 
 

리팩토링 과정


1. Redis에 대해 서칭

먼저 Redis에 대해 서칭 하며 공부하는 것으로 시작하였다. 아래의 글들은 공부하며 블로그에 남긴 글이다.

 

Redis란 무엇인가

Intro 개인 프로젝트 리팩토링 중 첫 번째로 Access Token, Refresh Token을 이용한 인증을 구현해보려 한다. 그전에 Redis에 대해서 알아보자! Redis란? Redis는 NoSql In-memory database이다. 주로 캐싱, 세션 관리

newny6400.tistory.com

 

Redis 트랜잭션

Intro 기존 프로젝트에 Redis + refreshToken, accessToken을 이용한 인증을 적용 중이다. 현재는 로그인 부분을 리팩토링 중인데 redis에 refreshToken값을 넣는 로직을 만드는 도중 'redis 트랜잭션은 어떻게 하

newny6400.tistory.com

 

2. Access Token, Refresh Token 인증 프로세스 구상

마찬가지로 해당 프로세스에 대해 블로그에 정리하여 올렸다.
결론에 해당 프로세스를 구상하며 생겼던 고민이 있는데 아마도 다음 리팩토링은 그 부분이 되지 않을까 싶다.

 

[리팩토링] Access Token, Refresh Token 인증 프로세스

Intro 리팩토링 중인 프로젝트의 토큰 인증 프로세스를 구상해 보았다. 토큰 형식 Access Token 형식 : JWT Refresh Token 형식 : UUID Refresh Token 저장 형태 Key (String) refreshToken:{사용자 아이디} Value (String) real

newny6400.tistory.com

 

3. 프로젝트에 실제 적용

의존성 추가

현재 리팩토링에서는 redisTemplate만 사용하면 되므로 아래의 의존성 하나만 추가하였다. 이후에는 아마도 Lettuce 의존성 추가가 될 듯싶다.

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

 

 Repository

원래는 JPA처럼 사용할 수 있도록 제공해 주는 repository 인터페이스가 있으나 해당 인터페이스를 사용하면 @Transactional 어노테이션을 사용할 수 없다. 따라서 redis Template를 @Transactional 어노테이션과 함께 사용할 수 있도록 설정하여 직접 repository를 만들어 사용하였다.

@Configuration
@EnableTransactionManagement
public class RedisConfig {

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

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setEnableTransactionSupport(true);  // 설정이 필요한 부분
        return redisTemplate;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new JpaTransactionManager();
    }
}
@Slf4j
@Repository
@RequiredArgsConstructor
public class RedisRefreshTokenRepository {

    private final RedisTemplate<String, String> redisTemplate;
    @Value("${token.expired-time.refresh}")
    private Long expiredRefreshTokenTime;
    @Value("${token.refresh-token-id-prefix}")
    private String refreshTokenIdPrefix;

    public void setRefreshToken(Long id, String refreshToken) {
        redisTemplate.opsForValue().set(refreshTokenIdPrefix + id, refreshToken,
                Duration.ofSeconds(expiredRefreshTokenTime));
    }

    public String getRefreshToken(Long id) {
        return redisTemplate.opsForValue().get(refreshTokenIdPrefix + id);
    }
}

 

유효하지 않은 토큰의 접근 방지

Access 토큰 재발급 시 만료된 Access 토큰과 refresh 토큰을 가지고 재발급 API를 호출하며, 새로운 토큰 생성이 진행되는 로직을 거친다. 해당 서버에서 발급하지 않은 토큰을 가지고 API를 호출하는 경우가 발생될 수도 있다고 생각하여 인터셉터에서 해당 서버가 발급한 토큰이 아니면 controller 내부로 접근할 수 없도록 하였다.

```
catch (TokenExpiredException e) {
    /* 토큰 재발급 시 Access 토큰이 서버에서 발급된 토큰 이면서 만료된 토큰일 때에만
    토큰 재발급 API 호출 가능 */
    if (request.getRequestURI().equals(ALLOW_EXPIRED_TOKEN_ENDPOINT)) {
        request.setAttribute("userId", tokenUtils.getUserIdFromToken(token));
        return true;
    }

    log.error("[{}] ex", e.getClass().getSimpleName(), e);
    throw new AuthTokenExpiredException();
}    
```

 
 
 

결론


해당 리팩토링을 진행하며 Redis에 대해 굉장히 많이 알게 된 느낌이다. 이후 리팩토링을 이것저것 진행해 볼 건데 Redis를 이용한 캐싱도 적용해볼예정이다.
그리고 리팩토링을 진행하며 조금 찜찜했던 부분이 Redis에 refresh 토큰을 저장할 때 테이블의 pk를 key로 사용한 부분이다. 이 부분에 대해서 더 조사해 보니 테이블의 컬럼을 id(pk)와 uid(unique)를 구분해서 사용한다고 한다. 아무래도 다음 리팩토링은 이 부분부터 시작이 될 것 같다.
그러면서 든 생각은 `테이블 구조까지 변경될 거면 ver.2로 리팩토링 할걸 그랬다`는 생각이 들었다.(ㅎ) 그래서 '기존의 테이블이 온전치 못해서 서비스할 정도가 아니었고 이번이 첫 정식 버전이다~~' 생각하고 리팩토링 할 예정이다. 다음 리팩토링 때에는 테이블 구조 설계를 탄탄하게 해야겠다.

반응형

댓글