본문 바로가기
Research Notes (개인)

Redis Configuration 완전 정리


전체 Configuration 코드

@Configuration
@EnableCaching
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("${spring.data.redis.password:}")
    private String password;

    @Value("${spring.data.redis.database:0}")
    private int database;

    // ============================================
    // 1. 연결 설정 (Connection Factory)
    // ============================================
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration serverConfig =
            new RedisStandaloneConfiguration();
        serverConfig.setHostName(host);
        serverConfig.setPort(port);
        serverConfig.setDatabase(database);

        if (!password.isEmpty()) {
            serverConfig.setPassword(RedisPassword.of(password));
        }

        LettucePoolingClientConfiguration poolConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig())
                .commandTimeout(Duration.ofMillis(500))  // 명령 타임아웃
                .shutdownTimeout(Duration.ofMillis(200))
                .build();

        return new LettuceConnectionFactory(serverConfig, poolConfig);
    }

    // 커넥션 풀 설정
    private GenericObjectPoolConfig<?> poolConfig() {
        GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(100);       // 최대 연결 수
        config.setMaxIdle(50);         // 최대 유휴 연결
        config.setMinIdle(10);         // 최소 유휴 연결 (미리 생성)
        config.setMaxWait(Duration.ofMillis(1000));  // 연결 대기 최대 시간
        config.setTestOnBorrow(true);  // 빌릴 때 연결 유효성 검사
        config.setTestWhileIdle(true); // 유휴 중 주기적 검사
        return config;
    }


    // ============================================
    // 2. RedisTemplate (범용, Object 직렬화)
    // ============================================
    @Bean
    public RedisTemplate<String, Object> redisTemplate(
        RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // Key: String 직렬화
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // Value: JSON 직렬화
        template.setValueSerializer(jackson2JsonRedisSerializer());
        template.setHashValueSerializer(jackson2JsonRedisSerializer());

        // 기본 직렬화 (명시적으로 설정 안 된 경우)
        template.setDefaultSerializer(jackson2JsonRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }


    // ============================================
    // 3. StringRedisTemplate (String 전용)
    // ============================================
    @Bean
    public StringRedisTemplate stringRedisTemplate(
        RedisConnectionFactory connectionFactory
    ) {
        // Key, Value 모두 StringRedisSerializer 사용
        // 단순 문자열 저장에 사용 (가장 빠름)
        return new StringRedisTemplate(connectionFactory);
    }


    // ============================================
    // 4. Jackson2 JSON 직렬화 설정
    // ============================================
    @Bean
    public Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();

        // 날짜/시간 처리
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        // null 필드 제외 (용량 최적화)
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        // 타입 정보 포함 (역직렬화 시 정확한 타입 복원)
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );

        return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
    }


    // ============================================
    // 5. Cache Manager (@Cacheable 등 애노테이션용)
    // ============================================
    @Bean
    public CacheManager cacheManager(
        RedisConnectionFactory connectionFactory
    ) {
        RedisCacheConfiguration defaultConfig =
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))           // 기본 TTL
                .serializeKeysWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(jackson2JsonRedisSerializer())
                )
                .disableCachingNullValues();  // null 캐싱 방지

        // 캐시별 개별 TTL 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("product",
            defaultConfig.entryTtl(Duration.ofMinutes(30)));    // 상품: 30분
        cacheConfigs.put("user",
            defaultConfig.entryTtl(Duration.ofMinutes(60)));    // 유저: 1시간
        cacheConfigs.put("ranking",
            defaultConfig.entryTtl(Duration.ofSeconds(30)));    // 랭킹: 30초

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

application.yml 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: your-password
      database: 0
      timeout: 2000ms           # 연결 타임아웃

      # Lettuce 풀 설정 (위 Config와 맞춰서)
      lettuce:
        pool:
          max-active: 100
          max-idle: 50
          min-idle: 10
          max-wait: 1000ms

직렬화 방식 비교

┌─────────────────────────┬──────────────────────────────────────┐
│ Serializer              │ 특징                                 │
├─────────────────────────┼──────────────────────────────────────┤
│ StringRedisSerializer   │ String 전용, 가장 빠름               │
│                         │ 사람이 읽기 쉬움                     │
│                         │ Object 저장 불가                     │
├─────────────────────────┼──────────────────────────────────────┤
│ Jackson2JsonRedisSerial │ JSON 형태 저장                       │
│ izer                    │ 사람이 읽기 쉬움                     │
│                         │ 타입 정보 포함 가능                  │
│                         │ 권장 ✅                              │
├─────────────────────────┼──────────────────────────────────────┤
│ JdkSerializationRedis   │ Java 기본 직렬화                     │
│ Serializer (기본값)     │ 바이너리 저장 (읽기 어려움)          │
│                         │ 클래스 변경 시 역직렬화 깨짐 ❌      │
├─────────────────────────┼──────────────────────────────────────┤
│ GenericJackson2JsonRedi │ Jackson2와 유사                      │
│ sSerializer             │ 타입 정보 자동 포함                  │
└─────────────────────────┴──────────────────────────────────────┘

@Cacheable 사용 예시

@Service
@RequiredArgsConstructor
public class ProductService {

    // ============================================
    // 조회 - 캐시 적용
    // ============================================
    @Cacheable(
        cacheNames = "product",
        key = "#productId",
        unless = "#result == null"  // null이면 캐싱 안 함
    )
    public Product getProduct(Long productId) {
        return productRepository.findById(productId)
            .orElseThrow(() -> new RuntimeException("상품 없음"));
    }

    // ============================================
    // 수정 - 캐시 갱신
    // ============================================
    @CachePut(
        cacheNames = "product",
        key = "#product.id"
    )
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }

    // ============================================
    // 삭제 - 캐시 제거
    // ============================================
    @CacheEvict(
        cacheNames = "product",
        key = "#productId"
    )
    public void deleteProduct(Long productId) {
        productRepository.deleteById(productId);
    }

    // ============================================
    // 여러 캐시 동시 제거
    // ============================================
    @Caching(evict = {
        @CacheEvict(cacheNames = "product", key = "#productId"),
        @CacheEvict(cacheNames = "ranking", allEntries = true)
    })
    public void deleteProductAndRanking(Long productId) {
        productRepository.deleteById(productId);
    }
}

Lua Script용 별도 설정

@Configuration
public class RedisScriptConfig {

    // ============================================
    // Lua Script EVALSHA용 (스크립트 캐싱)
    // ============================================
    @Bean
    public DefaultRedisScript<Long> issueCouponScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(ISSUE_SCRIPT);     // 인라인 스크립트
        // script.setScriptSource(             // 파일로 관리 가능
        //     new ResourceScriptSource(
        //         new ClassPathResource("scripts/issue_coupon.lua")
        //     )
        // );
        script.setResultType(Long.class);
        return script;
    }

    private static final String ISSUE_SCRIPT = """
        local stock = tonumber(redis.call('GET', KEYS[1]))
        if not stock or stock <= 0 then return 0 end
        local sadd = redis.pcall('SADD', KEYS[2], ARGV[1])
        if type(sadd) == 'table' and sadd['err'] then return -2 end
        if sadd == 0 then return -1 end
        local decr = redis.pcall('DECRBY', KEYS[1], 1)
        if type(decr) == 'table' and decr['err'] then
            redis.call('SREM', KEYS[2], ARGV[1])
            return -2
        end
        return 1
        """;
}


// 사용 시
@Service
@RequiredArgsConstructor
public class CouponStockRedisService {

    private final StringRedisTemplate    redisTemplate;
    private final DefaultRedisScript<Long> issueCouponScript;  // 주입

    public IssueResult tryIssue(Long couponId, Long userId) {
        Long result = redisTemplate.execute(
            issueCouponScript,  // 미리 등록된 스크립트 사용
            List.of(stockKey(couponId), issuedKey(couponId)),
            String.valueOf(userId)
        );
        // ...
    }
}

클러스터 환경 설정

@Bean
public RedisConnectionFactory redisClusterConnectionFactory() {
    RedisClusterConfiguration clusterConfig =
        new RedisClusterConfiguration();

    clusterConfig.addClusterNode(new RedisNode("node1", 6379));
    clusterConfig.addClusterNode(new RedisNode("node2", 6380));
    clusterConfig.addClusterNode(new RedisNode("node3", 6381));
    clusterConfig.setMaxRedirects(3);  // MOVED 리다이렉션 최대 횟수

    LettuceClientConfiguration clientConfig =
        LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofMillis(500))
            .build();

    return new LettuceConnectionFactory(clusterConfig, clientConfig);
}

전체 설정 체크리스트

┌─────────────────────────────────────────────────────────────┐
│                  Redis Config 체크리스트                     │
├─────────────────────────────────────────────────────────────┤
│ 연결                                                         │
│  ✅ host, port, password, database                          │
│  ✅ commandTimeout (500ms 권장)                             │
│  ✅ Connection Pool 설정                                     │
├─────────────────────────────────────────────────────────────┤
│ 직렬화                                                       │
│  ✅ Key:   StringRedisSerializer                            │
│  ✅ Value: Jackson2JsonRedisSerializer                      │
│  ✅ JDK 기본 직렬화 반드시 교체                             │
│  ✅ ObjectMapper: JavaTimeModule 등록                       │
├─────────────────────────────────────────────────────────────┤
│ 캐시                                                         │
│  ✅ CacheManager 등록 (@Cacheable 사용 시)                  │
│  ✅ 캐시별 TTL 개별 설정                                    │
│  ✅ disableCachingNullValues()                              │
├─────────────────────────────────────────────────────────────┤
│ Lua Script                                                   │
│  ✅ DefaultRedisScript Bean 등록                            │
│  ✅ 스크립트 파일 분리 관리 (.lua)                          │
└─────────────────────────────────────────────────────────────┘

Redis Master / Replica 분리 Configuration


전체 구조

[Write 요청] ──→ Master (쓰기 전용)
[Read  요청] ──→ Replica 1, 2 (읽기 전용, 부하 분산)

Master → (비동기 복제) → Replica 1
                      → Replica 2

application.yml

spring:
  data:
    redis:
      # Sentinel (Master/Replica 자동 감지 + Failover)
      sentinel:
        master: mymaster
        nodes:
          - sentinel1:26379
          - sentinel2:26379
          - sentinel3:26379
        password: sentinel-password

      # 또는 직접 지정 방식 (Sentinel 없을 때)
      master:
        host: master-host
        port: 6379
      replicas:
        - host: replica1-host
          port: 6380
        - host: replica2-host
          port: 6381

      password: redis-password
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 100
          max-idle: 50
          min-idle: 10
          max-wait: 1000ms

Redis Configuration

@Configuration
@EnableCaching
public class RedisConfig {

    @Value("${spring.data.redis.password:}")
    private String password;

    // ============================================
    // 1. Master 연결 (쓰기 전용)
    // ============================================
    @Bean
    @Primary
    public RedisConnectionFactory masterConnectionFactory() {
        RedisStandaloneConfiguration masterConfig =
            new RedisStandaloneConfiguration();
        masterConfig.setHostName("master-host");
        masterConfig.setPort(6379);

        if (!password.isEmpty()) {
            masterConfig.setPassword(RedisPassword.of(password));
        }

        LettucePoolingClientConfiguration poolConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig(100, 50, 10))
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.MASTER)  // 마스터에서만 읽기
                .build();

        return new LettuceConnectionFactory(masterConfig, poolConfig);
    }


    // ============================================
    // 2. Replica 연결 (읽기 전용)
    // ============================================
    @Bean
    public RedisConnectionFactory replicaConnectionFactory() {
        // 여러 Replica를 등록하면 Lettuce가 자동 부하 분산
        RedisStaticMasterReplicaConfiguration replicaConfig =
            new RedisStaticMasterReplicaConfiguration("master-host", 6379);
        replicaConfig.addNode("replica1-host", 6380);
        replicaConfig.addNode("replica2-host", 6381);

        if (!password.isEmpty()) {
            replicaConfig.setPassword(RedisPassword.of(password));
        }

        LettuceClientConfiguration clientConfig =
            LettuceClientConfiguration.builder()
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.REPLICA_PREFERRED)  // Replica 우선, 없으면 Master
                .build();

        return new LettuceConnectionFactory(replicaConfig, clientConfig);
    }


    // ============================================
    // 3. Sentinel 방식 (운영 권장 - 자동 Failover)
    // ============================================
    @Bean
    @Primary
    public RedisConnectionFactory sentinelMasterConnectionFactory() {
        RedisSentinelConfiguration sentinelConfig =
            new RedisSentinelConfiguration()
                .master("mymaster")
                .sentinel("sentinel1", 26379)
                .sentinel("sentinel2", 26379)
                .sentinel("sentinel3", 26379);

        sentinelConfig.setPassword(RedisPassword.of(password));
        sentinelConfig.setSentinelPassword(RedisPassword.of("sentinel-password"));

        LettucePoolingClientConfiguration poolConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig(100, 50, 10))
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.MASTER)  // 쓰기 → Master만
                .build();

        return new LettuceConnectionFactory(sentinelConfig, poolConfig);
    }

    @Bean
    public RedisConnectionFactory sentinelReplicaConnectionFactory() {
        RedisSentinelConfiguration sentinelConfig =
            new RedisSentinelConfiguration()
                .master("mymaster")
                .sentinel("sentinel1", 26379)
                .sentinel("sentinel2", 26379)
                .sentinel("sentinel3", 26379);

        sentinelConfig.setPassword(RedisPassword.of(password));
        sentinelConfig.setSentinelPassword(RedisPassword.of("sentinel-password"));

        LettucePoolingClientConfiguration poolConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig(200, 100, 20))  // Replica는 더 많은 연결
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.REPLICA_PREFERRED)  // 읽기 → Replica 우선
                .build();

        return new LettuceConnectionFactory(sentinelConfig, poolConfig);
    }


    // ============================================
    // 4. Master용 RedisTemplate (쓰기 전용)
    // ============================================
    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(
        @Qualifier("sentinelMasterConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return buildRedisTemplate(connectionFactory);
    }


    // ============================================
    // 5. Replica용 RedisTemplate (읽기 전용)
    // ============================================
    @Bean
    public RedisTemplate<String, Object> readRedisTemplate(
        @Qualifier("sentinelReplicaConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return buildRedisTemplate(connectionFactory);
    }


    // ============================================
    // 6. Master용 StringRedisTemplate
    // ============================================
    @Bean
    @Primary
    public StringRedisTemplate stringRedisTemplate(
        @Qualifier("sentinelMasterConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return new StringRedisTemplate(connectionFactory);
    }


    // ============================================
    // 7. Replica용 StringRedisTemplate
    // ============================================
    @Bean
    public StringRedisTemplate readStringRedisTemplate(
        @Qualifier("sentinelReplicaConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return new StringRedisTemplate(connectionFactory);
    }


    // ============================================
    // 8. CacheManager (읽기: Replica, 쓰기: Master)
    // ============================================
    @Bean
    public CacheManager cacheManager(
        @Qualifier("sentinelReplicaConnectionFactory")
        RedisConnectionFactory connectionFactory  // 캐시 조회는 Replica
    ) {
        RedisCacheConfiguration config =
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(jackson2JsonRedisSerializer())
                )
                .disableCachingNullValues();

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }


    // ============================================
    // 공통: RedisTemplate 빌더
    // ============================================
    private RedisTemplate<String, Object> buildRedisTemplate(
        RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(jackson2JsonRedisSerializer());
        template.setHashValueSerializer(jackson2JsonRedisSerializer());
        template.setDefaultSerializer(jackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }

    private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );
        return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
    }

    private GenericObjectPoolConfig<?> poolConfig(
        int maxTotal, int maxIdle, int minIdle
    ) {
        GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(maxTotal);
        config.setMaxIdle(maxIdle);
        config.setMinIdle(minIdle);
        config.setMaxWait(Duration.ofMillis(1000));
        config.setTestOnBorrow(true);
        config.setTestWhileIdle(true);
        return config;
    }
}

ReadFrom 옵션 정리

ReadFrom.MASTER
  → 항상 Master에서 읽기
  → 강한 일관성 필요 시 사용
  → 쓰기 직후 바로 읽어야 할 때

ReadFrom.REPLICA_PREFERRED
  → Replica 우선, 없으면 Master
  → 일반적인 읽기 부하 분산에 권장 ✅

ReadFrom.REPLICA
  → Replica에서만 읽기
  → Replica 없으면 에러

ReadFrom.NEAREST
  → 네트워크 레이턴시가 가장 낮은 노드
  → 멀티 리전 환경에서 유용

실제 사용 예시

// ============================================
// Master/Replica 분리 서비스
// ============================================
@Service
@RequiredArgsConstructor
public class ProductCacheService {

    // 쓰기용 (Master)
    @Qualifier("redisTemplate")
    private final RedisTemplate<String, Object> writeTemplate;

    // 읽기용 (Replica)
    @Qualifier("readRedisTemplate")
    private final RedisTemplate<String, Object> readTemplate;

    private final ProductRepository productRepository;

    // ============================================
    // 읽기 → Replica
    // ============================================
    public Product getProduct(Long productId) {
        String key = "product:" + productId;

        // Replica에서 조회
        Product cached = (Product) readTemplate.opsForValue().get(key);
        if (cached != null) return cached;

        // 캐시 미스 → DB 조회
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new RuntimeException("상품 없음"));

        // Master에 저장 → Replica로 복제됨
        writeTemplate.opsForValue()
            .set(key, product, Duration.ofMinutes(30));

        return product;
    }

    // ============================================
    // 쓰기 → Master
    // ============================================
    public void updateProduct(Product product) {
        String key = "product:" + product.getId();

        // Master에 저장
        writeTemplate.opsForValue()
            .set(key, product, Duration.ofMinutes(30));
    }

    // ============================================
    // 삭제 → Master
    // ============================================
    public void evictProduct(Long productId) {
        writeTemplate.delete("product:" + productId);
    }
}

