| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- springboot
- 스프링
- 카카오
- 알고리즘
- 클린아키텍처
- k8s
- kubernetes
- 이펙티브자바
- effectivejava
- 예제로 배우는 스프링 입문
- 이펙티브 자바
- Sort
- java
- JavaScript
- 자바스크립트
- 엘라스틱서치
- Spring
- 오블완
- ElasticSearch
- 티스토리챌린지
- Effective Java 3
- 데이터베이스
- 알고리즘정렬
- Kotlin
- 스프링핵심원리
- 김영한
- 스프링부트
- 자바
- 스프링 핵심원리
- Effective Java
- Today
- Total
Kim-Baek 개발자 이야기
DB 복제(Replication)와 샤딩(Sharding) - 확장의 두 가지 길 본문
서버 한 대로는 한계였다
Court Alarm이 2년째 운영되면서 사용자가 급증했다.
월간 활성 사용자: 10만 명
일일 예약: 5,000건
동시 접속: 1,000명
MySQL 서버 하나로 버티고 있었는데, 점점 한계가 보이기 시작했다.
CPU: 90% (쿼리 처리)
메모리: 85% (버퍼 풀)
디스크 I/O: 포화 (읽기/쓰기)
응답 시간: 2초 (평소 50ms)
특히 새벽 6시, 예약 오픈 시간에는 서버가 거의 죽었다.
두 가지 선택지가 있었다:
1. Scale-Up (수직 확장)
- 서버 스펙 업그레이드
- CPU, RAM, SSD 증설
- 한계: 비용 급증, 물리적 한계
2. Scale-Out (수평 확장)
- 서버 추가
- 읽기/쓰기 분산
- 무한 확장 가능
당연히 Scale-Out을 선택했다. 그리고 두 가지 기법을 적용했다:
- Replication: 읽기 부하 분산
- Sharding: 쓰기 부하 분산
Replication (복제)
기본 개념
Master-Slave 구조:
Master (쓰기)
↓
Binary Log 전송
↓
┌────────┴────────┐
↓ ↓
Slave 1 Slave 2
(읽기) (읽기)
동작 방식:
1. Master에 쓰기
INSERT INTO reservations ...
2. Binary Log 기록
[Log] INSERT reservations id=1001 ...
3. Slave가 Log 읽기
Slave: Binary Log 요청
4. Slave에서 재실행
Slave 1: INSERT 실행
Slave 2: INSERT 실행
5. 읽기는 Slave에서
SELECT * FROM reservations ...
MySQL 설정
Master (my.cnf):
[mysqld]
server-id = 1
log-bin = /var/log/mysql/mysql-bin
binlog-format = ROW
Slave (my.cnf):
[mysqld]
server-id = 2
relay-log = /var/log/mysql/relay-bin
read-only = 1
Replication 설정:
-- Master에서 복제 계정 생성
CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
-- Master 상태 확인
SHOW MASTER STATUS;
-- File: mysql-bin.000001, Position: 154
-- Slave에서 연결
CHANGE MASTER TO
MASTER_HOST='master-host',
MASTER_USER='repl',
MASTER_PASSWORD='password',
MASTER_LOG_FILE='mysql-bin.000001',
MASTER_LOG_POS=154;
START SLAVE;
-- Slave 상태 확인
SHOW SLAVE STATUS\G
-- Slave_IO_Running: Yes
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 0
Spring 설정
DataSource 분리:
@Configuration
class DataSourceConfig {
@Bean
@Primary
fun masterDataSource(): DataSource {
return DataSourceBuilder.create()
.url("jdbc:mysql://master:3306/court_alarm")
.username("app")
.password("password")
.build()
}
@Bean
fun slaveDataSource(): DataSource {
return DataSourceBuilder.create()
.url("jdbc:mysql://slave:3306/court_alarm")
.username("app")
.password("password")
.build()
}
@Bean
fun routingDataSource(
@Qualifier("masterDataSource") master: DataSource,
@Qualifier("slaveDataSource") slave: DataSource
): DataSource {
val routing = ReplicationRoutingDataSource()
val sources = mapOf(
"master" to master,
"slave" to slave
)
routing.setTargetDataSources(sources as Map<Any, Any>)
routing.setDefaultTargetDataSource(master)
return routing
}
}
라우팅 로직:
class ReplicationRoutingDataSource : AbstractRoutingDataSource() {
override fun determineCurrentLookupKey(): String {
val isReadOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly()
return if (isReadOnly) "slave" else "master"
}
}
사용:
// 쓰기 → Master
@Transactional
fun createReservation(request: Request): Reservation {
return reservationRepository.save(...)
}
// 읽기 → Slave
@Transactional(readOnly = true)
fun getReservations(userId: Long): List<Reservation> {
return reservationRepository.findByUserId(userId)
}
성능 개선
Before (단일 서버):
총 쿼리: 1,000 req/s
- 읽기: 800 req/s (80%)
- 쓰기: 200 req/s (20%)
CPU: 90%
응답: 2초
After (Master + Slave 2대):
Master: 200 req/s (쓰기만)
Slave 1: 400 req/s (읽기)
Slave 2: 400 req/s (읽기)
Master CPU: 30%
Slave CPU: 50%
응답: 100ms (20배 개선)
Replication Lag
문제:
// 1. Master에 쓰기
reservationRepository.save(reservation) // id=1001
// 2. 즉시 Slave에서 읽기
@Transactional(readOnly = true)
fun getReservation(id: Long) {
reservationRepository.findById(1001) // null! (아직 복제 안 됨)
}
원인: Slave가 Master보다 뒤처짐 (Lag)
모니터링:
-- Slave에서 확인
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master: 2 ← 2초 지연
해결 방법:
// 1. 쓰기 후 읽기는 Master에서
@Transactional // readOnly = false
fun createAndReturn(request: Request): Reservation {
val reservation = reservationRepository.save(...)
return reservationRepository.findById(reservation.id) // Master
}
// 2. Lag 허용 가능한 경우만 Slave
@Transactional(readOnly = true)
fun getReservationList(): List<Reservation> {
// 목록은 1~2초 지연 허용
return reservationRepository.findAll() // Slave
}
Sharding (샤딩)
기본 개념
데이터를 여러 DB로 분산:
Shard 1 Shard 2 Shard 3
user_id: 1~100만 user_id: 100만~200만 user_id: 200만~300만
┌────────────┐ ┌────────────┐ ┌────────────┐
│ user_id=1 │ │ user_id= │ │ user_id= │
│ user_id=2 │ │ 1000001 │ │ 2000001 │
│ ... │ │ ... │ │ ... │
└────────────┘ └────────────┘ └────────────┘
Sharding 전략
1. Range-Based (범위 기반)
fun getShardKey(userId: Long): Int {
return when {
userId <= 1_000_000 -> 1
userId <= 2_000_000 -> 2
else -> 3
}
}
장점: 구현 간단, 범위 쿼리 쉬움
단점: 데이터 편향 (신규 사용자가 몰림)
2. Hash-Based (해시 기반)
fun getShardKey(userId: Long): Int {
return (userId % 3) + 1
}
장점: 균등 분산
단점: 범위 쿼리 어려움, 샤드 추가 시 재분배
3. Directory-Based (디렉토리 기반)
-- 샤드 매핑 테이블
CREATE TABLE shard_map (
user_id BIGINT PRIMARY KEY,
shard_id INT NOT NULL
);
장점: 유연함
단점: 매핑 테이블 병목
Court Alarm 구현
Hash-Based 선택:
@Configuration
class ShardingConfig {
@Bean
fun shardDataSources(): Map<Int, DataSource> {
return mapOf(
1 to createDataSource("shard1:3306"),
2 to createDataSource("shard2:3306"),
3 to createDataSource("shard3:3306")
)
}
private fun createDataSource(host: String): DataSource {
return DataSourceBuilder.create()
.url("jdbc:mysql://$host/court_alarm")
.username("app")
.password("password")
.build()
}
}
class ShardingService(
private val shardDataSources: Map<Int, DataSource>
) {
fun getShardKey(userId: Long): Int {
return ((userId % 3) + 1).toInt()
}
fun getDataSource(userId: Long): DataSource {
val shardKey = getShardKey(userId)
return shardDataSources[shardKey]!!
}
fun <T> executeOnShard(
userId: Long,
operation: (JdbcTemplate) -> T
): T {
val dataSource = getDataSource(userId)
val jdbcTemplate = JdbcTemplate(dataSource)
return operation(jdbcTemplate)
}
}
사용:
@Service
class ReservationService(
private val shardingService: ShardingService
) {
fun createReservation(
userId: Long,
request: Request
): Reservation {
return shardingService.executeOnShard(userId) { jdbc ->
jdbc.update(
"""
INSERT INTO reservations
(user_id, court_id, date, time)
VALUES (?, ?, ?, ?)
""",
userId, request.courtId, request.date, request.time
)
Reservation(...)
}
}
fun getUserReservations(userId: Long): List<Reservation> {
return shardingService.executeOnShard(userId) { jdbc ->
jdbc.query(
"SELECT * FROM reservations WHERE user_id = ?",
{ rs, _ -> mapToReservation(rs) },
userId
)
}
}
}
Sharding의 도전과제
1. 트랜잭션
// ❌ 불가능: 여러 샤드에 걸친 트랜잭션
@Transactional
fun transferReservation(fromUserId: Long, toUserId: Long) {
// fromUserId는 Shard 1
// toUserId는 Shard 2
// 2PC 필요하지만 복잡함
}
해결: 애플리케이션 레벨에서 보상 트랜잭션
2. JOIN 불가
// ❌ 불가능: 샤드 간 JOIN
SELECT r.*, u.name
FROM reservations r
JOIN users u ON r.user_id = u.id
-- reservations와 users가 다른 샤드에 있을 수 있음
해결: 애플리케이션에서 조합
fun getReservationWithUser(reservationId: Long): ReservationDto {
// 1. 예약 조회
val reservation = getReservation(reservationId)
// 2. 사용자 조회 (다른 샤드 가능)
val user = getUser(reservation.userId)
// 3. 조합
return ReservationDto(reservation, user)
}
3. Global ID
// ❌ AUTO_INCREMENT는 샤드별로 중복
// Shard 1: id=1, 2, 3, ...
// Shard 2: id=1, 2, 3, ...
// ✅ Snowflake ID 사용
class SnowflakeIdGenerator(private val shardId: Int) {
fun generateId(): Long {
val timestamp = System.currentTimeMillis() - EPOCH
val sequence = getNextSequence()
return (timestamp shl 22) or
(shardId.toLong() shl 12) or
sequence
// [41bit: timestamp][10bit: shard][12bit: sequence]
}
}
성능 개선
Before (단일 DB):
사용자: 300만 명
테이블 크기: 50GB
쓰기 TPS: 200
조회 속도: 2초
After (3개 샤드):
샤드당 사용자: 100만 명
샤드당 크기: 17GB
쓰기 TPS: 600 (샤드당 200)
조회 속도: 200ms (10배 개선)
Replication + Sharding 조합
최종 아키텍처:
App Server
↓
┌─────────┴─────────┐
↓ ↓
Shard 1 Shard 2
┌────────┐ ┌────────┐
│ Master │ │ Master │
└───┬────┘ └───┬────┘
↓ ↓
┌───┴───┐ ┌───┴───┐
↓ ↓ ↓ ↓
Slave1 Slave2 Slave1 Slave2
라우팅 로직:
fun executeQuery(
userId: Long,
readOnly: Boolean,
operation: (JdbcTemplate) -> Unit
) {
// 1. 샤드 선택
val shardKey = getShardKey(userId)
// 2. Master/Slave 선택
val dataSource = if (readOnly) {
getSlaveDataSource(shardKey)
} else {
getMasterDataSource(shardKey)
}
// 3. 실행
val jdbcTemplate = JdbcTemplate(dataSource)
operation(jdbcTemplate)
}
결과:
총 사용자: 300만
샤드: 3개
샤드당 Master: 1개
샤드당 Slave: 2개
총 DB: 9대
쓰기 TPS: 600 (샤드당 200)
읽기 TPS: 3,600 (샤드당 1,200)
응답 시간: 50ms
면접에서 이렇게 답하자
1단계: 개념 설명 (20초)
"Replication은 Master-Slave 구조로 읽기 부하를 분산하고, Sharding은 데이터를 여러 DB로 나눠 쓰기 부하를 분산합니다. Replication은 같은 데이터를 복제하지만, Sharding은 다른 데이터를 저장합니다."
2단계: 구현 방법 (40초)
"Replication은 Binary Log로 Master의 변경을 Slave에 전달합니다. Spring에서는 readOnly 트랜잭션을 Slave로 라우팅합니다. Sharding은 user_id 해시로 샤드를 선택하며, 각 샤드가 독립적인 DB입니다."
3단계: 실무 경험 (1분 30초)
"Court Alarm에서 단일 DB가 CPU 90%, 응답 2초로 한계에 도달했습니다. Master-Slave Replication을 도입해 읽기의 80%를 Slave 2대로 분산했고, Master CPU가 30%로 낮아지며 응답이 100ms로 20배 개선됐습니다."
"사용자 300만 명을 3개 샤드로 분산했습니다. user_id 해시로 샤드를 선택하며, 샤드당 100만 명씩 균등 분배됐습니다. 테이블 크기가 50GB에서 17GB로 줄고, 쓰기 TPS가 200에서 600으로 3배 증가했습니다."
핵심 팁
숫자로 말하기:
- ❌ "Replication으로 빨라졌어요"
- ✅ "CPU 90% → 30%, 응답 2초 → 100ms, 읽기 TPS 800 → 2,400"
도전과제 언급:
- Replication Lag: 쓰기 후 읽기는 Master
- Sharding: JOIN 불가, 애플리케이션에서 조합
- Global ID: Snowflake ID 사용
정리
Scale-Out은 무한 확장의 열쇠다.
Replication:
- Master: 쓰기
- Slave: 읽기
- Binary Log로 복제
- 읽기 부하 분산
Sharding:
- 데이터 분할
- Hash/Range/Directory 방식
- 쓰기 부하 분산
- JOIN 제약
Court Alarm 결과:
단일 DB → Master + Slave 2 + Shard 3
사용자: 300만
DB: 9대 (Master 3 + Slave 6)
쓰기 TPS: 200 → 600
읽기 TPS: 800 → 3,600
응답: 2초 → 50ms
체크리스트:
- ✅ Replication Lag 모니터링
- ✅ Slave 장애 대응
- ✅ Sharding Key 설계
- ✅ Global ID 생성
- ✅ 크로스 샤드 쿼리 최소화
'컴퓨터 공학 > DB' 카테고리의 다른 글
| DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프 (0) | 2026.01.10 |
|---|---|
| DB 백업 및 복구 전략 - 데이터 손실 제로를 향한 여정 (1) | 2026.01.09 |
| NoSQL 특징 - 언제, 왜 사용하는가 (0) | 2026.01.09 |
| 데이터베이스 - RDBMS 특징 (1) | 2026.01.09 |
| MongoDB에서 Time Series 기능을 사용하지 않고 일반 컬렉션을 활용하는 것과의 차이점 (0) | 2025.01.21 |
