대규모 서비스 데이터베이스 분리 전략 – 샤딩·레플리카·MSA


대규모 서비스 데이터베이스 분리는 백엔드 시스템 설계 면접과 실무 모두에서 가장 핵심적인 주제 중 하나입니다. 사용자 100명짜리 서비스와 사용자 1억 명짜리 서비스는 같은 DB 구조로 운영할 수 없습니다. 카카오톡 메시지 DB, 네이버 검색 인덱스, 쿠팡 주문 데이터가 하나의 서버에 들어있다고 상상해보세요. 단 하나의 DB 서버가 다운되는 순간 전체 서비스가 멈춥니다. 이 글에서는 단일 DB의 한계부터 수직·수평 분할, 샤딩, 레플리케이션, MSA의 DB 분리 전략까지 아키텍처 다이어그램과 실전 코드로 완전히 정리합니다.


목차

  1. 단일 데이터베이스의 한계 – 언제 분리를 고민해야 하는가
  2. 수직 분할(Vertical Partitioning) – 테이블과 서비스 단위 분리
  3. 수평 분할과 샤딩(Sharding) – 데이터를 여러 서버에 나누기
  4. 레플리케이션(Replication) – 읽기 성능과 고가용성 확보
  5. CQRS와 MSA 환경의 DB 분리 – 마이크로서비스 시대의 전략
  6. DB 분리의 트레이드오프와 실전 의사결정 가이드

1. 단일 데이터베이스의 한계 – 언제 분리를 고민해야 하는가

모든 대규모 서비스는 처음에는 단일 DB로 시작합니다. 스타트업의 MVP, 사이드 프로젝트, 초기 서비스 모두 단일 DB로 충분합니다. 문제는 서비스가 성장하면서 발생합니다.

단일 DB 아키텍처의 구조

[단일 DB 구조 – 초기 서비스]

클라이언트 (Web / App)
        ↓
   애플리케이션 서버
        ↓
  ┌─────────────┐
  │  단일 DB    │  ← 모든 데이터가 한 곳에
  │  (MySQL /   │     users, orders, products,
  │   PostgreSQL│     payments, logs 모두 여기
  │   등)       │
  └─────────────┘

장점: 단순한 구조, 트랜잭션 관리 용이, 운영 비용 낮음
단점: 트래픽 급증 시 단일 장애 지점(SPOF), 확장성 한계

단일 DB 한계 증상 체크리스트

⚠️ 아래 증상이 보이면 DB 분리를 고민해야 합니다

□ 쿼리 응답 시간이 평균 500ms를 초과하기 시작
□ 피크 타임마다 DB CPU 사용률이 80% 이상
□ 하나의 쿼리가 다른 서비스의 응답을 지연시킴
  (예: 배치 리포트 쿼리가 주문 처리를 느리게 만듦)
□ 테이블 수가 200개를 넘어 스키마 변경이 두려워짐
□ 장애 발생 시 전체 서비스가 동시에 다운됨
□ 특정 팀의 스키마 변경이 다른 팀 서비스에 영향을 줌

DB 분리 전략의 전체 지도

[DB 분리 전략 전체 로드맵]

① 수직 분할 (Vertical Partitioning)
   "어떤 테이블/컬럼을 분리할 것인가?"
   └── 기능별 DB 분리, 컬럼 분리

② 수평 분할 / 샤딩 (Horizontal Sharding)
   "같은 테이블의 데이터를 여러 서버에 분산"
   └── Range, Hash, Directory 샤딩

③ 레플리케이션 (Replication)
   "동일 데이터를 여러 서버에 복제"
   └── Primary-Replica 구조, 읽기 부하 분산

④ CQRS + 이벤트 소싱
   "읽기/쓰기 모델 분리"
   └── Command DB / Query DB 완전 분리

⑤ MSA DB 분리
   "서비스마다 독립 DB 소유"
   └── Saga 패턴, 분산 트랜잭션

