Spring Boot에서 Redis 클러스터 설정하기
클러스터는 띄웠고, 이제 애플리케이션에서 쓰자
Docker로 Redis 클러스터를 구성했으니 Spring Boot에서 연결합니다. 그런데 단일 노드를 쓰던 코드를 그대로 클러스터에 붙이면 십중팔구 두 가지에서 터집니다 — CROSSSLOT 에러와 redis-cli에서 안 보이는 깨진 값. 둘 다 “클러스터가 키를 어떻게 노드로 보내는가”와 “값을 어떻게 직렬화하는가”를 모르면 못 피합니다. 이 글은 application.yml 복붙을 넘어서, 키 → 슬롯 → 노드 라우팅과 직렬화·커넥션 동작을 클라이언트(Lettuce) 레벨에서 설명합니다.
동작 한눈에: 키는 어떻게 노드를 찾는가
클러스터는 데이터를 16384개의 해시 슬롯으로 쪼개 마스터들이 나눠 가집니다. 어떤 키를 읽고 쓰든 클라이언트는 먼저 CRC16(key) mod 16384로 슬롯 번호를 구하고, 그 슬롯을 소유한 노드로 명령을 보냅니다. 초록은 올바른 노드로 직행하는 정상 경로, 빨강은 토폴로지가 바뀌어 엉뚱한 노드에 갔다가 MOVED로 재라우팅되는 경로입니다.
이 그림의 모든 단계 — CRC16, 슬롯 소유, MOVED 재라우팅 — 는 비유가 아니라 클라이언트(Lettuce)가 실제로 하는 일입니다. 하나씩 내려가 봅니다.
클라이언트 선택: Lettuce vs Jedis
Spring Boot의 spring-boot-starter-data-redis는 기본 클라이언트로 Lettuce를 가져옵니다(RedisAutoConfiguration → LettuceConnectionConfiguration이 RedisConnectionFactory를 등록). 왜 Lettuce가 기본일까요?
| Lettuce (기본) | Jedis | |
|---|---|---|
| I/O 모델 | Netty 기반 논블로킹 | 블로킹 소켓 |
| 스레드 안전성 | 하나의 커넥션을 여러 스레드가 공유 가능 | 커넥션이 스레드 안전하지 않음 → 풀 필수 |
| 클러스터 토폴로지 | 자동 인식·갱신 내장 | 지원하나 설정이 더 수동적 |
| 리액티브 | 지원(ReactiveRedisTemplate) | 미지원 |
핵심은 Lettuce는 멀티플렉싱된 단일 커넥션을 스레드끼리 공유한다는 점입니다. 그래서 “동시 요청 수만큼 커넥션이 필요하다”는 풀 기반 사고가 Lettuce에선 대개 불필요합니다(뒤의 커넥션 풀 절 참고). Jedis로 바꾸려면 starter에서 lettuce-core를 빼고 jedis를 추가하면 자동 구성이 JedisConnectionFactory로 전환됩니다 — 이 “클래스패스에 무엇이 있느냐”로 갈리는 동작이 바로 자동 구성의 @ConditionalOnClass입니다.
해시 슬롯과 MOVED/ASK — 라우팅의 핵심
클러스터에는 “전체를 아는 중앙 라우터”가 없습니다. 대신 클라이언트가 슬롯→노드 지도(Lettuce의 Partitions)를 들고 직접 라우팅합니다.
sequenceDiagram
participant C as Lettuce (slot map)
participant A as Node A
participant B as Node B
C->>C: CRC16("product:42") mod 16384 = 1234
C->>A: 지도상 slot 1234 → Node A 로 GET
Note over A: 리샤딩으로 slot 1234가 B로 이동한 상태라면
A-->>C: MOVED 1234 10.0.0.2:6379
C->>C: 슬롯 지도 갱신
C->>B: 올바른 노드 B로 재시도
MOVED: 슬롯이 영구히 다른 노드로 옮겨졌다는 응답. 클라이언트는 지도를 갱신하고 그 노드로 다시 보냅니다.ASK: 슬롯이 마이그레이션 중(일시적)이라는 응답. 이번 요청만 대상 노드로 보내되 지도는 바꾸지 않습니다.max-redirects: 이 리다이렉트를 몇 번까지 따라갈지. 기본 5, 초과하면 예외. 토폴로지가 요동칠 때 무한 추적을 막는 안전장치입니다.
정상 상황에선 Lettuce의 지도가 최신이라 리다이렉트 없이 한 번에 올바른 노드로 갑니다. MOVED가 자주 보인다면 토폴로지 갱신이 안 되고 있다는 신호입니다.
토폴로지 갱신: failover를 견디는 설정
마스터가 죽고 레플리카가 승격(failover)되면 슬롯 소유자가 바뀝니다. 클라이언트 지도가 옛날이면 죽은 노드를 계속 찌릅니다. Lettuce의 adaptive refresh는 MOVED/ASK/재연결 같은 트리거가 오면 지도를 자동 재조회하고, periodic refresh는 주기적으로 갱신합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
data:
redis:
cluster:
nodes: # 시드 노드 — 가능하면 전부 나열(시작 안정성)
- 127.0.0.1:6379
- 127.0.0.1:6380
- 127.0.0.1:6381
- 127.0.0.1:6382
- 127.0.0.1:6383
- 127.0.0.1:6384
max-redirects: 3
lettuce:
cluster:
refresh:
adaptive: true # MOVED/재연결 트리거 시 토폴로지 자동 갱신
period: 30s # + 30초마다 주기 갱신
adaptive: true가 핵심입니다. 이게 없으면 failover 후 클라이언트가 옛 마스터를 계속 호출해 타임아웃이 쌓입니다.
커넥션 풀: Lettuce는 풀이 (대개) 필요 없다
Lettuce는 멀티플렉싱 단일 커넥션을 공유하므로 명령 단위로 풀에서 빌리고 반납할 필요가 없습니다. 풀(commons-pool2)이 의미 있는 경우는 블로킹 연산(MULTI/EXEC 트랜잭션, WATCH, 블로킹 명령)처럼 커넥션을 점유해야 할 때입니다. 풀을 쓰려면 commons-pool2를 클래스패스에 올려야 자동 구성이 풀을 활성화합니다.
1
2
3
4
5
6
7
8
spring:
data:
redis:
lettuce:
pool:
max-active: 16 # commons-pool2 의존성이 있어야 적용됨
max-idle: 8
max-wait: 200ms # 풀 고갈 시 대기 한도(무한 대기 금지)
RedisTemplate, 그리고 직렬화라는 진짜 함정
RedisTemplate은 키·값을 바이트로 바꿀 serializer가 필요합니다. 지정하지 않으면 기본이 JdkSerializationRedisSerializer — 자바 직렬화 바이트라 redis-cli에서 못 읽고, 클래스 구조가 바뀌거나 다른 서비스가 다른 클래스로 읽으면 깨집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(cf);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
| Serializer | 저장 형태 | 비고 |
|---|---|---|
JdkSerializationRedisSerializer(기본) | 바이너리 | 사람이 못 읽음·클래스 결합·이식성 최악 |
StringRedisSerializer | 평문 문자열 | 키에 적합 |
GenericJackson2JsonRedisSerializer | JSON(@class 타입 정보 포함) | 다형성 OK, @class 만큼 용량↑ |
Jackson2JsonRedisSerializer<T> | JSON(타입 고정) | 타입 정보 없음, 단일 타입에 깔끔 |
키만 다룰 거면
RedisTemplate대신StringRedisTemplate(키·값 모두 String)을 쓰는 게 단순하고 안전합니다.
프로덕션에서 실제로 터지는 것들
① 직렬화 불일치 — 쓸 땐 JSON, 읽을 땐 JDK(기본) serializer면 역직렬화가 깨집니다. 쓰는 쪽과 읽는 쪽의 serializer를 반드시 일치시키세요. 여러 서비스가 같은 Redis를 공유하면 직렬화 포맷을 팀 표준으로 못박아야 합니다.
② CROSSSLOT 에러 — 멀티키 명령(MGET, 파이프라인, 트랜잭션, Lua)은 모든 키가 같은 슬롯에 있어야 합니다. 클러스터는 한 명령이 여러 노드에 걸치는 걸 허용하지 않기 때문입니다. 해법은 해시 태그 {} — 중괄호 안 부분만 CRC16에 쓰여, 같은 태그를 가진 키들이 같은 슬롯에 모입니다.
1
2
3
4
user:{42}:profile ┐ 둘 다 "{42}"로 해싱 → 같은 슬롯
user:{42}:cart ┘ → MGET user:{42}:profile user:{42}:cart OK
user:42:profile ┐ 서로 다른 슬롯 → MGET 시
user:43:cart ┘ CROSSSLOT Keys ... 에러
③ 커넥션 타임아웃·풀 고갈 — max-wait를 무한(-1)으로 두면 풀 고갈 시 스레드가 영원히 대기합니다. 명시적 한도 + 적절한 command timeout을 두세요.
디버깅
- Lettuce 로그:
logging.level.io.lettuce.core=DEBUG로 실제 어느 노드로 명령이 가고MOVED가 오는지 추적. - 클러스터 상태:
redis-cli -c cluster slots/cluster nodes로 슬롯 분포·소유자 확인. - 키→슬롯 직접 계산:
redis-cli cluster keyslot "user:{42}:cart"로 해시 태그가 의도대로 묶이는지 검증.
캐시 추상화와 함께
Spring 캐시 추상화(@Cacheable)의 저장소로 이 클러스터를 그대로 쓸 수 있습니다. spring.cache.type: redis면 RedisCacheManager가 자동 구성되어 애너테이션 캐싱이 클러스터에 저장됩니다. 이때도 RedisCacheConfiguration의 value serializer를 JSON으로 맞춰 두는 게 운영상 안전합니다.
면접/리뷰 단골 질문
- Q. 클러스터에서
MGET이 왜 실패하나? → 키들이 서로 다른 슬롯(다른 노드)이라CROSSSLOT. 해시 태그{}로 같은 슬롯에 모아야 한다. - Q.
MOVED와ASK의 차이는? → MOVED는 슬롯이 영구 이동(지도 갱신), ASK는 마이그레이션 중 일시 리다이렉트(지도 미갱신). - Q. Lettuce가 Jedis와 달리 풀이 거의 필요 없는 이유는? → Netty 기반 멀티플렉싱 단일 커넥션을 스레드끼리 공유하기 때문. 블로킹/트랜잭션 점유 시에만 풀이 의미.
정리
- 클러스터 라우팅 =
CRC16(key) mod 16384→ 슬롯 → 소유 노드. 토폴로지가 바뀌면MOVED/ASK로 재라우팅된다. - 기본 클라이언트 Lettuce는 Netty 논블로킹·스레드 공유 단일 커넥션 → 풀이 대개 불필요.
refresh.adaptive: true로 failover에 대응한다. - 멀티키 연산은 해시 태그
{}로 같은 슬롯에 모아CROSSSLOT을 피한다. - 직렬화는 쓰는 쪽·읽는 쪽을 일치시키고, 기본 JDK 직렬화 대신 String/JSON serializer를 명시하자.
관련 글: 애너테이션 캐싱은 캐시 추상화, “클라이언트 클래스패스로 동작이 갈리는” 원리는 자동 구성에서 다룹니다.
