현대 웹 애플리케이션에서 데이터 증가와 트래픽 급증은 피할 수 없는 현실이다. 단일 PostgreSQL 인스턴스로는 더 이상 감당할 수 없는 규모에 도달했을 때, 샤딩(Sharding)은 필수적인 해결책이 된다. 이 글에서는 샤딩의 핵심 개념부터 PostgreSQL에서의 실제 구현까지 상세히 알아본다.
목차
1. 샤딩이란 무엇인가?
2. 샤딩 키 관리 전략 5가지3. 실제 운영시 고려사항
- 2.0. Docker를 이용한 PostgreSQL 샤드 인스턴스 구성
- 2.1. 해시 기반 샤딩 (Hash-Based Sharding)
- 2.2. 범위 기반 샤딩 (Range-Based Sharding)
- 2.3. 디렉토리 기반 샤딩 (Directory-Based Sharding)
- 2.4. 지리적 기반 샤딩 (Geographic-Based Sharding)
- 2.5. 기능 기반 샤딩 (Functional Sharding)
샤딩은 하나의 큰 데이터베이스를 여러 개의 작은 조각(Shard)으로 수평 분할하여 각각 다른 서버에 분산 저장하는 기법이다. 각 샤드는 샤드 키(Shard Key)를 기준으로 데이터가 분산되며, 애플리케이션은 적절한 샤드로 쿼리를 라우팅한다.
수평 확장(Horizontal Scaling): 서버를 추가하여 처리 용량을 확장한다.
부하 분산: 트래픽과 데이터가 여러 서버에 분산된다.
독립성: 각 샤드는 독립적으로 운영되며 장애가 격리된다.
파티셔닝은 단일 서버 내에서 테이블을 분할하는 것이고, 샤딩은 여러 서버에 걸쳐 데이터를 분산하는 것이다. PostgreSQL의 내장 파티셔닝 기능을 기반으로 샤딩을 구현할 수 있다.
version: '3.8'
services:
# 룩업 테이블용 마스터 DB
postgres-master:
image: postgres:15
container_name: postgres-master
environment:
POSTGRES_DB: lookup_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- master_data:/var/lib/postgresql/data
- ./init-master.sql:/docker-entrypoint-initdb.d/init.sql
# 샤드 1
postgres-shard1:
image: postgres:15
container_name: postgres-shard1
environment:
POSTGRES_DB: shard1_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5433:5432"
volumes:
- shard1_data:/var/lib/postgresql/data
- ./init-shard.sql:/docker-entrypoint-initdb.d/init.sql
# 샤드 2
postgres-shard2:
image: postgres:15
container_name: postgres-shard2
environment:
POSTGRES_DB: shard2_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5434:5432"
volumes:
- shard2_data:/var/lib/postgresql/data
- ./init-shard.sql:/docker-entrypoint-initdb.d/init.sql
# 샤드 3
postgres-shard3:
image: postgres:15
container_name: postgres-shard3
environment:
POSTGRES_DB: shard3_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5435:5432"
volumes:
- shard3_data:/var/lib/postgresql/data
- ./init-shard.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
master_data:
shard1_data:
shard2_data:
shard3_data:
-- 룩업 테이블 생성
CREATE TABLE user_shard_mapping (
user_id BIGINT PRIMARY KEY,
shard_key VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_shard_mapping_shard_key ON user_shard_mapping(shard_key);
-- 각 샤드의 사용자 테이블
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
country VARCHAR(2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
spring:
datasource:
master:
url: jdbc:postgresql://localhost:5432/lookup_db
username: postgres
password: password
shard1:
url: jdbc:postgresql://localhost:5433/shard1_db
username: postgres
password: password
shard2:
url: jdbc:postgresql://localhost:5434/shard2_db
username: postgres
password: password
shard3:
url: jdbc:postgresql://localhost:5435/shard3_db
username: postgres
password: password
@Configuration
public class ShardingConfiguration {
@Bean
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.shard1")
public DataSource shard1DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.shard2")
public DataSource shard2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.shard3")
public DataSource shard3DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public List<DataSource> shardDataSources() {
return Arrays.asList(
shard1DataSource(),
shard2DataSource(),
shard3DataSource()
);
}
}
장점
단점
@Service
public class HashBasedShardRouter {
private final List<DataSource> shards;
private final int shardCount;
public HashBasedShardRouter(List<DataSource> shards) {
this.shards = shards;
this.shardCount = shards.size();
}
public DataSource getShardByUserId(Long userId) {
int shardIndex = Math.abs(userId.hashCode()) % shardCount;
return shards.get(shardIndex);
}
public void insertUser(User user) {
DataSource shard = getShardByUserId(user.getId());
try (Connection conn = shard.getConnection()) {
String sql = "INSERT INTO users (id, name, email) VALUES (?, ?, ?)";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, user.getId());
stmt.setString(2, user.getName());
stmt.setString(3, user.getEmail());
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert user", e);
}
}
}
범위 기반 샤딩은 샤드 키의 값 범위에 따라 데이터를 분산하는 방식이다.
@Service
public class RangeBasedShardRouter {
private final Map<String, DataSource> shardMap;
public RangeBasedShardRouter() {
this.shardMap = new HashMap<>();
// 범위별 샤드 매핑
shardMap.put("0-99999", createDataSource("shard1"));
shardMap.put("100000-199999", createDataSource("shard2"));
shardMap.put("200000-299999", createDataSource("shard3"));
shardMap.put("300000-", createDataSource("shard4"));
}
public DataSource getShardByUserId(Long userId) {
if (userId < 100000) return shardMap.get("0-99999");
else if (userId < 200000) return shardMap.get("100000-199999");
else if (userId < 300000) return shardMap.get("200000-299999");
else return shardMap.get("300000-");
}
public List<User> getUsersByIdRange(Long startId, Long endId) {
Set<DataSource> targetShards = getTargetShards(startId, endId);
List<User> results = new ArrayList<>();
for (DataSource shard : targetShards) {
try (Connection conn = shard.getConnection()) {
String sql = "SELECT * FROM users WHERE id BETWEEN ? AND ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, startId);
stmt.setLong(2, endId);
ResultSet rs = stmt.executeQuery();
while (rs.next()) {
results.add(mapToUser(rs));
}
} catch (SQLException e) {
throw new RuntimeException("Failed to query users", e);
}
}
return results;
}
}
@Service
public class DirectoryBasedShardRouter {
private final DataSource lookupDataSource;
private final Map<String, DataSource> shardDataSources;
private final Cache<Long, String> shardCache;
public DirectoryBasedShardRouter(DataSource lookupDataSource,
Map<String, DataSource> shardDataSources) {
this.lookupDataSource = lookupDataSource;
this.shardDataSources = shardDataSources;
this.shardCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
public DataSource getShardByUserId(Long userId) {
String shardKey = shardCache.get(userId, this::lookupShardKey);
return shardDataSources.get(shardKey);
}
private String lookupShardKey(Long userId) {
try (Connection conn = lookupDataSource.getConnection()) {
String sql = "SELECT shard_key FROM user_shard_mapping WHERE user_id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return rs.getString("shard_key");
}
throw new RuntimeException("Shard key not found for user: " + userId);
} catch (SQLException e) {
throw new RuntimeException("Failed to lookup shard key", e);
}
}
public void assignUserToShard(Long userId, String shardKey) {
try (Connection conn = lookupDataSource.getConnection()) {
String sql = "INSERT INTO user_shard_mapping (user_id, shard_key) VALUES (?, ?)";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, userId);
stmt.setString(2, shardKey);
stmt.executeUpdate();
// 캐시 업데이트
shardCache.put(userId, shardKey);
} catch (SQLException e) {
throw new RuntimeException("Failed to assign user to shard", e);
}
}
}
장점
단점
@Service
public class GeographicShardRouter {
private final Map<Region, DataSource> regionShards;
public GeographicShardRouter() {
this.regionShards = new HashMap<>();
regionShards.put(Region.ASIA, createDataSource("asia-shard"));
regionShards.put(Region.EUROPE, createDataSource("europe-shard"));
regionShards.put(Region.AMERICA, createDataSource("america-shard"));
}
public DataSource getShardByRegion(Region region) {
return regionShards.get(region);
}
public void insertUser(User user) {
Region region = determineRegion(user.getCountry());
DataSource shard = getShardByRegion(region);
try (Connection conn = shard.getConnection()) {
String sql = "INSERT INTO users (id, name, email, country) VALUES (?, ?, ?, ?)";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, user.getId());
stmt.setString(2, user.getName());
stmt.setString(3, user.getEmail());
stmt.setString(4, user.getCountry());
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert user", e);
}
}
private Region determineRegion(String country) {
// 국가별 지역 매핑 로직
if (Arrays.asList("KR", "JP", "CN").contains(country)) {
return Region.ASIA;
} else if (Arrays.asList("DE", "FR", "UK").contains(country)) {
return Region.EUROPE;
} else {
return Region.AMERICA;
}
}
}
장점
단점
@Service
public class FunctionalShardRouter {
private final Map<String, DataSource> functionShards;
public FunctionalShardRouter() {
this.functionShards = new HashMap<>();
functionShards.put("user", createDataSource("user-shard"));
functionShards.put("order", createDataSource("order-shard"));
functionShards.put("product", createDataSource("product-shard"));
functionShards.put("payment", createDataSource("payment-shard"));
}
public DataSource getShardByFunction(String function) {
return functionShards.get(function);
}
@Transactional
public void createOrder(Order order) {
DataSource orderShard = getShardByFunction("order");
DataSource userShard = getShardByFunction("user");
// 분산 트랜잭션 처리가 필요한 경우
try {
// 주문 정보 저장
insertOrderData(orderShard, order);
// 사용자 주문 히스토리 업데이트
updateUserOrderHistory(userShard, order.getUserId(), order.getId());
} catch (Exception e) {
// 보상 트랜잭션 또는 롤백 처리
throw new RuntimeException("Failed to create order", e);
}
}
private void insertOrderData(DataSource shard, Order order) {
try (Connection conn = shard.getConnection()) {
String sql = "INSERT INTO orders (id, user_id, product_id, amount) VALUES (?, ?, ?, ?)";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, order.getId());
stmt.setLong(2, order.getUserId());
stmt.setLong(3, order.getProductId());
stmt.setBigDecimal(4, order.getAmount());
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert order", e);
}
}
}
분산 트랜잭션 처리 : 여러 샤드에 걸친 트랜잭션은 2PC(Two-Phase Commit) 또는 Saga 패턴을 사용해야 한다.
백업 및 복구 전략 : 각 샤드별로 독립적인 백업 전략이 필요하며, 일관성 있는 복구 지점을 유지해야 한다.
모니터링 및 알림 : 샤드별 성능 지표를 모니터링하고, 불균형한 부하 분산을 감지할 수 있는 시스템이 필요하다.
리샤딩 계획 : 데이터 증가나 성능 요구사항 변경에 따른 리샤딩 계획을 미리 수립해야 한다.