Spring Boot 환경에서 Redis를 도입할 때, 단순히 의존성을 추가하고 RedisTemplate을 사용하는 것에서 그치는 경우가 많습니다. 하지만 Redis 클라이언트의 동작 방식을 제대로 이해하지 못하면, 운영 환경에서 예상치 못한 커넥션 폭증이나 성능 저하를 마주할 수 있습니다.
이 글에서는 Spring Boot에서 사용할 수 있는 Redis 클라이언트들을 비교하고, 기본 클라이언트인 Lettuce의 내부 동작 방식과 Connection Pool의 중요성에 대해 실제 트러블슈팅 경험을 바탕으로 설명합니다.
Spring Boot에서 Redis를 사용할 때 선택할 수 있는 클라이언트는 크게 세 가지입니다.
| Jedis | Lettuce | Redisson | |
|---|---|---|---|
| 동작 방식 | 동기, 블로킹 | 비동기, 논블로킹 | 비동기, 논블로킹 |
| Thread-safe | ❌ (Pool 필수) | ✅ | ✅ |
| Reactive 지원 | ❌ | ✅ | ✅ |
| Spring Data Redis | ✅ | ✅ | ✅ (별도 연동) |
| 분산락 / 고수준 기능 | ❌ | ❌ | ✅ |
| Spring Boot 기본값 | ❌ | ✅ | ❌ |
Spring Boot 2.0부터 기본 Redis 클라이언트는 Lettuce입니다. spring-boot-starter-data-redis 의존성만 추가하면 별도 설정 없이 Lettuce가 사용됩니다.
Jedis는 커넥션이 thread-safe하지 않아 멀티스레드 환경에서 반드시 Connection Pool을 사용해야 하고, 동기 블로킹 방식이라 고부하 환경에서 불리합니다. Redisson은 분산락, 분산 컬렉션 등 고수준 기능이 필요할 때 적합하며, 다음 편에서 자세히 다룰 예정입니다.
Lettuce는 Netty 기반의 비동기 논블로킹 Redis 클라이언트입니다.
Lettuce는 내부적으로 단일 TCP 커넥션(StatefulRedisConnection)을 통해 모든 커맨드를 처리합니다. 커맨드는 Netty의 이벤트 루프에서 순차적으로 처리되며, 각 커맨드는 내부 큐에 적재되어 순서대로 Redis 서버에 전송됩니다.
이 구조 덕분에 여러 스레드가 하나의 커넥션을 공유해도 thread-safe합니다. 각 스레드가 커맨드를 큐에 넣으면, Netty 이벤트 루프가 순서대로 처리하기 때문입니다.
Lettuce(+ Spring Data Redis)에서 커넥션을 사용하는 방식은 크게 두 가지로 나뉩니다.
별도 설정이 없으면 Lettuce는 하나의 커넥션을 모든 스레드가 공유합니다.
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379);
// 기본 LettuceConnectionFactory — Connection Pool 없음
return new LettuceConnectionFactory(config);
}
}
일반적인 GET/SET 커맨드처럼 커넥션을 독점할 필요가 없는 작업에서는 이 방식으로 충분합니다. 커넥션 하나를 재사용하므로 리소스 효율이 높습니다.
커넥션을 독점적으로 점유해야 하는 작업에는 Connection Pool이 필요합니다. 대표적으로:
executePipelined)MULTI/EXEC)BLPOP 등)@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379);
LettucePoolingClientConfiguration poolingConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(connectionPoolConfig())
.build();
return new LettuceConnectionFactory(config, poolingConfig);
}
}
일반적으로 Redis 커맨드는 요청 → 응답 → 요청 → 응답 형태로 round-trip마다 네트워크 지연이 발생합니다. Pipeline은 여러 커맨드를 한 번에 묶어서 전송하고 응답도 한 번에 받는 방식으로, 네트워크 왕복 비용을 크게 줄일 수 있습니다.
Spring Data Redis에서는 RedisTemplate.executePipelined()로 Pipeline을 사용합니다.
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String key : keyList) {
connection.stringCommands().get(key.getBytes(StandardCharsets.UTF_8));
}
return null;
});
Pipeline을 사용한다고 해서 Redis 서버가 커맨드를 동시에 실행하는 것은 아닙니다.
Redis 서버는 싱글 스레드 이벤트 루프 기반으로 동작하므로, Pipeline으로 묶인 커맨드도 수신된 순서대로 하나씩 순차 실행됩니다.
Pipeline의 핵심은 서버의 실행 방식 변경이 아니라, 클라이언트 ↔ 서버 간의 네트워크 왕복(RTT) 횟수를 줄이는 것입니다.
[ Without Pipeline ] — N번의 RTT
Client Redis Server
│ │
│── GET key1 ──────────────────► │ ← 수신 즉시 실행
│ ◄──────────────────── value1 ──│
│ │
│── GET key2 ──────────────────► │ ← 수신 즉시 실행
│ ◄──────────────────── value2 ──│
│ │
│── GET key3 ──────────────────► │ ← 수신 즉시 실행
│ ◄──────────────────── value3 ──│
총 RTT: 3회 (커맨드 수만큼)
[ With Pipeline ] — 1번의 RTT
Client Redis Server
│ │
│── GET key1 ──────────────────► │ ┐
│── GET key2 ──────────────────► │ │ 싱글 스레드가
│── GET key3 ──────────────────► │ │ 순서대로 실행
│ │ │
│ ◄────────────────── value1 ────│ │ 결과를 한번에
│ ◄────────────────── value2 ────│ │ 응답
│ ◄────────────────── value3 ────│ ┘
총 RTT: 1회
⚠️ Pipeline은 병렬 실행이 아닙니다.
서버는 커맨드를 순서대로 하나씩 실행하며, 절약되는 것은 네트워크 왕복 비용(RTT) 입니다.
또한 RTT 외에도 시스템 콜(syscall) 비용도 줄어듭니다. Pipeline 없이는 커맨드마다 read() / write() 시스템 콜이 발생해 user space ↔ kernel space 간 컨텍스트 스위칭이 일어나지만, Pipeline을 사용하면 여러 커맨드를 단 한 번의 read() / write() 시스템 콜로 처리합니다.
이 두 가지 효과가 합쳐져 Redis 공식 문서 기준으로 Pipeline 적용 시 처리량이 최대 10배까지 향상될 수 있습니다.
단, 한 번에 너무 많은 커맨드를 묶으면 서버가 응답을 메모리에 큐잉해야 하므로 메모리 부하가 증가합니다. 1,000 ~ 10,000개 단위로 나눠서 전송하는 것이 권장됩니다.
Pipeline은 내부적으로 openPipeline()을 호출하여 커넥션을 파이프라인 모드로 전환합니다. 이 상태에서는 커맨드가 즉시 전송되지 않고 버퍼에 누적되다가 flushCommands() 시점에 한꺼번에 전송됩니다.
만약 이 커넥션을 다른 스레드와 공유한다면, 다른 스레드의 커맨드가 파이프라인 버퍼에 섞여 의도치 않은 커맨드가 함께 전송되거나, 응답 순서가 뒤섞이는 문제가 발생합니다. 따라서 Pipeline은 반드시 커넥션을 독점해야 합니다.
아래와 같이 여러 키에 대해 SMEMBERS 커맨드를 Pipeline으로 묶어 처리하는 코드가 있었습니다. 이 코드는 가장 많이 호출되는 API의 핵심 로직이었습니다.
public Map<Long, Set<Object>> getGroupIdAndItemIdSetMap(List<Long> groupIdList, String cacheKey) {
Map<Long, Set<Object>> resultMap = new HashMap<>();
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Long groupId : groupIdList) {
String key = cacheKey + ":" + groupId;
connection.setCommands().sMembers(key.getBytes(StandardCharsets.UTF_8));
}
return null;
});
for (int i = 0; i < groupIdList.size(); i++) {
Set<Object> items = (Set<Object>) results.get(i);
if (items.contains("PLACEHOLDER")) {
resultMap.put(groupIdList.get(i), Collections.emptySet());
} else {
resultMap.put(groupIdList.get(i), items);
}
}
return resultMap;
}
executePipelined()는 내부적으로 커넥션을 독점적으로 점유해야 합니다. Connection Pool이 설정되지 않은 상태에서는 Shared Connection을 파이프라인 전용으로 사용할 수 없기 때문에, 매 executePipelined() 호출마다 새로운 커넥션을 생성하게 됩니다.
트래픽이 몰리는 피크타임에 이 API가 대량으로 호출되자, Redis 신규 커넥션 수립 지표가 1,000개 이상으로 폭증했습니다. 이는 Redis 서버의 커넥션 부하를 높이고 자원 고갈 위험을 초래하는 상황이었습니다.
[Connection Pool 없는 경우]
스레드 A → executePipelined() → 새 커넥션 생성 → 사용 후 종료
스레드 B → executePipelined() → 새 커넥션 생성 → 사용 후 종료
스레드 C → executePipelined() → 새 커넥션 생성 → 사용 후 종료
...
(동시 요청 수만큼 커넥션 생성)
[Connection Pool 있는 경우]
스레드 A → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 B → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 C → executePipelined() → Pool이 고갈되면 maxWait 동안 대기 → 대여
...
(최대 maxTotal 수의 커넥션만 유지)
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration serverConfig =
new RedisStandaloneConfiguration("localhost", 6379);
LettucePoolingClientConfiguration poolingConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(connectionPoolConfig())
.build();
return new LettuceConnectionFactory(serverConfig, poolingConfig);
}
private GenericObjectPoolConfig<StatefulConnection<?, ?>> connectionPoolConfig() {
GenericObjectPoolConfig<StatefulConnection<?, ?>> poolConfig = new GenericObjectPoolConfig<>();
// 피크 기준 전체 엔드포인트 동시 Redis 작업 수의 2~3배 여유를 확보한 값
poolConfig.setMaxTotal(16);
// maxTotal의 절반: 트래픽 스파이크 시 신규 커넥션 생성 없이 즉시 사용 가능한 커넥션 예비
poolConfig.setMaxIdle(8);
// 저트래픽 시간대에도 최소 2개 상시 유지하여 첫 요청 시 cold start 지연 방지
poolConfig.setMinIdle(2);
// 풀 고갈 시 최대 대기 시간: commandTimeout보다 짧게 설정하여 풀 대기 중 타임아웃 선행 방지
poolConfig.setMaxWait(Duration.ofSeconds(2));
// eviction 실행 시 유휴 커넥션 유효성 검증: 커넥션 끊김 문제를 선제적으로 감지
poolConfig.setTestWhileIdle(true);
// 30초 주기로 eviction 스레드를 실행하여 유휴 커넥션 정리 및 유효성 검증
poolConfig.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
// 60초 초과 유휴 커넥션 회수: minIdle 아래로 풀 크기를 줄여 Redis 서버 측 커넥션 부하 최소화
poolConfig.setMinEvictableIdleDuration(Duration.ofSeconds(60));
return poolConfig;
}
}
설정 전 (기본값 사용 시)
GenericObjectPoolConfig를 별도로 지정하지 않으면 Commons Pool2의 기본값이 적용됩니다. 기본값은 maxTotal=8, maxIdle=8, minIdle=0, maxWait=-1(무제한) 입니다.
이 상태에서 executePipelined()를 사용하면:
minIdle=0이므로 저트래픽 구간에 유휴 커넥션이 모두 반납되고, 다음 요청 시 매번 새 커넥션을 생성하는 cold start 지연이 발생합니다.maxWait=-1(무제한)이므로 Pool이 고갈되면 스레드가 무한정 대기하여 요청 처리 지연 및 장애로 이어질 수 있습니다.LettuceConnectionFactory 사용 시) 피크타임에 동시 요청 수만큼 신규 커넥션이 생성되어 Redis 서버 측 커넥션 지표가 폭증합니다.설정 후 (위 Pool Config 적용 시)
| 상황 | 권장 방식 |
|---|---|
| 일반 GET/SET 등 단순 커맨드 | Shared Connection (기본값) |
| Pipeline, Transaction, Blocking 커맨드 | Connection Pool 필수 |
Lettuce는 기본적으로 훌륭한 thread-safe 클라이언트이지만, Pipeline처럼 커넥션을 독점해야 하는 작업을 Connection Pool 없이 사용하면 운영 환경에서 커넥션 폭증이라는 심각한 문제로 이어질 수 있습니다.
executePipelined()를 사용하고 있다면, 반드시 LettucePoolingClientConfiguration을 통해 Connection Pool을 설정하세요.
다음 편에서는 Lettuce와 자주 비교되는 Redisson 클라이언트를 다룹니다. 분산락, 분산 컬렉션 등 고수준 기능이 필요한 경우 Redisson이 어떤 이점을 제공하는지 살펴볼 예정입니다.