주의사항: 복제 지연 (Replication Lag)

@Service
@RequiredArgsConstructor
public class OrderService {

    @Qualifier("redisTemplate")        // Master
    private final RedisTemplate<String, Object> writeTemplate;

    @Qualifier("readRedisTemplate")    // Replica
    private final RedisTemplate<String, Object> readTemplate;

    public void processOrder(Long orderId) {

        // ❌ 이 패턴 주의
        writeTemplate.opsForValue().set("order:" + orderId, "PROCESSING");
        Object status = readTemplate.opsForValue().get("order:" + orderId);
        // → null 또는 이전 값이 나올 수 있음!
        //   (비동기 복제 지연 때문)


        // ✅ 쓰기 직후 읽기는 Master에서
        writeTemplate.opsForValue().set("order:" + orderId, "PROCESSING");
        Object status2 = writeTemplate.opsForValue().get("order:" + orderId);
        // → 정확한 값 보장


        // ✅ 또는 쓰기 후 일정 시간 후 Replica 조회
        writeTemplate.opsForValue().set("order:" + orderId, "PROCESSING");
        Thread.sleep(50);  // 복제 지연 대기 (권장하지 않음)
        Object status3 = readTemplate.opsForValue().get("order:" + orderId);
    }
}

전체 요약

┌─────────────────────────────────────────────────────────────┐
│           Master / Replica 분리 핵심 요약                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ConnectionFactory 분리:                                    │
│    masterConnectionFactory  → ReadFrom.MASTER              │
│    replicaConnectionFactory → ReadFrom.REPLICA_PREFERRED   │
│                                                             │
│  RedisTemplate 분리:                                        │
│    redisTemplate     (Master) → @Primary                   │
│    readRedisTemplate (Replica)                              │
│                                                             │
│  운영 권장:                                                  │
│    Sentinel 방식 → 자동 Failover 지원                       │
│    Replica Pool을 더 크게 (읽기 트래픽이 더 많음)           │
│                                                             │
│  주의:                                                       │
│    쓰기 직후 바로 읽기 → Master 사용                        │
│    일반 조회 → Replica 사용                                 │
│    비동기 복제 지연 항상 고려                               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Lettuce 완전 정리


1. Lettuce란?

Lettuce = Java 기반 Redis 클라이언트 라이브러리

Spring Boot 2.0+ 기본 Redis 클라이언트
(Jedis에서 Lettuce로 교체됨)

핵심 특징:
  Netty 기반 비동기 논블로킹 I/O
  Thread-safe → 단일 연결로 여러 스레드 공유 가능
  동기 / 비동기 / 리액티브 API 모두 지원

2. Jedis vs Lettuce 비교

┌──────────────────┬─────────────────────┬─────────────────────┐
│                  │      Jedis          │      Lettuce        │
├──────────────────┼─────────────────────┼─────────────────────┤
│ I/O 방식         │ 동기 블로킹          │ 비동기 논블로킹      │
│ 기반 라이브러리  │ 직접 소켓           │ Netty               │
│ Thread-safe      │ ❌ (연결 공유 불가) │ ✅ (연결 공유 가능) │
│ Connection Pool  │ 필수                │ 선택적              │
│ 비동기 API       │ ❌                  │ ✅                  │
│ 리액티브 API     │ ❌                  │ ✅ (Reactive)       │
│ 클러스터 지원    │ 제한적              │ ✅ 완전 지원         │
│ Sentinel 지원    │ ✅                  │ ✅                  │
│ 연결 재사용      │ 스레드당 1개        │ 여러 스레드 공유     │
│ Spring Boot 기본 │ 2.0 이전            │ 2.0 이후 ✅         │
└──────────────────┴─────────────────────┴─────────────────────┘

3. Lettuce가 Thread-safe한 이유

[Jedis - Thread-safe 아님]

Thread 1 ──→ Connection 1 (전용)
Thread 2 ──→ Connection 2 (전용)
Thread 3 ──→ Connection 3 (전용)
→ 스레드마다 연결 필요 → Connection Pool 필수


[Lettuce - Thread-safe]

Thread 1 ──┐
Thread 2 ──┤──→ Connection 1개 (공유)
Thread 3 ──┘

왜 가능한가?
  Netty의 이벤트 루프 기반
  명령을 비동기로 큐잉 → 순서대로 처리
  여러 스레드가 같은 연결에 동시 접근 가능
[내부 동작 원리]

Thread 1: SET key1 ──┐
Thread 2: GET key2 ──┤──→ [Netty Channel] ──→ Redis
Thread 3: INCR cnt ──┘         ↑
                          명령 순서대로 처리
                          응답도 순서대로 매핑

4. Lettuce 아키텍처

┌──────────────────────────────────────────────────────────┐
│                    Lettuce 내부 구조                      │
│                                                          │
│  Application Code                                        │
│       │                                                  │
│       ▼                                                  │
│  RedisCommands (API 레이어)                              │
│  ┌────────────────────────────────────┐                 │
│  │  StatefulRedisConnection           │                 │
│  │  (상태 유지 연결 객체)               │                 │
│  │    └── RedisChannelHandler         │                 │
│  │         └── CommandHandler         │                 │
│  └────────────────────────────────────┘                 │
│       │                                                  │
│       ▼                                                  │
│  [Netty EventLoop]                                       │
│  ┌────────────────────────────────────┐                 │
│  │  Channel Pipeline                  │                 │
│  │  CommandEncoder → CommandDecoder   │                 │
│  └────────────────────────────────────┘                 │
│       │                                                  │
│       ▼                                                  │
│  Redis Server                                            │
└──────────────────────────────────────────────────────────┘

5. Connection 종류

// ============================================
// 5-1. StatefulRedisConnection (기본, 동기)
// ============================================
RedisClient client = RedisClient.create("redis://localhost:6379");
StatefulRedisConnection<String, String> connection = client.connect();

// 동기 커맨드
RedisCommands<String, String> syncCommands = connection.sync();
syncCommands.set("key", "value");
String value = syncCommands.get("key");


// ============================================
// 5-2. 비동기 API
// ============================================
RedisAsyncCommands<String, String> asyncCommands = connection.async();

// CompletableFuture 반환
RedisFuture<String> future = asyncCommands.get("key");
future.thenAccept(val -> System.out.println("비동기 결과: " + val));


// ============================================
// 5-3. 리액티브 API
// ============================================
RedisReactiveCommands<String, String> reactiveCommands = connection.reactive();

// Mono/Flux 반환
Mono<String> mono = reactiveCommands.get("key");
mono.subscribe(val -> System.out.println("리액티브 결과: " + val));

6. Connection Pool 설정

// Lettuce는 Thread-safe하므로 Pool이 필수가 아님
// BUT 높은 동시성 환경에서는 Pool 사용 권장

@Configuration
public class LettuceConfig {

    // ============================================
    // Pool 없이 (단일 연결 공유)
    // ============================================
    @Bean
    public RedisConnectionFactory redisConnectionFactoryNoPool() {
        LettuceClientConfiguration clientConfig =
            LettuceClientConfiguration.builder()
                .commandTimeout(Duration.ofMillis(500))
                .build();
        // → 단일 연결을 모든 스레드가 공유
        // → 낮은 동시성 환경에서 충분

        return new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379),
            clientConfig
        );
    }


    // ============================================
    // Pool 사용 (높은 동시성 환경 권장)
    // ============================================
    @Bean
    public RedisConnectionFactory redisConnectionFactoryWithPool() {
        GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
        poolConfig.setMaxTotal(100);         // 최대 연결 수
        poolConfig.setMaxIdle(50);           // 최대 유휴 연결
        poolConfig.setMinIdle(10);           // 최소 유휴 연결
        poolConfig.setMaxWait(Duration.ofMillis(1000));

        // 연결 유효성 검사
        poolConfig.setTestOnBorrow(true);    // 빌릴 때 검사
        poolConfig.setTestOnReturn(true);    // 반납 시 검사
        poolConfig.setTestWhileIdle(true);   // 유휴 중 검사

        LettucePoolingClientConfiguration clientConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig)
                .commandTimeout(Duration.ofMillis(500))
                .build();

        return new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379),
            clientConfig
        );
    }
}

7. Lettuce Client Options (상세 설정)

@Bean
public RedisConnectionFactory redisConnectionFactory() {

    // ============================================
    // 소켓 옵션
    // ============================================
    SocketOptions socketOptions = SocketOptions.builder()
        .connectTimeout(Duration.ofMillis(1000))  // 연결 타임아웃
        .keepAlive(true)                           // TCP Keep-Alive
        .tcpNoDelay(true)                          // Nagle 알고리즘 비활성
        .build();


    // ============================================
    // 재연결 옵션
    // ============================================
    ClientOptions clientOptions = ClientOptions.builder()
        .socketOptions(socketOptions)
        .autoReconnect(true)                       // 자동 재연결
        .cancelCommandsOnReconnectFailure(false)   // 재연결 실패 시 명령 취소 여부
        .requestQueueSize(1000)                    // 요청 큐 크기
        .disconnectedBehavior(
            ClientOptions.DisconnectedBehavior.REJECT_COMMANDS  // 연결 끊기면 즉시 에러
            // DEFAULT: 연결될 때까지 큐에 쌓음
        )
        .build();


    // ============================================
    // 클러스터 옵션 (클러스터 환경)
    // ============================================
    ClusterClientOptions clusterClientOptions =
        ClusterClientOptions.builder()
            .autoReconnect(true)
            .maxRedirects(5)                       // MOVED 최대 리다이렉션
            .validateClusterNodeMembership(true)   // 노드 멤버십 검증
            .topologyRefreshOptions(               // 토폴로지 자동 갱신
                ClusterTopologyRefreshOptions.builder()
                    .enablePeriodicRefresh(Duration.ofSeconds(30))
                    .enableAllAdaptiveRefreshTriggers()
                    .build()
            )
            .build();


    // ============================================
    // RedisClient에 옵션 적용
    // ============================================
    LettuceClientConfiguration clientConfig =
        LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofMillis(500))
            .clientOptions(clientOptions)
            .build();

    return new LettuceConnectionFactory(
        new RedisStandaloneConfiguration("localhost", 6379),
        clientConfig
    );
}

8. Lettuce 비동기 활용 (Spring WebFlux)

// ============================================
// ReactiveRedisTemplate (WebFlux 환경)
// ============================================
@Configuration
public class ReactiveRedisConfig {

    @Bean
    public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() {
        return new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379)
        );
    }

    @Bean
    public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
        ReactiveRedisConnectionFactory factory
    ) {
        RedisSerializationContext<String, Object> context =
            RedisSerializationContext.<String, Object>newSerializationContext(
                new StringRedisSerializer()
            )
            .value(new Jackson2JsonRedisSerializer<>(Object.class))
            .build();

        return new ReactiveRedisTemplate<>(factory, context);
    }
}


// ============================================
// 리액티브 서비스
// ============================================
@Service
@RequiredArgsConstructor
public class ReactiveProductService {

    private final ReactiveRedisTemplate<String, Object> reactiveTemplate;
    private final ProductRepository productRepository;

    public Mono<Product> getProduct(Long productId) {
        String key = "product:" + productId;

        return reactiveTemplate.opsForValue()
            .get(key)
            .cast(Product.class)
            .switchIfEmpty(
                // 캐시 미스 → DB 조회 → 캐시 저장
                Mono.fromCallable(() ->
                    productRepository.findById(productId)
                        .orElseThrow(() -> new RuntimeException("상품 없음"))
                )
                .flatMap(product ->
                    reactiveTemplate.opsForValue()
                        .set(key, product, Duration.ofMinutes(30))
                        .thenReturn(product)
                )
            );
    }

    public Flux<Product> getProducts(List<Long> productIds) {
        return Flux.fromIterable(productIds)
            .flatMap(this::getProduct);  // 병렬 조회
    }
}

9. Lettuce 모니터링

// ============================================
// 명령 실행 시간 측정
// ============================================
@Bean
public RedisConnectionFactory monitoredConnectionFactory() {
    LettuceClientConfiguration clientConfig =
        LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofMillis(500))
            .clientResources(
                DefaultClientResources.builder()
                    .commandLatencyRecorder(    // 레이턴시 기록
                        DefaultCommandLatencyCollector.create(
                            DefaultCommandLatencyCollectorOptions.builder()
                                .enable()
                                .useSameConnectionForNIOAndCommandLatency(true)
                                .build()
                        )
                    )
                    .build()
            )
            .build();

    return new LettuceConnectionFactory(
        new RedisStandaloneConfiguration("localhost", 6379),
        clientConfig
    );
}

10. 실무 이슈 & 해결

이슈 1: Connection Timeout

// 증상: RedisCommandTimeoutException
// 원인: commandTimeout 내 응답 없음

// 해결: 타임아웃 조정 + 슬로우 명령 제거
LettuceClientConfiguration.builder()
    .commandTimeout(Duration.ofMillis(500))  // 적절한 타임아웃 설정
    .build();

이슈 2: 연결 끊김 후 명령 큐잉

// 증상: Redis 재시작 후 명령이 큐에 쌓여 한 번에 실행
// 원인: DisconnectedBehavior.DEFAULT = 큐잉

// 해결: REJECT_COMMANDS로 즉시 에러 반환
ClientOptions.builder()
    .disconnectedBehavior(
        ClientOptions.DisconnectedBehavior.REJECT_COMMANDS
    )
    .build();

이슈 3: Pool 고갈

// 증상: PoolException - Unable to validate object
// 원인: maxTotal 초과, 연결 미반환

// 해결: Pool 크기 증가 + 반환 확인
poolConfig.setMaxTotal(200);
poolConfig.setMaxWait(Duration.ofMillis(500));  // 대기 후 에러 반환

// 모니터링
log.info("Pool 상태: active={}, idle={}",
    pool.getNumActive(), pool.getNumIdle());

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                     Lettuce 핵심 요약                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  핵심 특징:                                                  │
│    Netty 기반 비동기 논블로킹                                │
│    Thread-safe → 연결 공유 가능                             │
│    동기 / 비동기 / 리액티브 API                              │
│                                                             │
│  Jedis 대비 장점:                                            │
│    높은 동시성 환경에서 효율적                               │
│    WebFlux 완벽 지원                                        │
│    클러스터 토폴로지 자동 갱신                               │
│                                                             │
│  Pool 전략:                                                  │
│    낮은 동시성 → Pool 없이 단일 연결                        │
│    높은 동시성 → LettucePoolingClientConfiguration          │
│                                                             │
│  운영 주의:                                                  │
│    commandTimeout 반드시 설정                               │
│    DisconnectedBehavior 명시적 설정                         │
│    클러스터 환경: topologyRefresh 활성화                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

AWS ElastiCache Redis Configuration


ElastiCache 구성 종류

┌─────────────────────────────────────────────────────────────┐
│              ElastiCache 구성 방식                           │
├────────────────────────┬────────────────────────────────────┤
│ Cluster Mode Disabled  │ Primary + Read Replica             │
│                        │ Primary Endpoint (쓰기)            │
│                        │ Reader Endpoint  (읽기 부하 분산)  │
├────────────────────────┼────────────────────────────────────┤
│ Cluster Mode Enabled   │ Multi-Shard 클러스터               │
│                        │ Configuration Endpoint             │
│                        │ 자동 샤딩, 수평 확장               │
└────────────────────────┴────────────────────────────────────┘

application.yml

spring:
  data:
    redis:
      # ── Cluster Mode Disabled (Primary / Reader 분리) ──
      elasticache:
        primary-endpoint: master.myredis.xxxxx.use1.cache.amazonaws.com
        reader-endpoint:  replica.myredis.xxxxx.use1.cache.amazonaws.com
        port: 6379
        ssl: true            # ElastiCache In-Transit 암호화
        token: your-auth-token  # ElastiCache AUTH Token

      # ── Cluster Mode Enabled ──
      # cluster:
      #   nodes:
      #     - myredis.xxxxx.clustercfg.use1.cache.amazonaws.com:6379

      lettuce:
        pool:
          max-active: 100
          max-idle: 50
          min-idle: 10
          max-wait: 1000ms
        shutdown-timeout: 200ms

# ElastiCache 전용 프로퍼티
elasticache:
  primary-endpoint: ${ELASTICACHE_PRIMARY_ENDPOINT}
  reader-endpoint:  ${ELASTICACHE_READER_ENDPOINT}
  port:             ${ELASTICACHE_PORT:6379}
  auth-token:       ${ELASTICACHE_AUTH_TOKEN}
  ssl:              ${ELASTICACHE_SSL:true}

