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

안동민 개발노트

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

리포지토리와 저장 코드 분리

리포지토리와 애그리거트의 뜻부터 익히고, 회원 게시판의 저장 규칙을 JDBC·MyBatis·JPA와 분리된 인터페이스로 표현합니다.

앞 장에서는 서비스가 JDBC 코드를 직접 호출했습니다.

기능이 늘어나면 서비스 안에 SQL과 업무 규칙이 섞이고, 저장 기술을 바꿀 때 서비스까지 고쳐야 합니다.

리포지토리(repository)는 “게시글을 저장한다”, “ID로 게시글을 찾는다”처럼 저장소에 필요한 동작을 인터페이스로 모은 경계입니다.

이 장에서 자주 쓰는 용어를 먼저 정리합니다.

  • 애그리거트: 한 번의 업무 변경에서 일관성을 함께 지켜야 하는 객체 묶음입니다. 이 예제에서는 게시글 한 건을 중심으로 봅니다.
  • 어댑터: 리포지토리 인터페이스를 JDBC·MyBatis·JPA 같은 구체 기술로 구현한 클래스입니다.
  • 영속성: 프로그램이 끝난 뒤에도 데이터를 남기는 성질입니다.

인터페이스 이름만 바꾼다고 분리가 끝나는 것은 아닙니다.

없음과 버전 충돌을 어떻게 표현할지, 결과 순서와 페이지네이션을 어떻게 보장할지도 리포지토리 계약에 포함합니다.


리포지토리 인터페이스 규칙

JPA EntityManager, JDBC ResultSet, MyBatis 페이지 객체를 애플리케이션 인터페이스에 노출하지 않습니다.

식별자와 초안, 조회 커서 같은 애플리케이션 타입을 사용합니다.

src/main/java/board/domain/PostRepository.java
package board.domain;

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

public interface PostRepository {
    Post save(Post post);

    Optional<Post> findById(PostId id);

    Optional<Post> findByIdempotencyKey(
            MemberId authorId,
            String idempotencyKey);

    PostSlice findRecent(
            MemberId authorId,
            LocalDate from,
            LocalDate to,
            PostCursor cursor,
            int size);

    boolean delete(
            PostId id,
            MemberId ownerId,
            long expectedVersion);
}
src/main/java/board/domain/RepositoryTypes.java
package board.domain;

import java.time.LocalDate;

record PostId(long value) {
    PostId {
        if (value <= 0) {
            throw new IllegalArgumentException("positive post id required");
        }
    }
}

record MemberId(long value) {
    MemberId {
        if (value <= 0) {
            throw new IllegalArgumentException("positive member id required");
        }
    }
}

record PostCursor(LocalDate createdOn, long id) {
    PostCursor {
        if (createdOn == null || id <= 0) {
            throw new IllegalArgumentException("valid cursor required");
        }
    }
}

findById는 없을 수 있어 선택적이고, 서비스의 required 메서드가 찾을 수 없음 의미를 만듭니다.

save가 삽입과 갱신을 모두 처리할지, 생성·수정을 분리할지는 애그리거트 생명주기와 기술 독립성을 보고 정합니다.

ID가 DB에서 생성된다면 새 엔티티와 저장된 엔티티 상태를 타입 또는 null 허용 ID 정책으로 명확히 합니다.

findRecent의 정렬 (createdOn desc, id desc)과 커서의 배타 조건은 인터페이스 문서 또는 값 타입에 포함합니다.

구현마다 순서가 달라지면 같은 서비스가 다른 결과를 냅니다.


애그리거트 불변식

src/main/java/board/domain/Post.java
package board.domain;

import java.time.Instant;
import java.time.LocalDate;

public final class Post {
    private final PostId id;
    private final MemberId authorId;
    private String title;
    private String content;
    private final LocalDate createdOn;
    private long version;
    private final Instant createdAt;

