콘텐츠로 이동

DB Replication & Sharding

분류: Layer 8 - 데이터베이스 심화

이 문서는 한 대의 데이터베이스가 감당하지 못하는 순간을 다룬다. 트랜잭션 문서에서 커밋 경계와 WAL(Write-Ahead Log, 선기록 로그)을 봤고, 인덱스 문서에서 “DB가 너무 많은 것을 읽으면 느려진다”는 감각을 잡았다면 이제 질문이 바뀐다.

한 대의 DB에서 읽기, 쓰기, 장애 복구, 저장 용량을 모두 해결할 수 없을 때 무엇을 복사하고, 무엇을 나누고, 어떤 일관성을 포기해야 하는가.


Replication(복제) 은 같은 데이터를 여러 DB 서버에 복사해 읽기 부하와 장애 위험을 나누는 전략이다. 보통 하나의 Primary가 쓰기를 받고, 하나 이상의 Replica가 Primary의 변경 로그를 따라가며 읽기 요청을 분산한다.

Sharding(샤딩) 은 데이터를 행 단위로 여러 DB 서버에 나누어 저장하는 전략이다. 각 행이 어느 Shard로 갈지는 Shard Key가 결정한다. 샤딩은 복제와 달리 모든 서버가 전체 데이터를 갖지 않는다.

두 전략은 모두 수평 확장(horizontal scaling)처럼 보이지만 해결하는 병목이 다르다.

전략가장 먼저 해결하는 문제늘어나는 것새로 생기는 비용
Replication읽기 부하, 장애 시 대체 경로같은 데이터의 복사본복제 지연, failover 판단, 읽기 일관성
Sharding쓰기 부하, 저장 용량, 단일 노드데이터가 나뉜 여러 원본shard key 설계, resharding, cross-shard

핵심은 “복제하면 안전하다”, “샤딩하면 빨라진다”가 아니다. 복제는 원본과 복사본 사이의 시간차를 만들고, 샤딩은 하나의 쿼리가 여러 조각을 건너야 하는 비용을 만든다.


2. 선행 기술의 한계 — 왜 단일 DB를 넘어가나

섹션 제목: “2. 선행 기술의 한계 — 왜 단일 DB를 넘어가나”

단일 DB는 이해하기 쉽다. 모든 쓰기는 한 곳으로 가고, 모든 읽기도 같은 곳에서 일어난다. 트랜잭션 경계, 제약 조건, 인덱스, 백업 정책을 한 서버 또는 한 클러스터 안에서 생각하면 된다.

문제는 서비스가 커질수록 병목이 한 가지가 아니라는 점이다.

관찰되는 현상단일 DB에서 생기는 한계바로 샤딩해야 하나?
조회 API가 느려지고 CPU가 높다읽기 쿼리가 Primary를 계속 점유한다보통은 아니다. 먼저 인덱스와 Replica를 본다.
쓰기 p99가 길어지고 lock wait가 늘다쓰기 작업이 같은 데이터와 인덱스를 놓고 경합한다가능성은 있지만 모델과 쿼리 모양을 먼저 본다.
장애 시 수분 이상 중단된다Primary가 죽으면 쓰기 경로가 사라진다복제와 failover 설계가 우선이다.
테이블/인덱스가 한 노드의 한계를 넘다저장 공간, vacuum, 백업, restore 시간이 커진다샤딩 또는 파티셔닝을 검토한다.
특정 고객/계정만 계속 터진다부하 분포가 균등하지 않다shard key, 캐시, 전용 shard를 함께 본다.

복제와 샤딩의 철학은 서로 다르다.

복제는 같은 사실을 여러 곳에 두고, 읽기와 장애 대응을 더 쉽게 만든다. 대신 원본과 복사본이 항상 같은 순간에 같은 값을 보리라고 가정할 수 없다.

샤딩은 서로 다른 사실을 서로 다른 원본에 두고, 쓰기와 용량을 나눈다. 대신 하나의 업무 질문이 여러 shard에 걸치면 애플리케이션이 더 어려워진다.

따라서 이 문서의 순서는 먼저 복제, 그다음 샤딩이다. 대부분의 관계형 DB 서비스는 “단일 Primary + Read Replica”를 먼저 지나고, 그 뒤에도 쓰기나 용량 한계가 남을 때 샤딩을 검토한다.


3. Replication: 같은 데이터를 복사해 읽기와 장애를 나누기

섹션 제목: “3. Replication: 같은 데이터를 복사해 읽기와 장애를 나누기”

먼저 용어를 좁게 정의한다.

용어
Primary쓰기를 받아 커밋 순서를 결정하는 원본 DB 서버다. 예전 문서에서는 master라고도 불렀지만 이 문서에서는 Primary라고 부른다.
ReplicaPrimary의 변경을 따라가는 복사본 DB 서버다. 대부분 읽기 전용으로 사용한다.
WAL / Binary LogDB 변경을 순서대로 남기는 로그다. PostgreSQL은 WAL, MySQL은 Binary Log를 복제의 입력으로 쓴다.
LSN(Log Sequence Number)WAL 안의 위치를 나타내는 주소다. “어디까지 보냈고, 어디까지 적용했는가”를 비교하는 기준이다.
Replication LagPrimary에 커밋된 변경이 Replica에서 보이기까지의 지연이다. 시간으로도 보고, LSN 차이로도 본다.
Read-after-write Consistency내가 방금 쓴 데이터를 바로 다음 읽기에서 볼 수 있어야 한다는 요구사항이다.
FailoverPrimary 장애 때 쓰기 경로를 다른 서버로 넘기는 절차다.
PromotionReplica를 새 Primary로 승격하는 동작이다.

이 용어들은 체크리스트나 운영 명령에만 나오면 안 된다. 복제의 핵심 문제를 이해하려면 “Primary가 커밋 순서를 만들고, Replica는 로그를 뒤따라가며, 그 사이의 간격이 lag”라는 구조를 먼저 잡아야 한다.

사용자 목록, 상품 상세, 피드, 관리자 검색 같은 읽기 요청은 쓰기 요청보다 훨씬 많을 수 있다. 단일 Primary가 모든 읽기와 쓰기를 받으면, 조회 쿼리가 CPU와 I/O를 점유해 쓰기까지 느려진다.

예를 들어 다음과 같은 서비스가 있다고 하자.

초당 요청:
읽기 9,500 req/s
쓰기 500 req/s
단일 Primary가 처리하는 일:
읽기 9,500 + 쓰기 500 = 10,000 req/s

읽기 쿼리가 대부분이고 각 조회가 최신 값을 100% 요구하지 않는다면, 읽기 일부를 Replica로 보내는 것이 자연스럽다.

Primary:
쓰기 500 req/s
최신성이 꼭 필요한 읽기 500 req/s
Replica 1~3:
나머지 읽기 9,000 req/s를 나누어 처리

이때 복제의 목표는 Primary를 없애는 것이 아니다. 쓰기 순서와 커밋 판단은 Primary에 남겨두고, 읽기와 장애 대응 경로를 늘리는 것이다.

Replica가 Primary의 데이터 파일 전체를 계속 복사하면 비용이 너무 크다. 수백 GB 또는 수 TB의 데이터 파일을 매번 복사할 수는 없다. 대신 DB는 커밋된 변경을 로그에 순서대로 기록하고, Replica는 그 로그를 받아 같은 순서로 적용한다.

Primary-Replica 로그 기반 복제 흐름
flowchart LR
A[Application Write] --> B[Primary]
B --> C[WAL/Binary Log]
C --> D[Send Log]
D --> E[Replica receives]
E --> F[Replay]
F --> G[Replica Read]

Primary는 변경 로그를 만들고, Replica는 그 로그를 받아 적용한다. Primary와 Replica 사이에는 항상 처리 위치의 차이가 생길 수 있다.

PostgreSQL streaming replication을 단순화하면 세 단계다.

Primary
1. 트랜잭션이 커밋된다.
2. WAL에 변경과 커밋 기록이 남는다.
3. WAL sender가 Replica로 WAL 레코드를 보낸다.
Replica
4. WAL receiver가 레코드를 받는다.
5. 받은 WAL을 저장한다.
6. startup/replay 프로세스가 WAL을 재생해 데이터 파일에 반영한다.

이 흐름에서 LSN은 “로그 스트림의 위치”다. 파일 줄 번호처럼 생각하면 된다. Primary가 100번 줄까지 만들었는데 Replica가 92번 줄까지만 적용했다면 Replica는 8줄 뒤처져 있다.