Cluster Mode Disabled Configuration (Primary + Reader)

@Configuration
@EnableCaching
@Slf4j
public class ElastiCacheConfig {

    @Value("${elasticache.primary-endpoint}")
    private String primaryEndpoint;

    @Value("${elasticache.reader-endpoint}")
    private String readerEndpoint;

    @Value("${elasticache.port:6379}")
    private int port;

    @Value("${elasticache.auth-token}")
    private String authToken;

    @Value("${elasticache.ssl:true}")
    private boolean ssl;


    // ============================================
    // 1. Primary Connection Factory (쓰기 전용)
    // ============================================
    @Bean
    @Primary
    public RedisConnectionFactory primaryConnectionFactory() {
        RedisStandaloneConfiguration config =
            new RedisStandaloneConfiguration();
        config.setHostName(primaryEndpoint);
        config.setPort(port);
        config.setPassword(RedisPassword.of(authToken));  // AUTH Token

        LettucePoolingClientConfiguration clientConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig(100, 50, 10))
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.MASTER)  // Primary에서만 읽기
                .clientOptions(clientOptions())
                .apply(builder -> {
                    if (ssl) builder.useSsl();  // TLS 활성화
                })
                .build();

        LettuceConnectionFactory factory =
            new LettuceConnectionFactory(config, clientConfig);
        factory.afterPropertiesSet();
        return factory;
    }


    // ============================================
    // 2. Reader Connection Factory (읽기 전용)
    // ============================================
    @Bean
    public RedisConnectionFactory readerConnectionFactory() {
        // ElastiCache Reader Endpoint:
        // 여러 Replica에 자동 부하 분산
        RedisStandaloneConfiguration config =
            new RedisStandaloneConfiguration();
        config.setHostName(readerEndpoint);
        config.setPort(port);
        config.setPassword(RedisPassword.of(authToken));

        LettucePoolingClientConfiguration clientConfig =
            LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig(200, 100, 20))  // 읽기는 더 많은 연결
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.REPLICA_PREFERRED)
                .clientOptions(clientOptions())
                .apply(builder -> {
                    if (ssl) builder.useSsl();
                })
                .build();

        LettuceConnectionFactory factory =
            new LettuceConnectionFactory(config, clientConfig);
        factory.afterPropertiesSet();
        return factory;
    }


    // ============================================
    // 3. RedisTemplate (쓰기 전용 - Primary)
    // ============================================
    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(
        @Qualifier("primaryConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return buildRedisTemplate(connectionFactory);
    }


    // ============================================
    // 4. RedisTemplate (읽기 전용 - Reader)
    // ============================================
    @Bean
    public RedisTemplate<String, Object> readRedisTemplate(
        @Qualifier("readerConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return buildRedisTemplate(connectionFactory);
    }


    // ============================================
    // 5. StringRedisTemplate (쓰기 - Primary)
    // ============================================
    @Bean
    @Primary
    public StringRedisTemplate stringRedisTemplate(
        @Qualifier("primaryConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return new StringRedisTemplate(connectionFactory);
    }


    // ============================================
    // 6. StringRedisTemplate (읽기 - Reader)
    // ============================================
    @Bean
    public StringRedisTemplate readStringRedisTemplate(
        @Qualifier("readerConnectionFactory")
        RedisConnectionFactory connectionFactory
    ) {
        return new StringRedisTemplate(connectionFactory);
    }


    // ============================================
    // 7. CacheManager (@Cacheable 등)
    // ============================================
    @Bean
    public CacheManager cacheManager(
        @Qualifier("readerConnectionFactory")
        RedisConnectionFactory connectionFactory  // 캐시 조회 → Reader
    ) {
        RedisCacheConfiguration defaultConfig =
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(jackson2JsonRedisSerializer())
                )
                .disableCachingNullValues();

        // 캐시별 TTL 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("product",
            defaultConfig.entryTtl(Duration.ofMinutes(30)));
        cacheConfigs.put("user",
            defaultConfig.entryTtl(Duration.ofMinutes(60)));
        cacheConfigs.put("coupon",
            defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }


    // ============================================
    // 공통: Lettuce Client Options
    // ============================================
    private ClientOptions clientOptions() {
        SocketOptions socketOptions = SocketOptions.builder()
            .connectTimeout(Duration.ofMillis(1000))
            .keepAlive(true)
            .tcpNoDelay(true)
            .build();

        return ClientOptions.builder()
            .socketOptions(socketOptions)
            .autoReconnect(true)
            .disconnectedBehavior(
                ClientOptions.DisconnectedBehavior.REJECT_COMMANDS
            )
            .requestQueueSize(1000)
            .build();
    }


    // ============================================
    // 공통: Pool 설정
    // ============================================
    private GenericObjectPoolConfig<?> poolConfig(
        int maxTotal, int maxIdle, int minIdle
    ) {
        GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(maxTotal);
        config.setMaxIdle(maxIdle);
        config.setMinIdle(minIdle);
        config.setMaxWait(Duration.ofMillis(1000));
        config.setTestOnBorrow(true);
        config.setTestOnReturn(true);
        config.setTestWhileIdle(true);
        config.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
        return config;
    }


    // ============================================
    // 공통: RedisTemplate 빌더
    // ============================================
    private RedisTemplate<String, Object> buildRedisTemplate(
        RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(jackson2JsonRedisSerializer());
        template.setHashValueSerializer(jackson2JsonRedisSerializer());
        template.setDefaultSerializer(jackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }


    // ============================================
    // 공통: Jackson2 직렬화
    // ============================================
    @Bean
    public Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );
        return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
    }
}

Cluster Mode Enabled Configuration

@Configuration
@EnableCaching
@Profile("cluster")  // 클러스터 환경에서만 활성화
public class ElastiCacheClusterConfig {

    @Value("${elasticache.cluster.endpoint}")
    private String clusterEndpoint;

    @Value("${elasticache.port:6379}")
    private int port;

    @Value("${elasticache.auth-token}")
    private String authToken;


    // ============================================
    // 클러스터 Connection Factory
    // ============================================
    @Bean
    @Primary
    public RedisConnectionFactory clusterConnectionFactory() {
        RedisClusterConfiguration clusterConfig =
            new RedisClusterConfiguration();
        clusterConfig.addClusterNode(
            new RedisNode(clusterEndpoint, port)
        );
        clusterConfig.setPassword(RedisPassword.of(authToken));
        clusterConfig.setMaxRedirects(3);

        // 클러스터 토폴로지 자동 갱신
        ClusterTopologyRefreshOptions topologyRefresh =
            ClusterTopologyRefreshOptions.builder()
                .enablePeriodicRefresh(Duration.ofSeconds(30))
                .enableAllAdaptiveRefreshTriggers()
                .dynamicRefreshSources(true)
                .build();

        ClusterClientOptions clusterClientOptions =
            ClusterClientOptions.builder()
                .autoReconnect(true)
                .maxRedirects(3)
                .topologyRefreshOptions(topologyRefresh)
                .nodeFilter(redisClusterNode ->
                    // REPLICA 노드 필터링 가능
                    redisClusterNode.hasFlag(
                        RedisClusterNode.Flag.MASTER
                    )
                )
                .build();

        LettuceClientConfiguration clientConfig =
            LettuceClientConfiguration.builder()
                .commandTimeout(Duration.ofMillis(500))
                .readFrom(ReadFrom.REPLICA_PREFERRED)  // Replica 우선 읽기
                .clientOptions(clusterClientOptions)
                .useSsl()                              // TLS
                .build();

        return new LettuceConnectionFactory(clusterConfig, clientConfig);
    }
}

실제 서비스 사용 예시

@Service
@RequiredArgsConstructor
@Slf4j
public class ProductCacheService {

    // Primary (쓰기)
    @Qualifier("redisTemplate")
    private final RedisTemplate<String, Object> writeTemplate;

    // Reader (읽기)
    @Qualifier("readRedisTemplate")
    private final RedisTemplate<String, Object> readTemplate;

    private final ProductRepository productRepository;

    // ============================================
    // 읽기 → Reader Endpoint
    // ============================================
    public Product getProduct(Long productId) {
        String key = "product:" + productId;

        Product cached = (Product) readTemplate.opsForValue().get(key);
        if (cached != null) return cached;

        // DB 조회 후 Primary에 저장 → 자동으로 Replica 복제
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new RuntimeException("상품 없음"));

        writeTemplate.opsForValue()
            .set(key, product, Duration.ofMinutes(30));

        return product;
    }

    // ============================================
    // 쓰기 → Primary Endpoint
    // ============================================
    public void updateProductCache(Product product) {
        String key = "product:" + product.getId();
        writeTemplate.opsForValue()
            .set(key, product, Duration.ofMinutes(30));
    }

    // ============================================
    // 쓰기 직후 조회 → Primary에서 (복제 지연 방지)
    // ============================================
    public Product updateAndGet(Product product) {
        String key = "product:" + product.getId();

        writeTemplate.opsForValue()
            .set(key, product, Duration.ofMinutes(30));

        // ⚠️ 쓰기 직후는 Reader 아닌 Primary에서 읽기
        return (Product) writeTemplate.opsForValue().get(key);
    }
}

ElastiCache 전용 주의사항

// ============================================
// ElastiCache에서 사용 불가한 명령어
// ============================================

// ❌ 사용 불가
redisTemplate.getConnectionFactory()
    .getConnection()
    .serverCommands()
    .config("maxmemory", "1gb");  // CONFIG SET 불가

redisTemplate.getConnectionFactory()
    .getConnection()
    .serverCommands()
    .debug("sleep", "1");         // DEBUG 불가

// ✅ ElastiCache 콘솔에서 Parameter Group으로 설정
// maxmemory-policy, timeout 등
ElastiCache 사용 불가 명령어:
  CONFIG SET/GET  → AWS 콘솔 Parameter Group으로 대체
  DEBUG           → 보안상 차단
  CLUSTER NODES   → Cluster Mode Disabled에서 불가
  BGSAVE          → AWS가 자동 관리
  BGREWRITEAOF    → AWS가 자동 관리
  SLAVEOF         → AWS가 자동 관리

전체 요약

┌─────────────────────────────────────────────────────────────┐
│           AWS ElastiCache 핵심 포인트                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Cluster Mode Disabled (일반적):                            │
│    Primary Endpoint → 쓰기 전용                             │
│    Reader Endpoint  → 읽기 전용 (자동 부하 분산)            │
│    Sentinel 불필요 (AWS가 Failover 자동 처리)               │
│                                                             │
│  Cluster Mode Enabled (대용량):                             │
│    Configuration Endpoint 하나로 연결                       │
│    Hash Tag 필수 ({key})                                    │
│    Lua Script 주의 (같은 슬롯 키만)                         │
│                                                             │
│  보안 설정:                                                  │
│    TLS: useSsl() 필수                                       │
│    AUTH Token: setPassword() 설정                           │
│    VPC 내부 통신 권장                                        │
│                                                             │
│  운영 주의:                                                  │
│    CONFIG/DEBUG 명령 불가 → 콘솔에서 설정                   │
│    쓰기 직후 읽기 → Primary 사용 (복제 지연)                │
│    클러스터 토폴로지 자동 갱신 활성화                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

거의 정확합니다. 다만 세 가지를 구분해야 합니다


사용자 이해 정리 + 보완

"100% 원자적 실행 보장 안 됨" → ✅ 맞음 (롤백 없음)

"다른 세션 요청이 중간에 섞일 수 있냐 없냐" → ⚠️ 세 가지가 다 다름

정확한 비교

┌──────────────┬────────────────────────────┬──────────────────┐
│              │  다른 세션 끼어들기         │  롤백            │
├──────────────┼────────────────────────────┼──────────────────┤
│ Pipeline     │ ✅ 끼어들기 가능            │ ❌               │
│              │ (묶음 전송이지만 원자성 없음)│                  │
├──────────────┼────────────────────────────┼──────────────────┤
│ MULTI/EXEC   │ MULTI~EXEC 사이: ✅ 가능   │ ❌               │
│              │ EXEC 실행 순간:  ❌ 불가   │                  │
├──────────────┼────────────────────────────┼──────────────────┤
│ Lua Script   │ ❌ 전혀 불가               │ ❌               │
│              │ (처음~끝 전체 원자적)       │                  │
└──────────────┴────────────────────────────┴──────────────────┘

핵심 차이

Pipeline:
  클라이언트에서 묶어서 보내는 것
  서버에서는 명령 하나씩 순차 처리
  → 다른 클라이언트 언제든 끼어들기 가능
  → 목적: RTT 감소

MULTI/EXEC:
  MULTI~EXEC 사이(큐잉): 다른 클라이언트 끼어들기 가능
  EXEC 실행 순간: 끼어들기 불가
  → 목적: 명령 묶음 실행 순서 보장

Lua Script:
  스크립트 시작~끝 전체가 하나의 원자 단위
  → 목적: 조건 판단 + 실행 전체를 원자적으로

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  공통점:                                                     │
│    셋 다 롤백 없음 → 완전한 All or Nothing 불가             │
│                                                             │
│  차이점:                                                     │
│    Pipeline   → 끼어들기 가능  (네트워크 최적화 목적)       │
│    MULTI/EXEC → EXEC 순간만 끼어들기 불가                  │
│    Lua Script → 전체 실행 구간 끼어들기 불가               │
│                                                             │
│  따라서:                                                     │
│    "끼어들기 차단" 강도: Lua > MULTI/EXEC >> Pipeline       │
│    "완전한 원자성(롤백)": 셋 다 불가                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확히 이해하셨습니다


정확한 동작

[Pipeline 없이]

Client → SET k1 → Server 큐: [SET k1] → 처리 → 응답
Client → SET k2 → Server 큐: [SET k2] → 처리 → 응답
Client → SET k3 → Server 큐: [SET k3] → 처리 → 응답

네트워크: 3번 왕복


[Pipeline 사용]

Client → SET k1                      Server 큐: [SET k1]
         SET k2  (한 번에 전송) →              [SET k2]  → 순서대로 처리
         SET k3                                [SET k3]

네트워크: 1번 왕복

서버 입장에서는 완전히 동일!

Pipeline이 하는 일의 전부

Pipeline = 클라이언트 사이드 최적화

클라이언트:
  명령들을 버퍼에 모음
  한 번에 TCP 전송

서버:
  받은 명령을 순서대로 파싱
  일반 명령과 똑같이 큐에 적재
  하나씩 처리
  응답을 한 번에 전송

그래서 Pipeline의 유일한 가치

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  Pipeline이 해결하는 것:                                     │
│    N번 RTT → 1번 RTT  (네트워크 왕복 비용)                  │
│                                                             │
│  Pipeline이 해결 못 하는 것:                                 │
│    서버 처리 방식 → 동일 (하나씩 큐에서 처리)               │
│    원자성         → 없음 (끼어들기 가능)                    │
│    순서 보장      → 같은 클라이언트 명령 순서만 보장         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redis Pipeline vs MULTI/EXEC 차이


한 줄 핵심

Pipeline   = 네트워크 최적화 (RTT 감소)
MULTI/EXEC = 실행 순서 보장 (원자적 실행)

동작 방식 비교

[Pipeline]

Client: 명령들을 버퍼에 모아서 한 번에 전송
Server: 받은 명령을 큐에 적재 → 하나씩 처리

Client ──→ [SET k1, SET k2, SET k3] ──→ Server
                                          큐: [SET k1]
                                              [SET k2]  ← 다른 클라이언트 끼어들기 가능
                                              [SET k3]


[MULTI/EXEC]

Client: MULTI 선언 → 명령 큐잉 → EXEC로 일괄 실행
Server: EXEC 순간에 큐의 명령을 연속 실행

Client ──→ MULTI
       ──→ SET k1  (QUEUED)
       ──→ SET k2  (QUEUED)
       ──→ SET k3  (QUEUED)
       ──→ EXEC
                   Server: SET k1 ┐
                                  ├ 연속 실행 (끼어들기 불가)
                           SET k3 ┘

핵심 차이

┌──────────────────┬──────────────────────┬──────────────────────┐
│                  │      Pipeline        │     MULTI/EXEC       │
├──────────────────┼──────────────────────┼──────────────────────┤
│ 목적             │ RTT 감소             │ 명령 묶음 원자 실행  │
│ 처리 위치        │ 클라이언트 버퍼링     │ 서버 큐잉            │
│ 끼어들기         │ ✅ 가능              │ EXEC 순간 ❌ 불가   │
│ 원자성           │ ❌ 없음              │ ✅ 제한적            │
│ 롤백             │ ❌ 없음              │ ❌ 없음              │
│ 응답 시점        │ 전체 완료 후 한 번에 │ EXEC 완료 후 한 번에 │
│ 조건 분기        │ ❌                   │ ❌                   │
│ 서버 부하        │ 낮음                 │ 낮음                 │
└──────────────────┴──────────────────────┴──────────────────────┘

같은 점

둘 다 동일한 것:

1. 여러 명령을 한 번에 보냄 → 1번 RTT
2. 롤백 없음
3. 조건 분기 불가
4. 서버에서 명령은 하나씩 처리