    private Post(
            PostId id,
            MemberId authorId,
            String title,
            String content,
            LocalDate createdOn,
            long version,
            Instant createdAt
    ) {
        if (authorId == null || createdOn == null || createdAt == null) {
            throw new IllegalArgumentException("required post values missing");
        }
        validateTitle(title);
        validateContent(content);
        if (version < 0) {
            throw new IllegalArgumentException("negative version");
        }
        this.id = id;
        this.authorId = authorId;
        this.title = title.strip();
        this.content = content;
        this.createdOn = createdOn;
        this.version = version;
        this.createdAt = createdAt;
    }

    public static Post newPost(
            MemberId authorId,
            String title,
            String content,
            LocalDate createdOn,
            Instant createdAt
    ) {
        return new Post(null, authorId, title, content,
                createdOn, 0L, createdAt);
    }

    public static Post restored(
            PostId id,
            MemberId authorId,
            String title,
            String content,
            LocalDate createdOn,
            long version,
            Instant createdAt
    ) {
        if (id == null) {
            throw new IllegalArgumentException("restored id required");
        }
        return new Post(id, authorId, title, content,
                createdOn, version, createdAt);
    }

    public void change(String newTitle, String newContent) {
        validateTitle(newTitle);
        validateContent(newContent);
        title = newTitle.strip();
        content = newContent;
    }

    private static void validateTitle(String value) {
        if (value == null || value.isBlank() || value.strip().length() > 80) {
            throw new IllegalArgumentException("invalid title");
        }
    }

    private static void validateContent(String value) {
        if (value == null || value.isBlank() || value.length() > 5_000) {
            throw new IllegalArgumentException("invalid content");
        }
    }

    public PostId id() { return id; }
    public MemberId authorId() { return authorId; }
    public String title() { return title; }
    public String content() { return content; }
    public LocalDate createdOn() { return createdOn; }
    public long version() { return version; }
    public Instant createdAt() { return createdAt; }
}

JPA 구현이 필요하면 영속성 엔티티를 별도로 두거나 도메인 모델에 최소 애노테이션을 허용하는 전략을 선택합니다.

영속성 기술과 완전히 분리하는 방식이 항상 최선은 아니지만, 지연 프록시와 기본 생성자 요구가 도메인 불변식을 약화하는 비용을 의식합니다.


도메인 예외 규칙

고유 제약 조건은 JDBC SQLException, MyBatis PersistenceException, JPA PersistenceException으로 다르게 보일 수 있습니다.

어댑터는 원인을 보존하며 DuplicatePostException 같은 애플리케이션 의미로 번역합니다.

모든 무결성 위반을 중복으로 바꾸지 않습니다.

낙관적 갱신 개수 0과 JPA 낙관적 잠금 예외도 같은 PostVersionConflictException으로 맞출 수 있습니다.

단, 다른 책임 주체와 존재하지 않음을 외부에 같은 404로 숨기는 정책은 웹 어댑터에서 결정합니다.

리포지토리는 확인 가능한 사실을 반환합니다.

트랜잭션은 리포지토리 인터페이스가 시작하지 않습니다.

서비스가 여러 리포지토리 작업을 하나로 묶고 구현은 현재 트랜잭션에 참여합니다.

구현별로 메서드 안에서 커밋하면 규칙이 깨집니다.


공통 리포지토리 테스트

추상 계약 테스트 모음은 리포지토리 팩토리만 구현별로 제공받고 같은 시나리오를 실행합니다.

메모리 기반 가짜도 이 테스트를 통과해야 지나치게 관대한 동작이 운영 환경 어댑터와 어긋나지 않습니다.

src/test/java/board/domain/RepositoryContract.java
package board.domain;

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

import java.time.Instant;
import java.time.LocalDate;

import org.junit.jupiter.api.Test;

public abstract class RepositoryContract {
    protected abstract PostRepository repository();

