Redis 영속성: RDB, AOF 그리고 데이터 손실

1. 개요

Redis는 모든 데이터를 RAM에 보관합니다. 프로세스가 죽으면 Redis가 이전에 디스크에 데이터를 기록했을 때만 데이터가 남습니다. 이 글에서는 RDB(스냅샷)와 AOF(append-only file)라는 두 가지 영속성 메커니즘을 설명하고, 이들을 선택하고 구성하는 방법을 다룹니다. 또한 영속성을 활성화했음에도 데이터가 손실되는 상황을 나열합니다. 글의 마지막에는 영속성 상태를 확인하는 Spring Boot 코드와 Testcontainers를 사용한 테스트가 포함되어 있습니다.

2. RDB: 주기적 스냅샷

RDB는 한 시점의 전체 데이터셋을 바이너리 파일(기본값: dump.rdb)로 기록합니다. redis.conf에서의 설정:

# 3600초 후 최소 1개의 변경, 300초 후 최소 100개의 변경, 또는 60초 후 최소 10000개의 변경이 있으면 스냅샷
save 3600 1 300 100 60 10000

dbfilename dump.rdb
dir /data

# 가장 최근의 BGSAVE가 실패했으면 쓰기 명령을 거부
stop-writes-on-bgsave-error yes
rdbcompression yes

save 조건이 만족되면 Redis는 fork()를 호출합니다. 자식 프로세스가 스냅샷을 임시 파일에 기록한 다음 dump.rdb로 이름을 바꿉니다. 부모 프로세스는 여전히 클라이언트를 정상적으로 처리합니다. 운영 체제의 copy-on-write 메커니즘 덕분에 두 프로세스가 메모리 페이지를 공유합니다. 페이지는 부모 프로세스가 수정할 때만 복사됩니다.

스냅샷을 수동으로 생성할 수도 있습니다:

redis-cli BGSAVE
redis-cli LASTSAVE
Background saving started
(integer) 1760169600

BGSAVE는 백그라운드에서 실행됩니다. LASTSAVE는 마지막 성공한 스냅샷의 Unix 타임스탬프를 반환합니다. 프로덕션에서는 SAVE 사용을 피하세요. 이 명령은 작업이 완료될 때까지 전체 서버를 차단합니다.

RDB의 장점:

  • 파일이 작아서 백업이나 다른 환경으로 복사하기에 매우 적합합니다.
  • 재시작이 빠릅니다. Redis는 바이너리 파일 하나만 로드하면 되기 때문입니다.
  • 스냅샷 사이의 처리량에 거의 영향을 주지 않습니다.

단점: Redis가 충돌하면 마지막 스냅샷 이후의 모든 변경 사항이 손실됩니다. 위의 구성으로는 최악의 경우 몇 분 동안의 데이터를 손실할 수 있습니다.

참고: 큰 데이터셋(수십 GB)에서 fork()는 Redis를 수백 밀리초 동안 멈출 수 있습니다. 스냅샷 중 많이 쓰면 copy-on-write로 인해 RAM이 거의 2배로 증가할 수 있습니다. 여유 메모리를 남겨두고 Linux에서 vm.overcommit_memory = 1을 활성화하세요.

3. AOF: 쓰기 명령 기록

AOF는 데이터를 변경하는 모든 명령(SET, INCR, HSET 등)을 로그 파일의 끝에 기록합니다. 시작할 때 Redis는 이 로그를 다시 실행하여 데이터셋을 재구성합니다.

appendonly yes
appenddirname "appendonlydir"
appendfilename "appendonly.aof"

# always | everysec | no
appendfsync everysec

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes

3.1. appendfsync 정책

파일에 쓰기만 해서는 데이터가 디스크에 있다는 보장이 없습니다. 데이터는 fsync() 명령이 실행될 때까지 커널의 페이지 캐시에 남아 있을 수 있습니다. 여기가 얼마나 많은 데이터를 손실할 수 있는지를 결정하는 부분입니다:

값 fsync 시점 충돌 시 최대 손실 성능
always 각 쓰기 명령 후 거의 손실 없음 가장 느림
everysec 매초, 백그라운드 스레드에서 약 1초(최대 2초까지 가능) 좋음, 기본값
no OS에 의해 결정됨(보통 약 30초) 수십 초 가장 빠름

everysec의 경우, 이전 fsync가 2초 후에도 완료되지 않으면 (디스크가 느려서), Redis는 지연을 피하기 위해 쓰기 명령을 차단합니다. 따라서 실제 데이터 손실 윈도우는 1초가 아닌 2초가 될 수 있습니다.

또한 두 가지 유형의 장애를 구분해야 합니다. Redis 프로세스만 충돌하면 write()된 데이터는 여전히 페이지 캐시에 있고 커널이 나중에 디스크에 기록합니다. 그러나 전체 시스템이 정전되거나 커널 패닉이 발생하면 아직 fsync되지 않은 부분이 손실됩니다.

3.2. AOF rewrite 및 다중 파트 AOF

AOF는 시간이 지남에 따라 커집니다. 예를 들어, 백만 개의 INCR counter 명령은 단 하나의 SET counter 1000000 명령으로 대체할 수 있습니다. Redis는 파일이 이전 rewrite의 2배가 되고(auto-aof-rewrite-percentage 100) 최소 64MB에 도달하면 AOF를 자동으로 rewrite합니다.

Redis 7부터 AOF는 appendonlydir 디렉토리에 있고 여러 파일로 구성됩니다:

appendonlydir/
├── appendonly.aof.1.base.rdb    # base, aof-use-rdb-preamble 덕분에 RDB 형식
├── appendonly.aof.1.incr.aof    # base 이후 발생한 쓰기 명령
└── appendonly.aof.manifest      # 현재 AOF에 속하는 파일 설명

aof-use-rdb-preamble yes를 활성화하면 base 파일은 RDB 형식으로 기록되어 빠르게 로드됩니다. incr 부분은 여전히 명령 로그이므로 데이터 손실이 적습니다. 이것이 “하이브리드” 모드이며 Redis 4.0부터 기본값입니다.

4. 어떤 모드를 선택해야 할까?

요구사항 권장 구성
순수 캐시, DB에서 재구성 가능한 데이터 모두 비활성화: save "", appendonly no
몇 분의 손실 허용, 작은 백업 필요 RDB만
세션, 장바구니, rate limit은 견고함 필요 AOF everysec + 백업용 RDB
명령 손실 불가(드문 경우) AOF always, 하지만 다른 DB 사용을 고려

대부분의 애플리케이션에 합리적인 선택은 둘 다 활성화하는 것입니다: 손실을 줄이기 위해 AOF everysec을 사용하고, 정기적인 백업 파일을 위해 RDB를 사용합니다. 둘 다 활성화되면 Redis는 AOF가 더 완전하기 때문에 시작 시 AOF를 로드하는 것을 선호합니다.

참고: Redis를 캐시로만 사용하면 영속성을 비활성화하세요. 캐시에 RDB를 활성화하면 fork(), 디스크 I/O 및 RAM을 낭비할 뿐 이점이 없습니다.

5. Spring Boot 애플리케이션에서 영속성 확인

종속성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

다음 클래스는 영속성 상태를 읽고, 스냅샷을 요청하며, 레플리카 확인을 기다립니다:

@Component
public class PersistenceInspector {

    private final StringRedisTemplate template;

    public PersistenceInspector(StringRedisTemplate template) {
        this.template = template;
    }

    public Properties persistenceInfo() {
        return template.execute((RedisCallback<Properties>) conn ->
                conn.serverCommands().info("persistence"));
    }

    public void triggerSnapshot() {
        template.execute((RedisCallback<Object>) conn ->
                conn.execute("BGSAVE", "SCHEDULE".getBytes()));
    }

    public long waitForReplicas(int replicas, long timeoutMs) {
        Long acked = template.execute((RedisCallback<Long>) conn ->
                (Long) conn.execute("WAIT",
                        String.valueOf(replicas).getBytes(),
                        String.valueOf(timeoutMs).getBytes()));
        return acked == null ? 0 : acked;
    }
}