MySQL을 볼 때는 이름이 조금 달라진다. PostgreSQL은 보통 WAL 바이트 스트림을 따라가는 물리적 복제를 먼저 떠올리고, MySQL은 Primary의 Binary Log를 Replica가 Relay Log로 받아 SQL thread가 다시 적용하는 흐름을 떠올리면 된다. 두 방식 모두 핵심은 같다. 전체 데이터 파일을 계속 복사하는 것이 아니라, 커밋된 변경 순서를 로그로 전달한다는 점이다.

실제 PostgreSQL에서는 sent_lsn, write_lsn, flush_lsn, replay_lsn 같은 위치가 나뉜다.

위치질문lag이 크면 의심할 것
sent_lsnPrimary가 어디까지 보냈나Primary 전송 또는 네트워크
write_lsnReplica가 어디까지 받았나네트워크 수신, Replica write
flush_lsnReplica가 어디까지 디스크에 flush했나Replica 디스크 I/O
replay_lsnReplica가 어디까지 적용했나Replica replay CPU/I/O, lock

여기서 중요한 학습 포인트는 “Replica Lag”이 하나의 원인이 아니라는 점이다. 네트워크가 느려서 못 받는 lag과, 받았지만 replay가 느려서 못 보이는 lag은 대응이 다르다.

3.4 worked example: 방금 쓴 게시물이 안 보이는 이유

섹션 제목: “3.4 worked example: 방금 쓴 게시물이 안 보이는 이유”

다음 사건을 손으로 따라가 보자.

t=0ms
사용자가 게시물을 작성한다.
Primary가 posts(id=101, user_id=7, body='hello')를 커밋한다.
애플리케이션은 "작성 완료"를 응답한다.
t=20ms
사용자가 목록 페이지로 이동한다.
목록 SELECT가 Replica로 라우팅된다.
t=80ms
Replica가 아직 해당 WAL을 replay하지 못했다.
목록에는 id=101 게시물이 없다.
t=250ms
Replica가 WAL을 replay한다.
그 뒤의 목록 조회에는 게시물이 보인다.

이 현상은 데이터 유실이 아니다. 비동기 복제에서는 Primary 커밋과 Replica 반영 사이에 시간이 있다. 문제는 이 시간이 사용자 경험이나 업무 규칙에 허용되는지다.

같은 lag도 도메인마다 의미가 다르다.

데이터300ms lag5초 lag5분 lag
게시물 목록대체로 허용 가능UX 불만 가능장애로 볼 수 있음
결제 완료 내역위험대부분 허용 불가장애
검색 인덱스흔히 허용 가능서비스 정책에 따라 가능freshness 문제
재고 차감 직후 재조회위험허용 불가장애

그래서 read-after-write consistency가 필요하면 그 읽기는 Replica가 아니라 Primary에서 수행하거나, 쓰기 응답에 포함된 객체를 그대로 사용하거나, lag이 특정 기준 이하일 때만 Replica를 사용해야 한다.

복제 방식의 핵심 선택지는 “Primary가 언제 커밋 성공을 응답할 것인가”다.

동기 복제와 비동기 복제

비동기 복제

Primary가 먼저 커밋 성공을 응답하고, Replica는 나중에 따라온다.

읽기 확장과 낮은 쓰기 지연을 우선할 때

동기 복제

Replica 쪽 확인을 기다린 뒤 Primary가 커밋 성공을 응답한다.

최근 커밋 손실을 줄이는 것이 쓰기 지연보다 중요할 때

반동기 복제

Replica가 로그를 받았다는 확인 정도만 기다리는 중간 선택이다.

완전 동기보다 지연을 줄이면서 손실 위험을 낮추고 싶을 때

간단한 숫자로 보면 trade-off가 보인다.

Primary 내부 커밋 처리: 8ms
Replica까지 네트워크 왕복: 12ms
Replica flush/replay 확인: 5ms
비동기 복제 응답:
약 8ms 뒤 성공 응답
단, 장애가 바로 나면 Replica에 없는 커밋이 있을 수 있음
동기 복제 응답:
약 8ms + 12ms + 5ms = 25ms 뒤 성공 응답
단, 확인받은 Replica에는 커밋 기록이 있음

동기 복제가 항상 더 좋지는 않다. 네트워크 지연이나 Replica 장애가 Primary 쓰기 지연으로 전파된다. 결제 원장처럼 손실 위험을 극도로 낮춰야 하는 쓰기와, 소셜 피드처럼 낮은 지연과 높은 처리량이 더 중요한 쓰기는 서로 다른 선택을 한다.

3.6 Failover와 Promotion: 장애 때 무엇을 믿을 것인가

섹션 제목: “3.6 Failover와 Promotion: 장애 때 무엇을 믿을 것인가”

복제가 있어도 장애 대응은 자동으로 쉬워지지 않는다. Primary가 죽으면 누군가를 새 Primary로 정해야 한다. 이때 Replica를 새 쓰기 노드로 올리는 동작이 promotion이다.

가장 단순한 흐름은 다음과 같다.

1. Primary가 응답하지 않는다.
2. 여러 Replica 중 가장 최신에 가까운 후보를 고른다.
3. 후보 Replica를 promote한다.
4. 애플리케이션 쓰기 endpoint를 새 Primary로 바꾼다.
5. 이전 Primary가 살아나면 자동으로 쓰기를 받지 못하게 격리한다.

여기서 어려운 점은 “가장 최신”과 “정말 Primary가 죽었는가”를 확신하기 어렵다는 점이다.

실패 모드왜 위험한가필요한 판단
오래된 Replica promotion최근 커밋 일부가 사라진 것처럼 보일 수 있다.lag, RPO, 업무 허용 손실
split-brain두 Primary가 동시에 쓰기를 받아 서로 다른 진실을 만든다.fencing, 단일 writer 보장
endpoint 전환 지연앱 일부는 옛 Primary, 일부는 새 Primary를 본다.연결 재시도, DNS/프록시 전환
이전 Primary 재합류이전 Primary가 낡은 상태로 다시 쓰기를 받으면 충돌한다.재동기화 또는 폐기

RPO(Recovery Point Objective)는 “장애 때 얼마만큼의 데이터 손실을 허용할 수 있는가”다. RTO(Recovery Time Objective)는 “얼마나 빨리 서비스를 복구해야 하는가”다. 복제와 failover 설계는 이 두 값을 동시에 본다.

복제는 다음을 보장하지 않는다.

  1. 모든 읽기가 항상 최신이라는 보장
  2. Primary의 쓰기 병목이 사라진다는 보장
  3. 잘못된 쿼리가 사라진다는 보장
  4. 장애 때 데이터 손실이 절대 없다는 보장

특히 “Read Replica를 붙이면 DB가 빨라진다”는 말은 반만 맞다. 읽기 쿼리 중 Replica로 보내도 되는 쿼리만 분산된다. 쓰기 쿼리, read-after-write가 필요한 읽기, 강한 일관성이 필요한 조회는 여전히 Primary를 봐야 한다.

반례를 보자.

상황:
사용자가 결제한다.
결제 성공 후 "현재 포인트 잔액"을 보여준다.
잘못된 설계:
결제 쓰기는 Primary
잔액 읽기는 Replica
결과:
Replica lag 때문에 결제 전 잔액을 보여줄 수 있다.
사용자는 결제가 실패했다고 생각하고 재시도할 수 있다.

이 경우의 해법은 모든 트래픽을 Primary로 보내는 것이 아니라, “방금 쓴 사용자의 잔액 읽기”만 Primary로 보내는 것이다. 같은 서비스 안에서도 데이터 종류와 요청 맥락에 따라 읽기 라우팅이 달라질 수 있다.

운영 명령을 외우기 전에 증상과 의미를 연결해야 한다.

증상의미먼저 의심할 개념
방금 작성한 글이 목록에 없다Replica가 Primary보다 뒤처져 있다replication lag, read-after-write
Replica CPU만 높고 lag이 늘어난다WAL replay를 따라가지 못한다replay lag, long query on replica
pg_wal 또는 RDS storage가 계속 증가한다어떤 consumer/slot이 WAL을 붙잡을 수 있다replication slot, retained WAL
failover 뒤 일부 데이터가 사라진 것처럼 보인다promote한 Replica가 최신 커밋을 다 갖지 않았을 수 있다RPO, async replication
장애 복구 뒤 두 DB의 값이 다르다split-brain 또는 잘못된 promotion 가능성fencing, single writer

