[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법

2025. 5. 30. 18:28·DDD
반응형

시작하며


안녕하세요 개발자 stark입니다.

최근 제가 업무 중에 선배님과 애그리거트(Aggregate) 설계에 대한 토의를 하던 중 VO에 대해서 많은 얘기가 오갔습니다. 저희는 어떤 시점에 VO를 어떻게 사용하는 것이 적절한지 얘기를 나누었습니다. 우선 식별자를 통해 값을 구별할 필요가 없다는 것부터 시작해서 VO안에 비즈니스를 담을 수 있고 불변의 장점이 있다는 것까지 얘기가 진행되었습니다. 그런데 얘기를 다 하고 나서 생각해 보니 무언가 핵심을 놓친 것만 같았습니다. 그게 무엇인가 했더니 VO를 어떻게(how) 사용하는지는 알고있는데 왜(why) 사용하는지는 모르고 있었기에 핵심 개념에 대해서는 하나도 얘기를 하지 않았던 것입니다.

 

그래서 바로 블라드 코노노프가 지은 '도메인 주도 설계 첫걸음'이라는 책을 구매해서 읽었습니다. 책에서는 VO 사용의 장점을 "도메인에서 유비쿼터스 언어(Ubiquitous Language)를 사용하게 되므로 도메인 용어를 표현하게 된다."라고 표현하고 있었고 이게 무슨 말인지 깊이 고민해 본 저는 드디어 VO가 DDD의 핵심 사상을 담고 있으며, 소프트웨어를 해당 분야의 전문가와 개발자가 동일한 시각으로 바라보고 소통할 수 있게 만드는 중요한 개념인 유비쿼터스 언어를 표현하는데 사용된다는 것을 알게 되었습니다.

 

지금부터 이것이 무슨 의미인지, 왜 중요한지 쉽고 자세하게 설명해 드리겠습니다.

 

 

유비쿼터스 언어(Ubiquitous Language)란?


DDD에서 가장 먼저 강조하는 것 중 하나가 바로 유비쿼터스 언어의 구축입니다. 유비쿼터스 언어는 특정 도메인(예: 온라인 쇼핑몰, 은행 시스템, 병원 관리 시스템 등)에 관련된 모든 사람들이 '공통'으로 사용하고 이해하는 단일화된 용어 체계입니다. 이 언어는 도메인 전문가(현업 담당자, 기획자 등)와 소프트웨어 개발팀 모두가 함께 만들어가고 동의한 언어입니다.

 

예를 들어 온라인 쇼핑몰 도메인에서 '고객이 물건을 장바구니에 담고, 주문하고, 결제한다'라고 할 때, '고객', '물건', '장바구니', '주문', '결제' 등이 유비쿼터스 언어의 일부가 될 수 있습니다. 만약 개발팀은 '사용자(User)'라고 부르고, 현업팀은 '회원(Member)'이라고 부른다면 혼란이 생깁니다. 유비쿼터스 언어는 이를 '고객(Customer)'과 같이 하나의 용어로 통일하는 것입니다.

 

유비쿼터스 언어를 사용하면 3가지 장점을 얻을 수 있습니다. 첫째, 의사소통이 명확해집니다. 왜냐하면 모든 관련자들이 같은 용어를 사용해서 소통하게 된다면 오해의 소지가 줄어들 것이기 때문입니다. 둘째, 요구사항을 정확히 반영할 수 있습니다. 도메인 전문가의 지식과 요구사항이 소프트웨어 모델에 정확하게 반영될 가능성이 높아집니다. 셋째, 살아있는 모델을 구축할 수 있습니다. 비즈니스가 변화함에 따라 유비쿼터스 언어도 함께 발전하고, 이는 곧 소프트웨어 모델의 발전으로 이어집니다.

 

 

값 객체(Value Object, VO)란 무엇일까요?


값 객체는 도메인에서 어떤 값을 나타내는 객체입니다. 중요한 특징은 다음과 같습니다.

 

1. 식별성 없음(No Identity): 값 객체는 고유한 식별자를 가지지 않습니다. 예를 들어, '1000원'이라는 돈의 가치는 어떤 지폐로 표현되든 동일하게 '1000원'입니다. 두 개의 '1000원'짜리 Money 객체가 있다면, 그 객체들이 메모리상 다른 위치에 존재하더라도(객체의 주소값이 다르더라도) 그 안의 값(금액과 통화)이 같다면 동일한 것으로 취급합니다.

 

2. 불변성(Immutable): 일단 생성되면 그 상태(값)가 변하지 않습니다. 만약 다른 값이 필요하다면, 새로운 값 객체를 만들어야 합니다. '1000원' 객체에 '500원'을 더하고 싶다면, '1000원' 객체의 상태를 '1500원'으로 바꾸는 것이 아니라, 새로운 '1500원' 객체를 생성합니다.

 

3. 개념적 전체(Conceptual Whole): 여러 속성이 모여 하나의 의미 있는 값을 표현합니다. 예를 들어, '주소(Address)'라는 값 객체는 '우편번호', '도시', '상세 주소' 등의 속성을 가질 수 있으며, 이들이 모여 하나의 '주소'라는 개념을 나타냅니다.

 

4. 자기 유효성 검사(Self-Validation): 값 객체는 생성될 때 스스로 유효한 값인지 검사할 수 있습니다. 예를 들어, EmailAddress라는 값 객체는 이메일 형식이 올바른지 스스로 검증할 수 있습니다.

 

 

VO가 유비쿼터스 언어를 사용해 도메인 용어를 표현한다는 것의 의미


이제 핵심 질문으로 돌아와서, "VO를 사용한다는 것은 도메인에서 유비쿼터스 언어를 사용하게 되므로 도메인 용어를 표현하게 된다"는 것이 무슨 의미인지 살펴보겠습니다. 이는 소프트웨어 코드 안에 도메인의 개념과 용어를 그대로 녹여내어, 코드가 마치 도메인에 대한 설명서처럼 읽히도록 만드는 과정을 의미합니다.

 

예시를 통해 이해해 봅시다. 지금부터 온라인 쇼핑몰에서 '상품 가격'과 '배송 주소'를 다룬다고 가정해 보겠습니다. 먼저 VO를 사용하지 않는 경우 (Primitive Obsession - 기본 타입 집착)의 코드를 살펴봅시다.

// 상품 정보
String productName;
int priceAmount; // 그냥 정수형 가격
String currency; // 문자열 통화

// 배송 정보
String shippingZipCode;
String shippingCity;
String shippingStreet;

위 코드의 문제점은 다음과 같습니다.

  • priceAmount가 1000인지 10000인지, currency가 "KRW"인지 "USD"인지 등, 이 값들이 무엇을 의미하는지 명확하지 않습니다. 맥락을 모르면 이것들은 단순한 숫자와 문자열일 뿐입니다.
  • '상품 가격'이라는 도메인 용어나 '배송 주소'라는 도메인 용어가 코드에 직접적으로 드러나지 않습니다. 개발자는 주석이나 주변 코드를 통해 의미를 파악해야 합니다.
  • 가격에 대한 연산(예: 할인 적용, 합산)이나 주소 형식 검증 같은 로직이 해당 변수들을 사용하는 여러 곳에 흩어져 중복될 가능성이 큽니다.

 

이제 VO를 사용해서 재구성해봅시다.

먼저, 도메인 전문가와 논의하여 '상품 가격'은 '금액(Money)'이라는 개념으로, '배송 주소'는 '주소(Address)'라는 개념으로 유비쿼터스 언어를 정의합니다. 그리고 이 용어들을 사용해 VO를 만듭니다.

// Money VO (금액)
public class Money {

    private final int amount;    // 유비쿼터스 언어: '금액'
    private final String currency; // 유비쿼터스 언어: '통화'

    public Money(int amount, String currency) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0보다 작을 수 없습니다.");
        }
        if (currency == null || currency.isBlank()) {
            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);
    }

    // equals(), hashCode() 등 재정의 (값으로 비교하기 위해)
    // ... 기타 금액 관련 로직 (할인, 곱셈 등)
    
}
// Address VO (주소)
public class Address {

