DB 복제와 읽기·쓰기 분리 #Primary-Replica #Replication Lag

서비스의 사용자가 증가하면 데이터베이스에 전달되는 요청도 함께 증가한다. 특히 게임 서버에서는 로그인, 프로필 조회, 인벤토리 조회, 랭킹 조회, 상점 조회 등 다양한 읽기 요청이 발생한다.

모든 읽기와 쓰기 요청을 하나의 데이터베이스 서버에서 처리하면 CPU, 메모리, 디스크 I/O, 네트워크 등의 자원이 병목이 될 수 있다.

이 문제를 해결하는 대표적인 방법 중 하나가 데이터베이스 복제(Replication)와 읽기·쓰기 분리(Read/Write Splitting) 다.

핵심은 데이터를 여러 데이터베이스 서버에 복제하고, 데이터 변경은 Primary에서 처리하며, 조회는 Replica로 분산하는 것이다.

하지만 복제는 데이터를 모든 서버에 즉시 동일하게 만드는 작업이 아니다. 복제 지연이 발생할 수 있으며, 읽기·쓰기 분리만으로 모든 성능 문제가 해결되는 것도 아니다.

이 글에서는 Primary-Replica 구조, Replication, Replication Lag, Read Replica, 읽기·쓰기 분리의 한계를 살펴본다.


Primary-Replica 구조

Primary와 Replica란?

Primary-Replica는 데이터베이스 서버의 역할을 구분하는 구조다.

  • Primary: 데이터 변경 작업을 처리하는 기준 서버
  • Replica: Primary의 데이터를 복제하여 주로 읽기 작업을 처리하는 서버

일반적으로 데이터 변경은 Primary에서 수행하고, Replica는 복제된 데이터를 이용해 조회 요청을 처리한다.

예를 들어 게임 서버에서 다음과 같은 요청이 발생한다고 가정하자.

요청처리 서버
플레이어 정보 조회Replica
인벤토리 조회Replica
랭킹 조회Replica
아이템 구매Primary
포인트 차감Primary
캐릭터 이름 변경Primary

이렇게 요청을 분리하면 Primary에 집중되던 조회 부하를 여러 Replica로 분산할 수 있다.

구조

                  Game Server
                       |
             Database Router
                       |
          +------------+------------+
          |                         |
     Write Request             Read Request
          |                         |
          v                         v
      +---------+              +----------+
      | Primary | ------------>| Replica 1|
      +---------+  Replication  +----------+
          |                         ^
          |                         |
          +-------------------------+
                    Replication

실제 환경에서는 Primary가 여러 Replica에 변경 사항을 전파한다. 위 구조는 개념을 설명하기 위한 단순화된 예시다.

Replica가 여러 개라면 읽기 요청을 각 서버에 분산할 수 있다.

                Game Server
                     |
              Database Router
                /          \
          Write              Read
            |                 |
            v          +------+------+
        +---------+    |      |      |
        | Primary |    v      v      v
        +---------+  Replica Replica Replica
                       1       2      3

다만 Replica가 많아진다고 해서 쓰기 처리량까지 자동으로 증가하는 것은 아니다. 일반적인 Primary-Replica 구조에서는 모든 쓰기 요청이 여전히 Primary에 집중되기 때문이다.

Primary-Replica 구조의 장점

읽기 부하 분산

조회 요청을 여러 Replica로 분산하여 Primary의 CPU와 디스크 I/O 부담을 줄일 수 있다.

서비스 확장성

읽기 요청이 증가하면 Replica를 추가하여 읽기 처리 능력을 확장할 수 있다. 단, 네트워크, 복제 처리량, 스토리지 등 다른 병목도 함께 고려해야 한다.

장애 대응

Primary에 장애가 발생했을 때 Replica를 새로운 Primary로 승격하는 방식으로 복구할 수 있다.

다만 복제 지연이나 비동기 복제로 인해 최신 데이터 일부가 유실될 수 있으며, 자동 승격에는 장애 감지, 리더 선출, 기존 Primary의 쓰기 차단 등의 절차가 필요하다.


Replication

Replication이란?

Replication은 한 데이터베이스의 변경 사항을 다른 데이터베이스로 복제하는 과정이다.