실패 신호는 “무슨 명령을 실행할까”보다 “어떤 보장이 깨졌나”로 읽어야 한다. 그래야 PostgreSQL, MySQL, RDS, Aurora, CDC 도구가 달라져도 같은 사고 모델을 유지할 수 있다.

복제를 붙인 뒤 가장 자주 생기는 설계 질문은 “이 SELECT를 Primary로 보낼까, Replica로 보낼까”다. 정답은 테이블 이름이 아니라 읽기 의미에 달려 있다.

읽기 종류예시기본 라우팅이유
쓰기 응답 직후 자기 데이터게시글 작성 후 상세 보기Primary 또는 쓰기 응답 재사용read-after-write가 필요하다.
돈/재고/권한처럼 틀리면 위험한 값결제 후 잔액, 권한 변경 후 접근 확인Primary오래된 값을 보여주면 중복 결제나 권한 오류가 된다.
목록/피드/검색 후보상품 목록, 공개 게시물 목록Replica 가능수백 ms~수초 지연을 UX로 흡수할 수 있다.
관리자 통계/리포트어제 매출, 월별 가입자Replica 또는 별도 분석 저장소최신성보다 Primary 보호가 중요할 수 있다.
배치 읽기정산 후보 스캔, 알림 대상 조회Replica 우선 검토Primary의 OLTP 쓰기를 방해하지 않는 것이 중요하다.

작은 의사결정 예시를 보자.

요청 A:
POST /orders
-> 주문 생성 후 생성된 주문 상세를 응답한다.
판단:
방금 생성한 주문은 save 결과나 Primary 조회로 처리한다.
Replica에서 다시 읽으면 lag 때문에 주문 없음으로 보일 수 있다.
요청 B:
GET /products?category=book
-> 상품 목록을 보여준다.
판단:
최신 상품 1개가 수백 ms 늦게 보이는 것이 치명적이지 않다면 Replica 가능.
단, 재고 수량을 즉시 결제 판단에 쓰면 해당 값은 별도 최신성 기준이 필요하다.

이렇게 읽기를 분류하면 “모든 SELECT는 Replica”나 “중요하니 전부 Primary” 같은 양극단을 피할 수 있다. 복제의 성과는 Replica 개수보다 라우팅 정책의 정확도에서 나온다.

ORM(Object-Relational Mapper)이 read/write routing을 자동으로 지원하더라도 이 분류는 사라지지 않는다. find()가 기본적으로 Replica로 간다면, 주문 생성 직후 상세 조회나 권한 변경 직후 검증처럼 read-after-write가 필요한 경로는 명시적으로 Primary를 사용해야 한다. 자동 라우팅은 부하 분산 장치이지, 도메인별 최신성 요구를 대신 판단해주지 않는다.

Replication lag을 운영 지표로 보려면 “몇 초부터 장애인가”를 도메인별로 정해야 한다. 단순히 0초가 아니면 실패라고 보면 Replica를 쓸 수 없고, 5분까지 괜찮다고 보면 사용자에게 오래된 사실을 보여줄 수 있다.

도메인허용 가능한 lag 감각이유
결제 승인 결과거의 0에 가까워야 함중복 결제, 환불, 고객 신뢰 문제가 생긴다.
재고 차감 직후 재조회매우 낮아야 함품절 상품을 계속 판매할 수 있다.
게시글 목록수백 ms~수초 가능작성 직후 자기 글만 Primary로 보완할 수 있다.
검색 색인수초~수분 가능할 수 있음검색 freshness를 제품 정책으로 설명할 수 있다.
일별 리포트수분 이상 가능실시간 의사결정이 아니라면 batch 기준으로 본다.

lag 예산은 한 번 정하면 끝나는 값이 아니다. 기능이 바뀌면 다시 봐야 한다. 예를 들어 상품 목록은 Replica로 충분했더라도, 그 목록에서 “남은 재고 1개”를 바로 결제 버튼 활성화 기준으로 쓰기 시작하면 최신성 요구가 올라간다.


4. Sharding: 서로 다른 데이터를 서로 다른 원본에 두기

섹션 제목: “4. Sharding: 서로 다른 데이터를 서로 다른 원본에 두기”

샤딩 용어도 먼저 좁게 잡는다.

용어
Shard전체 데이터 중 일부 행을 책임지는 독립 DB 서버 또는 DB 그룹이다.
Shard Key어떤 행이 어느 shard에 저장될지 결정하는 컬럼 또는 값이다.
Shard Mapshard key 범위나 해시 결과가 실제 shard 주소로 어떻게 매핑되는지 나타내는 정보다.
Hotspot / Hot Shard특정 shard나 특정 key에 요청이 몰려 다른 shard보다 먼저 병목이 되는 상태다.
Reshardingshard 수, 범위, 배치 방식을 바꾸기 위해 데이터를 다시 나누는 작업이다.
Cross-shard Query하나의 쿼리나 트랜잭션이 여러 shard를 동시에 봐야 하는 상황이다.

샤딩의 핵심은 “데이터를 나누면 부하도 나뉜다”가 아니라 나눈 기준과 쿼리 기준이 맞아야 부하가 나뉜다는 점이다.

복제는 읽기를 늘릴 수 있지만, Primary 쓰기 한계와 저장 용량 한계를 해결하지 못한다. 모든 쓰기는 여전히 Primary의 WAL, 인덱스, lock, storage를 지난다.

여기서 checkpoint와 autovacuum 같은 배경 작업도 같이 커진다. checkpoint는 메모리에 쌓인 변경을 디스크에 반영하는 경계이고, autovacuum은 PostgreSQL에서 오래된 행 버전과 통계를 정리하는 배경 작업이다. 처음에는 이름만 잡아도 된다. 쓰기가 늘면 “INSERT 처리”뿐 아니라 로그, 인덱스, 배경 정리 비용도 함께 커진다는 것이 핵심이다.

예를 들어 주문 이벤트가 초당 40,000건 들어오고, 각 쓰기가 여러 인덱스를 갱신한다고 하자.

단일 Primary:
INSERT 40,000/s
각 INSERT마다 테이블 + 인덱스 4개 갱신
WAL 생성량 증가
autovacuum, checkpoint, backup 부담 증가
Read Replica 10대:
읽기는 나눌 수 있음
하지만 INSERT 40,000/s는 여전히 Primary 한 곳에서 처리

이때 데이터를 고객, tenant, 지역, 시간, 계정 등 어떤 기준으로 나누면 쓰기와 저장 용량을 여러 원본으로 분산할 수 있다.

Shard A: tenant_id 1~1000
Shard B: tenant_id 1001~2000
Shard C: tenant_id 2001~3000
각 shard:
자기 범위의 쓰기만 처리
자기 범위의 인덱스와 WAL만 관리

Shard key는 샤딩 설계의 거의 전부다. 이유는 간단하다. 애플리케이션이 어떤 shard를 볼지 빠르게 결정할 수 있어야 하고, 대부분의 쿼리가 그 key를 포함해야 한다.

좋은 shard key는 세 가지 질문을 통과해야 한다.

  1. 값의 종류가 충분히 많은가.
  2. 데이터 양과 트래픽이 비교적 균등하게 퍼지는가.
  3. 중요한 쿼리의 WHERE 조건에 자주 등장하는가.

나쁜 예시는 is_premium, status, created_date처럼 값이 적거나 특정 값에 몰리는 key다. 좋은 후보는 tenant_id, user_id, account_id, organization_id처럼 업무 경계와 쿼리 경계가 함께 움직이는 값이다. 다만 user_id도 유명인 계정처럼 접근 편중이 심하면 hot key가 될 수 있다.

4.4 worked example: tenant_id로 샤딩하기

섹션 제목: “4.4 worked example: tenant_id로 샤딩하기”

SaaS 서비스가 고객사별 데이터를 저장한다고 하자. 대부분의 요청은 한 고객사의 주문, 사용자, 정산만 조회한다.

주요 쿼리:
SELECT * FROM orders WHERE tenant_id = 42 AND created_at >= ...
SELECT * FROM users WHERE tenant_id = 42 AND email = ...
UPDATE invoices SET status = 'PAID' WHERE tenant_id = 42 AND id = ...

이 경우 tenant_id는 좋은 shard key 후보가 된다. 요청마다 어느 tenant인지 알고 있고, 같은 tenant의 데이터가 한 shard에 있으면 cross-shard join을 피할 수 있기 때문이다.

