DDD의 전술적 패턴을 떠올리면 많은 사람이 Entity부터 생각한다. 식별자, 생명주기, 상태 변화가 있는 객체는 분명 중요하다. 하지만 실제 코드에서 도메인 의미를 가장 작고 선명하게 드러내는 출발점은 Value Object일 때가 많다.
Value Object는 단순히 작은 객체가 아니다. Value Object는 원시 타입에 흩어져 있던 도메인 의미를 하나의 이름 있는 개념으로 끌어올리는 방법이다.
코드에 int, String, LocalDateTime이 많다고 해서 문제가 되는 것은 아니다. 문제는 그 값들이 어떤 도메인 의미를 갖는지 코드만 보고 알기 어려울 때 생긴다.
원시 타입은 너무 많은 것을 숨긴다
구독 서비스에 다음과 같은 코드가 있다고 해보자.
public class Subscription {
private int price;
private String currency;
private int periodDays;
private String email;
}
처음에는 자연스러워 보인다. 가격은 숫자이고, 통화는 문자열이고, 기간은 일수이며, 이메일도 문자열이다. 하지만 이 코드에는 많은 질문이 숨어 있다.
price는 음수가 될 수 있는가?
currency는 어떤 형식을 가져야 하는가?
periodDays가 0이면 무슨 뜻인가?
email은 검증된 값인가?
price와 currency는 항상 함께 다녀야 하는가?
이 질문들의 답이 코드에 드러나지 않으면, 검증 로직은 여러 곳에 흩어진다.
if (price < 0) {
throw new IllegalArgumentException();
}
if (!currency.equals("KRW") && !currency.equals("USD")) {
throw new IllegalArgumentException();
}
이런 코드는 동작할 수 있다. 하지만 도메인의 의미를 잘 보존하지는 못한다. price와 currency가 함께 하나의 금액을 이룬다는 사실도 약하게 표현된다.
Value Object는 이런 의미를 이름 있는 객체로 만든다.
Money monthlyFee;
SubscriptionPeriod period;
EmailAddress emailAddress;
이제 코드는 단순한 값이 아니라 도메인 개념을 말한다.
Value Object는 값으로 식별된다
Entity와 Value Object의 가장 큰 차이는 식별성이다. Entity는 식별자로 구분된다. 두 구독이 같은 요금제와 같은 만료일을 갖고 있어도 식별자가 다르면 다른 구독이다.
Value Object는 다르다. Value Object는 값이 같으면 같은 것으로 본다.
Money a = new Money(10000, Currency.KRW);
Money b = new Money(10000, Currency.KRW);
이 둘은 서로 다른 인스턴스일 수 있지만 도메인적으로는 같은 값이다. 10,000원은 10,000원이다. 별도의 식별자가 필요하지 않다.
이 특성 때문에 Value Object는 보통 불변으로 만든다. 한 번 만들어진 값이 바뀌지 않으면 비교와 추론이 쉬워진다.
public record Money(long amount, Currency currency) {
public Money {
if (amount < 0) {
throw new IllegalArgumentException("금액은 음수일 수 없습니다.");
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("서로 다른 통화는 더할 수 없습니다.");
}
return new Money(this.amount + other.amount, this.currency);
}
}
여기서 중요한 것은 Money가 단순 데이터 묶음이 아니라는 점이다. Money는 금액에 대한 도메인 규칙을 갖는다. 음수가 될 수 없고, 서로 다른 통화를 더할 수 없다는 규칙이 값 객체 안에 들어 있다.
불변성은 제약이 아니라 안정성이다
Value Object를 불변으로 만들면 처음에는 불편해 보일 수 있다. 값을 바꾸려면 새 객체를 만들어야 하기 때문이다.
Money fee = new Money(10000, KRW);
Money discounted = fee.subtract(new Money(1000, KRW));
하지만 이 불편함은 장점이 된다. 값이 중간에 바뀌지 않는다는 확신이 생기기 때문이다. 특히 도메인 로직이 복잡해질수록 불변성은 큰 힘을 발휘한다.
예를 들어 구독 기간을 나타내는 SubscriptionPeriod가 있다고 하자.
public record SubscriptionPeriod(LocalDate startDate, LocalDate endDate) {
public SubscriptionPeriod {
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("종료일은 시작일보다 빠를 수 없습니다.");
}
}
public boolean contains(LocalDate date) {
return !date.isBefore(startDate) && !date.isAfter(endDate);
}
}
이 객체가 불변이면 “기간의 시작일만 바뀌고 종료일은 그대로 남는” 이상한 상태가 생기지 않는다. 항상 유효한 기간만 존재한다.
Value Object의 중요한 역할 중 하나는 유효하지 않은 값을 애초에 만들 수 없게 하는 것이다.
Primitive Obsession에서 벗어나기
원시 타입을 과도하게 사용하는 문제를 Primitive Obsession이라고 부른다. 모든 것을 String, int, long으로 표현하면 코드가 간단해 보이지만, 도메인 의미는 사라진다.
다음 메서드를 보자.
public void changePlan(Long subscriptionId, String planCode, int amount, int days) {
// ...
}
이 메서드는 무엇을 받는가? amount는 결제 금액인가, 할인 금액인가, 월 요금인가? days는 구독 기간인가, 유예 기간인가, 무료 체험 기간인가? planCode는 유효한 요금제 코드인가?
Value Object를 사용하면 의도가 훨씬 분명해진다.
public void changePlan(
SubscriptionId subscriptionId,
PlanCode targetPlanCode,
Money changeFee,
SubscriptionPeriod nextPeriod
) {
// ...
}
코드가 조금 길어졌지만, 읽는 사람은 더 적은 추측을 하게 된다. 좋은 코드의 목적은 짧게 쓰는 것이 아니라, 중요한 의미를 잃지 않는 것이다.
Value Object에는 행위가 들어갈 수 있다
Value Object를 단순히 getter만 가진 객체로 만들면 아쉽다. Value Object는 값에 관련된 행위를 가질 수 있다.
예를 들어 EmailAddress는 이메일 형식을 검증할 수 있다. Money는 더하기, 빼기, 비교를 할 수 있다. PlanQuota는 사용량이 한도를 초과했는지 판단할 수 있다.
public record UsageLimit(long maxUsage) {
public UsageLimit {
if (maxUsage < 0) {
throw new IllegalArgumentException("사용 한도는 음수일 수 없습니다.");
}
}
public boolean isExceededBy(long currentUsage) {
return currentUsage > maxUsage;
}
}
이런 행위가 없으면 같은 조건문이 여러 곳에 반복된다.
if (currentUsage > plan.getMaxUsage()) {
// 제한
}
반면 Value Object에 행위를 넣으면 도메인 의미가 한곳에 모인다.
if (plan.usageLimit().isExceededBy(currentUsage)) {
// 제한
}
작은 차이지만 코드가 도메인의 언어에 가까워진다.
언제 Value Object를 만들 것인가
모든 원시값을 Value Object로 감쌀 필요는 없다. 그렇게 하면 코드가 오히려 지나치게 무거워질 수 있다. 중요한 것은 값에 도메인 의미나 규칙이 있는지다.
다음 질문을 해볼 수 있다.
이 값에 유효성 규칙이 있는가?
이 값과 관련된 계산이나 비교가 반복되는가?
이 값이 여러 필드와 항상 함께 다니는가?
이 값을 잘못 사용하면 중요한 버그가 생기는가?
이 값에 도메인에서 부르는 이름이 있는가?
이 질문 중 여러 개에 “그렇다”고 답한다면 Value Object를 고려할 만하다.
예를 들어 Money, EmailAddress, SubscriptionPeriod, PlanCode, UsageLimit, SeatCount 같은 개념은 좋은 후보가 될 수 있다.
반대로 단순히 화면 표시 순서나 내부 플래그처럼 의미가 작고 규칙이 거의 없는 값은 원시 타입으로 충분할 수 있다.
마치며
Value Object는 작은 객체를 많이 만들자는 주장이 아니다. Value Object는 도메인에서 중요한 값을 이름 있는 개념으로 표현하자는 제안이다.
원시 타입은 편하다. 하지만 원시 타입은 도메인 의미를 숨긴다. 금액, 기간, 이메일, 요금제 코드, 사용 한도 같은 개념을 int와 String으로만 표현하면, 검증과 규칙은 코드 곳곳에 흩어진다.
Value Object는 그 의미를 한곳에 모은다. 유효하지 않은 값을 만들 수 없게 하고, 값에 관련된 행위를 값 자신에게 둔다. 그리고 무엇보다 코드가 도메인의 언어를 말하게 한다.
DDD는 거대한 아키텍처에서만 시작되지 않는다. 때로는 String email을 EmailAddress로 바꾸는 작은 선택에서 시작된다. 그 작은 선택이 도메인의 의미를 코드 안에 남기는 첫걸음이 될 수 있다.