본문 바로가기

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

명령과 조회를 분리한 애플리케이션 포트, 불변 스냅샷과 키셋 페이지 계약, 모든 저장 어댑터가 함께 통과할 공통 테스트를 정의합니다.

저장 기술이 달라도 사용 사례가 관찰하는 사실은 같아야 합니다.

이 문서에서는 생성·수정·삭제를 맡는 명령 포트와 소유자 범위 조회를 맡는 조회 포트를 나눕니다. JDBC, MyBatis, JPA 타입은 어느 공개 서명에도 등장하지 않습니다.

APPLICATION PORTS · SHARED CONTRACT

저장 기술이 바뀌어도 애플리케이션 포트는 같다

사용 사례는 PostRepository와 PostQuery만 호출합니다. JDBC·MyBatis·JPA 어댑터가 같은 입력과 결과 의미를 구현합니다.

사용 사례, 명령·조회 포트와 세 저장 어댑터사용 사례는 명령·조회 포트를 사용한다. 세 어댑터는 모두 이 두 포트를 구현한다. 위에서 아래로 호출을 진행하는 순서가 아니라 사용과 구현 관계를 나타낸다.게시글 사용 사례업무 규칙 · 트랜잭션 정책사용PostRepositorycreate · update · deletePostQueryfind · page · summary구현구현구현Spring JDBC명시적 SQL · 행 매핑MyBatismapper XML · 값 바인딩JPA · Querydsl엔티티 내부스냅샷 외부
세 어댑터가 같은 두 포트 영역을 구현한다사용 사례에서 두 포트 영역으로 향하는 한 간선은 사용이다. 아래 세 어댑터에서 두 포트 영역의 서로 다른 지점으로 들어오는 세 간선은 영역 전체의 구현이며 각 어댑터가 두 포트를 모두 구현한다. 개별 PostQuery만 구현하는 뜻이나 위아래 실행 순서가 아니다. desktop과 같은 여섯 노드·네 관계를 보존한다.게시글 사용 사례업무 · 정책사용두 포트PostRepository명령PostQuery조회Spring JDBC두 포트 구현MyBatis두 포트 구현JPA · Querydsl두 포트 구현
  1. 사용 사례 → 두 포트

    업무 규칙과 트랜잭션 정책은 사용 사례가 소유합니다. 명령은 PostRepository, 조회는 PostQuery를 호출합니다.

  2. 세 어댑터 → 같은 포트 구현

    Spring JDBC는 SQL·매핑, MyBatis는 mapper XML, JPA·Querydsl은 영속성 컨텍스트와 동적 검색을 담당합니다.

포트 밖으로 나가는 결과

불변 PostSnapshot·PostPage만 반환합니다. 열린 커서와 지연 로딩 엔티티는 넘기지 않습니다.

모든 구현이 지키는 계약

소유자 범위·버전·키셋 순서를 동일하게 유지하고 하나의 스키마와 공통 계약 테스트를 공유합니다.

어댑터에서 포트로 향하는 화살표는 구현 관계입니다. 포트를 저장 기술별로 복제하지 않습니다.


두 개의 애플리케이션 포트

생성 입력은 앞 장의 CreatePostUseCase.CreatePostCommand를 그대로 사용합니다. 생성 결과와 조회 결과는 모두 완전히 복사된 PostSnapshot이므로 열린 커서나 지연 로딩 객체가 경계를 넘지 않습니다.

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

import java.time.Instant;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

public interface PostRepository {
    PostSnapshot create(CreatePostCommand command, Instant createdAt);

    boolean update(ChangedPost command);

    boolean delete(DeletePostCommand command);
}
src/main/java/board/application/PostQuery.java
package board.application;

import java.util.Optional;

public interface PostQuery {
    Optional<PostSnapshot> find(long postId, long memberId);

    Optional<PostSnapshot> findByIdempotencyKey(
            long memberId,
            String clientRequestId
    );

