본문 바로가기

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

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

저장 기술이 달라도 사용 사례가 보는 사실은 같아야 합니다. 이 글에서는 생성과 수정, 삭제를 맡는 명령 포트와, 소유자 범위의 조회를 맡는 조회 포트를 나눕니다. 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만 반환합니다. 열린 커서와 지연 로딩 엔티티는 넘기지 않습니다.

모든 구현이 지키는 계약

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

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

그림은 게시글 사용 사례가 업무 규칙과 트랜잭션 정책을 맡고, 명령은 PostRepository의 create, update, delete로, 조회는 PostQuery의 find, page, summary로 호출하는 모습을 보여 줍니다. 그림의 page는 코드의 findPage를 줄여 적은 것이고, 그림에서 빠진 findByIdempotencyKey도 같은 조회 포트에 있습니다. 아래의 Spring JDBC와 MyBatis, JPA·Querydsl 어댑터는 모두 이 두 포트를 함께 구현합니다. 화살표는 위에서 아래로 실행되는 순서가 아니라 사용과 구현의 관계입니다. 그림에는 PostSnapshot과 PostPage만 적었지만 summary가 돌려주는 불변 PostSummary와 update·delete의 boolean도 포트 밖으로 나가며, 엔티티나 열린 커서 같은 저장 기술의 객체는 나가지 않습니다.


두 개의 애플리케이션 포트

생성 입력은 앞 장의 CreatePostUseCase.CreatePostCommand를 그대로 씁니다. 생성과 단건 조회의 결과는 완전히 복사된 PostSnapshot이고, 페이지와 요약도 그 값을 담은 불변 PostPage와 PostSummary이므로, 열린 커서나 지연 로딩 객체가 포트 밖으로 나가지 않습니다.

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입니다. 돌려준 값은 같아도 두 원인은 다릅니다.


명령과 스냅샷의 불변식

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

여기서 Java String.length()의 단위는 화면에 보이는 글자 수가 아니라 UTF-16 코드 단위입니다. PostSnapshot은 돌려준 시점의 값을 복사한 객체일 뿐, 여러 호출이 같은 DB 스냅샷을 함께 본다는 뜻도 아닙니다. SQL의 길이 함수가 보충 문자까지 같은 단위로 세는지는 데이터베이스마다 다를 수 있습니다. 뒤의 데이터베이스 테스트에서는 이것을 H2 테스트만으로는 확인할 수 없고 실제 엔진을 준비한 테스트에서 따로 확인할 항목으로 나눕니다. 단위의 기준은 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");
            }
        });
    }
}

페이지는 방어적으로 복사됩니다. 다음 커서가 있다면 반드시 돌려준 마지막 항목에서 만들어야 하며, 미리 읽은 행은 항목에 넣지 않습니다.

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

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


모든 어댑터가 함께 쓰는 테스트

추상 테스트는 구현마다의 자세한 설정을 모릅니다. 구체 테스트 클래스는 추상 메서드 네 개만 채웁니다. 두 포트를 돌려주는 repository()와 query(), 격리된 DB 예제를 다루는 resetDatabase()와 insertMember(long)이며, 같은 테스트 본문이 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 문자열을 단정하지 않습니다. 대신 생성된 스냅샷과 좁은 범위의 중복 의미, 행 수, 소유자의 격리, 키셋의 순서, 글자 그대로의 검색처럼 모든 어댑터가 똑같이 보여야 하는 결과를 고정합니다.

JUnit은 추상 클래스를 테스트 클래스로 실행하지 않으므로, 이 클래스만 두면 테스트 본문은 한 번도 실행되지 않습니다. 어댑터마다 이 클래스를 상속하고 추상 메서드 네 개를 채운 구체 클래스가 하나씩 있어야 합니다. 상속한 @Test와 @BeforeEach 메서드는 패키지 전용이지만, 다른 패키지의 하위 클래스가 재정의할 수 없으므로 그 하위 클래스에서 그대로 실행됩니다. 세 구체 클래스는 각 어댑터를 만드는 JdbcTemplate과 MyBatis SQL 매핑, Spring Data JPA와 Querydsl에서 더합니다.


정리

  • 명령 포트와 조회 포트를 나누고, JDBC나 MyBatis, JPA의 타입을 공개 시그니처에 드러내지 않습니다.
  • 포트는 PostSnapshot과 PostPage, PostSummary처럼 값을 복사한 불변 결과만 돌려주므로, 열린 커서나 지연 로딩 객체가 밖으로 나가지 않습니다.
  • update와 delete의 false는 조건에 맞는 행이 없다는 사실만 뜻하며, 원인은 사용 사례가 따로 정합니다.
  • 키셋 커서는 고정된 정렬 키 전체를 담고, 검색어의 %, _, 역슬래시는 글자 그대로 다룹니다.
  • 같은 요청 키의 충돌만 좁은 예외로 바꾸고, 공통 테스트를 모든 어댑터에 되풀이해 적용합니다.

연습 문제

1. 돌려준 사실과 사용자에게 할 설명

다른 변경이 없는 조건에서 ID 70, 회원 41, 기대 버전 3으로 수정을 두 번 연속 호출하면 각각 무엇을 돌려주고 마지막 버전은 얼마인가요? 두 번째 false만 보고 서비스가 게시글이 삭제되었다고 단정해도 될까요?

해설 보기

첫 호출은 현재 버전 3과 같으므로 true이고 버전은 4가 됩니다. 두 번째 호출의 기대 버전은 여전히 3이므로 false이며, 버전 4는 그대로입니다. 이 조건에서는 버전이 오래된 것이 이유이므로 삭제되었다고 답하면 틀립니다.

일반적인 호출에서도 false는 WHERE 조건에 맞는 행이 없다는 사실만 알려 줍니다. 사용자에게 알려 줄 원인과 소유자 정보를 얼마나 노출할지는 사용 사례가 따로 정하며, 추가 조회를 한다면 그사이에 데이터가 달라질 수 있다는 점도 고려합니다. 이 답은 행 수와 버전의 규칙을 따라 계산한 것입니다.

2. 공통 테스트에 새 어댑터 붙이기

나중에 JPA 대신 jOOQ 어댑터를 더하려고 합니다. PostPersistenceContract를 그대로 쓰려면 새 구체 테스트 클래스가 무엇을 제공해야 하고, 공통 테스트가 통과하면 무엇을 확인한 것이며 무엇은 확인하지 못한 것인가요?

해설 보기

새 구체 테스트 클래스는 jOOQ로 구현한 PostRepository와 PostQuery, 그리고 DB를 초기화하고 회원을 넣는 것 같은 격리된 예제 동작들만 제공하면 됩니다. 테스트 본문은 바꾸지 않습니다.

공통 테스트가 통과하면 생성 스냅샷과 중복 요청의 의미, 소유자와 버전 조건의 행 수, 키셋 순서, 글자 그대로의 검색, 요약 집계가 다른 어댑터와 같다는 것을 확인한 것입니다. 반면 jOOQ가 만든 실제 SQL의 성능이나 운영 DB 방언에서의 동작, 트랜잭션 관리자와의 연결, 동시 요청에서의 동작은 이 테스트가 다루지 않으므로 따로 검증합니다.

다음 글 JdbcTemplate에서는 이 두 포트를 JdbcTemplate과 이름 기반 파라미터로 구현합니다.