Skip to content
hello tis
Go back

[26.10.29] 학습: 주문 데이터를 여러 DB로 나누기 위한 샤딩 설계하기

샤딩은 하나의 데이터 집합을 여러 DB에 나누어 저장하고 처리하는 방식이다. 예를 들어 주문 테이블의 행을 사용자 ID 기준으로 나누고, 사용자별로 정해진 DB에 저장한다. 나뉜 데이터 단위를 샤드, 배치를 결정하는 기준 값을 샤드 키라고 한다.

주문을 DB 1과 DB 2에 나누어 저장한 모습이다. 테이블 구조는 같고 저장된 행이 다르다. C는 두 DB에 나뉘어 있는 이동 대상 주문을 표시한다.

DB 1에는 A와 C, DB 2에는 B와 C 주문을 나누어 저장하는 구조

이렇게 저장량과 요청을 여러 DB 서버에 분산할 수 있다. 다만 사용자 기준으로 나누면 주문 처리는 한 DB에서 끝낼 수 있어도, 판매자별 목록과 전체 매출 집계는 여러 DB에 걸친다. 데이터가 늘거나 특정 사용자에게 요청이 집중되면 배치도 다시 바꿔야 한다.

사용자별 주문 처리를 한 DB에서 끝내기 위한 데이터 배치와 조회 조건 정하기

사용자별 주문 처리가 주된 작업이라면 사용자 ID(tenant_id)를 샤드 키로 삼고 주문과 주문 항목을 같은 샤드에 둔다. 관련 조회와 변경을 한 DB에서 끝내기 위한 선택이다.

주문과 항목을 다른 DB에 두면 두 DB의 조회 결과를 결합하고, 같은 DB에 두면 한 DB의 JOIN으로 처리하는 비교

접근 방식사용자 ID 기준 배치에서 필요한 작업
사용자 ID와 주문 ID로 주문 조회사용자에 해당하는 샤드에서 조회
주문 ID만으로 조회대상 샤드를 알아낼 추가 정보가 없으면 여러 샤드 조회
판매자별 주문 목록여러 샤드 조회 또는 판매자 기준 조회 구조 필요
전체 판매자의 매출 집계여러 샤드의 집계 결과 병합 또는 분석용 저장소 필요

Notion도 워크스페이스 ID로 블록과 관련 테이블을 함께 나눠 주요 요청을 같은 샤드에서 처리하도록 설계했다. Notion의 샤딩 설계

조회 조건에도 샤드 키를 전달해야 한다. 주문 ID만으로 대상 샤드를 알 수 없다면 여러 샤드에 조회해야 한다.

방식요청당 DB 쿼리 수중앙값p95
샤드 키로 단일 DB 조회10.3ms0.9ms
전체 샤드 병렬 조회40.5ms1.4ms

샤드 키가 없으면 네 DB에서 각각 기본 키 인덱스를 조회한다. 한 행을 찾는 데 필요한 DB 쿼리가 네 번으로 늘었다.

Vitess의 Vindex는 키로 대상 샤드를 찾는 규칙이다. tenant_id에 hash Vindex를 적용하고 주문과 주문 항목을 함께 배치했다. vttestserver의 MySQL 한 개에 네 논리 샤드를 구성하고 VEXPLAIN QUERIES로 실행 경로를 확인했다. Vindex, VEXPLAIN

VEXPLAIN QUERIES
SELECT id, tenant_id, seller_id, amount_cents
FROM orders
WHERE tenant_id = 1 AND id = 101;
쿼리 조건과 작업실제 실행 샤드 수확인한 동작
tenant_id = 1 AND id = 1011EqualUnique 계획으로 -40 샤드 조회
id = 1014Scatter 계획으로 전체 샤드 조회, 같은 주문 반환
판매자별 COUNT, SUM4샤드별 부분 집계 후 VTGate에서 병합
사용자 ID를 포함한 주문과 주문 항목 JOIN1같은 샤드에서 JOIN 실행

