🗄️ 데이터베이스

샤딩

Sharding

대규모 데이터를 여러 데이터베이스 서버에 수평 분산하는 기법입니다. 샤드 키(Shard Key)를 기준으로 데이터를 분할하여 단일 서버의 한계를 극복하고 확장성과 성능을 향상시킵니다.

📖 상세 설명

샤딩(Sharding)은 하나의 거대한 데이터베이스를 여러 개의 작은 조각(샤드)으로 나누어 여러 서버에 분산 저장하는 수평 확장(Horizontal Scaling) 기법입니다. 각 샤드는 독립적인 데이터베이스로 동작하며, 전체 데이터의 일부만 담당합니다.

샤드 키(Shard Key)는 데이터를 어느 샤드에 저장할지 결정하는 기준 컬럼입니다. 예를 들어 user_id를 샤드 키로 사용하면, user_id % 4 연산으로 4개의 샤드 중 하나를 선택합니다. 좋은 샤드 키는 데이터를 균등하게 분산시키고 핫스팟을 방지해야 합니다.

샤딩 전략에는 여러 가지가 있습니다. Range Sharding은 값의 범위로 분할(예: A-M, N-Z), Hash Sharding은 해시 함수로 균등 분산, Directory Sharding은 별도의 매핑 테이블을 사용합니다. 각 전략은 장단점이 있어 데이터 특성에 맞게 선택해야 합니다.

샤딩의 가장 큰 장점은 이론적으로 무한한 확장성입니다. 샤드를 추가하면 저장 용량과 처리량이 선형으로 증가합니다. 또한 각 샤드가 독립적이므로 한 샤드의 장애가 전체 시스템에 영향을 주지 않습니다.

반면 단점도 있습니다. 샤드 간 조인이 어렵고, 트랜잭션 관리가 복잡해지며, 리샤딩(Resharding) 시 대규모 데이터 마이그레이션이 필요합니다. MongoDB, Vitess(MySQL), Citus(PostgreSQL) 등의 도구가 샤딩 복잡성을 줄여줍니다.

💻 코드 예제

# 애플리케이션 레벨 샤딩 예제 - Python
import hashlib
from typing import Dict, Any, List
import psycopg2

class ShardRouter:
    """샤드 라우터 - 데이터를 적절한 샤드로 라우팅"""

    def __init__(self, shard_configs: List[Dict[str, str]]):
        """
        Args:
            shard_configs: 샤드별 연결 정보 리스트
            [{"host": "shard1.db.com", "dbname": "app_db"}, ...]
        """
        self.shards = []
        self.num_shards = len(shard_configs)

        for config in shard_configs:
            conn = psycopg2.connect(**config)
            self.shards.append(conn)

    def get_shard_id(self, shard_key: str) -> int:
        """
        해시 기반 샤드 ID 결정
        일관된 해싱으로 같은 키는 항상 같은 샤드로
        """
        hash_value = int(hashlib.md5(str(shard_key).encode()).hexdigest(), 16)
        return hash_value % self.num_shards

    def get_connection(self, shard_key: str):
        """샤드 키에 해당하는 DB 커넥션 반환"""
        shard_id = self.get_shard_id(shard_key)
        return self.shards[shard_id]

    def execute_on_shard(self, shard_key: str, query: str, params: tuple = None):
        """특정 샤드에서 쿼리 실행"""
        conn = self.get_connection(shard_key)
        with conn.cursor() as cur:
            cur.execute(query, params)
            conn.commit()
            return cur.fetchall() if cur.description else None

    def execute_on_all_shards(self, query: str, params: tuple = None) -> List:
        """모든 샤드에서 쿼리 실행 (집계 등)"""
        results = []
        for shard in self.shards:
            with shard.cursor() as cur:
                cur.execute(query, params)
                if cur.description:
                    results.extend(cur.fetchall())
        return results


class UserService:
    """사용자 서비스 - 샤딩 적용"""

    def __init__(self, router: ShardRouter):
        self.router = router

    def create_user(self, user_id: str, name: str, email: str):
        """
        사용자 생성 - user_id가 샤드 키
        같은 user_id는 항상 같은 샤드에 저장됨
        """
        query = """
            INSERT INTO users (user_id, name, email, created_at)
            VALUES (%s, %s, %s, NOW())
        """
        self.router.execute_on_shard(user_id, query, (user_id, name, email))
        print(f"User {user_id} created on shard {self.router.get_shard_id(user_id)}")

    def get_user(self, user_id: str) -> Dict[str, Any]:
        """사용자 조회 - 샤드 키로 정확한 샤드 접근"""
        query = "SELECT * FROM users WHERE user_id = %s"
        result = self.router.execute_on_shard(user_id, query, (user_id,))
        return result[0] if result else None

    def get_user_count(self) -> int:
        """전체 사용자 수 - 모든 샤드에서 집계"""
        query = "SELECT COUNT(*) FROM users"
        results = self.router.execute_on_all_shards(query)
        return sum(row[0] for row in results)