Primary에서 데이터가 변경되면 데이터베이스는 변경 내용을 로그로 기록하고, Replica는 해당 내용을 전달받아 자신의 데이터에 반영한다.

MySQL에서는 일반적으로 바이너리 로그(Binary Log)를 이용해 변경 사항을 전달하고, Replica가 이를 적용한다.

기본적인 과정은 다음과 같다.

1. 클라이언트가 Primary에 쓰기 요청
              |
              v
2. Primary에서 트랜잭션 실행
              |
              v
3. 변경 사항을 Binary Log에 기록
              |
              v
4. Replica가 변경 로그를 수신
              |
              v
5. Replica가 변경 사항을 적용
              |
              v
6. Replica 데이터 갱신 완료

정확한 내부 처리 순서와 로그 기록 방식은 데이터베이스 엔진과 복제 설정에 따라 달라질 수 있다.

동기 복제와 비동기 복제

복제 방식은 변경 사항이 Replica에 반영되는 시점과 Primary가 쓰기 요청의 성공을 확정하는 조건에 따라 구분할 수 있다.

비동기 복제(Asynchronous Replication)

Primary가 변경 사항을 커밋한 뒤 Replica의 적용 완료를 기다리지 않고 클라이언트에 성공을 반환하는 방식이다.

Client
  |
  v
Primary: 변경 및 커밋
  |
  +------> Client: 성공 응답
  |
  +------> Replica: 변경 사항 적용

장점은 쓰기 응답 지연이 비교적 낮다는 것이다.

단점은 Primary의 커밋 이후 Replica가 변경 사항을 적용하기 전에 장애가 발생하면, 해당 변경 사항이 새 Primary에 반영되지 않을 가능성이 있다는 것이다.

동기 복제(Synchronous Replication)

Primary가 쓰기 성공을 확정하기 전에 정해진 Replica의 응답이나 복제 확인을 기다리는 방식이다.

다만 동기 복제라고 해서 반드시 모든 Replica의 데이터 적용이 완료될 때까지 기다리는 것은 아니다. 제품과 설정에 따라 로그 수신, 로그 디스크 기록, 트랜잭션 적용 등 확인하는 단계가 다르다.

장점은 복제 확인 조건을 통해 데이터 유실 가능성을 줄일 수 있다는 것이다.

단점은 Replica의 응답이 늦거나 네트워크 장애가 발생하면 쓰기 지연이 증가하거나 쓰기 처리가 제한될 수 있다는 것이다.

구분비동기 복제동기 복제
쓰기 지연상대적으로 낮음상대적으로 높을 수 있음
복제 확인커밋 성공 전에 기다리지 않음설정된 확인 조건을 기다림
데이터 유실 위험장애 시 존재할 수 있음설정에 따라 감소
장애 영향최신 데이터가 누락될 수 있음응답 지연이나 쓰기 중단 가능
주요 고려 사항복제 지연, 장애 복구가용성, 네트워크 지연

복제는 단순히 데이터베이스를 복사하는 작업이 아니다. 데이터 일관성, 장애 복구, 응답 시간 사이에서 적절한 균형을 선택하는 설계 문제다.


Replication Lag

Replication Lag란?

Replication Lag는 Primary에서 발생한 변경 사항이 Replica에 반영되기까지 발생하는 지연을 의미한다.

예를 들어 플레이어의 포인트가 1,000점에서 700점으로 변경되었다고 가정하자.

Primary에는 다음과 같이 저장되어 있다.

player_id: 1001
points: 700

하지만 Replica가 변경 사항을 아직 적용하지 않았다면 다음과 같은 상태일 수 있다.

player_id: 1001
points: 1000

이때 Replica에서 조회하면 이미 차감된 포인트가 아니라 이전 포인트가 반환될 수 있다.

이것이 Replication Lag로 인해 발생하는 대표적인 문제다.

Replication Lag가 발생하는 원인

네트워크 지연

Primary와 Replica 사이의 네트워크 지연으로 인해 변경 로그 전달이 늦어질 수 있다.

Replica의 처리 능력 부족

Primary가 변경 사항을 생성하는 속도보다 Replica가 변경 사항을 적용하는 속도가 느리면 복제 대기열이 증가한다.

대량 트랜잭션

