리포지토리와 저장 코드 분리
명령과 조회를 분리한 애플리케이션 포트, 불변 스냅샷과 키셋 페이지 계약, 모든 저장 어댑터가 함께 통과할 공통 테스트를 정의합니다.
저장 기술이 달라도 사용 사례가 관찰하는 사실은 같아야 합니다.
이 문서에서는 생성·수정·삭제를 맡는 명령 포트와 소유자 범위 조회를 맡는 조회 포트를 나눕니다. JDBC, MyBatis, JPA 타입은 어느 공개 서명에도 등장하지 않습니다.
APPLICATION PORTS · ONE PERSISTENCE CONTRACT
명령과 조회 포트 하나가 세 저장 어댑터의 공통 경계를 만든다
사용 사례는 저장 기술을 모르고 PostRepository와
PostQuery만 호출한다. 세 어댑터는 같은 불변 스냅샷,
소유자 범위, 버전, 키셋 순서를 구현한다.
-
사용 사례
업무 규칙과 트랜잭션 정책을 소유하고 두 애플리케이션 포트만 호출합니다.
-
명령·조회 경계
PostRepository는 변경을,PostQuery는 소유자 범위 조회와 키셋 페이지를 맡습니다. -
교체 가능한 어댑터
Spring JDBC, MyBatis, JPA가 같은 스냅샷·버전·검색 계약과 하나의 스키마를 구현합니다.
포트를 저장 기술별로 복제하지 않는다. 공통 계약 테스트가 세 구현의 관찰 가능한 결과를 같은 기준으로 검증한다.
두 개의 애플리케이션 포트
생성 입력은 앞 장의 CreatePostUseCase.CreatePostCommand를 그대로 사용합니다. 생성 결과와 조회 결과는 모두 완전히 복사된 PostSnapshot이므로 열린 커서나 지연 로딩 객체가 경계를 넘지 않습니다.
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);
}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는 오직 조건에 맞는 행이 없다는 뜻입니다. 없음, 다른 회원, 낡은 버전을 저장 계층이 추측하지 않습니다. 두 행 이상이 바뀌면 정상 결과가 아니라 영속성 실패입니다.
명령과 스냅샷의 불변식
수정 명령은 소유자와 읽었던 버전을 함께 보냅니다. 문자열을 잘라서 통과시키지 않고 원문 길이와 공백 여부를 검증합니다.
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");
}
}
}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)입니다.
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자로 고정하며 %, _, 역슬래시는 와일드카드가 아니라 리터럴 데이터입니다.
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 행은 항목에 포함되지 않습니다.
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·매핑·행 수·키 생성 실패는 하나의 영속성 예외로 경계를 통과하고 원인은 보존됩니다.
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;
}
}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에 반복 적용됩니다.
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과 이름 기반 파라미터로 구현합니다.