# 일관된 해싱 (Consistent Hashing) 구현
class ConsistentHashRing:
    """
    일관된 해싱 - 샤드 추가/제거 시 최소한의 데이터 이동
    """

    def __init__(self, nodes: List[str], virtual_nodes: int = 150):
        self.ring = {}
        self.sorted_keys = []
        self.virtual_nodes = virtual_nodes

        for node in nodes:
            self.add_node(node)

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node: str):
        """노드 추가 - 가상 노드로 균등 분산"""
        for i in range(self.virtual_nodes):
            virtual_key = f"{node}:{i}"
            hash_key = self._hash(virtual_key)
            self.ring[hash_key] = node
            self.sorted_keys.append(hash_key)
        self.sorted_keys.sort()

    def remove_node(self, node: str):
        """노드 제거"""
        for i in range(self.virtual_nodes):
            virtual_key = f"{node}:{i}"
            hash_key = self._hash(virtual_key)
            del self.ring[hash_key]
            self.sorted_keys.remove(hash_key)

    def get_node(self, key: str) -> str:
        """키에 해당하는 노드 찾기"""
        if not self.ring:
            return None

        hash_key = self._hash(key)

        # 이진 탐색으로 적절한 노드 찾기
        for ring_key in self.sorted_keys:
            if hash_key <= ring_key:
                return self.ring[ring_key]

        # 링의 처음으로 순환
        return self.ring[self.sorted_keys[0]]


# 사용 예시
if __name__ == "__main__":
    # 4개 샤드 설정
    shard_configs = [
        {"host": "shard1.db.com", "dbname": "app", "user": "app"},
        {"host": "shard2.db.com", "dbname": "app", "user": "app"},
        {"host": "shard3.db.com", "dbname": "app", "user": "app"},
        {"host": "shard4.db.com", "dbname": "app", "user": "app"},
    ]

    router = ShardRouter(shard_configs)
    user_service = UserService(router)

    # 사용자 생성 - 자동으로 적절한 샤드에 저장
    user_service.create_user("user_001", "Alice", "alice@example.com")
    user_service.create_user("user_002", "Bob", "bob@example.com")
// MongoDB 샤딩 예제 - Node.js
const { MongoClient } = require('mongodb');

/**
 * MongoDB 샤드 클러스터 연결 및 사용
 * mongos (라우터)에 연결하면 자동으로 샤딩 처리됨
 */
class ShardedMongoService {
    constructor(mongosUri) {
        // mongos 라우터에 연결
        this.client = new MongoClient(mongosUri);
        this.db = null;
    }

    async connect() {
        await this.client.connect();
        this.db = this.client.db('ecommerce');
        console.log('Connected to sharded MongoDB cluster');
    }

    /**
     * 샤드 컬렉션 설정
     * 실제로는 mongosh에서 설정하지만, 예시로 포함
     */
    async setupSharding() {
        const adminDb = this.client.db('admin');

        // 데이터베이스 샤딩 활성화
        await adminDb.command({ enableSharding: 'ecommerce' });

        // 컬렉션 샤딩 - user_id를 해시 샤드 키로
        await adminDb.command({
            shardCollection: 'ecommerce.orders',
            key: { user_id: 'hashed' }  // 해시 샤딩
        });

        // Range 샤딩 예시
        await adminDb.command({
            shardCollection: 'ecommerce.products',
            key: { category: 1, product_id: 1 }  // 복합 샤드 키
        });
    }

    /**
     * 주문 생성 - user_id 기반으로 자동 샤드 라우팅
     */
    async createOrder(userId, items, totalAmount) {
        const order = {
            user_id: userId,  // 샤드 키
            items,
            total_amount: totalAmount,
            status: 'pending',
            created_at: new Date()
        };

        // mongos가 user_id 해시값으로 적절한 샤드 선택
        const result = await this.db.collection('orders').insertOne(order);
        console.log(`Order created: ${result.insertedId}`);
        return result.insertedId;
    }

