Redis 운영 심화 — 클러스터, Sentinel, 메모리 튜닝, Lua 스크립트

Q1. Redis 서버가 죽으면 서비스도 죽는다. 어떻게 HA를 구성하나?

새벽 3시, Redis 단일 인스턴스가 죽었다. 캐시를 쓰는 모든 API가 DB를 직격하고 DB가 버티지 못했다. 세션은 모두 만료됐다. 유저들은 강제 로그아웃됐다. 당신은 잠에서 깨어 서버를 살려야 한다. Redis 한 대가 단일 장애점(SPOF)이다.

Redis를 어떤 용도로 쓰느냐에 따라 장애의 성격이 다르다.

캐시 저장소로만 쓰는 경우: DB를 직격하면서 응답이 느려진다. DB가 버티면 서비스는 살아있다. 캐시가 채워질 때까지 느린 것을 감수해야 한다.

세션 저장소로 쓰는 경우: Redis가 죽는 순간 모든 세션 데이터가 사라진다. 로그인한 유저 전원이 강제 로그아웃된다. 재로그인을 해도 세션이 또 사라질 수 있다.

메인 데이터 저장소로 쓰는 경우: 서비스 자체가 중단된다. DB처럼 쓰고 있었다면 데이터 복구도 해야 한다.

단일 Redis 인스턴스는 운영 환경에서 쓸 수 없다. HA 구성이 필요하다.

Master-Replica 복제: HA의 기반

모든 Redis HA 구성의 기반은 Master-Replica 복제다. Master가 받은 쓰기 명령을 Replica에 실시간으로 복제한다.

# Replica 설정 (redis.conf 또는 런타임)
REPLICAOF 10.0.1.10 6379

# 복제 상태 확인
redis-cli -h 10.0.1.10 INFO replication

복제만으로는 HA가 아니다. Master가 죽었을 때 Replica를 자동으로 Master로 승격시키는 메커니즘이 필요하다.

HA 구성 두 가지

Sentinel: 감시자(Sentinel) 프로세스가 Master 장애를 감지하고 자동으로 Failover를 수행한다. 수평 확장은 안 된다. 데이터는 여전히 Master 한 대에 전부 저장된다.

Cluster: 데이터를 여러 샤드(Master)에 분산 저장한다. 수평 확장과 Failover를 동시에 제공한다. Sentinel보다 복잡하다.

SentinelCluster
목적가용성가용성 + 수평 확장
데이터 분산단일 샤드다중 샤드
멀티키 커맨드제한 없음제한 있음
최소 노드 수1 Master + 1 Replica + 3 Sentinel3 Master + 3 Replica
복잡도중간높음

Q2. Sentinel이 Failover를 자동으로 한다는데 어떻게 감지하나?

Master가 죽었다. Sentinel이 없으면 어떻게 해야 하나.

새벽 3시에 모니터링 알림을 받는다. SSH로 서버에 접속한다. Replica 중 하나를 Master로 승격시켜야 한다.

# 1. 살아있는 Replica에 접속
redis-cli -h 10.0.1.11

# 2. Replica를 독립 Master로 승격
127.0.0.1:6379> REPLICAOF NO ONE

# 3. 애플리케이션 설정에서 Redis 호스트 변경
# application.yml 수정: spring.data.redis.host: 10.0.1.11

# 4. 애플리케이션 재배포
./gradlew build && docker push ... && kubectl rollout restart ...

30분에서 1시간이 걸린다. 그동안 서비스는 내려가 있다.

Sentinel은 이 과정을 자동화한다. 사람 없이 10~30초 안에 완료된다.

Sentinel의 동작 단계

1단계: 헬스 체크

Sentinel 프로세스는 1초마다 Master에 PING을 보낸다. Master가 응답하지 않으면 카운트를 시작한다.

# sentinel.conf
sentinel monitor mymaster 10.0.1.10 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

down-after-milliseconds 5000: 5000ms(5초) 동안 응답이 없으면 SDOWN으로 판정한다.

2단계: SDOWN (Subjectively Down)

하나의 Sentinel이 Master를 SDOWN으로 판정했다. 그러나 이 Sentinel 자체의 네트워크 문제일 수 있다. 혼자 결정하면 안 된다.

3단계: ODOWN (Objectively Down)

Sentinel들끼리 서로 물어본다. “너도 Master가 죽었다고 보이나?” quorum 이상이 동의하면 ODOWN으로 확정한다.

sentinel monitor mymaster 10.0.1.10 6379 2
# 마지막 숫자 2가 quorum: 2개 이상 Sentinel이 동의해야 ODOWN

4단계: Sentinel 리더 선출

ODOWN이 확정되면 어떤 Sentinel이 Failover를 담당할지 결정한다. Raft 알고리즘으로 리더를 선출한다. 리더 하나만 Failover를 수행해서 중복 동작을 막는다.

5단계: 새 Master 선출

리더 Sentinel이 살아있는 Replica 중 하나를 선택한다. 선택 기준은 다음 순서다.

  1. 우선순위가 낮은 Replica (replica-priority 설정값, 낮을수록 우선)
  2. Master와 복제 격차가 적은 Replica (가장 최신 데이터를 갖는 쪽)
  3. Replica ID가 사전순으로 앞선 쪽

6단계: Failover 실행

선택된 Replica에 REPLICAOF NO ONE을 보내 Master로 승격시킨다. 나머지 Replica에는 새 Master를 복제 소스로 가리키도록 REPLICAOF <new-master> 6379를 보낸다.

7단계: 클라이언트 통보

Sentinel은 클라이언트가 새 Master 주소를 조회할 수 있는 인터페이스를 제공한다.

redis-cli -h 10.0.1.20 -p 26379 SENTINEL get-master-addr-by-name mymaster
# 출력: 10.0.1.11 6379 (새 Master 주소)

Lettuce의 자동 재연결

Lettuce(Spring Data Redis의 기본 클라이언트)는 Sentinel과 통합되어 자동으로 새 Master 주소를 조회한다. 애플리케이션 재배포 없이 새 Master로 전환된다.

# application.yml
spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes:
          - 10.0.1.20:26379
          - 10.0.1.21:26379
          - 10.0.1.22:26379
      password: your-redis-password
@Configuration
public class RedisConfig {

    @Bean
    public LettuceConnectionFactory redisConnectionFactory(RedisProperties properties) {
        RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration();
        sentinelConfig.master(properties.getSentinel().getMaster());

        for (String node : properties.getSentinel().getNodes()) {
            String[] parts = node.split(":");
            sentinelConfig.sentinel(parts[0], Integer.parseInt(parts[1]));
        }

        if (properties.getPassword() != null) {
            sentinelConfig.setPassword(RedisPassword.of(properties.getPassword()));
        }

        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofSeconds(2))
            .shutdownTimeout(Duration.ZERO)
            .build();

        return new LettuceConnectionFactory(sentinelConfig, clientConfig);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

Failover 동안의 쓰기 실패 처리

Failover 완료까지 10~30초가 걸린다. 이 동안 쓰기 커맨드는 실패한다. 읽기는 Replica에서 가능하지만 쓰기는 불가능하다.

@Service
@RequiredArgsConstructor
@Slf4j
public class SessionService {

    private final RedisTemplate<String, Object> redisTemplate;