function shardForTenant(tenantId: number): string {
if (tenantId <= 1000) return "shard-a";
if (tenantId <= 2000) return "shard-b";
return "shard-c";
}

하지만 이 설계도 완벽하지 않다.

반례:
tenant_id=7 고객사가 전체 트래픽의 40%를 만든다.
tenant_id=7은 shard-a에 있다.
결과:
shard-a CPU와 I/O만 먼저 포화된다.
shard-b, shard-c는 여유가 있어도 전체 서비스 p99가 나빠진다.

이 반례는 “고카디널리티 key면 충분하다”는 직관을 깨뜨린다. 값의 종류가 많아도 접근 분포가 한쪽으로 치우치면 hotspot이 생긴다.

샤딩 방식은 크게 범위 기반과 해시 기반으로 나눠 볼 수 있다.

Range Sharding과 Hash Sharding

Range Sharding

값의 범위로 shard를 나눈다. 예: user_id 1~100만은 shard A.

범위 조회와 데이터 위치 설명이 중요할 때

Hash Sharding

shard key를 해시하거나 modulo 계산해 shard를 고른다.

균등 분산과 단건 조회가 중요할 때

Directory 기반 Sharding

별도 shard map 테이블이 key와 shard 위치를 저장한다.

큰 고객을 별도 shard로 옮기는 등 유연성이 중요할 때

Range sharding은 사람이 이해하기 쉽고 범위 조회에 강하다.

Shard A: user_id 1 ~ 1,000,000
Shard B: user_id 1,000,001 ~ 2,000,000
Shard C: user_id 2,000,001 ~ 3,000,000

하지만 user_id가 증가하는 순서로 생성되면 새 사용자는 항상 마지막 shard로 들어간다. 최신 데이터 쓰기가 한 shard에 몰리는 것이다.

Hash sharding은 분산이 더 균등하다.

function shardForUser(userId: number, totalShards: number): number {
return userId % totalShards;
}
// user_id=1005, totalShards=3 -> shard 0
// user_id=1006, totalShards=3 -> shard 1
// user_id=1007, totalShards=3 -> shard 2

하지만 범위 조회가 약하다. user_id BETWEEN 1 AND 100 같은 조회가 모든 shard를 돌아야 할 수 있다. 또한 단순 modulo는 shard 수가 바뀌는 순간 매핑이 대부분 바뀐다.

4.6 Resharding: shard 수를 바꾸는 비용

섹션 제목: “4.6 Resharding: shard 수를 바꾸는 비용”

처음에 3개 shard로 시작했다가 4개로 늘린다고 하자.

기존:
shard = user_id % 3
변경:
shard = user_id % 4

user_id=1001을 계산해보면 문제가 보인다.

1001 % 3 = 2
1001 % 4 = 1

기존에는 shard 2에 있던 데이터가 새 규칙에서는 shard 1에 있어야 한다. 이런 일이 대부분의 key에서 발생하면, shard 추가가 단순한 서버 증설이 아니라 대규모 데이터 이동이 된다.

Resharding은 보통 다음 위험을 만든다.

위험설명
이중 쓰기이동 중 구 shard와 새 shard에 동시에 써야 할 수 있다.
읽기 라우팅 혼란어떤 데이터가 이미 이동됐는지 모르면 잘못된 shard를 읽는다.
백필 부하오래된 데이터를 복사하는 작업이 운영 DB의 I/O를 먹는다.
검증 비용이동 전후 row 수, checksum, 누락 데이터를 확인해야 한다.
롤백 어려움이동 중 일부만 성공하면 원래 상태로 되돌리기 어렵다.

따라서 샤딩은 “나중에 늘리면 되지”로 접근하면 위험하다. 처음부터 shard key와 resharding 경로를 함께 생각해야 한다.

4.7 Consistent Hashing: 이동량을 줄이는 아이디어

섹션 제목: “4.7 Consistent Hashing: 이동량을 줄이는 아이디어”

Consistent Hashing(일관적 해싱)은 shard를 원형 해시 링 위에 놓고, 데이터 key도 같은 링 위에 배치한다. 데이터는 자기 위치에서 시계 방향으로 만나는 첫 shard에 저장된다.

해시 링:
Shard A
|
key x ----+---- Shard B
|
Shard C
key x는 시계 방향으로 가장 가까운 Shard B에 저장된다.

새 shard를 추가하면 링의 일부 구간만 새 shard로 넘어간다. 단순 key % N보다 데이터 이동량이 훨씬 작다.

기존 shard 3개:
각 shard가 대략 1/3 범위를 담당
새 shard 1개 추가:
새 shard 주변 구간만 이동
모든 key를 다시 배치하지 않는다

비교 계산은 이렇게 잡으면 된다. 단순 hash(key) % 3에서 hash(key) % 4로 바꾸면 나머지 값의 의미가 바뀌어서 상당수 key가 새 shard가 아닌 기존 shard 사이에서도 이동한다. 반대로 consistent hashing은 새 shard가 링에 들어온 위치와 그 이전 노드 사이의 구간만 이동한다. key가 1억 개라면 “대부분 재배치”와 “새 shard가 맡는 구간만 재배치”의 운영 차이는 백업, 이중 쓰기, 검증 시간에서 크게 벌어진다.

하지만 consistent hashing도 만능은 아니다.

깨지는 조건왜 깨지는가대응 방향
물리 노드 수가 적다링 위치가 우연히 불균등해질 수 있다.virtual node로 분산을 부드럽게 한다.
특정 key가 매우 뜨겁다그 key를 맡은 노드 하나가 먼저 터진다.캐시, key salting, 전용 shard, fan-out 설계
노드 제거가 갑자기 일어난다제거된 노드 범위가 다음 노드로 몰린다.여유 용량, 점진적 이동, throttling

가상 노드(virtual node)는 하나의 물리 노드를 링 위 여러 위치에 배치하는 방식이다. 예를 들어 물리 노드 3개를 각각 100개의 virtual node로 나누면 총 300개의 작은 구간이 생기고, 각 물리 노드는 링 전체에 더 고르게 퍼진 구간을 담당한다.

그러나 접근 편중은 virtual node만으로 해결되지 않는다. 팔로워 1,000만 명인 계정의 프로필이 하나의 key라면, 그 key를 담당하는 shard가 계속 뜨겁다. 이 경우는 샤딩 알고리즘보다 캐시, fan-out, 데이터 모델 변경이 더 중요하다.

작은 장애 흐름으로 보면 더 분명하다. user_id를 hash해서 shard 32개에 고르게 나눴더라도 user_id=7 하나가 전체 읽기의 30%를 만들면 그 사용자가 있는 shard만 먼저 포화된다. virtual node는 많은 key의 분포를 부드럽게 만들지만, 하나의 hot key를 여러 shard로 찢어주지는 않는다.

4.8 Cross-shard Query와 Cross-shard Transaction

섹션 제목: “4.8 Cross-shard Query와 Cross-shard Transaction”

샤딩의 가장 큰 대가는 하나의 질문이 여러 shard로 찢어지는 것이다.

좋은 쿼리는 shard key를 포함한다.

SELECT *
FROM orders
WHERE tenant_id = 42
AND created_at >= '2026-04-01';

애플리케이션은 tenant_id=42만 보고 한 shard로 보낼 수 있다.

나쁜 쿼리는 shard key가 없다.

SELECT *
FROM orders
WHERE status = 'FAILED'
ORDER BY created_at DESC
LIMIT 100;

이 쿼리는 모든 shard에서 FAILED 주문을 찾고, 결과를 모아 다시 정렬해야 할 수 있다. 이를 scatter-gather라고 부른다.

1. Shard A에서 후보 100개 조회
2. Shard B에서 후보 100개 조회
3. Shard C에서 후보 100개 조회
4. 애플리케이션 또는 query layer에서 300개를 합쳐 정렬
5. 최종 100개 반환

shard가 3개일 때는 버틸 수 있어도 50개가 되면 네트워크, fan-out, tail latency가 커진다. 가장 느린 shard 하나가 전체 응답 시간을 끌어올린다.

Cross-shard transaction은 더 어렵다. 예를 들어 shard A의 계좌에서 돈을 빼고 shard B의 계좌에 넣어야 한다면, 두 shard가 하나의 원자적 커밋 경계를 공유해야 한다. 2PC(Two-Phase Commit) 같은 분산 트랜잭션을 쓸 수 있지만 지연, 장애 처리, 운영 복잡도가 커진다. 많은 서비스는 이런 작업을 피하려고 shard key를 업무 경계와 맞추거나, 보상 트랜잭션과 비동기 정산으로 모델을 바꾼다.

