본문 바로가기

데이터 접근 기술 전환

공개 읽기 계약을 유지한 채 표본 비동기 비교와 실패 폐쇄형 관문으로 데이터 접근 구현을 전환합니다.

데이터 접근 기술은 코드 줄 수 하나로 고르지 않습니다. 애그리거트 변경, 동적 검색, 보고서 SQL, 생성 SQL 진단, 팀의 운영 경험과 업그레이드 비용을 실제 작업 부하에서 함께 봅니다.

기술을 바꿀 때 더 중요한 기준은 공개 계약이 그대로 실행되는가입니다. 기존 구현을 기본 응답으로 유지하고 후보 구현은 같은 PostQuery 입력과 불변 결과만 비동기로 비교합니다. 쓰기는 언제나 한 구현이 소유하며 읽기 비교가 명령 경로를 만들지 않습니다.

표본 비동기 비교는 한 쓰기 소유자와 실패 폐쇄형 전환을 지킨다

기본 읽기를 유지한 표본 비교·관문·읽기 전환 또는 새 관찰 구간으로의 복귀가 상태 전이이다.

PERSISTENCE CUTOVER · FAIL-CLOSED GATE

관문이 닫히면 기본 읽기를 유지한다

기본 결과를 먼저 고정하고 후보는 표본 비동기 읽기로 비교합니다. 후보의 실패·타임아웃·큐 거부는 사용자 응답을 바꾸지 않습니다.

읽기 전환 관문의 통과와 실패 분기공유 계약 테스트를 통과한 후보를 표본 비동기 읽기로 비교하고 최소 표본, 불일치 비율, p95 지연과 실패 조건을 함께 검사한다. 통과 때만 읽기를 전환하며 실패하면 기본 읽기를 유지하고 수정 후 새 관찰 구간으로 되돌아간다.공유 계약 테스트같은 포트 · 같은 fixture표본 비동기 비교후보 읽기 · 유한 큐전환 관문표본 · 정확도 · 지연통과후보를 기본 읽기로전환 후 계속 관찰실패기본 읽기 유지수정 + 새 관찰 구간관문: 최소 표본 · 허용 불일치 비율 · p95 · 실패/timeout/거부 0
관문 통과와 유지·새 관찰의 피드백을 구별한다desktop과같은다섯노드·다섯간선이다. 계약→표본비교→관문,관문통과→후보전환,실패→기본유지,유지후수정→표본비교의새관찰구간으로돌아간다. 비교TIMEOUT은실행SQL취소를보장하지않으며이그림은관문정책이다.쓰기소유자는한곳이라는원패널을보존한다.공유 계약 테스트같은 fixture표본 비동기 비교후보 읽기 · 유한 큐전환 관문표본 · 정확도 · 지연후보를 기본 읽기로계속 관찰기본 읽기 유지수정 · 새 관찰통과실패새 관찰
  1. 공유 계약 → 표본 비교

    후보가 같은 계약을 통과한 뒤 기본 결과를 먼저 얻어 고정합니다. 유한 큐에서 PostQuery만 비교합니다.

  2. 모든 관문을 함께 판단

    최소 표본·불일치 비율·p95와 실패·타임아웃·거부를 확인합니다. 하나라도 실패하면 기본 읽기를 유지하고 새 관찰 구간을 엽니다.

  3. 통과 때만 기본 읽기 전환

    후보를 기본 읽기로 바꾸고 지표를 계속 관찰합니다.

전환 뒤 · 롤백 창 → 이전 읽기 제거

되돌릴 관찰 기간 동안 이전 읽기 구현을 대기 상태로 유지합니다. 관찰과 롤백 창이 끝난 뒤 제거합니다.

전 구간 · 쓰기 소유자는 하나

shadow는 읽기 비교만 수행합니다. 진단 이벤트에는 operation·outcome·failure type만 담고 행 결과는 싣지 않습니다.

비동기 비교는 후보 결과를 응답으로 돌려주지 않습니다. 관문을 통과한 읽기 전환과 쓰기 소유권은 구분합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 공유 계약 테스트를 통과한 후보를 표본 비동기 읽기로 비교하고 최소 표본, 불일치 비율, p95 지연과 실패 조건을 함께 검사한다. 통과 때만 읽기를 전환하며 실패하면 기본 읽기를 유지하고 수정 후 새 관찰 구간으로 되돌아간다.
  • 가로 도표와 같은 다섯 노드와 다섯 간선이다. 계약→표본 비교→관문, 관문 통과→후보 전환, 실패→기본 유지, 유지 후 수정→표본 비교의 새 관찰 구간으로 돌아간다. 비교 TIMEOUT은 실행 SQL의 취소를 보장하지 않으며 이 그림은 관문 정책을 나타낸다. 쓰기 소유자는 한 곳이라는 원래 패널을 보존한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1표본 비동기 비교는 한 쓰기 소유자와 실패 폐쇄형 전환을 지킨다
