
복제(Replication): 가용성과 읽기 분산
데이터베이스 복제를 통한 고가용성과 읽기 성능 향상을 경험을 통해 이해한 과정

데이터베이스 복제를 통한 고가용성과 읽기 성능 향상을 경험을 통해 이해한 과정
데이터베이스 커넥션 풀의 개념과 성능 최적화를 경험을 통해 이해한 과정

데이터베이스 트랜잭션의 개념과 ACID 특성을 경험을 통해 이해한 과정

데이터베이스 샤딩의 개념과 대규모 트래픽 처리를 경험을 통해 이해한 과정

벡터 데이터베이스의 동작 원리와 활용 방법을 프로젝트 경험을 통해 이해한 과정

DB가 하나뿐이면 그게 죽었을 때 서비스 전체가 멈춘다. 이 단순한 질문에서 Replication을 공부하게 됐다.
장애 대응 글들을 찾아보면 공통적으로 등장하는 게 있다. "단일 DB 구성이었고, 장애 발생 시 수 시간 동안 서비스가 중단됐다." 그러면 다음 질문이 자연스럽게 따라온다. 그럼 DB를 여러 개 두면 되지 않나? 어떻게 데이터를 같게 유지하지? 마스터가 죽으면 누가 대신하지?
이 질문들을 따라가다 보니, Replication이라는 개념이 나왔다. 마스터 DB의 데이터를 슬레이브 DB로 자동 복제하고, 마스터가 죽으면 슬레이브가 마스터로 승격(Failover)된다. 게다가 읽기 쿼리를 슬레이브로 분산시키면 마스터의 부하도 줄어든다.
복제를 처음 접했을 때 가장 혼란스러웠던 부분은 "어떻게 데이터가 동기화되는가?"였습니다. 마스터에 데이터를 쓰면 슬레이브에 자동으로 복사되는 건가? 그럼 네트워크 지연은 어떻게 처리하나?
또 다른 혼란은 "동기 복제 vs 비동기 복제"의 차이였습니다. 동기가 더 안전해 보이는데 왜 비동기를 쓰는 걸까? 성능 때문인가?
그리고 "Failover는 어떻게 자동으로 되는가?"도 궁금했습니다. 마스터가 죽었다는 걸 누가 감지하고, 슬레이브를 마스터로 승격시키는 건 누가 하나?
복제를 이해하는 데 결정적이었던 비유는 "회의록 작성"이었습니다.
회의를 하면서 한 명(마스터)이 회의록을 작성합니다. 다른 사람들(슬레이브)은 그 회의록을 복사해서 가지고 있습니다.
동기 복제 (Synchronous):이 비유를 듣자마자 이해가 됐습니다. 아, 그래서 대부분 비동기 복제를 쓰는 거구나. 성능을 위해 약간의 지연을 감수하는 거였습니다.
가장 기본적인 구조입니다.
┌─────────┐
│ 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]);
}
여러 마스터가 서로 복제합니다.
┌─────────┐ ←──────→ ┌─────────┐
│ 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 }
}
슬레이브가 다른 슬레이브를 복제합니다.
┌─────────┐
│ Master │
└────┬────┘
│
▼
┌─────────┐
│ Slave 1 │
└────┬────┘
├────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Slave 2 │ │ Slave 3 │
└─────────┘ └─────────┘
장점:
Amazon DynamoDB, Cassandra가 사용하는 방식입니다. "마스터가 없다"는 것이 핵심입니다. 클라이언트는 쓰기 요청을 여러 노드에 동시에 보냅니다.
핵심 개념: 정족수 (Quorum)공식: W + R > N 이면 최신 데이터를 읽을 수 있음이 보장됩니다. (비둘기집 원리)
심화: 충돌은 어떻게 해결하나요? 동시에 두 명이 같은 데이터를 수정하면 어떻게 될까요?
[v1, v2] 처럼 버전 정보를 다 들고 다닌다. 충돌 시 애플리케이션이 병합(Merge) 로직을 수행해야 함.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() 같은 비결정적 함수는 결과가 다를 수 있음변경된 행의 실제 데이터를 복제합니다.
-- 마스터에서 실행
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'
장점:
상황에 따라 Statement/Row를 선택합니다.
// MySQL 설정
binlog_format = MIXED
// 비결정적 함수 사용 시 → Row-based
// 일반 쿼리 → Statement-based
슬레이브가 마스터보다 뒤처지는 현상입니다.
// 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 (아직 복제 안 됨!)
쓰기 직후 읽기는 마스터에서 합니다.
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);
}
}
슬레이브의 지연을 확인하고, 지연이 크면 마스터로 라우팅합니다.
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);
}
같은 세션 내에서는 일관성을 보장합니다.
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);
}
}
마스터가 죽었을 때 슬레이브를 마스터로 승격시킵니다.
# 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;
// 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
동작 과정:
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);
}
}
// 슬레이브 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배 증가
// 한국 사용자 → 한국 슬레이브
// 미국 사용자 → 미국 슬레이브
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로 연결됩니다.
// 무거운 분석 쿼리는 전용 슬레이브로
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 전략이 핵심입니다. 실제로는 읽기 부하 분산, 지역별 복제, 분석 쿼리 분리 등으로 활용합니다.