대량의 데이터를 변경하는 트랜잭션은 Replica가 처리해야 하는 작업량을 증가시킨다.

디스크 I/O 병목

Replica의 디스크 성능이 부족하거나 다른 작업과 자원을 경쟁하면 로그 적용이 지연될 수 있다.

복제 구조의 복잡성

복제 토폴로지, 병렬 적용 설정, 데이터베이스 엔진의 내부 처리 방식 등에 따라서도 지연 특성이 달라진다.

Replication Lag가 만드는 실제 문제

게임에서 포인트 차감과 상점 조회가 연속으로 발생한다고 가정하자.

1. 플레이어 포인트: 1,000

2. Primary에서 아이템 구매
   포인트: 1,000 → 700

3. 구매 요청 성공 응답

4. 직후 Replica에서 포인트 조회

5. Replica가 아직 변경 사항을 적용하지 못함
   조회 결과: 1,000

플레이어 입장에서는 구매가 성공했는데 포인트가 차감되지 않은 것처럼 보인다.

이러한 문제는 단순한 화면 표시 오류를 넘어 다음과 같은 결과를 만들 수 있다.

  • 오래된 인벤토리 표시
  • 구매 직후 구매 가능 여부를 잘못 판단
  • 캐릭터 정보의 일시적인 불일치
  • 최신 데이터가 필요한 업무 처리의 오류

특히 포인트 차감, 아이템 지급, 구매 가능 여부 판정과 같이 정확성이 중요한 작업에서는 오래된 Replica 데이터를 기준으로 결정을 내리면 안 된다.

Replication Lag 대응 방법

최신 데이터가 필요한 조회는 Primary 사용

쓰기 직후 결과를 확인해야 하는 요청은 Primary에서 조회한다.

아이템 구매
    |
    v
Primary에서 포인트 차감
    |
    v
Primary에서 최신 포인트 조회
    |
    v
응답 반환

읽기 후 쓰기 일관성 보장

사용자가 데이터를 변경한 뒤 이어지는 조회에서 자신의 변경 사항을 확인할 수 있도록 정책을 설계한다.

예를 들어 일정 시간 동안 해당 사용자의 조회를 Primary로 보내거나, 쓰기 요청의 결과를 클라이언트에 직접 반환할 수 있다.

복제 지연 모니터링

복제 지연을 측정하고 일정 기준을 초과한 Replica를 읽기 대상에서 제외할 수 있다.

다만 복제 지연을 측정하는 지표가 실제 데이터 적용 시점과 정확히 일치하는지는 사용 중인 데이터베이스의 모니터링 방식에 따라 다르다.

Replica 읽기 대기

필요한 경우 특정 변경 사항이 Replica에 반영되었는지 확인한 뒤 조회하도록 구현할 수 있다. 그러나 모든 요청에 적용하면 지연과 복잡성이 증가하므로 선택적으로 사용해야 한다.

핵심은 모든 조회를 무조건 Replica로 보내는 것이 아니라, 조회에 요구되는 최신성 수준을 먼저 정의하는 것이다.


Read Replica

Read Replica란?

Read Replica는 Primary의 데이터를 복제하고 읽기 요청을 처리하도록 운영하는 데이터베이스 복제본이다.

Primary와 Replica는 물리적으로 별도의 서버일 수도 있고, 클라우드 환경에서 별도 인스턴스로 구성할 수도 있다.

일반적인 구조는 다음과 같다.

                Application Server
                        |
                 Database Layer
                   /         \
                  /           \
             Write             Read
               |                 |
               v                 v
           Primary          Read Replica
               |                 ^
               +-----------------+
                  Replication

애플리케이션은 요청의 성격에 따라 적절한 데이터베이스 연결을 선택한다.

Node.js에서 읽기·쓰기 분리하기

Node.js와 TypeScript 환경에서 데이터베이스 접근 계층을 분리하는 예시다. 아래 코드는 mysql2/promise를 사용하는 간단한 구조다.

import mysql from "mysql2/promise";

const primaryPool = mysql.createPool({
  host: process.env.DB_PRIMARY_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 20,
});

const replicaPool = mysql.createPool({
  host: process.env.DB_REPLICA_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 40,
});

