본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 데이터 접근 기술과 테스트

데이터 접근 기술 선택

JDBC·JdbcTemplate·MyBatis·JPA·Spring 데이터 JPA·Querydsl을 회원 게시판 요구에 맞춰 비교하고 계약 테스트, 섀도 읽기, 단일 쓰기 마이그레이션과 롤백 기준을 설계합니다.

한 기술이 모든 리포지토리에 가장 좋다는 결론은 없습니다. 단순 CRUD 비중, 복잡한 보고서 SQL, 애그리거트 변경, 팀의 SQL·ORM 경험, 운영 환경 진단 도구가 선택을 좌우합니다. 회원 게시판에서는 명령 애그리거트에 JPA를 쓰면서 통계 투영에 JdbcTemplate를 쓰는 조합도 가능합니다. 중요한 것은 트랜잭션과 예외, 리포지토리 규칙이 어댑터마다 같아야 한다는 점입니다.


쿼리·변경 단위 분류

JDBC는 SQL과 리소스를 모두 직접 다뤄 제어력은 높지만 반복이 큽니다. JdbcTemplate은 SQL 제어를 유지하며 생명주기와 예외 변환을 줄입니다. MyBatis는 SQL을 매퍼로 조직하고 동적 XML을 제공합니다. JPA는 애그리거트 변경과 연관 생명주기에 강하지만 생성 SQL과 영속성 컨텍스트를 이해해야 합니다.

도구강한 요구주요 비용먼저 확인할 실패
원시 JDBC드라이버 세부·작은 핵심리소스 반복종료·트랜잭션 누락
JdbcTemplate명시적 SQL·투영매핑 수작업SQL 차이·방언
MyBatis많은 사용자 정의 SQLXML/인터페이스 동기화${}·구문 바인딩
JPA애그리거트 변경·작업 단위지연·플러시·N+1생성 SQL·잠금
Spring 데이터 JPA표준 CRUD·짧은 쿼리메서드 추론 과용개수·쿼리 이름
Querydsl타입-안전 동적 조건식Q 코드 생성투영·빌드 설정

“반복 코드가 적다”는 초기 작성 비용만 말합니다. 장애 때 SQL을 찾는 시간, 업그레이드와 코드 생성, 신규 팀원의 사고 모델도 총비용입니다. 기술 선택 문서에는 기대 쿼리와 실패 진단 절차를 함께 적습니다.

어댑터 비교

서비스가 기술별 API를 직접 알면 마이그레이션 시 애플리케이션 전체가 바뀝니다. 포트는 업무 결과와 실패만 표현하고 어댑터가 Spring 예외를 도메인 의미로 바꿉니다.

src/main/java/board/application/PostStore.java
package board.application;

import java.time.LocalDate;
import java.util.Optional;

public interface PostStore {
    StoredPost create(CreatePost command);

    Optional<StoredPost> find(long id, long authorId);

    boolean update(
            long id,
            long authorId,
            long expectedVersion,
            String title,
            String content);

    record CreatePost(
            long authorId,
            String title,
            String content,
            LocalDate createdOn,
            String idempotencyKey
    ) {
    }

    record StoredPost(
            long id,
            long authorId,
            String title,
            String content,
            LocalDate createdOn,
            long version
    ) {
    }
}

이 인터페이스를 무조건 모든 쿼리에 사용하지 않습니다. 대시보드 투영은 별도 PostReportQuery가 SQL에 맞는 DTO를 반환합니다. 기술 독립성을 위해 성능에 필요한 배치와 커서 능력까지 숨기면 최소 공통 기능만 남게 됩니다. 사용 사례가 실제로 필요한 기능을 포트에 표현합니다.

점진적 마이그레이션

이전 어댑터와 새 어댑터에 동시에 쓰면 한쪽만 성공하는 분할 상태를 해결해야 합니다. 같은 데이터베이스 테이블을 두 어댑터가 읽는 마이그레이션이라면 쓰기는 기존 어댑터 하나로 유지하고 새 어댑터를 섀도 읽기로 비교합니다. 두 데이터베이스 사이를 이동한다면 아웃박스·CDC·역채우기와 전환 계획이 필요합니다.

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