손으로 따라가면 비용이 보인다.

1. Shard A에서 A 계좌 10,000원 차감 준비
2. Shard B에서 B 계좌 10,000원 입금 준비
3. coordinator가 두 shard의 prepare 성공을 기다림
4. 둘 다 성공하면 commit, 하나라도 실패하면 rollback
문제:
2번 뒤 coordinator가 죽으면 두 shard는 "결정 대기" 상태가 된다.
한 shard만 commit되면 돈이 사라지거나 생긴다.

여기서 coordinator는 여러 shard의 prepare/commit/rollback 결정을 모아 최종 결정을 내리는 조정자다. 조정자가 장애 지점이 되면 각 shard가 “커밋해야 하는지 롤백해야 하는지”를 기다리는 시간이 생긴다.

그래서 shard key가 계좌나 tenant 같은 업무 트랜잭션 경계와 맞는지 먼저 본다. cross-shard transaction을 기술로 해결하기 전에, 애초에 중요한 변경이 한 shard 안에서 끝나도록 모델을 잡는 것이 더 단순한 경우가 많다.

샤딩 실패는 보통 “모든 DB가 느리다”가 아니라 “나눈 기준이 실제 트래픽과 맞지 않는다”로 나타난다.

증상의미먼저 의심할 개념
shard 하나만 CPU 90% 이상hot shard 또는 hot keyshard key 분포, 접근 편중
shard 추가 후 데이터가 안 보인다routing rule과 실제 데이터 위치가 어긋났다resharding, shard map
특정 관리자 검색만 느리다shard key 없는 cross-shard query일 수 있다scatter-gather, global lookup/index
대형 고객 하나가 전체 shard를 흔든다tenant 크기 편차가 크다dedicated shard, directory sharding
분산 트랜잭션이 자주 필요하다shard 경계가 업무 트랜잭션 경계와 맞지 않는다shard key 재검토

실패 신호를 보는 기준은 간단하다. 각 shard의 CPU, I/O, QPS, p99, 저장량이 비슷해야 한다는 뜻은 아니다. 중요한 것은 특정 shard가 항상 전체 서비스의 한계를 먼저 만들고 있는지, 그리고 그 이유가 데이터 양인지 접근 패턴인지 구분하는 것이다.

Shard key 후보는 감으로 고르지 말고 같은 질문으로 비교한다. 아래 표는 설계 리뷰 때 쓸 수 있는 학습용 scorecard다.

질문좋은 신호위험 신호
카디널리티가 충분한가수만~수백만 개 이상의 값boolean, status, 날짜 하나
트래픽이 균등한가상위 key 몇 개가 전체를 지배하지 않음top 1 key가 전체 QPS의 큰 비중
주요 쿼리에 포함되는가대부분의 OLTP 쿼리가 key를 WHERE에 포함관리자/검색 API 대부분이 key 없이 조회
업무 트랜잭션 경계와 맞는가한 주문/정산/권한 변경이 한 shard 안에서 끝남매 요청마다 여러 shard를 함께 갱신
성장 방향을 설명할 수 있는가큰 tenant 분리, shard 추가, archive 경로가 있음shard 수 변경 시 전체 재배치만 가능
운영 관측이 가능한가shard별 QPS, p99, storage, top key를 볼 수 있음평균 지표만 있고 분포를 모름

세 후보를 비교해보면 차이가 더 선명하다.

후보 key좋아 보이는 이유깨지는 지점판정
created_at기간별 조회에 편하다최신 시간대에 쓰기가 몰린다로그 archive에는 가능, OLTP shard key로는 위험
status업무 상태별 조회가 많다값 종류가 적고 PENDING 같은 값에 몰린다shard key로 부적합
tenant_id대부분 요청이 tenant 단위다대형 tenant가 hot shard가 될 수 있다좋은 후보지만 대형 tenant 분리 전략 필요
hash(user_id)분산이 균등하다사용자 간 집계와 범위 조회가 어렵다단건 사용자 조회 중심이면 가능

좋은 shard key는 단순히 “고르게 나뉘는 key”가 아니다. 고르게 나뉘면서도 애플리케이션의 주요 질문이 그 key를 알고 있어야 한다. 균등 분산만 보고 key를 고르면, 이후 모든 중요한 조회가 cross-shard가 될 수 있다.

샤딩된 시스템에서 자주 나오는 요구가 “shard key 없이도 빠르게 찾고 싶다”다. 예를 들어 email로 사용자를 찾아야 하는데 shard key가 tenant_id라고 하자.

users shard key:
tenant_id
조회 요구:
email = 'a@example.com'으로 사용자 찾기
문제:
email만으로는 어느 tenant shard에 있는지 모른다.

선택지는 몇 가지다.

선택동작비용
모든 shard 검색shard 전체에 email 조회를 보낸다shard 수가 늘수록 지연과 부하가 커진다.
global lookup tableemail -> tenant_id/shard 매핑 테이블을 따로 둔다lookup table의 정합성과 중복 갱신을 관리해야 한다.
검색 인덱스Elasticsearch 같은 별도 인덱스에 email을 둔다최종적 일관성과 재색인 비용을 받아들인다.
shard key 변경email 또는 user_id 중심으로 다시 나눈다기존 업무 쿼리와 resharding 비용이 커질 수 있다.

이 지점에서 샤딩과 CDC/Outbox가 이어진다. CDC(Change Data Capture, 변경 데이터 캡처)는 DB의 변경 로그나 변경 테이블을 읽어 외부 시스템으로 흘려보내는 방식이고, logical replication은 행 단위 변경처럼 DB가 이해할 수 있는 형태의 변경 스트림을 소비자가 읽게 하는 복제 방식이다. 운영 DB shard를 직접 cross-shard로 뒤지는 대신, 변경 이벤트를 별도 검색 인덱스로 보내는 구조가 필요할 수 있다. 다만 이 문서에서는 “왜 그런 저장소가 필요해지는가”까지만 잡고, 발행 신뢰성은 content/topics/L8/cdc-outbox.mdx에서 이어서 본다.


5. Replication과 Sharding을 함께 판단하기

섹션 제목: “5. Replication과 Sharding을 함께 판단하기”

복제와 샤딩은 대체재가 아니라 순서가 있는 선택지에 가깝다.

상황먼저 볼 것이유
읽기 QPS가 많고 쓰기는 낮다Read Replica, 캐시, 인덱스쓰기 원본을 나누기 전에 읽기 분산이 더 단순하다.
방금 쓴 데이터가 안 보이면 치명적이다Primary 읽기 경로, 동기/반동기 복제Replica 읽기는 lag을 만든다.
쓰기 자체가 Primary를 포화시킨다모델링, 인덱스 수, 배치 설계, 샤딩Replica는 Primary 쓰기를 줄이지 못한다.
데이터가 한 노드 저장/백업/복구 한계를 넘는다파티셔닝, 아카이빙, 샤딩저장 단위 자체를 줄여야 한다.
특정 tenant만 거대하다directory sharding, dedicated shard균등 hash만으로는 큰 tenant 문제를 못 풀 수 있다.
전역 검색/통계가 많다별도 search/warehouse/CDC 파이프라인모든 요청을 cross-shard로 만들면 운영이 어려워진다.

판단 순서는 다음처럼 잡으면 좋다.

1. 느린 이유가 읽기 비용인가?
-> 인덱스, 쿼리 계획, Read Replica, 캐시
2. 느린 이유가 쓰기 경합인가?
-> 트랜잭션 범위, 인덱스 수, lock, 배치 패턴
3. 단일 Primary 한계가 명확한가?
-> shard key 후보와 cross-shard 쿼리 비율 계산
4. 장애 허용 목표가 명확한가?
-> RPO/RTO, failover, promotion, split-brain 방지
5. 분산 후 새로 생길 비용을 감당할 수 있는가?
-> resharding, observability, data repair, 운영 도구

5.2 정량 감각: 읽기 95% 서비스와 쓰기 40% 서비스

섹션 제목: “5.2 정량 감각: 읽기 95% 서비스와 쓰기 40% 서비스”

읽기가 95%인 서비스와 쓰기가 40%인 서비스는 같은 “트래픽 증가”라도 해법이 다르다.

