OAuth - 레디스로 리프레시 토큰 관리
레디스
리프레시 토큰을 레디스로 관리하기 전에 레디스가 무엇인지 알아보자. 레디스는 인메모리에서 관리되는 NoSQL DBMS로, 자료를 키-값
구조로 관리한다.
메모리에 자료를 저장하고 읽고 쓰기 때문에 전통적인 RDBMS보다 빠르고, 데이터 구조도 유연하다. 또한 데이터의 영속성 관리가 필요할 때에는 데이터를 디스크에 저장할 수도 있는 등의 특징을 가지고 있다. (자세한 설명은 참고 자료들을 보는 것을 추천한다)
레디스를 사용하는 이유
Redis를 사용하여 리프레시 토큰을 관리하려는 이유는 세 가지가 있다. 첫 번째 이유는 서버를 stateless로 설계하여 수평적 확장이 용이하다는 점이다. 인증 정보를 서버의 상태(state)로 유지하는 stateful 방식으로 설계하면 클라이언트가 특정 서버에 의존하게 된다. 이 경우 서버가 여러 대로 분산된 환경에서는 이미 인증한 클라이언트가 서버를 변경할 때 다시 인증을 요구받는 문제가 발생할 수 있다. 그러나 Redis를 통해 인증 상태를 외부에서 관리하면 서버는 상태 정보를 유지하지 않아도 되므로 이러한 문제를 효과적으로 해결할 수 있다.
두 번째 이유는 성능 최적화이다. MySQL과 같은 RDBMS를 통해 리프레시 토큰을 관리할 수도 있지만, RDBMS는 디스크 기반의 접근 방식을 사용하기 때문에 매번 데이터베이스에 접근하여 토큰을 저장하거나 삭제하는 작업에서 병목현상이 발생할 수 있다. 특히 대규모 트래픽을 처리하는 서비스에서는 RDBMS로 인증 정보를 관리할 경우 성능 저하가 심각해질 수 있다. 반면 Redis는 메모리 기반 데이터베이스로 높은 처리 속도를 제공하며, 대규모 요청을 효율적으로 처리할 수 있는 이점이 있다. 따라서 Redis를 사용하면 서버를 stateless하게 유지하면서도 인증 처리에 따른 성능 오버헤드를 효과적으로 줄일 수 있다.
마지막으로 Redis의 TTL(Time-To-Live) 기능은 리프레시 토큰 관리에서 매우 유용하다. Redis는 데이터를 저장할 때 각 항목에 저장 기한을 설정할 수 있어 리프레시 토큰의 만료 기한을 자동으로 관리할 수 있다. 이 기능을 활용하면 만료된 토큰을 자동으로 제거하여 개발 및 운영상의 복잡성을 줄이고, 리프레시 토큰의 유효성 검사를 보다 간단하게 구현할 수 있다.
프로젝트에 적용해보기
이제 임시 저장소를 레디스로 바꿔보자. 레디스를 설치하는 것은 인터넷이나 아래 참고 자료를 통해 진행하면 될 것 같다. 개인적으로 도커를 통해 DB를 관리하는데, 특별한 이유는 없고 단순하게 사용하지 않는데도 백그라운드에서 데이터베이스가 구동 중인게 싫어서 그렇게 관리하고 있다.
의존성 설정
프로젝트에 레디스를 적용하기 위해서 의존성을 설정해야 한다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
설정 파일에 레디스 정보 설정
설정 파일에서 레디스에 접속할 호스트와 포트 번호, 그리고 타임아웃 시간을 설정해준다.
# application.properties
spring:
data:
redis:
host: localhost
port: 6379
timeout: 6000
레디스 설정 및 연결
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.timeout}")
private long timeout;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofMillis(timeout))
.build();
RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(host, port);
return new LettuceConnectionFactory(serverConfig, clientConfig);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisConnectionFactory
를 통해 어플리케이션과 실제 레디스 간 연결을 관리한다. 이때 LettuceClientConfiguration
로 타임아웃을 설정하고, 호스트 주소와 포트 번호를 이용하여 RedisStandaloneConfiguration
로 연결할 곳을 지정하여 두 인스턴스를 넘겨줘 레디스 연결 위치와 설정을 할 수 있다.
RedisTemplate
를 설정할 때 set___Serializer()
는 레디스에 저장될 키 또는 값의 직렬화 방식을 지정해주는 메소드이다. 리프레시 토큰 및 사용자 번호는 둘 다 문자열로 관리되므로, 문자열 직렬화 방식을 선택했다. 직렬화 방식을 직접 설정해야 하는 이유는 스프링 부트의 기본 레디스 설정은 JDK 직렬화를 사용하여 바이너리 형태로 저장이 되기 때문이다.
토큰 저장 서비스 설계
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisTokenService {
private final RedisTemplate<String, String> redisTemplate;
// 토큰 저장
public void saveToken(String key, String value, long expirationMinutes) {
redisTemplate.opsForValue().set(key, value, expirationMinutes, TimeUnit.MILLISECONDS);
}
// 토큰 조회
public String getToken(String key) {
return redisTemplate.opsForValue().get(key);
}
// 토큰 삭제
public void deleteToken(String key) {
redisTemplate.delete(key);
}
}
앞서 사용한 임시 저장소와 똑같다. 저장되는 위치가 레디스로 변경되고, 리프레시 토큰을 저장할 때 만료 시간과 기입된 만료 시간의 시간 타입만 설정해주는 코드로 바뀌었다.
JwtUtil 수정
임시 저장소 의존성을 제거하고 레디스에 저장하도록 코드를 수정해보자. 전체적인 코드의 동작은 임시 저장소를 사용할 때와 동일하다.
의존 관계 수정
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final long accessTokenExpirationTIme;
private final long refreshTokenExpirationTIme;
private final RedisTokenService redisTokenService;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.access-token-expiration-time}") long accessTokenExpirationTIme,
@Value("${jwt.refresh-token-expiration-time}") long refreshTokenExpirationTime,
RedisTokenService redisTokenService
) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpirationTIme = accessTokenExpirationTIme;
this.refreshTokenExpirationTIme = refreshTokenExpirationTime;
this.redisTokenService = redisTokenService;
}
}
저장 메소드 수정
public UserToken generateToken(String id) {
String accessToken = createToken(id, accessTokenExpirationTIme);
String refreshToken = createToken("REFRESH_TOKEN", refreshTokenExpirationTIme);
redisTokenService.saveToken(refreshToken, id, refreshTokenExpirationTIme);
return new UserToken(refreshToken, accessToken);
}
사용자 번호 추출 메소드 수정
public Long getUserId(String accessToken, String refreshToken) {
String parsedAccessToken = bearerParse(accessToken);
validateTokens(parsedAccessToken, refreshToken);
if (redisTokenService.getToken(refreshToken) == null) {
throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
}
return Long.parseLong(parseToken(parsedAccessToken).getPayload().get("id", String.class));
}
수정 사항
예전에 진행한 미니 프로젝트인데 이 부분에서 그때 구상한 로직과 잘못 작성한 부분이 있어서 수정하도록 한다. 나는 리프레시 토큰에 사용자 값을 넣은 것으로 기억하고 있다. 따라서 다음과 같이 검증을 진행하면 좀 더 안전할 것이라고 생각한다.
public Long getUserId(String accessToken, String refreshToken) {
String parsedAccessToken = bearerParse(accessToken);
validateTokens(parsedAccessToken, refreshToken);
String refreshUserId = redisTokenService.getToken(refreshToken);
String accessUserId = parseToken(parsedAccessToken)
.getPayload().get("id", String.class);
if (!refreshUserId.equals(accessUserId)) {
throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
}
return Long.parseLong(accessUserId);
}
리프레시 토큰 재발급 메소드 수정
public String regenerateAccessToken(String refreshToken) {
validateRefreshToken(refreshToken);
String userId = String.valueOf(redisTokenService.getToken(refreshToken));
if (userId == null) {
throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
}
return createToken(userId, accessTokenExpirationTIme);
}
테스트
성공적으로 인증이 완료되었을 때 레디스에 접속하여 조회하면 리프레시 토큰이 저장되어 있는 것을 것을 볼 수 있다.
127.0.0.1:6379> keys *
1) "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IlJFRlJFU0hfVE9LRU4iLCJpYXQiOjE3MzQ1ODM2MDEsImV4cCI6MTczNTE4ODQwMX0.9sO0R4BkEGEHNiThOSQXgqZiaA0Zw74lWD25f45g8G8"
127.0.0.1:6379> get "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IlJFRlJFU0hfVE9LRU4iLCJpYXQiOjE3MzQ1ODM2MDEsImV4cCI6MTczNTE4ODQwMX0.9sO0R4BkEGEHNiThOSQXgqZiaA0Zw74lWD25f45g8G8"
"1"
127.0.0.1:6379>
레디스를 통해서 리프레시 토큰을 관리하도록 수정하였다. 나중에 스프링 시큐리티를 이용하여 OAuth 인증을 한 번 더 만들어볼 생각이다.
댓글남기기