21가로 도표와 같은 다섯 노드와 다섯 간선이다. 계약→표본 비교→관문, 관문 통과→후보 전환, 실패→기본 유지, 유지 후 수정→표본 비교의 새 관찰 구간으로 돌아간다. 비교 TIMEOUT은 실행 SQL의 취소를 보장하지 않으며 이 그림은 관문 정책을 나타낸다. 쓰기 소유자는 한 곳이라는 원래 패널을 보존한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
기본 읽기와 표본 비교

계약 통과 후보를 비동기 표본 읽기로 비교합니다.

쓰기 소유자: 하나 · 기본 읽기: 유지후보 관찰이 운영 읽기를 즉시 바꾸지 않습니다.
관찰 구간 판정

최소 표본·불일치 비율·p95·실패 조건을 검사합니다.

근거: 동일 관찰 구간 · 판정: 통과 또는 실패표본 근거가 전환 결정을 만듭니다.
전환 또는 새 구간

통과 시에만 읽기를 전환하고 실패 시에는 기본 읽기를 유지합니다.

통과: 읽기 전환 · 실패: 수정 뒤 새 구간실패 폐쇄형 조건으로 읽기 소유를 바꿉니다.

표본 비동기 비교는 한 쓰기 소유자와 실패 폐쇄형 전환을 지킨다

기본 읽기를 유지한 표본 비교·관문·읽기 전환 또는 새 관찰 구간으로의 복귀가 상태 전이이다.

PERSISTENCE CUTOVER · FAIL-CLOSED GATE

관문이 닫히면 기본 읽기를 유지한다

기본 결과를 먼저 고정하고 후보는 표본 비동기 읽기로 비교합니다. 후보의 실패·타임아웃·큐 거부는 사용자 응답을 바꾸지 않습니다.

읽기 전환 관문의 통과와 실패 분기공유 계약 테스트를 통과한 후보를 표본 비동기 읽기로 비교하고 최소 표본, 불일치 비율, p95 지연과 실패 조건을 함께 검사한다. 통과 때만 읽기를 전환하며 실패하면 기본 읽기를 유지하고 수정 후 새 관찰 구간으로 되돌아간다.공유 계약 테스트같은 포트 · 같은 fixture표본 비동기 비교후보 읽기 · 유한 큐전환 관문표본 · 정확도 · 지연통과후보를 기본 읽기로전환 후 계속 관찰실패기본 읽기 유지수정 + 새 관찰 구간관문: 최소 표본 · 허용 불일치 비율 · p95 · 실패/timeout/거부 0
관문 통과와 유지·새 관찰의 피드백을 구별한다desktop과같은다섯노드·다섯간선이다. 계약→표본비교→관문,관문통과→후보전환,실패→기본유지,유지후수정→표본비교의새관찰구간으로돌아간다. 비교TIMEOUT은실행SQL취소를보장하지않으며이그림은관문정책이다.쓰기소유자는한곳이라는원패널을보존한다.공유 계약 테스트같은 fixture표본 비동기 비교후보 읽기 · 유한 큐전환 관문표본 · 정확도 · 지연후보를 기본 읽기로계속 관찰기본 읽기 유지수정 · 새 관찰통과실패새 관찰
  1. 공유 계약 → 표본 비교

    후보가 같은 계약을 통과한 뒤 기본 결과를 먼저 얻어 고정합니다. 유한 큐에서 PostQuery만 비교합니다.

  2. 모든 관문을 함께 판단

    최소 표본·불일치 비율·p95와 실패·타임아웃·거부를 확인합니다. 하나라도 실패하면 기본 읽기를 유지하고 새 관찰 구간을 엽니다.

  3. 통과 때만 기본 읽기 전환

    후보를 기본 읽기로 바꾸고 지표를 계속 관찰합니다.

전환 뒤 · 롤백 창 → 이전 읽기 제거

되돌릴 관찰 기간 동안 이전 읽기 구현을 대기 상태로 유지합니다. 관찰과 롤백 창이 끝난 뒤 제거합니다.

전 구간 · 쓰기 소유자는 하나

shadow는 읽기 비교만 수행합니다. 진단 이벤트에는 operation·outcome·failure type만 담고 행 결과는 싣지 않습니다.

