복제(Replication) - 가용성과 읽기 분산
왜 복제를 공부하게 됐나
DB가 하나뿐이면 그게 죽었을 때 서비스 전체가 멈춘다. 이 단순한 질문에서 Replication을 공부하게 됐다.
장애 대응 글들을 찾아보면 공통적으로 등장하는 게 있다. "단일 DB 구성이었고, 장애 발생 시 수 시간 동안 서비스가 중단됐다." 그러면 다음 질문이 자연스럽게 따라온다. 그럼 DB를 여러 개 두면 되지 않나? 어떻게 데이터를 같게 유지하지? 마스터가 죽으면 누가 대신하지?
이 질문들을 따라가다 보니, Replication이라는 개념이 나왔다. 마스터 DB의 데이터를 슬레이브 DB로 자동 복제하고, 마스터가 죽으면 슬레이브가 마스터로 승격(Failover)된다. 게다가 읽기 쿼리를 슬레이브로 분산시키면 마스터의 부하도 줄어든다.
처음엔 뭐가 이해가 안 갔나
복제를 처음 접했을 때 가장 혼란스러웠던 부분은 "어떻게 데이터가 동기화되는가?"였습니다. 마스터에 데이터를 쓰면 슬레이브에 자동으로 복사되는 건가? 그럼 네트워크 지연은 어떻게 처리하나?
또 다른 혼란은 "동기 복제 vs 비동기 복제"의 차이였습니다. 동기가 더 안전해 보이는데 왜 비동기를 쓰는 걸까? 성능 때문인가?
그리고 "Failover는 어떻게 자동으로 되는가?"도 궁금했습니다. 마스터가 죽었다는 걸 누가 감지하고, 슬레이브를 마스터로 승격시키는 건 누가 하나?
어떤 포인트에서 이해가 됐나
복제를 이해하는 데 결정적이었던 비유는 "회의록 작성"이었습니다.
회의를 하면서 한 명(마스터)이 회의록을 작성합니다. 다른 사람들(슬레이브)은 그 회의록을 복사해서 가지고 있습니다.
동기 복제 (Synchronous):
- 마스터가 회의록을 쓰면, 모든 슬레이브가 복사를 완료할 때까지 기다림
- 장점: 모두가 항상 최신 내용을 가지고 있음
- 단점: 느림 (모든 사람이 복사할 때까지 회의가 멈춤)
비동기 복제 (Asynchronous):
- 마스터가 회의록을 쓰면 바로 다음 진행, 슬레이브는 나중에 복사
- 장점: 빠름 (회의가 멈추지 않음)
- 단점: 슬레이브가 약간 뒤처질 수 있음 (Replication Lag)
이 비유를 듣자마자 이해가 됐습니다. 아, 그래서 대부분 비동기 복제를 쓰는 거구나. 성능을 위해 약간의 지연을 감수하는 거였습니다.
복제 아키텍처
1. Master-Slave (Primary-Replica)
가장 기본적인 구조입니다.
┌─────────┐
│ Master │ ← 쓰기 (Write)
└────┬────┘
│ Replication
├────────┬────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Slave 1 │ │ Slave 2 │ │ Slave 3 │ ← 읽기 (Read)
└─────────┘ └─────────┘ └─────────┘
특징:
- 쓰기는 마스터에만
- 읽기는 슬레이브로 분산
- 슬레이브는 마스터의 복제본
코드 예시:
// 쓰기: 마스터로
async function createUser(userData) {
return await masterDb.query('INSERT INTO users ...', userData);
}
// 읽기: 슬레이브로 (라운드 로빈)
let slaveIndex = 0;
async function getUser(userId) {
const slave = slaves[slaveIndex % slaves.length];
slaveIndex++;
return await slave.query('SELECT * FROM users WHERE id = ?', [userId]);
}
2. Master-Master (Multi-Master)
여러 마스터가 서로 복제합니다.
┌─────────┐ ←──────→ ┌─────────┐
│ Master1 │ │ Master2 │
└─────────┘ └─────────┘
↑ ↑
│ │
쓰기/읽기 쓰기/읽기
장점:
- 쓰기 부하 분산
- 지역별로 마스터 배치 가능 (지연 감소)
단점:
- 충돌 가능성 (같은 데이터를 동시에 수정)
- 복잡도 증가
충돌 해결:
// Last Write Wins (LWW)
// 타임스탬프가 더 최신인 것을 선택
UPDATE users
SET name = 'Alice', updated_at = '2025-07-08 10:00:00'
WHERE id = 1 AND updated_at < '2025-07-08 10:00:00';
// Version Vector
// 각 마스터의 버전을 추적
{
data: { name: 'Alice' },
version: { master1: 5, master2: 3 }
}
3. Cascading Replication
슬레이브가 다른 슬레이브를 복제합니다.
┌─────────┐
│ Master │
└────┬────┘
│
▼
┌─────────┐
│ Slave 1 │
└────┬────┘
├────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Slave 2 │ │ Slave 3 │
└─────────┘ └─────────┘
장점:
- 마스터 부하 감소
- 지역별 계층 구조 가능
단점:
- Replication Lag 증가
- 장애 전파 위험
4. 리더 없는 복제 (Leaderless)
Amazon DynamoDB, Cassandra가 사용하는 방식입니다. "마스터가 없다"는 것이 핵심입니다. 클라이언트는 쓰기 요청을 여러 노드에 동시에 보냅니다.
핵심 개념: 정족수 (Quorum)
- N: 복제본 수 (예: 3)
- W: 쓰기 성공으로 간주할 최소 노드 수 (예: 2)
- R: 읽기 성공으로 간주할 최소 노드 수 (예: 2)
공식: W + R > N 이면 최신 데이터를 읽을 수 있음이 보장됩니다. (비둘기집 원리)
- 장점: 마스터라는 단일 실패 지점(SPOF)이 없음. 쓰기 가용성이 극대화됨.
- 단점: 충돌 해결을 클라이언트가 해야 할 수도 있음(Read Repair).
심화: 충돌은 어떻게 해결하나요? 동시에 두 명이 같은 데이터를 수정하면 어떻게 될까요?
- LWW (Last Write Wins): 가장 마지막에 쓴 놈이 이긴다. (타임스탬프 기준). 쉽지만 데이터 유실 위험.
- 벡터 시계 (Vector Clock):
[v1, v2]처럼 버전 정보를 다 들고 다닌다. 충돌 시 애플리케이션이 병합(Merge) 로직을 수행해야 함. - CRDTs (Conflict-free Replicated Data Types): 수학적으로 충돌이 안 나게 설계된 자료구조. (예: 구글 독스, 피그마 동시 편집)
복제 방식
1. Statement-based Replication
SQL 문을 그대로 복제합니다.
-- 마스터에서 실행
INSERT INTO users (id, name, created_at)
VALUES (1, 'Alice', NOW());
-- 슬레이브에서도 똑같이 실행
INSERT INTO users (id, name, created_at)
VALUES (1, 'Alice', NOW());
문제점:
NOW(),RAND()같은 비결정적 함수는 결과가 다를 수 있음- 슬레이브에서 실행 시점이 다르면 결과가 달라질 수 있음
2. Row-based Replication
변경된 행의 실제 데이터를 복제합니다.
-- 마스터에서 실행
INSERT INTO users (id, name, created_at)
VALUES (1, 'Alice', '2025-07-08 10:00:00');
-- 슬레이브로 전송되는 데이터
Row: id=1, name='Alice', created_at='2025-07-08 10:00:00'
장점:
- 결정적 (항상 같은 결과)
- 안전함
단점:
- 데이터 크기가 큼 (특히 대량 UPDATE 시)
3. Mixed Replication
상황에 따라 Statement/Row를 선택합니다.
// MySQL 설정
binlog_format = MIXED
// 비결정적 함수 사용 시 → Row-based
// 일반 쿼리 → Statement-based
Replication Lag 처리
슬레이브가 마스터보다 뒤처지는 현상입니다.
문제 상황
// 1. 마스터에 쓰기
await masterDb.query('INSERT INTO posts (id, title) VALUES (1, "Hello")');
// 2. 즉시 슬레이브에서 읽기
const post = await slaveDb.query('SELECT * FROM posts WHERE id = 1');
// 결과: null (아직 복제 안 됨!)
해결책 1: Read-after-Write Consistency
쓰기 직후 읽기는 마스터에서 합니다.
class Database {
private lastWriteTime = 0;
private LAG_THRESHOLD = 1000; // 1초
async write(query, params) {
const result = await masterDb.query(query, params);
this.lastWriteTime = Date.now();
return result;
}
async read(query, params) {
const timeSinceWrite = Date.now() - this.lastWriteTime;
if (timeSinceWrite < this.LAG_THRESHOLD) {
// 최근에 쓰기를 했으면 마스터에서 읽기
return await masterDb.query(query, params);
}
// 충분히 시간이 지났으면 슬레이브에서 읽기
return await slaveDb.query(query, params);
}
}
해결책 2 - Lag 모니터링
슬레이브의 지연을 확인하고, 지연이 크면 마스터로 라우팅합니다.
async function getReplicationLag(slave) {
const result = await slave.query('SHOW SLAVE STATUS');
return result[0].Seconds_Behind_Master;
}
async function read(query, params) {
const lag = await getReplicationLag(slaveDb);
if (lag > 5) { // 5초 이상 지연
console.warn(`Replication lag: ${lag}s, using master`);
return await masterDb.query(query, params);
}
return await slaveDb.query(query, params);
}
해결책 3: Session Consistency
같은 세션 내에서는 일관성을 보장합니다.
class SessionAwareDatabase {
private sessions = new Map<string, number>();
async write(sessionId, query, params) {
const result = await masterDb.query(query, params);
// 이 세션의 마지막 쓰기 시간 기록
this.sessions.set(sessionId, Date.now());
return result;
}
async read(sessionId, query, params) {
const lastWrite = this.sessions.get(sessionId);
if (lastWrite && Date.now() - lastWrite < 1000) {
// 최근에 쓰기를 한 세션은 마스터에서 읽기
return await masterDb.query(query, params);
}
return await slaveDb.query(query, params);
}
}
Failover (장애 조치)
마스터가 죽었을 때 슬레이브를 마스터로 승격시킵니다.
수동 Failover
# 1. 마스터 상태 확인
mysql> SHOW MASTER STATUS;
# 2. 슬레이브를 마스터로 승격
mysql> STOP SLAVE;
mysql> RESET SLAVE ALL;
# 3. 애플리케이션 설정 변경
# master_host: slave1.db.example.com
# 4. 다른 슬레이브들이 새 마스터를 따르도록 설정
mysql> CHANGE MASTER TO MASTER_HOST='slave1.db.example.com';
mysql> START SLAVE;
자동 Failover (MHA, Orchestrator)
// MHA (Master High Availability) 설정
[server1]
hostname=master.db.example.com
port=3306
[server2]
hostname=slave1.db.example.com
port=3306
candidate_master=1
[server3]
hostname=slave2.db.example.com
port=3306
동작 과정:
- MHA가 마스터 헬스체크 (3초마다)
- 마스터 응답 없음 감지 (3회 연속 실패)
- 가장 최신 데이터를 가진 슬레이브 선택
- 선택된 슬레이브를 마스터로 승격
- 다른 슬레이브들이 새 마스터를 따르도록 재설정
- VIP (Virtual IP)를 새 마스터로 이동
- 애플리케이션은 VIP를 사용하므로 변경 불필요
애플리케이션 레벨 Failover
class ResilientDatabase {
private master = 'master.db.example.com';
private slaves = ['slave1.db.example.com', 'slave2.db.example.com'];
private failedHosts = new Set<string>();
async query(sql, params, isWrite = false) {
if (isWrite) {
return await this.writeQuery(sql, params);
}
return await this.readQuery(sql, params);
}
private async writeQuery(sql, params) {
try {
return await this.connect(this.master).query(sql, params);
} catch (error) {
console.error('Master failed, attempting failover...');
// 슬레이브 중 하나를 임시 마스터로 사용
for (const slave of this.slaves) {
try {
const result = await this.connect(slave).query(sql, params);
console.warn(`Failover to ${slave} successful`);
this.master = slave; // 마스터 변경
return result;
} catch (e) {
continue;
}
}
throw new Error('All databases failed');
}
}
private async readQuery(sql, params) {
// 슬레이브 중 하나 선택 (실패한 호스트 제외)
const availableSlaves = this.slaves.filter(s => !this.failedHosts.has(s));
for (const slave of availableSlaves) {
try {
return await this.connect(slave).query(sql, params);
} catch (error) {
this.failedHosts.add(slave);
continue;
}
}
// 모든 슬레이브 실패 시 마스터에서 읽기
return await this.connect(this.master).query(sql, params);
}
}
적용 사례
1. 읽기 부하 분산
// 슬레이브 3대로 읽기 분산
const slaves = [
mysql.createPool({ host: 'slave1.db.example.com' }),
mysql.createPool({ host: 'slave2.db.example.com' }),
mysql.createPool({ host: 'slave3.db.example.com' })
];
let slaveIndex = 0;
async function getUser(userId) {
const slave = slaves[slaveIndex % slaves.length];
slaveIndex++;
return await slave.query('SELECT * FROM users WHERE id = ?', [userId]);
}
// 결과: 읽기 TPS가 3배 증가
2. 지역별 복제
// 한국 사용자 → 한국 슬레이브
// 미국 사용자 → 미국 슬레이브
function getSlaveByRegion(region) {
const regionMap = {
'kr': slaves.kr,
'us': slaves.us,
'eu': slaves.eu
};
return regionMap[region] || slaves.kr;
}
async function getUser(userId, region) {
const slave = getSlaveByRegion(region);
return await slave.query('SELECT * FROM users WHERE id = ?', [userId]);
}
// 결과: 지역별 응답 시간 50% 감소
글로벌 서비스의 핵심: Geo-Replication 단순히 읽기 분산만 하는 게 아닙니다. 재해 복구(DR) 측면에서도 중요합니다. 한국 데이터센터가 지진으로 마비되어도, 미국/유럽 슬레이브를 즉시 마스터로 승격시켜 서비스를 지속할 수 있습니다. Route53 같은 DNS 레벨에서 Latency-based Routing을 걸어주면, 사용자는 알아서 가장 가까운(빠른) DB로 연결됩니다.
3. 분석 쿼리 전용 슬레이브
// 무거운 분석 쿼리는 전용 슬레이브로
const analyticsDb = mysql.createPool({
host: 'analytics-slave.db.example.com',
// 분석용이므로 약간의 지연 허용
});
async function getDailyStats() {
return await analyticsDb.query(`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30
`);
}
// 결과: 마스터 부하 감소, 서비스 영향 없음
한 줄 요약
데이터베이스 복제는 마스터 DB의 데이터를 슬레이브 DB로 자동 복사하여 고가용성과 읽기 성능 향상을 제공하는 기법입니다. 동기/비동기 복제 방식이 있으며, Replication Lag 처리와 Failover 전략이 핵심입니다. 실제로는 읽기 부하 분산, 지역별 복제, 분석 쿼리 분리 등으로 활용합니다.