Replication Lag 완화 - 병렬 복제와 Sticky Primary

조용현·2026년 7월 9일

문제 해결

목록 보기
13/15

배경

이전 글에서 Read Replica를 도입하여 TPS +3.2%, 평균 응답시간 -73.8%(42ms → 11ms)를 달성했지만, 부하테스트 중 Replication Lag이 평균 104초, 최대 348초까지 발생했다.

Lag 자체는 로컬 Docker 환경(총 2cpu)의 한계이지만, Lag이 존재할 때 발생하는 Read-your-own-writes 정합성 문제는 실 서버에서도 동일하다. 사용자가 댓글을 작성한 직후 새로고침했는데 자신의 댓글이 보이지 않는다면 — 이것은 버그처럼 느껴진다.

이 문제를 해결하기 위해 두 가지 전략을 적용했다:

  1. 병렬 복제 — Replica의 binlog replay 속도를 높여 Lag 자체를 줄임
  2. Sticky Primary — 쓰기 직후 일정 시간 Master에서 읽어 정합성 보장

이 글의 구조 — Primary / Replica 분리와 Sticky Primary

이 글의 구조 — Primary / Replica 분리와 Sticky Primary


전략 1: 병렬 복제 (Parallel Replication)

문제

MySQL Replica의 SQL Thread는 기본적으로 단일 스레드로 binlog를 재생한다. Master에서 여러 트랜잭션이 동시에 커밋되더라도 Replica에서는 순차 처리된다.

Master: TX1, TX2, TX3 동시 커밋 ──→ binlog에 기록

Replica (기본): TX1 → TX2 → TX3 (순차 재생, 느림)
Replica (병렬): TX1 ┐
                TX2 ├→ 동시 재생 (빠름)
                TX3 ┘

설정

mysql-replica:
  command:
    - "--replica-parallel-workers=2"
    - "--replica-parallel-type=LOGICAL_CLOCK"

LOGICAL_CLOCK이란?

Master에서 같은 binary log group commit에 포함된 트랜잭션들은 서로 충돌하지 않는다. Master가 이미 동시 커밋을 허용했다면, Replica에서도 동시 적용이 안전하다는 논리다.

MySQL은 각 트랜잭션에 sequence_numberlast_committed 타임스탬프를 부여한다. last_committed가 동일한 트랜잭션들은 병렬 적용이 가능하다.

트레이드오프

장점단점
binlog replay 속도 향상 (worker 수에 비례)Replica CPU 사용량 소폭 증가
설정만으로 적용 (코드 변경 없음)동시 커밋이 적은 워크로드에서는 효과 제한
기존 데이터 정합성에 영향 없음worker 간 조율 오버헤드

전략 2: Sticky Primary (Read-your-own-writes)

문제

Lag이 존재하면 사용자가 글을 작성한 직후 자신의 글이 Replica에 아직 반영되지 않아 보이지 않는다.

해결

쓰기 트랜잭션이 커밋되면 해당 사용자에게 STICKY_PRIMARY 쿠키(3초 TTL)를 발급한다. 이후 3초간 해당 사용자의 readOnly 쿼리가 Master로 라우팅되어, 방금 쓴 데이터를 즉시 읽을 수 있다.

사용자 → 댓글 작성 (Master에 커밋)
  → 응답에 STICKY_PRIMARY=1 쿠키 (maxAge=3초) 포함
  → 3초 이내 다음 요청 → Master에서 읽기 (자기 글 확인 가능)
  → 3초 후 쿠키 만료 → Replica 라우팅 복귀

아키텍처

Request → StickyPrimaryFilter (Cookie → ThreadLocal)
  → LazyConnectionDataSourceProxy
      ├── @Transactional           → Master (항상)
      └── @Transactional(readOnly)
              → StickyAwareRoutingDataSource
                  ├── sticky=true  → Master (3초간)
                  └── sticky=false → Replica (평상시)
  → StickyPrimaryAspect (커밋 후 → Cookie 발급)

핵심 구현

StickyPrimaryHolder — ThreadLocal 플래그

public final class StickyPrimaryHolder {
    private static final ThreadLocal<Boolean> STICKY = ThreadLocal.withInitial(() -> Boolean.FALSE);

    public static boolean isSticky() { return STICKY.get(); }
    public static void markSticky() { STICKY.set(Boolean.TRUE); }
    public static void clear() { STICKY.remove(); }
}

StickyAwareRoutingDataSource — 조건부 라우팅

public class StickyAwareRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        if (StickyPrimaryHolder.isSticky()) {
            return MASTER;  // 쓰기 직후 → Master에서 읽기
        }
        return REPLICA;     // 평상시 → Replica에서 읽기
    }
}