    @Test
    void 저장한_aggregate를_ID로_복원한다() {
        var original = Post.newPost(
                new MemberId(41L),
                "Persistence",
                "영속성 경계를 설명하는 게시글 본문",
                LocalDate.parse("2026-07-14"),
                Instant.parse("2026-07-14T09:00:00Z"));

        Post saved = repository().save(original);
        Post found = repository().findById(saved.id()).orElseThrow();

        assertThat(saved.id()).isNotNull();
        assertThat(found.authorId()).isEqualTo(new MemberId(41L));
        assertThat(found.title()).isEqualTo("Persistence");
        assertThat(found.content())
                .isEqualTo("영속성 경계를 설명하는 게시글 본문");
        assertThat(found.version()).isZero();
    }

    @Test
    void 다른_owner는_같은_ID를_delete할_수_없다() {
        Post saved = repository().save(Post.newPost(
                new MemberId(41L), "JDBC", "JDBC 저장 예제 본문",
                LocalDate.parse("2026-07-13"),
                Instant.parse("2026-07-13T09:00:00Z")));

        boolean deleted = repository().delete(
                saved.id(), new MemberId(99L), saved.version());

        assertThat(deleted).isFalse();
        assertThat(repository().findById(saved.id())).isPresent();
    }
}
repository contract 결과
implementations = [in-memory, JDBC, JPA]
save then find = PASSED
owner-scoped delete = PASSED
version conflict semantics = identical
recent ordering = identical
transaction ownership = service

DB 어댑터 계약 테스트는 매 테스트마다 스키마와 데이터를 격리합니다.

메모리 기반 가짜가 ID 생성과 버전 증가를 실제 구현처럼 수행하지 않으면 서비스 테스트가 거짓으로 통과할 수 있습니다.

가짜 구현 비용이 커지면 실제 내장 DB 테스트가 더 단순할 수 있습니다.


쿼리 요구 분리

대시보드용 다중 집계와 검색 투영을 애그리거트 리포지토리에 모두 추가하면 인터페이스가 보고서 기술에 끌려갑니다.

명령 애그리거트 리포지토리와 쿼리 투영 포트를 나눌 수 있습니다.

CQRS를 거대한 아키텍처로 도입하지 않아도 읽기 DTO 전용 쿼리 인터페이스는 유용합니다.

요구적합한 계약반환
게시글 변경애그리거트 리포지토리Post
주간 합계보고서 쿼리WeeklySummary
검색 결과검색 쿼리얇은 행 DTO
내보내기 스트림내보내기 포트제한된 커서 콜백

ORM 엔티티 그래프를 API 응답으로 직접 반환하지 않습니다.

쿼리 투영은 필요한 열만 읽고 N+1을 피하며, 애그리거트 변경은 불변식을 지키는 객체를 복원합니다.

같은 테이블을 사용해도 목적에 따라 어댑터가 다를 수 있습니다.


연습 문제

회원 게시판 리포지토리 규칙에 findRecent 키셋 페이지네이션 테스트를 추가하세요.

날짜가 같은 행을 포함해 45개를 저장하고 페이지 크기 20으로 모두 순회했을 때 ID 중복·누락·정렬 변화가 없어야 합니다.

메모리 기반과 JDBC 구현이 같은 커서 의미를 따르게 하세요.

해설 보기

커서는 마지막 행의 날짜와 ID를 가지며 해당 행을 제외합니다.

반환 슬라이스는 항목과 다음 커서만 제공하고 오프셋이나 전체 개수를 필수로 요구하지 않습니다.

package board.domain;

import java.util.List;

public record PostSlice(
        List<Post> items,
        PostCursor next
) {
    public PostSlice {
        items = List.copyOf(items);
    }

    public boolean hasNext() {
        return next != null;
    }
}

테스트는 모든 페이지의 ID를 순서 있는 목록과 집합에 담아 전체 수와 유일성을 함께 확인합니다.

새 행이 페이지 사이에 추가되는 일관성 요구가 있다면 스냅샷 시각이나 상한이 결합된 커서를 규칙에 더합니다.

다음 문서에서는 이 계약의 JDBC 구현을 JdbcTemplate과 이름 기반 파라미터로 작성해 리소스 관리와 예외 변환을 줄입니다.