비동기 비교는 후보 결과를 응답으로 돌려주지 않습니다. 관문을 통과한 읽기 전환과 쓰기 소유권은 구분합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 공유 계약 테스트를 통과한 후보를 표본 비동기 읽기로 비교하고 최소 표본, 불일치 비율, p95 지연과 실패 조건을 함께 검사한다. 통과 때만 읽기를 전환하며 실패하면 기본 읽기를 유지하고 수정 후 새 관찰 구간으로 되돌아간다.
  • 가로 도표와 같은 다섯 노드와 다섯 간선이다. 계약→표본 비교→관문, 관문 통과→후보 전환, 실패→기본 유지, 유지 후 수정→표본 비교의 새 관찰 구간으로 돌아간다. 비교 TIMEOUT은 실행 SQL의 취소를 보장하지 않으며 이 그림은 관문 정책을 나타낸다. 쓰기 소유자는 한 곳이라는 원래 패널을 보존한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1표본 비동기 비교는 한 쓰기 소유자와 실패 폐쇄형 전환을 지킨다
21가로 도표와 같은 다섯 노드와 다섯 간선이다. 계약→표본 비교→관문, 관문 통과→후보 전환, 실패→기본 유지, 유지 후 수정→표본 비교의 새 관찰 구간으로 돌아간다. 비교 TIMEOUT은 실행 SQL의 취소를 보장하지 않으며 이 그림은 관문 정책을 나타낸다. 쓰기 소유자는 한 곳이라는 원래 패널을 보존한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
기본 읽기와 표본 비교

계약 통과 후보를 비동기 표본 읽기로 비교합니다.

쓰기 소유자: 하나 · 기본 읽기: 유지후보 관찰이 운영 읽기를 즉시 바꾸지 않습니다.
관찰 구간 판정

최소 표본·불일치 비율·p95·실패 조건을 검사합니다.

근거: 동일 관찰 구간 · 판정: 통과 또는 실패표본 근거가 전환 결정을 만듭니다.
전환 또는 새 구간

통과 시에만 읽기를 전환하고 실패 시에는 기본 읽기를 유지합니다.

통과: 읽기 전환 · 실패: 수정 뒤 새 구간실패 폐쇄형 조건으로 읽기 소유를 바꿉니다.

선택 기준을 쿼리와 실패 단위로 나눈다

도구강한 요구먼저 관찰할 비용
원시 JDBC드라이버 제어와 작은 핵심 SQL자원 종료·트랜잭션 누락
JdbcTemplate명시적 SQL과 행 투영방언·매핑·키셋 조건
MyBatis많은 사용자 정의 SQLXML/인터페이스 동기화와 바인딩
JPA애그리거트 변경과 작업 단위플러시·지연 로딩·N+1·잠금
Spring Data JPA표준 저장과 짧은 고정 쿼리메서드 추론과 숨은 개수 쿼리
Querydsl타입이 있는 동적 조건과 투영Q 생성과 투영 순서

기술 이름은 트랜잭션을 대신하지 않습니다. 같은 테이블을 JPA와 SQL 어댑터가 함께 다룬다면 명령 경계 하나에서는 한 방식만 쓰거나 명시적으로 flush/clear 순서를 정해야 합니다.

벤치마크도 ID 단건 조회, 20개 키셋 투영, 버전 수정, 그룹 집계, 제약 충돌을 분리합니다. 준비 작업과 데이터 크기, 인덱스, 연결 풀, 커밋 여부를 같게 하고 p95·p99 지연, SQL 수, DB CPU와 풀 대기를 함께 기록합니다.


기본 응답 뒤에서만 후보를 비교한다

VerifyingPostQuery는 공개 PostQuery를 그대로 구현합니다. 각 메서드는 기본 구현을 먼저 호출해 그 결과를 지역 변수에 고정하고, 표본으로 선택된 경우에만 유한 큐와 AbortPolicy를 가진 ThreadPoolExecutor에 후보 읽기를 제출합니다.

후보 결과는 사용자에게 반환하거나 행 전체를 진단 이벤트에 싣지 않습니다. 이벤트는 연산 이름, 일치 여부와 실패 타입만 가지며 후보의 불일치·실패·타임아웃·제출 거부 중 가장 먼저 확정된 결과 하나만 기록합니다.

src/main/java/board/migration/VerifyingPostQuery.java
package board.migration;

import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;

import board.application.PostPage;
import board.application.PostQuery;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;

public final class VerifyingPostQuery implements PostQuery {
    private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(2);

    private final PostQuery primary;
    private final PostQuery candidate;
    private final ThreadPoolExecutor candidateExecutor;
    private final ScheduledExecutorService timeoutScheduler;
    private final Duration candidateTimeout;
    private final Sampler sampler;
    private final DifferenceSink differences;

