본문으로 건너뛰기

안동민 개발노트

본문 시작

데이터 접근 기술 전환

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

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

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

공유 계약 테스트에서 표본 비동기 섀도 읽기, 정확도와 지연 관문, 읽기 전환, 롤백 관찰 구간, 이전 구현 제거로 진행하되 관문 실패 시 전환하지 않고 쓰기는 항상 한 어댑터만 소유하는 상태 흐름

SAFE PERSISTENCE CUTOVER

기본 응답은 즉시, 후보 판단은 실패 폐쇄형으로

후보 불일치·실패·타임아웃·큐 거부는 진단 이벤트 하나로 끝나며 사용자 응답과 쓰기 소유자를 바꾸지 않습니다.

데이터 접근 구현의 안전한 전환 상태 기계 공유 계약 테스트를 통과한 후보를 유한 큐에서 표본 비동기 읽기로 비교한다. 최소 표본, 불일치 비율, p95 지연, 실패와 타임아웃 관문을 모두 통과해야 읽기를 전환한다. 관문 실패는 기본 구현 유지와 새 관찰 구간으로 돌아간다. 전환 뒤 롤백 구간이 끝나야 이전 구현을 제거한다. 전체 과정에서 쓰기 소유자는 하나다. 계약 테스트 같은 포트·픽스처 candidate ready 표본 shadow bounded async read primary response Readiness gate 표본 · mismatch · p95 failure = 0 읽기 전환 candidate primary flag change Rollback 창 이전 읽기 대기 observe pass Gate closed 기본 읽기 유지 fix + new window fail 이전 읽기 제거 rollback 창 종료 뒤 retire 불변식: write owner = exactly one adapter shadow는 PostQuery만 호출하고 명령·이중 쓰기 경로를 갖지 않는다 진단 이벤트는 결과를 담지 않는다 operation · outcome · failure type
한 쓰기 소유자 shadow는 읽기 비교만
  1. 공유 계약 테스트

    후보가 같은 명령·조회 픽스처와 실패 의미를 먼저 통과합니다.

  2. 표본 비동기 비교

    기본 결과를 즉시 반환하고 유한 큐에서 후보 PostQuery만 실행합니다.

  3. Readiness 관문

    최소 표본, 불일치 비율, p95, 실패·타임아웃·거부를 모두 판정합니다.

  4. 실패하면 관문 닫기

    기본 읽기와 한 쓰기 소유자를 유지하고 수정 뒤 새 관찰 구간을 엽니다.

  5. 통과하면 읽기 전환

    관문이 모두 참일 때만 후보를 기본 읽기로 바꾸고 지표를 계속 봅니다.

  6. Rollback 창 뒤 제거

    되돌릴 관찰 기간이 끝난 다음에만 이전 읽기 구현을 제거합니다.

  • 표본 비동기 비교
  • 관문 통과
  • 실패 폐쇄
  • 수정 뒤 재관찰

핵심: 후보 관찰은 기본 응답을 늦추거나 실패시키지 않고, 전환 권한은 누적 관문이 가집니다.


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

도구강한 요구먼저 관찰할 비용
원시 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 구현은 여러 후보 스레드가 동시에 기록할 수 있는 thread-safe 수집기여야 합니다.


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

테스트는 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 지연, 실패·타임아웃·제출 거부를 한 관문에서 판단합니다.

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을 연결했습니다. 구현 기술보다 입력·결과·행 수·트랜잭션·실패 의미를 먼저 고정하면 각 도구의 장점을 쓰면서도 교체 경계를 작게 유지할 수 있습니다.