티스토리 뷰

VO는 도메인에서 의미를 가지는 값을 하나의 객체로 표현한 것이다.한번 생성되면 값이 변경될 수 없는 불변 객체이며, 값 자체가 같으면 객체로 취급한다.

"값 자체가 같으면 같은 객체로 취급한다."는 것은 일반적으로 Java에서 두 객체를 동등 연산자(==)로 비교하는 경우 메모리 주소가 같은지 비교한다. 하지만 VO는 내부의 값이 같으면 동일한 객체로 판단해야 한다. 이를 위해 VO는 equals()hashCode()를 반드시 오버라이드 해야한다.

VO 도입 기준

일반 필드를 VO 변환하는 기준은 다음과 같다. 아래 기준 중 어느 하나라도 해당하면 VO도입을 고려해야 한다.

1. 여러 필드가 모여서 하나의 완전한 의미를 만들 때

독립적으로 존재할 때 의미가 불완전한 필드들은 VO로의 전환을 고려해야 한다.

  • amount만 있고 currency가 없으면 '1000'이 원인지 달러인지 알 수 없다. -> 불완전
  • street만 있고 city, zone이 없으면 주소가 어디인지 알 수 없다. -> 불완전
  • startDate만 있고 endDate가 없으면 기간을 나타낼 수 없다 -> 불완전

반대로 아래와 같은 경우는 VO로의 변환이 필요 없다.

  • email 은 그 자체로 "이메일 주소"라는 의미가 완전하다.
  • name 은 그 자체로 "사람의 이름"이라는 의미가 완전하다.

2. 비즈니스 규칙이 흩어지면 관리가 힘들겠다고 판단될 때

먼저 비즈니스 규칙이란 현재 시스템에서 특정 필드가 가져야할 규칙을 의미한다.
예를 들어 주문 서비스에서 주문하는 물건의 수량을 나타내는 필드가 존재할 때 수량은 1개 이상을 가져야한다는 규칙을 가질 수 있다. 이와 같이 데이터가 가져야하는 규칙이 비즈니스 규칙이다.

그렇다면 비즈니스 규칙을 가진 모든 필드들이 VO로 변환되어야하는가?
아니다.
비즈니스 규칙이 단 1개이고 사용처도 1곳이라면 VO로 분리하는 것이 오히려 유지보수 비용을 증가시킬 수 있다.
따라서 아래와 같은 기준에 부합한다면 VO로의 변환을 고려해볼만 하다.

1. 비즈니스 규칙이 여러곳에서 동일하게 반복될 때

// 회원가입 서비스
if (!email.contains("@")) {
    throw new IllegalArgumentException("이메일 형식 오류");
}

// 주문 서비스
if (!email.contains("@")) {
    throw new IllegalArgumentException("이메일 형식 오류");
}

// 뉴스레터 서비스
if (!email.contains("@")) {
    throw new IllegalArgumentException("이메일 형식 오류");
}

위와 같이 구현되어 있는 경우 이메일에 대한 비즈니스 규칙이 변경되면 세 곳을 다 찾아서 수정해야하는 문제가 발생한다. 이런 경우가 바로 흩어져서 관리가 힘든 상태를 의미하며 이 경우에 VO로의 변환이 필요하다.

2. 검증 규칙이 2개 이상으로 복잡해질 때

// 비밀번호 검증이 서비스에 이만큼 들어가 있으면
if (password == null) {
    throw new IllegalArgumentException("비밀번호 필수");
}
if (password.length() < 8) {
    throw new IllegalArgumentException("8자 이상");
}
if (password.length() > 20) {
    throw new IllegalArgumentException("20자 이하");
}
if (!password.matches(".*[A-Z].*")) {
    throw new IllegalArgumentException("대문자 포함");
}
if (!password.matches(".*[!@#$%].*")) {
    throw new IllegalArgumentException("특수문자 포함");
}