    public VerifyingPostQuery(
            PostQuery primary,
            PostQuery candidate,
            ThreadPoolExecutor candidateExecutor,
            ScheduledExecutorService timeoutScheduler,
            Duration candidateTimeout,
            Sampler sampler,
            DifferenceSink differences
    ) {
        this.primary = Objects.requireNonNull(primary, "primary");
        this.candidate = Objects.requireNonNull(candidate, "candidate");
        this.candidateExecutor = Objects.requireNonNull(
                candidateExecutor, "candidateExecutor");
        this.timeoutScheduler = Objects.requireNonNull(
                timeoutScheduler, "timeoutScheduler");
        this.candidateTimeout = requireTimeout(candidateTimeout);
        this.sampler = Objects.requireNonNull(sampler, "sampler");
        this.differences = Objects.requireNonNull(differences, "differences");
        long queueCapacity = (long) candidateExecutor.getQueue().size()
                + candidateExecutor.getQueue().remainingCapacity();
        if (queueCapacity >= Integer.MAX_VALUE
                || !(candidateExecutor.getRejectedExecutionHandler()
                        instanceof ThreadPoolExecutor.AbortPolicy)) {
            throw new IllegalArgumentException(
                    "candidateExecutor must be bounded and reject with AbortPolicy");
        }
    }

    @Override
    public Optional<PostSnapshot> find(long postId, long memberId) {
        Optional<PostSnapshot> response = primary.find(postId, memberId);
        compare(
                "find",
                response,
                () -> candidate.find(postId, memberId));
        return response;
    }

    @Override
    public Optional<PostSnapshot> findByIdempotencyKey(
            long memberId,
            String clientRequestId
    ) {
        Optional<PostSnapshot> response = primary.findByIdempotencyKey(
                memberId, clientRequestId);
        compare(
                "findByIdempotencyKey",
                response,
                () -> candidate.findByIdempotencyKey(
                        memberId, clientRequestId));
        return response;
    }

    @Override
    public PostPage findPage(PostSearch search) {
        PostPage response = primary.findPage(search);
        compare("findPage", response, () -> candidate.findPage(search));
        return response;
    }

    @Override
    public PostSummary summary(long memberId) {
        PostSummary response = primary.summary(memberId);
        compare("summary", response, () -> candidate.summary(memberId));
        return response;
    }

    private <T> void compare(
            String operation,
            T primaryResult,
            Supplier<T> candidateRead
    ) {
        if (!selected()) {
            return;
        }
        ComparisonAttempt attempt = new ComparisonAttempt(operation);
        try {
            ScheduledFuture<?> deadline = timeoutScheduler.schedule(
                    () -> attempt.complete(Outcome.TIMEOUT, null),
                    candidateTimeout.toNanos(),
                    TimeUnit.NANOSECONDS);
            attempt.arm(deadline);
            candidateExecutor.execute(() -> {
                if (attempt.isComplete()) {
                    return;
                }
                try {
                    T candidateResult = candidateRead.get();
                    Outcome outcome = Objects.equals(
                            primaryResult, candidateResult)
                            ? Outcome.MATCH
                            : Outcome.MISMATCH;
                    attempt.complete(outcome, null);
                } catch (RuntimeException candidateFailure) {
                    attempt.complete(Outcome.FAILURE, candidateFailure);
                }
            });
        } catch (RejectedExecutionException rejected) {
            attempt.complete(Outcome.REJECTED, rejected);
        }
    }

    private boolean selected() {
        try {
            return sampler.selected();
        } catch (RuntimeException samplerFailure) {
            return false;
        }
    }

    private static Duration requireTimeout(Duration timeout) {
        Objects.requireNonNull(timeout, "candidateTimeout");
        if (timeout.isZero()
                || timeout.isNegative()
                || timeout.compareTo(MAXIMUM_TIMEOUT) > 0) {
            throw new IllegalArgumentException(
                    "candidateTimeout must be in (0, 2 seconds]");
        }
        return timeout;
    }

    private final class ComparisonAttempt {
        private final String operation;
        private final AtomicBoolean completed = new AtomicBoolean();
        private final AtomicReference<ScheduledFuture<?>> deadline =
                new AtomicReference<>();

        private ComparisonAttempt(String operation) {
            this.operation = operation;
        }

        private void arm(ScheduledFuture<?> scheduled) {
            if (!deadline.compareAndSet(null, scheduled)) {
                scheduled.cancel(false);
                throw new IllegalStateException("deadline already armed");
            }
            if (completed.get()) {
                scheduled.cancel(false);
            }
        }