결정적 차이: 끼어들기

[Pipeline - 끼어들기 가능]

Server 큐:
  [SET k1 - Client A]
  [SET k2 - Client B]  ← B가 끼어들었음
  [SET k3 - Client A]
  [SET k4 - Client A]

A의 명령 사이에 B가 섞일 수 있음


[MULTI/EXEC - EXEC 순간 끼어들기 불가]

Server:
  EXEC 받는 순간
  ┌──────────────────┐
  │ SET k1 - Client A│
  │ SET k3 - Client A│  ← 이 구간은 B 절대 불가
  │ SET k4 - Client A│
  └──────────────────┘
  → B는 EXEC 완료 후 실행 가능

함께 사용 가능

// Pipeline + MULTI/EXEC 동시 사용
// → RTT 감소 + 원자성 동시 확보

redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    connection.multi();                         // MULTI
    connection.set("k1".getBytes(), "v1".getBytes());
    connection.set("k2".getBytes(), "v2".getBytes());
    connection.exec();                          // EXEC
    return null;
});

언제 무엇을 쓸까

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  Pipeline:                                                  │
│    대량 캐시 저장/조회                                       │
│    원자성 불필요한 배치 작업                                 │
│    캐시 워밍업                                              │
│                                                             │
│  MULTI/EXEC:                                                │
│    여러 키 동시 삭제/초기화                                  │
│    명령들이 반드시 연속 실행되어야 할 때                     │
│    WATCH와 조합한 낙관적 락                                 │
│                                                             │
│  Lua Script:                                                │
│    조건 분기 포함한 원자적 로직                              │
│    Pipeline도 MULTI/EXEC도 부족할 때                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 각각 개별 요청으로 날아갑니다


MULTI/EXEC 단독 사용 시

Client                          Server
────────────────────────────────────────────
──→ MULTI              ──→    "OK"       (1 RTT)
──→ SET k1 "v1"        ──→    "QUEUED"   (2 RTT)
──→ SET k2 "v2"        ──→    "QUEUED"   (3 RTT)
──→ SET k3 "v3"        ──→    "QUEUED"   (4 RTT)
──→ EXEC               ──→    [OK,OK,OK] (5 RTT)

총 5번 네트워크 왕복!
서버 입장:
  MULTI 수신 → "트랜잭션 모드 진입"
  SET k1 수신 → 실행 안 하고 큐에 저장 → "QUEUED" 응답
  SET k2 수신 → 실행 안 하고 큐에 저장 → "QUEUED" 응답
  SET k3 수신 → 실행 안 하고 큐에 저장 → "QUEUED" 응답
  EXEC 수신  → 큐의 명령 전체 연속 실행

Pipeline + MULTI/EXEC 함께 사용 시

Client                          Server
────────────────────────────────────────────
──→ MULTI       ┐
──→ SET k1 "v1" ├── 한 번에 전송 ──→  "OK"
──→ SET k2 "v2" │                      "QUEUED"
──→ SET k3 "v3" │                      "QUEUED"
──→ EXEC        ┘                      "QUEUED"
                                       [OK,OK,OK]

총 1번 네트워크 왕복

핵심 정리

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  MULTI/EXEC 단독:                                           │
│    명령마다 개별 전송 → N번 RTT                             │
│    서버 큐에 하나씩 쌓임                                    │
│                                                             │
│  Pipeline + MULTI/EXEC:                                     │
│    전체를 한 번에 전송 → 1번 RTT                           │
│    서버에서 받은 후 동일하게 큐에 쌓고 EXEC 실행           │
│                                                             │
│  → 운영에서는 Pipeline + MULTI/EXEC 함께 사용 권장         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

EXEC 실행 중 끼어들기 불가의 의미


Redis 싱글 스레드 큐로 이해하기

평소 Redis 큐:

Client A: SET k1  ──┐
Client B: GET k2  ──┤──→ [큐: SET k1, GET k2, INCR k3, SET k4 ...]
Client A: INCR k3 ──┤         ↑
Client B: SET k4  ──┘    순서 섞임 (누가 먼저 도착하냐에 따라)

EXEC 순간에 일어나는 일

Client A가 EXEC 전송
       │
       ▼
Redis 싱글 스레드:
  "EXEC 받았다"
  ┌────────────────────────────────┐
  │  큐에 있는 A의 명령들 연속 실행 │
  │                                │
  │  SET k1 실행                   │
  │  SET k2 실행                   │ ← 이 구간이 끝날 때까지
  │  SET k3 실행                   │   다른 명령 처리 안 함
  │                                │
  └────────────────────────────────┘
       │
       ▼
  이제 다른 클라이언트 명령 처리

타임라인으로 보기

시간 →

Client A: MULTI─SET k1─SET k2─SET k3─EXEC
                                       │
Client B:           GET x─────────────┼──────GET x
                      ↑               │         ↑
                  A 큐잉 중           │      EXEC 완료 후
                  끼어들기 가능       │      이제 실행됨
                                      │
                              ←이 구간→
                              B 명령이
                              있어도 대기

왜 가능한가

Redis = 싱글 스레드

EXEC를 처리하는 순간:
  큐에 있는 A의 명령들을 전부 처리할 때까지
  다른 명령을 꺼내지 않음

→ 물리적으로 끼어들기 불가능
→ "싱글 스레드가 큐에서 하나씩 처리"하는 구조 덕분

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  "EXEC 중 끼어들기 불가"의 의미:                             │
│                                                             │
│  EXEC를 받은 순간                                           │
│  Redis 싱글 스레드가 큐에 담긴 명령을                       │
│  전부 처리할 때까지                                         │
│  다른 클라이언트 명령을 처리하지 않음                        │
│                                                             │
│  = 싱글 스레드 구조가 만들어주는 원자성                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


큐잉 구간에서 섞이는 예시

Client A: MULTI → SET k1 → SET k2 → SET k3 → EXEC
Client B:                GET x → SET y

시간 순서대로 큐에 쌓이면:

큐: [A:SET k1] [B:GET x] [A:SET k2] [B:SET y] [A:SET k3] [A:EXEC]
      ↑                                                       ↑
   A 큐잉 중                                            EXEC 도착
   B 끼어들기 가능!                                     이 이후는
                                                        A 명령만 연속 실행

EXEC 전 vs EXEC 후

EXEC 전 (큐잉 구간):

  큐: [A:SET k1] [B:GET x] [A:SET k2] [B:SET y] [A:SET k3]
       ↑           ↑
       A           B 섞임 → ABABA 패턴 가능


EXEC 도착 순간:

  큐: [...] [A:EXEC]
              │
              ▼
      A의 남은 명령 전부 연속 실행
      ┌─────────────────┐
      │ A:SET k1        │
      │ A:SET k2        │ ← B 끼어들기 불가
      │ A:SET k3        │
      └─────────────────┘

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  큐잉 구간 (MULTI ~ EXEC 사이):                             │
│    ABAA, ABBA, AABB 등 어떤 패턴이든 섞일 수 있음          │
│    네트워크 도착 순서에 따라 결정                            │
│                                                             │
│  EXEC 실행 순간:                                             │
│    AAAA 패턴 보장                                           │
│    A의 명령이 반드시 연속으로 실행됨                        │
│                                                             │
│  이것이 WATCH가 필요한 이유:                                 │
│    큐잉 구간에 B가 값을 바꿀 수 있으므로                    │
│    EXEC 전에 값이 바뀌었으면 실패 처리                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

아주 쉽게 설명


Redis는 싱글 스레드

Redis = 직원 1명인 은행 창구

한 번에 한 명만 처리 가능

EXEC 전 (큐잉 구간)

Client A가 번호표 뽑고 서류 준비 중

창구 직원:
  A 서류 1장 처리
  B 손님 끼어들어서 처리  ← 가능!
  A 서류 2장 처리
  C 손님 끼어들어서 처리  ← 가능!
  A 서류 3장 처리

EXEC 순간

A가 "지금부터 제 서류 한 번에 처리해주세요" (EXEC)

창구 직원:
  ┌─────────────────────────────┐
  │  A 서류 1장                 │
  │  A 서류 2장                 │ ← 이 묶음 끝날 때까지
  │  A 서류 3장                 │   다른 손님 절대 안 받음
  └─────────────────────────────┘
  → 다음 손님

왜 못 끼어드나?

직원(싱글 스레드)이 
EXEC 묶음을 처리하는 동안

다른 손님(Client B, C)은
대기실에서 기다려야 함

직원이 1명이라 
물리적으로 동시 처리 불가능

한 줄 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  싱글 스레드 = 한 번에 하나만 처리                          │
│                                                             │
│  EXEC = "이 묶음 다 끝낼 때까지 다른 거 하지마"             │
│                                                             │
│  → 물리적으로 끼어들 틈이 없음                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


핵심 이해

Redis는 클라이언트(연결)별로 트랜잭션 큐를 따로 관리합니다

내부 동작

Client A: MULTI 선언
               │
               ▼
Redis: "A 전용 트랜잭션 큐 생성"

┌─────────────────────────────────────────┐
│  A 전용 큐  │  글로벌 실행 큐           │
│  (대기)     │  (즉시 실행)              │
├─────────────────────────────────────────┤
│             │                           │
│  A:SET k1   │  B:GET x  → 즉시 처리    │
│  A:SET k2   │  C:SET y  → 즉시 처리    │
│  A:SET k3   │                           │
│             │                           │
└─────────────────────────────────────────┘

A가 EXEC 전송
    │
    ▼
A 전용 큐의 명령만 꺼내서 연속 실행
(B, C 명령과 무관)

타임라인

Client A                Client B               Redis 처리
──────────────────────────────────────────────────────────
MULTI
                                               A 전용 큐 생성
SET k1 → QUEUED
                        GET x → OK             GET x 즉시 처리
SET k2 → QUEUED
                        SET y → OK             SET y 즉시 처리
SET k3 → QUEUED
EXEC
                                               ┌────────────┐
                                               │ A:SET k1   │
                                               │ A:SET k2   │ 연속 실행
                                               │ A:SET k3   │
                                               └────────────┘

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  MULTI 선언 시:                                             │
│    해당 클라이언트 전용 트랜잭션 큐 생성                    │
│    이후 명령은 전용 큐에만 쌓임                             │
│                                                             │
│  다른 클라이언트 명령:                                       │
│    전용 큐와 무관하게 글로벌 큐에서 즉시 처리               │
│                                                             │
│  EXEC 시:                                                   │
│    해당 클라이언트 전용 큐의 명령만 연속 실행               │
│    다른 클라이언트 명령은 포함되지 않음                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

거의 정확한데 한 가지만 수정


사용자 이해 검증

✅ "MULTI 이후 다른 클라이언트 요청이 섞일 수 있다"
   → 맞음

✅ "EXEC 시 해당 클라이언트 명령만 처리"
   → 맞음

⚠️ "EXEC 중 다른 클라이언트 요청이 큐에 쌓이지 않는다"
   → 큐에는 쌓임, 단 처리를 못 할 뿐

정확한 동작

Client A: EXEC 실행 중
                │
                ▼
싱글 스레드: A 전용 큐 처리 중

Client B: SET k1 전송
               │
               ▼
          네트워크 버퍼에 쌓임 (큐에는 들어옴)
          BUT 싱글 스레드가 A 처리 중이라
          꺼내서 실행을 못 할 뿐

A EXEC 완료
    │
    ▼
B의 SET k1 처리

한 줄 수정

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  "큐에 쌓이지 않고 대기"  ← ❌                              │
│  "큐에는 쌓이지만 처리를 못 하고 대기" ← ✅                │
│                                                             │
│  이유: 싱글 스레드가 EXEC 처리로 바쁘기 때문               │
│        큐에서 꺼내서 실행할 스레드가 없음                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확히 이해하셨습니다


큐에는 모든 클라이언트 요청이 쌓임
              │
              ▼
싱글 스레드가 큐에서 하나씩 꺼내서 처리
              │
              ├── 일반 명령  → 그냥 실행
              │
              └── EXEC 명령 → 해당 클라이언트
                              전용 큐 통째로
                              연속 실행 후
                              다음 명령으로

전체 그림

글로벌 큐:
[B:GET x] [A:EXEC] [C:SET y] [B:SET z]
    │          │
    │          └── A 전용 큐 전체를 한 번에 처리
    │              (SET k1, SET k2, SET k3)
    │
    ▼
하나씩 순서대로 처리

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  결국 Redis의 모든 동작은                                    │
│  "싱글 스레드가 큐에서 하나씩 꺼내서 처리"                  │
│  이 한 문장으로 설명됩니다                                   │
│                                                             │
│  EXEC도 결국 큐에서 꺼낸 하나의 작업                        │
│  다만 그 작업이 "여러 명령을 연속 실행하는 묶음"일 뿐        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redis에서 클라이언트 구분 방법


Redis의 클라이언트 구분 기준

Redis = TCP 연결(Connection) 단위로 클라이언트 구분

연결 1 (Connection 1) = 클라이언트 A
연결 2 (Connection 2) = 클라이언트 B
연결 3 (Connection 3) = 클라이언트 C

→ IP:Port 조합으로 식별
→ MULTI/EXEC 트랜잭션 큐도 연결 단위로 관리

Spring Boot에서의 연결 구조

Spring Boot 애플리케이션
         │
         ▼
  [Lettuce Connection Pool]
  ┌──────────────────────────────────┐
  │  Connection 1 (TCP 연결)         │
  │  Connection 2 (TCP 연결)         │
  │  Connection 3 (TCP 연결)         │
  │  ...                             │
  └──────────────────────────────────┘
         │
         ▼
    Redis Server
    (각 Connection = 하나의 클라이언트)

실제 Spring Boot 요청 흐름

HTTP 요청 1 (Thread 1)
    │
    ▼
Pool에서 Connection 1 빌림
    │
    ├── Redis 명령 실행 (Connection 1로)
    │
    └── Connection 1 반납

HTTP 요청 2 (Thread 2)
    │
    ▼
Pool에서 Connection 2 빌림
    │
    ├── Redis 명령 실행 (Connection 2로)
    │
    └── Connection 2 반납

MULTI/EXEC에서 중요한 이유

// ============================================
// ❌ 잘못된 방법: 연결이 바뀔 수 있음
// ============================================
public void wrongTransaction() {
    redisTemplate.multi();   // Connection 1에서 MULTI
    // 내부적으로 Connection이 바뀔 수 있음!
    redisTemplate.opsForValue().set("k1", "v1");  // Connection 2??
    redisTemplate.opsForValue().set("k2", "v2");  // Connection 3??
    redisTemplate.exec();    // 어떤 Connection??
    
    // MULTI를 선언한 Connection과
    // EXEC를 실행한 Connection이 다르면
    // 트랜잭션 의미 없음!
}


// ============================================
// ✅ 올바른 방법: 같은 Connection 보장
// ============================================
public void correctTransaction() {
    redisTemplate.execute(new SessionCallback<List<Object>>() {
        @Override
        public List<Object> execute(RedisOperations ops) {
            // SessionCallback 안에서는
            // 처음부터 끝까지 같은 Connection 사용 보장!

            ops.multi();
            ops.opsForValue().set("k1", "v1");
            ops.opsForValue().set("k2", "v2");
            return ops.exec();
        }
    });
}

SessionCallback이 하는 일

SessionCallback 없이:

Thread 1
  │
  ├── redisTemplate.multi()    → Pool에서 Connection 1 빌림 → 반납
  ├── redisTemplate.set(k1)   → Pool에서 Connection 2 빌림 → 반납
  ├── redisTemplate.set(k2)   → Pool에서 Connection 3 빌림 → 반납
  └── redisTemplate.exec()    → Pool에서 Connection 1 빌림 → 반납
                                                  ↑
                                 운 좋게 같은 연결이면 동작
                                 다른 연결이면 트랜잭션 깨짐!


SessionCallback 사용:

Thread 1
  └── SessionCallback 시작
        Connection 1 빌림 (여기서 한 번만!)
        │
        ├── MULTI    (Connection 1)
        ├── SET k1   (Connection 1)
        ├── SET k2   (Connection 1)
        └── EXEC     (Connection 1)
        │
        Connection 1 반납 (여기서 한 번만!)

Redis에서 직접 확인

# Redis CLI로 클라이언트 목록 확인
CLIENT LIST

# 결과:
id=1 addr=127.0.0.1:51234 fd=8 name= age=0 idle=0 ...  ← Connection 1
id=2 addr=127.0.0.1:51235 fd=9 name= age=0 idle=0 ...  ← Connection 2
id=3 addr=127.0.0.1:51236 fd=10 name= age=0 idle=0 ... ← Connection 3

# Spring Boot Pool 크기만큼 연결이 맺어져 있음
// 클라이언트 이름 지정 (디버깅 용이)
@Bean
public RedisConnectionFactory redisConnectionFactory() {
    LettuceClientConfiguration clientConfig =
        LettuceClientConfiguration.builder()
            .clientName("my-spring-app")  // 클라이언트 이름 설정
            .build();

    return new LettuceConnectionFactory(
        new RedisStandaloneConfiguration("localhost", 6379),
        clientConfig
    );
}

