저장소·서비스 테스트
메모리 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을 반환합니다.
호출자는 구현이 순서, 동일성 열, UUID 중 무엇으로 식별자를 만들었는지 몰라도 됩니다.
findById가 Optional을 반환하는 것은 미존재가 정상적인 조회 결과일 수 있음을 타입에 드러냅니다.
메모리 구현은 테스트와 첫 실행에 충분하지만 동시 요청도 망가지지 않도록 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) {
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은 내부 맵 뷰를 노출하지 않고 새 불변 목록을 반환합니다.
호출자가 목록을 수정해 저장 상태를 우회 변경하는 일을 막습니다.
서비스의 사용 사례 순서
등록 요청을 별도 레코드로 받아 웹 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;
public final class PostService {
private final PostRepository repository;
private final Clock clock;
public PostService(PostRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
public Post register(CreatePostCommand command) {
var draft = new PostDraft(
command.authorId(),
command.title(),
command.content(),
command.publishedOn());
draft.validateDate(LocalDate.now(clock));
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();
}
}서비스는 “같은 회원·제목·작성일 조합은 한 번만 등록한다”는 현재 요구를 소유합니다.
메모리 구현의 맵 키 구조로 중복을 막지 않습니다.
나중에 JDBC 저장소를 추가할 때는 서비스의 빠른 확인과 DB 고유 제약 조건을 함께 사용해 동시 요청까지 보호합니다.
저장소·서비스 단위 테스트
저장소 테스트는 식별자와 조회 계약만 확인합니다.
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");
}
}서비스 테스트는 고정 Clock을 주입해 날짜와 중복 정책을 확인합니다.
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import board.infrastructure.MemoryPostRepository;
class PostServiceTest {
private final Clock clock = Clock.fixed(
Instant.parse("2026-07-13T09:00:00Z"),
ZoneOffset.UTC);
private final PostService service = new PostService(
new MemoryPostRepository(), clock);
@Test
void 유효한_게시글을_등록한다() {
var saved = service.register(new CreatePostCommand(
"member-1", "Spring", "Spring MVC를 정리합니다.",
LocalDate.of(2026, 7, 13)));
assertThat(saved.id()).isEqualTo(1);
assertThat(service.find(saved.id())).isEqualTo(saved);
}
@Test
void 같은_날_같은_제목은_중복_등록하지_않는다() {
var command = new CreatePostCommand(
"member-1", "Spring", "Spring MVC를 정리합니다.",
LocalDate.of(2026, 7, 13));
service.register(command);
assertThatThrownBy(() -> service.register(command))
.isInstanceOf(DuplicatePostException.class)
.hasMessageContaining("member-1")
.hasMessageContaining("Spring");
}
}MemoryPostRepositoryTest > 저장할_때_식별자를_부여하고_다시_조회한다() PASSED
MemoryPostRepositoryTest > 목록은_식별자_순서로_반환한다() 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()로 증상을 가릴 수 있지만 운영에서도 리포지토리 인스턴스 경계가 무의미해집니다.
현재 구현처럼 상태를 인스턴스 필드에 두면 객체를 새로 만드는 것이 곧 격리입니다.
다음 문서에서 Spring은 이 리포지토리 인스턴스를 기본 싱글톤 빈으로 한 개만 관리합니다.
테스트는 필요할 때 직접 새 인스턴스를 만들 수 있으므로 운영 수명과 테스트 수명을 혼동하지 않습니다.
중복 검사의 경쟁 조건
exists 다음 save 사이에 다른 스레드가 같은 값을 저장할 수 있습니다.
메모리 예제의 첫 기능을 이해하는 데는 충분하지만 운영 무결성을 보장하지는 못합니다.
해결은 저장 기술 장에서 두 층으로 진행합니다.
- 서비스 검사는 사용자에게 빠르고 읽기 좋은 오류를 준다.
- DB 고유 제약 조건은 마지막 순간의 동시 경쟁까지 막는다.
- 제약 조건 위반을
DuplicatePostException으로 변환한다.
서비스 검사만 믿거나 DB 예외만 그대로 노출하는 두 극단 모두 부족합니다.
현재 제한을 테스트 이름이나 코드 주석이 아니라 자습서에서 명시해 이후 개선 이유를 남깁니다.
연습 문제
findAll이 반환한 목록을 호출자가 수정하지 못한다는 테스트와, 존재하지 않는 id를 find했을 때 PostNotFoundException이 발생한다는 테스트를 추가하세요.
해설 보기
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 컨테이너가 같은 인스턴스를 연결하는지 확인합니다.