    /**
     * 사용자의 모든 주문 조회
     * 샤드 키(user_id) 포함 쿼리 - 단일 샤드만 접근 (효율적)
     */
    async getOrdersByUser(userId) {
        // user_id가 샤드 키이므로 targeted query
        const orders = await this.db.collection('orders')
            .find({ user_id: userId })
            .sort({ created_at: -1 })
            .toArray();

        console.log(`Found ${orders.length} orders for user ${userId}`);
        return orders;
    }

    /**
     * 날짜 범위 주문 조회
     * 샤드 키 없는 쿼리 - 모든 샤드 스캔 (scatter-gather)
     */
    async getOrdersByDateRange(startDate, endDate) {
        // 샤드 키 없음 - 모든 샤드에서 검색 (비효율적)
        const orders = await this.db.collection('orders')
            .find({
                created_at: {
                    $gte: startDate,
                    $lte: endDate
                }
            })
            .toArray();

        console.log(`Scatter-gather query: ${orders.length} orders found`);
        return orders;
    }

    /**
     * 샤드 분산 상태 확인
     */
    async getShardDistribution() {
        const stats = await this.db.collection('orders').stats();

        console.log('Shard Distribution:');
        console.log(`  Total documents: ${stats.count}`);
        console.log(`  Total size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);

        // 각 샤드별 청크 정보
        const configDb = this.client.db('config');
        const chunks = await configDb.collection('chunks')
            .find({ ns: 'ecommerce.orders' })
            .toArray();

        const shardCounts = {};
        chunks.forEach(chunk => {
            shardCounts[chunk.shard] = (shardCounts[chunk.shard] || 0) + 1;
        });

        console.log('  Chunks per shard:', shardCounts);
        return shardCounts;
    }

    /**
     * 집계 쿼리 - mapReduce 또는 aggregation
     */
    async getOrderStats() {
        // Aggregation pipeline - 모든 샤드에서 실행 후 mongos가 병합
        const stats = await this.db.collection('orders').aggregate([
            {
                $group: {
                    _id: '$status',
                    count: { $sum: 1 },
                    total_revenue: { $sum: '$total_amount' }
                }
            },
            { $sort: { count: -1 } }
        ]).toArray();

        return stats;
    }
}

// Zone 샤딩 (지역 기반 샤딩) 예시
async function setupZoneSharding(adminDb) {
    // 샤드를 지역 존에 할당
    await adminDb.command({
        addShardToZone: 'shard-seoul',
        zone: 'asia'
    });

    await adminDb.command({
        addShardToZone: 'shard-virginia',
        zone: 'americas'
    });

    // 존 범위 설정 - 지역별로 데이터 배치
    await adminDb.command({
        updateZoneKeyRange: 'ecommerce.users',
        min: { region: 'asia', user_id: MinKey },
        max: { region: 'asia', user_id: MaxKey },
        zone: 'asia'
    });
}

// 사용 예시
async function main() {
    const service = new ShardedMongoService(
        'mongodb://mongos1.example.com:27017,mongos2.example.com:27017'
    );

    await service.connect();

    // 주문 생성 - 자동으로 적절한 샤드에 저장
    await service.createOrder('user_123', [
        { product_id: 'prod_1', quantity: 2 }
    ], 50000);

    // 샤드 분산 상태 확인
    await service.getShardDistribution();
}

main().catch(console.error);
-- Citus (PostgreSQL 샤딩 확장) 예제

-- ============================================
-- 1. 분산 테이블 생성
-- ============================================
-- Citus 확장 활성화
CREATE EXTENSION citus;

-- 워커 노드 추가
SELECT citus_add_node('worker1.db.com', 5432);
SELECT citus_add_node('worker2.db.com', 5432);
SELECT citus_add_node('worker3.db.com', 5432);

-- 분산 테이블 생성 (해시 샤딩)
CREATE TABLE orders (
    order_id BIGSERIAL,
    user_id BIGINT NOT NULL,  -- 샤드 키
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    total_amount DECIMAL(12,2),
    status VARCHAR(20) DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW(),
    PRIMARY KEY (user_id, order_id)  -- 샤드 키 포함 필수
);

-- user_id를 샤드 키로 분산 (32개 샤드)
SELECT create_distributed_table('orders', 'user_id');


-- ============================================
-- 2. 레퍼런스 테이블 (모든 노드에 복제)
-- ============================================
CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    name VARCHAR(200),
    category VARCHAR(50),
    price DECIMAL(10,2)
);

-- 모든 워커에 전체 복제 (조인 성능 향상)
SELECT create_reference_table('products');


-- ============================================
-- 3. 샤드 키 포함 쿼리 (단일 샤드 접근)
-- ============================================
-- 효율적: user_id(샤드 키)로 필터링
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC;

-- 효율적: 샤드 키 포함 조인
SELECT o.*, p.name as product_name
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.user_id = 12345;


-- ============================================
-- 4. 크로스 샤드 쿼리 (모든 샤드 스캔)
-- ============================================
-- 비효율적: 샤드 키 없는 쿼리 (scatter-gather)
SELECT * FROM orders
WHERE created_at > '2024-01-01';

-- 집계 쿼리 - Citus가 분산 실행 후 병합
SELECT
    DATE_TRUNC('day', created_at) as order_date,
    COUNT(*) as order_count,
    SUM(total_amount) as daily_revenue
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY order_date;


-- ============================================
-- 5. Co-location (같은 샤드 키로 관련 테이블 배치)
-- ============================================
CREATE TABLE order_items (
    item_id BIGSERIAL,
    order_id BIGINT,
    user_id BIGINT NOT NULL,  -- 같은 샤드 키
    product_id INT,
    quantity INT,
    price DECIMAL(10,2),
    PRIMARY KEY (user_id, item_id)
);

-- orders와 같은 샤드에 배치 (Co-location)
SELECT create_distributed_table('order_items', 'user_id',
    colocate_with => 'orders');

-- Co-located 조인 - 로컬 조인으로 매우 효율적
SELECT o.order_id, COUNT(oi.item_id) as item_count
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
    AND o.user_id = oi.user_id  -- 샤드 키 조인 필수!
WHERE o.user_id = 12345
GROUP BY o.order_id;


-- ============================================
-- 6. 샤드 밸런싱 및 모니터링
-- ============================================
-- 샤드별 데이터 분포 확인
SELECT
    nodename,
    count(*) as shard_count,
    pg_size_pretty(sum(shard_size)) as total_size
FROM citus_shards
JOIN citus_stat_statements ON shard_id = shardid
GROUP BY nodename;

-- 샤드 리밸런싱
SELECT citus_rebalance_start();

-- 핫스팟 샤드 확인
SELECT
    shardid,
    nodename,
    shard_size,
    total_time_ms
FROM citus_shards s
JOIN (
    SELECT shardid, sum(total_time) as total_time_ms
    FROM citus_stat_statements
    GROUP BY shardid
) q ON s.shardid = q.shardid
ORDER BY total_time_ms DESC
LIMIT 10;


-- ============================================
-- 7. 리샤딩 (샤드 수 변경)
-- ============================================
-- 샤드 수 조정 (다운타임 없이)
SELECT alter_distributed_table('orders', shard_count := 64);

🗣️ 실무에서 이렇게 말하세요

💬 데이터베이스 설계 회의에서
"사용자 수가 1억 명을 넘어서면서 단일 DB로는 한계입니다. user_id를 샤드 키로 해서 8개 샤드로 나누면 각 샤드가 약 1,200만 명을 담당하게 됩니다. 사용자 관련 데이터는 모두 같은 샤드에 co-locate 해서 조인 성능을 유지하면 좋겠습니다."
💬 성능 이슈 논의에서
"이 쿼리가 느린 이유는 샤드 키 없이 날짜로만 검색해서 모든 샤드를 스캔하기 때문입니다. scatter-gather 쿼리는 피해야 해요. user_id를 조건에 추가하거나, 통계용 별도 테이블을 만들어서 해결합시다."
💬 장애 대응 회의에서
"3번 샤드에 핫스팟이 발생했습니다. 특정 대형 고객이 이 샤드에 집중되어 있네요. 일관된 해싱으로 리샤딩하거나, 대형 고객은 별도 샤드로 분리하는 방안을 검토해야 합니다."

⚠️ 주의사항 & 베스트 프랙티스

잘못된 샤드 키 선택

카디널리티가 낮은 컬럼(예: 성별, 국가)을 샤드 키로 사용하면 데이터가 불균형하게 분산됩니다. 높은 카디널리티의 균등 분포 컬럼을 선택하세요.

크로스 샤드 트랜잭션

여러 샤드에 걸친 트랜잭션은 2PC(Two-Phase Commit)가 필요해 성능이 크게 저하됩니다. 관련 데이터는 같은 샤드에 배치(co-location)하세요.

조기 샤딩

데이터가 적을 때 샤딩하면 복잡성만 증가합니다. 단일 DB의 수직 확장(스케일 업)을 먼저 고려하고, 정말 필요할 때 샤딩하세요.

샤딩 베스트 프랙티스

초기 샤드 수를 넉넉히 잡고, 애플리케이션에서 샤드 키를 항상 포함하도록 강제하며, 모니터링으로 핫스팟을 조기 감지하세요.

🔗 관련 용어

📚 더 배우기