    PostPage findPage(PostSearch search);

    PostSummary summary(long memberId);
}

update와 delete의 false는 오직 조건에 맞는 행이 없다는 뜻입니다. 없음, 다른 회원, 낡은 버전을 저장 계층이 추측하지 않습니다. 두 행 이상이 바뀌면 정상 결과가 아니라 영속성 실패입니다.

예시 게시글 ID 70의 소유자는 회원 41이고 현재 버전은 3이라고 합시다. 회원 41이 버전 3으로 수정하면 한 행이 바뀌어 true이고 새 버전은 4입니다. 같은 회원이 읽어 둔 버전 2로 수정하거나 회원 42가 버전 3으로 수정하면 조건에 맞는 행이 없어 둘 다 false입니다. 반환값이 같아도 두 원인은 다릅니다.

반환 사실과 사용자 설명을 구별하기 다른 변경이 없는 조건에서 ID 70·회원 41·기대 버전 3으로 수정을 두 번 연속 호출하면 각 반환값과 마지막 버전은 무엇인가요? 두 번째 false만 보고 서비스가 “게시글이 삭제됐다”라고 단정해도 될까요?

조건을 다시 대입하는 풀이

첫 호출은 현재 버전 3과 일치해 true이고 버전은 4가 됩니다. 두 번째 호출의 기대 버전은 여전히 3이므로 false이며 버전 4는 그대로입니다. 이 조건에서는 낡은 버전이 이유이므로 삭제됐다고 답하면 틀립니다. 일반 호출에서도 false는 WHERE 조건에 맞는 행이 없다는 사실만 전달합니다. 사용자에게 공개할 원인과 소유자 정보의 노출 정책은 사용 사례가 별도로 정합니다. 추가 조회를 한다면 그 조회 사이에 데이터가 달라질 수 있다는 점도 고려합니다.

이는 이 절의 행 수·버전 계약을 따라 계산한 연습이며 새 DB 실행 결과가 아닙니다.


명령과 스냅샷의 불변식

수정 명령은 소유자와 읽었던 버전을 함께 보냅니다. 문자열을 잘라서 통과시키지 않고 원문 길이와 공백 여부를 검증합니다.

여기서 Java String.length()의 단위는 화면에서 보이는 글자 수가 아니라 UTF-16 코드 단위입니다. PostSnapshot은 반환 시점의 값을 복사한 객체이며 여러 호출이 같은 DB 스냅샷을 공유한다는 뜻도 아닙니다. SQL의 길이 함수와 보충 문자까지 같은 단위인지 확인하는 범위는 뒤의 데이터베이스 테스트 절에서 구분합니다. Java의 length 계약을 단위 기준으로 읽으세요.

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

public record ChangedPost(
        long id,
        long memberId,
        String title,
        String content,
        long expectedVersion
) {
    public ChangedPost {
        if (id <= 0 || memberId <= 0 || expectedVersion < 0) {
            throw new IllegalArgumentException(
                    "invalid identity or expectedVersion");
        }
        if (title == null || title.isBlank() || title.length() > 80) {
            throw new IllegalArgumentException("invalid title");
        }
        if (content == null || content.isBlank()
                || content.length() > 720) {
            throw new IllegalArgumentException("invalid content");
        }
    }
}
src/main/java/board/application/PostSnapshot.java
package board.application;

import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;

public record PostSnapshot(
        long id,
        long memberId,
        String title,
        String content,
        LocalDate publishedOn,
        String clientRequestId,
        Instant createdAt,
        long version
) {
    public PostSnapshot {
        if (id <= 0 || memberId <= 0 || version < 0) {
            throw new IllegalArgumentException(
                    "invalid identity or version");
        }
        Objects.requireNonNull(publishedOn, "publishedOn");
        Objects.requireNonNull(createdAt, "createdAt");
        if (title == null || title.isBlank() || title.length() > 80) {
            throw new IllegalArgumentException("invalid title");
        }
        if (content == null || content.isBlank()
                || content.length() > 720) {
            throw new IllegalArgumentException("invalid content");
        }
        if (clientRequestId == null
                || !clientRequestId.matches("[A-Za-z0-9_-]{8,64}")) {
            throw new IllegalArgumentException("invalid clientRequestId");
        }
    }
}