위와 같이 하나의 필드를 위한 다수의 검증 로직이 존재하는 경우 해당 로직으로 인해 서비스나 도메인 객체의 본래 책임이 흐려지게 된다. 이럴 때 VO로 분리하면 각 계층의 책임이 뚜렷하게 드러나게 된다.

VO 적용 사례

주문 도메인 객체가 아래와 같이 구성되어 있다고 하자.

// Entity
public class Order {  
    private Long id;  
    private String street;  
    private String city;  
    private String zipCode;  
    private int quantity;  
    private long price;  
    private String currency;  
    private String receiverName;  
    private String receiverPhone;  

    //주문 객체는 생성 기능을 갖고 있다.  
    public Order create(String street, String city, String zipCode, int quantity, long price, String currency, String receiverName, String receiverPhone) {  
        if(street == null || street.isBlank()) {  
            throw new IllegalArgumentException("도로명 필수");  
        }  
        if(city == null || city.isBlank()) {  
            throw new IllegalArgumentException("도시명 필수");  
        }  
        if(zipCode == null || !zipCode.matches("^\\d{5}$")) {  
            throw new IllegalArgumentException("우편번호는 5자리 숫자");  
        }  
        if(quantity < 1) {  
            throw new IllegalArgumentException("수량은 1 이상");  
        }  
        if(price < 0) {  
            throw new IllegalArgumentException("가격은 0 이상");  
        }  
        if(currency == null || !currency.matches("^(KRW|USD|JPY)$")) {  
            throw new IllegalArgumentException("지원하지 않는 통화");  
        }  
        if(receiverName == null || receiverName.isBlank()) {  
            throw new IllegalArgumentException("수령인 이름 필수");  
        }  
        if(receiverPhone == null || !receiverPhone.matches("^010\\d{8}$")) {  
            throw new IllegalArgumentException("수령인 연락처 형식 오류");  
        }  
        Order order = new Order();  
        order.street = street;  
        order.city = city;  
        order.zipCode = zipCode;  
        order.quantity = quantity;  
        order.price = price;  
        order.currency = currency;  
        order.receiverName = receiverName;  
        order.receiverPhone = receiverPhone;  
        return order;  
    }  

    public void changeAddress(String street, String city, String zipCode) {  
        // 주소 검증이 또 등장  
        if(street == null || street.isBlank()) {  
            throw new IllegalArgumentException("도로명 필수");  
        }  
        if(city == null || city.isBlank()) {  
            throw new IllegalArgumentException("도시명 필수");  
        }  
        if(zipCode == null || !zipCode.matches("^\\d{5}$")) {  
            throw new IllegalArgumentException("우편번호는 5자리 숫자");  
        }  
        this.street = street;  
        this.city = city;  
        this.zipCode = zipCode;  
    }  

    public void changeReceiver(String name, String phone) {  
        if(name == null || name.isBlank()) {  
            throw new IllegalArgumentException("수령인 이름 필수");  
        }  
        if(phone == null || !phone.matches("^010\\d{8}$")) {  
            throw new IllegalArgumentException("수령인 연락처 형식 오류");  
        }  
        this.receiverName = name;  
        this.receiverPhone = phone;  
    }  

    public void addShippingFee(long feeAmount, String feeCurrency) {  
        if(feeAmount < 0) {  
            throw new IllegalArgumentException("배송비는 0원 이상");  
        }  
        if(!this.currency.equals(feeCurrency)) {  
            throw new IllegalArgumentException("통화가 다르면 합산 불가");  
        }  
        this.price = this.price + feeAmount;  
    }  
}

위 코드에서 문제점을 살펴보면 다음과 같다.

  • 주소 검증이 createchangeAddress에 중복되어 있다.
  • 수령인 검증이 createcalculateTotalPrice에 중복되어 있다.
  • street, city, zipCode는 항상 함께 움직이는데 따로 관리된다.
  • pricecurrency는 항상 함께 움직이는데 분리되어 있다.

위 코드에 VO를 적용하면 아래와 같이 변경된다.