        private void complete(Outcome outcome, RuntimeException failure) {
            if (!completed.compareAndSet(false, true)) {
                return;
            }
            ScheduledFuture<?> scheduled = deadline.get();
            if (scheduled != null) {
                scheduled.cancel(false);
            }
            String failureType = failure == null
                    ? null
                    : failure.getClass().getName();
            try {
                differences.record(new ComparisonEvent(
                        operation, outcome, failureType));
            } catch (RuntimeException sinkFailure) {
                // Diagnostics cannot replace or fail the primary response.
            }
        }

        private boolean isComplete() {
            return completed.get();
        }
    }

    @FunctionalInterface
    public interface Sampler {
        boolean selected();
    }

    @FunctionalInterface
    public interface DifferenceSink {
        void record(ComparisonEvent event);
    }

    public enum Outcome {
        MATCH,
        MISMATCH,
        FAILURE,
        TIMEOUT,
        REJECTED
    }

    public record ComparisonEvent(
            String operation,
            Outcome outcome,
            String failureType
    ) {
        public ComparisonEvent {
            if (operation == null || operation.isBlank() || outcome == null) {
                throw new IllegalArgumentException("invalid comparison event");
            }
            boolean failureOutcome = outcome == Outcome.FAILURE
                    || outcome == Outcome.REJECTED;
            if (failureOutcome
                    != (failureType != null && !failureType.isBlank())) {
                throw new IllegalArgumentException(
                        "failure type must match the outcome");
            }
        }
    }
}

타임아웃 작업과 후보 작업은 같은 AtomicBoolean을 두고 경쟁하므로 한 시도에서는 완료 결과 하나만 선점하고 진단 기록도 한 번만 시도합니다. 타임아웃 뒤 늦게 끝난 후보는 두 번째 기록을 시도하지 않습니다. 큐가 찼거나 스케줄러가 요청을 거부해도 기본 결과값은 변하지 않습니다. DifferenceSink는 여러 스레드가 호출해도 안전하고 빠르게 반환해야 합니다. 특히 제출 거부를 처리하는 경로는 호출자 스레드에서도 sink를 호출하므로, 느린 sink까지 사용자 응답 지연에서 격리하는 코드는 아닙니다. sink가 실패하면 예외는 숨기지만 이벤트가 실제 저장됐다고 보장하지는 않습니다.

이 코드의 TIMEOUT은 비교 결과를 마감하는 사건입니다. 실행 중인 후보 작업이나 JDBC statement를 취소하지 않으며, 큐가 유한하다는 조건도 실행 중인 작업의 종료를 보장하지 않습니다. 후보 DB 접근의 쿼리 시간 제한·연결 풀·작업 정리와 진단 누락 감지는 별도로 준비해야 합니다. 후보 작업은 원 스레드의 EntityManager나 트랜잭션을 넘겨받지 않으므로 자기 읽기 경계를 구성합니다.


비동기 격리를 결정적으로 검증한다

테스트는 sleep 대신 latch와 유한 시간 poll을 사용합니다. 후보가 다른 값, 예외, 타임아웃, 포화된 큐를 만들 때도 반환값은 항상 같은 기본 PostSnapshot이며 시도한 비교에는 이벤트 하나만 생깁니다. 표본에서 빠진 요청은 후보를 제출하지 않습니다.

src/test/java/board/migration/VerifyingPostQueryTest.java
package board.migration;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;

import board.application.PostPage;
import board.application.PostQuery;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;
import board.migration.VerifyingPostQuery.ComparisonEvent;
import board.migration.VerifyingPostQuery.Outcome;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

class VerifyingPostQueryTest {
    private static final PostSnapshot PRIMARY = new PostSnapshot(
            1L,
            41L,
            "Persistence",
            "primary content",
            LocalDate.parse("2026-07-14"),
            "migration-01",
            Instant.parse("2026-07-14T09:00:00Z"),
            0L);

    @Test
    void mismatch_returns_primary_and_emits_one_redacted_event()
            throws Exception {
        PostSnapshot different = new PostSnapshot(
                PRIMARY.id(),
                PRIMARY.memberId(),
                PRIMARY.title(),
                "candidate content",
                PRIMARY.publishedOn(),
                PRIMARY.clientRequestId(),
                PRIMARY.createdAt(),
                PRIMARY.version());
        FindOnlyQuery candidate = new FindOnlyQuery(
                (id, memberId) -> Optional.of(different));
        RecordingSink sink = new RecordingSink();

        try (ThreadPoolExecutor executor = boundedExecutor(8);
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor()) {
            VerifyingPostQuery query = query(
                    candidate, executor, scheduler, Duration.ofSeconds(1),
                    true, sink);

            Optional<PostSnapshot> response = query.find(1L, 41L);

            assertThat(response.orElseThrow()).isSameAs(PRIMARY);
            assertEvent(sink.await(), Outcome.MISMATCH, null);
            assertThat(sink.poll()).isNull();
            assertThat(candidate.calls()).isEqualTo(1);
        }
    }