import java.util.Optional;

import board.application.PostStore.StoredPost;

public final class VerifyingPostQuery {
    private final PostReader primary;
    private final PostReader candidate;
    private final DifferenceSink differences;

    public VerifyingPostQuery(
            PostReader primary,
            PostReader candidate,
            DifferenceSink differences
    ) {
        this.primary = primary;
        this.candidate = candidate;
        this.differences = differences;
    }

    public Optional<StoredPost> find(long id, long authorId) {
        Optional<StoredPost> result = primary.find(id, authorId);
        try {
            Optional<StoredPost> observed = candidate.find(id, authorId);
            if (!result.equals(observed)) {
                differences.record(new QueryDifference(
                        id, authorId, result, observed));
            }
        } catch (RuntimeException candidateFailure) {
            differences.failed(id, authorId, candidateFailure);
        }
        return result;
    }

    public interface PostReader {
        Optional<StoredPost> find(long id, long authorId);
    }

    public interface DifferenceSink {
        void record(QueryDifference difference);
        void failed(long id, long authorId, RuntimeException failure);
    }

    public record QueryDifference(
            long id,
            long authorId,
            Optional<StoredPost> primary,
            Optional<StoredPost> candidate
    ) {
    }
}

후보 실패가 사용자 응답을 깨뜨리지 않고 메트릭과 표본 추출된 진단으로 남습니다. 단, 섀도 쿼리가 DB 부하를 두 배로 만들 수 있어 트래픽 비율과 타임아웃을 제한합니다. 민감한 행 전체를 로그하지 않고 ID, 필드별 해시 또는 차이 종류를 기록합니다.

읽기 결과의 순서, 타임스탬프 정밀도, null 정규화 차이를 미리 정의합니다. 무조건 JSON 문자열을 비교하면 의미 없는 열 순서와 형식 차이가 불일치가 됩니다. 리포지토리 규칙의 값 타입으로 비교합니다.

섀도 읽기 불일치

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

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

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Optional;

import org.junit.jupiter.api.Test;

import board.application.PostStore.StoredPost;

class VerifyingPostQueryTest {
    @Test
    void candidate가_다른_본문을_반환해도_primary를_응답하고_차이를_기록한다() {
        var primaryRow = new StoredPost(
                1L, 41L, "Persistence", "JPA로 전환한 게시글 본문",
                LocalDate.parse("2026-07-14"), 0L);
        var candidateRow = new StoredPost(
                1L, 41L, "Persistence", "일부가 누락된 게시글 본문",
                LocalDate.parse("2026-07-14"), 0L);
        var observed = new ArrayList<VerifyingPostQuery.QueryDifference>();
        var sink = new RecordingSink(observed);
        var query = new VerifyingPostQuery(
                (id, authorId) -> Optional.of(primaryRow),
                (id, authorId) -> Optional.of(candidateRow),
                sink);

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

        assertThat(response).contains(primaryRow);
        assertThat(observed).hasSize(1);
        assertThat(observed.getFirst().candidate())
                .contains(candidateRow);
    }

    record RecordingSink(
            java.util.List<VerifyingPostQuery.QueryDifference> values
    ) implements VerifyingPostQuery.DifferenceSink {
        @Override
        public void record(VerifyingPostQuery.QueryDifference difference) {
            values.add(difference);
        }

        @Override
        public void failed(
                long id,
                long authorId,
                RuntimeException failure
        ) {
            throw new AssertionError("unexpected candidate failure", failure);
        }
    }
}
adapter migration 관찰 결과
primary content = JPA로 전환한 게시글 본문
candidate content = 일부가 누락된 게시글 본문
user response source = primary
difference events = 1
dual write count = 0
candidate cutover allowed = false

계약 테스트와 섀도 읽기가 일정 기간 0 불일치라고 바로 전환하지 않습니다. p95·p99 지연 시간, DB CPU, 쿼리 계획, 오류 비율, 풀 대기, 잠금 대기도 비교합니다. 롤백 분기와 스키마 하위 호환성을 유지할 기간을 정합니다.

실제 부하 벤치마크