    @Retryable(
        retryFor = {RedisConnectionFailureException.class, QueryTimeoutException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public void saveSession(String sessionId, Object sessionData) {
        redisTemplate.opsForValue().set(
            "session:" + sessionId,
            sessionData,
            Duration.ofHours(2)
        );
    }

    @Recover
    public void recoverSession(RedisConnectionFailureException e, String sessionId, Object sessionData) {
        log.error("Redis 쓰기 실패. 세션 저장 불가. sessionId={}", sessionId, e);
        // 폴백: DB에 임시 저장하거나 에러 응답 반환
    }
}

Resilience4j를 쓴다면:

@Configuration
public class ResilienceConfig {

    @Bean
    public Retry redisRetry() {
        RetryConfig config = RetryConfig.custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofMillis(500))
            .retryExceptions(
                RedisConnectionFailureException.class,
                QueryTimeoutException.class
            )
            .build();
        return Retry.of("redis", config);
    }
}

Sentinel이 적합한 규모 기준

  • 데이터가 단일 서버 메모리에 들어갈 것 (예: 32GB 이하)
  • 초당 수만 ops 이하 수준
  • 멀티키 커맨드(MGET, MSET, Lua)를 자유롭게 쓰고 싶은 경우

이 범위를 넘으면 Cluster로 가야 한다.


Q3. Cluster는 Sentinel과 뭐가 다른가?

데이터가 많아져서 단일 서버 메모리 32GB로는 부족해졌다. 어떻게 하나.

Sentinel을 써도 데이터는 Master 한 대에 전부 저장된다. Master 메모리가 32GB라면 저장할 수 있는 데이터는 32GB가 한계다. 메모리를 64GB짜리 서버로 바꿀 수 있지만, 그것도 한계가 있다. 결국 스케일 업(Scale-up)이 한계에 부딪힌다.

스케일 아웃(Scale-out)이 필요하다. 여러 Master에 데이터를 나눠 저장하면 된다.

Redis Cluster: 16384 슬롯 분산

Redis Cluster는 16384개의 슬롯(Slot)으로 데이터를 관리한다. 모든 키는 슬롯 번호를 갖는다.

슬롯 번호 = CRC16(key) % 16384

슬롯들을 여러 Master에 나눠 할당한다. 3개 샤드 구성이라면:

Master A: 슬롯    0 ~ 5460
Master B: 슬롯 5461 ~ 10922
Master C: 슬롯 10923 ~ 16383

키를 읽거나 쓸 때 Redis가 슬롯 번호를 계산하고 해당 슬롯을 가진 Master로 요청을 보낸다.

Redis Cluster 구조 (3 샤드 x 2 복제)

  Master A (슬롯 0~5460)         Replica A
  10.0.1.10:6379          <---   10.0.1.13:6379

  Master B (슬롯 5461~10922)     Replica B
  10.0.1.11:6379          <---   10.0.1.14:6379

  Master C (슬롯 10923~16383)    Replica C
  10.0.1.12:6379          <---   10.0.1.15:6379

총 저장 용량이 Master A + B + C의 메모리 합이 된다. 서버를 추가하면 수평으로 확장된다.

각 샤드가 독립적으로 Failover

Sentinel 프로세스가 없어도 된다. 각 Master가 죽으면 해당 샤드의 Replica가 자동으로 Master로 승격된다.

노드들 사이에 Gossip 프로토콜로 서로 상태를 감시한다. 모든 노드가 다른 모든 노드의 상태를 안다. 특정 노드에 문제가 생기면 과반수 Master가 동의할 때 해당 노드를 장애로 확정하고 Failover를 수행한다.

# Cluster 노드 상태 확인
redis-cli -h 10.0.1.10 -p 6379 CLUSTER NODES

# 출력 예시
a1b2c3d4 10.0.1.10:6379@16379 myself,master - 0 1719600000000 1 connected 0-5460
d4e5f6g7 10.0.1.11:6379@16379 master - 0 1719600000001 2 connected 5461-10922
g7h8i9j0 10.0.1.12:6379@16379 master - 0 1719600000002 3 connected 10923-16383
j1k2l3m4 10.0.1.13:6379@16379 slave a1b2c3d4 0 1719600000003 1 connected
m4n5o6p7 10.0.1.14:6379@16379 slave d4e5f6g7 0 1719600000004 2 connected
p7q8r9s0 10.0.1.15:6379@16379 slave g7h8i9j0 0 1719600000005 3 connected

Spring Boot 설정

# application.yml
spring:
  data:
    redis:
      cluster:
        nodes:
          - 10.0.1.10:6379
          - 10.0.1.11:6379
          - 10.0.1.12:6379
          - 10.0.1.13:6379
          - 10.0.1.14:6379
          - 10.0.1.15:6379
        max-redirects: 3
      password: your-redis-password
@Configuration
public class RedisClusterConfig {

    @Bean
    public LettuceConnectionFactory redisConnectionFactory(RedisProperties properties) {
        RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(
            properties.getCluster().getNodes()
        );
        clusterConfig.setMaxRedirects(properties.getCluster().getMaxRedirects());

        if (properties.getPassword() != null) {
            clusterConfig.setPassword(RedisPassword.of(properties.getPassword()));
        }

        // 클러스터 토폴로지 자동 갱신: Failover 후 새 Master를 자동으로 감지
        ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
            .enablePeriodicRefresh(Duration.ofMinutes(1))
            .enableAllAdaptiveRefreshTriggers()
            .build();

        ClusterClientOptions clientOptions = ClusterClientOptions.builder()
            .topologyRefreshOptions(topologyRefreshOptions)
            .build();

        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
            .clientOptions(clientOptions)
            .commandTimeout(Duration.ofSeconds(2))
            .build();

        return new LettuceConnectionFactory(clusterConfig, clientConfig);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

Cluster가 필요한 시점

  • 데이터가 단일 서버 메모리를 초과할 때
  • 초당 10만 ops 이상이 필요할 때
  • 특정 시간대에 쓰기 부하가 집중되어 단일 Master로 감당 안 될 때
  • 데이터를 샤드별로 격리해야 하는 경우 (멀티테넌트)

Cluster의 핵심 제약

멀티키 커맨드에 제한이 생긴다. 이것이 가장 큰 운영 부담이다. 바로 다음 문제다.


Q4. Cluster에서 MGET key1 key2가 에러나는 이유가 뭔가?

Cluster로 전환했더니 MGET이 갑자기 에러가 난다.

CROSSSLOT Keys in request don't hash to the same slot

멀쩡하게 쓰던 코드인데 에러가 난다. 무슨 일인가.

에러의 원인

MGET user:1 user:2 user:3을 실행하면 Redis는 각 키의 슬롯을 계산한다.

redis-cli CLUSTER KEYSLOT user:1    # 출력: 8106
redis-cli CLUSTER KEYSLOT user:2    # 출력: 5649
redis-cli CLUSTER KEYSLOT user:3    # 출력: 1686

슬롯이 다르면 키들이 서로 다른 노드에 저장된다. MGET은 단일 명령으로 단일 노드에만 요청을 보낼 수 있다. 여러 노드에 걸친 단일 명령은 지원하지 않는다.

# 직접 재현
redis-cli -h 10.0.1.10 -p 6379 MGET user:1 user:2
# (error) CROSSSLOT Keys in request don't hash to the same slot

해결 1: 파이프라인으로 각자 조회

각 키를 개별 GET으로 조회하되, 파이프라인으로 묶어 RTT를 최소화한다. Lettuce는 각 키를 자동으로 올바른 노드로 라우팅한다.

@Service
@RequiredArgsConstructor
public class UserCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public Map<String, Object> getUsers(List<String> userIds) {
        // 파이프라인으로 병렬 조회
        // Lettuce가 각 키를 해당 슬롯의 노드로 자동 라우팅
        List<Object> results = redisTemplate.executePipelined(
            (RedisCallback<Object>) connection -> {
                for (String userId : userIds) {
                    connection.stringCommands().get(
                        ("user:" + userId).getBytes(StandardCharsets.UTF_8)
                    );
                }
                return null;
            }
        );

        Map<String, Object> userMap = new HashMap<>();
        for (int i = 0; i < userIds.size(); i++) {
            if (results.get(i) != null) {
                userMap.put(userIds.get(i), results.get(i));
            }
        }
        return userMap;
    }
}

파이프라인을 쓰면 Cluster에서도 동작한다. 다만 키가 여러 노드에 분산되어 있으면 Lettuce가 노드별로 묶어 각각 요청을 보낸다. 네트워크 왕복은 줄지만 여전히 여러 노드에 요청이 간다.

해결 2: HashTag로 같은 슬롯 강제

키에 {...} 형태의 HashTag를 붙이면 중괄호 안의 내용만 슬롯 계산에 사용된다.

{user:1}:session  →  CRC16("user:1") % 16384  =  8106
{user:1}:cart     →  CRC16("user:1") % 16384  =  8106
{user:1}:profile  →  CRC16("user:1") % 16384  =  8106

세 키 모두 같은 슬롯에 저장된다. MGET이 가능해진다.

redis-cli CLUSTER KEYSLOT "{user:1}:session"    # 8106
redis-cli CLUSTER KEYSLOT "{user:1}:cart"       # 8106 (같음)
redis-cli CLUSTER KEYSLOT "{user:1}:profile"    # 8106 (같음)

# 이제 MGET 가능
redis-cli -h 10.0.1.10 MGET "{user:1}:session" "{user:1}:cart" "{user:1}:profile"
@Service
@RequiredArgsConstructor
public class UserDataService {

