비동기 복제
Primary가 먼저 커밋 성공을 응답하고, Replica는 나중에 따라온다.
읽기 확장과 낮은 쓰기 지연을 우선할 때분류: 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 |
핵심은 “복제하면 안전하다”, “샤딩하면 빨라진다”가 아니다. 복제는 원본과 복사본 사이의 시간차를 만들고, 샤딩은 하나의 쿼리가 여러 조각을 건너야 하는 비용을 만든다.
단일 DB는 이해하기 쉽다. 모든 쓰기는 한 곳으로 가고, 모든 읽기도 같은 곳에서 일어난다. 트랜잭션 경계, 제약 조건, 인덱스, 백업 정책을 한 서버 또는 한 클러스터 안에서 생각하면 된다.
문제는 서비스가 커질수록 병목이 한 가지가 아니라는 점이다.
| 관찰되는 현상 | 단일 DB에서 생기는 한계 | 바로 샤딩해야 하나? |
|---|---|---|
| 조회 API가 느려지고 CPU가 높다 | 읽기 쿼리가 Primary를 계속 점유한다 | 보통은 아니다. 먼저 인덱스와 Replica를 본다. |
| 쓰기 p99가 길어지고 lock wait가 늘다 | 쓰기 작업이 같은 데이터와 인덱스를 놓고 경합한다 | 가능성은 있지만 모델과 쿼리 모양을 먼저 본다. |
| 장애 시 수분 이상 중단된다 | Primary가 죽으면 쓰기 경로가 사라진다 | 복제와 failover 설계가 우선이다. |
| 테이블/인덱스가 한 노드의 한계를 넘다 | 저장 공간, vacuum, 백업, restore 시간이 커진다 | 샤딩 또는 파티셔닝을 검토한다. |
| 특정 고객/계정만 계속 터진다 | 부하 분포가 균등하지 않다 | shard key, 캐시, 전용 shard를 함께 본다. |
복제와 샤딩의 철학은 서로 다르다.
복제는 같은 사실을 여러 곳에 두고, 읽기와 장애 대응을 더 쉽게 만든다. 대신 원본과 복사본이 항상 같은 순간에 같은 값을 보리라고 가정할 수 없다.
샤딩은 서로 다른 사실을 서로 다른 원본에 두고, 쓰기와 용량을 나눈다. 대신 하나의 업무 질문이 여러 shard에 걸치면 애플리케이션이 더 어려워진다.
따라서 이 문서의 순서는 먼저 복제, 그다음 샤딩이다. 대부분의 관계형 DB 서비스는 “단일 Primary + Read Replica”를 먼저 지나고, 그 뒤에도 쓰기나 용량 한계가 남을 때 샤딩을 검토한다.
먼저 용어를 좁게 정의한다.
| 용어 | 뜻 |
|---|---|
| Primary | 쓰기를 받아 커밋 순서를 결정하는 원본 DB 서버다. 예전 문서에서는 master라고도 불렀지만 이 문서에서는 Primary라고 부른다. |
| Replica | Primary의 변경을 따라가는 복사본 DB 서버다. 대부분 읽기 전용으로 사용한다. |
| WAL / Binary Log | DB 변경을 순서대로 남기는 로그다. PostgreSQL은 WAL, MySQL은 Binary Log를 복제의 입력으로 쓴다. |
| LSN(Log Sequence Number) | WAL 안의 위치를 나타내는 주소다. “어디까지 보냈고, 어디까지 적용했는가”를 비교하는 기준이다. |
| Replication Lag | Primary에 커밋된 변경이 Replica에서 보이기까지의 지연이다. 시간으로도 보고, LSN 차이로도 본다. |
| Read-after-write Consistency | 내가 방금 쓴 데이터를 바로 다음 읽기에서 볼 수 있어야 한다는 요구사항이다. |
| Failover | Primary 장애 때 쓰기 경로를 다른 서버로 넘기는 절차다. |
| Promotion | Replica를 새 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는 그 로그를 받아 같은 순서로 적용한다.
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_lsn | Primary가 어디까지 보냈나 | Primary 전송 또는 네트워크 |
write_lsn | Replica가 어디까지 받았나 | 네트워크 수신, Replica write |
flush_lsn | Replica가 어디까지 디스크에 flush했나 | Replica 디스크 I/O |
replay_lsn | Replica가 어디까지 적용했나 | Replica replay CPU/I/O, lock |
여기서 중요한 학습 포인트는 “Replica Lag”이 하나의 원인이 아니라는 점이다. 네트워크가 느려서 못 받는 lag과, 받았지만 replay가 느려서 못 보이는 lag은 대응이 다르다.
다음 사건을 손으로 따라가 보자.
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 lag | 5초 lag | 5분 lag |
|---|---|---|---|
| 게시물 목록 | 대체로 허용 가능 | UX 불만 가능 | 장애로 볼 수 있음 |
| 결제 완료 내역 | 위험 | 대부분 허용 불가 | 장애 |
| 검색 인덱스 | 흔히 허용 가능 | 서비스 정책에 따라 가능 | freshness 문제 |
| 재고 차감 직후 재조회 | 위험 | 허용 불가 | 장애 |
그래서 read-after-write consistency가 필요하면 그 읽기는 Replica가 아니라 Primary에서 수행하거나, 쓰기 응답에 포함된 객체를 그대로 사용하거나, lag이 특정 기준 이하일 때만 Replica를 사용해야 한다.
복제 방식의 핵심 선택지는 “Primary가 언제 커밋 성공을 응답할 것인가”다.
Primary가 먼저 커밋 성공을 응답하고, Replica는 나중에 따라온다.
읽기 확장과 낮은 쓰기 지연을 우선할 때Replica 쪽 확인을 기다린 뒤 Primary가 커밋 성공을 응답한다.
최근 커밋 손실을 줄이는 것이 쓰기 지연보다 중요할 때Replica가 로그를 받았다는 확인 정도만 기다리는 중간 선택이다.
완전 동기보다 지연을 줄이면서 손실 위험을 낮추고 싶을 때간단한 숫자로 보면 trade-off가 보인다.
Primary 내부 커밋 처리: 8msReplica까지 네트워크 왕복: 12msReplica flush/replay 확인: 5ms
비동기 복제 응답: 약 8ms 뒤 성공 응답 단, 장애가 바로 나면 Replica에 없는 커밋이 있을 수 있음
동기 복제 응답: 약 8ms + 12ms + 5ms = 25ms 뒤 성공 응답 단, 확인받은 Replica에는 커밋 기록이 있음동기 복제가 항상 더 좋지는 않다. 네트워크 지연이나 Replica 장애가 Primary 쓰기 지연으로 전파된다. 결제 원장처럼 손실 위험을 극도로 낮춰야 하는 쓰기와, 소셜 피드처럼 낮은 지연과 높은 처리량이 더 중요한 쓰기는 서로 다른 선택을 한다.
복제가 있어도 장애 대응은 자동으로 쉬워지지 않는다. 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 설계는 이 두 값을 동시에 본다.
복제는 다음을 보장하지 않는다.
특히 “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개”를 바로 결제 버튼 활성화 기준으로 쓰기 시작하면 최신성 요구가 올라간다.
샤딩 용어도 먼저 좁게 잡는다.
| 용어 | 뜻 |
|---|---|
| Shard | 전체 데이터 중 일부 행을 책임지는 독립 DB 서버 또는 DB 그룹이다. |
| Shard Key | 어떤 행이 어느 shard에 저장될지 결정하는 컬럼 또는 값이다. |
| Shard Map | shard key 범위나 해시 결과가 실제 shard 주소로 어떻게 매핑되는지 나타내는 정보다. |
| Hotspot / Hot Shard | 특정 shard나 특정 key에 요청이 몰려 다른 shard보다 먼저 병목이 되는 상태다. |
| Resharding | shard 수, 범위, 배치 방식을 바꾸기 위해 데이터를 다시 나누는 작업이다. |
| 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~1000Shard B: tenant_id 1001~2000Shard C: tenant_id 2001~3000
각 shard: 자기 범위의 쓰기만 처리 자기 범위의 인덱스와 WAL만 관리Shard key는 샤딩 설계의 거의 전부다. 이유는 간단하다. 애플리케이션이 어떤 shard를 볼지 빠르게 결정할 수 있어야 하고, 대부분의 쿼리가 그 key를 포함해야 한다.
좋은 shard key는 세 가지 질문을 통과해야 한다.
WHERE 조건에 자주 등장하는가.나쁜 예시는 is_premium, status, created_date처럼 값이 적거나 특정 값에 몰리는 key다. 좋은 후보는 tenant_id, user_id, account_id, organization_id처럼 업무 경계와 쿼리 경계가 함께 움직이는 값이다. 다만 user_id도 유명인 계정처럼 접근 편중이 심하면 hot key가 될 수 있다.
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이 생긴다.
샤딩 방식은 크게 범위 기반과 해시 기반으로 나눠 볼 수 있다.
값의 범위로 shard를 나눈다. 예: user_id 1~100만은 shard A.
범위 조회와 데이터 위치 설명이 중요할 때shard key를 해시하거나 modulo 계산해 shard를 고른다.
균등 분산과 단건 조회가 중요할 때별도 shard map 테이블이 key와 shard 위치를 저장한다.
큰 고객을 별도 shard로 옮기는 등 유연성이 중요할 때Range sharding은 사람이 이해하기 쉽고 범위 조회에 강하다.
Shard A: user_id 1 ~ 1,000,000Shard B: user_id 1,000,001 ~ 2,000,000Shard 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 수가 바뀌는 순간 매핑이 대부분 바뀐다.
처음에 3개 shard로 시작했다가 4개로 늘린다고 하자.
기존: shard = user_id % 3
변경: shard = user_id % 4user_id=1001을 계산해보면 문제가 보인다.
1001 % 3 = 21001 % 4 = 1기존에는 shard 2에 있던 데이터가 새 규칙에서는 shard 1에 있어야 한다. 이런 일이 대부분의 key에서 발생하면, shard 추가가 단순한 서버 증설이 아니라 대규모 데이터 이동이 된다.
Resharding은 보통 다음 위험을 만든다.
| 위험 | 설명 |
|---|---|
| 이중 쓰기 | 이동 중 구 shard와 새 shard에 동시에 써야 할 수 있다. |
| 읽기 라우팅 혼란 | 어떤 데이터가 이미 이동됐는지 모르면 잘못된 shard를 읽는다. |
| 백필 부하 | 오래된 데이터를 복사하는 작업이 운영 DB의 I/O를 먹는다. |
| 검증 비용 | 이동 전후 row 수, checksum, 누락 데이터를 확인해야 한다. |
| 롤백 어려움 | 이동 중 일부만 성공하면 원래 상태로 되돌리기 어렵다. |
따라서 샤딩은 “나중에 늘리면 되지”로 접근하면 위험하다. 처음부터 shard key와 resharding 경로를 함께 생각해야 한다.
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로 찢어주지는 않는다.
샤딩의 가장 큰 대가는 하나의 질문이 여러 shard로 찢어지는 것이다.
좋은 쿼리는 shard key를 포함한다.
SELECT *FROM ordersWHERE tenant_id = 42 AND created_at >= '2026-04-01';애플리케이션은 tenant_id=42만 보고 한 shard로 보낼 수 있다.
나쁜 쿼리는 shard key가 없다.
SELECT *FROM ordersWHERE status = 'FAILED'ORDER BY created_at DESCLIMIT 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 key | shard 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 table | email -> 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에서 이어서 본다.
복제와 샤딩은 대체재가 아니라 순서가 있는 선택지에 가깝다.
| 상황 | 먼저 볼 것 | 이유 |
|---|---|---|
| 읽기 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, 운영 도구읽기가 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 비율, 쿼리 비용, 최신성 요구를 함께 봐야 의미가 있다.
다음 상황에서는 샤딩이 오히려 문제를 키운다.
| 상황 | 왜 악화되는가 |
|---|---|
| 쿼리 대부분이 shard key를 포함하지 않는다 | 모든 요청이 scatter-gather가 되어 tail latency가 커진다. |
| 전역 정렬과 페이지네이션이 핵심 기능이다 | shard별 결과를 합쳐 안정적으로 정렬하기 어렵다. |
| 강한 cross-shard transaction이 자주 필요하다 | 분산 트랜잭션 또는 보상 로직이 본문 기능보다 커진다. |
| 팀이 shard map, migration, data repair 도구가 없다 | 장애 시 원인 파악보다 데이터 위치 파악에 시간이 든다. |
| 병목이 잘못된 인덱스 하나다 | 샤딩은 쿼리 계획 문제를 감춘 채 운영 복잡도만 늘린다. |
샤딩은 성능 버튼이 아니라 데이터 소유권을 나누는 구조 변경이다. 한 번 나누면 쿼리, 배치, 백업, 장애 대응, 데이터 수정 작업이 모두 shard를 의식해야 한다.
실제 시스템을 갑자기 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 Replica | Primary 읽기 부하가 줄고 lag 예산 안에 있다 | 방금 쓴 데이터 누락 문의가 늘어난다 |
| Partitioning | 오래된 데이터 관리와 기간 조회 비용이 줄어든다 | 쓰기 Primary 한계는 그대로인데 해결됐다고 착각한다 |
| Shard 후보 검증 | 주요 OLTP API 대부분이 shard key를 안다 | 핵심 API가 전부 scatter-gather가 된다 |
| 일부 도메인 샤딩 | 작은 범위에서 routing과 migration을 검증한다 | 전역 migration 중 데이터 위치를 설명하지 못한다 |
이 경로는 보수적이지만, 샤딩의 진짜 비용을 드러낸다. 샤딩은 DB 서버를 여러 대로 늘리는 작업이 아니라, 데이터 위치를 애플리케이션 계약으로 끌어올리는 작업이다.
샤딩이나 복제를 도입하면 코드보다 운영 지식이 먼저 오래 남는다. 다음 항목은 설계 문서에 남겨야 한다.
| 항목 | 남겨야 하는 내용 |
|---|---|
| 읽기 라우팅 정책 | 어떤 읽기는 Primary, 어떤 읽기는 Replica인지와 예외 조건 |
| lag 예산 | 도메인별 허용 lag, 알람 기준, 사용자 영향 |
| failover 기준 | promotion 후보, RPO/RTO, 이전 Primary fencing 방식 |
| shard key | 선택 이유, 반려한 후보, hot key 대응 |
| shard map | key 범위 또는 hash 결과가 어느 shard로 가는지 |
| resharding 절차 | backfill, dual write, 검증, rollback 경계 |
| cross-shard API | 허용한 API, 금지한 API, 별도 저장소로 보낸 API |
이런 문서가 없으면 장애 때 질문이 바뀐다. “왜 느린가”가 아니라 “이 데이터가 원래 어디 있어야 하는가”부터 추적해야 한다. 분산된 DB에서 위치 정보는 운영 계약이다.
AWS RDS 문맥에서는 Multi-AZ와 Read Replica가 자주 섞인다.
| 개념 | 목적 | 읽기 분산 | 장애 때 역할 |
|---|---|---|---|
| Multi-AZ Standby | 고가용성 | 보통 읽기용으로 쓰지 않는다 | 장애 시 standby가 Primary 역할을 맡는다. |
| Read Replica | 읽기 확장, DR 보조 | 가능 | promotion으로 새 Primary가 될 수 있지만 lag을 고려해야 한다. |
| Aurora Replica | Aurora 클러스터 안 읽기 확장 | 가능 | 클러스터 구조와 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 Partitioning | Sharding |
|---|---|---|
| 위치 | 같은 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와 다르다. users와 user_profiles는 보통 같은 사용자 행을 서로 다른 테이블에 나눈 것이다. 주로 row size, cache 효율, 민감 정보 접근 제어를 개선한다. 쓰기 수평 확장이나 여러 서버 분산을 자동으로 만들어주지는 않는다.
CAP 정리는 분산 시스템에서 네트워크 파티션이 생겼을 때 consistency와 availability 중 무엇을 우선할지 묻는 렌즈다. 이 문서에서는 외우는 정리보다 다음 질문이 중요하다.
네트워크가 끊겼다.두 노드가 같은 데이터를 최신이라고 확신할 수 없다.
선택 A: 틀릴 수 있으면 요청을 거절한다. -> 일관성 우선, 가용성 일부 포기
선택 B: 일단 응답하고 나중에 맞춘다. -> 가용성 우선, 일시적 불일치 허용Primary-Replica 구조는 대개 단일 writer를 유지해 충돌을 줄인다. Multi-master나 AP 성격의 시스템은 가용성을 높이는 대신 충돌 해결과 eventual consistency를 설계해야 한다.
여기서 eventual consistency(최종적 일관성)는 “언젠가 맞겠지”라는 막연한 희망이 아니다. 쓰기 순서, 충돌 해결, 재시도, 보상 로직, 관측 지표가 있어야 의미가 있다.
운영 절차보다 먼저 다음 질문을 던진다.
| 질문 | 관측값 | 의미 |
|---|---|---|
| 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, p99 | hot shard |
| shard별 데이터 크기가 비슷한가 | row 수, index size, storage | shard key 분포 |
| cross-shard 쿼리가 많은가 | scatter-gather 호출 수, fan-out 수 | shard key와 쿼리 경계 불일치 |
| resharding이 가능한가 | shard map, migration 상태, 검증 지표 | 데이터 이동 준비도 |
| 특정 key가 전체를 흔드는가 | top key QPS, cache hit rate | hot key |
평균 QPS가 정상이어도 shard 하나의 p99가 치솟으면 사용자 일부는 장애를 겪는다. 샤딩된 시스템에서 “전체 평균”은 자주 거짓 안정감을 준다.
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를 요구하는지 도메인 기준으로 판단한다.해결 방향은 세 가지다.
여기서 중요한 것은 “모든 SELECT를 Primary로 보내기”가 아니다. 그렇게 하면 복제를 붙인 이유가 사라진다. 최신성이 필요한 읽기와 지연 허용 읽기를 나누는 것이 핵심이다.
증상: DB shard 8개 중 shard-3만 CPU 95%. 특정 tenant의 관리자 페이지에서 timeout. 다른 tenant는 정상.
원인 후보: shard key가 tenant_id이고, tenant 하나가 지나치게 크다. 또는 특정 tenant의 배치/리포트가 같은 shard를 계속 점유한다.대응은 원인에 따라 다르다.
| 원인 | 단기 대응 | 장기 대응 |
|---|---|---|
| 읽기 hot key | 캐시, rate limit, query 결과 precompute | fan-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는 하나여야 한다.
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가 기본값이 된다. 운영 서비스의 쓰기 원본과 전역 분석/검색 저장소를 분리하는 이유가 여기에 있다.
아래 스니펫은 본문 이해에 필수는 아니다. 목적은 명령을 외우는 것이 아니라, 본문에서 설명한 개념이 어떤 관측값으로 드러나는지 확인하는 것이다.
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_lagFROM pg_stat_replication;읽는 법은 단순하다. network_lag이 크면 전송/수신 경로를 보고, disk_lag이 크면 Replica flush 경로를 보고, replay_lag이 크면 replay가 막혔는지 본다. 값의 절대 크기보다 증가 방향과 지속 시간이 중요하다.
async function createPost(input: CreatePostInput): Promise<Post> { const post = await postRepository.save(input);
// 방금 쓴 데이터는 Replica에서 다시 읽지 않고 쓰기 결과를 반환한다. return post;}ORM의 replication 옵션을 쓰더라도 모든 SELECT가 Replica로 가도 되는 것은 아니다. 결제, 재고, 권한, 방금 생성한 리소스처럼 read-after-write가 필요한 요청은 Primary 읽기나 쓰기 응답 재사용을 명시한다.
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 같은 확장 경로를 함께 설계한다.
SELECT slot_name, active, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ) AS retained_walFROM pg_replication_slotsORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;active = true만으로 안심하지 않는다. restart_lsn이나 confirmed_flush_lsn이 현재 WAL 위치를 따라가고 있는지, retained WAL이 계속 증가하는지 함께 본다.
RDS Read Replica에서는 ReplicaLag 같은 지표를 본다. PostgreSQL logical replication 또는 CDC slot을 쓴다면 OldestReplicationSlotLag나 storage 증가도 같이 본다.
중요한 것은 CloudWatch 경로가 아니라 질문이다.
Replica가 몇 초 뒤처졌는가?lag이 계속 증가하는가, catch-up 중인가?lag이 사용자에게 오래된 값을 보여주는가?slot 때문에 WAL이 저장 공간을 압박하는가?| 키워드 | 설명 |
|---|---|
| Primary | 쓰기를 받아 커밋 순서를 결정하는 원본 DB 서버 |
| Replica | Primary의 변경 로그를 따라가는 복사본 DB 서버 |
| WAL | PostgreSQL의 변경 로그. 커밋 복구와 복제의 기반 |
| Binary Log | MySQL의 변경 로그. logical replication 입력으로 사용 |
| LSN | WAL 안의 위치를 나타내는 주소 |
| Replication Lag | Primary 커밋이 Replica에 반영되기까지의 지연 |
| Read-after-write Consistency | 내가 쓴 데이터를 곧바로 다음 읽기에서 볼 수 있어야 하는 요구 |
| Failover | Primary 장애 때 쓰기 경로를 다른 노드로 넘기는 절차 |
| Promotion | Replica를 새 Primary로 승격하는 동작 |
| Split-brain | 두 노드가 동시에 Primary처럼 쓰기를 받는 상태 |
| Shard | 전체 데이터 중 일부를 책임지는 독립 저장 단위 |
| Shard Key | 데이터가 어느 shard로 갈지 결정하는 값 |
| Shard Map | key와 shard 위치의 매핑 정보 |
| Resharding | shard 수나 배치 방식을 바꾸며 데이터를 다시 나누는 작업 |
| Hot Shard | 특정 shard에 부하가 몰리는 상태 |
| Hot Key | 특정 key 하나에 요청이 몰리는 상태 |
| Cross-shard Query | 여러 shard를 동시에 봐야 하는 쿼리 |
| Scatter-gather | 여러 shard에 요청을 흩뿌린 뒤 결과를 모으는 실행 방식 |
| Consistent Hashing | shard 추가/제거 때 데이터 이동량을 줄이는 해싱 방식 |
| Virtual Node | 물리 노드를 해시 링 위 여러 위치에 배치해 분포를 부드럽게 하는 단위 |
| Table Partitioning | 같은 DB 안에서 큰 테이블을 논리적으로 나누는 기능 |
| Vertical Partitioning | 자주 쓰는 컬럼과 드문 컬럼을 분리하는 설계 |
| RPO | 장애 때 허용 가능한 데이터 손실 범위 |
| RTO | 장애 뒤 목표 복구 시간 |
| Eventual Consistency | 일시적 불일치를 허용하되 시간이 지나면 맞아지게 하는 모델 |
아래 리소스는 기존 문서가 참고하던 자료다. 첫 회독에서는 본문만 읽고, 운영 도구나 특정 제품 설정이 필요해질 때 확인한다.
Replication은 같은 데이터를 복사해 읽기와 장애 대응을 나누는 대신 복제 지연과 failover 판단을 만든다. Sharding은 데이터를 서로 다른 원본에 나눠 쓰기와 용량을 확장하는 대신 shard key, resharding, cross-shard query라는 새 복잡도를 만든다.