StickyPrimaryFilter — 요청 진입점

@Component
public class StickyPrimaryFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(...) {
        try {
            if (hasStickyPrimaryCookie(request)) {
                StickyPrimaryHolder.markSticky();
            }
            filterChain.doFilter(request, response);
        } finally {
            StickyPrimaryHolder.clear();  // 반드시 정리 (스레드 풀 재사용)
        }
    }
}

StickyPrimaryAspect — 커밋 후 쿠키 발급

@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE)  // 트랜잭션 인터셉터 안쪽에서 실행
public class StickyPrimaryAspect {

    @Before("@annotation(tx)")
    public void beforeMethodLevelWriteTransaction(Transactional tx) {
        if (tx.readOnly()) return;
        if (!TransactionSynchronizationManager.isSynchronizationActive()) return;

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                Cookie cookie = new Cookie("STICKY_PRIMARY", "1");
                cookie.setMaxAge(3);
                cookie.setHttpOnly(true);
                response.addCookie(cookie);
            }
        });
    }
}

DataSourceConfig — 조립

@Value("${app.sticky-primary.enabled:true}")
private boolean stickyPrimaryEnabled;

@Bean
@Primary
public DataSource dataSource(
        @Qualifier("writeDataSource") DataSource writeDataSource,
        @Qualifier("readDataSource") DataSource readDataSource) {
    LazyConnectionDataSourceProxy proxy = new LazyConnectionDataSourceProxy(writeDataSource);
    if (stickyPrimaryEnabled) {
        proxy.setReadOnlyDataSource(new StickyAwareRoutingDataSource(writeDataSource, readDataSource));
    } else {
        proxy.setReadOnlyDataSource(readDataSource);
    }
    return proxy;
}

app.sticky-primary.enabled 환경변수로 Sticky Primary를 on/off할 수 있다. 비활성화 시 readOnly 쿼리는 항상 Replica로 직접 라우팅된다.

설계 결정

왜 Cookie인가? (vs HttpSession)

비교CookieHttpSession
StatelessOX (서버 메모리)
Scale Out 호환O (클라이언트 보관)Redis 등 필요
자동 만료maxAge로 브라우저가 처리별도 TTL 관리
비로그인 사용자동작세션 생성 필요

이 서비스는 비로그인 게스트도 글/댓글 작성이 가능하므로, 세션 없이도 동작하는 Cookie 방식이 적합하다. 또한 향후 Scale Out(멀티 인스턴스)으로 확장할 때 별도 세션 스토어 없이 동작한다.

왜 TransactionSynchronization.afterCommit인가?

방식문제
AOP @AfterReturning메서드 반환 ≠ 커밋 완료. 롤백 시에도 쿠키 발급될 수 있음
@TransactionalEventListener이벤트 발행 코드를 모든 서비스에 추가해야 함
afterCommit 콜백커밋 성공 시에만 호출. 기존 서비스 코드 수정 없음

왜 3초인가?

  • 실 서버 환경의 Replica Lag P99이 1초 이내로 예상
  • 3초는 충분한 안전 마진 (lag P99의 3배)
  • 너무 길면 Master에 불필요한 읽기 부하 증가
  • 너무 짧으면 lag spike 시 정합성 미보장

@Order(LOWEST_PRECEDENCE)의 의미

Spring의 트랜잭션 인터셉터는 기본 order가 LOWEST_PRECEDENCE다. 같은 order면 선언 순서에 따라 실행되지만, 핵심은 aspect가 트랜잭션 안쪽에서 실행되어야 TransactionSynchronizationManager가 활성 상태라는 것이다.

호출 순서: Proxy → Transaction Interceptor (begin TX) → StickyPrimaryAspect (@Before)
                                                         → 실제 메서드 실행
                                                      ← afterCommit() 호출
                                                   ← Transaction Interceptor (commit)

부하테스트 결과

병렬 복제 + Sticky Primary를 적용한 후 동일 조건(750 vusers, 10분)으로 부하테스트를 재실행했다.

Replication Lag 비교

지표Before (전략 미적용)After (병렬 복제 + Sticky Primary)변화
Lag 최대348s177s-49%
Lag 평균104s26s-75%
복구 시간~2.75분~1.5분-45%

병렬 복제 2 workers로 binlog replay 속도가 향상되어, Lag 최대 49%, 평균 75% 감소했다.

Before — Replication Lag (workers=1, Sticky Primary OFF)

Replication Lag Before

단일 SQL Thread(workers=1)로 binlog를 순차 처리하면서 Lag이 테스트 중반부터 급격히 상승하여 최대 348초에 도달했다. 테스트 종료 후 약 2.75분 만에 복구되었다.

After — Replication Lag (workers=2, Sticky Primary ON)

