샤딩(Sharding) 키 관리 전략 5가지

eggggg·2025년 9월 9일
0
post-thumbnail

들어가며

현대 웹 애플리케이션에서 데이터 증가와 트래픽 급증은 피할 수 없는 현실이다. 단일 PostgreSQL 인스턴스로는 더 이상 감당할 수 없는 규모에 도달했을 때, 샤딩(Sharding)은 필수적인 해결책이 된다. 이 글에서는 샤딩의 핵심 개념부터 PostgreSQL에서의 실제 구현까지 상세히 알아본다.

목차

1. 샤딩이란 무엇인가?
2. 샤딩 키 관리 전략 5가지3. 실제 운영시 고려사항

1. 샤딩이란 무엇인가?

샤딩은 하나의 큰 데이터베이스를 여러 개의 작은 조각(Shard)으로 수평 분할하여 각각 다른 서버에 분산 저장하는 기법이다. 각 샤드는 샤드 키(Shard Key)를 기준으로 데이터가 분산되며, 애플리케이션은 적절한 샤드로 쿼리를 라우팅한다.

샤딩의 핵심 특징

  • 수평 확장(Horizontal Scaling): 서버를 추가하여 처리 용량을 확장한다.

  • 부하 분산: 트래픽과 데이터가 여러 서버에 분산된다.

  • 독립성: 각 샤드는 독립적으로 운영되며 장애가 격리된다.

샤딩 vs 파티셔닝

파티셔닝은 단일 서버 내에서 테이블을 분할하는 것이고, 샤딩은 여러 서버에 걸쳐 데이터를 분산하는 것이다. PostgreSQL의 내장 파티셔닝 기능을 기반으로 샤딩을 구현할 수 있다.


2. 샤딩 키 관리 전략 5가지


2.0. Docker를 이용한 PostgreSQL 샤드 인스턴스 구성

Docker Compose 설정

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: 

초기화 SQL 스크립트

  • init-master.sql
-- 룩업 테이블 생성
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);
  • init-shard.sql
-- 각 샤드의 사용자 테이블
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);
  • application.yml
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

DataSource 설정

@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()
        );
    }
}

2.1 해시 기반 샤딩 (Hash-Based Sharding)

해시 기반 샤딩은 샤드 키 값을 해시 함수에 통과시켜 나온 결과로 데이터를 분산하는 방식이다.
  • 장점

    • 데이터가 균등하게 분산된다.
    • 구현이 비교적 간단하다.
    • 핫스팟 문제를 효과적으로 방지한다.
  • 단점

    • 범위 쿼리가 비효율적이다.
    • 샤드 추가/제거 시 리샤딩이 필요하다.
@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);
        }
    }
}

2.2 범위 기반 샤딩 (Range-Based Sharding)

범위 기반 샤딩은 샤드 키의 값 범위에 따라 데이터를 분산하는 방식이다.

  • 장점
    • 범위 쿼리가 효율적이다.
    • 데이터의 논리적 구조를 유지한다.
    • 새로운 샤드 추가가 상대적으로 용이하다.
  • 단점
    • 데이터 분산이 불균등할 수 있다.
    • 핫스팟 문제가 발생할 수 있다.
@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;
    }
}

2.3 디렉토리 기반 샤딩 (Directory-Based Sharding)

디렉토리 기반 샤딩은 조회 테이블(Lookup Table)을 사용하여 데이터베이스 정보를 해당 물리적 샤드에 매핑하는 방식이다.
  • 장점
    • 유연한 데이터 분산이 가능하다.
    • 샤드 추가/제거가 용이하다.
    • 복잡한 분산 로직을 구현할 수 있다.
  • 단점
    • 조회 테이블이 단일 장애점이 될 수 있다.
    • 추가적인 조회 오버헤드가 발생한다.
@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);
        }
    }
}

2.4 지리적 기반 샤딩 (Geographic-Based Sharding)

지리적 위치에 따라 데이터를 분산하는 방식이다.
  • 장점

    • 지연시간을 최소화할 수 있다.
    • 데이터 주권 규정을 준수할 수 있다.
    • 지역별 장애 격리가 가능하다.
  • 단점

    • 지역별 데이터 불균형이 발생할 수 있다.
    • 글로벌 쿼리가 복잡해진다.
@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;
        }
    }
}

2.5 기능 기반 샤딩 (Functional Sharding)

애플리케이션의 기능이나 테이블 단위로 데이터를 분산하는 방식이다.
  • 장점

    • 기능별 독립적인 확장이 가능하다.
    • 서비스 지향 아키텍처와 잘 맞는다.
    • 팀별 독립적인 개발이 가능하다.
  • 단점

    • 기능 간 조인이 어렵다.
    • 데이터 일관성 관리가 복잡하다.
@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);
        }
    }
}

3. 실제 운영시 고려사항

  • 분산 트랜잭션 처리 : 여러 샤드에 걸친 트랜잭션은 2PC(Two-Phase Commit) 또는 Saga 패턴을 사용해야 한다.

  • 백업 및 복구 전략 : 각 샤드별로 독립적인 백업 전략이 필요하며, 일관성 있는 복구 지점을 유지해야 한다.

  • 모니터링 및 알림 : 샤드별 성능 지표를 모니터링하고, 불균형한 부하 분산을 감지할 수 있는 시스템이 필요하다.

  • 리샤딩 계획 : 데이터 증가나 성능 요구사항 변경에 따른 리샤딩 계획을 미리 수립해야 한다.

0개의 댓글