    private final String zipCode;  // 유비쿼터스 언어: '우편번호'
    private final String city;     // 유비쿼터스 언어: '도시'
    private final String street;   // 유비쿼터스 언어: '도로명주소'

    public Address(String zipCode, String city, String street) {
        // 유효성 검사 등
        this.zipCode = zipCode;
        this.city = city;
        this.street = street;
    }

    // equals(), hashCode() 등 재정의
    // ... 기타 주소 관련 로직 (주소 형식 검증, 전체 주소 문자열 반환 등)
    
}

이제 상품 정보와 배송 정보를 VO를 사용해 표현합니다.

// 상품 정보
String productName;
Money price; // 'Money'라는 도메인 용어(VO)로 가격을 표현

// 배송 정보
Address shippingAddress; // 'Address'라는 도메인 용어(VO)로 주소를 표현

 

 

이렇게 VO를 사용함으로써 얻는 효과는?


1. 도메인 용어의 명시적 표현

  • 상품 정보를 표현하던 코드에서 int priceAmount 대신 Money price를 사용함으로써, '가격'이라는 단순한 숫자가 아니라 '금액'이라는 도메인의 특정 개념을 다루고 있음을 명확히 알 수 있게 되었습니다. 또한 Money라는 클래스 이름 자체가 유비쿼터스 언어에서 가져온 도메인 용어입니다. 마찬가지로 Address shippingAddress는 이것이 단순한 문자열들의 집합이 아니라, '배송 주소'라는 의미 있는 도메인 개념임을 코드 수준에서 표현하게 되었습니다.
