본문으로 건너뛰기

안동민 개발노트

본문 시작

게시판 도메인 모델

게시글의 생성 규칙을 Java 25 레코드와 테스트로 표현해 웹이나 저장 기술 없이도 업무 불변식을 실행 가능하게 만듭니다.

게시판의 첫 기능은 “회원이 제목과 본문을 가진 게시글을 작성한다”입니다.

짧은 문장이지만 코드로 옮기기 전에 모호함을 제거해야 합니다.

제목과 본문은 실제 문자열로 모델링하고, 길이는 문자열에서 계산합니다.

길이 숫자만 저장하면 정작 화면에 보여 줄 본문을 잃기 때문입니다.

  • authorId, title, content는 앞뒤 공백을 제거합니다.
  • 회원 식별자는 String.length() 기준 1~50이어야 합니다.
  • 제목은 String.length() 기준 1~80이어야 합니다.
  • 본문은 String.length() 기준 1~720이어야 합니다.
  • 작성일은 null일 수 없습니다.
  • 미래 작성일은 애플리케이션 사용 사례가 전달한 기준 날짜로 거부합니다.
  • 생성 입력에는 식별자가 없고, 저장 상태의 Post에는 양수 식별자가 필요합니다.

컨트롤러의 if 문으로만 이 규칙을 지키면 배치 입력이나 테스트 픽스처가 다른 경로로 도메인을 만들 때 검증을 건너뜁니다.

문자열 정규화·길이와 날짜의 null 여부처럼 입력 자체만으로 판단할 수 있는 구조 규칙은 순수 Java 객체에 둡니다.

반면 “미래인가”는 today라는 외부 기준이 필요하므로 애플리케이션 사용 사례가 시간 게이트를 반드시 호출해야 합니다. PostDraft를 생성했다는 사실만으로 시간 규칙까지 자동 보장되지는 않습니다.

신뢰하지 않은 생성 값이 PostDraft 생성자의 구조 검증과 정규화를 거친 뒤, 애플리케이션 사용 사례가 today를 전달하는 시간 게이트를 통과하고, 다음 문서에서 구현할 저장소가 양수 식별자를 결합해 Post로 전이하는 계약을 설명합니다.

DOMAIN TRANSITION CONTRACT · CURRENT + REQUIRED + PLANNED

구조 게이트와 시간 게이트를 모두 지난 초안만 저장 전이로 보낸다

PostDraft 생성자는 입력 자체의 구조만 보장합니다. 미래 날짜 판단은 애플리케이션 사용 사례가 호출해야 하는 별도 시간 게이트이며, 저장소의 식별자 부여는 다음 문서에서 구현할 전이 계약입니다.

PRIMARY FLOW · ORDER IS THE CONTRACT

생성 값에서 저장 상태까지

  1. 신뢰 전 생성 값

    authorId, title, content, publishedOn을 받으며 id는 받지 않습니다.

  2. PostDraft 구조 게이트

    세 문자열의 null·blank를 거부하고 strip한 뒤, String.length()가 각각 50·80·720 이하인지 확인합니다. 작성일의 null도 거부합니다.

  3. 애플리케이션 시간 게이트

    Clock에서 계산한 todayvalidateDate(today)에 전달합니다. 생성자가 자동으로 호출하지 않습니다.

  4. 저장소 식별자 전이

    다음 문서의 저장소가 앞선 게이트 뒤에 양수 식별자를 부여하고 Post.saved 전이를 호출합니다.

  5. 저장 상태 Post

    public 생성자도 PostDraft의 구조 규칙을 재사용합니다. 다만 임의의 양수 id 출처와 선행 시간 게이트는 스스로 증명하지 않습니다.

DOMAIN · IMPLEMENTED HERE

규칙을 계산한다

PostDraft가 문자열 구조를 검사하고, 전달받은 today와 작성일을 비교합니다.

APPLICATION · REQUIRED CALL ORDER

시간 맥락과 순서를 제공한다

사용 사례가 Clock으로 today를 만들고 저장소보다 먼저 시간 게이트를 호출해야 합니다.

REPOSITORY · PLANNED IN CH1-4

식별자를 결합한다

저장소 기원은 현재 public factory가 증명하지 않습니다. 다음 문서의 경계와 테스트가 그 호출 순서를 소유합니다.

현재 증명된 것은 domain의 구조 검사와 날짜 비교 로직입니다. application 호출 순서와 repository 식별자 부여는 명시적 계약이며, 후속 사용 사례 테스트로 실행 증거를 추가해야 합니다.


생성 요청과 저장 모델

클라이언트가 보내는 값과 저장소가 부여하는 값은 수명이 다릅니다.

생성 요청에는 아직 id가 없으므로 PostDraft가 입력을 표현하고, Post는 양수 식별자가 결합된 저장 상태를 표현합니다.

