Spring Boot + Redis: Lettuce 동작 방식과 Connection Pool

KIM Jongwan·2026년 4월 26일

들어가며

Spring Boot 환경에서 Redis를 도입할 때, 단순히 의존성을 추가하고 RedisTemplate을 사용하는 것에서 그치는 경우가 많습니다. 하지만 Redis 클라이언트의 동작 방식을 제대로 이해하지 못하면, 운영 환경에서 예상치 못한 커넥션 폭증이나 성능 저하를 마주할 수 있습니다.

이 글에서는 Spring Boot에서 사용할 수 있는 Redis 클라이언트들을 비교하고, 기본 클라이언트인 Lettuce의 내부 동작 방식과 Connection Pool의 중요성에 대해 실제 트러블슈팅 경험을 바탕으로 설명합니다.


Redis 클라이언트 비교: Jedis vs Lettuce vs Redisson

Spring Boot에서 Redis를 사용할 때 선택할 수 있는 클라이언트는 크게 세 가지입니다.

JedisLettuceRedisson
동작 방식동기, 블로킹비동기, 논블로킹비동기, 논블로킹
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 아키텍처

Lettuce는 Netty 기반의 비동기 논블로킹 Redis 클라이언트입니다.

Thread-safe한 이유

Lettuce는 내부적으로 단일 TCP 커넥션(StatefulRedisConnection)을 통해 모든 커맨드를 처리합니다. 커맨드는 Netty의 이벤트 루프에서 순차적으로 처리되며, 각 커맨드는 내부 큐에 적재되어 순서대로 Redis 서버에 전송됩니다.

이 구조 덕분에 여러 스레드가 하나의 커넥션을 공유해도 thread-safe합니다. 각 스레드가 커맨드를 큐에 넣으면, Netty 이벤트 루프가 순서대로 처리하기 때문입니다.


Lettuce의 Connection 사용 방식 두 가지

Lettuce(+ Spring Data Redis)에서 커넥션을 사용하는 방식은 크게 두 가지로 나뉩니다.

1. Shared Connection (기본값)

별도 설정이 없으면 Lettuce는 하나의 커넥션을 모든 스레드가 공유합니다.

@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379);
        // 기본 LettuceConnectionFactory — Connection Pool 없음
        return new LettuceConnectionFactory(config);
    }
}

일반적인 GET/SET 커맨드처럼 커넥션을 독점할 필요가 없는 작업에서는 이 방식으로 충분합니다. 커넥션 하나를 재사용하므로 리소스 효율이 높습니다.

2. Connection Pool 방식

커넥션을 독점적으로 점유해야 하는 작업에는 Connection Pool이 필요합니다. 대표적으로:

  • Pipeline (executePipelined)
  • Transaction (MULTI/EXEC)
  • Blocking 커맨드 (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);
    }
}

executePipelined()와 openPipeline()의 동작 원리

Pipeline이란?

일반적으로 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의 실제 동작 방식

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 설정

[Connection Pool 있는 경우]

스레드 A → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 B → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 C → executePipelined() → Pool이 고갈되면 maxWait 동안 대기 → 대여
...
(최대 maxTotal 수의 커넥션만 유지)

Connection Pool 설정 코드

@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;
    }
}

각 설정값의 의미

  • maxTotal(16): Pool이 유지할 수 있는 최대 커넥션 수. 피크 트래픽 기준 동시 Redis 작업 수의 2~3배로 설정하여 여유를 확보합니다.
  • maxIdle(8): 유휴 상태로 유지할 최대 커넥션 수. 트래픽 스파이크 시 즉시 사용 가능한 커넥션을 예비해 둡니다.
  • minIdle(2): 항상 유지할 최소 커넥션 수. 저트래픽 시간대에도 최소한의 커넥션을 상시 유지하여 첫 요청의 cold start 지연을 방지합니다.
  • maxWait(2s): Pool이 고갈되었을 때 커넥션을 기다리는 최대 시간. commandTimeout보다 짧게 설정하여 풀 대기 중 타임아웃이 먼저 발생하는 상황을 방지합니다.
  • testWhileIdle(true): eviction 실행 시 유휴 커넥션의 유효성을 검증합니다. 네트워크 장애 등으로 끊어진 커넥션을 선제적으로 감지합니다.
  • timeBetweenEvictionRuns(30s): eviction 스레드 실행 주기. 30초마다 유휴 커넥션을 정리하고 유효성을 검증합니다.
  • minEvictableIdleDuration(60s): 60초 이상 유휴 상태인 커넥션을 회수합니다. minIdle 이하로는 줄이지 않으며, Redis 서버 측 커넥션 부하를 최소화합니다.