// Redis에서 확인:
// CLIENT LIST
// id=1 addr=... name=my-spring-app ...

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  Redis 클라이언트 구분 기준:                                 │
│    TCP Connection (연결) 단위                               │
│    각 연결마다 고유 ID 부여                                  │
│                                                             │
│  Spring Boot에서:                                           │
│    Lettuce Connection Pool이 연결 관리                      │
│    각 HTTP 요청 → Pool에서 Connection 빌림                  │
│    요청 완료 → Connection 반납                              │
│                                                             │
│  MULTI/EXEC 주의:                                           │
│    반드시 같은 Connection으로 실행해야 함                    │
│    → SessionCallback 사용 필수                              │
│    → 내부적으로 같은 Connection 바인딩 보장                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


핵심 구조

같은 Spring Boot 서버라도
스레드마다 다른 Connection을 빌림
→ Redis 입장에서는 다른 클라이언트

실제 흐름

Spring Boot 서버 (IP: 10.0.0.1)
│
├── Thread 1 (HTTP 요청 1)
│       └── Pool에서 Connection 1 빌림
│               └── Redis: id=1 addr=10.0.0.1:51001  ← 클라이언트 1
│
├── Thread 2 (HTTP 요청 2)
│       └── Pool에서 Connection 2 빌림
│               └── Redis: id=2 addr=10.0.0.1:51002  ← 클라이언트 2
│
└── Thread 3 (HTTP 요청 3)
        └── Pool에서 Connection 3 빌림
                └── Redis: id=3 addr=10.0.0.1:51003  ← 클라이언트 3
같은 서버에서 온 요청이지만
Port 번호가 다름 → Redis는 다른 클라이언트로 인식

MULTI/EXEC에서 왜 중요한가

Thread 1: Connection 1로 MULTI 선언
Thread 2: Connection 2로 MULTI 선언

Redis:
  Connection 1 전용 큐 생성
  Connection 2 전용 큐 생성

Thread 1: Connection 1로 SET k1 → Connection 1 큐에 쌓임
Thread 2: Connection 2로 SET k1 → Connection 2 큐에 쌓임

Thread 1: Connection 1로 EXEC → Connection 1 큐만 실행
Thread 2: Connection 2로 EXEC → Connection 2 큐만 실행

Connection Pool 재사용 시 주의

Thread 1: Connection 1 빌림 → MULTI → SET k1 → 반납 (실수!)
                                                    ↓
Thread 2:                           Connection 1 빌림 → SET k2
                                                    ↓
Thread 1:                                       Connection 1 빌림
                                                → EXEC
                                                (k1, k2 모두 실행됨!)
// ❌ Connection이 중간에 반납/재사용되는 위험한 코드
redisTemplate.multi();
// ← 여기서 Connection 반납될 수 있음
redisTemplate.opsForValue().set("k1", "v1");
redisTemplate.exec();


// ✅ SessionCallback으로 Connection 점유 보장
redisTemplate.execute(new SessionCallback<>() {
    public List<Object> execute(RedisOperations ops) {
        // 이 블록 안에서는 Connection 반납 안 됨
        // Thread 1이 끝날 때까지 Connection 1 점유
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();
    }
});

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  같은 Spring Boot 서버라도:                                  │
│    스레드마다 다른 Connection 사용                           │
│    Connection = 다른 Port → Redis가 다른 클라이언트로 인식  │
│                                                             │
│  Redis 클라이언트 식별:                                      │
│    IP:Port 조합                                             │
│    같은 서버여도 Port가 다르면 다른 클라이언트               │
│                                                             │
│  MULTI/EXEC 안전하게 쓰려면:                                │
│    SessionCallback으로 같은 Connection 점유 보장            │
│    중간에 Connection 반납/재사용 방지                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

IP:Port가 완전히 같으면?


TCP 구조상 불가능합니다

TCP 연결의 유일성 보장:

연결 = (클라이언트 IP : 클라이언트 Port : 서버 IP : 서버 Port)
                              ↑
                     OS가 자동으로 유니크하게 할당
                     (Ephemeral Port: 1024 ~ 65535)

→ 같은 시점에 IP:Port가 완전히 동일한 연결은
  TCP 구조상 존재 불가능

실제 연결 예시

Spring Boot (IP: 10.0.0.1) → Redis (IP: 10.0.0.2:6379)

Connection 1: 10.0.0.1:51001 → 10.0.0.2:6379
Connection 2: 10.0.0.1:51002 → 10.0.0.2:6379  ← Port 다름
Connection 3: 10.0.0.1:51003 → 10.0.0.2:6379  ← Port 다름

OS가 각 연결마다 다른 Port 번호 자동 할당
→ 절대 중복 없음

Port 재사용 케이스 (연결 종료 후)

연결 종료 후에는 같은 Port 재사용 가능

Connection 1: 10.0.0.1:51001 → 연결됨 → 종료
                    ↓
              (TIME_WAIT 상태, 약 60초)
                    ↓
              Port 51001 반환

Connection 5: 10.0.0.1:51001 → 새로 연결 (재사용)

Redis 입장:
  이전 Connection 1 = id=1
  새 Connection 5   = id=5  ← 새로운 클라이언트로 인식

Connection Pool이 이를 관리하는 이유

Pool이 없으면:
  요청마다 연결 생성/종료
  → Port 고갈 위험 (60초 TIME_WAIT)
  → 연결 비용 발생

Pool이 있으면:
  연결을 미리 만들어서 재사용
  → Port 유지 (종료/재생성 없음)
  → 각 연결은 항상 유니크한 Port 유지

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  동시에 IP:Port가 완전히 같은 연결:                          │
│    TCP 구조상 불가능                                        │
│    OS가 항상 유니크한 Port 할당                             │
│                                                             │
│  Connection Pool의 역할:                                    │
│    연결을 유지해서 Port 재사용 방지                         │
│    각 연결 = 항상 유니크한 클라이언트로 Redis에 식별        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

날카로운 질문입니다


우려하는 시나리오

Thread 1: Connection 1 빌림
          MULTI 선언
          SET k1 → QUEUED
          SET k2 → QUEUED
          (예외 발생! EXEC 못 함)
          Connection 1 반납 ← MULTI 상태 그대로!

Thread 2: Connection 1 빌림 (같은 연결!)
          SET k3 → QUEUED?? ← Thread 1의 큐에 섞임!
          SET k4 → QUEUED??
          (정상 명령인데 QUEUED 응답이 옴 → 이상함)

실제로 발생하는가?

Lettuce + Spring이 이를 방지하는 방법:

방어 1: SessionCallback 자동 정리

// SessionCallback은 예외 발생해도 자동으로 DISCARD
redisTemplate.execute(new SessionCallback<>() {
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("k1", "v1");

        throw new RuntimeException("예외 발생!");
        // ↓
        // Spring이 자동으로 DISCARD 호출
        // Connection 1의 MULTI 상태 초기화
    }
});
// Spring 내부 동작 (RedisConnectionUtils)
try {
    callback.execute(ops);
} catch (Exception e) {
    // DISCARD로 트랜잭션 상태 초기화
    connection.discard();
    throw e;
} finally {
    // 연결 반납
    releaseConnection(connection);
}

방어 2: Connection 유효성 검사

// Pool 설정에서 연결 상태 검증
GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setTestOnBorrow(true);   // 빌릴 때 검증
poolConfig.setTestOnReturn(true);   // 반납 시 검증
testOnReturn = true:
  Connection 반납 시
  → PING 테스트
  → 비정상 상태면 Pool에서 제거
  → 새 Connection 생성

testOnBorrow = true:
  Connection 빌릴 때
  → PING 테스트
  → 비정상 상태면 버리고 다른 Connection

방어 3: Lettuce StatefulConnection 상태 관리

Lettuce의 StatefulRedisConnection:

연결 반납 시 내부적으로 상태 확인
  → MULTI 상태 감지
  → 자동 DISCARD 전송
  → 상태 초기화 후 Pool에 반납

즉, 더러운 Connection(dirty connection)이
Pool에 들어가지 않도록 보장

그래도 문제가 생기는 케이스

Lettuce Pool이 아닌 직접 Connection 관리 시:

// ❌ 직접 관리 (위험)
RedisConnection conn = factory.getConnection();
conn.multi();
conn.set(k1, v1);
// 예외 발생! DISCARD 없이 반납
factory.releaseConnection(conn);  // MULTI 상태 그대로!

// ✅ try-finally 필수
RedisConnection conn = factory.getConnection();
try {
    conn.multi();
    conn.set(k1, v1);
    conn.exec();
} catch (Exception e) {
    conn.discard();  // 반드시 명시적 처리
    throw e;
} finally {
    factory.releaseConnection(conn);
}

최종 정리

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  우려하는 상황:                                              │
│    MULTI 상태 Connection이 Pool에 반납 후 재사용            │
│    → 다른 Thread 명령이 이전 큐에 섞임                      │
│                                                             │
│  Spring + Lettuce의 방어:                                   │
│    SessionCallback → 예외 시 자동 DISCARD                  │
│    testOnReturn    → 반납 시 상태 검증                      │
│    StatefulConnection → dirty connection 자동 처리          │
│                                                             │
│  직접 Connection 관리 시:                                    │
│    try-finally에서 명시적 DISCARD 필수                      │
│    → 그래서 SessionCallback 사용을 강력 권장               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Connection Pool + MULTI/EXEC 다이어그램


1. 정상적인 흐름

[Lettuce Connection Pool]
┌─────────────────────────────┐
│  Connection 1 (Port 51001)  │
│  Connection 2 (Port 51002)  │
│  Connection 3 (Port 51003)  │
└─────────────────────────────┘

Thread 1 요청:
  ① Pool에서 Connection 1 빌림
  ② MULTI 선언
  ③ SET k1 → QUEUED
  ④ SET k2 → QUEUED
  ⑤ EXEC → 실행 완료
  ⑥ Connection 1 반납 (깨끗한 상태)

Thread 2 요청:
  ① Pool에서 Connection 1 빌림 (재사용)
  ② 정상 명령 실행
  ③ Connection 1 반납

2. 우려하는 문제 상황

Thread 1                Pool              Redis
────────────────────────────────────────────────────────
① Connection 1 빌림
   ←─────────────────[Conn 1]──────────────────────────
② MULTI 선언                         Conn 1: MULTI 상태
   ──────────────────────────────────────────────────→
③ SET k1                             Conn 1 큐: [SET k1]
   ──────────────────────────────────────────────────→
④ 예외 발생!!
   EXEC 못 함

⑤ Connection 1 반납 ──[Conn 1]──→
   (MULTI 상태 그대로!)


Thread 2                Pool              Redis
────────────────────────────────────────────────────────
⑥ Connection 1 빌림
   ←─────────────────[Conn 1]──────────────────────────
   (MULTI 상태인 Connection!)

⑦ SET k3 전송                        Conn 1 큐: [SET k1]
   ──────────────────────────────────────────────────→   [SET k3] ← 섞임!!
   응답: "QUEUED" ← 이상하다??

⑧ GET x 전송                         Conn 1 큐: [SET k1]
   ──────────────────────────────────────────────────→   [SET k3]
   응답: "QUEUED" ← GET인데 QUEUED??  [GET x]  ← 섞임!!

3. Spring + Lettuce의 방어

Thread 1                SessionCallback        Pool        Redis
────────────────────────────────────────────────────────────────
① Connection 1 빌림
② MULTI 선언                                          MULTI 상태
③ SET k1 → QUEUED
④ 예외 발생!!
      │
      ▼
⑤ catch (Exception e)
   자동으로 DISCARD ──────────────────────────→  MULTI 상태 해제
                                                  큐 비워짐
⑥ Connection 1 반납 ────────────[Conn 1 깨끗]──→ Pool 반납


Thread 2
────────────────────────────────────────────────────────────────
⑦ Connection 1 빌림 ←──────────[Conn 1 깨끗]──
⑧ SET k3 전송 ─────────────────────────────→   "OK" (정상!)
⑨ GET x  전송 ─────────────────────────────→   "value" (정상!)

4. SessionCallback 내부 동작

SessionCallback 실행 구조:

┌─────────────────────────────────────────────────┐
│  try {                                          │
│      connection = pool.borrowConnection()       │
│                                                 │
│      ops.multi()  ──────────────────→  Redis   │
│      ops.set(k1)  ──────────────────→  Redis   │
│      ops.set(k2)  ──────────────────→  Redis   │
│      ops.exec()   ──────────────────→  Redis   │
│                                                 │
│  } catch (Exception e) {                        │
│      connection.discard()  ─────────→  Redis   │ ← 자동 DISCARD
│      throw e                                    │
│                                                 │
│  } finally {                                    │
│      pool.returnConnection(connection)          │ ← 정리 후 반납
│  }                                              │
└─────────────────────────────────────────────────┘

5. testOnReturn 방어

testOnReturn = true 설정 시:

Thread 1이 MULTI 상태 Connection 반납
         │
         ▼
Pool: "반납 전 상태 검증"
         │
         ├── PING 전송 → Redis 응답 확인
         │
         ├── 비정상 상태 감지 (MULTI 상태)
         │        │
         │        ▼
         │   Connection 버림 (Pool에서 제거)
         │   새 Connection 생성
         │
         └── 정상 상태
                  │
                  ▼
             Pool에 정상 반납
             다음 Thread가 안전하게 사용

최종 요약 다이어그램

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  문제:                                                       │
│  MULTI 상태 Connection 재사용 → 명령 섞임                   │
│                                                             │
│  해결책 3중 방어:                                            │
│                                                             │
│  1. SessionCallback                                         │
│     예외 발생 → 자동 DISCARD → 깨끗하게 반납               │
│                                                             │
│  2. testOnReturn                                            │
│     반납 시 상태 검증 → 비정상이면 버리고 새로 생성         │
│                                                             │
│  3. testOnBorrow                                            │
│     빌릴 때 상태 검증 → 비정상이면 다른 Connection 제공    │
│                                                             │
│  → 3중 방어로 dirty connection 문제 방지                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

SessionCallback 사용 범위


결론

MULTI/EXEC  → ✅ SessionCallback 필수
Lua Script  → ❌ 불필요 (단일 명령)
Pipeline    → ❌ 불필요 (executePipelined 사용)

이유

MULTI/EXEC → SessionCallback 필수

MULTI → SET → SET → EXEC
  ↑                   ↑
  같은 Connection이어야 의미 있음
  다른 Connection이면 트랜잭션 깨짐

→ SessionCallback으로 같은 Connection 바인딩 필수
// ✅ MULTI/EXEC
redisTemplate.execute(new SessionCallback<List<Object>>() {
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        ops.opsForValue().set("k2", "v2");
        return ops.exec();
    }
});

Lua Script → SessionCallback 불필요

EVAL script ...
  ↑
  단일 명령 하나로 전송
  내부 로직이 서버에서 실행
  Connection 바인딩 필요 없음

→ 그냥 execute() 로 충분
// ✅ Lua Script (SessionCallback 불필요)
String script = """
    local stock = tonumber(redis.call('GET', KEYS[1]))
    if stock <= 0 then return 0 end
    redis.call('DECRBY', KEYS[1], 1)
    return 1
    """;

Long result = redisTemplate.execute(
    new DefaultRedisScript<>(script, Long.class),
    List.of("stock:1"),
    "1"
);

Pipeline → SessionCallback 불필요

executePipelined() 가 내부적으로
같은 Connection 바인딩을 알아서 처리

→ SessionCallback 필요 없음
→ executePipelined() 사용
// ✅ Pipeline (executePipelined 사용)
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    connection.set("k1".getBytes(), "v1".getBytes());
    connection.set("k2".getBytes(), "v2".getBytes());
    connection.set("k3".getBytes(), "v3".getBytes());
    return null;
});

각각 Connection 처리 방식 비교

┌──────────────┬─────────────────────┬──────────────────────────┐
│              │  Connection 바인딩  │  사용 방법               │
├──────────────┼─────────────────────┼──────────────────────────┤
│ MULTI/EXEC   │ 필수                │ SessionCallback          │
│              │ (여러 명령,          │                          │
│              │  같은 연결 보장)     │                          │
├──────────────┼─────────────────────┼──────────────────────────┤
│ Lua Script   │ 불필요              │ execute()                │
│              │ (단일 명령)         │ + DefaultRedisScript     │
├──────────────┼─────────────────────┼──────────────────────────┤
│ Pipeline     │ 내부 자동 처리      │ executePipelined()       │
│              │ (executePipelined   │                          │
│              │  가 알아서 처리)     │                          │
└──────────────┴─────────────────────┴──────────────────────────┘

한눈에 보는 코드

@Service
@RequiredArgsConstructor
public class RedisExampleService {

    private final StringRedisTemplate redisTemplate;

    // ① MULTI/EXEC → SessionCallback 필수
    public void multiExec() {
        redisTemplate.execute(new SessionCallback<List<Object>>() {
            public List<Object> execute(RedisOperations ops) {
                ops.multi();
                ops.opsForValue().set("k1", "v1");
                ops.opsForValue().set("k2", "v2");
                return ops.exec();
            }
        });
    }

