저장소·서비스 테스트
메모리 PostRepository와 PostService를 순수 Java로 구현하고 저장 계약·중복 규칙·테스트 격리를 각각 검증합니다.
도메인 객체만으로는 게시글이 어디에도 남지 않습니다.
그렇다고 저장 코드를 서비스 안에 직접 넣으면 “게시글을 등록한다”는 사용 사례와 “맵 또는 DB에 보관한다”는 기술 선택이 한 클래스에서 바뀝니다.
이번 문서의 경계는 단순합니다.
PostRepository는 게시글에 식별자를 부여하고 저장·조회한다.PostService는 날짜 검증, 중복 검사, 저장 호출 순서를 지휘한다.MemoryPostRepository는 개발 중 사용할 한 구현일 뿐이다.
Spring은 아직 사용하지 않습니다.
평범한 생성자 호출만으로 객체 그래프를 만들 수 있어야 다음 문서에서 컨테이너가 무엇을 대신하는지 분명해집니다.
저장소 행동 규칙
인터페이스는 구현을 숨기기 위한 장식이 아니라 호출자가 기대할 수 있는 행동을 정합니다.
package board.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface PostRepository {
Post save(PostDraft draft);
Optional<Post> findById(long id);
List<Post> findAll();
boolean existsByAuthorIdAndTitleAndPublishedOn(
String authorId,
String title,
LocalDate publishedOn);
}save는 저장된 Post을 반환합니다.
호출자는 구현이 메모리 sequence와 DB identity 중 어떤 long 생성 전략을 쓰는지 몰라도 됩니다.
UUID로 바꾸려면 Post.id와 저장소 port의 저장·조회 타입을 함께 바꿔야 하므로 현재 long 계약 안에서 교체 가능한 구현 세부는 아닙니다.
findById가 Optional을 반환하는 것은 미존재가 정상적인 조회 결과일 수 있음을 타입에 드러냅니다.
메모리 구현은 테스트와 첫 실행에 충분하며, 동시 접근에서 맵 구조와 long 식별자 할당이 깨지지 않도록 ConcurrentHashMap과 AtomicLong을 사용합니다.
package board.infrastructure;
import board.domain.PostDraft;
import board.domain.PostRepository;
import board.domain.Post;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public final class MemoryPostRepository implements PostRepository {
private final AtomicLong sequence = new AtomicLong();
private final ConcurrentHashMap<Long, Post> store =
new ConcurrentHashMap<>();
@Override
public Post save(PostDraft draft) {
if (draft == null) {
throw new IllegalArgumentException("draft is required");
}
var id = sequence.incrementAndGet();
var saved = Post.saved(id, draft);
store.put(id, saved);
return saved;
}
@Override
public Optional<Post> findById(long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<Post> findAll() {
return store.values().stream()
.sorted(Comparator.comparingLong(Post::id))
.toList();
}
@Override
public boolean existsByAuthorIdAndTitleAndPublishedOn(
String authorId,
String title,
LocalDate publishedOn
) {
return store.values().stream().anyMatch(post ->
post.authorId().equals(authorId)
&& post.title().equals(title)
&& post.publishedOn().equals(publishedOn));
}
}findAll은 내부 맵 뷰와 분리된 unmodifiable 목록을 반환합니다.
맵 뷰와 분리된 반환값은 저장 상태와의 결합을 끊고, Stream.toList()의 unmodifiable 계약은 호출자가 그 목록 자체를 수정하지 못하게 합니다.
다만 ConcurrentHashMap의 동시 순회 결과는 한 시점에 고정한 atomic snapshot이 아닙니다. ConcurrentHashMap과 AtomicLong은 맵의 개별 연산과 식별자 할당을 안전하게 만들 뿐, 서비스의 exists → save 전체를 원자적으로 묶지는 않습니다.
서비스의 사용 사례 순서
등록 요청을 별도 레코드로 받아 웹 DTO와 도메인의 결합을 끊습니다.
package board.application;
import java.time.LocalDate;
public record CreatePostCommand(
String authorId,
String title,
String content,
LocalDate publishedOn
) {}package board.application;
public final class DuplicatePostException extends RuntimeException {
public DuplicatePostException(String authorId, String title) {
super("post already exists: member=%s title=%s"
.formatted(authorId, title));
}
}package board.application;
public final class PostNotFoundException extends RuntimeException {
public PostNotFoundException(long id) {
super("post not found: " + id);
}
}package board.application;
import board.domain.PostDraft;
import board.domain.PostRepository;
import board.domain.Post;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
public final class PostService {
private final PostRepository repository;
private final Clock clock;
public PostService(PostRepository repository, Clock clock) {
this.repository = Objects.requireNonNull(
repository, "repository");
this.clock = Objects.requireNonNull(clock, "clock");
}
public Post register(CreatePostCommand command) {
Objects.requireNonNull(command, "command");
var draft = new PostDraft(
command.authorId(),
command.title(),
command.content(),
command.publishedOn());
var today = LocalDate.now(clock);
draft.validateDate(today);
if (repository.existsByAuthorIdAndTitleAndPublishedOn(
draft.authorId(), draft.title(), draft.publishedOn())) {
throw new DuplicatePostException(
draft.authorId(), draft.title());
}
return repository.save(draft);
}
public Post find(long id) {
return repository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
public List<Post> findAll() {
return repository.findAll();
}
}서비스는 “같은 회원·제목·작성일 조합은 한 번만 등록한다”는 현재 요구를 소유합니다.
메모리 구현의 맵 키 구조로 중복을 막지 않습니다.
날짜 게이트는 register가 저장소와 처음 상호작용하기 전에 실행됩니다. 반면 MemoryPostRepository.save(draft)를 직접 호출하는 경로는 이 시간 게이트를 증명하지 않습니다.
나중에 JDBC 저장소를 추가할 때는 서비스의 빠른 확인과 DB 고유 제약 조건을 함께 사용해 동시 요청까지 보호할 계획입니다. 이는 현재 메모리 구현의 보장이 아닙니다.
REGISTER CONTRACT · SUPPORTED APPLICATION PATH
시간 게이트를 통과해야 저장소 상호작용이 시작된다
PostService.register의 지원 경로를 기준으로 읽습니다. 구조 또는 미래 날짜 실패는 저장소에 닿지 않고, 중복은 조회 뒤 저장 전에 멈추며, 성공만 식별자 부여와 저장 전이까지 진행합니다.
PRIMARY FLOW · ORDER BEFORE STORAGE
명령에서 저장 상태까지
-
CreatePostCommand애플리케이션이 null 명령을 즉시 거부합니다. 생성 입력에는 식별자가 없습니다.
-
PostDraft구조 게이트도메인이 세 문자열의 null·blank, 정규화, 50·80·720 UTF-16 단위와 작성일 null을 검사합니다.
-
validateDate(today)애플리케이션이 주입된
Clock에서 요청당 한 번 계산한today를 전달합니다. 아직 저장소 호출은 없습니다. -
repository.exists정규화된 회원·제목·작성일 키로 순차 중복을 확인합니다. 중복이면 여기서 예외로 끝납니다.
-
repository.save중복이 아닐 때만 초안을 저장 adapter에 전달합니다.
-
식별자 →
Post.saved→ 저장 상태메모리 adapter가 양수
long식별자를 만들고,Postcompact constructor가 구조 규칙을 재검사한 뒤 보관합니다.
| 결과 | 멈추는 경계 | port 호출 |
|---|---|---|
| 구조·미래 실패 | PostDraft 또는 날짜 게이트 |
exists = 0 · save = 0 |
| 순차 중복 | repository.exists가 true |
exists = 1 · save = 0 |
| 등록 성공 | 중복 확인 뒤 저장 전이 | exists = 1 → save = 1 |
APPLICATION · ORDER
맥락과 호출 순서를 소유한다
PostService가 명령, Clock, 시간 게이트, 중복 조회, 저장 호출을 순서대로 지휘합니다.
DOMAIN · RULES
구조와 날짜 비교를 계산한다
PostDraft가 구조를 검사하고 날짜를 비교합니다. Post는 구조를 재검사하지만 시간 게이트를 자동 실행하지 않습니다.
REPOSITORY · ID + STORAGE
식별자와 보관을 맡는다
save, findById, findAll 계약을 구현합니다. 직접 save 호출은 선행 시간 게이트의 증거가 아닙니다.
저장소가 식별자를 부여한다는 말은 지원 경로의 책임 계약입니다. public record 생성자와 factory는 임의의 양수 식별자가 저장소에서 왔음을 접근 제어로 증명하지 않습니다.
저장소·서비스 단위 테스트
저장소 테스트는 식별자와 조회 계약만 확인합니다.
package board.infrastructure;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import board.domain.PostDraft;
class MemoryPostRepositoryTest {
private final MemoryPostRepository repository =
new MemoryPostRepository();
@Test
void 저장할_때_식별자를_부여하고_다시_조회한다() {
var draft = new PostDraft(
"member-1", "Spring", "Spring 빈 구성을 정리합니다.",
LocalDate.of(2026, 7, 13));
var saved = repository.save(draft);
assertThat(saved.id()).isPositive();
assertThat(repository.findById(saved.id())).contains(saved);
}
@Test
void 목록은_식별자_순서로_반환한다() {
var day = LocalDate.of(2026, 7, 13);
repository.save(new PostDraft(
"member-1", "Java", "Java 객체 모델을 정리합니다.", day));
repository.save(new PostDraft(
"member-1", "HTTP", "HTTP 요청 흐름을 정리합니다.", day));
assertThat(repository.findAll())
.extracting("title")
.containsExactly("Java", "HTTP");
}
}저장소 테스트의 repository.save(draft) 직접 호출은 adapter의 식별자·조회 계약만 확인합니다. 이 호출 자체는 PostService의 시간 게이트를 거쳤다는 증거가 아닙니다.
서비스 테스트는 PostRepository port를 Mockito double로 대체하고 고정 Clock을 주입합니다. 그래야 날짜 실패에서 저장소 상호작용이 0인지, 중복 확인과 저장이 어떤 순서로 호출되는지 구현체와 분리해 검증할 수 있습니다.
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import board.domain.Post;
import board.domain.PostDraft;
import board.domain.PostRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class PostServiceTest {
private final LocalDate today = LocalDate.of(2026, 7, 13);
private final Clock clock = Clock.fixed(
Instant.parse("2026-07-13T09:00:00Z"),
ZoneOffset.UTC);
private final PostRepository repository =
mock(PostRepository.class);
private final PostService service = new PostService(
repository, clock);
@Test
void 미래_작성일은_저장소와_상호작용하기_전에_거부한다() {
var command = new CreatePostCommand(
"member-1", "Spring", "Spring MVC를 정리합니다.",
today.plusDays(1));
assertThatThrownBy(() -> service.register(command))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("publishedOn must not be in the future");
verifyNoInteractions(repository);
}
@Test
void 정규화한_키가_중복이면_저장하지_않는다() {
var command = new CreatePostCommand(
" member-1 ", " Spring ",
" Spring MVC를 정리합니다. ", today);
when(repository.existsByAuthorIdAndTitleAndPublishedOn(
"member-1", "Spring", today)).thenReturn(true);
assertThatThrownBy(() -> service.register(command))
.isInstanceOf(DuplicatePostException.class)
.hasMessageContaining("member-1")
.hasMessageContaining("Spring");
verify(repository)
.existsByAuthorIdAndTitleAndPublishedOn(
"member-1", "Spring", today);
verify(repository, never()).save(any(PostDraft.class));
verifyNoMoreInteractions(repository);
}
@Test
void 중복_확인_후_저장한다() {
var command = new CreatePostCommand(
" member-1 ", " Spring ",
" Spring MVC를 정리합니다. ", today);
var draft = new PostDraft(
"member-1", "Spring",
"Spring MVC를 정리합니다.", today);
var saved = Post.saved(1, draft);
when(repository.existsByAuthorIdAndTitleAndPublishedOn(
"member-1", "Spring", today)).thenReturn(false);
when(repository.save(draft)).thenReturn(saved);
assertThat(service.register(command)).isEqualTo(saved);
var order = inOrder(repository);
order.verify(repository)
.existsByAuthorIdAndTitleAndPublishedOn(
"member-1", "Spring", today);
order.verify(repository).save(draft);
verifyNoMoreInteractions(repository);
}
}MemoryPostRepositoryTest > 저장할_때_식별자를_부여하고_다시_조회한다() PASSED
MemoryPostRepositoryTest > 목록은_식별자_순서로_반환한다() PASSED
PostServiceTest > 미래_작성일은_저장소와_상호작용하기_전에_거부한다() PASSED
PostServiceTest > 정규화한_키가_중복이면_저장하지_않는다() PASSED
PostServiceTest > 중복_확인_후_저장한다() PASSED정적 저장소의 테스트 오염
초기 구현에서 다음처럼 store와 sequence를 static으로 두기 쉽습니다.
private static final AtomicLong sequence = new AtomicLong();
private static final Map<Long, Post> store =
new ConcurrentHashMap<>();그러면 각 테스트가 새 리포지토리 객체를 만들어도 실제 상태는 공유됩니다.
첫 테스트만 실행하면 ID가 1이지만 전체 테스트 모음에서는 앞 테스트의 저장 횟수에 따라 3이나 7이 됩니다.
@AfterEach clear()로 증상을 가릴 수 있지만 운영에서도 리포지토리 인스턴스 경계가 무의미해집니다.
현재 구현처럼 상태를 인스턴스 필드에 두면 새 MemoryPostRepository 객체가 새 저장 상태를 가집니다. JUnit Jupiter의 기본 test-instance lifecycle도 테스트 메서드마다 이 문서의 repository 필드를 다시 만듭니다.
다음 문서에서 Spring의 기본 singleton scope는 해당 ApplicationContext 안에서 빈 정의당 한 인스턴스를 관리합니다. JVM 전체에 남는 static singleton과는 수명 경계가 다릅니다.
테스트는 필요할 때 직접 새 인스턴스를 만들 수 있으므로 운영 수명과 테스트 수명을 혼동하지 않습니다.
중복 검사의 경쟁 조건
exists 다음 save 사이에 다른 스레드가 같은 값을 저장할 수 있습니다.
ConcurrentHashMap과 AtomicLong을 사용해도 두 repository 호출을 하나의 원자적 연산으로 만들지는 못합니다. 따라서 메모리 예제는 순차 요청의 빠른 중복 피드백에는 충분하지만 동시 중복 무결성을 보장하지는 못합니다.
해결은 저장 기술 장에서 두 층으로 진행합니다.
- 서비스 검사는 사용자에게 빠르고 읽기 좋은 오류를 준다.
- 미래의 DB 고유 제약 조건은 마지막 순간의 동시 경쟁까지 막는다.
- 저장 adapter가 제약 조건 위반을
DuplicatePostException으로 변환한다.
서비스 검사만 믿거나 DB 예외만 그대로 노출하는 두 극단 모두 부족합니다.
현재 제한을 테스트 이름이나 코드 주석이 아니라 자습서에서 명시해 이후 개선 이유를 남깁니다.
EVIDENCE MATRIX · TESTED, CODED, AND DEFERRED
실행한 테스트가 보장하는 경계까지만 주장한다
adapter 계약과 application 호출 계약은 서로 다른 증거가 필요합니다. 실제 assertion·Mockito verification과 코드만 존재하는 경계, 연습 또는 미래 저장 기술로 미룬 경계를 분리합니다.
| 경계 | 현재 실행 증거 | 실제 보장 | 보장하지 않음 |
|---|---|---|---|
| 저장·조회 | 양수 ID와 같은 Post 재조회를 assertion합니다. |
메모리 adapter의 지원 저장 경로와 ID 조회 계약 | ID 기원의 접근 제어, 선행 시간 게이트 |
| 목록 순서 | 두 저장 결과를 ID 순서로 반환함을 assertion합니다. | 호출 시 관찰한 항목의 정렬된 분리 목록 | 수정 불가 테스트와 동시 순회의 atomic snapshot |
| 미래 날짜 | 정확한 예외와 verifyNoInteractions를 실행합니다. |
미래 command가 repository port보다 먼저 거부됨 | repository 직접 호출의 시간 검증 |
| 순차 중복 | 정규화 키의 exists 1회와 save 0회를 검증합니다. |
한 service 흐름의 중복 조기 거부 | 동시 요청 원자성, 현재 DB unique |
| 성공 순서 | Mockito InOrder로 exists → save를 검증합니다. |
application이 port를 호출하는 성공 순서 | Map 내부 구현과 public ID provenance |
| 연습 경계 | 현재 transcript에는 실행 결과가 없습니다. | 아직 없음 | 목록 수정 불가와 미존재 예외 변환 |
STATE LIFETIME · INSTANCE VS STATIC
상태 수명은 필드 소유자가 정한다
현재 instance: sequence와 store는 인스턴스 필드라 새 repository 객체가 새 상태를 가집니다.
static 반례: 객체를 다시 만들어도 이전 테스트의 ID와 게시글이 남습니다. Spring singleton은 JVM 전역 static이 아니라 해당 container의 빈 정의당 한 인스턴스입니다.
CONCURRENCY LIMIT · CHECK THEN ACT
thread-safe 부품이 업무 전이를 원자화하지는 않는다
AtomicLong과 ConcurrentHashMap은 ID 할당과 맵 연산을 안전하게 합니다. 그러나 두 요청의 exists → save는 교차할 수 있고, findAll도 한 시점 atomic snapshot이 아닙니다.
DB unique와 제약 위반 변환은 미래 persistence 계약이며 현재 메모리 구현의 보장이 아닙니다.
테스트 격리와 동시성 무결성은 다른 문제입니다. 인스턴스 필드는 테스트 간 static 누출을 막지만, 여러 요청 사이의 check-then-save 경쟁까지 막지는 않습니다.
연습 문제
findAll이 반환한 목록을 호출자가 수정하지 못한다는 테스트와, 존재하지 않는 id를 find했을 때 PostNotFoundException이 발생한다는 테스트를 추가하세요.
아래 해설의 두 테스트는 현재 테스트 결과 transcript에는 포함되지 않은 연습 코드입니다. 직접 추가하고 실행하기 전에는 실행 증거로 세지 않습니다.
해설 보기
Stream.toList()가 반환한 목록은 수정할 수 없습니다.
@Test
void 목록은_외부에서_수정할_수_없다() {
var repository = new MemoryPostRepository();
repository.save(new PostDraft(
"member-1", "Spring", "Spring MVC를 정리합니다.",
LocalDate.of(2026, 7, 13)));
assertThatThrownBy(() -> repository.findAll().clear())
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void 없는_게시글은_구체적인_예외로_알린다() {
assertThatThrownBy(() -> service.find(999))
.isInstanceOf(PostNotFoundException.class)
.hasMessage("post not found: 999");
}목록 불변성 테스트가 맵 구현 자체를 알아서는 안 됩니다.
테스트는 공개 계약인 반환 목록만 사용합니다.
미존재 테스트도 리포지토리의 Optional과 서비스의 예외 변환 책임을 구분합니다.
순수 Java 객체 그래프가 완성되었습니다.
다음 문서에서는 지금 테스트에서 직접 호출한 new MemoryPostRepository()와 new PostService(...)를 구성 클래스로 옮기고 Spring 컨테이너가 같은 인스턴스를 연결하는지 확인합니다.