서비스 A:
읽기 95,000/s
쓰기 5,000/s
서비스 B:
읽기 60,000/s
쓰기 40,000/s

서비스 A는 Read Replica와 캐시로 큰 효과를 볼 가능성이 높다. Primary 쓰기는 5,000/s이므로, 읽기를 Replica로 옮기면 Primary의 CPU와 커넥션 부담이 크게 줄 수 있다.

서비스 B는 Replica를 늘려도 쓰기 40,000/s가 Primary에 남는다. 쓰기 트랜잭션 범위, 인덱스 갱신 비용, WAL 생성량, lock 경합을 먼저 줄여야 하고, 그래도 한계가 명확하면 shard key를 검토한다.

이 숫자는 정답 임계값이 아니다. 중요한 것은 비율이다. “읽기가 많다”는 말은 실제 read/write 비율, 쿼리 비용, 최신성 요구를 함께 봐야 의미가 있다.

5.3 반례: 샤딩이 문제를 악화시키는 경우

섹션 제목: “5.3 반례: 샤딩이 문제를 악화시키는 경우”

다음 상황에서는 샤딩이 오히려 문제를 키운다.

상황왜 악화되는가
쿼리 대부분이 shard key를 포함하지 않는다모든 요청이 scatter-gather가 되어 tail latency가 커진다.
전역 정렬과 페이지네이션이 핵심 기능이다shard별 결과를 합쳐 안정적으로 정렬하기 어렵다.
강한 cross-shard transaction이 자주 필요하다분산 트랜잭션 또는 보상 로직이 본문 기능보다 커진다.
팀이 shard map, migration, data repair 도구가 없다장애 시 원인 파악보다 데이터 위치 파악에 시간이 든다.
병목이 잘못된 인덱스 하나다샤딩은 쿼리 계획 문제를 감춘 채 운영 복잡도만 늘린다.

샤딩은 성능 버튼이 아니라 데이터 소유권을 나누는 구조 변경이다. 한 번 나누면 쿼리, 배치, 백업, 장애 대응, 데이터 수정 작업이 모두 shard를 의식해야 한다.

5.4 단일 DB에서 샤딩으로 가는 학습용 전환 경로

섹션 제목: “5.4 단일 DB에서 샤딩으로 가는 학습용 전환 경로”

실제 시스템을 갑자기 shard 8개로 나누는 경우는 드물다. 보통은 단일 DB를 최대한 이해하고, 복제로 읽기 병목을 줄이고, 남는 쓰기/용량 병목이 명확해졌을 때 데이터 소유권을 나눈다.

학습용 전환 경로는 다음과 같다.

단계 0: 단일 Primary
- 쿼리 계획, 인덱스, 트랜잭션 범위를 먼저 정리한다.
- 병목이 "DB가 한 대라서"인지 "쿼리가 잘못돼서"인지 분리한다.
단계 1: Read Replica
- 지연 허용 읽기와 최신성 필요 읽기를 분리한다.
- Replica lag과 read-after-write 실패를 관측한다.
단계 2: 테이블 파티셔닝/아카이빙
- 같은 서버 안에서 큰 테이블 관리 비용을 줄인다.
- 오래된 데이터 삭제, 기간별 조회, vacuum 범위를 줄인다.
단계 3: Shard key 후보 검증
- 주요 API의 WHERE 조건에 후보 key가 있는지 센다.
- top key QPS와 tenant/user 크기 분포를 본다.
- cross-shard가 될 API 목록을 만든다.
단계 4: 일부 도메인부터 샤딩
- 전역 검색, 통계, 정산 같은 보조 경로를 먼저 분리한다.
- 새 데이터부터 새 shard 규칙을 적용하거나, 작은 tenant부터 이동한다.
단계 5: Resharding 도구와 검증 체계
- shard map, dual read/write, backfill, checksum, rollback 경로를 준비한다.

각 단계의 성공 기준도 다르다.

단계성공 신호실패 신호
단일 Primary 정리느린 쿼리 원인이 plan/lock/index로 설명된다”느리다”만 있고 병목 이름이 없다
Read ReplicaPrimary 읽기 부하가 줄고 lag 예산 안에 있다방금 쓴 데이터 누락 문의가 늘어난다
Partitioning오래된 데이터 관리와 기간 조회 비용이 줄어든다쓰기 Primary 한계는 그대로인데 해결됐다고 착각한다
Shard 후보 검증주요 OLTP API 대부분이 shard key를 안다핵심 API가 전부 scatter-gather가 된다
일부 도메인 샤딩작은 범위에서 routing과 migration을 검증한다전역 migration 중 데이터 위치를 설명하지 못한다

이 경로는 보수적이지만, 샤딩의 진짜 비용을 드러낸다. 샤딩은 DB 서버를 여러 대로 늘리는 작업이 아니라, 데이터 위치를 애플리케이션 계약으로 끌어올리는 작업이다.

5.5 무엇을 문서화해야 나중에 운영할 수 있나

섹션 제목: “5.5 무엇을 문서화해야 나중에 운영할 수 있나”

샤딩이나 복제를 도입하면 코드보다 운영 지식이 먼저 오래 남는다. 다음 항목은 설계 문서에 남겨야 한다.

항목남겨야 하는 내용
읽기 라우팅 정책어떤 읽기는 Primary, 어떤 읽기는 Replica인지와 예외 조건
lag 예산도메인별 허용 lag, 알람 기준, 사용자 영향
failover 기준promotion 후보, RPO/RTO, 이전 Primary fencing 방식
shard key선택 이유, 반려한 후보, hot key 대응
shard mapkey 범위 또는 hash 결과가 어느 shard로 가는지
resharding 절차backfill, dual write, 검증, rollback 경계
cross-shard API허용한 API, 금지한 API, 별도 저장소로 보낸 API

이런 문서가 없으면 장애 때 질문이 바뀐다. “왜 느린가”가 아니라 “이 데이터가 원래 어디 있어야 하는가”부터 추적해야 한다. 분산된 DB에서 위치 정보는 운영 계약이다.


6.1 Replication vs Multi-AZ vs Read Replica

섹션 제목: “6.1 Replication vs Multi-AZ vs Read Replica”

AWS RDS 문맥에서는 Multi-AZ와 Read Replica가 자주 섞인다.

개념목적읽기 분산장애 때 역할
Multi-AZ Standby고가용성보통 읽기용으로 쓰지 않는다장애 시 standby가 Primary 역할을 맡는다.
Read Replica읽기 확장, DR 보조가능promotion으로 새 Primary가 될 수 있지만 lag을 고려해야 한다.
Aurora ReplicaAurora 클러스터 안 읽기 확장가능클러스터 구조와 endpoint 정책에 따라 failover 후보가 된다.

학습 포인트는 서비스 이름이 아니라 목적이다. “읽기를 나누기 위한 복사본”과 “장애 때 대체하기 위한 standby”는 겹칠 수 있지만 같은 말이 아니다.

Table partitioning은 같은 DB 서버 안에서 큰 테이블을 논리적으로 나누는 기능이다. Sharding은 서로 다른 DB 서버 또는 클러스터에 데이터를 물리적으로 나누는 설계다.

-- 같은 DB 안의 table partitioning 예시
CREATE TABLE orders (
id BIGINT,
created_at DATE,
amount NUMERIC(10, 2)
)
PARTITION BY RANGE (created_at);

Partitioning은 오래된 데이터 삭제, 특정 기간 조회, vacuum 범위 축소에 좋다. 하지만 같은 서버의 CPU, WAL, storage 한계를 그대로 공유한다면 쓰기 수평 확장이라고 부르기 어렵다.

구분Table PartitioningSharding
위치같은 DB 서버/클러스터 내부여러 DB 서버/클러스터
목적큰 테이블 관리, 기간 조회, 보관 정책쓰기/용량/장애 도메인 분산
쿼리DB planner가 partition pruning 가능애플리케이션 또는 라우터가 shard 선택
운영 난이도상대적으로 낮음높음

Vertical partitioning(수직 분할)은 행이 아니라 컬럼을 나누는 방식이다. 자주 읽는 작은 컬럼과 가끔 필요한 큰 컬럼을 분리한다.

분리 전 users:
id, email, name, created_at, bio, avatar_url, preferences_json
분리 후:
users: id, email, name, created_at
user_profiles: user_id, bio, avatar_url, preferences_json