    private final RedisTemplate<String, Object> redisTemplate;

    private static final String SESSION_KEY  = "{user:%s}:session";
    private static final String CART_KEY     = "{user:%s}:cart";
    private static final String PROFILE_KEY  = "{user:%s}:profile";

    public UserData getUserData(String userId) {
        String sessionKey = String.format(SESSION_KEY, userId);
        String cartKey    = String.format(CART_KEY, userId);
        String profileKey = String.format(PROFILE_KEY, userId);

        // 같은 슬롯이므로 MGET 가능
        List<Object> values = redisTemplate.opsForValue().multiGet(
            List.of(sessionKey, cartKey, profileKey)
        );

        return UserData.builder()
            .session((SessionData) values.get(0))
            .cart((CartData) values.get(1))
            .profile((ProfileData) values.get(2))
            .build();
    }

    public void saveUserData(String userId, SessionData session, CartData cart, ProfileData profile) {
        String sessionKey = String.format(SESSION_KEY, userId);
        String cartKey    = String.format(CART_KEY, userId);
        String profileKey = String.format(PROFILE_KEY, userId);

        // 같은 슬롯이므로 MSET 가능
        Map<String, Object> entries = new LinkedHashMap<>();
        entries.put(sessionKey, session);
        entries.put(cartKey, cart);
        entries.put(profileKey, profile);

        redisTemplate.opsForValue().multiSet(entries);
    }
}

HashTag의 원리

슬롯 계산 시 키에 {}가 있으면 첫 번째 {와 첫 번째 } 사이의 문자열만 CRC16 계산에 사용된다.

키:  {user:1}:session
슬롯 계산에 쓰이는 부분:  "user:1"
슬롯 계산에 무시되는 부분: ":session"

중괄호 밖의 내용(:session, :cart)은 슬롯 계산에 포함되지 않는다. 사람이 읽을 수 있는 이름을 자유롭게 붙일 수 있다.


Q5. HashTag {userId}:session이 왜 필요한가?

유저의 세션, 장바구니, 위시리스트를 한 번에 가져오려고 MGET을 쓰는데 Cluster에서는 안 된다. 파이프라인으로 풀 수도 있지만, 세 가지 데이터를 원자적으로 함께 읽어야 한다면 어떻게 하나.

세션이 있는데 장바구니가 없는 상태로 읽히면 데이터 불일치가 생길 수 있다. Lua 스크립트로 원자적 처리를 하려 해도 Cluster에서 Lua는 여러 슬롯의 키에 접근할 수 없다.

HashTag가 바로 이 문제를 해결한다.

HashTag가 필요한 이유

같은 사용자의 여러 데이터를 같은 슬롯에 배치하면:

  1. MGET / MSET으로 여러 키를 단일 커맨드에서 처리 가능
  2. Lua 스크립트에서 여러 키를 원자적으로 읽고 쓸 수 있음
  3. 관련 데이터를 한 노드에서 처리하므로 RTT 최소화
// 예: 주문 처리 시 세션 + 장바구니를 원자적으로 읽어야 하는 경우
// HashTag 덕분에 같은 슬롯 → Lua 스크립트에서 원자적 처리 가능
String luaScript =
    "local session = redis.call('GET', KEYS[1])\n" +
    "local cart = redis.call('GET', KEYS[2])\n" +
    "if session == false then\n" +
    "    return {-1}\n" +
    "end\n" +
    "if cart == false then\n" +
    "    return {-2}\n" +
    "end\n" +
    "return {1, session, cart}";

// KEYS[1] = "{user:123}:session", KEYS[2] = "{user:123}:cart"
// 두 키 모두 같은 슬롯이므로 Cluster에서도 동작

HashTag의 함정: Hotspot

HashTag가 편중되면 특정 슬롯에 데이터가 몰린다.

최악의 경우: 모든 키에 같은 HashTag를 붙인다.

{order}:1,  {order}:2,  {order}:3,  ...,  {order}:10000000

모든 키가 슬롯 하나에 저장된다. 데이터를 6개 Master에 분산했는데 실제로는 Master 하나만 쓴다. Cluster를 구성한 의미가 없다. 나머지 5개 Master는 유휴 상태이고, 1개 Master만 과부하 상태가 된다.

실용적 기준: 함께 읽어야 하는 데이터 묶음 단위로만 HashTag를 사용한다.

{user:123}:session    ← 같이 읽을 일 있음. HashTag 적합
{user:123}:cart       ← 같이 읽을 일 있음. HashTag 적합
{user:123}:wishlist   ← 같이 읽을 일 있음. HashTag 적합

user:123:order:1      ← HashTag 불필요. 주문별로 독립 저장이 낫다
user:123:order:2      ← order:1과 동시에 읽을 이유가 없으면 분산이 더 좋다

슬롯 분포 확인

특정 슬롯에 데이터가 집중되는지 정기적으로 확인한다.

# 클러스터 전체 슬롯 분포 확인
redis-cli --cluster check 10.0.1.10:6379

# 출력에서 각 노드의 키 수 비교 예시
# Master A: 150000 keys   ← 몰림
# Master B:   3200 keys
# Master C:   2800 keys

# 특정 HashTag 패턴의 슬롯 확인
redis-cli CLUSTER KEYSLOT "{user:123}"
redis-cli CLUSTER KEYSLOT "{order:456}"

Q6. Redis 메모리가 꽉 차면 어떻게 되나?

새벽에 Redis에서 에러가 쏟아지기 시작했다.

OOM command not allowed when used memory > 'maxmemory'

서비스 모든 기능이 Redis에 쓰기를 하는 순간 에러를 반환한다. 세션 갱신 실패, 장바구니 저장 실패, 재고 차감 실패. Redis 메모리 관리를 이해하지 않으면 운영 중 이런 상황을 맞는다.

maxmemory 미설정 시

Redis는 기본적으로 메모리 한계가 없다. 사용 가능한 OS 메모리를 모두 쓴다.

OS 메모리가 가득 차면 Linux OOM Killer가 동작한다. 메모리를 가장 많이 쓰는 프로세스를 강제 종료한다. Redis 프로세스가 종료된다. 서비스가 중단된다. RDB/AOF 저장이 완료되지 않았으면 데이터 손실도 생긴다.

# OOM Killer 동작 여부 확인
dmesg | grep -i "oom killer"
dmesg | grep -i "killed process"

maxmemory 설정 + noeviction

# redis.conf
maxmemory 24gb
maxmemory-policy noeviction

메모리 한계에 도달하면 모든 쓰기 커맨드에서 에러를 반환한다. 읽기(GET, HGET 등)는 정상이다.

서비스 코드는 에러를 받고도 계속 실행된다. 에러 로그가 쏟아지지만 겉으로는 조용해 보인다. 모니터링이 없으면 늦게 발견한다.

maxmemory 설정 + eviction policy

메모리 한계에 도달하면 설정한 정책에 따라 기존 키를 삭제하고 공간을 확보한다. 정책에 따라 세션 데이터, 재고 데이터 같은 중요한 키도 삭제될 수 있다. 캐시 용도라면 괜찮지만 주요 데이터를 저장하는 용도라면 위험하다.

maxmemory 설정 기준

물리 RAM의 75~80%를 설정한다. 32GB RAM 서버라면:

maxmemory 24gb

20% 여유를 남기는 이유가 있다.

BGSAVE (RDB 스냅샷) 문제: fork() 시스템 콜로 자식 프로세스를 생성한다. 자식 프로세스는 부모(Redis)의 메모리 공간을 Copy-on-Write로 공유한다. 스냅샷 저장 중 쓰기가 발생하면 해당 페이지가 복사된다. 쓰기가 많은 시간대에는 메모리 사용량이 순간적으로 2배 가까이 늘어날 수 있다. maxmemory를 32GB로 설정했는데 BGSAVE 중 갑자기 64GB가 필요해지면 OOM이 발생한다.

Redis 프로세스 자체 오버헤드: 키 메타데이터, 클라이언트 연결 버퍼, 복제 버퍼가 추가 메모리를 사용한다.

# BGSAVE 관련 상태 확인
redis-cli INFO persistence | grep rdb_last_bgsave_status
redis-cli INFO memory | grep mem_allocator

# 런타임 maxmemory 변경 (재시작 불필요)
redis-cli CONFIG SET maxmemory 24gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

# 변경 후 redis.conf에 영구 저장
redis-cli CONFIG REWRITE

Redis가 재시작되면 데이터는 어떻게 살아남나? RDB와 AOF

HA 구성(Q1~Q3)은 노드 장애를 다루지만, 클러스터 전체가 재시작되거나 Master와 Replica가 함께 내려가는 경우엔 디스크에 남긴 데이터로 복구해야 한다. Redis의 영속성 옵션은 RDB와 AOF 두 가지다.

RDB (스냅샷): 특정 시점의 전체 데이터셋을 바이너리 파일로 덤프한다. BGSAVEfork()로 자식 프로세스를 만들고 Copy-on-Write로 메모리를 공유하면서 저장하므로 부모(메인 프로세스)는 거의 멈추지 않는다.

