본문 바로가기

데이터 접근 기술 전환

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

데이터 접근 기술은 코드 줄 수 하나로 고르지 않습니다. 애그리거트의 변경과 동적 검색, 보고서 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·실패 조건을 검사합니다.

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

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

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

그림은 공통 테스트를 통과한 후보를 표본 비동기 읽기로 비교하고, 전환 관문에서 최소 표본과 불일치 비율, p95 지연, 실패·타임아웃·거부를 함께 판단하는 흐름을 보여 줍니다. 관문을 통과할 때만 후보가 기본 읽기가 되고 이후에도 계속 관찰하며, 실패하면 기본 읽기를 유지한 채 고친 뒤 새 관찰 구간으로 돌아갑니다. 쓰기를 맡는 곳은 전 구간에서 하나이며, 비교의 TIMEOUT이 실행 중인 SQL의 취소를 보장하지는 않습니다.


선택 기준을 쿼리와 실패의 단위로 나눕니다

도구잘 맞는 요구먼저 확인할 비용
원시 JDBC드라이버 제어와 작은 핵심 SQL자원 종료와 트랜잭션의 누락
JdbcTemplate명시적인 SQL과 행 투영방언, 매핑, 키셋 조건
MyBatis직접 쓰는 SQL이 많음XML과 인터페이스의 동기화, 바인딩
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는 여러 스레드가 호출해도 안전하고 빨리 돌아와야 합니다. 특히 제출 거부를 처리하는 경로는 호출한 스레드에서도 싱크를 호출하므로, 이 코드가 느린 싱크까지 사용자 응답의 지연에서 떼어 놓지는 못합니다. 싱크가 실패하면 예외는 숨기지만, 이벤트가 실제로 저장되었다고 보장하지는 않습니다.

이 코드의 TIMEOUT은 비교의 결과를 마감하는 사건일 뿐, 실행 중인 후보 작업이나 JDBC 구문을 취소하지 않습니다. 큐의 크기가 제한되어 있다는 것도 실행 중인 작업이 끝난다는 보장은 아닙니다. 후보가 DB에 접근할 때의 쿼리 시간 제한과 연결 풀, 작업 정리, 진단의 누락 감지는 따로 준비해야 합니다. 후보 작업은 원래 스레드의 EntityManager나 트랜잭션을 넘겨받지 않으므로, 자신의 읽기 트랜잭션을 따로 구성합니다.

더 깊이: 후보의 SQL에 실제로 시간 제한을 거는 방법

JDBC에서 실행 중인 구문에 시간 제한을 거는 표준 방법은 Statement.setQueryTimeout입니다. Java 25의 API 문서에 따르면 기본값은 제한 없음이고, 제한을 넘으면 SQLTimeoutException이 나며, 드라이버는 이 제한을 적어도 execute, executeQuery, executeUpdate에 적용해야 합니다. ResultSet을 읽는 동안에도 적용할지, 배치라면 명령마다 적용할지 배치 전체에 적용할지는 드라이버가 정합니다. 그래서 행을 많이 읽는 후보 쿼리라면, 실행 시간뿐 아니라 결과를 가져오는 시간까지 제한되는지 운영 드라이버의 문서로 확인해야 합니다.

Spring을 거치면 이 값이 어디서 오는지가 하나 더 있습니다. JdbcTemplate.setQueryTimeout의 기본값은 -1이어서 드라이버에 아무 값도 넘기지 않고, Spring Framework 7.0.10의 DataSourceUtils.applyTimeout은 현재 트랜잭션에 시간 제한이 있으면 템플릿의 값 대신 남은 트랜잭션 시간을 초 단위로 올림해 구문에 겁니다. 이미 기한이 지났다면 구문을 실행하기 전에 TransactionTimedOutException이 납니다. 후보의 읽기 트랜잭션에 TransactionTemplate.setTimeout을 짧게 주면, 그 안의 모든 JdbcTemplate 구문이 같은 기한을 나눠 쓰게 되는 셈입니다. 이 제한은 비교 결과를 마감하는 TIMEOUT과 달리 구문의 실행 자체에 걸리므로, 두 시간을 같은 값으로 맞출지 따로 정합니다.


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

테스트는 sleep 대신 래치와 시간이 정해진 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 지연, 실패와 타임아웃, 제출 거부를 하나의 관문에서 판단합니다.

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");
            }
        }
    }
}