// 상품 정보
String productName;
int priceAmount; // 그냥 정수형 가격
String currency; // 문자열 통화

// 배송 정보
String shippingZipCode;
String shippingCity;
String shippingStreet;

// VO를 사용한다면 이렇게 된다.

// 상품 정보
String productName;
Money price; // 'Money'라는 도메인 용어(VO)로 가격을 표현

// 배송 정보
Address shippingAddress; // 'Address'라는 도메인 용어(VO)로 주소를 표현

2. 코드의 가독성 및 의도 명확성 향상

  • product.getPrice()를 통해 반환하는 값이 단순 int가 아니라 Money 객체임을 알면, 이 값에 금액과 관련된 특정 행위(예: add(), isGreaterThan())가 포함될 수 있음을 예상할 수 있습니다. 또한 order.updateShippingInfo(new Address("12345", "서울시", "강남대로")) 와 같이 코드를 작성하면, 마치 도메인 전문가가 말하는 것처럼 자연스럽게 읽힙니다. "주문 정보를 새로운 주소(우편번호 '12345', 도시 '서울시', 도로명 '강남대로')로 변경한다."
order.updateShippingInfo(new Address("12345", "서울시", "강남대로"))

// 아래와 같이 읽힌다.
주문 정보를 새로운 주소(우편번호 '12345', 도시 '서울시', 도로명 '강남대로')로 변경한다.

3. 도메인 규칙 및 로직의 캡슐화

  • Money VO 안에 금액 관련 규칙(음수 불가, 통화 일치 시 덧셈 가능 등)을 포함시킬 수 있습니다. 이렇게 하면 가격 관련 로직이 Money VO로 집중되어 응집도가 높아지고, 다른 곳에서 중복되거나 잘못 사용될 가능성이 줄어듭니다. Address VO는 주소 형식 검증이나, 특정 국가의 주소 체계에 맞는 로직을 가질 수 있습니다.
// Money VO (금액)
public class Money {

    private final int amount;    // 유비쿼터스 언어: '금액'
    private final String currency; // 유비쿼터스 언어: '통화'

    public Money(int amount, String currency) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0보다 작을 수 없습니다.");
        }
        if (currency == null || currency.isBlank()) {
            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);
    }

    // equals(), hashCode() 등 재정의 (값으로 비교하기 위해)
    // ... 기타 금액 관련 로직 (할인, 곱셈 등)
    
}

4. 오류 감소 및 안정성 증가

  • Money 타입을 사용하면 실수로 가격에 일반 정수를 더하거나, 다른 종류의 숫자와 혼용하는 것을 컴파일 시점에 방지할 수 있습니다. 예를 들어 아래 첫 번째 예시코드를 보면 의미가 코드에 명확히 드러나지 않지만 두 번째 예시코드에서는 명확하게 드러납니다. 또한 특정 값(interestRate)을 더할 때 단순 연산자를 사용하더라도 컴파일 시점에 방어가 가능한 것을 확인할 수 있습니다.
// 상품 가격을 그냥 int로 표현
int priceAmount = 10000;
String currency = "KRW";
int quantity = 2;

// 단순 숫자 곱셈 -> 의미가 코드에 명확히 드러나지 않음
int totalPriceAmount = priceAmount * quantity;
System.out.println("총액: " + totalPriceAmount + " " + currency); // 총액: 20000 KRW

// 실수 가능성: 가격(금액)에 이자율(%) 같은 다른 종류의 숫자를 실수로 더해도 컴파일러는 모름
int interestRate = 5; // 5%
// int problematicCalculation = priceAmount + interestRate; // 논리적 오류지만 컴파일은 됨!
class Money {

    private final int amount;
    private final String currency;