  • [WHY] 파일 하나로 압축돼서 백업·전송이 쉽고, 재시작 시 로딩이 AOF보다 빠르다.
  • [WHY] 단점: 스냅샷은 주기적이라 마지막 스냅샷 이후 ~ 장애 시점 사이의 쓰기는 유실된다. 시점 백업에는 좋지만 유실 허용 폭이 작은 데이터엔 부족하다.

AOF (Append Only File): 쓰기 커맨드를 로그로 계속 추가 기록한다. 재시작 시 이 로그를 재생(replay)해서 상태를 복원한다. 디스크 동기화 주기는 appendfsync로 정한다.

  • always: 매 쓰기마다 fsync. 유실이 거의 없지만 가장 느리다.
  • everysec: 1초마다 fsync. 최대 1초치 유실 가능. 성능과 안전의 균형점이라 대부분 이걸 쓴다.
  • no: fsync를 OS에 맡김. 가장 빠르지만 유실 폭이 가장 크다.
  • AOF는 같은 키를 반복 수정하면 로그가 계속 커지므로, AOF rewrite(현재 상태를 재구성하는 최소 커맨드 집합으로 압축)로 파일 크기를 관리한다.

[WHY] 하이브리드(Redis 4+) 권장: aof-use-rdb-preamble yes를 켜면 AOF rewrite 시 RDB 포맷으로 베이스를 저장하고 그 뒤에 증분 커맨드를 AOF로 덧붙인다. RDB의 빠른 로딩과 AOF의 작은 유실 폭을 함께 얻는다. 그래서 유실에 민감한 용도라면 보통 AOF(everysec) + RDB를 같이 켜고, 하이브리드 포맷을 쓴다. 순수 캐시 용도라면 영속성을 꺼서 fork·fsync 비용을 아끼는 선택도 합리적이다.


Q7. Eviction Policy를 어떻게 선택해야 하나? LRU와 LFU가 없으면 어떤 데이터가 살아남나?

메모리가 꽉 차서 eviction이 일어났다. 날아가면 안 되는 세션 데이터가 제거됐다.

Eviction Policy를 allkeys-random으로 설정했기 때문이다. 무작위로 키를 삭제하니 운 나쁘게 세션 키가 걸렸다. 어떤 정책을 써야 하는지 이해해야 한다.

LRU vs LFU

LRU (Least Recently Used): 가장 오래전에 접근한 키를 제거한다. “최근에 쓴 것은 앞으로도 쓸 것이다”는 가정이다.

LFU (Least Frequently Used): 접근 빈도가 가장 낮은 키를 제거한다. “자주 쓰는 것은 앞으로도 쓸 것이다”는 가정이다.

예를 들어, 어제 딱 한 번 접근한 키 A와 3일 전부터 꾸준히 하루 1000번씩 접근하는 키 B가 있다.

  • LRU: 키 A를 최근에 접근했으므로 키 B를 제거한다. 잘못된 선택이다.
  • LFU: 키 A의 빈도가 낮으므로 키 A를 제거한다. 올바른 선택이다.

트래픽 패턴이 일정한 서비스라면 LRU로 충분하다. 특정 키가 폭발적으로 많이 쓰이는 패턴(핫 키)이 있다면 LFU가 더 적합하다.

volatile-* vs allkeys-*

volatile-*: TTL이 설정된 키만 제거 대상이다. TTL 없는 키는 절대 제거하지 않는다.

allkeys-*: TTL 여부에 관계없이 모든 키가 제거 대상이다.

세션 데이터에 TTL을 설정했다면 allkeys-lru를 써도 세션이 제거될 수 있다. 세션이 날아가면 안 된다면, 세션에 TTL을 설정하지 말고 volatile-lru를 쓰는 방법이 있다. 하지만 세션에 TTL이 없으면 메모리 정리가 안 된다. 트레이드오프다.

Policy별 적합 케이스

Policy동작적합 케이스
noeviction쓰기 에러 반환메인 데이터 저장소, 데이터 손실 절대 불가
allkeys-lru모든 키 중 LRU캐시 전용, 모든 키가 캐시인 경우
volatile-lruTTL 있는 키 중 LRU캐시 + 영구 데이터 혼용
allkeys-lfu모든 키 중 LFU핫 키 패턴, 특정 키 집중 접근
volatile-lfuTTL 있는 키 중 LFU혼용 + 핫 키 패턴
volatile-ttlTTL 짧은 키 우선 제거만료 임박한 캐시 먼저 정리
allkeys-random무작위 제거비추천
volatile-randomTTL 있는 키 무작위비추천

실무 선택 기준