Replication Lag After

병렬 복제(workers=2)로 binlog replay가 빨라져 Lag이 최대 177초로 약 절반 수준을 유지했다. 테스트 종료 후 약 1.5분 만에 복구되었다.

성능 비교

지표Before (전략 미적용)After (병렬 복제 + Sticky Primary)변화
TPS550.2/s530.4/s-3.6%
평균 응답시간425ms476ms+12%
P95 응답시간830ms747ms-10%
P99 응답시간1,241ms1,168ms-5.9%
에러율0%0%동일

TPS와 평균 응답시간은 소폭 하락(-3.6%, +12%)했지만, P95/P99 tail latency는 오히려 개선되었다.

TPS/Avg 소폭 하락 원인: Sticky Primary로 쓰기 직후 3초간 Master에서 읽기가 발생한다. 1 cpu로 제한된 Master가 쓰기 + 추가 읽기를 모두 처리하면서 소폭 오버헤드가 발생한다. 실 서버 환경에서는 Master CPU가 충분하므로 이 하락은 발생하지 않을 것으로 예상된다.

P95/P99 개선 원인: 병렬 복제로 Replica의 binlog replay 부하가 분산되면서 읽기 쿼리의 tail latency가 줄어들었다.


정리

전략별 효과

전략목적방식코드 변경
병렬 복제Lag 자체를 줄임MySQL 설정 (workers=2, LOGICAL_CLOCK)없음 (인프라만)
Sticky PrimaryLag이 있어도 정합성 보장Cookie + ThreadLocal + AOP4개 클래스 추가

핵심 수치

지표BeforeAfter변화
Replication Lag 최대348s177s-49%
Replication Lag 평균104s26s-75%
P95 응답시간830ms747ms-10%
P99 응답시간1,241ms1,168ms-5.9%

결론

왜 이 전략인가 — 커뮤니티 서비스의 특성

커뮤니티 포럼은 결제나 재고 시스템과 달리 강한 즉시 일관성이 필요하지 않다. 게시글 목록이나 다른 사람의 댓글이 1~2초 늦게 보여도 사용자는 인지하지 못한다. 그러나 "내가 방금 쓴 글이 보이지 않는 것"은 버그처럼 느껴진다. 즉, 전체 정합성은 Eventual로 충분하지만, 자기 자신의 쓰기에 대해서만은 즉시 일관성이 필요하다.

이 비대칭적 요구사항에 Semi-sync Replication이나 전체 쿼리를 Master로 보내는 방식은 과하다. Semi-sync는 모든 쓰기에 Replica ACK 대기를 강제하여 쓰기 지연이 증가하고, 전체 Master 라우팅은 Replica 도입 자체를 무의미하게 만든다.

병렬 복제 + Sticky Primary는 이 커뮤니티 특성에 정확히 맞는 조합이다:

  • 병렬 복제: Lag을 줄여 대다수 사용자(읽기만 하는 90%)에게 더 신선한 데이터를 제공
  • Sticky Primary: 쓰기를 수행한 특정 사용자에게만 3초간 Master 라우팅, 나머지는 Replica 유지

전체 시스템에 일관성 비용을 부과하지 않으면서, "내 글이 안 보이는" 문제만 정확히 해결한다.

트레이드오프

TPS -3.6%와 평균 응답시간 +12%는 로컬 Docker 환경에서 Master에 1 cpu만 할당된 상태에서 Sticky Primary로 인한 추가 읽기 부하가 원인이다. 실 서버 환경에서는 Master CPU가 충분하므로 이 트레이드오프는 발생하지 않을 것으로 예상한다.

최종적으로, 커뮤니티 서비스의 "대부분은 느슨하게, 본인 쓰기만 즉시" 요구사항을 코드 4개 파일 추가만으로 해결한 것이 핵심 성과다.

profile
백엔드 개발자

4개의 댓글

comment-user-thumbnail
2026년 8월 18일

Sticky Primary의 TTL을 3초로 설정하셨는데, 실제 운영 중 Replica Lag이 3초를 초과하는 spike가 발생하면 결국 Read-your-own-writes 정합성이 다시 깨질 수 있을 것 같습니다. 고정 TTL 대신 실제 Replica Lag을 기반으로 동적으로 판단하는 방식은 고려하지 않으셨나요?

1개의 답글
comment-user-thumbnail
2026년 8월 18일

Before는 workers=1 + Sticky Primary OFF이고 After는 workers=2 + Sticky Primary ON인데, 두 변수를 동시에 변경하셨습니다. 그렇다면 Lag 감소와 P95/P99 개선이 각각 병렬 복제 때문인지 Sticky Primary 때문인지 어떻게 구분할 수 있나요?

1개의 답글