src/main/java/board/domain/PostDraft.java
package board.domain;

import java.time.LocalDate;

public record PostDraft(
        String authorId,
        String title,
        String content,
        LocalDate publishedOn
) {
    public PostDraft {
        authorId = requireText(authorId, "authorId", 50);
        title = requireText(title, "title", 80);
        content = requireText(content, "content", 720);
        if (publishedOn == null) {
            throw new IllegalArgumentException("publishedOn is required");
        }
    }

    public void validateDate(LocalDate today) {
        if (today == null) {
            throw new IllegalArgumentException("today is required");
        }
        if (publishedOn.isAfter(today)) {
            throw new IllegalArgumentException(
                    "publishedOn must not be in the future");
        }
    }

    private static String requireText(
            String value,
            String field,
            int maxUtf16Units
    ) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " is required");
        }
        var normalized = value.strip();
        if (normalized.length() > maxUtf16Units) {
            throw new IllegalArgumentException(
                    field + " must be at most " + maxUtf16Units
                            + " UTF-16 code units");
        }
        return normalized;
    }
}

컴팩트 생성자 안에서 레코드 컴포넌트에 정규화한 값을 다시 대입하면 최종 필드에는 공백이 제거된 문자열이 저장됩니다.

이 생성자가 보장하는 것은 세 문자열의 정규화·길이와 publishedOn의 null 여부까지입니다.

publishedOn의 미래 여부는 시스템 날짜에 따라 달라지므로 생성자 안에서 LocalDate.now()를 직접 부르지 않습니다.

애플리케이션 사용 사례가 Clock으로 기준 날짜를 계산하고 validateDate(today)를 반드시 호출합니다. 사용 사례 테스트도 같은 시간 게이트가 저장소 호출보다 먼저 실행되는지 확인해야 합니다.

src/main/java/board/domain/Post.java
package board.domain;

import java.time.LocalDate;

public record Post(
        long id,
        String authorId,
        String title,
        String content,
        LocalDate publishedOn
) {
    public Post {
        if (id < 1) {
            throw new IllegalArgumentException("id must be positive");
        }
        var checked = new PostDraft(
                authorId, title, content, publishedOn);
        authorId = checked.authorId();
        title = checked.title();
        content = checked.content();
        publishedOn = checked.publishedOn();
    }

    public static Post saved(long id, PostDraft draft) {
        if (draft == null) {
            throw new IllegalArgumentException("draft is required");
        }
        return new Post(
                id,
                draft.authorId(),
                draft.title(),
                draft.content(),
                draft.publishedOn());
    }

    public int contentLength() {
        return content.length();
    }
}

Post.saved는 초안에 식별자를 결합하는 전이를 이름으로 드러내는 factory입니다.

현재 생성 입력 타입인 PostDraft에는 id 컴포넌트가 없으므로 이 입력 경로로 식별자를 전달할 수 없습니다.

Post의 compact constructor는 PostDraft를 만들어 문자열 정규화·길이와 날짜 null 규칙을 재사용합니다. 따라서 public canonical 생성자를 직접 호출해도 이 구조 규칙은 우회할 수 없습니다.

다만 record의 canonical 생성자와 saved는 public이므로 호출자의 신원을 제한하지 않습니다. 임의의 양수 id를 직접 전달할 수 있고, contextual future-date gate도 생성자가 자동 실행하지 않습니다. 따라서 식별자의 저장소 기원과 저장 전 시간 검증은 PostService → repository.save(draft) → Post.saved 지원 경로의 호출 순서로 보장해야 합니다. 다음 문서에서 애플리케이션 서비스와 저장소가 이 순서를 소유하고 테스트하도록 경계를 완성합니다.


경계값 우선 테스트

평범한 본문 하나가 성공하는 테스트만으로는 빈 본문, 721자 본문, 미래 날짜를 놓칩니다.

불변식의 경계 바로 안과 밖을 짝으로 검증합니다.