    // ② Lua Script → 그냥 execute()
    public Long luaScript() {
        String script = "return redis.call('INCR', KEYS[1])";
        return redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            List.of("counter")
        );
    }

    // ③ Pipeline → executePipelined()
    public void pipeline() {
        redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
            conn.set("k1".getBytes(), "v1".getBytes());
            conn.set("k2".getBytes(), "v2".getBytes());
            return null;
        });
    }
}

요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  SessionCallback이 필요한 이유:                              │
│    여러 명령을 같은 Connection으로 보장해야 할 때            │
│                                                             │
│  MULTI/EXEC: 여러 명령 → 같은 Connection 필수 → ✅ 필요    │
│                                                             │
│  Lua Script: 단일 명령(EVAL) → Connection 무관 → ❌ 불필요 │
│                                                             │
│  Pipeline:   executePipelined()가 내부 처리 → ❌ 불필요    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

소스 파일이 없으므로 Spring Data Redis 실제 구현 코드를 직접 설명드리겠습니다.


SessionCallback 내부 구현


1. SessionCallback 인터페이스

// Spring Data Redis 소스
// org.springframework.data.redis.core.SessionCallback

@FunctionalInterface
public interface SessionCallback<T> {

    /**
     * 같은 Connection에서 실행될 코드 블록
     * RedisOperations = Connection에 바인딩된 RedisTemplate
     */
    @Nullable
    <K, V> T execute(RedisOperations<K, V> operations) throws DataAccessException;
}

2. RedisTemplate.execute() 내부 구현

// org.springframework.data.redis.core.RedisTemplate

public class RedisTemplate<K, V> implements RedisOperations<K, V> {

    @Override
    public <T> T execute(SessionCallback<T> session) {

        // ① Connection Factory에서 Connection 획득
        RedisConnectionFactory factory = getRequiredConnectionFactory();
        RedisConnection conn = RedisConnectionUtils.bindConnection(
            factory,
            enableTransactionSupport  // 트랜잭션 지원 여부
        );

        try {
            // ② 획득한 Connection을 현재 Thread에 바인딩
            // (ThreadLocal에 저장)
            RedisOperations<K, V> ops = new BoundSessionRedisTemplate<>(
                conn, this
            );

            // ③ 사용자 코드 실행 (같은 Connection 사용 보장)
            return session.execute(ops);

        } catch (RuntimeException e) {

            // ④ 예외 발생 시 DISCARD (MULTI 상태 정리)
            if (conn.isQueueing()) {
                conn.discard();  // ← 자동 DISCARD!
            }
            throw e;

        } finally {

            // ⑤ 반드시 Connection 반납
            RedisConnectionUtils.unbindConnection(factory);
        }
    }
}

3. Connection 바인딩 핵심: ThreadLocal

// org.springframework.data.redis.core.RedisConnectionUtils

public class RedisConnectionUtils {

    // ThreadLocal로 현재 스레드에 Connection 바인딩
    private static final ThreadLocal<Map<RedisConnectionFactory, RedisConnection>>
        connectionHolder = new ThreadLocal<>();

    public static RedisConnection bindConnection(
        RedisConnectionFactory factory,
        boolean enableTx
    ) {
        // 이미 바인딩된 Connection이 있으면 재사용
        Map<RedisConnectionFactory, RedisConnection> connMap =
            connectionHolder.get();

        if (connMap != null && connMap.containsKey(factory)) {
            return connMap.get(factory);  // ← 같은 Connection 반환!
        }

        // 없으면 Pool에서 새로 획득
        RedisConnection conn = factory.getConnection();

        // ThreadLocal에 저장
        if (connMap == null) {
            connMap = new HashMap<>();
            connectionHolder.set(connMap);
        }
        connMap.put(factory, conn);

        return conn;
    }

    public static void unbindConnection(RedisConnectionFactory factory) {
        Map<RedisConnectionFactory, RedisConnection> connMap =
            connectionHolder.get();

        if (connMap != null) {
            RedisConnection conn = connMap.remove(factory);
            if (conn != null) {
                conn.close();  // Pool에 반납
            }
            if (connMap.isEmpty()) {
                connectionHolder.remove();  // ThreadLocal 정리
            }
        }
    }
}

4. 전체 흐름 다이어그램

Thread 1이 SessionCallback 실행

execute(SessionCallback) 호출
        │
        ▼
RedisConnectionUtils.bindConnection()
        │
        ├── ThreadLocal 확인
        │       │
        │       ├── 있으면 → 기존 Connection 재사용
        │       └── 없으면 → Pool에서 새 Connection 획득
        │                     ThreadLocal에 저장
        ▼
사용자 코드 실행 (session.execute(ops))
        │
        ├── ops.multi()   ──→ ThreadLocal의 Connection 사용
        ├── ops.set(k1)   ──→ ThreadLocal의 Connection 사용
        ├── ops.set(k2)   ──→ ThreadLocal의 Connection 사용
        └── ops.exec()    ──→ ThreadLocal의 Connection 사용
        │
        ├── 정상 완료
        │       │
        │       ▼
        │   finally: unbindConnection()
        │       │
        │       ▼
        │   ThreadLocal 제거 + Connection Pool 반납
        │
        └── 예외 발생
                │
                ▼
            catch: conn.isQueueing() → DISCARD 자동 호출
                │
                ▼
            finally: unbindConnection()
                │
                ▼
            ThreadLocal 제거 + Connection Pool 반납

5. ThreadLocal이 핵심인 이유

ThreadLocal = 스레드별로 독립적인 저장공간

Thread 1의 ThreadLocal: Connection 1
Thread 2의 ThreadLocal: Connection 2
Thread 3의 ThreadLocal: Connection 3

→ 같은 SessionCallback 코드를 실행해도
  각 스레드는 자신의 Connection만 사용
  절대 다른 스레드의 Connection과 섞이지 않음
// ThreadLocal 동작 원리
ThreadLocal<RedisConnection> local = new ThreadLocal<>();

// Thread 1
local.set(connection1);
local.get();  // → connection1 반환

// Thread 2 (동시에)
local.set(connection2);
local.get();  // → connection2 반환 (Thread 1과 무관)

최종 요약

┌─────────────────────────────────────────────────────────────┐
│              SessionCallback 내부 동작 원리                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  같은 Connection 보장 방법:                                  │
│    ThreadLocal에 Connection 저장                            │
│    SessionCallback 안에서는 항상 같은 Connection 반환       │
│                                                             │
│  예외 처리:                                                  │
│    isQueueing() → MULTI 상태 감지                          │
│    자동 DISCARD → Connection 상태 초기화                    │
│                                                             │
│  Connection 반납:                                           │
│    finally 블록에서 반드시 실행                             │
│    ThreadLocal 제거 + Pool 반납                             │
│                                                             │
│  결론:                                                       │
│    ThreadLocal이 "같은 Connection 보장"의 핵심 메커니즘     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

정확히 이해하셨습니다


DB Transaction vs Redis MULTI/EXEC 비교

완전히 동일한 원리입니다
[DB Transaction - ThreadLocal]

Thread 1
  │
  ├── TransactionSynchronizationManager (ThreadLocal)
  │       └── Connection 1 바인딩
  │
  ├── @Transactional 시작
  │       └── ThreadLocal에서 Connection 1 꺼냄
  │
  ├── repository.save()   → Connection 1 사용
  ├── repository.update() → Connection 1 사용  ← 같은 Connection 보장
  ├── repository.delete() → Connection 1 사용
  │
  ├── 정상 → COMMIT
  └── 예외 → ROLLBACK + Connection 반납


[Redis MULTI/EXEC - ThreadLocal]

Thread 1
  │
  ├── RedisConnectionUtils (ThreadLocal)
  │       └── Connection 1 바인딩
  │
  ├── SessionCallback 시작
  │       └── ThreadLocal에서 Connection 1 꺼냄
  │
  ├── ops.multi()   → Connection 1 사용
  ├── ops.set(k1)   → Connection 1 사용  ← 같은 Connection 보장
  ├── ops.set(k2)   → Connection 1 사용
  │
  ├── 정상 → EXEC
  └── 예외 → DISCARD + Connection 반납

핵심 대응 관계

┌────────────────────────┬────────────────────────────────────┐
│      DB Transaction    │      Redis MULTI/EXEC              │
├────────────────────────┼────────────────────────────────────┤
│ ThreadLocal 관리자     │ TransactionSynchronization  │ RedisConnectionUtils       │
│                        │ Manager                     │                            │
├────────────────────────┼────────────────────────────────────┤
│ 트랜잭션 시작          │ @Transactional              │ SessionCallback 시작       │
├────────────────────────┼────────────────────────────────────┤
│ 정상 종료              │ COMMIT                      │ EXEC                       │
├────────────────────────┼────────────────────────────────────┤
│ 예외 발생              │ ROLLBACK                    │ DISCARD                    │
├────────────────────────┼────────────────────────────────────┤
│ 자원 반납              │ Connection Pool 반납        │ Connection Pool 반납       │
└────────────────────────┴────────────────────────────────────┘

DISCARD의 정확한 역할

DB ROLLBACK:
  이미 실행된 변경사항을 되돌림 (Undo Log 활용)

Redis DISCARD:
  아직 실행되지 않은 큐를 비울 뿐
  (이미 실행된 건 되돌리지 못함)
MULTI 선언
    │
    ├── SET k1 → QUEUED  (아직 실행 안 됨)
    ├── SET k2 → QUEUED  (아직 실행 안 됨)
    ├── SET k3 → QUEUED  (아직 실행 안 됨)
    │
    └── 예외 발생!
              │
              ▼
          DISCARD
              │
              ▼
          큐 비워짐 [SET k1, SET k2, SET k3] 전부 삭제
          Connection 트랜잭션 모드 해제
          Redis 데이터 변경 없음 ← 애초에 실행 안 됐으니까

→ ROLLBACK처럼 "복구"가 아니라
  "실행 예정이던 것을 취소"하는 개념

전체 원리 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  공통 원리:                                                  │
│    ThreadLocal에 Connection 바인딩                          │
│    → 같은 스레드 = 항상 같은 Connection 보장               │
│                                                             │
│  정상 흐름:                                                  │
│    DB    → COMMIT  (변경사항 확정)                          │
│    Redis → EXEC    (큐의 명령 실행)                         │
│                                                             │
│  예외 흐름:                                                  │
│    DB    → ROLLBACK (변경사항 되돌림)                       │
│    Redis → DISCARD  (큐 비움, 실행 안 된 것 취소)           │
│                                                             │
│  차이점:                                                     │
│    DB ROLLBACK = 이미 실행된 것 되돌림 (Undo Log)          │
│    Redis DISCARD = 아직 실행 안 된 것 취소 (큐 비우기)     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redis DISCARD 완전 정리


1. DISCARD란?

MULTI 선언 후 쌓인 명령 큐를 전부 비우고
트랜잭션 모드를 종료하는 명령

2. 기본 동작

# 정상 트랜잭션 흐름
MULTI          → OK
SET k1 "v1"   → QUEUED
SET k2 "v2"   → QUEUED
EXEC           → [OK, OK]


# DISCARD 흐름
MULTI          → OK
SET k1 "v1"   → QUEUED
SET k2 "v2"   → QUEUED
DISCARD        → OK       ← 큐 전체 취소

GET k1         → nil      ← 아무것도 실행 안 됨
GET k2         → nil

3. DISCARD의 정확한 역할

MULTI 선언
    │
    ├── SET k1 → QUEUED  ← 아직 실행 안 됨
    ├── SET k2 → QUEUED  ← 아직 실행 안 됨
    ├── SET k3 → QUEUED  ← 아직 실행 안 됨
    │
    └── DISCARD
              │
              ▼
    큐: [SET k1, SET k2, SET k3] → 전부 삭제
    트랜잭션 모드 해제
    Redis 데이터 변경 없음

→ "실행 예정이던 것을 취소"
→ DB의 ROLLBACK과 다름!
   ROLLBACK = 이미 실행된 것 되돌림
   DISCARD  = 아직 실행 안 된 것 취소

4. DB ROLLBACK vs Redis DISCARD

┌──────────────────────────────────────────────────────────────┐
│               DB ROLLBACK vs Redis DISCARD                   │
├──────────────────┬───────────────────────────────────────────┤
│  DB ROLLBACK     │  Redis DISCARD                           │
├──────────────────┼───────────────────────────────────────────┤
│ 이미 실행된      │ 아직 실행 안 된                          │
│ 변경사항을       │ 큐에 쌓인 명령을                         │
│ 되돌림           │ 취소                                     │
│ (Undo Log 활용)  │ (큐 비우기)                              │
├──────────────────┼───────────────────────────────────────────┤
│ BEGIN            │ MULTI                                    │
│ UPDATE ...       │ SET k1 (QUEUED)                          │
│ DELETE ...       │ SET k2 (QUEUED)                          │
│ ROLLBACK ──────→ │ DISCARD ──────→                          │
│ 변경 전으로 복구 │ 큐만 비워짐                              │
│                  │ (데이터 변경 없었으므로 복구 불필요)      │
└──────────────────┴───────────────────────────────────────────┘

5. DISCARD가 필요한 상황들

상황 1: 조건 검증 실패

redisTemplate.execute(new SessionCallback<Object>() {
    public Object execute(RedisOperations ops) {

        ops.watch("stock:" + productId);
        int stock = Integer.parseInt(
            (String) ops.opsForValue().get("stock:" + productId)
        );

        // 재고 없으면 트랜잭션 불필요
        if (stock <= 0) {
            ops.unwatch();   // WATCH 해제
            // DISCARD 불필요 (MULTI 선언 전이므로)
            throw new RuntimeException("재고 없음");
        }

        ops.multi();
        ops.opsForValue().decrement("stock:" + productId);
        return ops.exec();
    }
});

상황 2: MULTI 후 비즈니스 로직 실패

redisTemplate.execute(new SessionCallback<Object>() {
    public Object execute(RedisOperations ops) {

        ops.multi();
        ops.opsForValue().set("k1", "v1");  // QUEUED
        ops.opsForValue().set("k2", "v2");  // QUEUED

        // 비즈니스 로직 검증 실패
        if (!isValid()) {
            ops.discard();  // ← 명시적 DISCARD
            return null;
        }

        return ops.exec();
    }
});

상황 3: 예외 발생 (Spring 자동 처리)

redisTemplate.execute(new SessionCallback<Object>() {
    public Object execute(RedisOperations ops) {

        ops.multi();
        ops.opsForValue().set("k1", "v1");  // QUEUED

        // 예외 발생!
        throw new RuntimeException("오류!");

        // Spring SessionCallback이 자동으로:
        // catch → conn.isQueueing() 확인 → DISCARD 호출
        // → 큐 비워짐
        // → Connection 정상 상태로 반납
    }
});

6. Spring SessionCallback 내부 DISCARD 처리

// RedisTemplate 내부 구현
public <T> T execute(SessionCallback<T> session) {

    RedisConnection conn = RedisConnectionUtils
        .bindConnection(factory, enableTransactionSupport);

    try {
        return session.execute(ops);

    } catch (RuntimeException e) {

        // MULTI 상태인지 확인 후 DISCARD
        if (conn.isQueueing()) {
            try {
                conn.discard();  // ← 자동 DISCARD
                log.debug("DISCARD 실행 - 트랜잭션 큐 비움");
            } catch (Exception discardEx) {
                log.error("DISCARD 실패", discardEx);
            }
        }
        throw e;

    } finally {
        // 정상/예외 모두 Connection 반납
        RedisConnectionUtils.unbindConnection(factory);
    }
}

7. WATCH + DISCARD 조합

# WATCH 후 DISCARD 시 WATCH도 자동 해제
WATCH key1 key2   → OK
MULTI             → OK
SET key1 "v1"    → QUEUED
DISCARD           → OK
                    ↑
                    WATCH도 함께 해제됨
                    (UNWATCH 따로 불필요)
redisTemplate.execute(new SessionCallback<Object>() {
    public Object execute(RedisOperations ops) {

        ops.watch(List.of("balance:" + userId));
        String balance = (String) ops.opsForValue()
            .get("balance:" + userId);

        if (Integer.parseInt(balance) < amount) {
            // DISCARD 시 WATCH도 함께 해제
            ops.discard();
            throw new RuntimeException("잔액 부족");
        }

        ops.multi();
        ops.opsForValue().decrement("balance:" + userId, amount);
        return ops.exec();
    }
});

8. DISCARD 후 상태

DISCARD 실행 후 Redis Connection 상태:

  이전: 트랜잭션 모드 (MULTI 상태)
              │
          DISCARD
              │
  이후: 일반 모드 (트랜잭션 해제)
         ↓
  바로 새로운 명령 실행 가능
  또는 새로운 MULTI 시작 가능
MULTI          → OK
SET k1 "v1"   → QUEUED
DISCARD        → OK        ← 트랜잭션 모드 해제

# 바로 일반 명령 실행 가능
SET k2 "v2"   → OK        ← 정상 실행
GET k2         → "v2"

# 새 트랜잭션 시작 가능
MULTI          → OK
SET k3 "v3"   → QUEUED
EXEC           → [OK]

9. 에러 케이스

# MULTI 없이 DISCARD 하면?
DISCARD
→ ERR DISCARD without MULTI