어댑터는 생성 명령의 제목·본문·요청 키를 trim, 소문자화, 축약하지 않습니다. DB에서 읽은 행이 같은 불변식을 어기면 손상된 값을 보정하지 않고 매핑 실패로 닫습니다.


고정된 키셋 검색 계약

커서는 정렬 키 전체인 게시일과 ID를 가집니다. 호출자가 다른 정렬을 고를 수 없으므로 한 커서의 의미는 항상 (publishedOn DESC, id DESC)입니다.

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

import java.time.LocalDate;
import java.util.Objects;

public record PostCursor(LocalDate publishedOn, long id) {
    public PostCursor {
        Objects.requireNonNull(publishedOn, "publishedOn");
        if (id <= 0) {
            throw new IllegalArgumentException("id must be positive");
        }
    }

    public static PostCursor from(PostSnapshot snapshot) {
        Objects.requireNonNull(snapshot, "snapshot");
        return new PostCursor(snapshot.publishedOn(), snapshot.id());
    }
}

검색어가 null이거나 공백뿐이면 검색 조건이 없습니다. 그 밖의 검색어는 양끝 공백을 제거한 뒤 1~80자로 고정하며 %, _, 역슬래시는 와일드카드가 아니라 리터럴 데이터입니다.

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

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

public record PostSearch(
        long memberId,
        String keyword,
        LocalDate from,
        LocalDate to,
        Integer minimumCharacters,
        Optional<PostCursor> after,
        int size
) {
    public PostSearch {
        Objects.requireNonNull(from, "from");
        Objects.requireNonNull(to, "to");
        after = Objects.requireNonNull(after, "after");
        if (memberId <= 0 || from.isAfter(to)) {
            throw new IllegalArgumentException("invalid search boundary");
        }
        if (size < 1 || size > 100) {
            throw new IllegalArgumentException("size must be between 1 and 100");
        }
        if (keyword == null || keyword.isBlank()) {
            keyword = null;
        } else {
            keyword = keyword.strip();
            if (keyword.length() > 80) {
                throw new IllegalArgumentException("keyword is too long");
            }
        }
        if (minimumCharacters != null
                && (minimumCharacters < 1 || minimumCharacters > 720)) {
            throw new IllegalArgumentException(
                    "minimumCharacters must be between 1 and 720");
        }
        after.ifPresent(cursor -> {
            if (cursor.publishedOn().isBefore(from)
                    || cursor.publishedOn().isAfter(to)) {
                throw new IllegalArgumentException(
                        "cursor date must be inside the search range");
            }
        });
    }
}

페이지는 방어적으로 복사됩니다. 다음 커서가 있으면 반드시 반환한 마지막 항목에서 만들어져야 하며, look-ahead 행은 항목에 포함되지 않습니다.

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

import java.util.List;
import java.util.Objects;
import java.util.Optional;

public record PostPage(
        List<PostSnapshot> items,
        Optional<PostCursor> nextCursor
) {
    public PostPage {
        items = List.copyOf(Objects.requireNonNull(items, "items"));
        nextCursor = Objects.requireNonNull(nextCursor, "nextCursor");
        if (items.isEmpty() && nextCursor.isPresent()) {
            throw new IllegalArgumentException(
                    "an empty page cannot have a next cursor");
        }
        if (nextCursor.isPresent()
                && !nextCursor.orElseThrow().equals(
                        PostCursor.from(items.getLast()))) {
            throw new IllegalArgumentException(
                    "next cursor must describe the last returned item");
        }
    }
}

