본문 바로가기

저장소·서비스 테스트

메모리 PostRepository와 PostService를 순수 Java로 구현하고 저장 계약·중복 규칙·테스트 격리를 각각 검증합니다.

앞 글의 도메인 객체만으로는 게시글이 어디에도 남지 않습니다. 그렇다고 저장 코드를 서비스 안에 바로 넣으면, "게시글을 등록한다"는 사용 사례와 "맵이나 데이터베이스에 보관한다"는 기술 선택이 한 클래스 안에서 함께 바뀌게 됩니다. 그래서 이번 글에서는 일을 셋으로 나눕니다. PostRepository는 게시글에 식별자를 붙여 저장하고 조회하는 방법을 정하고, PostService는 날짜 검사와 중복 검사, 저장 호출의 순서를 이끌며, MemoryPostRepository는 개발하는 동안 쓸 구현 하나일 뿐입니다.

이번 글에서도 Spring은 아직 쓰지 않습니다. 평범한 생성자 호출만으로 객체들을 연결할 수 있어야, 다음 글에서 Spring 컨테이너가 무엇을 대신해 주는지가 분명하게 보입니다.


저장소 행동 규칙

인터페이스는 구현을 감추려는 장식이 아니라, 호출하는 쪽이 기대할 수 있는 행동을 정하는 도구입니다. 아래 PostRepository의 네 메서드는 저장, 식별자로 조회, 전체 조회, 중복 확인이며, 도메인 타입과 JDK 타입만 주고받습니다. 그래서 이 인터페이스는 저장 기술에 관한 import 없이 board.domain 패키지에 둘 수 있습니다.

src/main/java/board/domain/PostRepository.java
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을 씁니다.

src/main/java/board/infrastructure/MemoryPostRepository.java
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은 맵의 개별 연산과 식별자 할당을 안전하게 만들 뿐, 서비스가 하는 "있는지 확인한 뒤 저장" 전체를 하나로 묶어 주지는 않습니다. 이 문제는 이 글의 뒷부분에서 다시 다룹니다.


서비스의 사용 사례 순서

등록 요청은 별도 레코드로 받아, 나중에 만들 웹 요청 객체와 도메인이 서로 묶이지 않게 합니다. 실패를 알리는 예외 두 개도 함께 만듭니다.

src/main/java/board/application/CreatePostCommand.java
package board.application;

import java.time.LocalDate;

public record CreatePostCommand(
        String authorId,
        String title,
        String content,
        LocalDate publishedOn
) {}
src/main/java/board/application/DuplicatePostException.java
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));
    }
}
src/main/java/board/application/PostNotFoundException.java
package board.application;

public final class PostNotFoundException extends RuntimeException {
    public PostNotFoundException(long id) {
        super("post not found: " + id);
    }
}
src/main/java/board/application/PostService.java
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 부여로 이어져 호출 수 변화가 중요하다.

PostService register가 CreatePostCommand를 PostDraft로 만들고 구조와 날짜를 검증한 뒤 중복을 조회하고 저장소가 식별자를 부여해 Post.saved로 저장 상태를 만드는 지원 경로와 세 결과 분기를 설명합니다.

REGISTER CONTRACT · SUPPORTED APPLICATION PATH

시간 게이트를 통과해야 저장소 상호작용이 시작된다

PostService.register의 지원 경로를 기준으로 읽습니다. 구조 또는 미래 날짜 실패는 저장소에 닿지 않고, 중복은 조회 뒤 저장 전에 멈추며, 성공만 식별자 부여와 저장 전이까지 진행합니다.

저장소에 도달하는 조건과 세 결과 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다. 유효 날짜 유효 구조 실패 실패 중복 없음 명령 → 초안 구조 검사 · 정규화 유효해야 다음 단계 시간 게이트 Clock → today 미래 날짜 비교 입력 거부 구조 또는 날짜 실패 exists 0 · save 0 중복 조회 exists 정규화 키 확인 호출 1회 중복 거부 exists = true save 0회 저장 save exists = false일 때 양수 ID · Post.saved exists 1 · save 1
저장소에 도달하는 조건과 세 결과 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다. 유효 날짜 유효 없음 중복 명령 → 초안 구조 검사 · 정규화 유효해야 다음 단계 시간 게이트 Clock → today 미래 날짜 비교 중복 조회 exists 정규화 키 확인 호출 1회 저장 save exists = false일 때 양수 ID · Post.saved exists 1 · save 1 중복 거부 exists = true save 0회 입력 거부 구조 또는 날짜 실패 exists 0 · save 0

PRIMARY FLOW · ORDER BEFORE STORAGE

