DB 복제와 샤딩 완벽 가이드: Master-Slave부터 분산 데이터베이스까지
DB 복제와 샤딩 완벽 가이드: Master-Slave부터 분산 데이터베이스까지
이전 글에서 인덱스, 트랜잭션, 정규화, SQL 조인 등 단일 데이터베이스의 핵심 개념을 다루었다. 하지만 서비스가 성장하면 단일 DB로는 감당할 수 없는 순간이 반드시 온다. 트래픽이 초당 수만 건이 되면 읽기 성능이 한계에 부딪히고, 데이터가 수억 건을 넘으면 하나의 테이블로는 쿼리 속도를 유지할 수 없다.
이 글에서는 복제(Replication)로 읽기 성능과 가용성을 확보하고, 샤딩(Sharding)으로 데이터를 분산하여 쓰기 성능과 저장 용량을 확장하는 전략을 정리한다. CAP 정리, Failover, Consistent Hashing, Spring Boot에서의 DataSource 라우팅까지 — 면접에서 “트래픽이 늘어나면 DB를 어떻게 확장하나요?”에 대한 완전한 답을 준비한다.
1. 단일 DB의 한계
1.1 왜 DB를 확장해야 하는가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
단일 DB의 병목 지점:
┌──────────────────────────────────────────────────────┐
│ 1. 읽기 병목 (Read Bottleneck) │
│ - 서비스 트래픽의 80~90%는 읽기(SELECT) │
│ - DB 커넥션 풀 한계 (보통 50~200개) │
│ - CPU, 메모리, 디스크 I/O 경합 │
│ │
│ 2. 쓰기 병목 (Write Bottleneck) │
│ - 락 경합 (Row Lock, Table Lock) │
│ - 인덱스 업데이트 비용 │
│ - WAL(Write-Ahead Log) 디스크 동기화 │
│ │
│ 3. 저장 용량 한계 │
│ - 단일 서버의 디스크 한계 (수 TB) │
│ - 테이블 크기 ↑ → 인덱스 크기 ↑ → 쿼리 성능 ↓ │
│ │
│ 4. 가용성 (Availability) │
│ - 단일 장애점 (Single Point of Failure) │
│ - DB 서버 다운 = 전체 서비스 다운 │
└──────────────────────────────────────────────────────┘
1.2 확장 전략: Scale-Up vs Scale-Out
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌──────────────────────────────────────────────────────────┐
│ Scale-Up (수직 확장): │
│ 더 좋은 하드웨어로 교체 │
│ CPU 업그레이드, RAM 증설, SSD 교체 │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ DB 서버 │ ──→ │ 더 강력한 │ │
│ │ 4core/8GB│ │ DB 서버 │ │
│ └──────────┘ │ 32core/128GB │ │
│ └──────────────┘ │
│ 장점: 단순, 애플리케이션 수정 불필요 │
│ 단점: 비용 기하급수적 증가, 물리적 한계 존재 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Scale-Out (수평 확장): │
│ 서버 수를 늘려서 부하를 분산 │
│ │
│ ┌──────────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ DB 서버 │ ──→ │ DB 1 │ │ DB 2 │ │ DB 3 │ │
│ │ (단일) │ └──────┘ └──────┘ └──────┘ │
│ └──────────┘ │
│ 장점: 이론적 무한 확장, 비용 효율적 │
│ 단점: 복잡도 ↑, 데이터 정합성 관리 필요 │
│ │
│ 방법: Replication (복제) + Sharding (샤딩) │
└──────────────────────────────────────────────────────────┘
2. Replication (복제)
2.1 Replication이란
Replication은 동일한 데이터를 여러 DB 서버에 복제하여 유지하는 기술이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
기본 구조 — Master-Slave (Primary-Replica):
┌─────────────────────────────────────────────────────┐
│ │
│ Application │
│ ┌──────────────────────────────────────┐ │
│ │ WRITE (INSERT/UPDATE/DELETE) │ │
│ └───────────────┬──────────────────────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ Master │ ← 쓰기 전용 │
│ │(Primary)│ │
│ └────┬────┘ │
│ │ Replication (복제) │
│ ┌────────┼────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐┌────────┐┌────────┐ │
│ │ Slave 1││ Slave 2││ Slave 3│ ← 읽기 전용 │
│ │(Replica)│(Replica)│(Replica)│ │
│ └────────┘└────────┘└────────┘ │
│ ▲ ▲ ▲ │
│ ┌──────┴────────┴────────┴──────┐ │
│ │ READ (SELECT) │ │
│ └────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
핵심:
- Write는 Master에만
- Read는 Slave에서 분산 처리
- 읽기 트래픽이 80~90%이므로 효과적
2.2 복제 방식
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
① 동기 복제 (Synchronous Replication):
Client → Master → Slave 1, 2, 3 모두 기록 완료 → Client에 응답
┌──────┐ WRITE ┌────────┐ 복제 ┌────────┐
│Client│───────→│ Master │──────→│Slave 1 │ ✓ 완료
│ │ │ │──────→│Slave 2 │ ✓ 완료
│ │ │ │──────→│Slave 3 │ ✓ 완료
│ │←───────│ │ │ │
│ │ 응답 │ │ │ │
└──────┘ └────────┘ └────────┘
장점: 데이터 정합성 100% 보장 (모든 복제본이 동일)
단점: 지연 시간 ↑↑ (가장 느린 Slave에 의존)
Slave 하나라도 다운되면 쓰기 불가
② 비동기 복제 (Asynchronous Replication) — 가장 일반적:
Client → Master 기록 완료 → Client에 응답 → 이후 Slave에 복제
┌──────┐ WRITE ┌────────┐
│Client│───────→│ Master │ ✓ 기록 완료
│ │←───────│ │ 즉시 응답!
└──────┘ │ │
│ │──→ Slave 1 (나중에 복제)
│ │──→ Slave 2 (나중에 복제)
└────────┘
장점: 쓰기 성능 영향 없음, Slave 장애가 Master에 영향 없음
단점: 복제 지연(Replication Lag) 발생!
→ Master에 쓴 데이터가 Slave에서 바로 안 보일 수 있음
③ 반동기 복제 (Semi-Synchronous Replication):
Client → Master → 최소 1개 Slave 기록 완료 → Client에 응답
장점: 비동기보다 안전, 동기보다 빠름
단점: 1개 Slave의 지연에 영향받음
MySQL의 기본 Semi-Sync 방식
2.3 Replication Lag (복제 지연) 문제
면접에서 “Replication을 사용할 때 주의할 점”으로 반드시 출제된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Replication Lag 시나리오:
시간 T=0: 사용자가 프로필 사진 변경 (Master에 WRITE)
시간 T=0: Master 기록 완료 → "변경 완료!" 응답
시간 T=0: 페이지 리다이렉트 → 프로필 페이지 (Slave에서 READ)
시간 T=0: Slave에는 아직 이전 사진! → "어? 왜 안 바뀌었지?"
시간 T=1초: Slave에 복제 완료 → 새로고침하면 정상
┌──────────────────────────────────────────────────────┐
│ "Write 직후 Read에서 최신 데이터가 보이지 않는 문제" │
│ = Read-After-Write Consistency 위반 │
└──────────────────────────────────────────────────────┘
해결 전략:
┌──────────────────────────────────────────────────────┐
│ 1. Write 후 Read는 Master에서 │
│ → 자기가 방금 변경한 데이터를 조회할 때는 Master 사용 │
│ → "내 프로필" 조회 = Master, "다른 사용자 프로필" = Slave│
│ │
│ 2. 일정 시간 동안 Master에서 읽기 │
│ → Write 후 N초 동안은 Master에서 READ │
│ → 쿠키/세션에 마지막 Write 시간 기록 │
│ │
│ 3. Slave의 복제 위치 확인 │
│ → Master의 binlog position과 Slave의 위치 비교 │
│ → 복제가 완료된 Slave에서만 READ │
│ │
│ 4. 동기/반동기 복제로 전환 │
│ → Lag 자체를 제거하지만 쓰기 성능 저하 │
└──────────────────────────────────────────────────────┘
2.4 MySQL의 Replication 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
MySQL Replication 동작 과정:
┌───────────────────────────────────────────────────────┐
│ Master: │
│ 1. 트랜잭션 실행 │
│ 2. Binary Log (binlog)에 변경사항 기록 │
│ → Statement-based: SQL 문장 자체를 기록 │
│ → Row-based: 변경된 행 데이터를 기록 (권장) │
│ → Mixed: 상황에 따라 자동 선택 │
│ │
│ Slave: │
│ 3. I/O Thread: Master의 binlog를 읽어 │
│ Relay Log에 기록 │
│ 4. SQL Thread: Relay Log를 읽어 SQL 재실행 │
│ → Master와 동일한 상태로 수렴 │
└───────────────────────────────────────────────────────┘
┌────────┐ binlog ┌────────────────────────┐
│ Master │ ──────────→ │ Slave │
│ │ │ │
│ binlog │ │ I/O Thread → Relay Log │
│ ↑ │ │ ↓ │
│ 트랜잭션│ │ SQL Thread │
│ │ │ ↓ │
│ Data │ │ Data │
└────────┘ └────────────────────────┘
GTID (Global Transaction ID) — MySQL 5.6+:
┌──────────────────────────────────────────────────┐
│ 각 트랜잭션에 고유 ID 부여 │
│ 형식: server_uuid:transaction_id │
│ 예: 3E11FA47-71CA-11E1-9E33-C80AA9429562:23 │
│ │
│ 장점: │
│ - Failover 시 복제 위치를 쉽게 파악 │
│ - 어떤 트랜잭션이 복제됐는지 정확히 추적 │
│ - Master 변경 시 Slave 재설정이 간단 │
└──────────────────────────────────────────────────┘
2.5 Failover (장애 복구)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Master 장애 시 Failover 과정:
정상 상태:
┌────────┐ ┌────────┐ ┌────────┐
│ Master │────→│Slave 1 │ │Slave 2 │
│ (쓰기) │ │ (읽기) │ │ (읽기) │
└────────┘ └────────┘ └────────┘
Master 장애 발생:
┌────────┐
│ Master │ ✗ 다운!
└────────┘
↓ Failover
┌────────┐ ┌────────┐
│Slave 1 │────→│Slave 2 │
│→ Master│ │ (읽기) │
│승격 (쓰기)│ │ │
└────────┘ └────────┘
Failover 과정:
┌──────────────────────────────────────────────────┐
│ 1. 장애 감지: Health Check / Heartbeat 실패 │
│ 2. Slave 중 하나를 Master로 승격 (Promotion) │
│ → 가장 최신 데이터를 가진 Slave 선택 │
│ 3. 나머지 Slave를 새 Master에 연결 │
│ 4. 애플리케이션의 Write 엔드포인트 변경 │
│ → DNS 변경 또는 Proxy(HAProxy, ProxySQL) 사용 │
│ 5. 기존 Master 복구 시 Slave로 편입 │
└──────────────────────────────────────────────────┘
자동 Failover 도구:
┌──────────────────────────────────────────────────┐
│ MySQL: │
│ - MHA (Master High Availability) │
│ - MySQL InnoDB Cluster + MySQL Router │
│ - Orchestrator │
│ │
│ PostgreSQL: │
│ - Patroni + etcd │
│ - PgBouncer (커넥션 풀링) │
│ │
│ 클라우드: │
│ - AWS RDS Multi-AZ (자동 Failover) │
│ - AWS Aurora (자동 Failover, 15개 Replica) │
└──────────────────────────────────────────────────┘
2.6 Spring Boot에서의 Read/Write 분리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/**
* DataSource 라우팅 — Master/Slave 자동 분기
*/
// 1. 라우팅 키 정의
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT =
new ThreadLocal<>();
public static void setMaster() { CONTEXT.set("master"); }
public static void setSlave() { CONTEXT.set("slave"); }
public static String get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
// 2. AbstractRoutingDataSource 구현
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.get();
}
}
// 3. DataSource 설정
@Configuration
public class DataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource") DataSource slave) {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
targets.put("master", master);
targets.put("slave", slave);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(master);
return routing;
}
@Bean
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://master-host:3306/mydb")
.build();
}
@Bean
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://slave-host:3306/mydb")
.build();
}
}
// 4. AOP로 자동 라우팅
@Aspect
@Component
public class DataSourceRoutingAspect {
@Before("@annotation(org.springframework.transaction.annotation.Transactional)" +
" && @annotation(tx)")
public void setDataSource(Transactional tx) {
if (tx.readOnly()) {
DataSourceContextHolder.setSlave(); // readOnly → Slave
} else {
DataSourceContextHolder.setMaster(); // 쓰기 → Master
}
}
@After("@annotation(org.springframework.transaction.annotation.Transactional)")
public void clearDataSource() {
DataSourceContextHolder.clear();
}
}
// 5. 서비스 코드 — 어노테이션만으로 분기!
@Service
public class UserService {
@Transactional(readOnly = true) // → Slave에서 읽기
public User findById(Long id) {
return userRepository.findById(id).orElseThrow();
}
@Transactional // → Master에서 쓰기
public User save(User user) {
return userRepository.save(user);
}
}
3. Sharding (샤딩)
3.1 Sharding이란
Sharding은 하나의 테이블 데이터를 여러 DB 서버에 수평 분할하여 저장하는 기법이다. Replication이 읽기를 분산한다면, Sharding은 쓰기와 저장 용량을 분산한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Sharding 개념:
단일 DB:
┌──────────────────────────────────────┐
│ orders 테이블 (1억 건) │
│ ┌──────┬──────────┬────────────────┐ │
│ │ id │ user_id │ data │ │
│ │ 1 │ 100 │ ... │ │
│ │ 2 │ 200 │ ... │ │
│ │ ... │ ... │ ... │ │
│ │ 1억 │ 9999 │ ... │ │
│ └──────┴──────────┴────────────────┘ │
└──────────────────────────────────────┘
Sharding 후 (user_id 기준, 3개 샤드):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Shard 1 │ │ Shard 2 │ │ Shard 3 │
│ user_id % 3=0│ │ user_id % 3=1│ │ user_id % 3=2│
│ │ │ │ │ │
│ user 300 │ │ user 100 │ │ user 200 │
│ user 600 │ │ user 400 │ │ user 500 │
│ user 900 │ │ user 700 │ │ user 800 │
│ ... │ │ ... │ │ ... │
│ (~3300만 건) │ │ (~3300만 건) │ │ (~3300만 건) │
└──────────────┘ └──────────────┘ └──────────────┘
각 샤드는 독립적인 DB 서버!
→ 쓰기 부하 분산
→ 저장 용량 분산
→ 쿼리 대상 데이터 감소 → 성능 ↑
3.2 Shard Key (샤드 키) 선택
샤드 키는 데이터를 어떤 샤드에 저장할지 결정하는 기준 컬럼이다. 샤딩 전략의 성패를 가르는 가장 중요한 결정이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
좋은 Shard Key의 조건:
┌──────────────────────────────────────────────────────┐
│ 1. 높은 카디널리티 (Cardinality) │
│ → 값의 종류가 충분히 많아야 균등 분배 가능 │
│ ✓ user_id (수백만 종류) │
│ ✗ gender (2종류) → 2개 샤드만 가능 │
│ │
│ 2. 균등 분포 (Even Distribution) │
│ → 특정 샤드에 데이터가 몰리지 않아야 함 │
│ ✗ created_date → 최근 샤드에만 데이터 집중! │
│ │
│ 3. 쿼리 패턴과 일치 │
│ → 대부분의 쿼리가 샤드 키를 포함해야 함 │
│ WHERE user_id = ? → user_id가 샤드 키이면 1개 샤드만 │
│ WHERE created_at > ? → 여러 샤드를 모두 조회해야! │
│ │
│ 4. 변경되지 않는 값 │
│ → 샤드 키가 변경되면 데이터를 다른 샤드로 이동해야 함 │
│ ✓ user_id (불변) │
│ ✗ email (변경 가능) │
└──────────────────────────────────────────────────────┘
3.3 Sharding 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
① Range Sharding (범위 기반):
Shard Key의 값 범위에 따라 분할
┌────────────────────┐
│ Shard 1 │ user_id 1 ~ 1,000,000
├────────────────────┤
│ Shard 2 │ user_id 1,000,001 ~ 2,000,000
├────────────────────┤
│ Shard 3 │ user_id 2,000,001 ~ 3,000,000
└────────────────────┘
장점: 범위 쿼리 효율적, 구현 단순
단점: Hotspot 문제! (신규 가입자 → 마지막 샤드에만 집중)
② Hash Sharding (해시 기반):
shard_number = hash(shard_key) % shard_count
user_id = 12345
hash(12345) % 3 = 0 → Shard 0
hash(12346) % 3 = 1 → Shard 1
hash(12347) % 3 = 2 → Shard 2
장점: 데이터 균등 분배, Hotspot 방지
단점: 범위 쿼리 비효율 (모든 샤드를 조회해야)
샤드 추가 시 리밸런싱 필요!
③ Directory-Based Sharding (디렉토리 기반):
별도의 매핑 테이블(Lookup Table)에서 샤드 위치를 관리
┌──────────┬─────────┐
│ user_id │ shard │
├──────────┼─────────┤
│ 100 │ shard_1 │
│ 200 │ shard_2 │
│ 300 │ shard_1 │
│ 400 │ shard_3 │
└──────────┴─────────┘
장점: 유연한 분배, 샤드 추가/제거 용이
단점: Lookup Table 자체가 SPOF, 추가 조회 비용
3.4 Consistent Hashing — 리밸런싱 문제 해결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
일반 Hash Sharding의 문제:
shard = hash(key) % 3 (3개 샤드)
샤드 1개 추가 (3 → 4개)하면?
shard = hash(key) % 4 → 대부분의 데이터 위치가 변경!
기존: 변경 후:
hash(key) % 3 hash(key) % 4
───────────── ─────────────
key A → 0 key A → 1 ← 이동!
key B → 1 key B → 2 ← 이동!
key C → 2 key C → 2 ← 유지
key D → 0 key D → 0 ← 유지
key E → 1 key E → 3 ← 이동!
→ 약 75%의 데이터가 재배치! → 대규모 데이터 이동 → 서비스 중단 위험
Consistent Hashing 해결:
해시 링(Hash Ring) 구조:
0 (= 2³²)
│
┌────┴────┐
╱ ╲
Shard A Shard B
│ │
│ ·key1 │
│ ·key2 │
│ │
│ ·key3 │
│ │
Shard C ────────────
│
·key4
동작:
1. 각 샤드를 해시 링 위의 한 점에 배치
2. 각 키를 해시하여 링 위의 한 점에 배치
3. 키에서 시계 방향으로 가장 가까운 샤드에 저장
샤드 추가 시:
┌──────────────────────────────────────────────────┐
│ 새 샤드 D를 링에 추가하면, │
│ 기존 Shard B와 Shard C 사이에 위치 │
│ → 영향받는 키: Shard C에서 Shard D로 이동하는 일부만│
│ → 나머지 키는 이동 없음! │
│ → 재배치 비율: 약 1/N (N = 샤드 수) │
│ │
│ 가상 노드 (Virtual Node): │
│ 각 물리 샤드를 여러 가상 노드로 배치 │
│ → 더 균등한 데이터 분배 │
│ → 물리 노드 장애 시 부하가 나머지에 균등 분산 │
└──────────────────────────────────────────────────┘
3.5 Sharding의 문제점
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
Sharding의 대가 — 반드시 알아야 하는 문제들:
┌──────────────────────────────────────────────────────┐
│ 1. Cross-Shard JOIN 불가 │
│ │
│ Shard 1의 orders와 Shard 2의 products를 JOIN? │
│ → 서로 다른 DB 서버! → 단일 SQL로 불가능! │
│ → 애플리케이션 레벨에서 데이터를 모아 조합해야 함 │
│ │
│ 해결: │
│ - 같이 조회되는 데이터는 같은 샤드에 배치 │
│ - Denormalization (반정규화)으로 JOIN 자체를 제거 │
│ - 글로벌 테이블: 변경이 적은 참조 테이블은 모든 샤드에 복제│
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 2. 분산 트랜잭션의 어려움 │
│ │
│ Shard 1에서 출금, Shard 2에서 입금 → 하나의 트랜잭션?│
│ → 단일 DB의 ACID가 보장되지 않음! │
│ │
│ 해결: │
│ - 2PC (Two-Phase Commit): 느리지만 정합성 보장 │
│ - Saga Pattern: 보상 트랜잭션으로 Eventually Consistent│
│ - 같은 사용자의 데이터를 같은 샤드에 배치 │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 3. Auto Increment ID 문제 │
│ │
│ 각 샤드에서 독립적으로 ID 생성 → ID 충돌! │
│ │
│ 해결: │
│ - UUID: 충돌 없지만 인덱스 성능 저하 (무작위 삽입) │
│ - Snowflake ID: Twitter 방식, 시간 기반 정렬 가능 │
│ ┌─────────────────────────────────────────┐ │
│ │ 41bit │ 10bit │ 12bit │ │
│ │ timestamp│ machine │ sequence │ │
│ │ │ ID │ number │ │
│ └─────────────────────────────────────────┘ │
│ - DB별 시작 값 + 증가폭 설정 │
│ Shard 1: 1, 4, 7, 10, ... (start=1, increment=3) │
│ Shard 2: 2, 5, 8, 11, ... (start=2, increment=3) │
│ Shard 3: 3, 6, 9, 12, ... (start=3, increment=3) │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 4. 리밸런싱 (Rebalancing) │
│ │
│ 특정 샤드에 데이터가 편중되면 재분배 필요 │
│ → 대량 데이터 이동 + 서비스 중단 위험 │
│ │
│ 해결: │
│ - Consistent Hashing으로 최소한의 이동 │
│ - 온라인 리밸런싱 도구 (Vitess, ProxySQL) │
│ - 처음부터 충분한 샤드 수 확보 (과소 분할 방지) │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 5. 집계 쿼리의 어려움 │
│ │
│ SELECT COUNT(*) FROM orders; → 모든 샤드 조회! │
│ ORDER BY + LIMIT → 각 샤드에서 조회 후 병합! │
│ │
│ 해결: │
│ - 집계 전용 DB (OLAP) 별도 구축 │
│ - 실시간 집계 → Redis, Elasticsearch 활용 │
│ - 각 샤드에서 부분 결과 → 애플리케이션에서 병합 │
└──────────────────────────────────────────────────────┘
4. Partitioning (파티셔닝) — 샤딩의 전 단계
4.1 Partitioning vs Sharding
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌──────────────────────────────────────────────────────────┐
│ Partitioning (파티셔닝): │
│ 단일 DB 서버 안에서 테이블을 논리적으로 분할 │
│ → 하나의 서버, 여러 파티션 │
│ → 쿼리 시 필요한 파티션만 스캔 (Partition Pruning) │
│ │
│ Sharding (샤딩): │
│ 여러 DB 서버에 데이터를 물리적으로 분할 │
│ → 여러 서버, 각각 독립적 │
│ → 쓰기/저장 용량 수평 확장 │
└──────────────────────────────────────────────────────────┘
파티셔닝: 샤딩:
┌───────────────────┐ ┌────────┐ ┌────────┐
│ DB 서버 (1대) │ │ DB 1 │ │ DB 2 │
│ ┌───────┬────────┐│ │ (서버1) │ │ (서버2) │
│ │파티션1 │파티션2 ││ │ Shard 1│ │ Shard 2│
│ ├───────┼────────┤│ └────────┘ └────────┘
│ │파티션3 │파티션4 ││
│ └───────┴────────┘│
└───────────────────┘
파티셔닝 먼저, 그래도 부족하면 샤딩!
4.2 파티셔닝 종류
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- Range Partitioning (범위 기반)
CREATE TABLE orders (
id BIGINT,
created_at DATE,
amount DECIMAL(10,2)
)
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- WHERE created_at >= '2026-01-01' → p2026 파티션만 스캔!
-- List Partitioning (목록 기반)
CREATE TABLE orders (
id BIGINT,
country VARCHAR(2),
amount DECIMAL(10,2)
)
PARTITION BY LIST COLUMNS (country) (
PARTITION p_asia VALUES IN ('KR', 'JP', 'CN'),
PARTITION p_us VALUES IN ('US', 'CA'),
PARTITION p_eu VALUES IN ('DE', 'FR', 'GB')
);
-- Hash Partitioning (해시 기반)
CREATE TABLE orders (
id BIGINT,
user_id BIGINT
)
PARTITION BY HASH (user_id)
PARTITIONS 8; -- 8개 파티션으로 균등 분배
5. CAP 정리와 분산 데이터베이스
5.1 CAP 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
CAP 정리 (Brewer's Theorem):
분산 시스템은 다음 세 가지를 동시에 만족할 수 없다.
최대 두 가지만 보장 가능하다.
┌───────────────────────────────────────────────────────┐
│ │
│ Consistency (일관성) │
│ ╱╲ │
│ ╱ ╲ │
│ ╱ ╲ │
│ ╱ CA ╲ CA: 단일 DB (분산 아님) │
│ ╱────────╲ CP: MongoDB, HBase │
│ ╱ ╲ AP: Cassandra, DynamoDB │
│ ╱ CP ╲ │
│ ╱ ╲ │
│ Partition ╱────────────────╲ Availability │
│ Tolerance╱ AP ╲ (가용성) │
│ ╱ ╲ │
│ │
└───────────────────────────────────────────────────────┘
C (Consistency, 일관성):
모든 노드가 동일한 시점에 같은 데이터를 보유
→ 어느 노드를 읽어도 같은 결과
A (Availability, 가용성):
모든 요청이 성공/실패 여부와 관계없이 응답을 받음
→ 노드 일부가 다운돼도 서비스 가능
P (Partition Tolerance, 분할 내성):
네트워크 단절이 발생해도 시스템이 계속 동작
→ 노드 간 통신 불가 상황을 견딤
실무에서의 선택:
┌──────────────────────────────────────────────────┐
│ 네트워크 분할은 불가피하므로 P는 필수! │
│ → 실질적 선택은 CP vs AP │
│ │
│ CP (일관성 우선): │
│ 금융, 결제, 재고 → 잘못된 데이터는 절대 안 됨 │
│ 쓰기 시 모든 노드에 확인 → 느리지만 정확 │
│ │
│ AP (가용성 우선): │
│ SNS 피드, 좋아요, 로그 → 잠깐 불일치 괜찮음 │
│ 일단 응답 → 나중에 동기화 (Eventually Consistent) │
└──────────────────────────────────────────────────┘
5.2 PACELC 정리 — CAP의 확장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PACELC: CAP에 "정상 상태"를 추가
Partition 발생 시 → A(가용성) vs C(일관성) 선택
Else (정상 상태) → L(지연시간) vs C(일관성) 선택
┌────────────────────┬──────────────┬──────────────┐
│ 시스템 │ 장애 시 (PAC) │ 정상 시 (ELC) │
├────────────────────┼──────────────┼──────────────┤
│ MySQL Replication │ PC │ EC │
│ (동기 복제) │ (일관성 선택) │ (일관성 선택) │
├────────────────────┼──────────────┼──────────────┤
│ MySQL Replication │ PA │ EL │
│ (비동기 복제) │ (가용성 선택) │ (지연 최소화) │
├────────────────────┼──────────────┼──────────────┤
│ Cassandra │ PA │ EL │
│ │ (가용성 선택) │ (지연 최소화) │
├────────────────────┼──────────────┼──────────────┤
│ MongoDB │ PC │ EC │
│ │ (일관성 선택) │ (일관성 선택) │
└────────────────────┴──────────────┴──────────────┘
6. 실무 아키텍처 패턴
6.1 확장 단계별 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
서비스 성장에 따른 DB 확장 로드맵:
Stage 1: 단일 DB
┌──────────┐
│ App │──→ │ DB (단일) │
└──────────┘ └──────────┘
→ 쿼리 최적화, 인덱스 튜닝으로 버틸 수 있는 만큼 버틴다
Stage 2: Read Replica 추가
┌──────────┐ ┌────────┐ ┌────────┐
│ App │──→ │ Master │────→│ Slave │
│ │←── │ (쓰기) │ │ (읽기) │
└──────────┘ └────────┘ └────────┘
→ 읽기 부하 분산 (80~90%의 트래픽)
Stage 3: 캐시 레이어 추가
┌──────────┐ ┌────────┐ ┌────────┐
│ App │──→ │ Redis │ │ Master │
│ │ │(캐시) │ │ │
└──────────┘ └────────┘ └────────┘
→ 자주 조회되는 데이터를 캐시하여 DB 부하 감소
Stage 4: 파티셔닝
→ 단일 테이블이 너무 크면 파티셔닝으로 쿼리 성능 유지
Stage 5: 샤딩 (최후의 수단!)
┌──────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ App │──→ │Shard 1 │ │Shard 2 │ │Shard 3 │
│ │ │M → S │ │M → S │ │M → S │
└──────────┘ └────────┘ └────────┘ └────────┘
→ 각 샤드는 자체 Master-Slave 구성
핵심 원칙:
"샤딩은 최후의 수단! 먼저 할 수 있는 최적화를 모두 한 뒤에."
→ 인덱스 → 쿼리 최적화 → 캐시 → Replica → 파티셔닝 → 샤딩
6.2 CQRS (Command Query Responsibility Segregation)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CQRS — 읽기/쓰기 모델 분리:
┌──────────────────────────────────────────────────────┐
│ │
│ Command (쓰기) Query (읽기) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Command API │ │ Query API │ │
│ │ (정규화된 모델)│ │ (비정규화 모델)│ │
│ └──────┬───────┘ └──────┬───────┘ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Write DB │ ──동기──→ │ Read DB │ │
│ │ (RDBMS) │ (이벤트) │ (Elasticsearch│ │
│ │ 정규화 │ │ Redis, etc.) │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 쓰기: 정규화된 RDBMS (데이터 정합성) │
│ 읽기: 비정규화된 조회 최적화 저장소 (성능) │
│ │
│ 장점: 읽기/쓰기 독립적 확장, 각 목적에 최적화 │
│ 단점: 복잡도 ↑, 동기화 지연 (Eventually Consistent) │
└──────────────────────────────────────────────────────┘
7. 면접 빈출 질문 정리
Q1. Replication이란 무엇이며, Master-Slave 구조를 설명하세요.
Replication은 동일한 데이터를 여러 DB 서버에 복제하여 유지하는 기술이다. Master-Slave(Primary-Replica) 구조에서 Master는 쓰기(INSERT/UPDATE/DELETE)를 담당하고, Slave는 Master의 데이터를 복제받아 읽기(SELECT)를 담당한다.
서비스 트래픽의 80~90%가 읽기이므로, Slave를 여러 대 추가하면 읽기 부하를 효과적으로 분산할 수 있다. 또한 Master 장애 시 Slave를 Master로 승격(Failover)하여 가용성을 높인다.
MySQL에서는 Master가 Binary Log에 변경사항을 기록하고, Slave의 I/O Thread가 이를 읽어 Relay Log에 저장한 후, SQL Thread가 Relay Log를 재실행하여 동기화한다.
Q2. Replication Lag 문제와 해결 방법을 설명하세요.
Replication Lag는 비동기 복제에서 Master의 변경이 Slave에 반영되기까지 시간 차이가 발생하는 현상이다. 사용자가 프로필을 수정(Master에 Write)한 직후 프로필 페이지(Slave에서 Read)를 보면 변경이 반영되지 않을 수 있다. 이를 Read-After-Write Consistency 위반이라 한다.
해결 방법: 1) 자기가 방금 수정한 데이터를 조회할 때는 Master에서 읽는다. 2) Write 후 일정 시간(예: 2초) 동안은 Master에서 읽는다. 3) Slave의 복제 위치(GTID)를 확인하여 복제가 완료된 Slave에서만 읽는다. 4) 반동기 복제로 전환하여 최소 1개 Slave에 복제된 후 응답한다.
Spring에서는 @Transactional(readOnly = true)와 AbstractRoutingDataSource를 활용하여 Read는 Slave, Write는 Master로 자동 라우팅한다.
Q3. Sharding이란 무엇이며, Replication과 어떻게 다른가요?
Replication은 같은 데이터를 여러 서버에 복제하여 읽기 부하를 분산한다. 모든 서버가 전체 데이터를 가진다. 쓰기는 여전히 Master 1대에서만 가능하므로 쓰기 성능은 확장되지 않는다.
Sharding은 데이터를 여러 서버에 수평 분할하여 저장한다. 각 샤드는 전체 데이터의 일부만 가진다. 쓰기와 읽기 모두 분산되며, 저장 용량도 확장된다.
Replication은 가용성과 읽기 성능을, Sharding은 쓰기 성능과 저장 용량을 해결한다. 실무에서는 각 샤드에 Replication을 적용(샤드별 Master-Slave)하여 두 장점을 모두 취한다.
Q4. Shard Key를 선택할 때 고려해야 할 점은?
1) 높은 카디널리티: 값의 종류가 충분히 많아야 균등하게 분배할 수 있다. gender(2종류)는 나쁜 Shard Key이다. 2) 균등 분포: 특정 샤드에 데이터가 몰리지 않아야 한다. created_date는 최신 샤드에만 데이터가 집중되므로 좋지 않다. 3) 쿼리 패턴과 일치: 대부분의 쿼리가 Shard Key를 포함해야 단일 샤드에서 처리 가능하다. Shard Key가 없는 쿼리는 모든 샤드를 조회(Scatter-Gather)해야 한다. 4) 불변 값: Shard Key가 변경되면 데이터를 다른 샤드로 이동해야 하므로 변경되지 않는 컬럼을 선택한다.
Q5. Consistent Hashing이란 무엇이며, 왜 필요한가요?
일반 Hash Sharding(hash(key) % N)은 샤드 수(N)가 변하면 대부분의 데이터 위치가 바뀌어 대규모 리밸런싱이 필요하다. 3개에서 4개로 늘리면 약 75%의 데이터가 이동해야 한다.
Consistent Hashing은 해시 링(Hash Ring) 위에 샤드와 키를 배치하고, 각 키를 시계 방향으로 가장 가까운 샤드에 저장하는 방식이다. 샤드를 추가/제거해도 인접한 범위의 키만 이동하므로 재배치 비율이 약 1/N으로 최소화된다.
가상 노드(Virtual Node)를 활용하면 각 물리 샤드를 링 위 여러 점에 배치하여 더 균등한 데이터 분배를 달성하고, 노드 장애 시 부하가 나머지 노드에 균등하게 분산된다. Redis Cluster, DynamoDB, Cassandra 등이 Consistent Hashing을 사용한다.
Q6. CAP 정리를 설명하고, 실무에서 어떻게 적용되나요?
CAP 정리는 분산 시스템에서 Consistency(일관성), Availability(가용성), Partition Tolerance(분할 내성) 세 가지를 동시에 만족할 수 없다는 이론이다.
네트워크 분할(Partition)은 분산 환경에서 불가피하므로 P는 필수이다. 실질적 선택은 CP vs AP이다.
CP(일관성 우선): 네트워크 분할 시 일부 노드의 응답을 거부하더라도 데이터 일관성을 보장한다. 금융, 결제, 재고처럼 잘못된 데이터가 치명적인 시스템에 적합하다. MongoDB, HBase가 CP 성향이다.
AP(가용성 우선): 네트워크 분할 시에도 모든 노드가 응답하되, 일시적 불일치를 허용한다(Eventually Consistent). SNS 피드, 좋아요 수처럼 잠깐의 불일치가 허용되는 시스템에 적합하다. Cassandra, DynamoDB가 AP 성향이다.
Q7. Sharding의 문제점은 무엇이며, 어떻게 해결하나요?
1) Cross-Shard JOIN 불가: 서로 다른 서버의 테이블은 SQL로 조인할 수 없다. 같이 조회되는 데이터를 같은 샤드에 배치하거나, 반정규화로 JOIN을 제거하거나, 변경이 적은 참조 데이터는 모든 샤드에 복제(글로벌 테이블)한다.
2) 분산 트랜잭션: 여러 샤드에 걸친 ACID 트랜잭션이 어렵다. 2PC(Two-Phase Commit) 또는 Saga Pattern(보상 트랜잭션)을 사용하거나, 같은 사용자의 데이터를 같은 샤드에 배치하여 단일 샤드 트랜잭션으로 해결한다.
3) Auto Increment ID 충돌: 각 샤드의 독립적 ID 생성이 충돌한다. Snowflake ID(시간+머신ID+시퀀스), UUID, 또는 DB별 시작값+증가폭 분리로 해결한다.
4) 집계 쿼리: COUNT, SUM 등이 모든 샤드를 조회해야 한다. 집계 전용 DB(OLAP)를 별도 구축하거나 Elasticsearch/Redis로 실시간 집계를 처리한다.
Q8. DB 확장 시 어떤 순서로 접근해야 하나요?
1단계(쿼리 최적화): 인덱스 설계, 쿼리 리라이팅, EXPLAIN 분석. 비용 없이 가장 큰 효과를 얻을 수 있다. 2단계(캐시): Redis 등 캐시 레이어를 추가하여 반복 읽기를 제거한다. 3단계(Read Replica): Master-Slave 복제로 읽기 부하를 분산한다. 80~90%의 읽기 트래픽을 분산하므로 효과적이다. 4단계(파티셔닝): 단일 테이블이 너무 크면 Range/Hash Partitioning으로 쿼리 대상 데이터를 줄인다. 5단계(샤딩): 모든 최적화 후에도 부족하면 최후의 수단으로 샤딩한다.
핵심 원칙: “샤딩은 최후의 수단”. 샤딩은 Cross-Shard JOIN 불가, 분산 트랜잭션, ID 관리 등 복잡도를 크게 높이므로, 그 전에 할 수 있는 최적화를 모두 적용해야 한다.
Q9. Failover란 무엇이며, 어떻게 동작하나요?
Failover는 Master DB 장애 시 Slave를 새 Master로 승격하여 서비스 중단을 최소화하는 과정이다.
과정: 1) Health Check/Heartbeat로 Master 장애 감지. 2) 가장 최신 데이터를 가진 Slave를 Master로 승격(Promotion). 3) 나머지 Slave를 새 Master에 연결. 4) 애플리케이션의 Write 엔드포인트를 새 Master로 변경(DNS 변경 또는 Proxy 사용). 5) 기존 Master 복구 시 Slave로 편입.
자동 Failover 도구: MySQL의 MHA/InnoDB Cluster, PostgreSQL의 Patroni, 클라우드의 AWS RDS Multi-AZ/Aurora 등이 있다. 수동 Failover는 수 분~수십 분 걸리지만, 자동 Failover는 수 초~30초 내에 완료된다.
Q10. CQRS 패턴이란 무엇이며, 언제 사용하나요?
CQRS(Command Query Responsibility Segregation)는 쓰기(Command)와 읽기(Query)의 모델과 저장소를 분리하는 아키텍처 패턴이다.
쓰기는 정규화된 RDBMS에서 데이터 정합성을 보장하고, 읽기는 비정규화된 조회 최적화 저장소(Elasticsearch, Redis, 읽기 전용 DB)에서 빠르게 처리한다. 쓰기 이벤트를 통해 읽기 저장소를 동기화한다.
적합한 경우: 읽기와 쓰기의 부하 비율이 극단적으로 다를 때, 읽기 모델과 쓰기 모델의 구조가 다를 때(예: 쓰기는 정규화, 읽기는 검색 최적화), 읽기와 쓰기를 독립적으로 확장해야 할 때. 단점은 복잡도 증가와 읽기 저장소의 동기화 지연(Eventually Consistent)이다.
마무리
DB 확장은 단순한 서버 추가가 아니라, 정합성, 가용성, 성능 사이의 트레이드오프를 이해하는 것이다.
- Replication: 읽기 분산 + 가용성 확보. Replication Lag을 어떻게 관리할지가 핵심.
- Sharding: 쓰기 분산 + 저장 용량 확장. Shard Key 선택과 Cross-Shard 문제가 핵심.
- CAP 정리: CP vs AP, 서비스 특성에 맞는 선택이 필요.
- 확장 순서: 쿼리 최적화 → 캐시 → Replica → 파티셔닝 → 샤딩.
면접에서 “트래픽이 늘어나면 어떻게 하나요?”라는 질문에, 단계별 전략과 각 단계의 트레이드오프를 함께 설명할 수 있어야 한다.