
샤딩: 수평적 분할
데이터베이스 샤딩의 개념과 대규모 트래픽 처리를 경험을 통해 이해한 과정

데이터베이스 샤딩의 개념과 대규모 트래픽 처리를 경험을 통해 이해한 과정
데이터베이스 커넥션 풀의 개념과 성능 최적화를 경험을 통해 이해한 과정

데이터베이스 트랜잭션의 개념과 ACID 특성을 경험을 통해 이해한 과정

데이터베이스 복제를 통한 고가용성과 읽기 성능 향상을 경험을 통해 이해한 과정

벡터 데이터베이스의 동작 원리와 활용 방법을 프로젝트 경험을 통해 이해한 과정

DB가 느려지면 어떻게 해야 할까? 인덱스도 걸었고, 쿼리도 최적화했는데 여전히 느리다면? 이 질문의 끝에 샤딩이 있었다.
처음엔 서버 스펙을 올리는 방법(수직 확장, Scale-up)을 생각한다. CPU를 늘리고, 메모리를 추가하고, SSD를 더 빠른 걸로 교체한다. 하지만 대규모 서비스에서는 이게 한계에 부딪힌다고 한다. 데이터가 많아지면 쿼리 속도가 느려지고, 디스크 용량이 부족해지고, 백업 시간이 몇 시간씩 걸린다. 스펙을 올리는 것만으로는 더 이상 해결이 안 되는 시점이 온다.
그 다음 단계가 수평 확장(Scale-out)이다. 하나의 DB를 여러 개로 쪼개는 것. 이게 샤딩이다. 데이터가 많아지면 각 샤드가 일부분만 담당하면 되니, 쿼리도 빨라지고 관리도 용이해진다는 논리다. 하지만 그만큼 트레이드오프도 많다.
샤딩을 처음 접했을 때 가장 혼란스러웠던 부분은 "어떻게 데이터를 나누는가?"였습니다. 단순히 테이블을 여러 DB에 복사하는 건가? 아니면 데이터를 랜덤하게 분산하는 건가?
또 다른 혼란은 "샤드 키를 어떻게 정하는가?"였습니다. 사용자 ID로 나눌까? 지역으로 나눌까? 시간으로 나눌까? 잘못 선택하면 어떤 문제가 생기는지 감이 안 왔습니다.
그리고 가장 큰 혼란은 "JOIN은 어떻게 하는가?"였습니다. 데이터가 여러 DB에 흩어져 있으면, 서로 다른 샤드에 있는 데이터를 JOIN할 수 없잖아요? 그럼 애플리케이션 레벨에서 JOIN을 해야 하나? 성능은 괜찮을까?
샤딩을 이해하는 데 결정적이었던 비유는 "도서관 분관"이었습니다.
하나의 도서관에 책이 너무 많아지면 어떻게 할까요? 두 가지 방법이 있습니다:
방법 1: 건물을 더 크게 짓기 (수직 확장)샤딩은 바로 방법 2입니다. 하나의 거대한 데이터베이스를 여러 개의 작은 데이터베이스(샤드)로 나누는 것입니다.
이 비유를 듣자마자 무릎을 쳤습니다. 아, 그래서 "샤드 키"가 중요한 거구나. 책을 어떤 기준으로 분관에 배치할지 정하는 것처럼, 데이터를 어떤 기준으로 샤드에 배치할지 정해야 하는 거였습니다.
코드로 표현하면 이렇습니다:
// 샤드 키: user_id
function getShardId(userId: number): number {
const SHARD_COUNT = 10;
return userId % SHARD_COUNT; // 0~9
}
// 사용자 데이터 조회
async function getUser(userId: number) {
const shardId = getShardId(userId);
const db = getShardConnection(shardId); // 해당 샤드 연결
return await db.query('SELECT * FROM users WHERE id = ?', [userId]);
}
user_id를 10으로 나눈 나머지로 샤드를 결정합니다. user_id = 1234면 1234 % 10 = 4, 즉 4번 샤드에 저장됩니다.
특정 범위로 데이터를 나눕니다.
function getShardId(userId: number): number {
if (userId < 1000000) return 0;
if (userId < 2000000) return 1;
if (userId < 3000000) return 2;
// ...
return 9;
}
장점:
주의: 시간 기반으로 샤딩하면 최신 데이터가 있는 샤드에만 부하가 집중된다. 이를 핫스팟(Hotspot) 문제라고 한다. 이런 경우 Hash-based 전략이 더 적합하다.
해시 함수로 데이터를 균등하게 분산합니다.
function getShardId(userId: number): number {
const SHARD_COUNT = 10;
return userId % SHARD_COUNT;
}
장점:
샤드를 추가/제거할 때 최소한의 데이터만 이동하도록 하는 기법입니다:
class ConsistentHash {
private ring: Map<number, number> = new Map();
private VIRTUAL_NODES = 150; // 가상 노드 개수
addShard(shardId: number) {
for (let i = 0; i < this.VIRTUAL_NODES; i++) {
const hash = this.hash(`shard-${shardId}-${i}`);
this.ring.set(hash, shardId);
}
}
getShardId(key: string): number {
const hash = this.hash(key);
const sortedHashes = Array.from(this.ring.keys()).sort((a, b) => a - b);
for (const ringHash of sortedHashes) {
if (hash <= ringHash) {
return this.ring.get(ringHash)!;
}
}
return this.ring.get(sortedHashes[0])!;
}
private hash(key: string): number {
// Simple hash function (use better one in production)
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash) + key.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
}
}
별도의 룩업 테이블로 샤드를 결정합니다.
// shard_directory 테이블
// user_id | shard_id
// 1 | 0
// 2 | 3
// 3 | 1
// ...
async function getShardId(userId: number): Promise<number> {
const result = await directoryDb.query(
'SELECT shard_id FROM shard_directory WHERE user_id = ?',
[userId]
);
return result[0].shard_id;
}
장점:
사용자 지역에 따라 샤드를 나눕니다.
function getShardId(region: string): number {
const regionMap = {
'us-east': 0,
'us-west': 1,
'eu-west': 2,
'ap-northeast': 3
};
return regionMap[region] || 0;
}
장점:
서로 다른 샤드에 있는 데이터는 JOIN할 수 없습니다.
문제:-- users는 shard 0에, posts는 shard 3에 있으면?
SELECT u.name, p.title
FROM users u
JOIN posts p ON u.id = p.user_id
WHERE u.id = 1234;
해결책 1: 같은 샤드에 저장
// user_id를 기준으로 users와 posts를 같은 샤드에 저장
function getShardId(userId: number): number {
return userId % SHARD_COUNT;
}
// users 테이블: user_id로 샤딩
// posts 테이블: user_id로 샤딩 (post_id가 아님!)
해결책 2: 애플리케이션 레벨 JOIN
async function getUserWithPosts(userId: number) {
const shardId = getShardId(userId);
const db = getShardConnection(shardId);
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [userId]);
return { ...user, posts };
}
해결책 3: 데이터 중복 (Denormalization)
// posts 테이블에 user_name 컬럼 추가
// JOIN 없이 posts만 조회해도 작성자 이름 표시 가능
여러 샤드에 걸친 트랜잭션은 복잡합니다.
문제:// user A는 shard 0에, user B는 shard 5에 있으면?
// 둘 사이의 송금 트랜잭션을 어떻게?
해결책 1: 2PC (Two-Phase Commit)
async function transfer(fromUserId: number, toUserId: number, amount: number) {
const shard1 = getShardConnection(getShardId(fromUserId));
const shard2 = getShardConnection(getShardId(toUserId));
// Phase 1: Prepare
await shard1.query('BEGIN');
await shard2.query('BEGIN');
try {
await shard1.query('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, fromUserId]);
await shard2.query('UPDATE users SET balance = balance + ? WHERE id = ?', [amount, toUserId]);
// Phase 2: Commit
await shard1.query('COMMIT');
await shard2.query('COMMIT');
} catch (error) {
await shard1.query('ROLLBACK');
await shard2.query('ROLLBACK');
throw error;
}
}
해결책 2: Saga 패턴
async function transfer(fromUserId: number, toUserId: number, amount: number) {
try {
// Step 1: 출금
await deduct(fromUserId, amount);
// Step 2: 입금
await deposit(toUserId, amount);
} catch (error) {
// 보상 트랜잭션
await deposit(fromUserId, amount); // 출금 취소
throw error;
}
}
각 샤드마다 독립적인 auto_increment를 사용하면 ID가 중복됩니다.
해결책 1: UUID 사용import { v4 as uuidv4 } from 'uuid';
const userId = uuidv4(); // '550e8400-e29b-41d4-a716-446655440000'
해결책 2: Snowflake ID
// Twitter의 Snowflake 알고리즘
// 64bit = timestamp(41) + datacenter(5) + worker(5) + sequence(12)
class SnowflakeId {
private sequence = 0;
private lastTimestamp = -1;
generate(shardId: number): bigint {
let timestamp = Date.now();
if (timestamp === this.lastTimestamp) {
this.sequence = (this.sequence + 1) & 0xFFF;
if (this.sequence === 0) {
timestamp = this.waitNextMillis(timestamp);
}
} else {
this.sequence = 0;
}
this.lastTimestamp = timestamp;
return (BigInt(timestamp) << 22n) |
(BigInt(shardId) << 12n) |
BigInt(this.sequence);
}
private waitNextMillis(current: number): number {
while (Date.now() <= current) {}
return Date.now();
}
}
샤딩은 한 번 시작하면 되돌리기 어렵습니다. 충분히 준비해야 합니다.
체크리스트:- [ ] 현재 DB 크기와 증가 속도 측정
- [ ] 샤드 키 선정 (변경 불가능한 값!)
- [ ] 샤딩 전략 결정 (Hash/Range/Directory)
- [ ] 샤드 개수 결정 (나중에 늘리기 어려움)
- [ ] 애플리케이션 코드 수정 계획
- [ ] 마이그레이션 계획 (다운타임 최소화)
- [ ] 롤백 계획
한 번에 모든 데이터를 옮기면 위험합니다. 점진적으로 진행합니다.
단계:// 1단계: 읽기는 기존 DB, 쓰기는 샤드에도 복제
async function createUser(userData) {
// 기존 DB에 저장
await masterDb.query('INSERT INTO users ...', userData);
// 샤드에도 저장 (비동기)
const shardId = getShardId(userData.id);
const shard = getShardConnection(shardId);
await shard.query('INSERT INTO users ...', userData).catch(err => {
logger.error('Shard write failed', err);
});
}
// 2단계: 읽기도 샤드에서 (fallback은 기존 DB)
async function getUser(userId) {
const shardId = getShardId(userId);
const shard = getShardConnection(shardId);
let user = await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
if (!user) {
// 샤드에 없으면 기존 DB에서 조회
user = await masterDb.query('SELECT * FROM users WHERE id = ?', [userId]);
if (user) {
// 샤드에 복사
await shard.query('INSERT INTO users ...', user);
}
}
return user;
}
// 3단계: 기존 DB 제거
애플리케이션 코드에서 샤딩 로직을 숨깁니다.
class ShardedUserRepository {
async findById(userId: number) {
const shard = this.getShard(userId);
return await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
}
async create(userData) {
const shard = this.getShard(userData.id);
return await shard.query('INSERT INTO users ...', userData);
}
async findByRegion(region: string) {
// 모든 샤드를 조회해야 함
const results = await Promise.all(
this.getAllShards().map(shard =>
shard.query('SELECT * FROM users WHERE region = ?', [region])
)
);
return results.flat();
}
private getShard(userId: number) {
const shardId = getShardId(userId);
return getShardConnection(shardId);
}
private getAllShards() {
return Array.from({ length: SHARD_COUNT }, (_, i) =>
getShardConnection(i)
);
}
}
데이터가 많아지면 가장 먼저 사용자 테이블이 샤딩 대상이 된다.
샤드 키: user_id (변경 불가능, 균등 분포)
샤딩 전략: Hash-based (Consistent Hashing)
샤드 개수: 16개 (2의 거듭제곱으로 설정)
const SHARD_COUNT = 16;
function getShardId(userId: number): number {
return userId % SHARD_COUNT;
}
// 샤드별 연결 풀
const shardPools = Array.from({ length: SHARD_COUNT }, (_, i) =>
mysql.createPool({
host: `shard-${i}.db.example.com`,
database: `users_shard_${i}`,
// ...
})
);
게시글은 user_id를 기준으로 샤딩해서 JOIN을 가능하게 했습니다.
// users와 posts를 같은 샤드에 저장
function getShardId(userId: number): number {
return userId % SHARD_COUNT;
}
// JOIN 가능
async function getUserWithPosts(userId: number) {
const shardId = getShardId(userId);
const shard = shardPools[shardId];
return await shard.query(`
SELECT u.*, p.*
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE u.id = ?
`, [userId]);
}
모든 샤드를 조회해야 하는 경우 병렬 처리:
async function searchUsers(keyword: string) {
const queries = shardPools.map(shard =>
shard.query('SELECT * FROM users WHERE name LIKE ? LIMIT 10', [`%${keyword}%`])
);
const results = await Promise.all(queries);
const merged = results.flat();
// 정렬 및 페이지네이션
return merged.sort((a, b) => b.created_at - a.created_at).slice(0, 20);
}
샤딩은 하나의 거대한 데이터베이스를 여러 개의 작은 데이터베이스로 수평 분할하는 기법으로, 데이터를 샤드 키 기준으로 분산 저장해서 확장성을 확보합니다. Hash-based, Range-based, Directory-based 등 다양한 전략이 있으며, JOIN 제약, 트랜잭션 복잡도, ID 중복 등의 문제를 해결해야 합니다. 실제로는 점진적 마이그레이션과 샤드 라우팅 추상화가 핵심입니다.