프록시가 SQL을 분배해도 키 없이 조회하는 비용은 남는다. Shopify도 Vitess 도입 전에 사용자 소유 테이블에 user_id를 추가하고 샤드 키가 빠진 쿼리를 검출했다. Shopify의 Vitess 전환

샤딩을 구현하는 세 가지 방법 비교하기

JPA 표준은 자동 샤딩을 제공하지 않는다. 직접 구현할 때는 Spring의 AbstractRoutingDataSource로 연결을 선택하고, JDBC 라이브러리나 DB 프록시를 사용할 때는 해당 계층에 SQL 라우팅을 맡긴다.

직접 구현과 JDBC 라이브러리는 앱 내부에서, 프록시는 앱 외부에서 DB를 선택하는 구조

방식JPA에서 DB까지의 경로선택할 상황추가 비용
애플리케이션 직접 구현JPA → 라우팅 DataSource → 샤드 DB대상 샤드를 명확히 선택할 수 있고 라우팅을 직접 통제할 때매핑, 샤드 간 조회와 운영 도구를 직접 관리
JDBC 라이브러리JPA → ShardingSphere-JDBC → 샤드 DBJava 안에서 SQL 라우팅과 결과 병합을 위임할 때지원 SQL 검증, 애플리케이션별 라이브러리와 설정 관리
DB 프록시JPA → JDBC → Vitess 또는 ShardingSphere-Proxy → 샤드 DB여러 언어와 애플리케이션에 공통 접속점을 제공할 때프록시 배포, 가용성 관리와 추가 네트워크 경로

직접 구현에서는 트랜잭션 전에 샤드를 결정하고 끝날 때까지 유지한다. AbstractRoutingDataSource는 getConnection() 시점에 키를 확인한다. 키가 없거나 등록되지 않았으면 연결을 거부하도록 구성한다. Spring 라우팅 API

class Routing extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        String shard = SHARD.get();
        if (shard == null) {
            throw new IllegalStateException("Missing shard");
        }
        return shard;
    }
}

routing.setTargetDataSources(Map.of("A", databaseA, "B", databaseB));
routing.setLenientFallback(false);
routing.afterPropertiesSet();

기존 트랜잭션과 영속성 컨텍스트가 없는 진입점에서 키를 설정한다. 필요한 값을 트랜잭션 안에서 읽고 종료 후 키를 정리한다.

SHARD.set("B");
try {
    return tx.execute(status ->
        orders.findById(1L).orElseThrow().getMarker()
    );
} finally {
    SHARD.remove();
}

같은 ID 1에 값만 A, B로 다르게 저장한 두 H2 DB를 Spring Data JPA로 조회했다. OSIV와 2차 캐시는 껐다.

실행 조건읽은 값
샤드 A를 선택하고 트랜잭션에서 조회A
같은 트랜잭션에서 키를 B로 바꾼 뒤 재조회A
EntityManager.clear() 후 재조회A
같은 트랜잭션에서 native SQL로 조회A
트랜잭션 종료 후 B를 선택하고 새 트랜잭션에서 조회B

연결을 확보한 뒤에는 라우팅 키를 바꿔도 같은 연결을 사용한다. 샤드를 바꾸려면 트랜잭션을 끝내고 새로 시작해야 한다.

ShardingSphere-JDBC에서는 sharding.yaml의 샤딩 규칙을 읽는 DataSource에 JPA를 연결한다. ShardingSphere-JDBC 설정

spring.datasource.driver-class-name=org.apache.shardingsphere.driver.ShardingSphereDriver
spring.datasource.url=jdbc:shardingsphere:classpath:sharding.yaml

프록시 방식에서는 일반 JDBC 드라이버로 Vitess나 ShardingSphere-Proxy에 접속한다. VTGate를 가리키는 DataSource에 JPA Repository를 연결했다.