100행 단순 조회만으로 ORM과 SQL 매퍼 전체를 비교하면 결론이 왜곡됩니다. 회원 게시판 작업 부하에서 다음 시나리오를 분리합니다.

  • ID 단건 조회와 영속성-컨텍스트 캐시 적중
  • 20개 키셋 투영 조회
  • 애그리거트 수정과 낙관적 버전
  • 제목별 그룹 보고서
  • 배치 삽입과 제약 조건 충돌
  • 콜드 스타트의 메타데이터·Q 클래스·매퍼 파싱

준비 작업, 연결 풀, DB 캐시, 네트워크 위치, 스키마와 인덱스를 같게 합니다. 벤치마크 트랜잭션이 롤백되면 WAL과 커밋 동기화 비용이 운영 환경과 다를 수 있습니다. 처리량뿐 아니라 꼬리 지연 시간과 생성된 SQL 개수를 기록합니다.

N+1은 프레임워크 전체가 느리다는 증거가 아니라 쿼리 계획 결함입니다. 가져오기 조인, 엔티티 그래프, 투영으로 고친 뒤 다시 측정합니다. JdbcTemplate도 반복 안에서 쿼리를 호출하면 같은 N+1을 만들 수 있습니다.

가역적 기술 선택

아키텍처 결정 레코드에는 선택 이유, 거절한 대안, 예상 쿼리, 팀 교육 계획, 관찰 메트릭, 재검토 조건을 둡니다. “JPA가 표준이라서”나 “SQL이 빠르니까”는 검증 가능한 근거가 아닙니다.

조건현재 선택재검토 트리거
애그리거트 명령JPA대량 SQL·플러시 병목 지속
보고서 투영JdbcTemplate동적 조건식 폭증
복잡한 공급자 SQLMyBatis 또는 JdbcTemplateXML 차이·진단 비용 증가
타입-안전 검색Querydsl빌드 처리기·업그레이드 부담

기술을 섞을 때 같은 트랜잭션 매니저와 DataSource에 참여하는지 확인합니다. JPA가 관리 중인 테이블을 JdbcTemplate로 갱신하면 영속성 컨텍스트의 상태가 낡을 수 있으므로 플러시·정리 순서를 명시하거나 명령 경계에서 한 방식을 사용합니다.

연습 문제

회원 게시판 보고서 쿼리를 JdbcTemplate에서 Querydsl로 바꾸는 마이그레이션 계획을 작성하세요. 규칙 픽스처, 섀도 비율, 허용 불일치, 지연 시간 예산, 전환과 롤백 플래그, 이전 코드 제거 시점을 정의하고 후보 타임아웃이 기본 응답을 늦추지 않게 구현하세요.

해설 보기

섀도를 요청 스레드에서 직렬로 호출하면 후보 타임아웃만큼 응답이 느려집니다. 제한된 실행자나 표본 추출된 비동기 비교를 쓰되 트랜잭션 엔티티를 다른 스레드로 넘기지 않고 불변 쿼리 입력과 결과만 전달합니다.

package board.migration;

public record MigrationGate(
        double shadowRatio,
        double maximumMismatchRatio,
        long candidateTimeoutMillis
) {
    public MigrationGate {
        if (shadowRatio < 0.0 || shadowRatio > 1.0
                || maximumMismatchRatio < 0.0
                || maximumMismatchRatio > 1.0
                || candidateTimeoutMillis < 1
                || candidateTimeoutMillis > 2_000) {
            throw new IllegalArgumentException("invalid migration gate");
        }
    }

    public boolean permitsCutover(double observedMismatchRatio) {
        return observedMismatchRatio <= maximumMismatchRatio;
    }
}

전환 플래그는 쓰기와 읽기 소스를 독립적으로 바꾸지 않도록 상태 기계로 관리합니다. 이전 어댑터 제거는 롤백 구간과 하위 호환 스키마 배포가 끝난 뒤 수행합니다.

이 장은 리포지토리 계약에서 시작해 여섯 영속성 접근을 같은 회원 게시판에 적용했습니다. 기술 이름보다 리소스, SQL, 트랜잭션, 매핑, 실패와 운영 관찰을 읽을 수 있으면 요구 변화에 맞춰 안전하게 선택하고 교체할 수 있습니다.