게시판 도메인 모델
게시글의 생성 규칙을 Java 25 레코드와 테스트로 표현해 웹이나 저장 기술 없이도 업무 불변식을 실행 가능하게 만듭니다.
게시판의 첫 기능은 "회원이 제목과 본문을 가진 게시글을 작성한다"입니다. 짧은 문장이지만 코드로 옮기기 전에 애매한 부분을 먼저 없애야 합니다. 이 교재에서는 게시글 규칙을 다음과 같이 정합니다. 길이 숫자만 따로 저장하면 정작 화면에 보여 줄 본문을 잃으므로, 제목과 본문은 문자열 그대로 두고 길이는 그 문자열에서 계산합니다.
| 항목 | 규칙 |
|---|---|
회원 식별자 authorId | 앞뒤 공백을 지운 뒤 String.length() 기준 1~50 |
제목 title | 앞뒤 공백을 지운 뒤 String.length() 기준 1~80 |
본문 content | 앞뒤 공백을 지운 뒤 String.length() 기준 1~720 |
작성일 publishedOn | null일 수 없고, 사용 사례가 넘긴 기준 날짜보다 미래일 수 없음 |
식별자 id | 생성 입력에는 없고, 저장된 Post에는 양수 값이 있어야 함 |
이 규칙을 컨트롤러의 if 문으로만 지키면, 배치 작업이나 테스트 픽스처처럼 다른 경로로 게시글을 만들 때 검사를 건너뛰게 됩니다. 그래서 문자열의 정규화와 길이, 작성일의 null 여부처럼 입력만 보고 판단할 수 있는 구조 규칙은 웹과 무관한 순수 Java 객체에 둡니다. 반면 "미래 날짜인가"는 오늘이 며칠인지라는 바깥의 기준이 있어야 판단할 수 있으므로, 애플리케이션의 사용 사례가 그 기준 날짜를 넘겨 검사를 호출해야 합니다. 객체를 만들었다는 것만으로 날짜 규칙까지 지켜지지는 않는다는 점이 이 글의 핵심입니다.
게시글 초안의 구조 검증과 저장 전이 계약
PostDraft 구조 검사 뒤 application의 날짜 검사를 거쳐 저장 상태로 넘어가는 값·상태 변화를 추적할 수 있다.
DOMAIN TRANSITION CONTRACT · CURRENT + REQUIRED + PLANNED
구조 게이트와 시간 게이트를 모두 지난 초안만 저장 전이로 보낸다
PostDraft 생성자는 입력 자체의 구조만 보장합니다. 미래 날짜 판단은 애플리케이션 사용 사례가 호출해야 하는 별도 시간 게이트이며, 저장소의 식별자 부여는 다음 문서에서 구현할 전이 계약입니다.
PRIMARY FLOW · ORDER IS THE CONTRACT
생성 값에서 저장 상태까지
-
신뢰 전 생성 값
authorId,title,content,publishedOn을 받으며id는 받지 않습니다. -
PostDraft구조 게이트세 문자열의 null·blank를 거부하고
strip한 뒤,String.length()가 각각 50·80·720 이하인지 확인합니다. 작성일의 null도 거부합니다. -
애플리케이션 시간 게이트
Clock에서 계산한today를validateDate(today)에 전달합니다. 생성자가 자동으로 호출하지 않습니다. -
저장소 식별자 전이
다음 문서의 저장소가 앞선 게이트 뒤에 양수 식별자를 부여하고
Post.saved전이를 호출합니다. -
저장 상태
Postpublic 생성자도
PostDraft의 구조 규칙을 재사용합니다. 다만 임의의 양수id출처와 선행 시간 게이트는 스스로 증명하지 않습니다.
DOMAIN · IMPLEMENTED HERE
규칙을 계산한다
PostDraft가 문자열 구조를 검사하고, 전달받은 today와 작성일을 비교합니다.
APPLICATION · REQUIRED CALL ORDER
시간 맥락과 순서를 제공한다
사용 사례가 Clock으로 today를 만들고 저장소보다 먼저 시간 게이트를 호출해야 합니다.
REPOSITORY · PLANNED IN CH1-4
식별자를 결합한다
저장소 기원은 현재 public factory가 증명하지 않습니다. 다음 문서의 경계와 테스트가 그 호출 순서를 소유합니다.
현재 증명된 것은 domain의 구조 검사와 날짜 비교 로직입니다. application 호출 순서와 repository 식별자 부여는 명시적 계약이며, 후속 사용 사례 테스트로 실행 증거를 추가해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 지원 사용 사례가 호출해야 하는 순서입니다. 구조 검사는 PostDraft 생성자에서 실행하지만 날짜 검사는 application이 today를 전달해야 합니다. 마지막 저장소 단계는 다음 절에서 구현하는 계약이며 임의의 public 생성자 호출이 이 경로를 증명하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 초안의 구조 검증과 저장 전이 계약 |
| 2 | 아직 신뢰할 수 없는 생성 값이 PostDraft 생성자의 구조 검증과 정규화를 거친 뒤, 애플리케이션 사용 사례가 today를 전달하는 시간 게이트를 통과하고, 다음 문서에서 구현할 저장소가 양수 식별자를 결합해 Post로 전이하는 계약을 설명합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 구조를 가진 초안 PostDraft 생성자가 구조를 검사합니다. | 값: PostDraft · 검사: 구조 | 유효한 구조만 초안이 됩니다. |
| application 날짜 검사 application이 today를 전달해 날짜를 확인합니다. | 입력: today · 검사: 날짜 | 사용 사례가 현재 날짜를 명시합니다. |
| 저장 경계 전달 유효한 초안만 다음 절의 저장 계약으로 전달합니다. | 결과: 저장 입력 · 구현: 다음 절 계약 | 검사된 값을 저장 경계에 넘깁니다. |
게시글 초안의 구조 검증과 저장 전이 계약
PostDraft 구조 검사 뒤 application의 날짜 검사를 거쳐 저장 상태로 넘어가는 값·상태 변화를 추적할 수 있다.
DOMAIN TRANSITION CONTRACT · CURRENT + REQUIRED + PLANNED
구조 게이트와 시간 게이트를 모두 지난 초안만 저장 전이로 보낸다
PostDraft 생성자는 입력 자체의 구조만 보장합니다. 미래 날짜 판단은 애플리케이션 사용 사례가 호출해야 하는 별도 시간 게이트이며, 저장소의 식별자 부여는 다음 문서에서 구현할 전이 계약입니다.
PRIMARY FLOW · ORDER IS THE CONTRACT
생성 값에서 저장 상태까지
-
신뢰 전 생성 값
authorId,title,content,publishedOn을 받으며id는 받지 않습니다. -
PostDraft구조 게이트세 문자열의 null·blank를 거부하고
strip한 뒤,String.length()가 각각 50·80·720 이하인지 확인합니다. 작성일의 null도 거부합니다. -
애플리케이션 시간 게이트
Clock에서 계산한today를validateDate(today)에 전달합니다. 생성자가 자동으로 호출하지 않습니다. -
저장소 식별자 전이
다음 문서의 저장소가 앞선 게이트 뒤에 양수 식별자를 부여하고
Post.saved전이를 호출합니다. -
저장 상태
Postpublic 생성자도
PostDraft의 구조 규칙을 재사용합니다. 다만 임의의 양수id출처와 선행 시간 게이트는 스스로 증명하지 않습니다.
DOMAIN · IMPLEMENTED HERE
규칙을 계산한다
PostDraft가 문자열 구조를 검사하고, 전달받은 today와 작성일을 비교합니다.
APPLICATION · REQUIRED CALL ORDER
시간 맥락과 순서를 제공한다
사용 사례가 Clock으로 today를 만들고 저장소보다 먼저 시간 게이트를 호출해야 합니다.
REPOSITORY · PLANNED IN CH1-4
식별자를 결합한다
저장소 기원은 현재 public factory가 증명하지 않습니다. 다음 문서의 경계와 테스트가 그 호출 순서를 소유합니다.
현재 증명된 것은 domain의 구조 검사와 날짜 비교 로직입니다. application 호출 순서와 repository 식별자 부여는 명시적 계약이며, 후속 사용 사례 테스트로 실행 증거를 추가해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 지원 사용 사례가 호출해야 하는 순서입니다. 구조 검사는 PostDraft 생성자에서 실행하지만 날짜 검사는 application이 today를 전달해야 합니다. 마지막 저장소 단계는 다음 절에서 구현하는 계약이며 임의의 public 생성자 호출이 이 경로를 증명하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 초안의 구조 검증과 저장 전이 계약 |
| 2 | 아직 신뢰할 수 없는 생성 값이 PostDraft 생성자의 구조 검증과 정규화를 거친 뒤, 애플리케이션 사용 사례가 today를 전달하는 시간 게이트를 통과하고, 다음 문서에서 구현할 저장소가 양수 식별자를 결합해 Post로 전이하는 계약을 설명합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 구조를 가진 초안 PostDraft 생성자가 구조를 검사합니다. | 값: PostDraft · 검사: 구조 | 유효한 구조만 초안이 됩니다. |
| application 날짜 검사 application이 today를 전달해 날짜를 확인합니다. | 입력: today · 검사: 날짜 | 사용 사례가 현재 날짜를 명시합니다. |
| 저장 경계 전달 유효한 초안만 다음 절의 저장 계약으로 전달합니다. | 결과: 저장 입력 · 구현: 다음 절 계약 | 검사된 값을 저장 경계에 넘깁니다. |
그림은 생성 값이 저장 상태가 되기까지 거쳐야 하는 세 단계를 보여 줍니다. 먼저 PostDraft 생성자가 구조를 검사하고, 다음으로 애플리케이션이 Clock에서 계산한 today를 validateDate(today)에 넘겨 날짜를 검사하며, 마지막으로 저장소가 양수 식별자를 붙여 Post로 바꿉니다. 마지막 저장소 단계는 다음 글에서 구현합니다.
생성 요청과 저장 모델
클라이언트가 보내는 값과 저장소가 붙이는 값은 생기는 시점이 다릅니다. 게시글을 만들어 달라는 요청에는 아직 id가 없으므로 그 입력은 PostDraft로 나타내고, 저장되어 양수 식별자가 붙은 상태는 Post로 나타냅니다.
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;
}
}레코드의 컴팩트 생성자(compact constructor) 안에서 컴포넌트 이름에 새 값을 대입하면, 그 값이 최종 필드에 저장됩니다. 그래서 PostDraft의 필드에는 앞뒤 공백을 지운 문자열이 들어갑니다. 이 생성자가 지키는 것은 세 문자열의 정규화와 길이, publishedOn의 null 여부까지입니다. 작성일이 미래인지는 시스템 날짜에 따라 달라지므로 생성자 안에서 LocalDate.now()를 부르지 않고, 사용 사례가 Clock으로 기준 날짜를 계산해 validateDate(today)를 호출하게 합니다. 사용 사례의 테스트도 이 날짜 검사가 저장소 호출보다 먼저 실행되는지 확인해야 합니다.
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의 컴팩트 생성자는 안에서 PostDraft를 만들어 문자열과 작성일 규칙을 다시 씁니다. 그래서 Post의 생성자를 직접 호출하더라도 이 구조 규칙은 건너뛸 수 없습니다.
다만 레코드의 정식 생성자(canonical constructor)와 saved는 둘 다 public이므로 누가 호출하는지는 막지 못합니다. 누구든 임의의 양수 id를 넘길 수 있고, 미래 날짜 검사도 생성자가 자동으로 실행하지 않습니다. 따라서 식별자를 저장소가 붙였다는 것과 저장 전에 날짜를 검사했다는 것은 PostService가 날짜를 검사한 뒤 repository.save(draft)를 부르고, 저장소가 Post.saved를 호출하는 순서로 지켜야 합니다. 다음 글에서 애플리케이션 서비스와 저장소가 이 순서를 맡고 테스트로 확인합니다.
경계값 우선 테스트
평범한 본문 하나가 성공하는지만 보는 테스트로는 빈 본문, 721자 본문, 미래 날짜 같은 실패를 놓칩니다. 규칙의 한계 바로 안쪽과 바로 바깥쪽을 짝지어 검사합니다.
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세 테스트가 모두 통과하면 BUILD SUCCESSFUL로 끝납니다. 첫 테스트에서 " member-1 "과 " Spring MVC "는 생성자를 지나며 앞뒤 공백이 지워지고, 둘째 테스트에서 빈 문자열은 isBlank()에 걸려 content is required로, 721자 본문은 길이 검사에 걸려 거부됩니다. 셋째 테스트의 내일 날짜는 생성자를 통과하지만 validateDate(today)에서 거부됩니다.
도메인 시간 주입
다음 구현은 짧지만 테스트하기 어렵습니다. 아래 생성자는 LocalDate.now()를 인자 없이 부르므로, 기준 날짜를 시스템 시계와 JVM의 기본 시간대에서 직접 읽습니다. 호출하는 쪽이 그 기준을 정해 넘길 방법이 없다는 점을 보면서 읽습니다.
public PostDraft {
if (publishedOn.isAfter(LocalDate.now())) {
throw new IllegalArgumentException("future date");
}
}테스트가 자정을 지나는 순간 결과가 바뀔 수 있고, 사용자의 지역 시간과 서버의 지역 시간이 다르면 "오늘"이 언제인지도 분명하지 않습니다. 그래서 이번 단계에서는 서비스가 Clock에서 오늘 날짜를 계산해 validateDate에 넘깁니다. 나중에 회원마다 시간대를 지원하게 되면 그 결정은 서비스나 별도 정책 객체로 넓히면 됩니다. 시간을 밖에서 넣는다고 해서 인터페이스를 새로 만들 필요는 없습니다. java.time.Clock은 JDK가 제공하는, 바꿔 끼울 수 있는 시계이므로 그대로 쓰면 됩니다.
var clock = Clock.fixed(
Instant.parse("2026-07-13T00:00:00Z"),
ZoneOffset.UTC);
var today = LocalDate.now(clock);
draft.validateDate(today);Clock.fixed는 테스트가 기준 시각과 시간대를 정하게 해 주므로, LocalDate.now(clock)은 언제 실행해도 2026년 7월 13일을 돌려줍니다. 실제 애플리케이션에서도 사용 사례가 주입받은 Clock으로 today를 계산하고 같은 validateDate(today)를 호출합니다.
BOUNDARY EVIDENCE · TESTED IS NOT THE SAME AS DECLARED
코드에 선언한 규칙과 실제 실행한 경계 증거를 구분한다
현재 테스트는 모든 정책 경계를 다루지 않습니다. 실행한 assertion은 증거로 표시하고, 제목·회원 식별자처럼 뒤의 연습 또는 추가 테스트로 남은 경계는 별도로 표시합니다.
| 규칙 | 본문에서 실행한 증거 | 연습·추가 검증 |
|---|---|---|
| 문자열 정규화 | authorId와 title의 앞뒤 공백이 제거됨을 확인합니다. |
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과 과거 날짜는 별도 경계 테스트가 없습니다. |
PostDraftTest의 현재 증거와 남은 경계
문자열 정규화
본문에서 실행한 증거: authorId와 title의 앞뒤 공백이 제거됨을 확인합니다.
연습·추가 검증: 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
시간도 사용 사례가 제공하는 입력값이다
-
Clock.fixed테스트가 기준 instant와 시간대를 고정합니다.
-
LocalDate.now(clock)같은 Clock에서 재현 가능한
today를 계산합니다. -
validateDate(today)작성일과 기준 날짜를 비교합니다. 애플리케이션 사용 사례가 이 호출을 생략하면 시간 규칙은 보장되지 않습니다.
경계표는 선언된 정책 전체가 아니라 이 문서에서 실제 실행한 테스트 증거를 기준으로 읽습니다. Clock을 고정하면 날짜 규칙의 결과가 자정이나 서버 시간대에 흔들리지 않습니다.
그림의 위쪽은 이 시간 전달 경로를, 표는 PostDraftTest가 실제로 확인한 규칙과 아직 확인하지 않은 규칙을 나누어 보여 줍니다. 본문 길이는 1과 720의 통과, 빈 문자열과 721의 거부까지 모두 확인했고, 작성일은 오늘의 통과와 내일의 거부를 확인했습니다. 반면 회원 식별자의 50자와 51자, 제목의 80자와 81자는 아직 확인하지 않았고, content의 정규화도 직접 검사하지 않았습니다. publishedOn이나 today가 null인 경우와 과거 날짜도 따로 테스트하지 않았습니다. 코드에 규칙을 적었다는 것과 테스트로 확인했다는 것은 다르므로, 이런 빈칸은 연습 문제와 이후 테스트로 채웁니다.
도메인 예외와 HTTP 분리
IllegalArgumentException을 그대로 API 사용자에게 보여 주는 것이 최종 설계는 아닙니다. 그렇다고 지금 ProblemDetail이나 상태 코드, JSON 오류 형식을 도메인에 넣으면 순수한 업무 규칙이 HTTP 기술에 의존하게 됩니다. 그래서 일을 세 층으로 나눕니다. 도메인은 구조 규칙과 넘겨받은 기준 날짜를 어긴 입력을 예외로 거부하고, 애플리케이션 서비스는 날짜 검사를 호출하고, 중복 게시글이나 없는 게시글처럼 사용 사례에서 생긴 실패를 전용 예외로 알립니다. 마지막으로 웹 계층의 컨트롤러 어드바이스(controller advice)가 예외를 HTTP 400이나 404 응답으로 바꿉니다. 세 번째 층은 게시판 웹 등록·조회에서 실제 컨트롤러와 함께 만들며, 지금은 도메인 테스트가 Spring 컨텍스트 없이 실행된다는 점만 확인하면 됩니다.
값 객체 도입 시점
authorId와 title을 지금 바로 별도 레코드로 만들 수도 있습니다. 하지만 타입을 하나 추가할 때는 그 덕분에 검증의 중복, 단위의 혼동, 인자 순서를 잘못 넘기는 실수 같은 실제 문제가 줄어드는지를 먼저 봐야 합니다. 지금 본문 길이는 content.length() 하나로 계산할 수 있습니다. 앞으로 입력 제한과 요약 표시에서 같은 계산이 되풀이된다면 PostContent 같은 값 객체가 정규화와 길이 정책을 함께 맡게 할 수 있습니다. 반면 title이 화면에 표시하는 문자열로만 쓰이는 동안에는 TitleName 같은 타입이 파일만 늘릴 수도 있습니다. 이 교재는 실제로 바꿀 이유가 생겼을 때 리팩터링하고, 테스트로 동작이 그대로인지 확인하며 옮깁니다.
저장소 규칙 확인
이제 저장소가 받을 값은 PostDraft, 저장한 뒤 돌려줄 값은 Post로 정해졌습니다. 저장소에 넘기는 초안은 생성자의 구조 규칙과 사용 사례의 날짜 검사를 모두 통과했고 아직 식별자가 없는 값이며, 저장된 상태는 양수 식별자와 초안의 값을 합친 Post입니다. 유효하지 않은 초안은 저장소를 호출하기 전에 거부되어야 합니다. 다음 글에서는 이 순서를 저장소와 애플리케이션 서비스의 테스트로 확인합니다. public인 Post 생성자는 PostDraft의 구조 규칙을 다시 쓰지만, 저장소가 식별자를 붙였다는 것이나 사용 사례가 날짜를 검사했다는 것까지 알려 주지는 않기 때문입니다.
정리
- 게시글을 만드는 입력은 식별자가 없는
PostDraft로, 저장된 상태는 양수 식별자가 있는Post로 나눕니다. - 입력만 보고 판단할 수 있는 정규화와 길이,
null규칙은 레코드의 컴팩트 생성자에 둡니다. - 미래 날짜처럼 바깥의 기준이 필요한 규칙은 사용 사례가
Clock으로 계산한today를 넘겨 호출하며, 생성자가 자동으로 지켜 주지 않습니다. - 테스트는 규칙의 한계 바로 안쪽과 바깥쪽을 짝지어 확인하고, 코드에 적었지만 아직 확인하지 않은 규칙은 따로 남겨 둡니다.
- 도메인은 HTTP 기술을 모른 채 예외로 거부만 하고, 예외를 HTTP 응답으로 바꾸는 일은 웹 계층이 맡습니다.
연습 문제
1. 제목 길이 확인하기
PostDraftTest에 정확히 80자인 제목과 81자인 제목을 넣는 테스트를 추가하세요. 그다음 한글, 영문, 이모지에서 String.length()가 사용자가 보는 글자 수와 항상 같은지 조사하고, 길이 정책을 지금처럼 코드 단위로 둘지 사용자가 보는 글자 수로 바꿀지 이유와 함께 정하세요.
해설 보기
영문 80자는 통과하고 81자는 거부되어야 합니다.
@Test
void 제목은_80자까지다() {
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 코드 단위(code unit)의 수를 돌려줍니다. 한글 음절 하나와 영문 한 글자는 1로 세지만, 기본 다국어 평면 밖에 있는 이모지 하나는 서로게이트 쌍(surrogate pair)이어서 2로 셉니다. 이 값은 데이터베이스가 세는 문자 수, UTF-8로 인코딩한 바이트 수, 사용자가 한 글자로 보는 문자소 군집(grapheme cluster)의 수와 모두 다를 수 있습니다.
그러므로 목적에 따라 정책을 고릅니다. 데이터베이스 컬럼을 넘치지 않게 하는 것이 목적이면 쓰는 데이터베이스가 길이를 세는 방식을, 저장하거나 전송하는 크기를 제한하려면 실제 인코딩의 바이트 수를, 화면의 글자 수를 제한하려면 코드 포인트나 문자소 군집의 수를 기준으로 삼고 그에 맞는 테스트를 씁니다. 지금 코드는 UTF-16 코드 단위 정책이므로, 정책을 바꿀 때는 계산과 경계값 테스트를 함께 바꿉니다.
2. 생성자에서 날짜를 검사하지 않는 이유
누군가 "PostDraft 생성자에서 LocalDate.now()로 미래 날짜까지 검사하면 validateDate를 깜빡할 일이 없지 않나요?"라고 묻습니다. 이 방법의 문제를 두 가지 들어 설명하세요.
해설 보기
첫째, 테스트 결과가 실행하는 날짜와 시각에 따라 달라집니다. 2026년 7월 13일로 만든 초안이 오늘은 통과해도, 테스트 데이터에 고정해 둔 날짜가 실행일보다 미래가 되면 실패합니다. 자정 무렵에 실행하면 같은 테스트가 통과했다가 실패하기도 합니다.
둘째, "오늘"을 어느 시간대로 정할지를 도메인 객체가 혼자 정하게 됩니다. LocalDate.now()는 서버의 기본 시간대를 씁니다. 한국 시간 7월 14일 오전 8시에 그날 날짜로 쓴 글은, UTC로 동작하는 서버에서는 아직 7월 13일 오후 11시이므로 미래 날짜로 거부됩니다. Clock을 사용 사례가 넘기게 하면 테스트에서는 고정된 시계를, 운영에서는 정책에 맞는 시간대의 시계를 쓸 수 있습니다.
다음 글 저장소·서비스 테스트에서는 이 두 타입을 메모리에 저장하는 PostRepository와 게시글 등록 사용 사례를 만들고, 도메인 테스트는 그대로 둔 채 저장 규칙과 서비스의 호출 순서를 별도 테스트로 확인합니다.