2. 수직 분할(Vertical Partitioning) – 테이블과 서비스 단위 분리

수직 분할은 하나의 거대한 DB를 기능·도메인·컬럼 단위로 여러 DB로 나누는 전략입니다. 가장 먼저 시도하는 분리 방식이며, 효과 대비 구현 복잡도가 낮아 현실적인 첫 번째 선택입니다.

기능별 DB 분리 (Functional Partitioning)

[수직 분할 전후 비교]

분리 전:
  단일 DB
  ├── users (사용자)
  ├── orders (주문)
  ├── products (상품)
  ├── payments (결제)
  ├── notifications (알림)
  └── logs (로그)

분리 후:
  User DB       → users, user_profiles, user_sessions
  Commerce DB   → orders, products, inventory, carts
  Payment DB    → payments, refunds, billing_info
  Notification  → notifications, push_tokens, email_queue
  Analytics DB  → logs, events, metrics (별도 분석용 DB)

컬럼 단위 수직 분할

한 테이블 내에서도 자주 접근하는 컬럼과 거의 접근하지 않는 컬럼을 분리할 수 있습니다. 사용자 프로필에서 기본 정보와 상세 정보를 나누는 것이 대표적입니다.

sql

-- 분리 전: 모든 컬럼이 한 테이블
CREATE TABLE users (
    id          BIGINT PRIMARY KEY,
    email       VARCHAR(255),    -- 자주 조회
    username    VARCHAR(100),    -- 자주 조회
    -- 아래는 상세 프로필 페이지에서만 조회
    bio         TEXT,
    avatar_url  VARCHAR(500),
    address     TEXT,
    birth_date  DATE,
    preferences JSON             -- 수백 바이트~수 KB
);

-- 분리 후: 핫(Hot) 데이터와 콜드(Cold) 데이터 분리
CREATE TABLE users (             -- 핫 테이블: 빠른 조회
    id          BIGINT PRIMARY KEY,
    email       VARCHAR(255),
    username    VARCHAR(100),
    created_at  TIMESTAMP
);