    @Test
    void candidate_failure_returns_primary_and_emits_one_failure_event()
            throws Exception {
        FindOnlyQuery candidate = new FindOnlyQuery((id, memberId) -> {
            throw new IllegalStateException("candidate unavailable");
        });
        RecordingSink sink = new RecordingSink();

        try (ThreadPoolExecutor executor = boundedExecutor(8);
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor()) {
            VerifyingPostQuery query = query(
                    candidate, executor, scheduler, Duration.ofSeconds(1),
                    true, sink);

            Optional<PostSnapshot> response = query.find(1L, 41L);

            assertThat(response.orElseThrow()).isSameAs(PRIMARY);
            assertEvent(
                    sink.await(),
                    Outcome.FAILURE,
                    IllegalStateException.class.getName());
            assertThat(sink.poll()).isNull();
        }
    }

    @Test
    @Timeout(4)
    void timeout_does_not_wait_and_late_candidate_cannot_emit_twice()
            throws Exception {
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch finished = new CountDownLatch(1);
        FindOnlyQuery candidate = new FindOnlyQuery((id, memberId) -> {
            started.countDown();
            try {
                await(release);
                return Optional.of(PRIMARY);
            } finally {
                finished.countDown();
            }
        });
        RecordingSink sink = new RecordingSink();

        try (ThreadPoolExecutor executor = boundedExecutor(8);
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor()) {
            executor.prestartCoreThread();
            VerifyingPostQuery query = query(
                    candidate, executor, scheduler, Duration.ofMillis(100),
                    true, sink);

            Optional<PostSnapshot> response = query.find(1L, 41L);

            assertThat(response.orElseThrow()).isSameAs(PRIMARY);
            assertThat(started.await(1, TimeUnit.SECONDS)).isTrue();
            assertEvent(sink.await(), Outcome.TIMEOUT, null);
            release.countDown();
            assertThat(finished.await(1, TimeUnit.SECONDS)).isTrue();
            assertThat(sink.poll()).isNull();
        } finally {
            release.countDown();
        }
    }

    @Test
    void saturated_executor_returns_primary_and_emits_one_rejection()
            throws Exception {
        CountDownLatch workerStarted = new CountDownLatch(1);
        CountDownLatch releaseWorker = new CountDownLatch(1);
        FindOnlyQuery candidate = new FindOnlyQuery(
                (id, memberId) -> Optional.of(PRIMARY));
        RecordingSink sink = new RecordingSink();

        try (ThreadPoolExecutor executor = boundedExecutor(1);
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor()) {
            try {
                executor.execute(() -> {
                    workerStarted.countDown();
                    await(releaseWorker);
                });
                assertThat(workerStarted.await(1, TimeUnit.SECONDS)).isTrue();
                executor.execute(() -> { });
                VerifyingPostQuery query = query(
                        candidate, executor, scheduler, Duration.ofSeconds(1),
                        true, sink);

                Optional<PostSnapshot> response = query.find(1L, 41L);

                assertThat(response.orElseThrow()).isSameAs(PRIMARY);
                assertEvent(
                        sink.await(),
                        Outcome.REJECTED,
                        java.util.concurrent.RejectedExecutionException.class
                                .getName());
                assertThat(candidate.calls()).isZero();
                assertThat(sink.poll()).isNull();
            } finally {
                releaseWorker.countDown();
            }
        }
    }

    @Test
    void unsampled_read_never_submits_candidate_work() throws Exception {
        FindOnlyQuery candidate = new FindOnlyQuery(
                (id, memberId) -> Optional.of(PRIMARY));
        RecordingSink sink = new RecordingSink();

        try (ThreadPoolExecutor executor = boundedExecutor(8);
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor()) {
            VerifyingPostQuery query = query(
                    candidate, executor, scheduler, Duration.ofSeconds(1),
                    false, sink);

            Optional<PostSnapshot> response = query.find(1L, 41L);

            assertThat(response.orElseThrow()).isSameAs(PRIMARY);
            assertThat(candidate.calls()).isZero();
            assertThat(sink.poll()).isNull();
        }
    }

