이전 글에서 Read Replica를 도입하여 TPS +3.2%, 평균 응답시간 -73.8%(42ms → 11ms)를 달성했지만, 부하테스트 중 Replication Lag이 평균 104초, 최대 348초까지 발생했다.
Lag 자체는 로컬 Docker 환경(총 2cpu)의 한계이지만, Lag이 존재할 때 발생하는 Read-your-own-writes 정합성 문제는 실 서버에서도 동일하다. 사용자가 댓글을 작성한 직후 새로고침했는데 자신의 댓글이 보이지 않는다면 — 이것은 버그처럼 느껴진다.
이 문제를 해결하기 위해 두 가지 전략을 적용했다:

이 글의 구조 — Primary / Replica 분리와 Sticky Primary
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"
Master에서 같은 binary log group commit에 포함된 트랜잭션들은 서로 충돌하지 않는다. Master가 이미 동시 커밋을 허용했다면, Replica에서도 동시 적용이 안전하다는 논리다.
MySQL은 각 트랜잭션에 sequence_number와 last_committed 타임스탬프를 부여한다. last_committed가 동일한 트랜잭션들은 병렬 적용이 가능하다.
| 장점 | 단점 |
|---|---|
| binlog replay 속도 향상 (worker 수에 비례) | Replica CPU 사용량 소폭 증가 |
| 설정만으로 적용 (코드 변경 없음) | 동시 커밋이 적은 워크로드에서는 효과 제한 |
| 기존 데이터 정합성에 영향 없음 | worker 간 조율 오버헤드 |
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 발급)
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(); }
}
public class StickyAwareRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
if (StickyPrimaryHolder.isSticky()) {
return MASTER; // 쓰기 직후 → Master에서 읽기
}
return REPLICA; // 평상시 → Replica에서 읽기
}
}
@Component
public class StickyPrimaryFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
try {
if (hasStickyPrimaryCookie(request)) {
StickyPrimaryHolder.markSticky();
}
filterChain.doFilter(request, response);
} finally {
StickyPrimaryHolder.clear(); // 반드시 정리 (스레드 풀 재사용)
}
}
}
@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);
}
});
}
}
@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 | HttpSession |
|---|---|---|
| Stateless | O | X (서버 메모리) |
| Scale Out 호환 | O (클라이언트 보관) | Redis 등 필요 |
| 자동 만료 | maxAge로 브라우저가 처리 | 별도 TTL 관리 |
| 비로그인 사용자 | 동작 | 세션 생성 필요 |
이 서비스는 비로그인 게스트도 글/댓글 작성이 가능하므로, 세션 없이도 동작하는 Cookie 방식이 적합하다. 또한 향후 Scale Out(멀티 인스턴스)으로 확장할 때 별도 세션 스토어 없이 동작한다.
| 방식 | 문제 |
|---|---|
AOP @AfterReturning | 메서드 반환 ≠ 커밋 완료. 롤백 시에도 쿠키 발급될 수 있음 |
@TransactionalEventListener | 이벤트 발행 코드를 모든 서비스에 추가해야 함 |
afterCommit 콜백 | 커밋 성공 시에만 호출. 기존 서비스 코드 수정 없음 |
Spring의 트랜잭션 인터셉터는 기본 order가 LOWEST_PRECEDENCE다. 같은 order면 선언 순서에 따라 실행되지만, 핵심은 aspect가 트랜잭션 안쪽에서 실행되어야 TransactionSynchronizationManager가 활성 상태라는 것이다.
호출 순서: Proxy → Transaction Interceptor (begin TX) → StickyPrimaryAspect (@Before)
→ 실제 메서드 실행
← afterCommit() 호출
← Transaction Interceptor (commit)
병렬 복제 + Sticky Primary를 적용한 후 동일 조건(750 vusers, 10분)으로 부하테스트를 재실행했다.
| 지표 | Before (전략 미적용) | After (병렬 복제 + Sticky Primary) | 변화 |
|---|---|---|---|
| Lag 최대 | 348s | 177s | -49% |
| Lag 평균 | 104s | 26s | -75% |
| 복구 시간 | ~2.75분 | ~1.5분 | -45% |
병렬 복제 2 workers로 binlog replay 속도가 향상되어, Lag 최대 49%, 평균 75% 감소했다.
Before — Replication Lag (workers=1, Sticky Primary OFF)

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

병렬 복제(workers=2)로 binlog replay가 빨라져 Lag이 최대 177초로 약 절반 수준을 유지했다. 테스트 종료 후 약 1.5분 만에 복구되었다.
| 지표 | Before (전략 미적용) | After (병렬 복제 + Sticky Primary) | 변화 |
|---|---|---|---|
| TPS | 550.2/s | 530.4/s | -3.6% |
| 평균 응답시간 | 425ms | 476ms | +12% |
| P95 응답시간 | 830ms | 747ms | -10% |
| P99 응답시간 | 1,241ms | 1,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 Primary | Lag이 있어도 정합성 보장 | Cookie + ThreadLocal + AOP | 4개 클래스 추가 |
| 지표 | Before | After | 변화 |
|---|---|---|---|
| Replication Lag 최대 | 348s | 177s | -49% |
| Replication Lag 평균 | 104s | 26s | -75% |
| P95 응답시간 | 830ms | 747ms | -10% |
| P99 응답시간 | 1,241ms | 1,168ms | -5.9% |
커뮤니티 포럼은 결제나 재고 시스템과 달리 강한 즉시 일관성이 필요하지 않다. 게시글 목록이나 다른 사람의 댓글이 1~2초 늦게 보여도 사용자는 인지하지 못한다. 그러나 "내가 방금 쓴 글이 보이지 않는 것"은 버그처럼 느껴진다. 즉, 전체 정합성은 Eventual로 충분하지만, 자기 자신의 쓰기에 대해서만은 즉시 일관성이 필요하다.
이 비대칭적 요구사항에 Semi-sync Replication이나 전체 쿼리를 Master로 보내는 방식은 과하다. Semi-sync는 모든 쓰기에 Replica ACK 대기를 강제하여 쓰기 지연이 증가하고, 전체 Master 라우팅은 Replica 도입 자체를 무의미하게 만든다.
병렬 복제 + Sticky Primary는 이 커뮤니티 특성에 정확히 맞는 조합이다:
전체 시스템에 일관성 비용을 부과하지 않으면서, "내 글이 안 보이는" 문제만 정확히 해결한다.
TPS -3.6%와 평균 응답시간 +12%는 로컬 Docker 환경에서 Master에 1 cpu만 할당된 상태에서 Sticky Primary로 인한 추가 읽기 부하가 원인이다. 실 서버 환경에서는 Master CPU가 충분하므로 이 트레이드오프는 발생하지 않을 것으로 예상한다.
최종적으로, 커뮤니티 서비스의 "대부분은 느슨하게, 본인 쓰기만 즉시" 요구사항을 코드 4개 파일 추가만으로 해결한 것이 핵심 성과다.
Sticky Primary의 TTL을 3초로 설정하셨는데, 실제 운영 중 Replica Lag이 3초를 초과하는 spike가 발생하면 결국 Read-your-own-writes 정합성이 다시 깨질 수 있을 것 같습니다. 고정 TTL 대신 실제 Replica Lag을 기반으로 동적으로 판단하는 방식은 고려하지 않으셨나요?