이렇게 설정하면 실제로 어떻게 달라지는가?

설정 전 (기본값 사용 시)

GenericObjectPoolConfig를 별도로 지정하지 않으면 Commons Pool2의 기본값이 적용됩니다. 기본값은 maxTotal=8, maxIdle=8, minIdle=0, maxWait=-1(무제한) 입니다.

이 상태에서 executePipelined()를 사용하면:

  • minIdle=0이므로 저트래픽 구간에 유휴 커넥션이 모두 반납되고, 다음 요청 시 매번 새 커넥션을 생성하는 cold start 지연이 발생합니다.
  • maxWait=-1(무제한)이므로 Pool이 고갈되면 스레드가 무한정 대기하여 요청 처리 지연 및 장애로 이어질 수 있습니다.
  • Connection Pool이 없는 경우(기본 LettuceConnectionFactory 사용 시) 피크타임에 동시 요청 수만큼 신규 커넥션이 생성되어 Redis 서버 측 커넥션 지표가 폭증합니다.

설정 후 (위 Pool Config 적용 시)

  • maxTotal(16) 덕분에 최대 16개의 커넥션만 생성되어, 피크타임에도 Redis 서버의 신규 커넥션 수립 지표가 안정적으로 유지됩니다. 실제로 피크타임 1,000개 이상이던 신규 커넥션 수립 지표가 Pool 적용 후 maxTotal 범위 내로 수렴하여 안정화되었습니다.
  • minIdle(2) 덕분에 저트래픽 시간대에도 최소 2개의 커넥션이 상시 유지되어 첫 요청의 커넥션 생성 지연(cold start)이 제거됩니다.
  • maxWait(2s) 덕분에 Pool이 고갈되더라도 최대 2초 내로 대기가 끊기므로 무한 대기에 의한 스레드 블로킹 장애를 방지합니다. commandTimeout보다 짧게 설정하여 Pool 대기 중 타임아웃 예외가 먼저 발생하는 상황도 방지됩니다.
  • testWhileIdle + timeBetweenEvictionRuns(30s) 조합으로 30초마다 유휴 커넥션의 유효성을 검증하여, 네트워크 장애나 Redis 서버 재시작으로 인해 끊어진 커넥션이 Pool에 남아 있다가 사용되는 문제를 선제적으로 방지합니다.
  • minEvictableIdleDuration(60s) 덕분에 트래픽이 줄어드는 야간 시간대에 60초 이상 유휴 상태인 커넥션이 자동 회수되어 Redis 서버의 불필요한 커넥션 유지 부하를 줄일 수 있습니다.

정리

상황권장 방식
일반 GET/SET 등 단순 커맨드Shared Connection (기본값)
Pipeline, Transaction, Blocking 커맨드Connection Pool 필수

Lettuce는 기본적으로 훌륭한 thread-safe 클라이언트이지만, Pipeline처럼 커넥션을 독점해야 하는 작업을 Connection Pool 없이 사용하면 운영 환경에서 커넥션 폭증이라는 심각한 문제로 이어질 수 있습니다.

executePipelined()를 사용하고 있다면, 반드시 LettucePoolingClientConfiguration을 통해 Connection Pool을 설정하세요.


다음 편 예고

다음 편에서는 Lettuce와 자주 비교되는 Redisson 클라이언트를 다룹니다. 분산락, 분산 컬렉션 등 고수준 기능이 필요한 경우 Redisson이 어떤 이점을 제공하는지 살펴볼 예정입니다.

0개의 댓글