    @Test
    void cutover_gate_fails_closed_for_coverage_mismatch_latency_or_failure() {
        MigrationGate gate = new MigrationGate(
                0.25,
                0.01,
                Duration.ofMillis(200),
                1_000,
                Duration.ofMillis(50));

        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 0, 0, 0, 0, Duration.ofMillis(40)))).isTrue();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                999, 0, 0, 0, 0, Duration.ofMillis(40)))).isFalse();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 11, 0, 0, 0, Duration.ofMillis(40)))).isFalse();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 0, 0, 0, 0, Duration.ofMillis(51)))).isFalse();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 0, 1, 0, 0, Duration.ofMillis(40)))).isFalse();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 0, 0, 1, 0, Duration.ofMillis(40)))).isFalse();
        assertThat(gate.permitsCutover(new MigrationGate.Observation(
                1_000, 0, 0, 0, 1, Duration.ofMillis(40)))).isFalse();
    }

    private static VerifyingPostQuery query(
            FindOnlyQuery candidate,
            ThreadPoolExecutor executor,
            ScheduledExecutorService scheduler,
            Duration timeout,
            boolean sampled,
            RecordingSink sink
    ) {
        return new VerifyingPostQuery(
                new FindOnlyQuery((id, memberId) -> Optional.of(PRIMARY)),
                candidate,
                executor,
                scheduler,
                timeout,
                () -> sampled,
                sink);
    }

    private static ThreadPoolExecutor boundedExecutor(int queueCapacity) {
        return new ThreadPoolExecutor(
                1,
                1,
                0L,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new ThreadPoolExecutor.AbortPolicy());
    }

    private static void assertEvent(
            ComparisonEvent event,
            Outcome outcome,
            String failureType
    ) {
        assertThat(event.operation()).isEqualTo("find");
        assertThat(event.outcome()).isEqualTo(outcome);
        assertThat(event.failureType()).isEqualTo(failureType);
    }

    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", interrupted);
        }
    }

    private static final class FindOnlyQuery implements PostQuery {
        private final BiFunction<Long, Long, Optional<PostSnapshot>> behavior;
        private final AtomicInteger calls = new AtomicInteger();

        private FindOnlyQuery(
                BiFunction<Long, Long, Optional<PostSnapshot>> behavior
        ) {
            this.behavior = behavior;
        }

        @Override
        public Optional<PostSnapshot> find(long postId, long memberId) {
            calls.incrementAndGet();
            return behavior.apply(postId, memberId);
        }

        @Override
        public Optional<PostSnapshot> findByIdempotencyKey(
                long memberId,
                String clientRequestId
        ) {
            throw new AssertionError("not used by this test");
        }

        @Override
        public PostPage findPage(PostSearch search) {
            throw new AssertionError("not used by this test");
        }

        @Override
        public PostSummary summary(long memberId) {
            throw new AssertionError("not used by this test");
        }

        private int calls() {
            return calls.get();
        }
    }

    private static final class RecordingSink
            implements VerifyingPostQuery.DifferenceSink {
        private final BlockingQueue<ComparisonEvent> events =
                new LinkedBlockingQueue<>();

        @Override
        public void record(ComparisonEvent event) {
            events.add(event);
        }

        private ComparisonEvent await() throws InterruptedException {
            ComparisonEvent event = events.poll(1, TimeUnit.SECONDS);
            assertThat(event).isNotNull();
            return event;
        }

        private ComparisonEvent poll() throws InterruptedException {
            return events.poll(100, TimeUnit.MILLISECONDS);
        }
    }
}

이 테스트의 후보는 PostQuery만 구현하므로 쓰기 API가 없습니다. 비교 요청도 엔티티나 트랜잭션 컨텍스트가 아니라 검증이 끝난 공유 입력과 불변 결과만 다른 스레드로 넘깁니다.


전환 관문은 표본 수·정확도·지연·실패를 모두 확인한다

불일치 0회만으로 후보를 기본 구현으로 바꾸지 않습니다. 최소 표본 수, 허용 불일치 비율, 후보 p95 지연, 실패·타임아웃·제출 거부를 한 관문에서 판단합니다.

관문 경계와 불일치 원인을 판단하기 원 관문은 최소 1,000표본, 최대 불일치 비율 1%, 최대 p95 50 ms이며 실패·타임아웃·거부가 하나라도 있으면 닫힙니다. 이 세 오류가 모두 0인 조건에서 1,000표본 중 불일치 10회·p95 50 ms는 통과하나요? 불일치 11회, p95 51 ms, 표본 999회로 한 조건씩 바꾸면 어떻게 되나요?

이어 기본 읽기 직후 다른 요청이 버전 1을 2로 커밋하고 후보가 새 버전 2를 읽었다고 합시다. 두 결과의 불일치가 곧 후보 구현의 오류인가요?