이것은 shard와 다르다. usersuser_profiles는 보통 같은 사용자 행을 서로 다른 테이블에 나눈 것이다. 주로 row size, cache 효율, 민감 정보 접근 제어를 개선한다. 쓰기 수평 확장이나 여러 서버 분산을 자동으로 만들어주지는 않는다.

6.4 CAP 정리를 이 문서에서 필요한 만큼만 읽기

섹션 제목: “6.4 CAP 정리를 이 문서에서 필요한 만큼만 읽기”

CAP 정리는 분산 시스템에서 네트워크 파티션이 생겼을 때 consistency와 availability 중 무엇을 우선할지 묻는 렌즈다. 이 문서에서는 외우는 정리보다 다음 질문이 중요하다.

네트워크가 끊겼다.
두 노드가 같은 데이터를 최신이라고 확신할 수 없다.
선택 A:
틀릴 수 있으면 요청을 거절한다.
-> 일관성 우선, 가용성 일부 포기
선택 B:
일단 응답하고 나중에 맞춘다.
-> 가용성 우선, 일시적 불일치 허용

Primary-Replica 구조는 대개 단일 writer를 유지해 충돌을 줄인다. Multi-master나 AP 성격의 시스템은 가용성을 높이는 대신 충돌 해결과 eventual consistency를 설계해야 한다.

여기서 eventual consistency(최종적 일관성)는 “언젠가 맞겠지”라는 막연한 희망이 아니다. 쓰기 순서, 충돌 해결, 재시도, 보상 로직, 관측 지표가 있어야 의미가 있다.


7. 실무 연결: 무엇을 관측할 것인가

섹션 제목: “7. 실무 연결: 무엇을 관측할 것인가”

운영 절차보다 먼저 다음 질문을 던진다.

질문관측값의미
Replica가 최신을 따라오고 있는가replication lag, LSN 차이read-after-write 위험
lag이 어디서 생기는가sent/write/flush/replay 차이네트워크, 디스크, replay 병목 구분
Replica가 읽기 부하를 실제로 나눴는가Primary/Replica QPS, CPU라우팅 정책 검증
failover 목표를 만족하는가RPO/RTO, promotion 시간장애 대응 가능성
WAL이 과도하게 쌓이는가retained WAL, storage 증가replication slot 또는 CDC 정체

PostgreSQL에서 LSN 차이를 볼 수 있는 대표 쿼리는 선택 부록에 두었다. 처음 읽을 때는 쿼리 자체보다 sent_lsn - replay_lsn이 “Primary가 보낸 위치와 Replica가 적용한 위치의 차이”라는 사실을 잡으면 된다.

샤딩은 전체 평균보다 분포를 봐야 한다.

질문관측값의미
shard별 부하가 한쪽으로 치우치는가shard별 CPU, IOPS, QPS, p99hot shard
shard별 데이터 크기가 비슷한가row 수, index size, storageshard key 분포
cross-shard 쿼리가 많은가scatter-gather 호출 수, fan-out 수shard key와 쿼리 경계 불일치
resharding이 가능한가shard map, migration 상태, 검증 지표데이터 이동 준비도
특정 key가 전체를 흔드는가top key QPS, cache hit ratehot key

평균 QPS가 정상이어도 shard 하나의 p99가 치솟으면 사용자 일부는 장애를 겪는다. 샤딩된 시스템에서 “전체 평균”은 자주 거짓 안정감을 준다.

7.3 DynamoDB 파티션 키를 예시로 보는 shard key 감각

섹션 제목: “7.3 DynamoDB 파티션 키를 예시로 보는 shard key 감각”

DynamoDB는 내부적으로 파티션을 자동 관리하지만, 개발자가 고르는 partition key는 shard key와 같은 역할을 한다. 기존 문서에서 다뤘던 숫자 감각을 학습용으로 남기면 다음과 같다.

단일 물리 파티션 처리량 예시:
읽기: 3,000 RCU
쓰기: 1,000 WCU

정확한 한도나 과금 모델은 서비스와 시점에 따라 문서를 확인해야 하지만, 학습 포인트는 변하지 않는다. 테이블 전체 처리량이 충분해도 하나의 partition key에 쓰기가 몰리면 그 물리 파티션이 먼저 throttling될 수 있다.

나쁜 key와 개선 방향을 비교해보자.

key문제개선 방향
created_date = 2026-04-07오늘 데이터가 한 파티션에 몰린다.날짜 뒤에 bucket suffix를 붙여 쓰기를 나눈다.
status = PENDING값 종류가 적어 분산되지 않는다.업무 owner key와 상태를 조합하거나 GSI를 별도 설계한다.
user_id대체로 분산되지만 유명인 hot key 가능캐시, fan-out, 대형 계정 분리

Write sharding은 key에 작은 suffix를 붙여 쓰기를 나누는 기법이다.

원래 key:
event_date = 2026-04-07
write sharding 후:
2026-04-07#0
2026-04-07#1
...
2026-04-07#9
쓰기:
10개 bucket 중 하나로 분산
읽기:
10개 bucket을 모두 읽어 합산

이 기법은 쓰기 hotspot을 줄이지만 읽기 fan-out을 만든다. 따라서 “쓰기 병목을 줄이기 위해 읽기 비용을 늘리는 선택”임을 알고 써야 한다.

작은 계산으로 보면 trade-off가 선명하다. 특정 날짜 key 하나에 8,000 WCU가 몰리고 단일 파티션이 1,000 WCU 정도를 감당한다고 가정하면, bucket 10개는 쓰기를 대략 800 WCU씩 나누어 throttling 위험을 낮춘다. 대신 “오늘 전체 합계”를 읽을 때는 10개 bucket을 모두 읽고 합쳐야 하므로 읽기 요청 수, tail latency, 재시도 지점이 늘어난다.


증상:
사용자가 주문을 생성했다.
주문 상세는 보이는데 주문 목록에는 몇 초 동안 안 보인다.
원인 후보:
목록 조회가 Replica로 가고 있다.
Replica가 Primary WAL replay를 따라오지 못한다.
학습 판단:
유실인지 lag인지 먼저 구분한다.
같은 id를 Primary에서 읽으면 보이는지 확인한다.
해당 읽기가 read-after-write를 요구하는지 도메인 기준으로 판단한다.

해결 방향은 세 가지다.

  1. 방금 쓴 객체는 쓰기 응답값을 그대로 사용한다.
  2. 쓰기 직후 일정 시간 또는 특정 요청은 Primary에서 읽는다.
  3. Replica lag이 임계값보다 크면 Replica를 읽기 pool에서 잠시 제외한다.

여기서 중요한 것은 “모든 SELECT를 Primary로 보내기”가 아니다. 그렇게 하면 복제를 붙인 이유가 사라진다. 최신성이 필요한 읽기와 지연 허용 읽기를 나누는 것이 핵심이다.

증상:
DB shard 8개 중 shard-3만 CPU 95%.
특정 tenant의 관리자 페이지에서 timeout.
다른 tenant는 정상.
원인 후보:
shard key가 tenant_id이고, tenant 하나가 지나치게 크다.
또는 특정 tenant의 배치/리포트가 같은 shard를 계속 점유한다.

대응은 원인에 따라 다르다.

원인단기 대응장기 대응
읽기 hot key캐시, rate limit, query 결과 precomputefan-out 모델, dedicated shard
쓰기 hot tenant배치 throttling, 인덱스/트랜잭션 범위 축소tenant 분리, sub-sharding
shard key 자체가 나쁨신규 데이터만 새 규칙 적용 검토resharding 계획

가장 흔한 실수는 shard 수만 늘리는 것이다. hot key가 하나라면 shard를 8개에서 32개로 늘려도 그 key는 여전히 한 shard에 있다.

상황:
Primary와 Replica 사이 네트워크가 끊겼다.
모니터링은 Primary가 죽었다고 판단하고 Replica를 promote했다.
실제 Primary는 일부 애플리케이션과 계속 통신 중이었다.
결과:
두 노드가 모두 쓰기를 받는다.
같은 주문 id, 같은 잔액, 같은 재고가 서로 다른 값으로 갈라진다.

Split-brain은 “복제본이 많아서 안전하다”는 직관을 깨뜨린다. 쓰기 가능한 노드가 둘 이상 생기는 순간, 어떤 값이 진짜인지 나중에 사람이나 충돌 해결 로직이 판단해야 한다.