전환한 뒤에도 롤백할 수 있는 기간이 끝날 때까지 이전 읽기 구현을 대기 상태로 두되, 쓰기를 맡는 곳을 둘로 나누지는 않습니다. 관문을 통과하지 못하면 전환 플래그를 그대로 두고 원인을 고친 뒤 새 관찰 구간을 시작합니다. 관찰과 롤백 기간이 모두 끝난 뒤에만 이전 어댑터를 지웁니다.


정리

  • 데이터 접근 기술은 쿼리와 실패의 단위로 나눠 비용을 보고, 같은 작업 조건에서 지연과 SQL 수, DB 부하를 함께 잽니다.
  • 전환할 때는 기존 구현의 결과를 먼저 고정해 돌려주고, 후보는 표본으로 뽑힌 읽기만 크기가 제한된 큐에서 비동기로 비교합니다.
  • 비교 결과는 가장 먼저 확정된 하나만 기록하고, 이벤트에는 연산과 결과, 실패 타입만 담으며 행 전체를 싣지 않습니다.
  • TIMEOUT은 비교를 마감할 뿐 실행 중인 SQL을 취소하지 않으므로, 후보의 쿼리 시간 제한과 정리는 따로 준비합니다.
  • 전환 관문은 표본 수와 불일치 비율, 지연, 실패를 모두 통과해야 열리며, 쓰기를 맡는 곳은 언제나 하나입니다.

연습 문제

1. 관문의 경곗값과 불일치의 원인

관문은 최소 1,000표본, 최대 불일치 비율 1%, 최대 p95 50ms이며, 실패와 타임아웃, 거부가 하나라도 있으면 닫힙니다. 이 세 오류가 모두 0인 조건에서 1,000표본 가운데 불일치 10회, p95 50ms는 통과하나요? 불일치 11회, p95 51ms, 표본 999회로 조건을 하나씩 바꾸면 어떻게 되나요? 이어서 기본 읽기 직후 다른 요청이 버전 1을 2로 커밋하고 후보가 새 버전 2를 읽었다고 합시다. 두 결과가 다르다는 것이 곧 후보 구현의 오류인가요?

해설 보기

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

버전 1과 2의 차이는 두 읽기의 시점이 달라서 생겼을 수도 있습니다. 불변 객체를 넘겨도 두 DB 읽기의 시점이 같아지지는 않습니다. 후보의 오류로 분류하기 전에 변경이 없는 픽스처나 일관된 버전 기준처럼 비교의 조건을 확인하고, 실제 두 읽기를 살펴봅니다.

2. 느린 진단 싱크

운영에서 DifferenceSink가 원격 로그 서버에 동기로 쓰다가 가끔 2초씩 멈춘다는 것이 발견되었습니다. 큐가 가득 차 제출이 거부되는 순간과 겹치면 사용자 응답에 어떤 영향이 있고, 어떻게 고쳐야 할까요?

해설 보기

제출이 거부되면 호출한 스레드가 직접 싱크를 호출하므로, 그 순간 싱크가 2초 멈추면 사용자 응답도 2초 늦어집니다. 기본 결과는 이미 정해져 있어도 돌려주는 시점이 늦어지는 것입니다. 싱크는 이벤트를 메모리의 크기가 제한된 버퍼에 넣고 바로 돌아오게 하며, 실제 전송은 별도의 스레드가 하도록 바꿉니다. 버퍼가 가득 차면 이벤트를 버리고 버린 개수를 메트릭으로 남겨, 진단의 누락이 사용자 응답보다 우선하지 않게 합니다.

이것으로 10장을 마칩니다. 10장에서는 게시글의 명령 포트와 조회 포트를 JdbcTemplate과 MyBatis, JPA, Spring Data와 Querydsl로 구현하고, 모든 어댑터가 함께 통과할 공통 테스트를 정의한 뒤, 기존 구현을 지키면서 새 구현으로 전환하는 방법까지 살펴봤습니다. 구현 기술보다 입력과 결과, 행 수, 트랜잭션, 실패의 의미를 먼저 정해 두면, 각 도구의 장점을 쓰면서도 바꿔야 할 범위를 작게 유지할 수 있습니다. 다음 장의 검사 예외 전파부터는 예외가 계층을 넘을 때 무엇을 바꾸고 무엇을 보존해야 하는지, 그리고 트랜잭션과 어떻게 맞물리는지를 더 깊이 다룹니다.