# EXEC 없이 연결 종료 시?
MULTI
SET k1 "v1"   → QUEUED
(연결 종료)
→ 자동 DISCARD (큐 자동 비워짐)

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                    DISCARD 핵심 정리                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  역할:                                                       │
│    MULTI 후 쌓인 큐를 비우고 트랜잭션 모드 종료             │
│                                                             │
│  특성:                                                       │
│    큐에 쌓인 명령만 취소 (아직 실행 안 됐으므로)            │
│    DB ROLLBACK과 다름 (실행된 것 되돌리기 아님)             │
│    WATCH도 함께 해제됨                                      │
│                                                             │
│  언제 실행되나:                                              │
│    명시적: ops.discard() 호출                               │
│    자동:   SessionCallback 내 예외 발생 시                  │
│    자동:   연결 종료 시                                      │
│                                                             │
│  Spring SessionCallback:                                    │
│    예외 발생 → isQueueing() 확인 → 자동 DISCARD            │
│    → Connection 정상 상태로 Pool 반납                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


MULTI 선언 이전에 실행된 것들 → 이미 Redis에 반영됨, 취소 불가
MULTI 선언 이후에 쌓인 큐    → DISCARD로 취소 가능

예시

SET k1 "v1"    → OK       ← 즉시 실행, Redis에 반영됨
SET k2 "v2"    → OK       ← 즉시 실행, Redis에 반영됨

MULTI          → OK       ← 트랜잭션 시작

SET k3 "v3"   → QUEUED   ← 큐에만 쌓임
SET k4 "v4"   → QUEUED   ← 큐에만 쌓임

DISCARD        → OK

GET k1         → "v1"    ← 그대로 있음 (MULTI 전 실행)
GET k2         → "v2"    ← 그대로 있음 (MULTI 전 실행)
GET k3         → nil     ← 취소됨 (MULTI 후 큐에 쌓였던 것)
GET k4         → nil     ← 취소됨 (MULTI 후 큐에 쌓였던 것)

한 줄 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  DISCARD = MULTI 이후 큐에 쌓인 것만 취소                   │
│                                                             │
│  MULTI 이전에 실행된 명령 → 이미 반영, 건드리지 않음        │
│  MULTI 이후 QUEUED된 명령 → 전부 취소                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 거의 동일한 구조입니다


DB vs Redis 연결 구조 비교

[DB 연결 구조]

Spring App
    │
    ▼
HikariCP (Connection Pool)
    │
    ├── DB Connection 1 (JDBC)
    ├── DB Connection 2 (JDBC)
    └── DB Connection 3 (JDBC)
    │
    ▼
Database Server


[Redis 연결 구조]

Spring App
    │
    ▼
Lettuce (Connection Pool)
    │
    ├── Redis Connection 1 (TCP)
    ├── Redis Connection 2 (TCP)
    └── Redis Connection 3 (TCP)
    │
    ▼
Redis Server

거의 동일한 것들

┌──────────────────────┬──────────────────┬──────────────────────┐
│                      │       DB         │       Redis          │
├──────────────────────┼──────────────────┼──────────────────────┤
│ Connection Pool      │ HikariCP         │ Lettuce Pool         │
├──────────────────────┼──────────────────┼──────────────────────┤
│ Connection 바인딩    │ ThreadLocal      │ ThreadLocal          │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 트랜잭션 시작        │ BEGIN            │ MULTI                │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 트랜잭션 확정        │ COMMIT           │ EXEC                 │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 트랜잭션 취소        │ ROLLBACK         │ DISCARD              │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 트랜잭션 추상화      │ @Transactional   │ SessionCallback      │
├──────────────────────┼──────────────────┼──────────────────────┤
│ Connection 반납      │ finally 블록     │ finally 블록         │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 유효성 검사          │ testOnBorrow     │ testOnBorrow         │
├──────────────────────┼──────────────────┼──────────────────────┤
│ 최대 연결 수         │ maximumPoolSize  │ maxTotal             │
└──────────────────────┴──────────────────┴──────────────────────┘

결정적 차이

DB (HikariCP):
  Connection = Thread-safe 하지 않음
  → 스레드마다 전용 Connection 필요
  → Pool 필수

Redis (Lettuce):
  Connection = Thread-safe
  → 단일 Connection을 여러 스레드가 공유 가능
  → Pool 없이도 동작 가능
  → 단, 높은 동시성 환경에서는 Pool 권장

DB:
  Connection 당 전용 스레드 점유
  → Pool 크기 = 동시 처리 가능한 요청 수

Redis:
  Connection 당 여러 스레드 공유 가능
  → Pool 크기 = 성능 튜닝 목적

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  공통점:                                                     │
│    Connection Pool에서 Connection 빌려서 사용               │
│    ThreadLocal로 같은 Connection 바인딩                     │
│    finally에서 반납                                         │
│    트랜잭션 시작/확정/취소 패턴 동일                        │
│                                                             │
│  차이점:                                                     │
│    DB    → Thread-safe 아님 → Pool 필수                    │
│    Redis → Thread-safe     → Pool 선택적                   │
│                                                             │
│  결론:                                                       │
│    "DB 연결 관리 방법을 이미 알면                           │
│     Redis 연결 관리도 거의 동일하게 이해 가능"              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redis MULTI/EXEC Spring Boot 코드


1. 기본 사용법

@Service
@RequiredArgsConstructor
public class RedisTransactionService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // 기본 MULTI/EXEC
    // ============================================
    public List<Object> basicMultiExec() {
        return redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {

                ops.multi();                                    // MULTI

                ops.opsForValue().set("key1", "value1");       // QUEUED
                ops.opsForValue().set("key2", "value2");       // QUEUED
                ops.opsForValue().increment("counter");        // QUEUED

                return ops.exec();                             // EXEC
                // → [true, true, 1]
            }
        });
    }
}

2. 실전 예시: 캐시 일괄 초기화

@Service
@RequiredArgsConstructor
public class CacheService {

    private final StringRedisTemplate redisTemplate;

    // 유저 관련 캐시 원자적 삭제
    public void clearUserCache(Long userId) {

        List<String> keys = List.of(
            "user:" + userId + ":profile",
            "user:" + userId + ":session",
            "user:" + userId + ":token",
            "user:" + userId + ":cart"
        );

        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {
                ops.multi();
                keys.forEach(ops::delete);   // 전부 QUEUED
                return ops.exec();
                // EXEC 순간 4개 명령 연속 실행
                // 다른 클라이언트 끼어들기 불가
            }
        });
    }

    // 여러 카운터 동시 리셋 (자정 배치)
    public void resetDailyCounters() {
        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {
                ops.multi();
                ops.opsForValue().set("count:view",  "0");
                ops.opsForValue().set("count:click", "0");
                ops.opsForValue().set("count:order", "0");
                ops.opsForValue().set("count:login", "0");
                return ops.exec();
            }
        });
    }
}

3. WATCH + MULTI/EXEC (낙관적 락)

@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {

    private final StringRedisTemplate redisTemplate;

    // 포인트 차감 (충돌 시 재시도)
    public boolean deductPoint(Long userId, int amount) {
        String key     = "point:" + userId;
        int    maxRetry = 5;

        for (int attempt = 1; attempt <= maxRetry; attempt++) {
            try {
                List<Object> result = redisTemplate.execute(
                    new SessionCallback<List<Object>>() {
                        @Override
                        public List<Object> execute(RedisOperations ops) {

                            // ① WATCH 설정
                            ops.watch(key);

                            // ② 현재 값 읽기
                            String balanceStr =
                                (String) ops.opsForValue().get(key);
                            int balance = Integer.parseInt(
                                balanceStr != null ? balanceStr : "0"
                            );

                            // ③ 비즈니스 검증
                            if (balance < amount) {
                                ops.unwatch();
                                throw new RuntimeException("포인트 부족");
                            }

                            // ④ MULTI/EXEC
                            ops.multi();
                            ops.opsForValue().set(
                                key,
                                String.valueOf(balance - amount)
                            );
                            return ops.exec();
                            // null 반환 = 충돌 발생 → 재시도
                        }
                    }
                );

                if (result != null) {
                    log.info("포인트 차감 성공 - userId: {}, 시도: {}회",
                        userId, attempt);
                    return true;
                }

                log.warn("충돌 감지, 재시도 {}/{}", attempt, maxRetry);
                Thread.sleep(10L * attempt);

            } catch (RuntimeException e) {
                if (e.getMessage().equals("포인트 부족")) throw e;
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        throw new RuntimeException("최대 재시도 초과");
    }
}

4. 에러 처리 (DISCARD 자동 실행 확인)

@Service
@RequiredArgsConstructor
@Slf4j
public class ErrorHandlingService {

    private final StringRedisTemplate redisTemplate;

    public void multiExecWithError() {
        try {
            redisTemplate.execute(new SessionCallback<List<Object>>() {
                @Override
                public List<Object> execute(RedisOperations ops) {
                    ops.multi();

                    ops.opsForValue().set("k1", "v1");  // QUEUED
                    ops.opsForValue().set("k2", "v2");  // QUEUED

                    // 예외 발생!
                    if (true) throw new RuntimeException("비즈니스 오류!");

                    return ops.exec();  // 실행 안 됨
                }
                // SessionCallback이 자동으로:
                // catch → isQueueing() 확인 → DISCARD 호출
                // → 큐 비워짐
                // → Connection 정상 반납
            });

        } catch (RuntimeException e) {
            log.error("트랜잭션 실패, DISCARD 자동 실행됨: {}", e.getMessage());
        }

        // k1, k2 모두 저장 안 됨 확인
        log.info("k1: {}", redisTemplate.opsForValue().get("k1")); // null
        log.info("k2: {}", redisTemplate.opsForValue().get("k2")); // null
    }

    // 조건부 DISCARD (명시적)
    public void conditionalDiscard(Long userId, int amount) {
        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations ops) {
                ops.multi();
                ops.opsForValue().decrement("stock:" + userId, amount);
                ops.opsForValue().set("log:" + userId, "used:" + amount);

                // 조건 검사 후 명시적 DISCARD
                if (amount <= 0) {
                    ops.discard();  // 명시적 DISCARD
                    log.warn("잘못된 수량, DISCARD 실행");
                    return null;
                }

                return ops.exec();
            }
        });
    }
}

5. 런타임 에러 케이스 (부분 실패 확인)

@Service
@RequiredArgsConstructor
@Slf4j
public class PartialFailureService {

    private final StringRedisTemplate redisTemplate;

    public void partialExecutionExample() {

        // string 타입 키 미리 저장
        redisTemplate.opsForValue().set("strKey", "hello");

        List<Object> results = redisTemplate.execute(
            new SessionCallback<List<Object>>() {
                @Override
                public List<Object> execute(RedisOperations ops) {
                    ops.multi();

                    ops.opsForValue().set("k1", "v1");         // ① 정상
                    ops.opsForValue().increment("strKey");     // ② 런타임 에러
                                                               //   (string에 INCR)
                    ops.opsForValue().set("k2", "v2");         // ③ 정상

                    return ops.exec();
                }
            }
        );

        // 결과 확인
        for (int i = 0; i < results.size(); i++) {
            if (results.get(i) instanceof Exception) {
                log.error("명령 {} 실패: {}", i + 1, results.get(i));
            } else {
                log.info("명령 {} 성공: {}", i + 1, results.get(i));
            }
        }

        // 출력:
        // 명령 1 성공: true   ← SET k1 ✅
        // 명령 2 실패: ...    ← INCR ❌ (롤백 없음!)
        // 명령 3 성공: true   ← SET k2 ✅

        // k1, k2 모두 저장됨! (부분 실패)
    }
}

6. Pipeline + MULTI/EXEC (RTT 최적화)

@Service
@RequiredArgsConstructor
public class PipelineTransactionService {

    private final StringRedisTemplate redisTemplate;

    // Pipeline + MULTI/EXEC 동시 사용
    // RTT 감소 + 원자성 동시 확보
    public void pipelineWithTransaction(List<String> keys, String value) {

        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations ops) {
                ops.multi();                                    // MULTI
                keys.forEach(key ->
                    ops.opsForValue().set(key, value)          // QUEUED
                );
                ops.exec();                                    // EXEC
                return null;
                // 전체가 1번 RTT로 전송됨
            }
        });
    }
}

7. 테스트 코드

@SpringBootTest
class RedisTransactionTest {

    @Autowired StringRedisTemplate     redisTemplate;
    @Autowired RedisTransactionService transactionService;

    @BeforeEach
    void setUp() {
        redisTemplate.getConnectionFactory()
            .getConnection().flushAll();
    }

    @Test
    @DisplayName("MULTI/EXEC 원자적 실행 확인")
    void multiExecAtomicTest() {
        List<Object> results = transactionService.basicMultiExec();

        assertThat(results).hasSize(3);
        assertThat(redisTemplate.opsForValue().get("key1")).isEqualTo("value1");
        assertThat(redisTemplate.opsForValue().get("key2")).isEqualTo("value2");
    }

    @Test
    @DisplayName("예외 발생 시 DISCARD 자동 실행")
    void autoDiscardOnException() {
        try {
            redisTemplate.execute(new SessionCallback<Object>() {
                public Object execute(RedisOperations ops) {
                    ops.multi();
                    ops.opsForValue().set("k1", "v1");
                    throw new RuntimeException("오류!");
                }
            });
        } catch (RuntimeException ignored) {}

        // DISCARD로 k1 저장 안 됨
        assertThat(redisTemplate.opsForValue().get("k1")).isNull();
    }

    @Test
    @DisplayName("WATCH 충돌 시 null 반환")
    void watchConflictTest() throws InterruptedException {
        redisTemplate.opsForValue().set("balance", "1000");

        List<Object> result = redisTemplate.execute(
            new SessionCallback<List<Object>>() {
                public List<Object> execute(RedisOperations ops) {
                    ops.watch("balance");
                    ops.opsForValue().get("balance");

                    // 다른 클라이언트가 변경한 것처럼 시뮬레이션
                    redisTemplate.opsForValue().set("balance", "500");

                    ops.multi();
                    ops.opsForValue().set("balance", "800");
                    return ops.exec();
                }
            }
        );

        // WATCH 키 변경됨 → null 반환
        assertThat(result).isNull();
        // balance는 500 그대로
        assertThat(redisTemplate.opsForValue().get("balance"))
            .isEqualTo("500");
    }
}

최종 요약

┌─────────────────────────────────────────────────────────────┐
│              MULTI/EXEC Spring Boot 핵심 요약               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  반드시 SessionCallback 사용                                 │
│    → ThreadLocal로 같은 Connection 보장                     │
│    → 예외 시 자동 DISCARD                                   │
│                                                             │
│  결과 처리                                                   │
│    exec() = null  → WATCH 충돌 (재시도)                    │
│    exec() = List  → 각 명령 결과 (Exception 포함 가능)      │
│                                                             │
│  WATCH 사용 시                                               │
│    exec() 전에 반드시 watch() 호출                          │
│    null 반환 시 재시도 로직 구현                            │
│                                                             │
│  주의                                                        │
│    런타임 에러 → 부분 실행 (롤백 없음)                      │
│    조건부 로직 → Lua Script 사용 권장                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

파라미터로 넘길 수 있는 원리


핵심: Java 인터페이스 + 콜백 패턴

SessionCallback = 인터페이스

인터페이스는 파라미터 타입으로 사용 가능
→ 구현체(익명 클래스 또는 람다)를 파라미터로 전달

단계별 이해

1단계: SessionCallback은 인터페이스

// Spring 내부 정의
public interface SessionCallback<T> {
    T execute(RedisOperations operations);
}

2단계: RedisTemplate.execute()가 인터페이스를 파라미터로 받음

// RedisTemplate 내부
public class RedisTemplate {

    public <T> T execute(SessionCallback<T> callback) {
        //                ↑
        //         인터페이스 타입으로 받음

        RedisConnection conn = pool.getConnection();  // Connection 획득

        try {
            // 전달받은 구현체의 execute() 호출
            return callback.execute(ops);
            //     ↑
            //     여기서 사용자 코드 실행됨!

        } catch (Exception e) {
            conn.discard();
            throw e;
        } finally {
            pool.returnConnection(conn);
        }
    }
}

3단계: 사용자가 익명 클래스로 구현체 전달

redisTemplate.execute(

    // 인터페이스의 익명 구현체를 그 자리에서 생성
    new SessionCallback<List<Object>>() {
        @Override
        public List<Object> execute(RedisOperations ops) {
            ops.multi();
            ops.opsForValue().set("k1", "v1");
            return ops.exec();
        }
    }
    //   ↑
    //   이 객체 전체가 파라미터로 전달됨
);

같은 코드를 단계별로 분리해서 보면

// ① 익명 클래스를 변수에 담기
SessionCallback<List<Object>> callback =
    new SessionCallback<List<Object>>() {
        @Override
        public List<Object> execute(RedisOperations ops) {
            ops.multi();
            ops.opsForValue().set("k1", "v1");
            return ops.exec();
        }
    };

// ② 변수를 파라미터로 전달
redisTemplate.execute(callback);

// ① + ②를 합친 것이 익명 클래스 인라인 전달
redisTemplate.execute(new SessionCallback<List<Object>>() {
    @Override
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();
    }
});