  1. 캐시 전용 Redis: allkeys-lru로 시작한다.
  2. eviction이 발생하는데 해당 키를 자주 접근하고 있다면: allkeys-lfu 검토한다.
  3. 캐시 + 세션 혼용 (비추천): volatile-lru. 세션에 TTL 없애고 캐시에만 TTL을 설정한다.
  4. 메인 데이터 저장소: noeviction. 메모리 부족 시 쓰기 에러를 반환하면 알림을 받고 처리한다.

Redis를 용도별로 분리하는 것이 가장 안전하다. 캐시 Redis, 세션 Redis, 데이터 Redis를 각각 다른 인스턴스로 운영하고 각각에 맞는 policy를 설정한다.

Redis LRU는 근사 알고리즘

Redis는 완전한 LRU를 구현하지 않는다. 정확한 LRU는 전체 키셋에서 가장 오래된 키를 찾아야 하는데, 키가 수백만 개라면 너무 느리다.

Redis는 샘플링으로 근사 LRU를 구현한다. maxmemory-samples 설정값만큼 키를 랜덤 샘플링해서 그 중 가장 오래된 키를 제거한다.

# 기본값: 5개 샘플링
maxmemory-samples 5

# 정확도 높이기: 10개 샘플링 (CPU 사용량 증가)
maxmemory-samples 10

샘플이 많을수록 정확한 LRU에 가까워지지만 CPU를 더 쓴다. 대부분의 운영 환경에서 기본값 5로 충분하다.


Q8. 메모리 파편화가 왜 생기고 어떻게 감지하나?

INFO memory를 봤더니 used_memory는 800MB인데 used_memory_rss는 1.5GB다. Redis가 메모리를 낭비하는 건가?

redis-cli INFO memory | grep -E "used_memory|mem_fragmentation"

used_memory:838860800           # Redis가 실제 데이터에 쓰고 있는 메모리: 800MB
used_memory_rss:1610612736      # OS 관점에서 Redis가 점유하는 물리 메모리: 1.5GB
mem_fragmentation_ratio:1.92    # 파편화 비율: 1.92

Redis가 800MB의 데이터를 저장하는데 OS는 1.5GB를 사용 중으로 인식한다. 700MB가 낭비되고 있다.

파편화 메커니즘

Redis는 메모리 할당기(jemalloc)를 통해 OS에서 메모리를 받는다. 특정 크기의 블록 단위로 할당받는다.

키를 삭제하면 해당 메모리 공간이 비워진다. 그런데 이 빈 공간을 OS에 돌려주지 못한다. 새 키가 들어올 때 재사용하려고 Redis 내부에 남겨둔다.

문제는 새 키의 크기가 빈 공간의 크기와 딱 맞지 않을 때다. 10바이트짜리 공간에 7바이트 키가 들어오면 3바이트가 파편으로 남는다. 이 3바이트는 작아서 다른 키로 채우기도 어렵다. 이런 조각이 쌓이면 파편화가 심해진다.

파편화 비율 해석

mem_fragmentation_ratio = used_memory_rss / used_memory
  • 1.5 이상: 파편화가 심각하다. 적극적인 대응이 필요하다.
  • 1.0 ~ 1.5: 정상 범위다. 약간의 파편화는 항상 존재한다.
  • 1.0 미만: 스왑(Swap)이 발생하고 있다. 물리 메모리가 부족해서 디스크 스왑을 쓰는 신호다. 매우 위험하다.

파편화가 생기는 주요 패턴

  1. 대량 키 삭제: DEL로 많은 키를 삭제하면 빈 공간이 산발적으로 생긴다.
  2. TTL 자동 만료: 다양한 크기의 키들이 만료되면 다양한 크기의 빈 공간이 생긴다.
  3. 키 값 크기가 자주 바뀜: SET key value1(10바이트) 후 SET key value2(100바이트)를 하면 처음 블록이 낭비된다.

해결 1: Active Defragmentation (Redis 4.0+)

Redis가 백그라운드에서 파편화된 메모리를 자동으로 정리한다. 운영 중 재시작 없이 사용 가능하다.

# redis.conf 또는 런타임 설정
activedefrag yes
active-defrag-ignore-bytes 100mb    # 파편화 크기가 100MB 이상일 때 시작
active-defrag-threshold-lower 10    # 파편화 비율 10% 이상일 때 시작
active-defrag-threshold-upper 100   # 파편화 비율 100%면 최대 CPU 사용
active-defrag-cycle-min 1           # Defrag에 사용할 최소 CPU 비율 (%)
active-defrag-cycle-max 25          # Defrag에 사용할 최대 CPU 비율 (%)

# 런타임 활성화
redis-cli CONFIG SET activedefrag yes
redis-cli CONFIG SET active-defrag-ignore-bytes 100mb
redis-cli CONFIG SET active-defrag-threshold-lower 10
redis-cli CONFIG SET active-defrag-cycle-min 1
redis-cli CONFIG SET active-defrag-cycle-max 25

active-defrag-cycle-max 25: CPU의 최대 25%까지 defrag에 사용한다. 서비스 응답시간에 영향이 생기면 이 값을 낮춘다.

해결 2: MEMORY PURGE

메모리 할당기에게 빈 블록을 OS에 반환하도록 강제한다. 즉시 효과가 있지만 실행 동안 Redis가 잠시 멈출 수 있다. 트래픽이 낮은 새벽 시간대에 실행한다.

redis-cli MEMORY PURGE

해결 3: 롤링 재시작

가장 확실하지만 가장 번거롭다. Redis를 재시작하면 새 메모리 레이아웃으로 다시 시작한다. Replica를 먼저 재시작하고 Failover 후 구 Master를 재시작한다.

# 1. Replica 재시작 (서비스 영향 없음)
sudo systemctl restart redis  # Replica 서버에서 실행

# 2. Sentinel로 Failover 강제 실행 (구 Master → Replica가 됨)
redis-cli -h 10.0.1.20 -p 26379 SENTINEL failover mymaster

# 3. 구 Master(이제 Replica)도 재시작
sudo systemctl restart redis  # 구 Master 서버에서 실행

Q9. Redis에서 원자적 연산이 필요한데 왜 Lua 스크립트가 필요한가?

재고가 10개인데 동시 주문 100개가 들어왔다. Redis에 재고를 저장하고 DECRBY로 차감했는데 재고가 -90이 됐다.

DECRBY stock:item:1 1은 원자적이다. 그런데 문제는 “재고가 있는지 확인”하고 “차감”하는 두 단계 사이에 생긴다.

GET + 조건 체크 + DECRBY의 Race Condition

Thread A:                      Thread B:
GET stock:item:1  →  10
                               GET stock:item:1  →  10
                               stock >= 1, OK
stock >= 1, OK
                               DECRBY stock:item:1 1  →  9
DECRBY stock:item:1 1  →  8   (이미 다른 스레드가 차감했는데 이쪽도 차감)

두 스레드가 거의 동시에 실행되면 둘 다 재고가 있다고 확인하고 차감한다. 재고 1개인 상황에서 동시에 100개 요청이 들어오면 재고가 -99가 될 수 있다.

MULTI/EXEC로 시도

// Optimistic Locking 방식
redisTemplate.execute(new SessionCallback<Object>() {
    @Override
    public Object execute(RedisOperations operations) {
        operations.watch("stock:item:1");
        Long stock = (Long) operations.opsForValue().get("stock:item:1");

        if (stock == null || stock <= 0) {
            operations.unwatch();
            return -1L;  // 재고 없음
        }

        operations.multi();
        operations.opsForValue().decrement("stock:item:1");
        List<Object> results = operations.exec();

        if (results == null) {
            // 다른 클라이언트가 수정 → EXEC 실패 → 재시도 필요
            return null;
        }
        return 1L;  // 성공
    }
});

WATCH는 Optimistic Locking이다. WATCH 후 다른 클라이언트가 해당 키를 수정하면 EXEC이 null을 반환한다. 재시도 로직이 필요하다.

경쟁이 심한 상황(동시 주문 100개)에서 EXEC 실패 → 재시도 → 또 실패 → 재시도가 반복된다. 재시도 폭풍이 생긴다. 부하가 오히려 증가한다.

Lua 스크립트가 진짜 원자적인 이유

Redis는 싱글 스레드로 커맨드를 처리한다. Lua 스크립트는 하나의 커맨드처럼 실행된다. 스크립트가 시작되면 완료될 때까지 다른 어떤 커맨드도 실행되지 않는다. 중간에 끼어드는 것이 불가능하다.

정확히는 “커맨드 실행”이 단일 스레드라는 뜻이다. Redis 6+는 네트워크 I/O(읽기/파싱/쓰기)를 io-threads로 멀티스레드 처리할 수 있고, BGSAVE fork와 lazyfree(비동기 메모리 해제)는 백그라운드 스레드에서 돈다. 원자성을 보장하는 것은 어디까지나 커맨드 실행 단계가 단일 스레드라는 점이다.

-- scripts/deduct-stock.lua
-- KEYS[1]: 재고 키 (예: "stock:item:1")
-- ARGV[1]: 차감량 (예: "1")
-- 반환값:
--   -1: 키 없음 (상품 미존재)
--   -2: 재고 부족
--   0 이상: 차감 후 남은 재고

local current = redis.call('GET', KEYS[1])

if current == false then
    return -1  -- 키 없음
end

local stock  = tonumber(current)
local deduct = tonumber(ARGV[1])

if stock < deduct then
    return -2  -- 재고 부족
end

local remaining = redis.call('DECRBY', KEYS[1], deduct)
return remaining  -- 차감 후 남은 재고

Spring Boot RedisScript 설정

@Configuration
public class RedisScriptConfig {

