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번) │ │
├──────────────────┼───────────────────┼──────────────────┼─────────────────┤
│ 주 용도 │ 조건부 원자 로직 │ 명령 묶음 실행 │ 대량 처리 │
└──────────────────┴───────────────────┴──────────────────┴─────────────────┘