src/test/java/board/domain/PostDraftTest.java
package board.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class PostDraftTest {
    private final LocalDate today = LocalDate.of(2026, 7, 13);

    @Test
    void 문자열을_정규화하고_유효한_게시글을_만든다() {
        var draft = new PostDraft(
                " member-1 ",
                " Spring MVC ",
                "Spring MVC 요청 흐름을 정리합니다.",
                today);

        draft.validateDate(today);

        assertThat(draft.authorId()).isEqualTo("member-1");
        assertThat(draft.title()).isEqualTo("Spring MVC");
    }

    @Test
    void 본문_길이는_1자부터_720자까지다() {
        assertThat(new PostDraft(
                "member-1", "Java", "a", today).content()).hasSize(1);
        assertThat(new PostDraft(
                "member-1", "Java", "a".repeat(720), today).content())
                .hasSize(720);

        assertThatThrownBy(() -> new PostDraft(
                "member-1", "Java", "", today))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("content is required");
        assertThatThrownBy(() -> new PostDraft(
                "member-1", "Java", "a".repeat(721), today))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void 미래_작성일은_거부한다() {
        var tomorrow = today.plusDays(1);
        var draft = new PostDraft(
                "member-1", "HTTP", "HTTP 요청을 정리합니다.", tomorrow);

        assertThatThrownBy(() -> draft.validateDate(today))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("publishedOn must not be in the future");
    }
}
도메인 테스트 실행
./gradlew test --tests board.domain.PostDraftTest
결과
PostDraftTest > 문자열을_정규화하고_유효한_게시글을_만든다() PASSED
PostDraftTest > 본문_길이는_1자부터_720자까지다() PASSED
PostDraftTest > 미래_작성일은_거부한다() PASSED

도메인 시간 주입

다음 구현은 짧지만 테스트하기 어렵습니다.

피해야 할 구현
public PostDraft {
    if (publishedOn.isAfter(LocalDate.now())) {
        throw new IllegalArgumentException("future date");
    }
}

테스트가 자정 경계를 지나는 순간 결과가 달라질 수 있고, 사용자 지역 시간과 서버 지역 시간이 다르면 “오늘”의 정의도 불명확합니다.

이번 단계에서는 서비스가 Clock에서 오늘을 계산해 validateDate에 전달합니다.

나중에 회원 시간대를 지원하면 그 결정을 서비스 또는 별도 정책으로 확장할 수 있습니다.

시간 의존성을 밖으로 꺼낸다고 모든 값에 인터페이스를 만들 필요는 없습니다.

Clock은 JDK가 제공하는 교체 가능한 추상화이므로 그대로 사용하면 됩니다.

Clock을 사용한 결정적 테스트
var clock = Clock.fixed(
        Instant.parse("2026-07-13T00:00:00Z"),
        ZoneOffset.UTC);
var today = LocalDate.now(clock);

draft.validateDate(today);

Clock.fixed는 테스트가 기준 instant와 시간대를 통제하게 합니다. 실제 애플리케이션에서도 사용 사례가 주입받은 Clock으로 today를 계산하고 같은 validateDate(today) 게이트를 호출해야 합니다.

PostDraftTest가 실제 실행한 문자열 정규화, 본문 길이, 오늘과 미래 날짜 경계를 연습 또는 추가 테스트로 남은 경계와 구분하고, Clock.fixed에서 LocalDate.now(clock)을 거쳐 validateDate(today)에 도달하는 결정적 시간 입력을 설명합니다.

BOUNDARY EVIDENCE · TESTED IS NOT THE SAME AS DECLARED

코드에 선언한 규칙과 실제 실행한 경계 증거를 구분한다

현재 테스트는 모든 정책 경계를 다루지 않습니다. 실행한 assertion은 증거로 표시하고, 제목·회원 식별자처럼 뒤의 연습 또는 추가 테스트로 남은 경계는 별도로 표시합니다.

PostDraftTest의 현재 증거와 남은 경계
규칙 본문에서 실행한 증거 연습·추가 검증
문자열 정규화 authorIdtitle의 앞뒤 공백이 제거됨을 확인합니다. content 정규화는 구현되어 있지만 직접 assertion하지 않습니다.
회원 식별자 유효한 값만 사용하며 길이 경계는 실행하지 않습니다. 50은 통과하고 51은 거부하는 경계를 추가해야 합니다.
본문 길이 1과 720은 통과하고, 빈 문자열과 721은 거부함을 확인합니다. 현재 상한·하한 경계는 실행 증거가 있습니다.
제목 길이 유효한 제목과 정규화만 확인하며 80·81 경계는 실행하지 않습니다. 뒤의 연습에서 80은 통과하고 81은 거부하는 테스트를 제시합니다.
길이 단위 String.length()UTF-16 code unit을 세며 일부 이모지는 2로 계산합니다. DB 문자 의미론, 인코딩된 바이트 수, UI 문자소 군집은 각각 별도 정책과 테스트가 필요합니다.
작성일 오늘은 통과하고 내일은 validateDate(today)에서 거부됨을 확인합니다. publishedOn·today의 null과 과거 날짜는 별도 경계 테스트가 없습니다.

DETERMINISTIC TEMPORAL GATE

시간도 사용 사례가 제공하는 입력값이다

  1. Clock.fixed

    테스트가 기준 instant와 시간대를 고정합니다.

  2. LocalDate.now(clock)

    같은 Clock에서 재현 가능한 today를 계산합니다.

  3. validateDate(today)

    작성일과 기준 날짜를 비교합니다. 애플리케이션 사용 사례가 이 호출을 생략하면 시간 규칙은 보장되지 않습니다.

경계표는 선언된 정책 전체가 아니라 이 문서에서 실제 실행한 테스트 증거를 기준으로 읽습니다. Clock을 고정하면 날짜 규칙의 결과가 자정이나 서버 시간대에 흔들리지 않습니다.


도메인 예외와 HTTP 분리

IllegalArgumentException을 그대로 API 사용자에게 노출하는 것은 최종 설계가 아닙니다.

하지만 지금 ProblemDetail, 상태 코드, JSON 오류 형식까지 도메인에 넣으면 순수 규칙이 HTTP 기술에 의존합니다.

구현해 갈 경계는 다음처럼 나눕니다.

  1. 현재 도메인은 구조 규칙과 전달받은 기준 날짜의 위반을 예외로 거부한다.
  2. 애플리케이션 서비스는 시간 게이트를 호출하고 어떤 사용 사례에서 실패했는지 보존한다.
  3. 웹 어드바이스는 예외를 HTTP 400 또는 404 표현으로 변환한다.

세 번째 단계는 ch1-6에서 실제 컨트롤러와 함께 구현합니다.

지금은 도메인 테스트가 Spring 컨텍스트 없이 실행된다는 사실이 중요합니다.


값 객체 도입 시점

authorIdtitle를 지금 바로 별도 레코드로 만들 수도 있습니다.

그러나 타입 하나를 추가할 때는 검증 중복, 단위, 연산, 잘못된 인자 순서 같은 실제 문제가 줄어드는지 봐야 합니다.

본문 길이는 content.length()로 계산할 수 있습니다.

입력 제한과 요약 표시에서 같은 계산이 반복되면 PostContent 값 객체가 정규화와 길이 정책을 함께 소유할 수 있습니다.

반면 title이 단순 표시 문자열로만 남는 동안 TitleName이 파일 이동 비용만 늘릴 수도 있습니다.

이 교재는 실제 변경 압력이 나타날 때 리팩터링하고 테스트로 안전하게 이동합니다.


저장소 규칙 확인

이제 저장소가 받아야 하는 값은 PostDraft, 저장 후 반환할 값은 Post으로 정해졌습니다.

두 타입 사이에는 명확한 상태 전이가 있습니다.

  • 저장소에 전달할 초안: 생성자의 구조 규칙과 사용 사례의 시간 게이트를 통과했고, 아직 식별자가 없음
  • 저장 상태: 양수 식별자와 초안의 값을 결합한 Post
  • 의도한 실패 순서: 유효하지 않은 초안은 저장소 호출 전에 거부

이 순서는 다음 문서에서 저장소와 애플리케이션 서비스 테스트로 고정할 전이 계약입니다. public Post 생성자는 PostDraft의 구조 규칙을 재사용하지만, 저장소의 식별자 부여와 사용 사례의 시간 게이트까지 증명하지는 않습니다.


연습 문제

제목을 80자로 제한한 테스트에 정확히 80자와 81자 입력을 추가하세요.

한글, 영문, 이모지에서 String.length()가 사용자가 보는 글자 수와 항상 같은지도 조사합니다.

지금 정책을 코드 단위 길이로 유지할지, 사용자 인식 문자 수로 바꿀지 선택 근거를 적습니다.

해설 보기

단순 영문 80자는 성공하고 81자는 실패해야 합니다.

@Test
void 제목_길이_경계를_검증한다() {
    var eighty = "a".repeat(80);
    var eightyOne = "a".repeat(81);

    assertThat(new PostDraft(
            "member-1", eighty, "본문", today).title()).hasSize(80);
    assertThatThrownBy(() -> new PostDraft(
            "member-1", eightyOne, "본문", today))
            .hasMessage("title must be at most 80 UTF-16 code units");
}

Java의 String.length()는 UTF-16 코드 단위 수를 반환하므로 일부 이모지는 2로 계산됩니다.

이 값은 DB가 세는 문자 수, UTF-8 같은 인코딩의 바이트 수, 사용자가 한 글자로 인식하는 문자소 군집 수와 서로 다릅니다.

DB 컬럼 보호가 목적이면 사용하는 DB의 길이 의미론을, 저장·전송 크기 보호가 목적이면 실제 인코딩의 바이트 수를, UI 글자 수가 목적이면 코드 포인트 또는 문자소 군집 정책을 각각 정하고 테스트해야 합니다.

현재 코드는 UTF-16 코드 단위 정책입니다. 다른 목적의 제한으로 해석하지 않고, 정책을 바꿀 때 해당 계산과 경계 테스트를 함께 바꿉니다.

다음 문서에서는 이 두 타입을 메모리에 저장하는 PostRepository와 등록 사용 사례를 만듭니다.

도메인 테스트는 그대로 유지한 채 저장 계약과 서비스 호출 순서를 별도 테스트로 추가합니다.