    @Bean
    public RedisScript<Long> deductStockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("scripts/deduct-stock.lua"));
        script.setResultType(Long.class);
        return script;
    }

    @Bean
    public RedisScript<Long> releaseLockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("scripts/release-lock.lua"));
        script.setResultType(Long.class);
        return script;
    }
}
@Service
@RequiredArgsConstructor
@Slf4j
public class StockService {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedisScript<Long> deductStockScript;

    private static final String STOCK_KEY_PREFIX = "stock:item:";

    /**
     * 재고 차감 (원자적)
     *
     * @return 차감 후 남은 재고.
     *         -1이면 상품 없음, -2이면 재고 부족, 0 이상이면 성공
     */
    public long deductStock(String itemId, int quantity) {
        String stockKey = STOCK_KEY_PREFIX + itemId;

        Long result = redisTemplate.execute(
            deductStockScript,
            Collections.singletonList(stockKey),
            String.valueOf(quantity)
        );

        if (result == null) {
            throw new RedisCommandExecutionException("Lua script 실행 실패: " + stockKey);
        }

        log.debug("재고 차감 결과. itemId={}, quantity={}, result={}", itemId, quantity, result);
        return result;
    }

    public void initStock(String itemId, long quantity) {
        String stockKey = STOCK_KEY_PREFIX + itemId;
        redisTemplate.opsForValue().set(stockKey, String.valueOf(quantity));
        log.info("재고 초기화. itemId={}, quantity={}", itemId, quantity);
    }

    public long getStock(String itemId) {
        String stockKey = STOCK_KEY_PREFIX + itemId;
        String value = redisTemplate.opsForValue().get(stockKey);
        return value != null ? Long.parseLong(value) : -1L;
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/orders")
public class OrderController {

    private final StockService stockService;

    @PostMapping("/{itemId}/purchase")
    public ResponseEntity<String> purchase(
            @PathVariable String itemId,
            @RequestParam(defaultValue = "1") int quantity) {

        long result = stockService.deductStock(itemId, quantity);

        if (result == -1L) {
            return ResponseEntity.badRequest().body("상품이 존재하지 않습니다.");
        }
        if (result == -2L) {
            return ResponseEntity.badRequest().body("재고가 부족합니다.");
        }

        return ResponseEntity.ok("구매 성공. 남은 재고: " + result);
    }
}

EVALSHA vs EVAL

처음 스크립트를 실행하면 Redis가 SHA1 해시값을 계산해 저장한다. 이후 같은 스크립트를 실행할 때 EVALSHA <sha1> ...로 해시값만 보낸다. 스크립트 전체를 다시 보내지 않아서 네트워크 효율이 좋다.

Spring Data Redis는 자동으로 EVALSHA를 먼저 시도하고, Redis가 NOSCRIPT 에러를 반환하면(스크립트가 캐시에 없음) EVAL로 폴백한다. 개발자가 직접 관리할 필요가 없다.

# 스크립트 SHA1 미리 로딩
redis-cli SCRIPT LOAD "$(cat src/main/resources/scripts/deduct-stock.lua)"
# 출력: "c0d8b2a3f1e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8"

# EVALSHA로 직접 실행
redis-cli EVALSHA c0d8b2a3f1e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8 1 stock:item:1 1

# 재배포 후 스크립트 캐시 초기화 (필요 시)
redis-cli SCRIPT FLUSH

분산 락 해제 Lua 스크립트

분산 락을 해제할 때도 Lua가 필요하다. Lua 없이 구현하면 왜 위험한가:

Thread A (락 보유자):            Thread B (다른 스레드):
GET lock:item:1  →  "uuid-A"
lock == "uuid-A"  →  OK
                                 lock TTL 만료
                                 SET lock:item:1 "uuid-B"  (새 락 획득)
DEL lock:item:1  ← Thread A가 Thread B의 락을 삭제해버림

GET으로 확인하고 DEL하는 사이에 다른 스레드가 새 락을 획득할 수 있다. Lua로 GET과 DEL을 원자적으로 처리해야 안전하다.

-- scripts/release-lock.lua
-- KEYS[1]: 락 키
-- ARGV[1]: 락 보유자 UUID
-- 반환값: 1 (해제 성공), 0 (본인 락이 아님 또는 이미 만료)

local owner = redis.call('GET', KEYS[1])
if owner == false then
    return 0  -- 락이 이미 TTL 만료됨
end
if owner ~= ARGV[1] then
    return 0  -- 다른 보유자의 락: 건드리지 않음
end
redis.call('DEL', KEYS[1])
return 1

Q10. Lua 스크립트가 Redis를 블로킹한다. 긴 스크립트는 어떻게 관리하나?

재고 차감 스크립트를 넣었더니 특정 시간대에 다른 Redis 커맨드들이 수백 ms씩 지연된다.

모니터링을 보니 Redis 응답시간이 보통 1ms인데 갑자기 200~500ms로 튄다. 타임아웃이 발생하는 커맨드도 생긴다. 슬로우 로그를 확인했다.

redis-cli SLOWLOG GET 20
1) 1) (integer) 42
   2) (integer) 1719600000
   3) (integer) 450000       ← 450ms 걸림
   4) 1) "EVAL"
      2) "local items = redis.call('KEYS', '{order:*}')..."
      3) "0"

Lua 스크립트 안에서 KEYS 패턴 검색을 하고 있었다. KEYS는 전체 키셋을 스캔한다. 키가 100만 개라면 Redis가 100만 개를 순회하는 동안 아무것도 못 한다.

Lua 스크립트의 블로킹 특성

Redis 싱글 스레드 특성 때문에 Lua 스크립트가 실행되는 동안 다른 커맨드는 대기한다. 스크립트가 1ms라면 문제없다. 스크립트가 100ms라면 그 동안 들어온 모든 커맨드가 100ms씩 지연된다. 연결당 큐가 쌓이면 타임아웃이 연쇄적으로 발생한다.

lua-time-limit

Redis는 Lua 스크립트 실행 시간을 제한한다.

# redis.conf
lua-time-limit 5000   # 5000ms = 5초

5초를 초과하면 Redis는 새로 들어오는 커맨드에 BUSY Redis is busy running a script 에러를 반환하기 시작한다. 하지만 스크립트가 멈추지는 않는다.

