Replication — 비동기 복제와 데이터 동기화
Redis 서버가 하나뿐인데 장애가 나면 데이터를 전부 잃는 건가요? 여러 대에 복제해둘 수는 없을까요?
개념 정의
Redis Replication 은 마스터(Master)의 데이터를 하나 이상의 레플리카(Replica)에 복제하는 기능입니다. 마스터에서 쓰기를 처리하고 레플리카에서 읽기를 분산하여, 비동기 방식 으로 가용성과 읽기 성능을 높입니다.
왜 필요한가
- **고가용성 **: 마스터 장애 시 레플리카가 승격하여 서비스 지속
- ** 읽기 분산 **: 읽기 트래픽을 여러 레플리카로 분산
- ** 데이터 안전성 **: 여러 서버에 데이터 사본 보관
- ** 무중단 백업 **: 레플리카에서 RDB/AOF 백업 실행 (마스터 부하 없음)
복제 설정
기본 설정
# redis.conf (레플리카 측)
replicaof 192.168.1.100 6379
# 또는 런타임에 설정
127.0.0.1:6380> REPLICAOF 192.168.1.100 6379
OK
# 복제 해제 (독립 마스터로 전환)
127.0.0.1:6380> REPLICAOF NO ONE
OK
인증이 필요한 경우
# 레플리카 설정
masterauth "마스터비밀번호"
masteruser "마스터사용자" # Redis 6.0+ ACL
복제 상태 확인
# 마스터에서
127.0.0.1:6379> INFO replication
# role:master
# connected_slaves:2
# slave0:ip=192.168.1.101,port=6380,state=online,offset=1234,lag=0
# slave1:ip=192.168.1.102,port=6381,state=online,offset=1234,lag=0
# master_replid:abc123...
# master_repl_offset:1234
# 레플리카에서
127.0.0.1:6380> INFO replication
# role:slave
# master_host:192.168.1.100
# master_port:6379
# master_link_status:up
# slave_repl_offset:1234
전체 동기화 (Full Resynchronization)
레플리카가 처음 연결하거나, 부분 동기화가 불가능할 때 실행됩니다.
과정
1. 레플리카 → 마스터: PSYNC <replid> <offset>
(처음이면 PSYNC ? -1)
2. 마스터: FULLRESYNC 응답 + RDB 생성 시작
- 백그라운드에서 fork() → RDB 파일 생성
- RDB 생성 중 들어오는 쓰기는 replication buffer에 저장
3. 마스터 → 레플리카: RDB 파일 전송
- 네트워크를 통해 RDB 파일을 스트리밍
4. 레플리카: RDB 로드
- 기존 데이터 삭제 후 RDB 로드
- (Redis 7.0+ diskless: 직접 메모리로 로드 가능)
5. 마스터 → 레플리카: buffer에 쌓인 명령 전송
- RDB 생성 이후의 쓰기 명령을 전달
6. 이후부터 실시간 명령 전파
Diskless Replication
# 마스터에서 디스크에 RDB를 쓰지 않고 직접 소켓으로 전송
repl-diskless-sync yes
# 여러 레플리카가 동시에 요청하면 모아서 한 번에 처리
repl-diskless-sync-delay 5 # 5초 대기 후 시작
# 레플리카에서도 디스크 없이 직접 메모리로 로드 (Redis 7.0+)
repl-diskless-load on-empty-db # 빈 DB일 때만
# repl-diskless-load swapdb # 새 데이터를 임시 DB에 로드 후 교체
전체 동기화의 비용
- 마스터에서 fork() 발생 → 메모리 사용량 일시적 증가
- RDB 파일 전송 → 네트워크 대역폭 사용
- 레플리카가 RDB 로드 중에는 이전 데이터로 응답하거나 응답 불가
부분 동기화 (Partial Resynchronization)
레플리카가 짧게 끊겼다가 재연결할 때, 전체 동기화 없이 누락된 명령만 받습니다.
repl_backlog (복제 백로그)
# 백로그 크기 설정 (기본 1MB)
repl-backlog-size 256mb
# 백로그 유지 시간 (모든 레플리카 연결 해제 후)
repl-backlog-ttl 3600 # 1시간
부분 동기화 과정
1. 레플리카 재연결: PSYNC <replid> <last_offset>
2. 마스터: 백로그에 해당 offset 이후의 데이터가 있는지 확인
- 있으면: CONTINUE 응답 + 누락 명령만 전송
- 없으면: FULLRESYNC 응답 → 전체 동기화
백로그 크기 계산
필요 크기 = (초당 쓰기 바이트) × (예상 최대 연결 끊김 시간)
예: 초당 10MB 쓰기, 최대 60초 끊김 예상
→ 10MB × 60 = 600MB
→ repl-backlog-size 768mb (여유 있게)
Replication ID
마스터는 고유한 Replication ID를 가집니다.
127.0.0.1:6379> INFO replication
# master_replid: abc123... (현재 ID)
# master_replid2: def456... (이전 마스터의 ID)
# master_repl_offset: 12345
master_replid: 현재 복제 스트림의 IDmaster_replid2: 페일오버 전 이전 마스터의 ID (부분 동기화 지원용)master_repl_offset: 현재까지 전파된 바이트 오프셋
레플리카가 마스터로 승격되면 새 replid를 생성하지만, 이전 마스터의 replid를 replid2에 보관합니다. 이를 통해 다른 레플리카가 새 마스터에 연결할 때 부분 동기화가 가능합니다.
WAIT — 동기 복제에 가깝게
# 최소 2개 레플리카가 확인할 때까지 최대 1000ms 대기
127.0.0.1:6379> SET important-data "value"
OK
127.0.0.1:6379> WAIT 2 1000
(integer) 2 # 2개 레플리카가 확인함
# 타임아웃 시 실제로 확인한 수 반환
127.0.0.1:6379> WAIT 3 500
(integer) 1 # 500ms 내에 1개만 확인됨
WAIT 주의사항
- WAIT는 레플리카의 메모리에 데이터가 도착한 것만 확인합니다 (디스크 저장은 아님)
- 마스터의 쓰기는 이미 완료된 상태이므로, WAIT가 실패해도 마스터에는 데이터가 있습니다
- 모든 쓰기에 WAIT를 걸면 성능이 크게 저하되므로, 중요한 데이터에만 선택적으로 사용합니다
// Java에서 WAIT 사용
public void setWithWait(String key, String value, int numReplicas) {
redisTemplate.opsForValue().set(key, value);
// WAIT 실행
Long confirmed = redisTemplate.execute((RedisCallback<Long>) connection ->
connection.waitForReplication(numReplicas, 1000)
);
if (confirmed < numReplicas) {
log.warn("{}개 레플리카만 확인됨 (요청: {})", confirmed, numReplicas);
}
}
복제 지연 대응
지연 모니터링
# 레플리카의 lag 확인 (초 단위)
127.0.0.1:6379> INFO replication
# slave0:ip=...,lag=0 # lag=0이면 거의 동기화됨
# REPLCONF GETACK으로 정확한 오프셋 확인
# (마스터가 주기적으로 레플리카에 확인)
stale read 방지 전략
// 전략 1: 쓰기 직후에는 마스터에서 읽기
public User getUser(Long userId, boolean afterWrite) {
String key = "user:" + userId;
if (afterWrite) {
// 마스터에서 읽기
return masterRedisTemplate.opsForValue().get(key);
}
// 레플리카에서 읽기
return replicaRedisTemplate.opsForValue().get(key);
}
// 전략 2: WAIT로 동기화 확인 후 레플리카에서 읽기
public void updateAndRead(Long userId, UserDto dto) {
masterRedisTemplate.opsForValue().set("user:" + userId, dto);
masterRedisTemplate.execute((RedisCallback<Long>) conn ->
conn.waitForReplication(1, 500));
// 이제 레플리카에서 읽어도 안전
}
레플리카 읽기 설정
# 레플리카에서 읽기 허용 (기본값: yes)
replica-read-only yes
# stale 데이터 제공 허용 (마스터 연결 끊겼을 때)
replica-serve-stale-data yes # yes: 오래된 데이터라도 응답
# no: LOADING 또는 MASTERDOWN 에러
왜 비동기 복제인가
- Redis는 ** 인메모리 **로 동작하기 때문에 쓰기 자체는 마이크로초 단위로 빠릅니다.
- 그런데 동기 복제를 하면 모든 쓰기마다 레플리카의 응답을 기다려야 하므로, 네트워크 RTT만큼 ** 레이턴시가 급증 **합니다.
- 따라서 Redis는 성능을 유지하기 위해 기본적으로 ** 비동기 복제 **를 채택하고, 꼭 필요한 경우에만 WAIT 명령으로 동기에 가까운 동작을 선택적으로 사용합니다.
복제 토폴로지
체인 복제
Master → Replica1 → Sub-Replica1
→ Sub-Replica2
→ Replica2
레플리카가 또 다른 레플리카의 마스터가 될 수 있습니다. 마스터의 복제 부하를 분산시킬 수 있지만, 지연이 누적됩니다.
min-replicas 설정
# 최소 레플리카 수가 충족되지 않으면 쓰기 거부
min-replicas-to-write 1 # 최소 1개 레플리카가 연결되어야 쓰기 허용
min-replicas-max-lag 10 # 레플리카의 lag가 10초 이내여야 함
이 설정은 마스터가 고립된 상태에서 데이터를 받아들이는 것(split-brain)을 방지합니다.
함정/Pitfall
1. repl_backlog가 너무 작으면 전체 동기화가 반복된다
레플리카가 잠깐 끊겼다 재연결했을 때 부분 동기화를 하려면 백로그에 누락분이 남아있어야 합니다. 쓰기 트래픽이 많은데 백로그가 작으면 금방 덮어씌워져서 매번 전체 동기화(RDB 전송)가 발생합니다. 전체 동기화는 fork() + 네트워크 전송 비용이 크므로, 프로덕션에서는 repl-backlog-size를 반드시 넉넉하게 설정하세요.
2. 레플리카에서 읽기는 stale read를 각오해야 한다
비동기 복제이기 때문에 마스터에 방금 쓴 데이터가 레플리카에 아직 도착하지 않은 상태가 있을 수 있습니다. "쓰기 직후 읽기" 패턴에서 레플리카를 사용하면 이전 값이 반환될 수 있으므로, 이 경우에는 마스터에서 읽거나 WAIT를 활용하세요.
3. 마스터 장애 시 아직 복제되지 않은 쓰기는 유실된다
비동기 복제 구간에서 마스터가 죽으면, 레플리카에 도달하지 못한 쓰기는 사라집니다. 결제/재고처럼 유실이 치명적인 데이터는 WAIT로 최소 1개 레플리카 확인 후 다음 로직을 진행하는 것이 안전합니다.
정리
| 항목 | 핵심 내용 |
|---|---|
| 복제 방식 | 기본 비동기, 성능 우선 설계 |
| 전체 동기화 | 최초 연결/백로그 오버플로 시 RDB 전송 (fork + 네트워크 비용) |
| 부분 동기화 | repl_backlog에서 누락분만 전송, 크기를 넉넉히 설정 |
| WAIT | 중요한 쓰기에 대해 동기 복제에 가까운 동작 가능 |
| stale read | 비동기 복제이므로 레플리카 읽기 시 항상 고려 필요 |