샤딩 - 수평적 분할
왜 샤딩을 공부하게 됐나
DB가 느려지면 어떻게 해야 할까? 인덱스도 걸었고, 쿼리도 최적화했는데 여전히 느리다면? 이 질문의 끝에 샤딩이 있었다.
처음엔 서버 스펙을 올리는 방법(수직 확장, Scale-up)을 생각한다. CPU를 늘리고, 메모리를 추가하고, SSD를 더 빠른 걸로 교체한다. 하지만 대규모 서비스에서는 이게 한계에 부딪힌다고 한다. 데이터가 많아지면 쿼리 속도가 느려지고, 디스크 용량이 부족해지고, 백업 시간이 몇 시간씩 걸린다. 스펙을 올리는 것만으로는 더 이상 해결이 안 되는 시점이 온다.
그 다음 단계가 수평 확장(Scale-out)이다. 하나의 DB를 여러 개로 쪼개는 것. 이게 샤딩이다. 데이터가 많아지면 각 샤드가 일부분만 담당하면 되니, 쿼리도 빨라지고 관리도 용이해진다는 논리다. 하지만 그만큼 트레이드오프도 많다.
처음엔 뭐가 이해가 안 갔나
샤딩을 처음 접했을 때 가장 혼란스러웠던 부분은 "어떻게 데이터를 나누는가?"였습니다. 단순히 테이블을 여러 DB에 복사하는 건가? 아니면 데이터를 랜덤하게 분산하는 건가?
또 다른 혼란은 "샤드 키를 어떻게 정하는가?"였습니다. 사용자 ID로 나눌까? 지역으로 나눌까? 시간으로 나눌까? 잘못 선택하면 어떤 문제가 생기는지 감이 안 왔습니다.
그리고 가장 큰 혼란은 "JOIN은 어떻게 하는가?"였습니다. 데이터가 여러 DB에 흩어져 있으면, 서로 다른 샤드에 있는 데이터를 JOIN할 수 없잖아요? 그럼 애플리케이션 레벨에서 JOIN을 해야 하나? 성능은 괜찮을까?
어떤 포인트에서 이해가 됐나
샤딩을 이해하는 데 결정적이었던 비유는 "도서관 분관"이었습니다.
하나의 도서관에 책이 너무 많아지면 어떻게 할까요? 두 가지 방법이 있습니다:
방법 1: 건물을 더 크게 짓기 (수직 확장)
- 층을 더 높이 쌓고, 서가를 더 많이 설치
- 하지만 한계가 있음 (땅 크기, 건축 비용)
방법 2: 분관을 만들기 (수평 확장 = 샤딩)
- 강남 분관, 강북 분관, 강서 분관...
- 각 분관은 특정 기준으로 책을 나눠서 보관
- 예: 저자 이름의 첫 글자로 분류 (ㄱ-ㄷ은 강남, ㄹ-ㅁ은 강북...)
샤딩은 바로 방법 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번 샤드에 저장됩니다.
샤딩 전략
1. Range-based Sharding (범위 기반)
특정 범위로 데이터를 나눕니다.
function getShardId(userId: number): number {
if (userId < 1000000) return 0;
if (userId < 2000000) return 1;
if (userId < 3000000) return 2;
// ...
return 9;
}
장점:
- 구현이 간단
- 범위 쿼리가 효율적 (예: 100만~200만 사용자 조회)
단점:
- 데이터 분포가 불균등할 수 있음
- 최신 데이터가 한 샤드에 몰림 (핫스팟 문제)
주의: 시간 기반으로 샤딩하면 최신 데이터가 있는 샤드에만 부하가 집중된다. 이를 핫스팟(Hotspot) 문제라고 한다. 이런 경우 Hash-based 전략이 더 적합하다.
2. Hash-based Sharding (해시 기반)
해시 함수로 데이터를 균등하게 분산합니다.
function getShardId(userId: number): number {
const SHARD_COUNT = 10;
return userId % SHARD_COUNT;
}
장점:
- 데이터가 균등하게 분산됨
- 핫스팟 문제 해결
단점:
- 범위 쿼리가 어려움
- 샤드 추가 시 리샤딩 필요
개선: Consistent Hashing
샤드를 추가/제거할 때 최소한의 데이터만 이동하도록 하는 기법입니다:
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);
}
}
3. Directory-based Sharding (디렉토리 기반)
별도의 룩업 테이블로 샤드를 결정합니다.
// 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;
}
장점:
- 유연함 (샤드를 자유롭게 변경 가능)
- 복잡한 샤딩 로직 구현 가능
단점:
- 룩업 테이블이 SPOF (Single Point of Failure)
- 모든 쿼리마다 룩업 필요 (성능 오버헤드)
4. Geo-based Sharding (지역 기반)
사용자 지역에 따라 샤드를 나눕니다.
function getShardId(region: string): number {
const regionMap = {
'us-east': 0,
'us-west': 1,
'eu-west': 2,
'ap-northeast': 3
};
return regionMap[region] || 0;
}
장점:
- 지역별 데이터 규정 준수 (GDPR 등)
- 네트워크 지연 최소화
단점:
- 지역별 데이터 불균형
- 글로벌 쿼리가 어려움
샤딩의 어려움과 해결책
1. JOIN 문제
서로 다른 샤드에 있는 데이터는 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만 조회해도 작성자 이름 표시 가능
2. 트랜잭션 문제
여러 샤드에 걸친 트랜잭션은 복잡합니다.
문제:
// 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;
}
}
3. 자동 증가 ID 문제
각 샤드마다 독립적인 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();
}
}
실제로 샤딩 적용하기
1. 샤딩 전 준비
샤딩은 한 번 시작하면 되돌리기 어렵습니다. 충분히 준비해야 합니다.
체크리스트:
- [ ] 현재 DB 크기와 증가 속도 측정
- [ ] 샤드 키 선정 (변경 불가능한 값!)
- [ ] 샤딩 전략 결정 (Hash/Range/Directory)
- [ ] 샤드 개수 결정 (나중에 늘리기 어려움)
- [ ] 애플리케이션 코드 수정 계획
- [ ] 마이그레이션 계획 (다운타임 최소화)
- [ ] 롤백 계획
2. 점진적 마이그레이션
한 번에 모든 데이터를 옮기면 위험합니다. 점진적으로 진행합니다.
단계:
// 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 제거
3. 샤드 라우팅 추상화
애플리케이션 코드에서 샤딩 로직을 숨깁니다.
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)
);
}
}
샤딩 적용 예시
1. 사용자 데이터 샤딩
데이터가 많아지면 가장 먼저 사용자 테이블이 샤딩 대상이 된다.
샤드 키: 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}`,
// ...
})
);
2. 게시글 데이터 샤딩
게시글은 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]);
}
3. 글로벌 쿼리 최적화
모든 샤드를 조회해야 하는 경우 병렬 처리:
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 중복 등의 문제를 해결해야 합니다. 실제로는 점진적 마이그레이션과 샤드 라우팅 추상화가 핵심입니다.