모델링에서 흔히 발생하는 문제점
Primitive Obsession
주문 시스템에서 상품의 가격을 표현하는 객체를 생각해봅시다.
type OrderEntry struct {
productID string
supplyPrice int // 공급가
vat int // 부가세
grossPrice int // 공급가 + 부가세
}
type OrderEntry = {
productID: string;
supplyPrice: number; // 공급가
vat: number; // 부가세
grossPrice: number; // 공급가 + 부가세
};
data class OrderEntry(
val productID: String,
val supplyPrice: Int, // 공급가
val vat: Int, // 부가세
val grossPrice: Int, // 공급가 + 부가세
)
@dataclass
class OrderEntry:
product_id: str
supply_price: int # 공급가
vat: int # 부가세
gross_price: int # 공급가 + 부가세
일반적으로 가격은 공급가, 부가세, 그리고 이 둘을 더한 최종 소비자 가격(grossPrice)으로 구성됩니다.
이 표현만 봐서는 언뜻 잘 모델링된 듯 보입니다. 하지만 주문은 단 1원의 오차도 허용되지 않는 시스템인데, 이 구조에는 세 가지 문제가 숨어 있습니다. 셋 다 가격을 원시 숫자 타입으로 표현했기 때문에 생깁니다.
1. 세 값이 타입으로 구분되지 않습니다.
supplyPrice, vat, grossPrice가 전부 같은 숫자 타입입니다. 그래서 인자 순서를 바꿔 넣어도 컴파일러가 막지 못합니다.
func applyDiscount(grossPrice, vat int) int { /* ... */ }
// 인자를 뒤바꿔 넣어도 컴파일은 성공한다 — 런타임에야 금액이 틀어진다
applyDiscount(entry.vat, entry.grossPrice)
function applyDiscount(grossPrice: number, vat: number): number {
/* ... */
}
// 인자를 뒤바꿔 넣어도 컴파일은 성공한다 — 런타임에야 금액이 틀어진다
applyDiscount(entry.vat, entry.grossPrice);
fun applyDiscount(grossPrice: Int, vat: Int): Int { /* ... */ }
// 인자를 뒤바꿔 넣어도 컴파일은 성공한다 — 런타임에야 금액이 틀어진다
applyDiscount(entry.vat, entry.grossPrice)
def apply_discount(gross_price: int, vat: int) -> int:
...
# 인자를 뒤바꿔 넣어도 타입 체커가 막지 못한다 — 런타임에야 금액이 틀어진다
apply_discount(entry.vat, entry.gross_price)
2. 불변식을 강제할 곳이 없습니다.
grossPrice == supplyPrice + vat, 그리고 “금액은 음수일 수 없다”는 규칙이 항상 성립해야 합니다. 그런데 이런 구조체는 그 관계를 검사하지 않습니다.
// 셋 다 그냥 int라, 앞뒤가 안 맞는 값도 아무 저항 없이 만들어진다
bad := OrderEntry{supplyPrice: 9_091, vat: 909, grossPrice: 12_000} // 합이 안 맞음
neg := OrderEntry{supplyPrice: -1_000} // 음수 금액
// 셋 다 그냥 number라, 앞뒤가 안 맞는 값도 아무 저항 없이 만들어진다
const bad: OrderEntry = {
productID: "A-1",
supplyPrice: 9091,
vat: 909,
grossPrice: 12000, // 합이 안 맞음
};
const neg: OrderEntry = {
productID: "A-1",
supplyPrice: -1000, // 음수 금액
vat: 0,
grossPrice: 0,
};
// 셋 다 그냥 Int라, 앞뒤가 안 맞는 값도 아무 저항 없이 만들어진다
val bad = OrderEntry(
productID = "A-1",
supplyPrice = 9091,
vat = 909,
grossPrice = 12000, // 합이 안 맞음
)
val neg = OrderEntry(
productID = "A-1",
supplyPrice = -1000, // 음수 금액
vat = 0,
grossPrice = 0,
)
# 셋 다 그냥 int라, 앞뒤가 안 맞는 값도 아무 저항 없이 만들어진다
bad = OrderEntry(
product_id="A-1",
supply_price=9091,
vat=909,
gross_price=12000, # 합이 안 맞음
)
neg = OrderEntry(
product_id="A-1",
supply_price=-1000, # 음수 금액
vat=0,
gross_price=0,
)
3. 의미 없는 연산이 허용됩니다.
숫자 타입이므로 supplyPrice * vat나 “공급가 + 수량”처럼 도메인적으로 말이 안 되는 계산도 그대로 컴파일됩니다. “금액”이라는 개념이 타입에 없으니, 무엇과 무엇을 더하고 곱해도 되는지를 언어가 알 수 없습니다.
세 문제의 뿌리는 하나입니다. 가격이라는 도메인 개념을, 그것이 지켜야 할 의미와 규칙과 함께 하나의 타입으로 묶지 않고, 의미 없는 숫자 세 개로 흩어 놓았다는 것. 이것이 바로 Primitive Obsession(원시 타입 집착) 입니다.
해결은 이 값들을 Price Value Object로 감싸는 것입니다. 생성 시점에 불변식을 강제하면, 잘못된 값 자체가 만들어지지 못합니다.
type Price struct {
supply int // 공급가
vat int // 부가세
gross int // 공급가 + 부가세
}
// 공급가만 받는다. vat/gross는 내부에서 계산해 불변식을 보장한다
func NewPrice(supply int) (Price, error) {
if supply < 0 {
return Price{}, errors.New("금액은 음수일 수 없습니다")
}
vat := supply * 10 / 100 // 부가세 10%
return Price{supply: supply, vat: vat, gross: supply + vat}, nil
}
class Price {
private constructor(
readonly supply: number, // 공급가
readonly vat: number, // 부가세
readonly gross: number, // 공급가 + 부가세
) {}
// 공급가만 받는다. vat/gross는 내부에서 계산해 불변식을 보장한다
static create(supply: number): Price {
if (supply < 0) {
throw new Error("금액은 음수일 수 없습니다");
}
const vat = Math.trunc((supply * 10) / 100); // 부가세 10%
return new Price(supply, vat, supply + vat);
}
}
// 공급가만 받는다. vat/gross는 내부에서 계산해 불변식을 보장한다
data class Price private constructor(
val supply: Int, // 공급가
val vat: Int, // 부가세
val gross: Int, // 공급가 + 부가세
) {
companion object {
fun create(supply: Int): Price {
require(supply >= 0) { "금액은 음수일 수 없습니다" }
val vat = supply * 10 / 100 // 부가세 10%
return Price(supply, vat, supply + vat)
}
}
}
@dataclass(frozen=True)
class Price:
supply: int # 공급가
vat: int # 부가세
gross: int # 공급가 + 부가세
# 공급가만 받는다. vat/gross는 내부에서 계산해 불변식을 보장한다
@classmethod
def create(cls, supply: int) -> "Price":
if supply < 0:
raise ValueError("금액은 음수일 수 없습니다")
vat = supply * 10 // 100 # 부가세 10%
return cls(supply=supply, vat=vat, gross=supply + vat)
이제 Price를 받는 함수는 “정상적인 가격”임을 타입으로 보장받습니다. 호출자는 공급가만 넘기면 되고, 부가세/최종가의 정합성은 생성자가 책임집니다.
Entity 남용
Value Object는 어떤 특정 인스턴스인지가 아니라 어떤 속성을 갖는지가 중요한 도메인 개념입니다. 같은 속성이면 같은 값으로 취급하고, 바꿀 때는 기존 객체를 수정하지 않고 새 값을 만듭니다.
판별 질문은 단순합니다.
이 객체가 어떤 특정 객체인지가 중요한가, 아니면 어떤 속성을 갖는지만 중요한가?
고객/주문처럼 시간에 따라 상태가 바뀌어도 “그 주문”으로 추적해야 하면 Entity입니다. 주소/금액/색상/기간처럼 속성 조합 자체가 의미면 Value Object입니다.
왜 필요한가
모델링하다 보면 습관적으로 모든 개념에 식별자를 붙여 Entity로 만들거나, 반대로 숫자/문자열에 의미를 맡깁니다.
- Entity 남용: ORM/DB 습관으로
Money,Address에도 PK를 붙입니다. “값이 같은가”만 보면 되는데 “어느 행인가”까지 추적하게 됩니다. 금액1000원두 개가 ID만 다르면 다른 객체가 되어, 비교와 생명주기 관리가 불필요하게 커집니다. - Primitive Obsession: 금액을
int/double로만 쓰면 통화, 음수 불가 같은 불변식이 코드 여기저기로 흩어집니다. 무게와 온도를 더하는 식의 의미 없는 연산도 막기 어렵습니다. - Aliasing: 가변 객체를 여러 곳에서 참조하면, 한쪽이 내부를 바꿀 때 다른 참조도 같이 바뀝니다. 추적하기 어려운 부작용의 원인입니다.
Value Object는 불필요한 identity 비용을 빼는 동시에, 관련 속성을 하나의 개념적 전체(conceptual whole)로 묶어 의미와 제약을 캡슐화합니다.
설계 원칙
- 불변성: 생성 후 내부를 바꾸지 않습니다. 다른 값이 필요하면 새 인스턴스를 만듭니다. aliasing을 원천 차단하고, 공유/캐싱도 안전해집니다.
- 구조적 동등성: 식별자가 아니라 속성값으로 같음을 판단합니다. identity 추적 장치가 필요 없어져 설계와 테스트가 단순해집니다.
가변 Value Object는 aliasing 위험이 남습니다. 성능을 위해 인스턴스를 공유(Flyweight)하려면 불변성이 전제여야 합니다.
에반스의 경험칙은 이겁니다. 식별자가 필요 없다면, 의심될 때는 Value Object로 두세요.
예시
type Price struct {
supply int
vat int
gross int
}
func NewPrice(supply int) (Price, error) {
if supply < 0 {
return Price{}, errors.New("amount must be >= 0")
}
vat := supply * 10 / 100 // 부가세 10%
return Price{supply: supply, vat: vat, gross: supply + vat}, nil
}
class Price {
private constructor(
private readonly supply: number,
private readonly vat: number,
private readonly gross: number,
) {}
static create(supply: number): Price {
if (supply < 0) {
throw new Error("amount must be >= 0");
}
const vat = Math.trunc((supply * 10) / 100); // 부가세 10%
return new Price(supply, vat, supply + vat);
}
}
data class Price private constructor(
val supply: Int,
val vat: Int,
val gross: Int,
) {
companion object {
fun create(supply: Int): Price {
require(supply >= 0) { "amount must be >= 0" }
val vat = supply * 10 / 100 // 부가세 10%
return Price(supply, vat, supply + vat)
}
}
}
@dataclass(frozen=True)
class Price:
supply: int
vat: int
gross: int
@classmethod
def create(cls, supply: int) -> "Price":
if supply < 0:
raise ValueError("amount must be >= 0")
vat = supply * 10 // 100 # 부가세 10%
return cls(supply=supply, vat=vat, gross=supply + vat)