경계 포함과 관찰 시점의 풀이

10/1,000 = 1%이고 50 ms는 상한 이하이므로 주어진 조건에서는 통과합니다. 불일치 11회는 1.1%, p95 51 ms는 지연 초과, 999표본은 최소 표본 미달이어서 각각 닫힙니다. 일치 여부가 좋아도 실패·타임아웃·거부가 하나라도 있으면 닫힙니다. 표본은 관찰 구간과 연산별 수집 범위를 먼저 정의한 값이어야 하며, shadowRatio를 선언하는 것만으로 실제 표본 추출기와 지연 측정이 연결되지는 않습니다.

버전 1과 2의 차이는 서로 다른 읽기 시점 때문일 수도 있습니다. 불변 반환 객체를 전달해도 두 DB 읽기의 관찰 시점이 같아지지는 않습니다. 후보 오류로 분류하기 전에 변경 없는 픽스처, 일관된 버전/읽기 기준 등 비교 조건과 실제 두 읽기를 확인합니다. 이 관문 계산과 시점 변형은 코드 추적 연습이며 새 Java 동시 실행·운영 벤치마크 결과가 아닙니다.

src/main/java/board/migration/MigrationGate.java
package board.migration;

import java.time.Duration;
import java.util.Objects;

public record MigrationGate(
        double shadowRatio,
        double maximumMismatchRatio,
        Duration candidateTimeout,
        long minimumSamples,
        Duration maximumP95Latency
) {
    public MigrationGate {
        Objects.requireNonNull(candidateTimeout, "candidateTimeout");
        Objects.requireNonNull(maximumP95Latency, "maximumP95Latency");
        if (!Double.isFinite(shadowRatio)
                || shadowRatio < 0.0
                || shadowRatio > 1.0
                || !Double.isFinite(maximumMismatchRatio)
                || maximumMismatchRatio < 0.0
                || maximumMismatchRatio > 1.0
                || candidateTimeout.isZero()
                || candidateTimeout.isNegative()
                || candidateTimeout.compareTo(Duration.ofSeconds(2)) > 0
                || minimumSamples < 1
                || maximumP95Latency.isZero()
                || maximumP95Latency.isNegative()
                || maximumP95Latency.compareTo(candidateTimeout) > 0) {
            throw new IllegalArgumentException("invalid migration gate");
        }
    }

    public boolean permitsCutover(Observation observation) {
        Objects.requireNonNull(observation, "observation");
        if (shadowRatio == 0.0
                || observation.samples() < minimumSamples
                || observation.failures() > 0
                || observation.timeouts() > 0
                || observation.rejections() > 0
                || observation.p95Latency().compareTo(maximumP95Latency) > 0) {
            return false;
        }
        double mismatchRatio = (double) observation.mismatches()
                / (double) observation.samples();
        return mismatchRatio <= maximumMismatchRatio;
    }

    public record Observation(
            long samples,
            long mismatches,
            long failures,
            long timeouts,
            long rejections,
            Duration p95Latency
    ) {
        public Observation {
            Objects.requireNonNull(p95Latency, "p95Latency");
            boolean invalid = samples < 0
                    || mismatches < 0
                    || failures < 0
                    || timeouts < 0
                    || rejections < 0
                    || p95Latency.isNegative();
            long remaining = samples;
            if (!invalid && mismatches <= remaining) {
                remaining -= mismatches;
            } else if (!invalid) {
                invalid = true;
            }
            if (!invalid && failures <= remaining) {
                remaining -= failures;
            } else if (!invalid) {
                invalid = true;
            }
            if (!invalid && timeouts <= remaining) {
                remaining -= timeouts;
            } else if (!invalid) {
                invalid = true;
            }
            if (!invalid && rejections > remaining) {
                invalid = true;
            }
            if (invalid) {
                throw new IllegalArgumentException("invalid observation");
            }
        }
    }
}

전환 뒤에도 롤백 구간이 끝날 때까지 이전 읽기 구현을 대기 상태로 유지하되 쓰기 소유자는 둘로 나누지 않습니다. 관문이 실패하면 전환 플래그를 그대로 두고 원인을 고친 뒤 새 관찰 구간을 시작합니다. 관찰과 롤백 구간이 모두 끝난 뒤에만 이전 어댑터를 제거합니다.

이 장은 하나의 게시글 계약에 JDBC, JdbcTemplate, MyBatis, JPA, Spring Data와 Querydsl을 연결했습니다. 구현 기술보다 입력·결과·행 수·트랜잭션·실패 의미를 먼저 고정하면 각 도구의 장점을 쓰면서도 교체 경계를 작게 유지할 수 있습니다.