본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
1장 : 회원가입과 게시판 시작

게시판 도메인 모델

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

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

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

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

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

  • 회원 식별자는 공백일 수 없습니다.
  • 제목은 앞뒤 공백을 제거한 뒤 1~80자여야 합니다.
  • 본문 길이는 1~720자입니다.
  • 작성일은 필수이며 미래 날짜를 허용하지 않습니다.
  • 저장 전에는 식별자가 없고, 저장된 게시글에는 양수 식별자가 있습니다.

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

규칙을 가장 안쪽의 순수 Java 객체에 두면 모든 진입점이 같은 실패를 공유합니다.


생성 요청과 저장 모델

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

생성 요청에는 아직 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 (publishedOn.isAfter(today)) {
            throw new IllegalArgumentException(
                    "publishedOn must not be in the future");
        }
    }

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

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

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

대신 기준 날짜를 받는 메서드로 분리해 테스트가 시간을 통제하게 합니다.

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");
        }
        if (authorId == null || title == null || content == null
                || publishedOn == null) {
            throw new IllegalArgumentException(
                    "saved post fields must not be null");
        }
    }

    public static Post saved(long id, PostDraft draft) {
        return new Post(
                id,
                draft.authorId(),
                draft.title(),
                draft.content(),
                draft.publishedOn());
    }

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

Post.saved는 저장소가 식별자를 부여하는 순간에만 사용합니다.

웹 요청이 임의의 id를 넣어 객체를 만들지 못하게 생성 경로를 좁힌 것입니다.


경계값 우선 테스트

평범한 본문 하나가 성공하는 테스트만으로는 빈 본문, 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);

도메인 예외와 HTTP 분리

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

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

현재 경계는 다음처럼 유지합니다.

  1. 도메인은 유효하지 않은 값을 예외로 거부한다.
  2. 애플리케이션 서비스는 어떤 사용 사례에서 실패했는지 보존한다.
  3. 웹 어드바이스는 예외를 HTTP 400 또는 404 표현으로 변환한다.

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

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


값 객체 도입 시점

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

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

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

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

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

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


저장소 규칙 확인

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

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

  • 초안: 검증된 입력, 아직 식별자 없음
  • 저장된 게시글: 저장소가 부여한 양수 식별자 있음
  • 실패: 유효하지 않은 초안은 저장소 호출 전에 거부

연습 문제

제목을 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 characters");
}

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

상품 요구가 “DB 컬럼과 전송 크기 보호”라면 현재 방식도 합리적일 수 있고, UI가 말하는 “글자 수”라면 코드 포인트 또는 문자소 군집 기준이 더 적합할 수 있습니다.

제한의 목적을 먼저 정해야 합니다.

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

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