요약
Redis 서버 병목이 걱정되어 세션과 일반캐시 전용으로 서버를 분리해 2개의 레디스를 운용했습니다. Redis 설정클래스를 각각 만들었으며 RedisTemplate도 2개를 만들었습니다. 캐시모듈에선 캐시타입(이름)을 기반으로 적절한 RedisTemplate을 사용하도록 했습니다. 그리고 redis 서버들은 maxmemory와 eviction 설정으로 성능을 향상시켰습니다.
그러나 성능 테스트시 레디스 서버 1개로도 충분함을 확인해서 스케일아웃을 철회했습니다.
이 서비스는 대부분의 API요청에 대해 세션키를 검증합니다. 세션정보는 Redis와 DB 모두에 저장되어있지만 보통은 매 번 Redis 서버를 조회하게 됩니다. 레디스는 싱글 스레드기 때문에, 실행중인 명령어 말고는 대기해야합니다. 그래서 많은 커넥션으로부터 요청이 온다면 병목현상이 심해질 것입니다. 또한, 최대 메모리 용량을 설정해두지 않는다면, 레디스의 저장내용이 최대 물리 용량을 초과하면 디스크 내의 스왑영역을 사용함으로써 계속 데이터를 저장하려고하는데, 이는 디스크를 계속 조회하게 되어 레디스의 성능이 저하되는 문제가 있습니다.
그래서 세션 정보를 담는 서버와 일반 캐시를 저장할 서버를 분리해서 운용했습니다. 레디스 서버가 2개면 캐시를 처리할 CPU가 2개가 되므로 성능이 향상됩니다.
application.properties
spring:
redis:
session:
host: 49.50.164.244
port: 6380
cache:
host: 49.50.164.244
port: 6379
Session전용 RedisConfig 와 Cache전용 RedisConfig 를 구분해 설정클래스를 만들어 각각 ConnectionFacotry와 RedisTemplate 등의 빈을 등록했습니다. 그럼 Session과 Cache 전용의 빈들이 같은타입으로 2개씩 생성되기 때문에, 적절한 빈을 사용하기 위해 name 값을 적절히 넣어주고, 사용할 땐 @Qualifier로 빈 이름을 명시해주었습니다.
캐시 모듈 에도 변화가 필요했습니다. 기존엔 redisTemplate이 하나였기 때문에 캐시모듈에 redisTemplate을 주입해서 내부적으로 redisTemplate을 사용했었는데, 지금은 RedisTemplate이 세션용, 캐시용으로 총 2개가 되었습니다. 그래서 redisTemplate을 주입하는 방식을 유지하면 세션 전용 캐시모듈, 일반 캐시 전용 캐시모듈 빈을 직접 등록해야했고 사용하는 측에선 해당 캐시타입이 어떤 캐시모듈을 사용하는 지 구분해서 주입받아야하기 떄문에 모듈 사용 시 불편함을 초래합니다. 특정 캐시타입을 다른 레디스 서버를 사용하게끔 바꾸려면, 관련 캐시모듈 사용 부분을 모두 수정해야하기도 합니다.
그래서 캐시모듈에 RedisTemplate을 직접적으로 주입하지 않고, redisTemplateFinder 라는 빈을 주입받아, 캐시타입이 Session이라면 Session 전용 RedisTemplate을, 아니라면 Cache전용 RedisTemplate을 사용할 수 있게하는 구조를 택했습니다. 덕분에 캐시모듈을 사용하는 입장에선 이 캐시타입이 어떤 redisTemplate을 사용해야하는지 알 필요가 없어졌습니다.
// CacheModule 중
public <K, V> V get(CacheType cacheType, K key){
// redisTemplateFinder를 통해 캐시타입을 기준으로 적절한 redisTemplate을 얻어옴
ValueOperations ops = redisTemplateFinder.findOf(cacheType).opsForValue();
String cacheKey = getCacheKey(cacheType, key);
return (V)ops.get(cacheKey);
}
RedisTemplateFinder이 캐시타입을 기준으로 적절한 redisTemplate을 찾아주게 되는데, 어떻게 할 지 고민이 되었습니다.
처음엔 아래와 같이 캐시타입별로 분기를 나눠 레디스 템플릿을 직접 리턴하는 식으로 했습니다.
// RedisTemplateFinder 중
public RedisTemplate findOf(CacheType cacheType){
if (cacheType.equals(CacheType.SESSION_INFO))
return sessionRedisTemplate;
else return cacheRedisTemplate;
}
하지만 이렇게 할 경우 캐시 타입이 추가되었을 때 이 분기 로직에 대해 인지하고 필요하다면 분기를 나눠주는 수정이 필요하게 되는데, 이 부분을 깜빡하고 그냥 진행하여 의도치 않은 서버에 저장될 수 있는 문제가 있었습니다.
위의 문제를 해결하기 위해, 캐시 타입을 추가할 때에 레디스 서버에 대한 정보를 명시하도록 하는 방식으로 진행했습니다. 이렇게 하면 캐시를 추가할 때 정확한 서버를 맵핑할 수 있습니다.
캐시타입엔 RedisServerType이라는 타입의 필드가 추가되었는데, 이 타입에는 해당 서버로 연결되는 RedisTemplate Bean의 이름을 명시했습니다.
public enum CacheType {
// 캐시타입에 레디스 서버타입 명시(마지막 인자)
SESSION_INFO("SessionInfo", ONE_HOUR, RedisServerType.SESSION),
CIGARETTE("Cigarette", ONE_DAY, RedisServerType.CACHE),
...
public enum RedisServerType {
// 레디스 서버타입에 redisTemplate Bean 이름 추가(마지막 인자)
SESSION("session", "sessionRedisTemplate"),
CACHE("cache", "cacheRedisTemplate")
;
...
}
RedisTemplateFinder에선 RedisTemplate 빈들을 주입해서 map에 담아둡니다. 그리고 findOf 메서드로 RedisTemplate을 조회할 땐, 인자로 들어온 캐시타입에 지정된 레디스 서버에 쓰이는 RedisTemplate 빈 이름을 key로 맵에서 조회하는 방식을 택했습니다.
이 방식의 단점은 캐시 서버를 추가할 때 RedisServerType
enum의 redisTemplateBeanName
필드를 정확히 기재해야하지 않으면 문제가 될 순 있다는 것입니다. 하지만 캐시가 추가되었을 때 확실히 저장될 서버를 명시하도록 강제한다는 장점이 있어 이렇게 진행했습니다.
@Component
@RequiredArgsConstructor
public class RedisTemplateFinder {
// 빈 자동 주입으로 인해, key는 bean Name이다.
private final Map<String, RedisTemplate> redisTemplateMap;
// map에서 캐시타입에 맞는 RedisTemplate bean 찾아서 반환
public RedisTemplate findOf(CacheType cacheType) {
return redisTemplateMap.get(cacheType.getRedisServerType().getRedisTemplateBeanName());
}
}
redis는 32bit 시스템이 아니면 기본적으로 최대 사용 가능 메모리 양에 제한이 없습니다. 기본적으로는 maxmemory 제한이 없으며 noeviction 정책이기 때문에, 메모리 용량이 다 차도 가상메모리까지 스왑해가며 계속 데이터를 저장하게 됩니다. 이는 데이터를 계속적으로 저장하려는 목적이라면 괜찮을 수 있지만, 레디스를 캐시로 사용하고 있는 이 프로젝트에선 불필요하기 때문에 수정이 필요했습니다.
maxmemory값을 두 서버 모두 1GB로 제한함으로써 잘 안쓰는 데이터는 삭제되도록 했습니다.
세션 캐시 : allkeys-lru
일반 캐시 : allkeys-lfu
maxmemory-policy, 즉 eviction 정책은 두 서버가 다르게 적용했습니다. 세션 정보가 만료되면 로그아웃이 되게 되는데, 최근 이용했다가 갑자기 로그아웃되면 사용자 입장에서 불편할 것 같아 근시성을 최우선으로 두고 allkeys-lru로 결정했습니다. 일반 캐시같은 경우엔 자주 사용되는 게 캐시되어 있는 게 DB 조회 횟수를 줄이기 때문에 빈도를 최우선으로 두고 allkeys-lfu로 결정했습니다. allkeys-XX 말고volatile-XX의 eviction 정책도 있었지만, expire set이 없을 땐 noeviction과 동일하게 작동해 에러를 발생시키기 때문에 allkeys-XX 정책을 사용했습니다.
담배200 프로젝트에서 캐시 서버를 2개 사용하는 걸 적용해봤었는데, 핵심 요청의 목표 TPS를 설정하고 성능 테스트 해봤을 때, WAS가 3개고 DB는 master/slave 2개가 필요했는데, Redis는 1개만으로도 충분히 감당 가능해서 캐시와 세션 서버를 분리하지 않기로 결정했습니다.