public interface Orders extends JpaRepository<VitessOrder, Long> {
    Optional<VitessOrder> findByTenantIdAndId(Long tenantId, Long id);
}

생성 SQL을 같은 바인딩 값으로 VEXPLAIN에서 실행했을 때 findByTenantIdAndId는 한 샤드, findById와 count()는 네 샤드에서 실행됐다. 연결 경로를 바꿔도 단일 샤드로 제한할 요청에는 샤드 키를 전달해야 한다.

여러 DB의 반복 조회를 줄이기 위한 조회용 데이터 만들기

사용자별 주문 원천은 유지하고, 반복되는 목록과 분석 요청은 별도로 준비한 데이터에서 처리할 수 있다. OLAP에서도 분산 실행은 가능하다. 다만 OLTP의 사용자별 배치가 분석 조건에 맞지 않으면 여러 샤드를 읽고 결과를 병합해야 한다.

원천 DB는 유지하고 목록과 분석 요청은 별도로 준비한 조회용 데이터를 읽는 상황

필요한 조회추가할 구조유지 비용
주문 ID만으로 상세 조회주문 ID에서 샤드를 찾는 매핑 또는 전역 인덱스원천과 매핑의 일관성과 가용성
반복되는 주문 상세 조회원천 앞의 결과 캐시무효화, 만료와 캐시 미스 시 부하
판매자별 주문 목록판매자 기준 조회 모델변경 전달, 갱신 지연과 페이징
날짜와 판매자별 매출 분석OLAP 저장소와 목적별 데이터마트적재와 집계, 변경과 삭제 반영

Vitess의 Lookup Vindex도 다른 키로 대상 샤드를 찾는 전역 인덱스다. 조회 범위를 줄이는 대신 매핑을 유지하는 쓰기가 추가된다. Lookup Vindex

반복 집계는 변경 이벤트나 CDC를 분석용 저장소로 전달해 미리 계산할 수 있다. 주문 200만 건의 판매자별 주문 수와 금액 합계를 네 MySQL DB에서 부분 집계한 뒤 병합하는 방식과, 샤드 0에 저장한 200행의 집계 테이블을 읽는 방식을 비교했다. 원천을 고정하고 예열 후 30쌍을 번갈아 측정했다.

방식읽는 행 수중앙값p95
네 샤드의 부분 집계 후 병합원천 총 2,000,000행71.6ms75.5ms
사전 집계 테이블 조회집계 200행0.8ms1.5ms

DB 한 대의 저장량과 요청량을 줄이기 위한 데이터 재배치하기

접근 경로를 분리해도 원천의 저장량이나 요청량이 한 DB의 한계를 넘으면 배치를 바꿔야 한다.

논리 버킷이나 특정 사용자처럼 옮길 단위를 정하고, 선택한 묶음만 새 DB로 보낸다. DB 1과 DB 2에 나뉜 C 주문을 새 DB 3에 모은다. C는 서로 다른 샤드 키의 주문 묶음이다.

  1. 데이터 이동: DB 1과 DB 2의 C 데이터를 DB 3으로 옮기고, 그동안 발생한 추가와 수정, 삭제까지 반영해 일치하는지 확인한다.
  2. 주문 분기 변경: C의 쓰기를 잠시 대기시키고 마지막 변경분까지 반영한 뒤, C 주문 요청을 DB 3으로 보낸다. A와 B의 경로는 유지한다.

실선은 기존 주문 요청 경로, 점선은 추가되는 데이터 이동과 C 주문 요청 경로다.

DB 1과 DB 2의 C 데이터를 신규 DB 3으로 모으고, C 주문 요청의 분기도 DB 3으로 바꾸는 구조

물리 DB 수를 그대로 hash(key) % N에 넣으면 DB 수를 바꿀 때 많은 키의 목적지가 달라진다. 키 10만 개를 SHA-256으로 해시해 네 DB에서 다섯 DB로 늘리는 계산을 비교했다. 고정 버킷 방식은 100개 중 기존 DB마다 5개씩 총 20개를 새 DB로 옮겼다.