명령에서 저장 상태까지

  1. CreatePostCommand

    애플리케이션이 null 명령을 즉시 거부합니다. 생성 입력에는 식별자가 없습니다.

  2. PostDraft 구조 게이트

    도메인이 세 문자열의 null·blank, 정규화, 50·80·720 UTF-16 단위와 작성일 null을 검사합니다.

  3. validateDate(today)

    애플리케이션이 주입된 Clock에서 요청당 한 번 계산한 today를 전달합니다. 아직 저장소 호출은 없습니다.

  4. repository.exists

    정규화된 회원·제목·작성일 키로 순차 중복을 확인합니다. 중복이면 여기서 예외로 끝납니다.

  5. repository.save

    중복이 아닐 때만 초안을 저장 adapter에 전달합니다.

  6. 식별자 → Post.saved → 저장 상태

    메모리 adapter가 양수 long 식별자를 만들고, Post compact constructor가 구조 규칙을 재검사한 뒤 보관합니다.

PostService.register 결과별 저장소 상호작용
결과 멈추는 경계 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 부여로 이어져 호출 수 변화가 중요하다.

PostService register가 CreatePostCommand를 PostDraft로 만들고 구조와 날짜를 검증한 뒤 중복을 조회하고 저장소가 식별자를 부여해 Post.saved로 저장 상태를 만드는 지원 경로와 세 결과 분기를 설명합니다.

REGISTER CONTRACT · SUPPORTED APPLICATION PATH

시간 게이트를 통과해야 저장소 상호작용이 시작된다

PostService.register의 지원 경로를 기준으로 읽습니다. 구조 또는 미래 날짜 실패는 저장소에 닿지 않고, 중복은 조회 뒤 저장 전에 멈추며, 성공만 식별자 부여와 저장 전이까지 진행합니다.

저장소에 도달하는 조건과 세 결과 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다. 유효 날짜 유효 구조 실패 실패 중복 없음 명령 → 초안 구조 검사 · 정규화 유효해야 다음 단계 시간 게이트 Clock → today 미래 날짜 비교 입력 거부 구조 또는 날짜 실패 exists 0 · save 0 중복 조회 exists 정규화 키 확인 호출 1회 중복 거부 exists = true save 0회 저장 save exists = false일 때 양수 ID · Post.saved exists 1 · save 1
저장소에 도달하는 조건과 세 결과 명령의 구조와 날짜가 유효해야 exists를 호출합니다. 구조 또는 날짜 실패는 저장소 호출 없이 끝납니다. 중복은 exists 한 번 뒤 save 없이 끝나고 중복이 아닐 때만 save와 식별자 부여가 진행됩니다. 이 순차 경로는 동시 요청의 exists-save 원자성을 보장하지 않습니다. 유효 날짜 유효 없음 중복 명령 → 초안 구조 검사 · 정규화 유효해야 다음 단계 시간 게이트 Clock → today 미래 날짜 비교 중복 조회 exists 정규화 키 확인 호출 1회 저장 save exists = false일 때 양수 ID · Post.saved exists 1 · save 1 중복 거부 exists = true save 0회 입력 거부 구조 또는 날짜 실패 exists 0 · save 0

PRIMARY FLOW · ORDER BEFORE STORAGE

명령에서 저장 상태까지

  1. CreatePostCommand

    애플리케이션이 null 명령을 즉시 거부합니다. 생성 입력에는 식별자가 없습니다.

  2. PostDraft 구조 게이트

    도메인이 세 문자열의 null·blank, 정규화, 50·80·720 UTF-16 단위와 작성일 null을 검사합니다.

  3. validateDate(today)

    애플리케이션이 주입된 Clock에서 요청당 한 번 계산한 today를 전달합니다. 아직 저장소 호출은 없습니다.

  4. repository.exists

    정규화된 회원·제목·작성일 키로 순차 중복을 확인합니다. 중복이면 여기서 예외로 끝납니다.

  5. repository.save

    중복이 아닐 때만 초안을 저장 adapter에 전달합니다.

  6. 식별자 → Post.saved → 저장 상태

    메모리 adapter가 양수 long 식별자를 만들고, Post compact constructor가 구조 규칙을 재검사한 뒤 보관합니다.

PostService.register 결과별 저장소 상호작용
결과 멈추는 경계 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이 식별자 순으로 정렬하지 않으면 이 테스트는 실패할 수 있습니다.

src/test/java/board/infrastructure/MemoryPostRepositoryTest.java
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에 들어 있습니다.

src/test/java/board/application/PostServiceTest.java
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가 레코드이므로 같은 값을 가진 초안이면 같은 호출로 인정됩니다.

MemoryPostRepository와 Mockito PostRepository port double로 실제 실행한 테스트 증거를 미실행 연습과 구분하고, 인스턴스 상태와 static 누출 및 thread-safe 자료구조와 비원자적 중복 검사의 차이를 설명합니다.

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 컨테이너가 같은 인스턴스를 연결하는지 확인합니다.