public class Order {  
    private Long id;  
    private Address address;  
    private int quantity;  
    private Money price;  
    private Receiver receiver;  

    public Order create(Address address, int quantity, Money price, Receiver receiver) {  
        if(quantity < 1) {  
            throw new IllegalArgumentException("수량은 1 이상");  
        }  
        Order order = new Order();  
        order.address = address;  
        order.quantity = quantity;  
        order.price = price;  
        order.receiver = receiver;  
        return order;  
    }  

    public void changeAddress(Address address) {  
        this.address = address;  
    }  

    public void changeReceiver(Receiver receiver) {  
        this.receiver = receiver;  
    }  

    public void addShippingFee(Money shippingFee) {  
        this.price = this.price.add(shippingFee);  
    }  
}  

public class Address {  
    private final String street;  
    private final String city;  
    private final String zipCode;  

    public Address(String street, String city, String zipCode) {  
        if(street == null || street.isBlank()) {  
            throw new IllegalArgumentException("도로명 필수");  
        }  
        if(city == null || city.isBlank()) {  
            throw new IllegalArgumentException("도시명 필수");  
        }  
        if(zipCode == null || !zipCode.matches("^\\d{5}$")) {  
            throw new IllegalArgumentException("우편번호는 5자리 숫자");  
        }  
        this.street = street;  
        this.city = city;  
        this.zipCode = zipCode;  
    }  
}  

public class Money {  
    private final long amount;  
    private final String currency;  

    public Money(long amount, String currency) {  
        if(amount < 0) {  
            throw new IllegalArgumentException("가격은 0 이상");  
        }  
        if(currency == null || !currency.matches("^(KRW|USD|JPY)$")) {  
            throw new IllegalArgumentException("지원하지 않는 통화");  
        }  
        this.amount = amount;  
        this.currency = currency;  
    }  

    public Money add(Money other) {  
        if(!this.currency.equals(other.currency)) {  
            throw new IllegalArgumentException("통화가 다르면 합산 불가");  
        }  
        return new Money(this.amount + other.amount, this.currency);  
    }  
}  

public class Receiver {  
    private final String name;  
    private final String phone;  

    public Receiver(String name, String phone) {  
        if(name == null || name.isBlank()) {  
            throw new IllegalArgumentException("수령인 이름 필수");  
        }  
        if(phone == null || !phone.matches("^010\\d{8}$")) {  
            throw new IllegalArgumentException("수령인 연락처 형식 오류");  
        }  
        this.name = name;  
        this.phone = phone;  
    }  
}

Order 엔티티는 데이터 검증의 책임에서 벗어났고 상태 변경이라는 본래의 책임만 갖게 되었다. 중복 코드는 사라지고, 각 개념의 의미 또한 명확해졌다. Moneyadd 메서드처럼 해당 값과 관련된 행위도 VO 내부에 자연스럽게 위치하게 된다. 이와 같이 VO를 적재적소에 활용한다면 코드의 구조는 훨씬 더 개선될 것이다.

정리

VO는 데이터를 의미있는 객체로 감싸는 것이다. 값이 같으면 같은 객체이며, 한 번 생성되면 변경할 수 없다.

VO 분리 기준은 다음과 같으며, 어느 하나라도 행당하면 도입을 고려해봐야 한다.

  1. 여러 필드가 모여 하나의 의미를 구성하는 경우
  2. 동일한 검증 로직이 여러곳에서 반복되는 경우
  3. 검증 규칙이 복잡하여 본래 객체의 책임을 흐리는 경우(1개 초과)

 

'언어 > Java' 카테고리의 다른 글

테스트 더블은 코드에서 어떻게 표현되는가  (0) 2026.02.14
VO는 왜 불변이어야 하는가?  (0) 2026.02.08
TDD 알아보기  (0) 2026.02.06
CompletableFuture 가이드  (0) 2026.01.01
애플리케이션 로그 분석 가이드  (0) 2025.12.28
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함