각 메서드 설명:

  • persistenceInfo()는 INFO persistence를 호출하고 aof_enabled, rdb_last_bgsave_status와 같은 필드를 포함하는 Properties를 반환합니다.
  • triggerSnapshot()은 BGSAVE SCHEDULE을 사용합니다. AOF rewrite이 실행 중이면 Redis는 오류를 반환하는 대신 스냅샷을 예약합니다.
  • waitForReplicas()는 WAIT를 호출하고 이전 쓰기 명령을 받은 레플리카 수를 반환합니다. 섹션 6에서 이 명령이 필요한 이유를 설명합니다.

AOF를 활성화했을 때 INFO persistence의 일부 출력:

loading:0
rdb_changes_since_last_save:42
rdb_bgsave_in_progress:0
rdb_last_save_time:1760169600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok

모니터링에 포함하고 값이 ok가 아닐 때 경고를 설정해야 할 세 가지 필드: rdb_last_bgsave_status, aof_last_write_status 및 aof_last_bgrewrite_status. rdb_changes_since_last_save 필드는 RDB에만 의존할 경우 얼마나 많은 변경 사항을 잃을지 알려줍니다.

5.1. Testcontainers를 사용한 테스트

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RedisPersistenceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4"))
            .withExposedPorts(6379)
            .withCommand("redis-server", "--appendonly", "yes", "--appendfsync", "everysec");

    @Autowired
    StringRedisTemplate template;

    @Autowired
    PersistenceInspector inspector;

    @Test
    void aofShouldBeEnabledWithEverySecPolicy() {
        template.opsForValue().set("order:1001", "PAID");

        Properties info = inspector.persistenceInfo();
        List<String> fsync = template.execute((RedisCallback<List<String>>) conn ->
                conn.serverCommands().getConfig("appendfsync")
                        .values().stream().map(Object::toString).toList());

        assertThat(info.getProperty("aof_enabled")).isEqualTo("1");
        assertThat(info.getProperty("aof_last_write_status")).isEqualTo("ok");
        assertThat(fsync).containsExactly("everysec");
    }

    @Test
    void backgroundSnapshotShouldSucceed() throws InterruptedException {
        template.opsForValue().set("customer:42", "Lan");
        long before = Long.parseLong(inspector.persistenceInfo().getProperty("rdb_last_save_time"));

        Thread.sleep(1100); // 새 타임스탬프가 이전 타임스탬프와 다르도록 보장
        inspector.triggerSnapshot();

        Properties info = inspector.persistenceInfo();
        for (int i = 0; i < 50 && Long.parseLong(info.getProperty("rdb_last_save_time")) <= before; i++) {
            Thread.sleep(100);
            info = inspector.persistenceInfo();
        }

        assertThat(info.getProperty("rdb_last_bgsave_status")).isEqualTo("ok");
        assertThat(Long.parseLong(info.getProperty("rdb_last_save_time"))).isGreaterThan(before);
    }
}

첫 번째 테스트는 Redis 7.4를 AOF everysec과 함께 시작하고 구성이 적용되었는지 확인합니다. @ServiceConnection은 spring.data.redis.host/port를 컨테이너로 자동으로 지정하므로 추가 속성을 선언할 필요가 없습니다. 두 번째 테스트는 rdb_last_save_time이 증가할 때까지 최대 5초를 기다린 다음 스냅샷이 성공했는지 확인합니다.

6. 여전히 데이터가 손실되는 경우는?

영속성을 활성화하는 것만으로는 충분하지 않습니다. 다음은 프로덕션에서 가장 일반적인 상황들입니다.

1. 두 스냅샷 사이의 충돌. RDB만 사용하면 rdb_last_save_time 이후에 쓴 모든 것을 잃습니다.

2. fsync 윈도우. AOF everysec의 경우 머신이 내려가면 약 1-2초를 잃습니다. no의 경우 이 수치는 수십 초까지 갈 수 있습니다.