배치 방식위치가 바뀐 키전체 키 대비
hash(key) % 4에서 % 5로 변경79,650개79.7%
고정 논리 버킷 100개 중 20개 이동19,977개20.0%

논리 샤드를 고정하면 옮길 버킷을 선택할 수 있고, 버킷과 DB의 매핑을 갱신해 경로를 바꾼다.

특정 사용자에 집중된 요청을 분리하기 위한 핫키 라우팅 구성하기

요청이 집중되는 샤드 키를 핫키라고 한다. 사용자 42의 주문 요청이 DB 1에 몰리면 같은 DB를 사용하는 A의 요청도 커넥션이나 쿼리 처리를 기다릴 수 있다. 데이터 건수가 고르게 나뉘어 있어도 요청량은 한 DB에 집중될 수 있다.

42의 데이터와 요청을 DB 3으로 분리하면 A와 다른 DB에서 처리할 수 있다. 점선은 새로 추가하는 DB와 라우팅 경로다.

사용자 42의 요청이 DB 1에 집중돼 A도 기다리는 상황과, 42를 DB 3으로 분리해 A와 다른 DB에서 처리하는 비교

42의 데이터를 옮긴 뒤 일반 키의 라우팅 규칙보다 먼저 전용 샤드를 선택하도록 구성한다.

직접 구현에서는 핫키와 대상 샤드를 정책 저장소에서 관리하고, 변경된 정책을 받으면 불변 맵을 교체한다. 샤드는 트랜잭션 전에 선택한다.

AtomicReference<Map<Long, String>> hotKeyShards =
    new AtomicReference<>(Map.of());

String shardFor(long tenantId) {
    return hotKeyShards.get().getOrDefault(
        tenantId, "ds_" + Math.floorMod(tenantId, 4)
    );
}

void applyHotKeyPolicy(Map<Long, String> nextPolicy) {
    hotKeyShards.set(Map.copyOf(nextPolicy));
}TenantRouter.java

ShardingSphere-JDBC와 Proxy에서는 샤딩 알고리즘에서 핫키의 목적지를 먼저 선택한다. ds_hot을 DataSource와 실제 데이터 노드에 추가하고 주문과 주문 항목의 tenant_id 분산 규칙에 연결한다. ShardingSphere 알고리즘

shardingAlgorithms:
  tenant_route:
    type: INLINE
    props:
      algorithm-expression: "ds_${tenant_id == 42 ? 'hot' : tenant_id % 4}"sharding.yaml

Vitess는 numeric_static_map으로 명시적 매핑과 기본 해시를 조합할 수 있다. 다음 Vindex를 주문과 주문 항목의 tenant_id에 연결하면 42는 keyspace ID 1로, 나머지는 해시로 매핑된다.

{
  "tenant_route": {
    "type": "numeric_static_map",
    "params": {
      "json": "{\"42\":1}",
      "fallback_type": "hash"
    }
  }
}vschema.json

일반 키도 같은 샤드에 도착할 수 있으므로 전용 샤드로 격리하려면 keyrange 배치까지 설계해야 한다. Vitess Vindex

운영 중 42의 경로를 교체하는 예

ds_hot의 연결과 테이블을 준비한 뒤 42의 경로를 ds_2 → ds_hot으로 바꾼다. 요청 재개 전에는 마지막 변경분을 반영하고 모든 앱이 ds_hot으로 라우팅하는지 확인한다. 실선은 요청 경로, 점선은 데이터 복제다.

각 앱이 ds_2로 요청하다 전환 대기 후 ds_hot으로 라우팅을 바꾸고 요청을 재개하는 과정