구현은 size + 1개까지만 읽습니다. 한 행을 더 읽었을 때에만 다음 페이지가 있음을 알 수 있으며, 전체 개수 쿼리는 이 계약의 일부가 아닙니다.


애플리케이션 예외 경계

동일 회원의 동일 요청 키 충돌만 좁은 예외로 번역합니다. 나머지 SQL·매핑·행 수·키 생성 실패는 하나의 영속성 예외로 경계를 통과하고 원인은 보존됩니다.

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

import java.util.Objects;

public final class DuplicatePostRequestException extends RuntimeException {
    private final long memberId;
    private final String clientRequestId;

    public DuplicatePostRequestException(
            long memberId,
            String clientRequestId,
            Throwable cause
    ) {
        super("duplicate post request", Objects.requireNonNull(cause, "cause"));
        if (memberId <= 0) {
            throw new IllegalArgumentException("memberId must be positive");
        }
        if (clientRequestId == null
                || !clientRequestId.matches("[A-Za-z0-9_-]{8,64}")) {
            throw new IllegalArgumentException("invalid clientRequestId");
        }
        this.memberId = memberId;
        this.clientRequestId = clientRequestId;
    }

    public long memberId() {
        return memberId;
    }

    public String clientRequestId() {
        return clientRequestId;
    }
}
src/main/java/board/application/PostPersistenceException.java
package board.application;

import java.util.Objects;

public final class PostPersistenceException extends RuntimeException {
    public PostPersistenceException(String message, Throwable cause) {
        super(
                Objects.requireNonNull(message, "message"),
                Objects.requireNonNull(cause, "cause"));
    }
}

트랜잭션은 이 포트가 시작하거나 커밋하지 않습니다. 사용 사례가 정한 현재 트랜잭션에 각 어댑터가 참여합니다.


모든 어댑터가 공유하는 계약 테스트

추상 테스트는 구현별 세부 설정을 모릅니다. concrete harness는 포트 두 개와 격리된 DB fixture 네 동작만 제공합니다. 같은 테스트 본문이 Spring JDBC, MyBatis, JPA에 반복 적용됩니다.

src/test/java/board/contract/PostPersistenceContract.java
package board.contract;

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

import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