람다로 표현하면 더 단순

// SessionCallback은 메서드가 1개 → @FunctionalInterface
// → 람다로 표현 가능

// 익명 클래스
redisTemplate.execute(new SessionCallback<List<Object>>() {
    @Override
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();
    }
});

// 람다 (동일한 코드)
redisTemplate.execute(ops -> {
    ops.multi();
    ops.opsForValue().set("k1", "v1");
    return ops.exec();
});

실생활 비유

식당 (RedisTemplate)이
요리사 (SessionCallback 구현체)를 고용하는 것

식당: "요리사 인터페이스를 구현한 사람 누구든 오세요"
      execute(요리사 callback) {
          재료 준비 (Connection 획득)
          callback.요리하기()  ← 요리사가 직접 요리
          설거지 (Connection 반납)
      }

사용자: 요리사 자격증(인터페이스)을 가진
        익명 요리사를 그 자리에서 만들어서 전달
        new 요리사() {
            요리하기() {
                파스타 만들기...
            }
        }

이 패턴의 이름: 콜백 패턴 (Template Method)

사용자가 정의하는 것:
  execute() 안의 비즈니스 로직
  (MULTI, SET, EXEC 등)

Spring이 처리하는 것:
  Connection 획득
  ThreadLocal 바인딩
  예외 시 DISCARD
  Connection 반납

→ "틀(Template)은 Spring이 제공"
   "내용(Callback)은 사용자가 채움"

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  원리:                                                       │
│    SessionCallback = 인터페이스                             │
│    인터페이스 타입은 파라미터로 전달 가능                    │
│    구현체(익명 클래스/람다)를 그 자리에서 생성해서 전달     │
│                                                             │
│  흐름:                                                       │
│    사용자 → 구현체 전달                                     │
│    Spring → Connection 관리 + 사용자 코드 실행              │
│                                                             │
│  Java 개념:                                                  │
│    인터페이스 + 익명 클래스 + 콜백 패턴                     │
│    Java 8+ → @FunctionalInterface + 람다로 단순화           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


SessionCallback = 함수형 인터페이스

// Spring 실제 소스
@FunctionalInterface  // ← 명시적으로 선언됨
public interface SessionCallback<T> {
    T execute(RedisOperations operations);
    // ↑ 추상 메서드가 딱 1개
}

함수형 인터페이스 조건

추상 메서드가 정확히 1개
    │
    ▼
@FunctionalInterface 사용 가능
    │
    ▼
람다로 표현 가능

표현 방식 3가지 (모두 동일)

// ① 익명 클래스
redisTemplate.execute(new SessionCallback<List<Object>>() {
    @Override
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();
    }
});


// ② 람다
redisTemplate.execute(ops -> {
    ops.multi();
    ops.opsForValue().set("k1", "v1");
    return ops.exec();
});


// ③ 변수에 담아서 전달
SessionCallback<List<Object>> callback = ops -> {
    ops.multi();
    ops.opsForValue().set("k1", "v1");
    return ops.exec();
};
redisTemplate.execute(callback);

Java 대표 함수형 인터페이스와 비교

┌─────────────────────────┬──────────────────────────────────┐
│  인터페이스             │  추상 메서드                      │
├─────────────────────────┼──────────────────────────────────┤
│  Runnable               │  void run()                      │
│  Callable<T>            │  T call()                        │
│  Function<T,R>          │  R apply(T t)                    │
│  Predicate<T>           │  boolean test(T t)               │
│  Consumer<T>            │  void accept(T t)                │
│  Supplier<T>            │  T get()                         │
│  SessionCallback<T>     │  T execute(RedisOperations ops)  │
│  RedisCallback<T>       │  T doInRedis(RedisConnection conn)│
└─────────────────────────┴──────────────────────────────────┘
모두 추상 메서드 1개 → 람다 사용 가능

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  SessionCallback = 함수형 인터페이스 ✅                     │
│                                                             │
│  @FunctionalInterface 명시 → 람다 사용 가능                 │
│                                                             │
│  Spring이 이 패턴을 쓰는 이유:                              │
│    "Connection 관리는 Spring이"                             │
│    "비즈니스 로직은 사용자가"                               │
│    → 함수형 인터페이스로 코드 블록을 파라미터로 전달        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

제네릭(Generic) 때문입니다


SessionCallback 정의

public interface SessionCallback<T> {
    T execute(RedisOperations operations);
    //↑ T = 반환 타입을 나중에 결정
}

T가 무엇인지 알려주는 것

// T = List<Object> 라고 명시
new SessionCallback<List<Object>>() {
    @Override
    public List<Object> execute(RedisOperations ops) {  // T = List<Object>
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();  // List<Object> 반환
    }
}


// T = String 이라고 명시
new SessionCallback<String>() {
    @Override
    public String execute(RedisOperations ops) {  // T = String
        return (String) ops.opsForValue().get("key");
    }
}


// T = Long 이라고 명시
new SessionCallback<Long>() {
    @Override
    public Long execute(RedisOperations ops) {  // T = Long
        return ops.opsForValue().increment("counter");
    }
}

굳이 명시 안 하면?

// ❌ raw type 사용 (T 명시 안 함)
new SessionCallback() {
    @Override
    public Object execute(RedisOperations ops) {  // T = Object
        ops.multi();
        ops.opsForValue().set("k1", "v1");
        return ops.exec();
        //         ↑ List<Object>인데 Object로만 알 수 있음
    }
}

// 결과를 사용할 때
Object result = redisTemplate.execute(callback);
List<Object> list = (List<Object>) result;  // 강제 캐스팅 필요!
                                            // ClassCastException 위험

타입 명시의 이점

// ✅ 제네릭 명시
SessionCallback<List<Object>> callback =
    new SessionCallback<List<Object>>() {
        public List<Object> execute(RedisOperations ops) {
            ops.multi();
            ops.opsForValue().set("k1", "v1");
            return ops.exec();
        }
    };

List<Object> result = redisTemplate.execute(callback);
//  ↑
//  캐스팅 불필요! 컴파일 타임에 타입 안전 보장

정리

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  SessionCallback<T> 에서 T =  "반환 타입"                   │
│                                                             │
│  <List<Object>> 명시 이유:                                  │
│    execute()가 List<Object>를 반환한다고 알려주는 것        │
│    컴파일 타임에 타입 체크                                   │
│    캐스팅 없이 안전하게 사용 가능                            │
│                                                             │
│  명시 안 하면:                                               │
│    반환 타입 = Object (뭔지 모름)                           │
│    강제 캐스팅 필요 → 런타임 에러 위험                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확합니다


핵심 관계

함수형 인터페이스 = 추상 메서드가 1개인 인터페이스
람다             = 함수형 인터페이스의 구현체

동일한 코드

// 함수형 인터페이스
@FunctionalInterface
public interface SessionCallback<T> {
    T execute(RedisOperations ops);
}


// ① 익명 클래스로 구현
SessionCallback<String> callback =
    new SessionCallback<String>() {
        @Override
        public String execute(RedisOperations ops) {
            return (String) ops.opsForValue().get("key");
        }
    };


// ② 람다로 구현 (완전히 동일)
SessionCallback<String> callback =
    ops -> (String) ops.opsForValue().get("key");

컴파일러가 하는 일

람다를 보면:

ops -> (String) ops.opsForValue().get("key")
 ↑                        ↑
파라미터              반환값

컴파일러:
  "SessionCallback<String>의 execute(RedisOperations ops) 구나"
  → 자동으로 익명 클래스로 변환

Java 대표 예시

// Runnable
Runnable r = new Runnable() {
    public void run() { System.out.println("실행"); }
};
Runnable r = () -> System.out.println("실행");  // 람다


// Comparator
Comparator<String> c = new Comparator<>() {
    public int compare(String a, String b) { return a.compareTo(b); }
};
Comparator<String> c = (a, b) -> a.compareTo(b);  // 람다


// SessionCallback
SessionCallback<List<Object>> cb = new SessionCallback<>() {
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        return ops.exec();
    }
};
SessionCallback<List<Object>> cb = ops -> {   // 람다
    ops.multi();
    return ops.exec();
};

결론

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  함수형 인터페이스 ──구현체──→ 익명 클래스                  │
│                   ──구현체──→ 람다 (익명 클래스의 축약)     │
│                                                             │
│  추상 메서드 1개 → 람다로 표현 가능                         │
│  추상 메서드 2개+ → 람다 불가, 익명 클래스만 가능           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redis Lua Script / Multi & Exec / Pipeline 코드


1. Lua Script

@Service
@RequiredArgsConstructor
public class LuaScriptService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // 예시 1: 재고 차감 (조건부 원자적 업데이트)
    // ============================================
    private static final String DECREASE_STOCK_SCRIPT = """
        local stock = tonumber(redis.call('GET', KEYS[1]))

        if not stock then
            return -1   -- 키 없음
        end
        if stock < tonumber(ARGV[1]) then
            return 0    -- 재고 부족
        end

        redis.call('DECRBY', KEYS[1], ARGV[1])
        redis.call('LPUSH',  KEYS[2], ARGV[2])

        return stock - tonumber(ARGV[1])  -- 남은 재고
        """;

    public Long decreaseStock(Long productId, int quantity, Long orderId) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(DECREASE_STOCK_SCRIPT, Long.class),
            List.of(
                "stock:" + productId,        // KEYS[1]
                "stock:history:" + productId // KEYS[2]
            ),
            String.valueOf(quantity),        // ARGV[1]
            String.valueOf(orderId)          // ARGV[2]
        );

        return switch (result.intValue()) {
            case -1 -> throw new RuntimeException("키 없음");
            case  0 -> throw new RuntimeException("재고 부족");
            default -> result;
        };
    }


    // ============================================
    // 예시 2: 분산락 해제 (토큰 비교 + DEL 원자적)
    // ============================================
    private static final String RELEASE_LOCK_SCRIPT = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
        """;

    public boolean acquireLock(String key, String token, long ttlMs) {
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(key, token, Duration.ofMillis(ttlMs));
        return Boolean.TRUE.equals(result);
    }

    public boolean releaseLock(String key, String token) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class),
            List.of(key),
            token
        );
        return Long.valueOf(1L).equals(result);
    }


    // ============================================
    // 예시 3: Rate Limiting (슬라이딩 윈도우)
    // ============================================
    private static final String RATE_LIMIT_SCRIPT = """
        local key       = KEYS[1]
        local now       = tonumber(ARGV[1])
        local window    = tonumber(ARGV[2])
        local max_count = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

        local count = redis.call('ZCARD', key)
        if count >= max_count then
            return 0  -- 한도 초과
        end

        redis.call('ZADD',    key, now, now)
        redis.call('PEXPIRE', key, window)

        return max_count - count - 1  -- 남은 횟수
        """;

    public boolean checkRateLimit(String userId, int maxCount, long windowMs) {
        long now = System.currentTimeMillis();

        Long remaining = redisTemplate.execute(
            new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class),
            List.of("rate_limit:" + userId),
            String.valueOf(now),
            String.valueOf(windowMs),
            String.valueOf(maxCount)
        );

        return !Long.valueOf(0L).equals(remaining);
    }
}

2. MULTI / EXEC

@Service
@RequiredArgsConstructor
public class MultiExecService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // 예시 1: 기본 MULTI/EXEC (캐시 일괄 삭제)
    // ============================================
    public void clearUserCache(Long userId) {
        List<String> keys = List.of(
            "user:" + userId + ":profile",
            "user:" + userId + ":session",
            "user:" + userId + ":token"
        );

        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {
                ops.multi();
                keys.forEach(ops::delete);  // 전부 QUEUED
                return ops.exec();
                // EXEC 순간 연속 실행 (끼어들기 불가)
            }
        });
    }


    // ============================================
    // 예시 2: WATCH + MULTI/EXEC (낙관적 락)
    // ============================================
    public boolean deductPoint(Long userId, int amount) {
        String key     = "point:" + userId;
        int    maxRetry = 5;

        for (int attempt = 1; attempt <= maxRetry; attempt++) {
            try {
                List<Object> result = redisTemplate.execute(
                    new SessionCallback<List<Object>>() {
                        @Override
                        public List<Object> execute(RedisOperations ops) {

                            // ① WATCH 설정
                            ops.watch(key);

                            // ② 현재 값 읽기
                            int balance = Integer.parseInt(
                                (String) ops.opsForValue().get(key)
                            );

                            // ③ 검증
                            if (balance < amount) {
                                ops.unwatch();
                                throw new RuntimeException("포인트 부족");
                            }

                            // ④ MULTI/EXEC
                            ops.multi();
                            ops.opsForValue().set(
                                key,
                                String.valueOf(balance - amount)
                            );
                            return ops.exec();
                            // null = 충돌 → 재시도
                        }
                    }
                );

                if (result != null) return true;  // 성공

                Thread.sleep(10L * attempt);  // 충돌 시 대기 후 재시도

            } catch (RuntimeException e) {
                if (e.getMessage().equals("포인트 부족")) throw e;
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        throw new RuntimeException("최대 재시도 초과");
    }


    // ============================================
    // 예시 3: 예외 발생 시 DISCARD 자동 실행
    // ============================================
    public void withAutoDiscard() {
        try {
            redisTemplate.execute(new SessionCallback<Object>() {
                @Override
                public Object execute(RedisOperations ops) {
                    ops.multi();
                    ops.opsForValue().set("k1", "v1");  // QUEUED
                    ops.opsForValue().set("k2", "v2");  // QUEUED

                    throw new RuntimeException("예외 발생!");
                    // SessionCallback이 자동으로 DISCARD 호출
                    // k1, k2 저장 안 됨
                }
            });
        } catch (RuntimeException e) {
            System.out.println("DISCARD 자동 실행됨");
        }
    }
}

3. Pipeline

@Service
@RequiredArgsConstructor
public class PipelineService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // 예시 1: 대량 저장 (캐시 워밍업)
    // ============================================
    public void bulkSave(List<Product> products) {
        int BATCH_SIZE = 500;

        for (int i = 0; i < products.size(); i += BATCH_SIZE) {
            List<Product> batch = products.subList(
                i, Math.min(i + BATCH_SIZE, products.size())
            );

            redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
                batch.forEach(product -> {
                    byte[] key = ("product:" + product.getId()).getBytes();
                    byte[] val = serialize(product).getBytes();
                    conn.setEx(key, 1800, val);  // TTL 30분
                });
                return null;
            });
        }
    }


    // ============================================
    // 예시 2: 대량 조회
    // ============================================
    public Map<Long, String> bulkGet(List<Long> productIds) {
        List<Object> results = redisTemplate.executePipelined(
            (RedisCallback<Object>) conn -> {
                productIds.forEach(id ->
                    conn.get(("product:" + id).getBytes())
                );
                return null;
            }
        );

        // 결과 매핑
        Map<Long, String> resultMap = new HashMap<>();
        for (int i = 0; i < productIds.size(); i++) {
            if (results.get(i) != null) {
                resultMap.put(productIds.get(i), (String) results.get(i));
            }
        }
        return resultMap;
    }


    // ============================================
    // 예시 3: 응답 순서 활용
    // ============================================
    public void pipelineWithResults() {
        List<Object> results = redisTemplate.executePipelined(
            (RedisCallback<Object>) conn -> {
                conn.set("name".getBytes(), "Alice".getBytes()); // 명령 0
                conn.incr("visit".getBytes());                   // 명령 1
                conn.get("name".getBytes());                     // 명령 2
                return null;
            }
        );

        // 명령 순서 = 응답 순서
        Boolean setResult  = (Boolean) results.get(0);  // true
        Long    incrResult = (Long)    results.get(1);  // 1
        String  getResult  = (String)  results.get(2);  // "Alice"
    }


    // ============================================
    // 예시 4: 부분 실패 처리
    // ============================================
    public void handlePartialFailure(List<String> keys) {
        List<Object> results = redisTemplate.executePipelined(
            (RedisCallback<Object>) conn -> {
                keys.forEach(key -> conn.get(key.getBytes()));
                return null;
            }
        );

        for (int i = 0; i < results.size(); i++) {
            if (results.get(i) instanceof Exception) {
                // 해당 명령만 실패, 나머지는 정상
                System.out.println(keys.get(i) + " 조회 실패");
            }
        }
    }
}

세 가지 비교 한눈에

┌──────────────────┬───────────────────┬──────────────────┬─────────────────┐
│                  │    Lua Script     │   Multi/Exec     │    Pipeline     │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 사용 방법        │ execute()         │ SessionCallback  │ executePipelined│
│                  │ DefaultRedisScript│                  │ RedisCallback   │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 원자성           │ ✅ 강함           │ ✅ 제한적        │ ❌              │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 끼어들기         │ ❌ 전혀 불가      │ EXEC 순간 불가   │ ✅ 가능         │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 조건 분기        │ ✅               │ ❌               │ ❌              │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ RTT              │ 1번              │ N번 (Pipeline 병행│ 1번             │
│                  │                   │ 시 1번)          │                 │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 주 용도          │ 조건부 원자 로직  │ 명령 묶음 실행   │ 대량 처리       │
└──────────────────┴───────────────────┴──────────────────┴─────────────────┘