// 조회 전용 함수
export async function readQuery<T = any>(
  sql: string,
  params: unknown[] = [],
): Promise<T[]> {
  const [rows] = await replicaPool.query(sql, params);
  return rows as T[];
}

// 변경 전용 함수
export async function writeQuery(
  sql: string,
  params: unknown[] = [],
): Promise<void> {
  await primaryPool.execute(sql, params);
}

사용 예시는 다음과 같다.

// 플레이어 정보 조회
const players = await readQuery(
  "SELECT id, nickname FROM players WHERE id = ?",
  [1001],
);

// 플레이어 닉네임 변경
await writeQuery(
  "UPDATE players SET nickname = ? WHERE id = ?",
  ["NewPlayer", 1001],
);

이 예시에서는 조회 요청은 Replica로, 변경 요청은 Primary로 보낸다.

단, readQuery()와 writeQuery()는 실제 운영 환경에서 사용할 수 있는 완전한 데이터베이스 계층이 아니다. 다음 사항을 추가로 고려해야 한다.

  • 입력값과 SQL 유형 검증
  • 연결 오류 및 타임아웃 처리
  • 트랜잭션 처리
  • 복제 지연 대응
  • 읽기 전용 계정 권한 설정
  • Replica 장애 시 대체 동작
  • SQL 주입 방지를 위한 파라미터 바인딩
  • 데이터 변경 직후의 조회 라우팅

특히 트랜잭션은 하나의 Primary 연결에서 시작하고 커밋해야 한다. 트랜잭션 도중 임의로 Replica 연결을 사용하면 동일한 트랜잭션의 일관성을 보장할 수 없다.

읽기·쓰기 분리를 위한 라우팅 정책

단순히 SQL이 SELECT로 시작하는지 확인하여 읽기와 쓰기를 분리하는 방식은 안전하지 않다.

예를 들어 다음 쿼리는 조회처럼 보이지만 데이터 변경이나 잠금과 관련될 수 있다.

SELECT *
FROM inventory
WHERE player_id = ?
FOR UPDATE;

FOR UPDATE는 일반적으로 트랜잭션 안에서 행 잠금을 획득하기 위한 쿼리다. 이러한 쿼리를 Replica로 보내면 의도한 잠금 및 트랜잭션 동작을 보장할 수 없다.

따라서 SQL 문자열만 분석하기보다는 Repository 또는 데이터 접근 계층에서 작업의 목적에 따라 명시적으로 연결을 선택하는 편이 안전하다.

class PlayerRepository {
  async findProfile(playerId: number) {
    // 일반 조회: Replica
  }

  async updateNickname(
    playerId: number,
    nickname: string,
  ) {
    // 데이터 변경: Primary
  }

  async findLatestBalance(playerId: number) {
    // 최신성이 중요한 조회: Primary
  }
}

이처럼 메서드의 책임에 따라 데이터베이스 접근 경로를 분리하면 라우팅 정책을 추적하고 테스트하기 쉬워진다.


읽기·쓰기 분리의 한계

읽기·쓰기 분리는 유용하지만 모든 데이터베이스 성능 문제를 해결하지는 않는다.

쓰기 성능은 자동으로 증가하지 않는다

Primary-Replica 구조에서는 일반적으로 모든 쓰기 요청이 Primary에 집중된다.

Read Request  → Replica 1
Read Request  → Replica 2
Read Request  → Replica 3

Write Request → Primary
Write Request → Primary
Write Request → Primary

Replica를 10대 추가하더라도 Primary의 쓰기 처리 능력이 자동으로 10배 증가하지는 않는다.

쓰기 부하가 병목이라면 다음과 같은 방법을 검토해야 한다.

  • 인덱스 및 SQL 최적화
  • 불필요한 쓰기 제거
  • 트랜잭션 시간 단축
  • 배치 처리
  • 데이터 모델 개선
  • 샤딩을 통한 데이터 분산
  • 쓰기 요청의 비동기 처리 및 큐 활용

단, 비동기 큐를 활용하면 처리 시점이 달라지므로 업무상 허용되는 일관성 수준을 함께 설계해야 한다.

복제 지연으로 인한 일관성 문제

Replica는 Primary와 항상 동일한 시점의 데이터를 보여 주는 것이 아니다.

최신 상태가 중요한 조회까지 Replica로 보내면 오래된 데이터를 기반으로 판단할 수 있다.

