Azure Cache for Redis란?
Azure Cache for Redis란, Azure에서 제공하는 관리형 Redis 서비스이다. Spring 애플리케이션에서 Azure 리소스들에 접근할 때 사용할 수 있는 여러 인증 방식 중, Entra ID 인증 방식을 선택했다. Entra ID 인증 방식에 대해서는 이전 포스팅에서 상세히 다루었으니, 아래 포스팅을 참고하면 좋을 것 같다.
Entra ID 인증 방식은 한 마디로 말하면 Entra 토큰을 통해 Redis와의 Connection을 인증하는 방식이다.
Entra 토큰으로 Cache for Redis를 인증할 때 주의해야 할 점이 있는데, 바로 Entra 토큰이 60~90분 사이에 만료된다는 것이다. 다음 문서를 살펴보면 60~90분(평균 75분) 범위에서 임의의 값이 토큰 수명으로 할당된다고 한다.
공식 문서에 다음과 같이 Entra 토큰이 만료되기 전에 정기적으로 AUTH
명령을 Redis 서버로 보내야한다고 나와 있으며, 실제로 Azure에 문의했을 때에도 “연결이 잘 유지될 수 있도록 토큰 갱신 후에 AUTH 명령어를 실행해야 합니다.” 라고 안내받았다.
Azure에서 제공하는 Lettuce 샘플을 보면, RedisCommands
를 사용하여 60분마다 REDIS AUTH
명령을 실행하는 구조를 갖추고 있다. TimerTask
를 활용해 만료 시을 계산하고, 이를 바탕으로 토큰 갱신 작업을 정기적으로 스케줄링한다.
흠.. 여기까지 봤을 때 고민이 생겼다. 토큰이 만료되기 전에 AUTH 명령을 수행하는 로직을 어떻게 개발해야 할까?? 우선 고민되는 선택지는 두가지였다.
- 샘플처럼 RedisCommands를 통해
REDIS AUTH
명령 수행을 스케줄링한다. - 토큰이 만료되기 전에 Redis Connection을 갱신한다.
(당초 RedisUtil
은 LettuceConnectionFactory
와 RedisTemplate
을 활용하여 개발할 계획이었다.)
RedisCommands는 Lettuce 클라이언트의 인터페이스로, Redis 서버에 직접 명령을 전송하는 저수준 API이다.
✔️ 먼저 1번은 RedisCommands
가 auth
메서드를 제공하므로, 이를 통해 직접 REDIS AUTH
명령을 수행할 수 있다. 그러나 이미 RedisTemplate
을 사용하고 있는데, 굳이 RedisCommands
객체를 따로 만들어야 할 필요가 있을까? 하는 생각이 들었다. 또한 RedisCommands는 저수준 API로서 일반적으로 사용하지 않는다.
✔️ 2번은 일정 시간마다 LettuceConnectionFactory
객체를 새로 생성하여 연결 설정을 해주는 방법이다. 가장 확실한 방법이지만, 성능 저하가 우려되는 부분이 있었다. (Redis와의 연결을 새로 생성하고 해제하는 과정은 리소스를 소모하기 때문이다) Azure에서 제공하는 샘플 중에 RedisTemplate
을 활용한 예시가 있었다면 좋았겠지만, 아쉽게도 그러한 자료는 찾을 수 없었다.
다른 분들과 논의하고 고민하여, 최종적으로 2번 방식을 채택하기로 결정했다. 매번 새로운 연결을 생성함으로써, 항상 유효한 토큰을 사용하여 Redis와 연결할 수 있어 가장 확실할 뿐만 아니라, 프로젝트에서 요구되는 MAU와 동시 접속자 수가 그리 높지 않아 성능에 미치는 영향이 크지 않을 것이라고 판단했기 때문이다.
LettuceConnectionFactory
LettuceConnectionFactory는 Spring Data Redis에서 Lettuce 클라이언트를 사용해서, Redis와의 연결을 관리하는 클래스이다. Lettuce는 비동기 및 동기 방식 모두를 지원하는 Redis 클라이언트로, Redis의 다양한 기능을 활용할 수 있게 해준다.
Redis를 클러스터로 구성하지 않았기 때문에, RedisStandaloneConfiguration
을 사용하여 Redis Config를 설정했다. Entra 토큰을 패스워드로 사용하여, Redis 연결을 설정해준다.
RedisStandaloneConfiguration이란 단일 Redis 서버 인스턴스에 연결하기 위한 설정 클래스로, Redis 클러스터나 복제본 구성이 필요하지 않은 경우, 즉 하나의 Redis 서버만 사용하려는 경우에 사용한다.
/**
* @Method createLettuceConnectionFactory
* @Description LettuceConnectionFactory 생성
* @Return LettuceConnectionFactory
*/
private LettuceConnectionFactory createLettuceConnectionFactory() {
// Entra ID Access Token
String accessToken = getRedisAccessToken().getToken();
// Redis Config 설정
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
redisConfig.setHostName(redisHostname); // Redis 호스트 이름 설정
redisConfig.setPort(redisPort); // Redis 포트 설정
redisConfig.setUsername(redisUsername); // Redis 사용자 이름 설정
redisConfig.setPassword(RedisPassword.of(accessToken)); // 토큰을 비밀번호로 사용
// Lettuce 연결 설정
LettuceConnectionFactory factory = new LettuceConnectionFactory(redisConfig)
factory.afterPropertiesSet(); // 설정 초기화
return factory; // 생성된 연결 팩토리 반환
}
RedisTemplate
RedisTemplate
은 Spring Data Redis에서 제공하는 클래스로, Redis 서버와 상호작용 하는데 필요한 다양한 기능을 제공한다. 이를 통해 Redis에 데이터 저장, 조회, 삭제 등을 손쉽게 수행할 수 있으며, 앞서 생성한 LettueConnectionFactory를 통해 연결 팩토리를 설정한다.
/**
* @Method redisTemplate
* @Description RedisTemplate 생성
* @Return RedisTemplate
*/
@Bean
private RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(lettuceConnectionFactory); // 연결 팩토리 설정
template.setKeySerializer(new StringRedisSerializer()); // 키 직렬화 설정
template.setValueSerializer(new StringRedisSerializer()); // 값 직렬화 설정
template.setHashKeySerializer(new StringRedisSerializer()); // 해시 키 직렬화 설정
template.setDefaultSerializer(new StringRedisSerializer()); // 모든 경우
template.afterPropertiesSet(); // 초기화
return template; // 생성된 RedisTemplate 반환
}
다음과 같이 RedisTemplate
를 통해 Redis GET/SET/DELETE 등의 작업이 가능하다.
/**
* @Method set
* @Description Redis에 값 설정
* @Param key Redis 키
* @Param value Redis 값
*/
public void set(String key, String value) {
try {
redisTemplate.opsForValue().set(key, value);
} catch (Exception e) {
log.error("Redis 데이터 저장 중 오류 발생. key: {}", key, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method setWithExpiration
* @Description Redis에 값 설정 (만료 시간 포함)
* @Param key Redis 키
* @Param value Redis 값
* @Param timeout 만료 시간
*/
public void setWithExpiration(String key, String value, Duration timeout) {
try {
redisTemplate.opsForValue().set(key, value, timeout);
} catch (Exception e) {
log.error("Redis 데이터 저장 중 오류 발생. key: {}, timeout: {}", key, timeout, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method get
* @Description Redis에서 값 가져오기
* @Param key Redis 키
* @Return Redis 값
*/
public String get(String key) {
try {
return redisTemplate.opsForValue().get(key);
} catch (Exception e) {
log.error("Redis 데이터 조회 중 오류 발생. key: {}", key, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method delete
* @Description Redis에서 키 삭제
* @Param key Redis 키
*/
public void delete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.error("Redis 데이터 삭제 중 오류 발생. key: {}", key, e);
throw new RedisException(REDIS_ERROR, e);
}
}
}
}
Entra 토큰 갱신
@Scheduled
어노테이션을 사용하여 55분마다 LettuceConnectionFactory
를 생성하도록 구성했다.
@Scheduled(fixedRate = TOKEN_REFRESH_INTERVAL)
public void refreshConnection() {
try {
log.info("Redis 연결 갱신 시작");
RedisConnectionFactory newFactory = redisConfig.redisConnectionFactory(); // 새로운 LettuceConnectionFactory 생성
RedisConnectionFactory oldFactory = this.currentFactory; // 이전 연결 저장
// RedisTemplate에 새 연결 설정
redisTemplate.setConnectionFactory(newFactory);
this.currentFactory = newFactory;
// 이전 연결 종료
if (oldFactory instanceof DisposableBean) {
((DisposableBean) oldFactory).destroy();
}
log.info("Redis 연결 갱신 완료");
} catch (Exception e) {
log.error("Redis 연결 갱신 중 오류 발생", e);
}
}
LettuceConnectionFactory
로그를 살펴보니, 기본적으로 60분마다 내부적으로 ReConnection을 진행하고 있어서, RedisTemplate
에 새로운 LettuceConnecitonFactory
를 set 해주는 것으로 마무리했다. (자동으로 새로 설정된 값으로 Reconnection을 진행한다)
RedisUtil
목적에 따라 Config 파일과 Util로 구분하여 다음과 같이 구성하였다.
RedisConfig.java
@Configuration
@ConfigurationProperties(prefix = "azure.redis")
@Getter
@Setter
public class RedisConfig {
private String host;
private int port;
private String username;
private final AzureTokenProvider tokenProvider;
public RedisConfig(AzureTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
/**
* @Method redisConnectionFactory
* @Description RedisConnectionFactory 생성
* @Return RedisConnectionFactory
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
redisConfig.setHostName(host);
redisConfig.setPort(port);
redisConfig.setUsername(username);
redisConfig.setPassword(RedisPassword.of(tokenProvider.getRedisAccessToken().getToken()));
LettuceConnectionFactory factory = new LettuceConnectionFactory(redisConfig);
factory.afterPropertiesSet();
return factory;
}
/**
* @Method redisTemplate
* @Description RedisTemplate 생성
* @Return RedisTemplate
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setDefaultSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
RedisUtil.java
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisUtil {
private static final long TOKEN_REFRESH_INTERVAL = 55 * 60 * 1000; // 55분
private final RedisTemplate<String, String> redisTemplate;
private final RedisConfig redisConfig;
private volatile RedisConnectionFactory currentFactory;
/**
* @Method init
* @Description RedisUtil 초기화 메서드 : @Value로 주입된 값들이 생성된 후에 초기화 작업을 보장할 수 있도록 @PostConstruct 사용
*/
@PostConstruct
public void init() {
this.currentFactory = redisConfig.redisConnectionFactory();
}
/**
* @Method refreshConnection
* @Description 55분 마다 RedisConnection을 갱신
*/
@Scheduled(fixedRate = TOKEN_REFRESH_INTERVAL)
public void refreshConnection() {
try {
log.info("Redis 연결 갱신 시작");
RedisConnectionFactory newFactory = redisConfig.redisConnectionFactory();
RedisConnectionFactory oldFactory = this.currentFactory;
redisTemplate.setConnectionFactory(newFactory);
this.currentFactory = newFactory;
if (oldFactory instanceof DisposableBean) {
((DisposableBean) oldFactory).destroy();
}
log.info("Redis 연결 갱신 완료");
} catch (Exception e) {
log.error("Redis 연결 갱신 중 오류 발생", e);
}
}
@PreDestroy
public void cleanup() {
try {
if (currentFactory instanceof DisposableBean) {
((DisposableBean) currentFactory).destroy();
log.info("Redis 연결 자원 정리 완료");
}
} catch (Exception e) {
log.error("Redis 연결 자원 정리 중 오류 발생", e);
}
}
/**
* @Method set
* @Description Redis에 값 설정
* @Param key Redis 키
* @Param value Redis 값
*/
public void set(String key, String value) {
try {
redisTemplate.opsForValue().set(key, value);
} catch (Exception e) {
log.error("Redis 데이터 저장 중 오류 발생. key: {}", key, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method setWithExpiration
* @Description Redis에 값 설정 (만료 시간 포함)
* @Param key Redis 키
* @Param value Redis 값
* @Param timeout 만료 시간
*/
public void setWithExpiration(String key, String value, Duration timeout) {
try {
redisTemplate.opsForValue().set(key, value, timeout);
} catch (Exception e) {
log.error("Redis 데이터 저장 중 오류 발생. key: {}, timeout: {}", key, timeout, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method get
* @Description Redis에서 값 가져오기
* @Param key Redis 키
* @Return Redis 값
*/
public String get(String key) {
try {
return redisTemplate.opsForValue().get(key);
} catch (Exception e) {
log.error("Redis 데이터 조회 중 오류 발생. key: {}", key, e);
throw new RedisOperationException(REDIS_ERROR, e);
}
}
/**
* @Method delete
* @Description Redis에서 키 삭제
* @Param key Redis 키
*/
public void delete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.error("Redis 데이터 삭제 중 오류 발생. key: {}", key, e);
throw new RedisException(REDIS_ERROR, e);
}
}
}
}
"이게 최선일까?"라는 고민은 여전히 머릿속을 맴돌고 있다. 사실 이 프로젝트는 이미 철수를 한 뒤여서, 내가 직접적인 개선을 하기는 어렵다. 현재는 다른 프로젝트에 참여하고 있지만, 개인적으로 고민도 많이했고 주도적으로 개발했던 만큼 기억에 깊이 남아 있다. 그래서 회고 형식으로 이 내용을 정리하게 되었다. 프레임워크 역할을 처음 맡아보며 배우기도 많이 배우고, 고민도 정말 많이했다. 그래서 이 글을 보고, 혹시 더 나은 방법이 있다면 편하게 의견을 남겨주면 좋을 거 같다.