3. 비동기 복제로 인한 페일오버. 마스터가 레플리카에 명령을 수신하기 전에 클라이언트에 OK를 반환합니다. 마스터가 죽고 Sentinel이나 Cluster가 레플리카를 마스터로 승격하면 복제를 못 한 명령은 사라집니다. 마스터가 AOF always라도 마찬가지입니다. WAIT는 이 위험을 줄이는 데 도움이 됩니다:

template.opsForValue().set("payment:7788", "CAPTURED");
long acked = inspector.waitForReplicas(1, 100);
if (acked < 1) {
    log.warn("payment:7788이 레플리카에 의해 아직 확인되지 않았습니다");
}

WAIT 1 100은 최소 1개의 레플리카가 확인될 때까지 최대 100ms를 차단합니다. WAIT는 Redis를 strongly consistent 시스템으로 만들지 않습니다: 레플리카가 명령을 받았지만 아직 fsync하지 않을 수 있습니다. 이를 데이터 손실 확률을 줄이는 방법으로 봐야 하며 보장이 아닙니다.

4. 빈 데이터셋으로 마스터를 다시 시작합니다. 마스터가 영속성을 비활성화했다고 가정하고 자동으로 재시작되어 빈 데이터로 다시 나타납니다. 레플리카는 마스터와 동기화되어 자체 데이터를 완전히 지웁니다. 마스터가 영속성을 활성화하지 않았다면 자동 재시작을 허용하지 마세요.

5. 컨테이너가 볼륨을 마운트하지 않았습니다. Redis는 여전히 dump.rdb와 AOF에 쓰지만 컨테이너의 파일 시스템으로 씁니다. 컨테이너를 삭제하고 다시 만들면 해당 파일이 손실됩니다.

services:
  redis:
    image: redis:7.4
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
volumes:
  redis-data:

Kubernetes에서 동등한 상황은 PersistentVolumeClaim 대신 emptyDir을 사용하는 것입니다.

6. 디스크 풀 또는 fork() 실패. BGSAVE가 실패하고 stop-writes-on-bgsave-error yes가 활성화되면 Redis는 MISCONF 오류로 모든 쓰기 명령을 거부합니다. 이 동작은 데이터를 보호하지만 애플리케이션을 실패하게 합니다. 많은 사람들이 이 옵션을 비활성화함으로써 대응하고, 그 후 Redis는 영속성 없이 계속 실행됩니다.

7. 실수로 FLUSHALL을 실행했습니다. AOF의 경우 FLUSHALL 명령은 incr 파일 끝의 한 줄일 뿐입니다. AOF가 아직 rewrite되지 않았다면 Redis를 중지하고 해당 줄을 삭제한 다음 다시 시작할 수 있습니다. rewrite가 이미 실행되었다면 복구 방법이 없습니다. 따라서 프로덕션에서는 이 명령의 이름을 바꾸거나 ACL로 차단하세요.

8. Eviction. 이것은 영속성 버그가 아니지만 결과는 같습니다. maxmemory-policy allkeys-lru의 경우 Redis는 RAM이 가득 차면 키를 삭제합니다. 손실될 수 없는 데이터는 noeviction이 필요합니다.

참고: Redis를 재정 데이터 또는 주문의 유일한 저장소로 사용하지 마세요. “OK가 반환되었으므로 손실이 없다”는 것을 보장해야 한다면 관계형 데이터베이스나 합의 시스템을 사용하세요. Redis는 캐시 또는 액셀러레이션 레이어 역할을 해야 합니다.

7. 결론

RDB는 작은 백업 파일과 빠른 시작을 제공하지만 몇 분 동안의 데이터를 잃을 수 있습니다. AOF everysec은 손실을 약 1-2초로 줄이지만 추가 I/O가 드는 대신입니다. 하이브리드 모드(aof-use-rdb-preamble)는 둘 다의 장점을 결합합니다. 대부분의 실제 위험은 이 두 메커니즘 밖에 있습니다: 비동기 복제, 마운트되지 않은 볼륨, 가득 찬 디스크, FLUSHALL 및 eviction. INFO persistence의 필드를 모니터링하고, 중요한 쓰기 명령에 WAIT를 사용하며, 섹션 5처럼 Testcontainers로 구성을 테스트하세요.

redispersistencerdbaofspring-boot