따라서 조회 요청은 다음과 같이 구분할 수 있다.

조회 유형권장 처리
일반적인 프로필 조회Replica 가능
랭킹 및 통계 조회Replica 가능
구매 직후 포인트 확인Primary 또는 쓰기 결과 사용
구매 가능 여부 판정Primary의 트랜잭션 기반 검증
아이템 지급 여부 확인Primary의 최신 상태 확인
데이터 변경 후 즉시 확인해야 하는 조회Primary 또는 일관성 보장 방식 적용

이 표는 일반적인 설계 기준이다. 실제 선택은 데이터의 중요도와 허용 가능한 지연에 따라 결정해야 한다.

운영 비용과 복잡성 증가

Replica를 추가하면 단순히 서버 비용만 증가하는 것이 아니다.

  • 데이터 복제 및 모니터링
  • 장애 감지 및 복구
  • 연결 풀 관리
  • 읽기 대상 선택
  • 복제 지연 대응
  • 운영 환경별 설정 관리
  • 데이터베이스 업그레이드 및 유지보수

이러한 관리 비용도 함께 증가한다.

또한 Replica가 장애를 일으키거나 복제 지연이 심해지면 읽기 요청을 Primary로 전환할지, 일부 조회를 제한할지 결정해야 한다.

Primary로 모든 읽기를 전환하면 Primary에 과부하가 발생할 수 있으므로 장애 시나리오까지 고려해야 한다.

Replica를 늘려도 모든 조회가 빨라지지는 않는다

조회 부하가 분산되더라도 다음과 같은 병목은 남을 수 있다.

  • 비효율적인 SQL
  • 인덱스 누락
  • 대량 데이터 전송
  • 높은 네트워크 지연
  • 애플리케이션의 Connection Pool 고갈
  • 특정 Replica에 집중되는 요청
  • 데이터베이스 외부의 캐시 또는 애플리케이션 병목

따라서 먼저 실행 계획과 성능 지표를 확인하고 실제 병목이 읽기 처리량인지 판단해야 한다.

장애 대응과 데이터 유실 가능성

비동기 복제 환경에서 Primary가 장애를 일으키면 Replica에 아직 전달되지 않았거나 적용되지 않은 변경 사항이 존재할 수 있다.

Replica를 새 Primary로 승격하더라도 기존 Primary에서 성공 처리했던 모든 트랜잭션이 보존된다고 보장할 수는 없다.

운영 환경에서는 다음 항목을 함께 설계해야 한다.

  • 장애 감지와 승격 절차
  • Split-brain 방지
  • 기존 Primary의 쓰기 차단
  • 데이터 유실 허용 범위
  • 복구 시점 목표(RPO)
  • 복구 시간 목표(RTO)
  • 백업과 복구 검증

Replication은 백업을 대체하지 않는다. 잘못된 데이터 변경이나 삭제가 복제되면 Replica에도 동일한 문제가 전파될 수 있기 때문이다.


실무에서 고려할 설계 기준

읽기·쓰기 분리는 서버 수를 늘리는 작업이 아니라 데이터의 최신성 요구와 처리 부하를 분리하는 작업이다.

도입 전에는 다음 사항을 확인하는 것이 좋다.

  • 읽기와 쓰기의 비율은 어느 정도인가?
  • 실제 병목은 Primary의 조회 부하인가?
  • 어떤 조회가 최신 데이터를 반드시 요구하는가?
  • 복제 지연을 어느 정도까지 허용할 수 있는가?
  • Replica 장애 시 읽기 요청은 어떻게 처리할 것인가?
  • Primary 장애 시 데이터 유실을 어디까지 허용할 것인가?
  • Replica를 추가하는 비용이 성능 개선 효과보다 낮은가?

특히 게임 서버에서는 랭킹, 통계, 공개 프로필처럼 약간 오래된 데이터가 허용되는 조회와 포인트, 구매, 인벤토리 변경처럼 정확성이 중요한 작업을 구분하는 것이 중요하다.

모든 조회를 Replica로 보내는 것이 목표가 아니다. 최신성이 필요한 작업은 정확하게 처리하고, 최신성이 덜 중요한 조회는 분산하여 처리하는 것이 핵심이다.

답글 남기기

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