샤딩
Sharding
대규모 데이터를 여러 데이터베이스 서버에 수평 분산하는 기법입니다. 샤드 키(Shard Key)를 기준으로 데이터를 분할하여 단일 서버의 한계를 극복하고 확장성과 성능을 향상시킵니다.
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의 수직 확장(스케일 업)을 먼저 고려하고, 정말 필요할 때 샤딩하세요.
초기 샤드 수를 넉넉히 잡고, 애플리케이션에서 샤드 키를 항상 포함하도록 강제하며, 모니터링으로 핫스팟을 조기 감지하세요.