그래서 failover에는 promotion만이 아니라 fencing이 필요하다. fencing은 이전 Primary가 더 이상 쓰기를 받지 못하도록 격리하는 조치다. 구현은 환경마다 다르지만 원칙은 같다. 한 시점에 write leader는 하나여야 한다.

8.4 Replication Slot이 WAL을 붙잡는 경우

섹션 제목: “8.4 Replication Slot이 WAL을 붙잡는 경우”

PostgreSQL replication slot은 consumer가 읽지 않은 WAL을 DB가 지우지 않도록 보존한다. CDC나 logical replication에서 유용하지만, consumer가 멈추면 WAL이 계속 쌓인다.

정상:
current_lsn이 앞으로 간다.
confirmed_flush_lsn도 비슷한 속도로 앞으로 간다.
retained WAL은 작게 유지된다.
정체:
current_lsn은 계속 앞으로 간다.
confirmed_flush_lsn은 멈춰 있다.
retained WAL이 시간에 비례해 커진다.

이 문제는 “메시지가 늦게 간다”에서 끝나지 않는다. retained WAL이 커지면 Primary 저장 공간이 차고, 최악에는 DB 쓰기 자체가 멈출 수 있다. CDC & Outbox 문서에서 이 문제를 더 깊게 다룬다.

증상:
전체 주문 검색 API가 shard 수 증가 이후 급격히 느려졌다.
개별 tenant 주문 조회는 정상이다.
원인:
개별 tenant 조회는 shard key를 포함한다.
전체 주문 검색은 shard key 없이 모든 shard를 fan-out한다.
학습 판단:
이 API는 OLTP shard에서 바로 처리할 질문인가?
별도 검색 인덱스나 warehouse로 보내야 할 질문인가?

샤딩된 DB에서 모든 분석/검색을 그대로 처리하려 하면 cross-shard query가 기본값이 된다. 운영 서비스의 쓰기 원본과 전역 분석/검색 저장소를 분리하는 이유가 여기에 있다.


아래 스니펫은 본문 이해에 필수는 아니다. 목적은 명령을 외우는 것이 아니라, 본문에서 설명한 개념이 어떤 관측값으로 드러나는지 확인하는 것이다.

PostgreSQL Replica Lag을 LSN 단계로 보기
SELECT
client_addr,
state,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, write_lsn)) AS network_lag,
pg_size_pretty(pg_wal_lsn_diff(write_lsn, flush_lsn)) AS disk_lag,
pg_size_pretty(pg_wal_lsn_diff(flush_lsn, replay_lsn)) AS replay_lag,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS total_lag
FROM pg_stat_replication;

읽는 법은 단순하다. network_lag이 크면 전송/수신 경로를 보고, disk_lag이 크면 Replica flush 경로를 보고, replay_lag이 크면 replay가 막혔는지 본다. 값의 절대 크기보다 증가 방향과 지속 시간이 중요하다.

쓰기 직후 읽기를 Primary로 보내는 최소 패턴
async function createPost(input: CreatePostInput): Promise<Post> {
const post = await postRepository.save(input);
// 방금 쓴 데이터는 Replica에서 다시 읽지 않고 쓰기 결과를 반환한다.
return post;
}

ORM의 replication 옵션을 쓰더라도 모든 SELECT가 Replica로 가도 되는 것은 아니다. 결제, 재고, 권한, 방금 생성한 리소스처럼 read-after-write가 필요한 요청은 Primary 읽기나 쓰기 응답 재사용을 명시한다.

Hash Sharding 라우팅의 최소 예시
const SHARDS = ["orders-a", "orders-b", "orders-c"];
function routeByUserId(userId: number): string {
return SHARDS[userId % SHARDS.length];
}

이 예시는 학습용이다. 실제 운영에서는 shard 수 변경 시 매핑이 대부분 바뀌므로 단순 modulo만으로 장기 운영하기 어렵다. shard map, consistent hashing, directory 기반 routing 같은 확장 경로를 함께 설계한다.

Replication Slot retained WAL 확인
SELECT
slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;

active = true만으로 안심하지 않는다. restart_lsn이나 confirmed_flush_lsn이 현재 WAL 위치를 따라가고 있는지, retained WAL이 계속 증가하는지 함께 본다.

AWS/RDS에서 기억할 관측 이름

RDS Read Replica에서는 ReplicaLag 같은 지표를 본다. PostgreSQL logical replication 또는 CDC slot을 쓴다면 OldestReplicationSlotLag나 storage 증가도 같이 본다.

중요한 것은 CloudWatch 경로가 아니라 질문이다.

Replica가 몇 초 뒤처졌는가?
lag이 계속 증가하는가, catch-up 중인가?
lag이 사용자에게 오래된 값을 보여주는가?
slot 때문에 WAL이 저장 공간을 압박하는가?

DB Replication & Sharding 이해 체크

  • Primary와 Replica의 역할을 쓰기 순서와 읽기 분산 관점에서 설명할 수 있다.
  • WAL/Binary Log와 LSN이 복제에서 어떤 위치 추적 역할을 하는지 설명할 수 있다.
  • Replication lag이 네트워크, flush, replay 중 어디서 생기는지 나눠 생각할 수 있다.
  • read-after-write consistency가 필요한 읽기를 Replica로 보내면 어떤 반례가 생기는지 설명할 수 있다.
  • failover와 promotion이 왜 split-brain 위험을 동반하는지 설명할 수 있다.
  • Shard, shard key, shard map, resharding, hotspot, cross-shard query를 구분할 수 있다.
  • Range sharding과 hash sharding의 장단점을 쿼리 모양과 데이터 분포로 비교할 수 있다.
  • 단순 modulo sharding에서 shard 수 변경이 왜 대규모 데이터 이동을 만드는지 계산할 수 있다.
  • Hot shard가 shard 수 부족이 아니라 접근 편중 문제일 수 있음을 설명할 수 있다.
  • Replication을 먼저 볼 상황과 Sharding을 검토할 상황을 read/write 비율, 용량, 일관성 요구로 판단할 수 있다.

키워드설명
Primary쓰기를 받아 커밋 순서를 결정하는 원본 DB 서버
ReplicaPrimary의 변경 로그를 따라가는 복사본 DB 서버
WALPostgreSQL의 변경 로그. 커밋 복구와 복제의 기반
Binary LogMySQL의 변경 로그. logical replication 입력으로 사용
LSNWAL 안의 위치를 나타내는 주소
Replication LagPrimary 커밋이 Replica에 반영되기까지의 지연
Read-after-write Consistency내가 쓴 데이터를 곧바로 다음 읽기에서 볼 수 있어야 하는 요구
FailoverPrimary 장애 때 쓰기 경로를 다른 노드로 넘기는 절차
PromotionReplica를 새 Primary로 승격하는 동작
Split-brain두 노드가 동시에 Primary처럼 쓰기를 받는 상태
Shard전체 데이터 중 일부를 책임지는 독립 저장 단위
Shard Key데이터가 어느 shard로 갈지 결정하는 값
Shard Mapkey와 shard 위치의 매핑 정보
Reshardingshard 수나 배치 방식을 바꾸며 데이터를 다시 나누는 작업
Hot Shard특정 shard에 부하가 몰리는 상태
Hot Key특정 key 하나에 요청이 몰리는 상태
Cross-shard Query여러 shard를 동시에 봐야 하는 쿼리
Scatter-gather여러 shard에 요청을 흩뿌린 뒤 결과를 모으는 실행 방식
Consistent Hashingshard 추가/제거 때 데이터 이동량을 줄이는 해싱 방식
Virtual Node물리 노드를 해시 링 위 여러 위치에 배치해 분포를 부드럽게 하는 단위
Table Partitioning같은 DB 안에서 큰 테이블을 논리적으로 나누는 기능
Vertical Partitioning자주 쓰는 컬럼과 드문 컬럼을 분리하는 설계
RPO장애 때 허용 가능한 데이터 손실 범위
RTO장애 뒤 목표 복구 시간
Eventual Consistency일시적 불일치를 허용하되 시간이 지나면 맞아지게 하는 모델

아래 리소스는 기존 문서가 참고하던 자료다. 첫 회독에서는 본문만 읽고, 운영 도구나 특정 제품 설정이 필요해질 때 확인한다.


Replication은 같은 데이터를 복사해 읽기와 장애 대응을 나누는 대신 복제 지연과 failover 판단을 만든다. Sharding은 데이터를 서로 다른 원본에 나눠 쓰기와 용량을 확장하는 대신 shard key, resharding, cross-shard query라는 새 복잡도를 만든다.