applyHotKeyPolicy(Map.of(42L, "ds_hot"));
방식운영 중 교체하는 위치
직접 구현각 애플리케이션에서 새 정책으로 applyHotKeyPolicy 호출
ShardingSphere-JDBC로컬 YAML 구성은 변경한 설정으로 DataSource를 다시 구성해 배포. Cluster 모드는 공유 메타데이터 변경을 전파
ShardingSphere-ProxyDistSQL ALTER SHARDING TABLE RULE로 주문과 주문 항목의 라우팅 규칙 변경
Vitess변경한 numeric_static_map을 포함한 전체 VSchema를 ApplyVSchema로 적용. 바뀐 keyspace ID에 맞는 데이터 이관은 별도로 구성

복사 중 추가되거나 수정된 데이터까지 반영한 뒤 DB 전환하기

복사 중 발생한 추가와 수정, 삭제까지 반영한 뒤 요청 경로를 전환한다.

방식데이터 이동전환과 중단 판단한계와 주의 사항
직접 구현전체 복사 후 CDC 또는 원본과 대상에 dual write기존 쓰기 중지 후 새 정책 적용한쪽 쓰기만 성공할 때의 재시도와 정합성 처리를 직접 구현
ShardingSphere-JDBC외부 도구로 데이터 이관앱 요청 제어 후 샤딩 규칙 변경데이터 이동과 앱 인스턴스별 설정 반영을 별도로 관리
ShardingSphere-Proxy migration (5.5.3)새 대상 클러스터로 전체 복사와 증분 반영read-only 구간이 필요할 수 있으며 트래픽 전환은 운영자가 제어복사 속도와 병렬도를 낮추면 원본 부하는 줄고 이관 시간은 증가
Vitess ReshardVReplication으로 복사와 증분 반영, VDiff 검증SwitchTraffic에서 쓰기 일시 중지, VTGate 버퍼링으로 요청 대기기존 Vindex 유지, 버퍼 용량과 timeout 제약

재샤딩 중 요청 지연과 오류 측정을 위해 실험하기

버퍼링은 전환 중 요청을 대기시켰다가 새 샤드로 보내는 기능이다. Reshard, VTGate 버퍼링

아래는 명시적 트랜잭션 실행의 성공 요청 p95다.

가로축은 구간 순서다. 위 그래프는 전체 구간을, 아래 그래프는 약 2s의 전환 구간을 제외한 나머지 네 구간을 연결해 보여준다.

전체 구간의 읽기와 쓰기 p95에서 전환 지연은 약 2s로 표시하고, 아래 그래프는 전환 구간을 제외한 네 구간의 p95를 연결한 비교

전환 중 요청 4건이 버퍼에 들어갔다가 다시 처리됐고 약 2초까지 기다렸다. 아래 최대 지연에는 오류 응답까지 포함한다.

쓰기 방식전환 읽기 최대전환 쓰기 최대쓰기 오류
autocommit INSERT2,049.5ms2,036.9ms2
BEGIN → INSERT → COMMIT2,013.2ms1,985.3ms0

autocommit INSERT에서는 버퍼 해제 직후 VT13001: [BUG] unexpected 'autocommitted' state in transaction 오류가 발생했다. autocommitted는 SQL을 보내기 전에 설정하는 VTGate의 내부 최적화 상태다. 이 상태가 재시도에도 남아 새 트랜잭션 ID를 등록할 때 충돌한다.

명시적 트랜잭션은 처음부터 일반 트랜잭션 경로를 사용해 이 autocommit 최적화를 건너뛴다. VTGate 재시도, autocommit 상태 검사, 트랜잭션 실행

autocommit은 재샤딩 후 재시도에서 남아 있는 autocommitted 상태와 새 트랜잭션 ID가 충돌하고, 명시적 BEGIN은 해당 최적화를 생략해 COMMIT하는 비교


Share this post:

Previous Post
[26.10.30] 알툴즈 AI API의 정책 관리를 위한 코드 역분석과 관리 화면 구성하기
Next Post
[26.10.28] ClickHouse 800억 건 집계 비용 줄이기