Kim-Baek 개발자 이야기

DB 복제(Replication)와 샤딩(Sharding) - 확장의 두 가지 길 본문

컴퓨터 공학/DB

DB 복제(Replication)와 샤딩(Sharding) - 확장의 두 가지 길

김백개발자 2026. 1. 10. 15:55
반응형

서버 한 대로는 한계였다

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 생성
  • ✅ 크로스 샤드 쿼리 최소화

 

 

반응형
Comments