[Redis] 캐시가 SPOF가 되지 않으려면
Redis는 기존 DB를 조회하는 느린 방식을 개선하기 위한 빠른 캐시 데이터 저장소입니다.
다만 비싼 가격으로 인해 대부분의 정보는 DB에 저장하고 인증/캐싱/이벤트 처리 등 빠른 응답이나 잦은 조회가 필요한 핵심 데이터에 대해 Redis를 사용합니다.
하지만 이런 장점은 Redis가 단일장애지점(SPOF)이 될 잠재적 위험성도 가지고 있기도 합니다.
Redis 기본 개념
자료구조
redis는 다양한 자료 구조를 지원합니다. String, Hash, List 등등…
소스 코드에서 redis에 값을 불러올때 사용하는 RedisTemplate.opsForValue()는 대표적인 String이며, Redis를 세션에 저장할 경우 필드가 여러개인데 이때는 Hash를 사용합니다.
Hash 형태 세션 저장값
1
HSET spring:session:sessions:abc123 sessionAttr:userId 1001
TTL
Redis는 기본적으로 저장공간이 크지 않기 때문에 저장소를 관리해줘야합니다.
이 삭제 방식에는 두 가지가 있습니다.
- Lazy(수동적) 삭제: 그 키에 접근했을 때 만료 시각을 확인하고 그때 지움
- Active(능동적) 삭제: 백그라운드에서 주기적으로 샘플링해서 만료된 키를 찾아 지움
이 방식으로 인해 TTL이 180초여도 180초에 정확히 데이터가 삭제된다는 보장은 없습니다.
영속성
Redis는 재시작해도 데이터가 남아 있습니다. 이는 데이터를 정기적으로 디스크내 백업을 하기 때문인데, 이 방식에 있어 아래 두 가지 설정이 있습니다.
- RDB: 주기적 스냅샷, 빠름
- AOF: 모든 명령어 기록 , 유실 최소화, 느림
Redis를 단순히 사용자 정보 저장으로 한다면 RDB 설정만 하고, 주요 정보 저장(BLACKLIST) 등을 한다면 둘 다 설정하는 게 좋아 보입니다.
메모리 관리
데이터가 꽉 차면 eviction policy 기반 삭제를 시작합니다.
주요 정책은 3가지입니다.
allkeys-lru— 가장 최근에 안 쓴 키부터 삭제 (범용 캐시에 흔함)volatile-lru— TTL이 설정된 키 중에서만 LRU 적용noeviction— 삭제 안 하고 쓰기 요청을 에러로 거부 (기본값)
noeviction 이 기본값이라서 만약 데이터가 다 차면 세션 저장에서 전부 에러를 뿜어내게 되는데… 세션 저장이 안 되니 서비스 전체가 장애가 될 수 있습니다.
연결 설정
Spring과 Redis를 연결하는 방식은 Lettuce, Jedis 두 가지가 있습니다.
- Lettuce: 커넥션 하나로 모든 스레드가 공유 - 기본값
- Jedis: 스레드당 커넥션 하나
Jedis는 스레드랑 직접 커넥션을 맺기에 과도한 TCP 핸드셰이크로 부하가 커질 수 있습니다.
Lettuce라면 Failover 같은 일시적 이상에도 모든 연결이 하나의 터널에서 대기하기에 모든 요청이 한번에 밀리는 트레이드 오프가 있습니다.
Redis의 주요 활용
세션 저장소
Redis의 주요 활용도 중 하나는 사용자 세션을 저장하는 저장소입니다.
Spring은 request 요청에 필터 체인 단에서 SessionRepositoryFilter가 이 요청을 별도의 Request Wrapper로 감싸줍니다.
이 Wrapper의 세션의 경우 SessionRepository 인터페이스 기반으로 의존을 하는데 이를 누가 구현하는지가 지정된 저장소에 따라 갈립니다.
- RedisSessionRepository(Redis)
- JdbcIndexedSessionRepository(JDBC)
- MapSessionRepository(Heap)
spring-session-data-redis 를 의존성으로 추가하면 Redis를 세션으로 사용할 수 있게 구현이됩니다.
이 구현체가 기존 서블릿의 세션 관리 API를 모두 갈아치우면서 개발자가 단순히 getSession()을 해도 구현된 (Redis나, JDBC)의 getSession 을 오버라이딩하여 지정된 저장소를 세션 저장소로 사용할 수 있습니다.
데이터 저장소
Redis에 데이터를 저장/조회 등을 하기 위해서는 Lettuce/Jedis 같은 저수준 클라이언트를 감싸서 사용할 수 있는 Spring이 제공하는 고수준 API인 RedisTemplate<K, V> 를 제공합니다.
다만 서비스의 상태나 필요한 값으로 매핑하기 위해 대부분 별도의 @Bean으로 등록하여 사용하는게 일반적입니다.
1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
return redisTemplate;
}
- KeySerializer:
StringRedisSerializer— 키는 항상 문자열로 - ValueSerializer:
Jackson2JsonRedisSerializer— 값을 JSON으로 저장해서 사람도 읽고 다른 시스템도 파싱 가능
그리고 이 Bean을 사용해서 아래처럼 저장/조회를 수행합니다.
1
2
3
4
5
// 저장
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(180));
// 조회
Object result = redisTemplate.opsForValue().get(key);
조회인 get()의 경우 값이 없을 경우 null을 리턴합니다. (NPE 주의)
Redis 장애
Redis가 주요 데이터 저장/조회 및 세션을 담당하는 중요한 역할이기에 단일장애지점(SPOF)가 되지 않도록 개선은 필수적입니다.
이중화 구성
가장 중요한건 이중화 구성입니다. Redis가 어떤 이유로든 정상적인 기능이 불가하다면 이를 보조할 다른 Redis를 준비하고 자동으로 fail-over 되도록 구성해야합니다.
Azure redis의 경우 standard/premium tier 이상에서 이중화를 제공합니다.
타임아웃 설정
이중화가 되어 있더라도 fail-over되는 몇초간 장애는 발생합니다.
Lettuce 환경에서는 모든 요청이 redis의 응답을 기다리게 되기에 적절한 타임아웃 설정은 필수적입니다.
1
2
3
4
5
spring:
data:
redis:
connect-timeout: 5s
timeout: 5s
Fail-open 처리
redis가 실패하면 500에러가 발생합니다. 이게 상위로 오류를 전파하지 않도록 try-catch를 적용해 적절한 fallback하도록 합니다.
1
2
3
4
5
6
try {
return redisTemplate.opsForValue().get(key);
} catch (Exception e) {
log.warn("Redis 조회 실패, DB로 폴백: {}", key, e);
return db.get(key);
}
쓰레드 풀 고갈 방지
Lettuce는 커넥션 하나를 통해 Spring과 Redis가 통신합니다.
단순 Fail-over는 1초 이내지만 Failover가 길어진다면 Timeout이 5000ms인 상태일 경우 하나의 연결이 5000ms 붙잡고 있고 이 시간동안 뒤이은 redis 요청은 뒤로 밀리게 됩니다.
이는 모든 요청이 block 상태로 되기에 스레드 풀 고갈로 redis를 사용하지 않은 업무까지 수행할 수 없어집니다.
이를 막기 위해 Redis가 정상적이지 않음이 확인되면 redis 조회 자체를 생략하는 써킷브레이커(Resilience4j) 도입이 필요합니다.
단순히 Redis만 사용한다면 아래처럼 간이 써킷 도입도 검토해볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class SimpleRedisCircuitBreaker {
private CircuitState state = CLOSED;
private long lastFailTime = 0;
public String get(String key) {
if (state == OPEN && lastFailTime + 5000 < System.currentTimeMillis()) {
state = HALF_OPEN; // 자동 복구 시도
}
if (state == OPEN) {
return "fallback";
}
try {
String value = redisTemplate.opsForValue().get(key);
if (state == HALF_OPEN) {
state = CLOSED; // 복구 성공
}
return value;
} catch (Exception e) {
state = OPEN;
lastFailTime = System.currentTimeMillis();
return "fallback";
}
}
}
세션 인증 실패
Redis가 세션을 담당하지만 이 Redis가 장애가 날 경우 서비스 영향은 피할 수 없습니다.
사용자에게 서비스 장애를 고지하며, 인증이 필요하지 않은 서비스는 정상적으로 이용할 수 있도록 조치해야 합니다. (try-catch, 스레드 풀 고갈 방지)
임시적으로 로컬 캐시를 통해 fallback 할 수 있지만 다중 Pod 환경에서 세션 불일치가 발생할 수 있습니다. sticky session을 통해 조치가 가능하지만 redis를 세션 저장소로 쓰는 이유인 무상태 확장성을 위반하기 때문에 자주 쓰이는 조합은 아닙니다.
마치며
Redis는 DB보다 빠른 응답속도와 확장성을 제공하지만 그에 따른 대가도 가지고 있습니다. (등가교환)
그렇기에 도입시 서비스가 어떤 대가를 가지고 있는지 파악하고 이를 버티기 위한 이중화, 타임아웃 설정,서킷 브레이커로 이를 해결하기 위해 노력하는 시간을 가졌습니다.
새 기술은 편의성과 이점을 주지만 이에 대한 최악의 상황에 대한 대비법을 같이 설계해야한다는걸 정리하면서 느끼게 되었습니다.