# 실행 중인 스크립트 강제 종료 (읽기 전용 스크립트만 가능)
redis-cli SCRIPT KILL

# 이미 쓰기를 시작한 경우: SCRIPT KILL 불가, 최후 수단
redis-cli SHUTDOWN NOSAVE

쓰기를 이미 시작한 스크립트는 SCRIPT KILL도 실패한다. 데이터 정합성을 위해 Redis가 허용하지 않는다. SHUTDOWN NOSAVE만 가능한 상태가 된다. 이런 상황에 빠지면 서비스 중단이다.

나쁜 패턴

-- 나쁜 예 1: Lua 내에서 KEYS로 전체 스캔
-- 키 100만 개 → 수백 ms 블로킹
local keys = redis.call('KEYS', 'order:*')
for i, key in ipairs(keys) do
    redis.call('DEL', key)
end

-- 나쁜 예 2: 루프 횟수가 런타임에 결정됨
-- 큐에 데이터가 많으면 얼마나 걸릴지 예측 불가
local count = 0
while true do
    local result = redis.call('LPOP', 'task:queue')
    if result == false then break end
    count = count + 1
end
return count

좋은 패턴

원자성이 필요한 최소한의 작업만 Lua로 처리한다. 루프가 있다면 루프 횟수를 상수로 제한하거나, 루프 자체를 앱 레벨로 옮긴다.

-- 좋은 예: 원자적 처리만, 루프 없음
local current = redis.call('GET', KEYS[1])
if current == false then return -1 end
local stock = tonumber(current)
if stock < tonumber(ARGV[1]) then return -2 end
return redis.call('DECRBY', KEYS[1], ARGV[1])

슬로우 로그에서 Lua 실행 시간 확인

# 슬로우 로그 임계값 설정 (마이크로초)
redis-cli CONFIG SET slowlog-log-slower-than 10000  # 10ms 이상인 커맨드 기록

# 슬로우 로그 최대 저장 개수
redis-cli CONFIG SET slowlog-max-len 128

# 슬로우 로그 조회 (최근 20개)
redis-cli SLOWLOG GET 20

# 슬로우 로그 길이 확인
redis-cli SLOWLOG LEN

# 슬로우 로그 초기화
redis-cli SLOWLOG RESET

실전 Lua 패턴 1: Rate Limiting (Sliding Window)

-- scripts/rate-limit.lua
-- KEYS[1]: rate limit 키 (예: "ratelimit:{user:1}:api")
-- ARGV[1]: 현재 타임스탬프 (밀리초)
-- ARGV[2]: 윈도우 크기 (밀리초, 예: "60000" = 1분)
-- ARGV[3]: 최대 요청 수 (예: "100")
-- 반환값: -1 (제한 초과), 0 이상 (남은 허용 요청 수)

local key          = KEYS[1]
local now          = tonumber(ARGV[1])
local window       = tonumber(ARGV[2])
local limit        = tonumber(ARGV[3])
local window_start = now - window

-- 윈도우 밖 오래된 데이터 제거
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

-- 현재 요청 수 확인
local count = redis.call('ZCARD', key)

if count >= limit then
    return -1  -- 제한 초과
end

-- 현재 요청 기록 (score = 타임스탬프, member = 유니크값)
local member = now .. '-' .. math.random(1000000)
redis.call('ZADD', key, now, member)

-- TTL 설정 (윈도우 크기만큼 유지)
redis.call('PEXPIRE', key, window)

return limit - count - 1  -- 남은 허용 수
@Configuration
public class RateLimitConfig {

    @Bean
    public RedisScript<Long> rateLimitScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("scripts/rate-limit.lua"));
        script.setResultType(Long.class);
        return script;
    }
}

@Service
@RequiredArgsConstructor
public class RateLimitService {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedisScript<Long> rateLimitScript;

    /**
     * Sliding Window 방식 Rate Limiting
     *
     * @param userId         유저 식별자
     * @param resource       API 리소스 식별자
     * @param limitPerMinute 분당 최대 허용 요청 수
     * @return true면 허용, false면 차단
     */
    public boolean isAllowed(String userId, String resource, int limitPerMinute) {
        // HashTag 사용: Cluster에서도 같은 슬롯 보장
        String key    = String.format("ratelimit:{user:%s}:%s", userId, resource);
        long   now    = System.currentTimeMillis();
        long   window = 60_000L;  // 1분

        Long result = redisTemplate.execute(
            rateLimitScript,
            Collections.singletonList(key),
            String.valueOf(now),
            String.valueOf(window),
            String.valueOf(limitPerMinute)
        );

        return result != null && result >= 0;
    }
}

@RestController
@RequiredArgsConstructor
public class ApiController {

    private final RateLimitService rateLimitService;

    @GetMapping("/api/data")
    public ResponseEntity<String> getData(
            @RequestHeader("X-User-Id") String userId) {

        if (!rateLimitService.isAllowed(userId, "data", 100)) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .body("요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.");
        }

        return ResponseEntity.ok("데이터");
    }
}

실전 Lua 패턴 2: 분산 락 안전한 해제 (완전한 코드)

@Service
@RequiredArgsConstructor
@Slf4j
public class DistributedLockService {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedisScript<Long> releaseLockScript;

    /**
     * 분산 락 획득
     *
     * @return 락 보유자 UUID. null이면 획득 실패
     */
    public String acquireLock(String lockKey, Duration ttl) {
        String lockValue = UUID.randomUUID().toString();
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(
            lockKey,
            lockValue,
            ttl
        );
        if (Boolean.TRUE.equals(acquired)) {
            log.debug("분산 락 획득. lockKey={}", lockKey);
            return lockValue;
        }
        return null;
    }

    /**
     * 분산 락 해제 (Lua로 원자적 처리)
     *
     * @return true면 해제 성공, false면 본인 락이 아님
     */
    public boolean releaseLock(String lockKey, String lockValue) {
        Long result = redisTemplate.execute(
            releaseLockScript,
            Collections.singletonList(lockKey),
            lockValue
        );
        boolean released = Long.valueOf(1L).equals(result);
        if (!released) {
            log.warn("분산 락 해제 실패. 본인 락이 아니거나 이미 만료됨. lockKey={}", lockKey);
        }
        return released;
    }
}

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final DistributedLockService lockService;
    private final StockService stockService;

    public OrderResult processOrder(String itemId, int quantity) {
        String lockKey   = "lock:item:" + itemId;
        String lockValue = lockService.acquireLock(lockKey, Duration.ofSeconds(10));

        if (lockValue == null) {
            log.warn("분산 락 획득 실패. itemId={}", itemId);
            return OrderResult.LOCK_FAILED;
        }

        try {
            long remaining = stockService.deductStock(itemId, quantity);
            if (remaining == -1L) return OrderResult.ITEM_NOT_FOUND;
            if (remaining == -2L) return OrderResult.OUT_OF_STOCK;
            log.info("주문 처리 성공. itemId={}, 남은재고={}", itemId, remaining);
            return OrderResult.SUCCESS;
        } finally {
            lockService.releaseLock(lockKey, lockValue);
        }
    }
}

redis.call vs redis.pcall 선택 기준

redis.call: Redis 커맨드가 에러를 반환하면 Lua 스크립트 전체가 즉시 중단된다. 에러가 상위로 전파된다.

redis.pcall: redis.call과 같은 커맨드를 실행하지만, 에러가 나도 스크립트를 중단(throw)시키지 않고 에러를 테이블({err = "..."})로 반환한다. 스크립트가 그 반환값을 검사해서 직접 처리할 수 있다. (Lua 내장 pcall은 임의의 Lua 함수를 보호 호출하는 일반 메커니즘으로, redis.pcall과는 다르다.)