    public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
        // 생성 시 유효성 검사 등 추가 가능
    }

    // "금액"에 "수량(정수)"을 곱하는 행위를 정의
    public Money multiply(int factor) {
        return new Money(this.amount * factor, this.currency);
    }
    
}

// 2. VO 사용
Money price = new Money(10000, "KRW"); // "금액" 타입으로 가격 표현
int quantity = 2;

// Money 객체의 multiply 메서드 사용 -> "금액을 수량만큼 곱한다"는 의미 명확
Money totalPrice = price.multiply(quantity);
System.out.println("총액: " + totalPrice); // 총액: 20000 KRW

// 실수 방지: Money 타입에 일반 정수를 직접 연산하려고 하면 컴파일 오류
int interestRate = 5;
// Money problematicCalculation = price + interestRate; // 컴파일 오류!
// Money problematicCalculation = price.add(interestRate); // Money.add(int)가 없으면 컴파일 오류!
                                                        // 만약 add(Money other)만 있다면,
                                                        // new Money(interestRate, "KRW")로 만들어 더해야 함.

5. 유비쿼터스 언어의 강화

  • 개발자와 도메인 전문가가 Money, Address와 같은 용어를 코드에서도 동일하게 사용함으로써, 유비쿼터스 언어는 더욱 공고해지고 실제 소프트웨어와 긴밀하게 연결됩니다. 만약 요구사항이 변경될 시 "배송지 주소 변경 정책이 바뀌었어요"라고 할 때, 개발자는 코드의 Address VO와 관련된 부분을 살펴보면 됩니다.

 

 

결론


DDD에서 VO를 사용한다는 것은, 도메인 전문가와 개발팀이 함께 정의한 유비쿼터스 언어의 용어들을 소프트웨어 코드 내에 클래스(VO)로 구체화하는 행위입니다. 이를 통해 코드는 단순한 데이터 덩어리가 아니라, 도메인의 개념, 규칙, 의미를 풍부하게 담고 있는 살아있는 모델이 됩니다.

 

결과적으로, 소프트웨어는 해당 도메인을 더 잘 이해하고 반영하게 되며, 이는 유지보수성, 확장성, 그리고 팀원 간의 의사소통 효율성을 크게 향상시키는 핵심적인 역할을 합니다. VO는 작지만 강력한 도구로, 도메인의 복잡성을 다루고 비즈니스 가치를 코드에 직접적으로 연결하는 데 기여합니다.

 

단순히 작성된 코드를 VO로 바꿔서 살아있는 모델을 구성해보시는것은 어떨까요?

반응형

'DDD' 카테고리의 다른 글

DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?  (0) 2025.06.01
[DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?  (0) 2025.05.31
[DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략  (1) 2025.05.06
코드에 비즈니스의 가치를 담자  (0) 2025.05.03
[DDD] Aggregate 당 하나의 Repository, 과연 최선인가?  (0) 2025.04.19
'DDD' 카테고리의 다른 글
  • DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?
  • [DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?
  • [DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략
  • 코드에 비즈니스의 가치를 담자
Stark97
Stark97
문의사항 또는 커피챗 요청은 링크드인 메신저를 보내주세요! : https://www.linkedin.com/in/writedev/
  • Stark97
    오늘도 개발중입니다
    Stark97
  • 전체
    오늘
    어제
    • 분류 전체보기 (247)
      • 개발지식 (20)
        • 스레드(Thread) (8)
        • WEB, DB, GIT (3)
        • 디자인패턴 (8)
      • JAVA (21)
      • Spring (88)
        • Spring 기초 지식 (35)
        • Spring 설정 (6)
        • JPA (7)
        • Spring Security (17)
        • Spring에서 Java 활용하기 (8)
        • 테스트 코드 (15)
      • 아키텍처 (6)
      • MSA (15)
      • DDD (11)
      • gRPC (9)
      • Apache Kafka (18)
      • DevOps (23)
        • nGrinder (4)
        • Docker (1)
        • k8s (1)
        • 테라폼(Terraform) (12)
      • AWS (32)
        • ECS, ECR (14)
        • EC2 (2)
        • CodePipeline, CICD (8)
        • SNS, SQS (5)
        • RDS (2)
      • notion&obsidian (3)
      • AI 탐험대 (1)
      • 팀 Pulse (0)
  • 링크

    • notion기록
    • 깃허브
    • 링크드인
  • hELLO· Designed By정상우.v4.10.0
Stark97
[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법
상단으로

티스토리툴바