저장소·서비스 테스트
메모리 PostRepository와 PostService를 순수 Java로 구현하고 저장 계약·중복 규칙·테스트 격리를 각각 검증합니다.
앞 글의 도메인 객체만으로는 게시글이 어디에도 남지 않습니다. 그렇다고 저장 코드를 서비스 안에 바로 넣으면, "게시글을 등록한다"는 사용 사례와 "맵이나 데이터베이스에 보관한다"는 기술 선택이 한 클래스 안에서 함께 바뀌게 됩니다. 그래서 이번 글에서는 일을 셋으로 나눕니다. PostRepository는 게시글에 식별자를 붙여 저장하고 조회하는 방법을 정하고, PostService는 날짜 검사와 중복 검사, 저장 호출의 순서를 이끌며, MemoryPostRepository는 개발하는 동안 쓸 구현 하나일 뿐입니다.
이번 글에서도 Spring은 아직 쓰지 않습니다. 평범한 생성자 호출만으로 객체들을 연결할 수 있어야, 다음 글에서 Spring 컨테이너가 무엇을 대신해 주는지가 분명하게 보입니다.
저장소 행동 규칙
인터페이스는 구현을 감추려는 장식이 아니라, 호출하는 쪽이 기대할 수 있는 행동을 정하는 도구입니다. 아래 PostRepository의 네 메서드는 저장, 식별자로 조회, 전체 조회, 중복 확인이며, 도메인 타입과 JDK 타입만 주고받습니다. 그래서 이 인터페이스는 저장 기술에 관한 import 없이 board.domain 패키지에 둘 수 있습니다.
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로 바꾸려면 Post.id와 이 인터페이스의 타입을 함께 바꿔야 하므로, 그것은 구현만 갈아 끼워서 할 수 있는 변경이 아닙니다. 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) {
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은 내부 맵의 뷰를 그대로 내보내지 않고, 스트림으로 정렬한 새 목록을 돌려줍니다. 그래서 반환된 목록은 저장 상태와 분리되어 있고, Stream.toList()가 만든 목록은 수정할 수 없으므로 호출하는 쪽이 목록 자체를 바꾸지도 못합니다. 다만 ConcurrentHashMap을 순회하는 동안 다른 스레드가 저장하면 그 결과가 보일 수도 안 보일 수도 있어서, 이 목록이 한 순간의 상태를 정확히 찍은 스냅샷(snapshot)은 아닙니다. 또 ConcurrentHashMap과 AtomicLong은 맵의 개별 연산과 식별자 할당을 안전하게 만들 뿐, 서비스가 하는 "있는지 확인한 뒤 저장" 전체를 하나로 묶어 주지는 않습니다. 이 문제는 이 글의 뒷부분에서 다시 다룹니다.
서비스의 사용 사례 순서
등록 요청은 별도 레코드로 받아, 나중에 만들 웹 요청 객체와 도메인이 서로 묶이지 않게 합니다. 실패를 알리는 예외 두 개도 함께 만듭니다.
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는 명령을 PostDraft로 바꾸며 구조를 검사하고, 주입받은 Clock으로 오늘 날짜를 한 번 계산해 날짜를 검사한 다음에야 저장소를 처음 호출합니다. 반대로 MemoryPostRepository.save(draft)를 직접 호출하면 이 날짜 검사를 거치지 않으므로, 저장소에 들어 있다는 사실만으로 날짜 검사를 통과했다고 말할 수는 없습니다.
게시글 등록 지원 경로와 저장소 상호작용 분기
구조·날짜 실패는 저장소 0회, 중복은 exists만, 정상은 save와 ID 부여로 이어져 호출 수 변화가 중요하다.
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 |
PostService.register 결과별 저장소 상호작용
구조·미래 실패
멈추는 경계: PostDraft 또는 날짜 게이트
port 호출: exists = 0 · save = 0
순차 중복
멈추는 경계: repository.exists가 true
port 호출: exists = 1 · save = 0
등록 성공
멈추는 경계: 중복 확인 뒤 저장 전이
port 호출: exists = 1 → save = 1
APPLICATION · ORDER
맥락과 호출 순서를 소유한다
PostService가 명령, Clock, 시간 게이트, 중복 조회, 저장 호출을 순서대로 지휘합니다.
DOMAIN · RULES
구조와 날짜 비교를 계산한다
PostDraft가 구조를 검사하고 날짜를 비교합니다. Post는 구조를 재검사하지만 시간 게이트를 자동 실행하지 않습니다.
REPOSITORY · ID + STORAGE
식별자와 보관을 맡는다
save, findById, findAll 계약을 구현합니다. 직접 save 호출은 선행 시간 게이트의 증거가 아닙니다.
저장소가 식별자를 부여한다는 말은 지원 경로의 책임 계약입니다. public record 생성자와 factory는 임의의 양수 식별자가 저장소에서 왔음을 접근 제어로 증명하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 등록 지원 경로와 저장소 상호작용 분기 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 구조·날짜 검사 명령의 구조와 날짜를 먼저 확인합니다. | 실패: 저장소 호출 0 · 정상: exists 진행 | 잘못된 명령이 저장소 전에 끝납니다. |
| 중복 확인 유효한 명령에 exists를 한 번 호출합니다. | 중복: exists 1·save 0 · 정상: save 가능 | 중복 판정이 저장 여부를 결정합니다. |
| 저장과 ID 중복 아닌 명령만 save하고 식별자를 받습니다. | 저장: save · 산출물: ID · 원자성: exists-save 별도 | 정상 입력에 저장 결과가 생깁니다. |
게시글 등록 지원 경로와 저장소 상호작용 분기
구조·날짜 실패는 저장소 0회, 중복은 exists만, 정상은 save와 ID 부여로 이어져 호출 수 변화가 중요하다.
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 |
PostService.register 결과별 저장소 상호작용
구조·미래 실패
멈추는 경계: PostDraft 또는 날짜 게이트
port 호출: exists = 0 · save = 0
순차 중복
멈추는 경계: repository.exists가 true
port 호출: exists = 1 · save = 0
등록 성공
멈추는 경계: 중복 확인 뒤 저장 전이
port 호출: exists = 1 → save = 1
APPLICATION · ORDER
맥락과 호출 순서를 소유한다
PostService가 명령, Clock, 시간 게이트, 중복 조회, 저장 호출을 순서대로 지휘합니다.
DOMAIN · RULES
구조와 날짜 비교를 계산한다
PostDraft가 구조를 검사하고 날짜를 비교합니다. Post는 구조를 재검사하지만 시간 게이트를 자동 실행하지 않습니다.
REPOSITORY · ID + STORAGE
식별자와 보관을 맡는다
save, findById, findAll 계약을 구현합니다. 직접 save 호출은 선행 시간 게이트의 증거가 아닙니다.
저장소가 식별자를 부여한다는 말은 지원 경로의 책임 계약입니다. public record 생성자와 factory는 임의의 양수 식별자가 저장소에서 왔음을 접근 제어로 증명하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 등록 지원 경로와 저장소 상호작용 분기 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 구조·날짜 검사 명령의 구조와 날짜를 먼저 확인합니다. | 실패: 저장소 호출 0 · 정상: exists 진행 | 잘못된 명령이 저장소 전에 끝납니다. |
| 중복 확인 유효한 명령에 exists를 한 번 호출합니다. | 중복: exists 1·save 0 · 정상: save 가능 | 중복 판정이 저장 여부를 결정합니다. |
| 저장과 ID 중복 아닌 명령만 save하고 식별자를 받습니다. | 저장: save · 산출물: ID · 원자성: exists-save 별도 | 정상 입력에 저장 결과가 생깁니다. |
그림은 register의 결과가 세 갈래로 나뉜다는 것을 보여 줍니다. 구조나 날짜 검사에서 실패하면 저장소를 한 번도 호출하지 않고(exists 0회, save 0회) 끝나고, 같은 글이 이미 있으면 exists를 한 번 호출한 뒤 save 없이 끝납니다. 중복이 아닐 때만 exists 한 번 뒤에 save가 호출되어, 저장소가 양수 식별자를 만들고 Post.saved로 저장 상태를 만듭니다. 이 순서는 요청이 하나씩 들어올 때의 이야기이며, 동시에 들어온 두 요청 사이에서 exists와 save가 끼어드는 문제까지 막지는 않습니다.
저장소·서비스 단위 테스트
저장소 테스트는 식별자를 붙이는 일과 조회만 확인합니다. 둘째 테스트의 extracting("title")은 목록의 각 Post에서 title 값만 꺼내고, containsExactly는 그 값들의 개수와 순서가 정확히 같을 때만 통과합니다. 그래서 findAll이 식별자 순으로 정렬하지 않으면 이 테스트는 실패할 수 있습니다.
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)를 직접 호출하므로 메모리 어댑터가 식별자를 붙이고 다시 찾아 주는지만 확인할 뿐, PostService의 날짜 검사를 거쳤는지는 확인하지 않습니다.
서비스 테스트는 PostRepository를 Mockito로 만든 테스트 대역(test double)로 바꾸고, 고정된 Clock을 넣습니다. 그래야 날짜가 틀렸을 때 저장소를 한 번도 호출하지 않는지, 중복 확인과 저장이 어떤 순서로 호출되는지를 실제 저장소 구현과 상관없이 확인할 수 있습니다. Mockito는 Java 25와 Spring Boot의 build.gradle에 넣은 spring-boot-starter-webmvc-test가 가져오는 spring-boot-starter-test에 들어 있습니다.
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);
}
}./gradlew test --tests 'board.infrastructure*' --tests 'board.application*'다섯 테스트가 모두 통과하면 BUILD SUCCESSFUL로 끝납니다. 첫 서비스 테스트의 verifyNoInteractions는 미래 날짜의 명령이 저장소에 닿기 전에 거부되었다는 것을 확인합니다. 둘째 테스트는 앞뒤에 공백이 있는 명령을 보냈는데도 exists가 "member-1"과 "Spring"으로 호출되었는지 확인하므로, 서비스가 정규화한 값으로 중복을 찾는다는 것까지 알 수 있습니다. 셋째 테스트의 inOrder는 exists가 save보다 먼저 호출되었다는 순서를 확인하며, save에 넘긴 draft가 레코드이므로 같은 값을 가진 초안이면 같은 호출로 인정됩니다.
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에는 실행 결과가 없습니다. | 아직 없음 | 목록 수정 불가와 미존재 예외 변환 |
현재 테스트 증거와 보장 한계
저장·조회
현재 실행 증거: 양수 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 경쟁까지 막지는 않습니다.
그림의 표는 각 테스트가 확인한 것과 확인하지 않은 것을 나란히 보여 줍니다. 예를 들어 저장·조회 테스트는 메모리 어댑터가 양수 식별자를 붙이고 같은 Post를 다시 찾는다는 것은 확인하지만, 그 식별자가 반드시 저장소에서 왔는지나 날짜 검사를 먼저 거쳤는지는 확인하지 않습니다. 순차 중복 테스트도 한 번의 서비스 흐름에서 중복을 일찍 거부한다는 것만 확인할 뿐, 동시에 들어온 요청까지 막는다는 뜻은 아닙니다. 표의 마지막 줄처럼, 연습 문제의 두 테스트는 직접 추가해 실행하기 전까지는 확인된 것으로 세지 않습니다. 그림 아래쪽의 두 상자는 이어지는 두 절의 내용입니다.
정적 저장소의 테스트 오염
처음 구현할 때 다음처럼 store와 sequence를 static으로 두기 쉽습니다. 애플리케이션 전체에 저장소가 하나여야 한다는 생각에서 출발하면, 객체 대신 클래스에 상태를 두는 static이 자연스러워 보이기 때문입니다. 아래 코드에서 앞의 구현과 결정적으로 다른 곳은 두 필드에 붙은 static입니다.
private static final AtomicLong sequence = new AtomicLong();
private static final ConcurrentHashMap<Long, Post> store =
new ConcurrentHashMap<>();이렇게 하면 테스트마다 새 저장소 객체를 만들어도 실제 상태는 모든 객체가 함께 씁니다. 저장 테스트 하나만 실행하면 식별자가 1이지만, 전체 테스트를 한꺼번에 실행하면 앞선 테스트가 몇 번 저장했느냐에 따라 3이 되기도 하고 7이 되기도 합니다. @AfterEach에서 맵을 비우면 증상은 감출 수 있지만, 운영 코드에서도 저장소 객체를 여러 개 만드는 의미가 사라집니다.
지금 구현처럼 상태를 인스턴스 필드에 두면 새 MemoryPostRepository 객체는 언제나 새 상태로 시작합니다. JUnit Jupiter도 기본적으로 테스트 메서드마다 테스트 클래스의 인스턴스를 새로 만들므로, 위 테스트의 repository 필드는 메서드마다 새 객체입니다. 다음 글에서 볼 Spring 빈의 기본 스코프(scope), 곧 싱글톤(singleton)도 이와 다릅니다. Spring 싱글톤은 JVM 전체에 하나뿐인 static 변수가 아니라, 한 ApplicationContext 안에서 빈 정의 하나당 인스턴스를 하나 두는 것입니다. 그래서 테스트는 필요할 때 직접 새 인스턴스를 만들 수 있고, 운영에서의 수명과 테스트에서의 수명이 섞이지 않습니다.
중복 검사의 경쟁 조건
exists를 호출한 뒤 save를 호출하기 전의 짧은 틈에 다른 스레드가 같은 글을 저장할 수 있습니다. 이렇게 "확인한 다음 행동하는(check-then-act)" 코드는 ConcurrentHashMap과 AtomicLong을 써도 두 호출 사이가 하나의 원자적 연산으로 묶이지 않습니다. 따라서 이 메모리 예제는 요청이 하나씩 들어올 때 중복을 빨리 알려 주는 데는 충분하지만, 동시에 들어온 중복 요청까지 막아 주지는 않습니다.
이 문제는 데이터베이스를 다루는 장에서 해결합니다. 서비스의 확인은 그대로 두어 사용자에게 빠르고 읽기 쉬운 오류를 주고, 데이터베이스의 고유 제약 조건(unique constraint)이 마지막 순간의 동시 경쟁을 막게 한 뒤, 저장 어댑터가 제약 조건 위반을 DuplicatePostException으로 바꿉니다. 서비스의 확인만 믿는 것도, 데이터베이스 예외를 그대로 사용자에게 보여 주는 것도 충분하지 않습니다. 지금은 이 한계를 알고 넘어가는 것으로 충분합니다.
정리
PostRepository는 식별자를 붙여 저장하고 조회하는 방법을 정하고,MemoryPostRepository는 그 구현 하나일 뿐입니다.PostService는 구조 검사, 날짜 검사, 중복 확인, 저장 순서로 등록을 이끌며, 앞 단계에서 실패하면 저장소를 호출하지 않습니다.- 서비스 테스트는 저장소를 Mockito로 만든 테스트 대역으로 바꾸어, 호출 여부와 순서를 실제 구현과 상관없이 확인합니다.
- 저장 상태를
static필드에 두면 테스트끼리 상태가 섞이므로 인스턴스 필드에 둡니다. Spring 싱글톤은 컨테이너 하나에 인스턴스 하나라는 뜻이며static과 다릅니다. - 스레드 안전한 자료구조를 써도 "확인한 뒤 저장"이 원자적으로 묶이지는 않으므로, 동시 요청의 중복은 데이터베이스의 고유 제약 조건으로 막습니다.
연습 문제
1. 수정할 수 없는 목록과 없는 게시글
findAll이 돌려준 목록을 호출하는 쪽에서 수정할 수 없다는 테스트와, 없는 id로 find를 호출하면 PostNotFoundException이 발생한다는 테스트를 추가하세요.
해설 보기
첫 테스트는 MemoryPostRepositoryTest에, 둘째 테스트는 PostServiceTest에 넣습니다. 둘째 테스트에는 assertThatThrownBy 정적 임포트가 이미 있고, 첫 테스트를 넣는 쪽에는 새로 추가해야 합니다.
@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");
}Stream.toList()가 돌려준 목록은 수정할 수 없으므로 clear()가 UnsupportedOperationException을 던집니다. 이 테스트는 내부의 맵을 들여다보지 않고, 공개된 반환 목록만으로 확인한다는 점이 중요합니다. 둘째 테스트에서는 따로 설정하지 않은 Mockito 테스트 대역의 findById가 Optional을 돌려주는 메서드에 대해 빈 Optional을 돌려주므로, 서비스가 그것을 PostNotFoundException으로 바꾸는지 확인할 수 있습니다. 저장소는 "없음"을 Optional로 알리고, 그것을 예외로 바꾸는 일은 서비스가 맡는다는 구분이 테스트에 드러납니다.
이제 Spring 없이도 동작하는 객체들이 준비되었습니다. 다음 글 빈 등록과 생성자 주입에서는 테스트에서 직접 호출한 new MemoryPostRepository()와 new PostService(...)를 구성 클래스로 옮기고, Spring 컨테이너가 같은 인스턴스를 연결하는지 확인합니다.