재고 차감에는 redis.call이 적합하다. 중간에 에러가 나면 차감을 중단해야 한다. Lua 스크립트는 부분 실행을 롤백하지 않는다. 따라서 에러 시 스크립트 전체를 중단하고 애플리케이션에서 에러를 처리하는 것이 안전하다.

-- 재고 차감: redis.call 사용 (에러 발생 시 즉시 중단, 에러 전파)
local current   = redis.call('GET', KEYS[1])   -- 에러 시 스크립트 전체 중단
local remaining = redis.call('DECRBY', KEYS[1], ARGV[1])
return remaining
-- Rate limiting: redis.pcall 사용 (에러를 throw하지 않고 테이블로 반환)
-- 이유: Rate Limit 계산 실패 시 서비스를 허용하는 것이 차단보다 낫다 (가용성 우선)
local res = redis.pcall('ZCARD', KEYS[1])
if type(res) == 'table' and res.err then
    return limit  -- 에러 시 허용으로 폴백
end
local count = tonumber(res)

Cluster에서 Lua 제약

Cluster에서 Lua 스크립트는 같은 슬롯의 키만 접근할 수 있다.

-- 에러 발생: user:1과 order:1이 다른 슬롯에 있는 경우
local user  = redis.call('GET', 'user:1')      -- 슬롯 8106
local order = redis.call('GET', 'order:1')     -- 슬롯 다름 → CROSSSLOT 에러

-- 정상: HashTag로 같은 슬롯 강제
local session = redis.call('GET', '{user:123}:session')    -- 슬롯 = CRC16("user:123") % 16384
local cart    = redis.call('GET', '{user:123}:cart')       -- 같은 슬롯

Cluster에서 Lua를 쓰려면 스크립트가 접근하는 모든 키에 같은 HashTag가 있어야 한다. 설계 시점에 이를 강제하지 않으면 Cluster 전환 시 런타임 에러를 맞는다.


최적화 요약

HA 구성 선택 기준

  • Sentinel: 데이터 32GB 이하, 단순 Failover만 필요, 멀티키 커맨드를 자유롭게 쓰고 싶은 경우
  • Cluster: 데이터가 단일 서버 메모리 초과, 초당 10만 ops 이상, 수평 확장이 필요한 경우

메모리 관리

  • maxmemory: 물리 RAM의 75~80%로 설정. BGSAVE fork 비용을 고려한 여유분을 남긴다.
  • eviction policy: 캐시 전용은 allkeys-lru부터 시작. 핫 키 패턴이 확인되면 allkeys-lfu 검토.
  • activedefrag: mem_fragmentation_ratio가 1.5를 초과하면 활성화. active-defrag-cycle-max 25로 CPU 사용 상한을 제한한다.

Lua 스크립트

  • 스크립트 길이: 원자성이 필요한 최소 작업만 포함. 루프와 KEYS 검색 금지.
  • lua-time-limit: 기본 5000ms. 이 한계를 넘는 스크립트가 있다면 설계 문제다.
  • Cluster에서 HashTag 필수: 스크립트가 접근하는 모든 키에 같은 HashTag가 있어야 한다.

튜닝 체크리스트

# 1. 메모리 파편화 비율
redis-cli INFO memory | grep mem_fragmentation_ratio

# 2. Eviction 발생 여부
redis-cli INFO stats | grep evicted_keys

# 3. 슬로우 로그
redis-cli SLOWLOG GET 20

# 4. 연결 수 현황
redis-cli INFO clients | grep connected_clients

# 5. Cluster 슬롯 분포
redis-cli --cluster check 10.0.1.10:6379

# 6. Sentinel 상태
redis-cli -h 10.0.1.10 -p 26379 SENTINEL masters

운영상 주의사항

KEYS * 절대 금지

KEYS *는 Redis의 모든 키를 순회한다. 키가 100만 개라면 Redis가 100만 개를 순회하는 동안 다른 모든 커맨드가 대기한다. 운영 환경에서 실행하는 순간 서비스 전체가 멈출 수 있다.

대신 SCAN을 사용한다. SCAN은 커서 기반으로 일부씩 조회하므로 Redis를 블로킹하지 않는다.

# 나쁜 예 (절대 금지)
redis-cli KEYS "*:session"

# 좋은 예: SCAN으로 커서 기반 조회
# 커서가 0이 될 때까지 반복하면 전체 순회 완료
redis-cli SCAN 0 MATCH "*:session" COUNT 100
// Java에서 SCAN 사용
Set<String> keys = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions()
    .match("*:session")
    .count(100)
    .build();

try (Cursor<byte[]> cursor = redisTemplate
        .getConnectionFactory()
        .getConnection()
        .scan(options)) {
    while (cursor.hasNext()) {
        keys.add(new String(cursor.next(), StandardCharsets.UTF_8));
    }
}

Lua redis.call vs redis.pcall 선택 기준

  • redis.call: 에러 발생 시 스크립트 즉시 중단 + 에러 전파. 데이터 정합성이 중요한 경우 (재고 차감, 잔액 변경, 분산 락 해제) 사용한다.
  • redis.pcall: 에러를 캐치하고 스크립트 계속 실행 가능. 서비스 가용성이 정합성보다 중요한 경우 (Rate Limiting, 통계 집계) 사용한다.

Cluster에서 Lua KEYS 제약

Cluster에서 Lua 스크립트 내 KEYS 배열에는 모두 같은 슬롯의 키만 있어야 한다. 실행 전에 Redis가 검증하지 않는다. 실행 중에 다른 슬롯 키를 접근하면 에러가 발생한다. 설계 시점에 KEYS 파라미터로 넘기는 모든 키에 같은 HashTag가 붙도록 강제해야 한다.

Sentinel quorum 설정: quorum=1의 위험성

quorum=1은 Sentinel 1개만 Master가 죽었다고 판단해도 Failover를 시작한다.

# 위험한 설정
sentinel monitor mymaster 10.0.1.10 6379 1

네트워크 파티션이 생기면 Split-brain이 된다. Sentinel A 쪽 네트워크가 Master와 분리됐다. Sentinel A는 Master가 죽었다고 판단하고 Failover를 시작한다. 실제 Master는 살아있는데 Sentinel B/C 쪽에서는 정상으로 보인다. 두 개의 Master가 생긴다. 클라이언트들이 두 Master에 각자 다른 데이터를 쓴다. 네트워크가 복구된 후 데이터를 합칠 수 없다.

Sentinel 3개 구성에 quorum=2가 기본이다.

# 권장 설정
sentinel monitor mymaster 10.0.1.10 6379 2

메모리 사용량 예측: listpack에서 hashtable 전환

Redis는 데이터 구조의 크기가 작을 때 압축된 자료구조(listpack)를 사용한다. 크기가 임계값을 넘으면 일반 자료구조(hashtable)로 전환된다.

# Hash 자료구조 전환 임계값 (redis.conf)
hash-max-listpack-entries 128   # 엔트리 수가 128 초과 시 hashtable로 전환
hash-max-listpack-value 64      # 필드나 값이 64바이트 초과 시 hashtable로 전환

listpack에서 hashtable로 전환되면 메모리 사용량이 2~3배 증가할 수 있다. 엔트리 수가 128개 근처인 Hash가 대량으로 있다면 하나씩 추가될 때마다 전환이 일어나면서 메모리가 급격히 증가할 수 있다. 용량 계획 시 이 전환 시점을 반드시 고려해야 한다.

# 현재 자료구조 인코딩 확인
redis-cli DEBUG OBJECT myhashkey
# 출력 예시: encoding:listpack  또는  encoding:hashtable

# 키별 메모리 사용량 확인
redis-cli MEMORY USAGE myhashkey