원시 집착 해결을 위한 값 객체(Value Object) 사용법
원시 집착 해결을 위한 값 객체(Value Object) 사용법
소프트웨어를 개발하다 보면 도메인의 중요한 개념들을 단순한 기본 데이터 타입으로 표현하려는 유혹에 빠지기 쉽습니다. 예를 들어, 온라인 쇼핑몰을 개발할 때 상품의 가격을 단순히 ‘double’ 타입으로 저장하거나, 사용자의 이메일 주소를 검증 없이 ‘String’으로 다루는 경우가 많습니다. 이러한 현상을 ‘원시 집착(Primitive Obsession)‘이라고 부르며, 이는 코드의 안정성과 유지보수성을 해치는 주요 원인이 됩니다.
원시 집착이 발생하는 이유와 그 영향
개발자들이 원시 타입을 선호하는 데에는 여러 이유가 있습니다. 구현이 단순하고 빠르며, 직관적으로 보이기 때문입니다. 하지만 이러한 접근 방식은 시간이 지날수록 여러 문제를 일으킵니다.
다음은 실제 프로젝트에서 자주 마주치는 문제 상황입니다:
public class Order {
private String email; // 이메일 형식이 올바른지 어떻게 보장할까요?
private double price; // 음수 금액이 들어올 수 있습니다
private String phoneNumber; // 국가 코드는 어떻게 처리해야 할까요?
public void applyCoupon(double discountRate) {
// 할인율이 100%를 넘어가면 어떻게 될까요?
this.price = this.price * (1 - discountRate);
}
}이러한 코드는 다음과 같은 문제를 일으킬 수 있습니다:
-
데이터 무결성이 보장되지 않습니다. 예를 들어, 잘못된 형식의 이메일이나 음수 금액이 시스템에 유입될 수 있습니다.
-
비즈니스 규칙이 코드 전반에 흩어집니다. 이메일 검증 로직이 필요한 모든 곳에서 동일한 정규식을 반복해서 작성하게 됩니다.
-
도메인 지식이 코드에 명확히 드러나지 않습니다. 다른 개발자가 코드를 읽을 때 각 필드의 제약조건이나 사용 규칙을 알기 어렵습니다.
값 객체를 통한 해결 방법
값 객체(Value Object)는 이러한 문제들을 해결하는 효과적인 방법을 제공합니다. 값 객체는 도메인의 작은 개념을 캡슐화하여 해당 개념과 관련된 모든 규칙과 동작을 한 곳에서 관리할 수 있게 해줍니다.
예를 들어, 이메일 주소를 다음과 같이 값 객체로 표현할 수 있습니다:
public final class Email {
private final String address;
public Email(String address) {
// 생성 시점에 유효성을 검증하여 무결성을 보장합니다
if (address == null || !isValidEmailFormat(address)) {
throw new IllegalArgumentException(
String.format("잘못된 이메일 형식입니다: %s", address));
}
this.address = address;
}
private boolean isValidEmailFormat(String email) {
// 이메일 검증 로직이 한 곳에 집중되어 있습니다
return email.matches("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$");
}
public String getUsername() {
// 이메일과 관련된 유용한 기능을 제공할 수 있습니다
return address.split("@")[0];
}
// equals, hashCode, toString 구현은 생략
}금액을 표현하는 Money 값 객체도 만들어보겠습니다:
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("금액과 통화는 필수값입니다.");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("금액은 0보다 작을 수 없습니다.");
}
// BigDecimal의 scale을 통화에 맞게 조정합니다
this.amount = amount.setScale(currency.getDefaultFractionDigits());
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("서로 다른 통화간 연산은 불가능합니다.");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(double rate) {
if (rate < 0) {
throw new IllegalArgumentException("음수 비율은 적용할 수 없습니다.");
}
return new Money(this.amount.multiply(BigDecimal.valueOf(rate)), this.currency);
}
}이제 이러한 값 객체들을 활용하여 주문 클래스를 다시 작성해보겠습니다:
public class Order {
private final Email customerEmail;
private Money price;
private PhoneNumber contactNumber;
public void applyCoupon(DiscountRate rate) {
// 할인율 계산이 더 안전하고 명확해졌습니다
this.price = price.multiply(rate.toMultiplier());
}
}값 객체 도입 시 고려사항
값 객체를 실제 프로젝트에 도입할 때는 다음 사항들을 고려해야 합니다:
-
데이터베이스 매핑 전략을 결정해야 합니다. JPA를 사용한다면 @Embeddable 애노테이션을 활용하여 값 객체를 매핑할 수 있습니다.
-
JSON 직렬화/역직렬화 방법을 정의해야 합니다. Jackson의 @JsonValue나 커스텀 직렬화기를 사용하여 처리할 수 있습니다.
-
기존 코드를 점진적으로 마이그레이션할 전략이 필요합니다. 한 번에 모든 코드를 변경하는 것은 위험할 수 있으므로, 중요한 도메인 개념부터 단계적으로 값 객체로 전환하는 것이 좋습니다.
결론
원시 집착은 단순해 보이지만 시간이 지날수록 코드의 품질을 저하시키는 중요한 문제입니다. 값 객체를 도입하면 도메인 개념을 더 명확하게 표현하고, 비즈니스 규칙을 더 안전하게 관리할 수 있습니다. 특히 도메인 주도 설계(Domain-Driven Design)를 적용하는 프로젝트에서는 값 객체의 활용이 필수적입니다.
추가로 학습하고 싶다면 다음 자료들을 참고하시기 바랍니다:
- 에릭 에반스의 ‘Domain-Driven Design’
- 마틴 파울러의 ‘Refactoring’
- 버논 본의 ‘Implementing Domain-Driven Design’