CREATE TABLE user_profiles (     -- 콜드 테이블: 필요할 때만 JOIN
    user_id     BIGINT PRIMARY KEY,  -- FK → users.id
    bio         TEXT,
    avatar_url  VARCHAR(500),
    address     TEXT,
    birth_date  DATE,
    preferences JSON,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

수직 분할의 장단점과 실전 주의사항

python

# 수직 분할 후 조인이 필요한 경우 – 애플리케이션 레벨 처리
# (서비스 간 DB가 달라 SQL JOIN 불가 → 코드로 조합)

class UserService:
    def __init__(self, user_db, profile_db):
        self.user_db = user_db          # User DB 커넥션
        self.profile_db = profile_db    # Profile DB 커넥션

    def get_full_user_info(self, user_id: int) -> dict:
        """
        기존: SELECT u.*, p.* FROM users u JOIN user_profiles p ...
        분리 후: 두 DB를 별도 호출 후 애플리케이션에서 합산
        """
        # DB 1에서 기본 정보 조회
        user = self.user_db.query(
            "SELECT id, email, username FROM users WHERE id = %s",
            (user_id,)
        )

        # DB 2에서 상세 프로필 조회 (필요할 때만)
        profile = self.profile_db.query(
            "SELECT bio, avatar_url, address FROM user_profiles WHERE user_id = %s",
            (user_id,)
        )

        # 애플리케이션 레벨에서 병합
        return {**user, **profile} if user else None

        # ⚠️ 주의: 두 DB 간 트랜잭션 보장이 어려움
        # → 결제·주문처럼 원자성이 중요한 데이터는 신중하게 분리할 것

3. 수평 분할과 샤딩(Sharding) – 데이터를 여러 서버에 나누기

수직 분할이 “어떤 테이블을 분리할까”라면, 샤딩은 “같은 테이블의 행(Row)을 여러 DB 서버에 분산” 하는 전략입니다. 수억 개의 행을 가진 테이블을 단일 서버로는 처리할 수 없을 때 사용합니다.

샤딩의 핵심 개념

[샤딩 구조 시각화]

users 테이블 (총 1억 명)
         ↓ 샤딩 키(shard key) = user_id
         ↓
┌────────────────────────────────────┐
│           샤드 라우터              │
│  (어느 샤드로 보낼지 결정)         │
└────┬──────────┬──────────┬─────────┘
     ↓          ↓          ↓
  Shard 0    Shard 1    Shard 2
 (id 0~33M) (id 33M~66M)(id 66M~1억)
  ┌───────┐  ┌───────┐  ┌───────┐
  │ DB 0  │  │ DB 1  │  │ DB 2  │
  └───────┘  └───────┘  └───────┘

각 샤드는 독립적인 DB 서버 → 병렬 처리 가능

3가지 샤딩 전략

① 범위 기반 샤딩 (Range-based Sharding)

python

class RangeShardRouter:
    """
    범위 기반 샤딩 라우터
    user_id 범위에 따라 샤드 결정
    """
    def __init__(self):
        # (시작값, 끝값, 샤드_인덱스)
        self.shard_ranges = [
            (0,          33_333_333,  0),   # Shard 0
            (33_333_334, 66_666_666,  1),   # Shard 1
            (66_666_667, 99_999_999,  2),   # Shard 2
        ]
        self.shards = {
            0: DatabaseConnection("shard-0.db.internal"),
            1: DatabaseConnection("shard-1.db.internal"),
            2: DatabaseConnection("shard-2.db.internal"),
        }

    def get_shard(self, user_id: int) -> DatabaseConnection:
        for start, end, shard_idx in self.shard_ranges:
            if start <= user_id <= end:
                return self.shards[shard_idx]
        raise ValueError(f"user_id {user_id}에 해당하는 샤드 없음")

# 장점: 범위 쿼리(WHERE id BETWEEN x AND y) 효율적
# 단점: 핫스팟 발생 위험 (신규 가입자가 항상 Shard 2에 집중)

② 해시 기반 샤딩 (Hash-based Sharding)

python

import hashlib

class HashShardRouter:
    """
    해시 기반 샤딩 라우터
    user_id의 해시값으로 샤드 균등 분배
    """
    def __init__(self, shard_count: int = 4):
        self.shard_count = shard_count
        self.shards = {
            i: DatabaseConnection(f"shard-{i}.db.internal")
            for i in range(shard_count)
        }

    def get_shard_index(self, shard_key: str | int) -> int:
        """
        MD5 해시로 균등 분배 (일관된 결과 보장)
        """
        key_bytes = str(shard_key).encode('utf-8')
        hash_value = int(hashlib.md5(key_bytes).hexdigest(), 16)
        return hash_value % self.shard_count

    def get_shard(self, user_id: int) -> DatabaseConnection:
        shard_idx = self.get_shard_index(user_id)
        return self.shards[shard_idx]

    def get_all_shards(self) -> list:
        """
        전체 샤드 대상 쿼리(집계 등) 시 모든 샤드 반환
        """
        return list(self.shards.values())

# 장점: 데이터 균등 분배, 핫스팟 방지
# 단점: 범위 쿼리 불가, 샤드 수 변경 시 리샤딩 필요

③ 디렉터리 기반 샤딩 (Directory-based Sharding)

python

class DirectoryShardRouter:
    """
    디렉터리 기반 샤딩 라우터
    별도 매핑 테이블로 유연한 샤드 할당
    """
    def __init__(self, lookup_db: DatabaseConnection):
        self.lookup_db = lookup_db  # 샤드 매핑 정보 저장 DB
        self._cache = {}            # 로컬 캐시로 조회 오버헤드 감소

    def get_shard(self, user_id: int) -> DatabaseConnection:
        # 캐시 확인
        if user_id in self._cache:
            return self._cache[user_id]

        # 룩업 DB에서 샤드 정보 조회
        result = self.lookup_db.query(
            "SELECT shard_endpoint FROM shard_map WHERE user_id = %s",
            (user_id,)
        )
        shard_conn = DatabaseConnection(result['shard_endpoint'])
        self._cache[user_id] = shard_conn  # 캐싱
        return shard_conn

# 장점: 유연한 샤드 할당, 핫 유저 샤드 이동 가능
# 단점: 룩업 DB가 단일 장애 지점, 추가 조회 오버헤드

샤딩의 최대 난제 – 크로스 샤드 쿼리

python

class CrossShardQueryHandler:
    """
    여러 샤드에 걸친 집계 쿼리 처리
    예: "전체 사용자 수", "연령대별 통계"
    """
    def __init__(self, shard_router: HashShardRouter):
        self.router = shard_router

    def count_all_users(self) -> int:
        """
        모든 샤드에 병렬로 COUNT 쿼리 실행 후 합산
        """
        import concurrent.futures

        def count_on_shard(shard: DatabaseConnection) -> int:
            result = shard.query("SELECT COUNT(*) as cnt FROM users")
            return result['cnt']

        all_shards = self.router.get_all_shards()

        # 병렬 실행으로 지연 최소화
        with concurrent.futures.ThreadPoolExecutor() as executor:
            counts = list(executor.map(count_on_shard, all_shards))

        return sum(counts)

    # ⚠️ 크로스 샤드 JOIN은 사실상 불가능
    # → 비정규화(Denormalization) 또는 별도 집계 DB 사용 권장

4. 레플리케이션(Replication) – 읽기 성능과 고가용성 확보

샤딩이 “데이터를 나눈다”면, 레플리케이션은 “동일한 데이터를 여러 서버에 복제” 합니다. 대부분의 서비스는 읽기(Read) 요청이 쓰기(Write) 요청보다 훨씬 많습니다. 레플리케이션은 이 비대칭 트래픽을 해결하는 핵심 전략입니다.

Primary-Replica 구조

[레플리케이션 아키텍처]

        쓰기 요청 (INSERT/UPDATE/DELETE)
              ↓
    ┌─────────────────┐
    │  Primary DB     │  ← 모든 쓰기는 여기로
    │  (Master)       │
    └────────┬────────┘
             │  복제 (Replication Log 전송)
    ┌────────┼────────┐
    ↓        ↓        ↓
┌───────┐ ┌───────┐ ┌───────┐
│Replica│ │Replica│ │Replica│  ← 읽기 요청 분산 처리
│  1    │ │  2    │ │  3    │
└───────┘ └───────┘ └───────┘
    ↑        ↑        ↑
    읽기 요청 (SELECT) 로드밸런싱

애플리케이션에서 읽기/쓰기 분리 구현

python

import random
from contextlib import contextmanager
from typing import List

class DatabaseRouter:
    """
    Primary-Replica 라우팅 구현
    쓰기는 Primary, 읽기는 Replica로 자동 분배
    """

    def __init__(
        self,
        primary_dsn: str,
        replica_dsns: List[str]
    ):
        self.primary = DatabaseConnection(primary_dsn)
        self.replicas = [
            DatabaseConnection(dsn) for dsn in replica_dsns
        ]

    def get_primary(self) -> DatabaseConnection:
        """INSERT / UPDATE / DELETE → Primary"""
        return self.primary

    def get_replica(self) -> DatabaseConnection:
        """
        SELECT → Replica 중 랜덤 선택 (라운드로빈도 가능)
        헬스체크 실패한 레플리카는 자동 제외
        """
        healthy_replicas = [
            r for r in self.replicas if r.is_healthy()
        ]
        if not healthy_replicas:
            # 모든 레플리카 다운 시 Primary로 폴백
            return self.primary
        return random.choice(healthy_replicas)

    @contextmanager
    def write_connection(self):
        """쓰기 전용 컨텍스트 매니저"""
        conn = self.get_primary()
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise

    @contextmanager
    def read_connection(self):
        """읽기 전용 컨텍스트 매니저"""
        conn = self.get_replica()
        try:
            yield conn
        finally:
            conn.close()


class UserRepository:
    def __init__(self, router: DatabaseRouter):
        self.router = router

    def create_user(self, email: str, username: str) -> int:
        """쓰기: Primary로 라우팅"""
        with self.router.write_connection() as db:
            return db.execute(
                "INSERT INTO users (email, username) VALUES (%s, %s)",
                (email, username)
            )

    def get_user_by_id(self, user_id: int) -> dict:
        """읽기: Replica로 라우팅"""
        with self.router.read_connection() as db:
            return db.query(
                "SELECT * FROM users WHERE id = %s",
                (user_id,)
            )

복제 지연(Replication Lag) 문제와 해결책

[복제 지연 문제 시나리오]

Time 0: Primary에 user_id=999 생성 (쓰기 완료)
Time 1: 복제 지연으로 Replica에는 아직 미반영 (약 10~100ms)
Time 2: 클라이언트가 방금 만든 user_id=999 조회 요청
Time 3: Replica에서 조회 → "사용자 없음" 반환 ← 버그!

해결책:

① 쓰기 후 즉시 읽기는 Primary에서 처리 (Read-Your-Writes)
   방금 INSERT한 데이터를 바로 조회할 때는 Replica 대신 Primary 사용

② 세션 일관성 (Session Consistency)
   특정 사용자의 쓰기 이후 동일 세션의 읽기는
   같은 Replica 또는 Primary에서 처리

③ 동기 복제 (Synchronous Replication)
   Primary가 최소 1개 Replica 복제 완료 확인 후 커밋
   → 지연 없지만 쓰기 성능 저하

5. CQRS와 MSA 환경의 DB 분리 – 마이크로서비스 시대의 전략

샤딩과 레플리케이션이 단일 서비스 내 DB 확장이라면, CQRS와 MSA는 서비스 아키텍처 차원의 DB 분리 전략입니다.

CQRS (Command Query Responsibility Segregation)

CQRS는 쓰기 모델(Command)과 읽기 모델(Query)의 데이터 저장소를 완전히 분리하는 패턴입니다.

[CQRS 아키텍처]

클라이언트
  ├── 쓰기 요청 (주문 생성, 결제 등)
  │        ↓
  │   Command Handler
  │        ↓
  │   Command DB (PostgreSQL)  ← 정규화된 쓰기 최적화 DB
  │        ↓
  │   이벤트 발행 (Kafka / RabbitMQ)
  │        ↓
  │   Event Handler (비동기)
  │        ↓
  │   Query DB 업데이트 (Elasticsearch / Redis / MongoDB)
  │
  └── 읽기 요청 (주문 목록 조회, 검색 등)
           ↓
      Query Handler
           ↓
      Query DB  ← 조회 최적화 (비정규화, 캐싱 친화적)

python

# CQRS 패턴 구현 예시

# ─── Command 측 (쓰기) ────────────────────────────────────
class CreateOrderCommand:
    """주문 생성 커맨드 데이터 클래스"""
    def __init__(self, user_id: int, items: list, total: float):
        self.user_id = user_id
        self.items = items
        self.total = total

class OrderCommandHandler:
    def __init__(self, command_db, event_bus):
        self.db = command_db      # PostgreSQL: 정규화 쓰기 DB
        self.event_bus = event_bus  # Kafka 이벤트 버스

    def handle_create_order(self, cmd: CreateOrderCommand) -> int:
        # 1. 정규화된 Command DB에 쓰기
        order_id = self.db.execute("""
            INSERT INTO orders (user_id, total_amount, status)
            VALUES (%s, %s, 'PENDING') RETURNING id
        """, (cmd.user_id, cmd.total))

        for item in cmd.items:
            self.db.execute("""
                INSERT INTO order_items (order_id, product_id, quantity)
                VALUES (%s, %s, %s)
            """, (order_id, item['product_id'], item['quantity']))

        # 2. 이벤트 발행 → Query DB 동기화 트리거
        self.event_bus.publish('order.created', {
            'order_id': order_id,
            'user_id': cmd.user_id,
            'items': cmd.items,
            'total': cmd.total
        })
        return order_id

# ─── Query 측 (읽기) ─────────────────────────────────────
class OrderQueryHandler:
    def __init__(self, query_db):
        self.db = query_db    # Elasticsearch or MongoDB: 조회 최적화

    def get_user_orders(self, user_id: int) -> list:
        """
        비정규화된 Query DB에서 단일 쿼리로 모든 정보 반환
        JOIN 없이 이미 조합된 데이터 구조 저장
        """
        return self.db.search({
            "query": {"term": {"user_id": user_id}},
            "sort": [{"created_at": "desc"}]
        })

# ─── 이벤트 핸들러 (Command → Query DB 동기화) ────────────
class OrderEventHandler:
    def __init__(self, query_db):
        self.query_db = query_db

    def on_order_created(self, event: dict):
        """
        주문 생성 이벤트 수신 시 Query DB에 비정규화 문서 생성
        이후 조회는 여기서 처리 (JOIN 없이 빠르게)
        """
        self.query_db.index('orders', {
            'order_id':   event['order_id'],
            'user_id':    event['user_id'],
            'item_count': len(event['items']),
            'total':      event['total'],
            'status':     'PENDING',
            'created_at': 'now'
        })

MSA(마이크로서비스)의 DB 분리 원칙

MSA에서는 각 서비스가 독자적인 DB를 소유합니다. 다른 서비스의 DB에 직접 접근하는 것은 엄격히 금지됩니다.

[MSA DB 분리 구조]

❌ 잘못된 MSA DB 설계:
  Order Service ──┐
  User Service  ──┼──→ 공유 DB (강결합 발생)
  Payment Service─┘

✅ 올바른 MSA DB 설계:
  Order Service   → Order DB   (PostgreSQL)
  User Service    → User DB    (MySQL)
  Product Service → Product DB (MongoDB)
  Payment Service → Payment DB (PostgreSQL)
  Search Service  → Search DB  (Elasticsearch)

  서비스 간 데이터 공유 방법:
  → API 호출 (동기)
  → 이벤트 메시지 (비동기, Kafka/RabbitMQ)
  → 공유 캐시 (Redis)

분산 트랜잭션 – Saga 패턴

MSA에서 여러 서비스에 걸친 트랜잭션은 전통적 ACID 트랜잭션으로 처리할 수 없습니다. Saga 패턴은 각 서비스의 로컬 트랜잭션을 이벤트로 연결합니다.

[Saga 패턴 – 주문 처리 흐름]

Step 1: Order Service → 주문 생성 (PENDING)
            ↓ 성공 이벤트 발행
Step 2: Inventory Service → 재고 차감
            ↓ 성공 이벤트 발행
Step 3: Payment Service → 결제 처리
            ↓
        성공 → Order Service: 주문 CONFIRMED
        실패 → 보상 트랜잭션(Compensating Transaction) 실행
               ← Inventory Service: 재고 복원
               ← Order Service: 주문 CANCELLED

핵심: 각 단계가 실패하면 이전 단계들을 '되돌리는' 보상 이벤트 발행
→ 분산 환경에서 최종적 일관성(Eventual Consistency) 보장

6. DB 분리의 트레이드오프와 실전 의사결정 가이드

DB를 분리할수록 성능·확장성은 올라가지만, 복잡도·일관성 관리·운영 비용도 함께 증가합니다. 올바른 선택은 기술 수준이 아니라 서비스의 규모와 요구사항에 달려 있습니다.

CAP 정리와 DB 분리의 관계

[CAP 정리 (분산 DB 설계의 핵심 원칙)]

     C (일관성)
    Consistency
        /\
       /  \
      /    \
     /  CA  \      ← 단일 DB (일관성 + 가용성, 분산 없음)
    /──────────\
   /  CP  │  AP \
  /       │      \
P ────────────────── A
(분할 허용)   (가용성)
Partition     Availability
Tolerance

CP: 일관성 + 분산 (HBase, Zookeeper) → 분산 환경에서 일관성 우선
AP: 가용성 + 분산 (Cassandra, DynamoDB) → 가용성 우선, 최종 일관성

분산 시스템에서는 P(네트워크 분할)가 필수 → C와 A 중 선택해야 함

분리 전략별 트레이드오프 비교

전략해결하는 문제발생하는 복잡도도입 시점
수직 분할스키마 복잡도, 팀 경계크로스 DB 조인 불가도메인이 명확히 나뉠 때
레플리케이션읽기 트래픽 과부하복제 지연, 정합성 이슈읽기:쓰기 = 5:1 이상
샤딩쓰기/저장 용량 한계크로스 샤드 쿼리, 리샤딩단일 DB 수백 GB 초과
CQRS읽기/쓰기 모델 불일치이벤트 동기화, 최종 일관성복잡한 조회 요구사항
MSA DB서비스 간 강결합분산 트랜잭션, Saga팀·서비스 독립 배포 필요 시

실전 의사결정 플로차트

[DB 분리 의사결정 가이드]

현재 DB 성능 문제가 있는가?
    │
    ├── NO → 분리 불필요 (단순함 유지가 최선)
    │
    └── YES
         ↓
    읽기 부하가 주된 문제인가?
         ├── YES → 레플리케이션 (Read Replica) 먼저 적용
         │          효과 부족 시 → Redis 캐시 레이어 추가
         │
         └── NO (쓰기/저장 용량 문제)
                  ↓
             단일 테이블 데이터가 수억 건 이상인가?
                  ├── YES → 샤딩 검토
                  │          샤딩 키 설계가 가장 중요
                  │
                  └── NO
                           ↓
                      도메인이 명확히 나뉘는가?
                           ├── YES → 수직 분할 (기능별 DB 분리)
                           └── NO  → 인덱스 최적화·쿼리 튜닝 우선

단계적 DB 분리 로드맵 – 실제 서비스 성장 경로

[단계별 DB 진화 경로]

Phase 1 (MAU ~10만)
  단일 RDB + 인덱스 최적화
  → 이 단계에서 샤딩 도입은 오버엔지니어링

Phase 2 (MAU ~100만)
  단일 Primary + Read Replica 2~3개
  + Redis 캐시 레이어 추가
  → 읽기 부하 80~90% 감소

Phase 3 (MAU ~1000만)
  기능별 수직 분할 (User DB / Order DB / Log DB)
  + 각 DB에 Replica 구성
  + 분석용 별도 DW(Data Warehouse) 분리

Phase 4 (MAU ~1억 이상)
  핵심 테이블 샤딩 (users, orders 등)
  + CQRS 도입 (복잡한 조회 서비스)
  + 완전한 MSA DB 분리
  + 글로벌 멀티 리전 레플리케이션

결론

대규모 서비스 데이터베이스 분리는 단일 정답이 없는 아키텍처 의사결정입니다. 수직 분할로 도메인을 나누고, 레플리케이션으로 읽기 부하를 분산하고, 샤딩으로 쓰기·저장 한계를 돌파하고, CQRS·MSA로 서비스 독립성을 확보하는 것이 단계적 진화 경로입니다. 가장 중요한 원칙은 현재 규모에 맞는 복잡도를 선택하는 것입니다. 섣부른 샤딩 도입은 불필요한 복잡도를 만들고, 너무 늦은 분리는 장애와 성능 위기를 불러옵니다. 오늘 배운 전략을 기반으로 현재 서비스의 병목 지점을 진단하고 적절한 분리 전략을 선택해보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다