import board.application.ChangedPost;
import board.application.DeletePostCommand;
import board.application.DuplicatePostRequestException;
import board.application.PostCursor;
import board.application.PostPage;
import board.application.PostQuery;
import board.application.PostRepository;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public abstract class PostPersistenceContract {
    private static final long MEMBER = 41L;
    private static final long OTHER_MEMBER = 99L;
    private static final LocalDate DAY = LocalDate.parse("2026-08-20");
    private static final Instant NOW = Instant.parse("2026-08-20T09:00:00Z");

    protected abstract PostRepository repository();

    protected abstract PostQuery query();

    protected abstract void resetDatabase();

    protected abstract void insertMember(long memberId);

    @BeforeEach
    final void preparePersistenceContract() {
        resetDatabase();
        insertMember(MEMBER);
        insertMember(OTHER_MEMBER);
    }

    @Test
    final void create는_지속된_snapshot을_그대로_반환하고_소유자를_숨긴다() {
        var command = command(
                MEMBER, "create_01", "Persistence", "경계를 보존하는 본문", DAY);

        PostSnapshot created = repository().create(command, NOW);

        assertThat(created.id()).isPositive();
        assertThat(created)
                .isEqualTo(new PostSnapshot(
                        created.id(), MEMBER, command.title(), command.content(),
                        DAY, command.clientRequestId(), NOW, 0L));
        assertThat(query().find(created.id(), MEMBER)).contains(created);
        assertThat(query().find(created.id(), OTHER_MEMBER)).isEmpty();
    }

    @Test
    final void 동일_회원과_요청_키의_두_번째_create만_좁은_예외가_된다() {
        var command = command(
                MEMBER, "repeat_01", "first", "원본은 한 행만 남는다", DAY);
        PostSnapshot original = repository().create(command, NOW);

        DuplicatePostRequestException failure = catchThrowableOfType(
                () -> repository().create(command, NOW.plusSeconds(1)),
                DuplicatePostRequestException.class);

        assertThat(failure.memberId()).isEqualTo(MEMBER);
        assertThat(failure.clientRequestId()).isEqualTo("repeat_01");
        assertThat(failure.getCause()).isNotNull();
        assertThat(query().findByIdempotencyKey(MEMBER, "repeat_01"))
                .contains(original);
    }

    @Test
    final void update는_소유자와_version이_모두_맞을_때만_한_번_증가한다() {
        PostSnapshot created = repository().create(command(
                MEMBER, "update_01", "before", "수정 전 본문", DAY), NOW);

        assertThat(repository().update(new ChangedPost(
                created.id(), MEMBER, "after", "수정 후 본문", 0L))).isTrue();
        PostSnapshot changed = query().find(created.id(), MEMBER).orElseThrow();
        assertThat(changed.title()).isEqualTo("after");
        assertThat(changed.content()).isEqualTo("수정 후 본문");
        assertThat(changed.version()).isEqualTo(1L);

        assertThat(repository().update(new ChangedPost(
                created.id(), MEMBER, "stale", "낡은 수정", 0L))).isFalse();
        assertThat(repository().update(new ChangedPost(
                created.id(), OTHER_MEMBER, "foreign", "다른 회원 수정", 1L)))
                .isFalse();
        assertThat(query().find(created.id(), MEMBER).orElseThrow())
                .isEqualTo(changed);
    }

    @Test
    final void delete는_정확한_소유자와_version에만_true를_반환한다() {
        PostSnapshot created = repository().create(command(
                MEMBER, "delete_01", "delete", "삭제 계약 본문", DAY), NOW);

        assertThat(repository().delete(new DeletePostCommand(
                created.id(), MEMBER, 1L))).isFalse();
        assertThat(repository().delete(new DeletePostCommand(
                created.id(), OTHER_MEMBER, 0L))).isFalse();
        assertThat(repository().delete(new DeletePostCommand(
                created.id(), MEMBER, 0L))).isTrue();
        assertThat(repository().delete(new DeletePostCommand(
                created.id(), MEMBER, 0L))).isFalse();
        assertThat(query().find(created.id(), MEMBER)).isEmpty();
    }

    @Test
    final void page는_45행을_20_20_5로_중복과_누락_없이_순회한다() {
        var expected = new ArrayList<PostSnapshot>();
        for (int index = 0; index < 45; index++) {
            expected.add(repository().create(command(
                    MEMBER,
                    "page_" + String.format("%03d", index),
                    "page " + index,
                    "키셋 페이지 본문 " + index,
                    DAY.minusDays(index % 4)),
                    NOW.plusSeconds(index)));
        }
        expected.sort(Comparator
                .comparing(PostSnapshot::publishedOn)
                .thenComparingLong(PostSnapshot::id)
                .reversed());

        var actual = new ArrayList<PostSnapshot>();
        var sizes = new ArrayList<Integer>();
        var cursorStates = new ArrayList<Boolean>();
        Optional<PostCursor> cursor = Optional.empty();
        for (int pageNumber = 0; pageNumber < 4; pageNumber++) {
            PostPage page = query().findPage(new PostSearch(
                    MEMBER, null, DAY.minusDays(3), DAY,
                    null, cursor, 20));
            actual.addAll(page.items());
            sizes.add(page.items().size());
            cursorStates.add(page.nextCursor().isPresent());
            cursor = page.nextCursor();
            if (cursor.isEmpty()) {
                break;
            }
        }

        assertThat(sizes).containsExactly(20, 20, 5);
        assertThat(cursorStates).containsExactly(true, true, false);
        assertThat(actual).containsExactlyElementsOf(expected);
        assertThat(actual.stream().map(PostSnapshot::id).distinct())
                .hasSize(45);
    }

    @Test
    final void 검색은_percent_underscore_역슬래시와_공격_문자열을_리터럴로_본다() {
        repository().create(command(
                MEMBER, "literal1", "100% exact", "검색 fixture", DAY), NOW);
        repository().create(command(
                MEMBER, "literal2", "100 percent", "검색 fixture", DAY), NOW);
        repository().create(command(
                MEMBER, "literal3", "a_b exact", "검색 fixture", DAY), NOW);
        repository().create(command(
                MEMBER, "literal4", "acb decoy", "검색 fixture", DAY), NOW);
        repository().create(command(
                MEMBER, "literal5", "path\\segment", "검색 fixture", DAY), NOW);
        repository().create(command(
                MEMBER, "literal6", "Spring') or 1=1 --", "검색 fixture", DAY), NOW);

        assertThat(titles("100%"))
                .containsExactly("100% exact");
        assertThat(titles("a_b"))
                .containsExactly("a_b exact");
        assertThat(titles("path\\segment"))
                .containsExactly("path\\segment");
        assertThat(titles("Spring') or 1=1 --"))
                .containsExactly("Spring') or 1=1 --");
    }

    @Test
    final void summary는_해당_회원의_행과_본문_길이만_집계한다() {
        String first = "첫 번째 본문";
        String second = "second body";
        repository().create(command(
                MEMBER, "summary1", "one", first, DAY), NOW);
        repository().create(command(
                MEMBER, "summary2", "two", second, DAY), NOW.plusSeconds(1));
        repository().create(command(
                OTHER_MEMBER, "summary3", "other", "제외할 본문", DAY),
                NOW.plusSeconds(2));

        var summary = query().summary(MEMBER);

        assertThat(summary.memberId()).isEqualTo(MEMBER);
        assertThat(summary.postCount()).isEqualTo(2L);
        assertThat(summary.totalCharacters())
                .isEqualTo(first.length() + second.length());
    }

    @Test
    final void 잘못된_검색_경계는_DB_호출_전에_거부된다() {
        assertThatIllegalArgumentException().isThrownBy(() -> new PostSearch(
                MEMBER, "keyword", DAY, DAY.minusDays(1),
                null, Optional.empty(), 20));
        assertThatIllegalArgumentException().isThrownBy(() -> new PostSearch(
                MEMBER, "keyword", DAY.minusDays(1), DAY,
                0, Optional.empty(), 20));
        assertThatIllegalArgumentException().isThrownBy(() -> new PostSearch(
                MEMBER, "keyword", DAY.minusDays(1), DAY,
                null, Optional.empty(), 101));
        assertThatIllegalArgumentException().isThrownBy(() -> new PostSearch(
                MEMBER, "keyword", DAY.minusDays(1), DAY,
                null, Optional.of(new PostCursor(DAY.plusDays(1), 1L)), 20));
    }

    private List<String> titles(String keyword) {
        return query().findPage(new PostSearch(
                        MEMBER, keyword, DAY, DAY,
                        null, Optional.empty(), 20))
                .items().stream()
                .map(PostSnapshot::title)
                .toList();
    }

    private CreatePostCommand command(
            long memberId,
            String clientRequestId,
            String title,
            String content,
            LocalDate publishedOn
    ) {
        return new CreatePostCommand(
                memberId, title, content, publishedOn, clientRequestId);
    }
}

공통 계약은 구현 세부 예외나 SQL 문자열을 단정하지 않습니다. 대신 생성 스냅샷, 좁은 중복 의미, 행 수 사실, 소유자 격리, 키셋 순서, 리터럴 검색처럼 모든 어댑터가 동일하게 관찰해야 하는 결과를 고정합니다.

다음 문서에서는 이 경계를 JdbcTemplate과 이름 기반 파라미터로 구현합니다.