DDD 2. 값 객체

    요약

    • DDD에서 이야기하는 값 객체는 '시스템 특유의 값'을 나타내는 객체임. (String으로 표현하기 보다, FullName 클래스를 만드는게 더 좋음) 
    • 값객체는 '값'의 특성을 지켜서 만들어야 함.
      • 불변해야함. (Setter 제공 X)
      • 교환가능해야함. (대입식으로 처리 가능해야함) 
      • 비교 가능해야함. (equals 구현)
    • 값 객체가 되기 위한 기준
      • 도메인으로 사용되는 객체일 때
      • 분리해서 사용되거나, 특정 규칙이 있을 때
    • 값 객체의 장점
      • 표현력이 좋아짐. (필드로 각각이 무엇을 하는 것인지 명확하게 표현해 줌.)
      • 무결성이 좋아짐. (특정 필드에 대한 제약조건을 확인해야 할 때, 값객체 내에 모아둘 수 있음)
      • 잘못된 대입 방지. (Primitive 타입을 이용하면, 잘못된 값을 대입해도 컴파일러가 잡아주지 못함.)
      • 로직 모아두기 (값객체에 관련된 메서드를 모아둘 수도 있고, 무결성 조건들도 다 포함해 버릴 수도 있음. ) 

     


    2-1 값 객체란? 

    public static void main(String[] args) {
        String fullName = "naruse masanobu";
        String[] tokens = fullName.split(" ");
    
        String lastName = tokens[0];
        String firstName = tokens[1];
    
        System.out.printf("lastName : %s, firstName : %s", lastName, firstName);
    }
    • 일반적으로 String, int 같은 Primitive 타입을 이용해서 값을 나타낸다. 그러나 Primitive 타입만 이용해서 코드를 만들 수는 없다. 
    • Primitive Type이 값을 나타내는 것처럼 시스템에 필요한 값을 표현하는 특정 클래스를 사용할 수 있는데, 이런 것을 '값 객체'라고 부른다.

    위의 코드는 단단하지 못한 코드다. 입력이 잘못될 수도 있고, tokens[0]가 무엇을 의미하는 것인지를 쉽게 알 수 없기 때문이다. 이 경우 fullname을 Primitive Type이 아닌 값 객체로 사용하면서 해결할 수 있다.

    @Getter
    @RequiredArgsConstructor
    static class FullName { // 값객체 타입 선언
        private final String firstName;
        private final String lastName;
    }
    
    public void main() {
        // 값 객체 사용
        FullName fullName = new FullName("naruse", "masanobu");
        String firstName = fullName.getFirstName();
        String lastName = fullName.getLastName();
    }

    String을 사용하는 대신 FullName이라는 클래스를 생성해서 사용한다. 값 객체를 사용해서 Primitive 타입만 사용했을 때의 단점을 개선하는 것이다.

    • 생성자를 통해 Invalid한 Input을 걸러낼 수 있음. 
    • getter()를 통해 필요한 필드를 명시적으로 얻을 수 있음. 

     


    2-2. 값의 성질과 값 객체 구현

    일반적인 값의 성질은 다음과 같다. 

    • 불변함. (예를 들어 1이라는 값을 2로 바꿀 수 없다. 값의 할당만 바꾸는 것임) 
    • 교환 가능하다. (변수에 새로운 객체를 할당할 수 있어야 함)
    • 값을 비교할 수 있음. (equals 등을 이용해 비교할 수 있다) 

    이 관점에서 값 객체를 어떻게 구현할지 고려해보자. 

     

    값의 불변성

    @RequiredArgsConstructor
    @Getter
    static class FullName {
        private final String firstName;
        private final String lastName;
    }
    • 값은 불변성을 가진다. 따라서 값객체도 불변해야한다. 객체가 생성될 때 지정된 값이 변하면 안된다는 것을 의미한다. 
    • 이를 달성하기 위해 Setter, Update 같은 메서드를 제공하면 안된다. 

     

    교환 가능해야함. 

    FullName fullName = new FullName("naruse", "masanobu");
    fullName = new FullName("new", "name");
    • 값객체는 불변값이다. 따라서 값을 수정하려면, 값객체를 새로 생성해서 교환해주는 작업을 해야한다. 
    • 일반적인 자바 클래스는 대입문에서 교환 가능하기 때문에 값 객체는 교환가능하다. 

     

    비교할 수 있어야 함.

    public void executeEqauls() {
        // 값객체 그 자체로 비교하는 것이 좋음. 
        FullName fullName1 = new FullName("naruse", "masanobu");
        FullName fullName2 = new FullName("naruse", "masanobu");
        fullName1.equals(fullName2);
        
        // 필드를 꺼내서 비교하는 것은 어색하다. 
        String f1 = fullName1.getFirstName();
        String f2 = fullName2.getFirstName();
        f1.equals(f2);
        
        String l1 = fullName1.getLastName();
        String l2 = fullName2.getLastName();
        l1.equals(l2);
    }
    • 숫자, 문자 값들을 서로 비교할 수 있다. 값객체도 비교할 수 있어야한다. 
    • 값객체끼리 비교를 위해 equals()를 오버라이드해서 equals() 메서드로 비교할 수 있게 만들면 된다. 
      • 값 객체의 비교는 기본적으로 값객체가 가지고 있는 내부 필드가 모두 같은 값인지를 확인하는 형식으로 구현된다.

    값 객체의 필드를 하나씩 꺼내서 비교하는 것은 어색하다. 따라서 값객체끼리 값을 비교할 수 있도록 equals()를 오버라이드해서 제공해주는 것이 좋다. 이처럼 값객체 비교를 메서드로 제공해주게 되면, 값객체에 새로운 필드가 추가되어도 코드를 수정해야 할 부분이 매우 적어진다는 장점이 있다. 

        String f1 = fullName1.getFirstName();
        String f2 = fullName2.getFirstName();
        f1.equals(f2);
        
        String l1 = fullName1.getLastName();
        String l2 = fullName2.getLastName();
        l1.equals(l2);

    예를 들어 여기서 middleName이 추가된다면, 위 코드가 나타나는 모든 곳에 middleName을 비교하는 코드를 하나씩 추가해야하며,수정범위가 굉장히 넓어진다. 반면 equals()를 이용해 값객체를 비교한다면, equals()에 새롭게 middleName을 비교하는 코드만 추가하면 된다. 또한, 값객체 비교 메서드에 middleName을 넣어서 고려하는 시점도 정할 수 있게 된다. 


    2.3 값 객체가 되기 위한 기준

    Primitive Type을 값객체로 사용하는 기준은 다음으로 살펴보면 좋을 것 같다.

    • 일반적인 도메인 객체는 최소한 값객체로 만듦. 
    • Primitive Type을 사용하는 필드중 제약조건이 있거나, 분할되어서 사용해야할 필요가 있는 경우 값객체로 사용. 

    위 기준을 이용해서 값객체를 정의해서 사용할 수 있다. 이 때 어느정도 수준까지 값객체를 정의해야할지 고민이 될 수도 있다.

    // FirstName, LastName 각각 타입 설정.
    static class FirstName{};
    static class LastName{};
    static class FullName {
        private final FirstName firstName;
        private final LastName lastName;
        public FullName(FirstName firstName, LastName lastName) {
            this.firstName = firstName;
            this.lastName = lastName;
        }
    }

    FirtsName과 LastName을 각각 타입 선언해서 사용하는 경우다. FirstName, LastName이 서로 다른 제약조건을 가진다거나 쓰임이 다른 경우에 유용하게 사용할 수 있을 것이다.

    // Name 타입만 설정 후 사용하기.
    static class Name{};
    static class FullName {
        private final Name firstName;
        private final Name lastName;
        public FullName(Name firstName, Name lastName) {
            this.firstName = firstName;
            this.lastName = lastName;
        }
    }

    Name이라는 값객체 클래스를 선언하고, 각각을 FirstName + LastName에서 사용하는 경우다. FirstName, LastName을 나누어서 사용은 하지만 제약조건이 나누어지지 않은 경우에 이런 식으로 나눠쓸 수도 있다. 

    위에서 볼 수 있듯이 값객체를 어느정도 Depth까지 나누어서 사용해야 할지는 애매한 문제다. 처음부터 완벽하게 값객체를 선언하는 것은 어렵다. 코드를 작성하다가 값 객체로 정의할만한 가치가 있는 개념을 발견했다면 그 개념을 값 객체로 전환하는 형태가 좋을 것이다.


    2.4 행동이 정의된 값객체

    값객체는 클래스로 구현한다. 이 말은 값객체에 필요한 행동을 추가할 수 있다. 이것은 무엇을 의미할까?

    • 값객체에 행동을 정의하면, 값객체가 할 수 있는 행동만 알려준다. 즉, 할 수 없는 행동은 하지 않을 수 있도록 강제할 수 있다.
    • 값객체는 단순한 값을 나타내는 클래스에서 값객체의 행동까지도 정의할 수 있다. 이런 행동이 커지면, 언젠가는 엔티티로 발전할 수도 있을 것임. 
    static class Money{
        private final int amount;
    
        public Money(int amount) {
            if (amount < 0) {
                throw new IllegalArgumentException("잘못된 돈입니다.");
            }
            this.amount = amount;
        }
    
        public Money add(Money other) {
            return new Money(this.amount + other.amount);
        }
    }

    위 코드에서는 Money 값객체에 add() 메서드만 구현했다. 이것은 다음을 의미한다.

    • Money 값객체끼리는 더해서 사용할 수 있음. 
    • Money 값객체끼리는 곱셈 같은 것이 지원되지 않음. 

     


    2.5 값 객체를 도입했을 때의 장점. 

    값 객체를 도입했을 때의 단점은 명확한데, 많은 클래스가 포함되어야 한다는 것이다. 많은 클래스가 도입되면 코드를 읽기가 어려워 질 수 있다. 그렇다면 값객체를 도입했을 때의 장점은 무엇일까? 

    • 표현력이 증가한다.
    • 무결성이 유지된다.
    • 잘못된 대입을 받지함. 
    • 로직이 코드 이곳저곳에 흩어지는 것을 방지함. 

     

    표현력이 증가함.

    @Getter
    @RequiredArgsConstructor
    static class ModelNumber{
        private final String productCode;
        private final String branch;
        private final String lot;
    
        @Override
        public String toString() {
            return String.format("%s-%s-%s", productCode, branch, lot);
        }
    }
    
    public static void main(String[] args) {
        // String만 이용하면 표현력이 떨어짐. 
        String stringModelNumber = "a20421-100-1";
        
        // 값객체로 사용하면 표현력이 증가함. 
        ModelNumber modelNumber = new ModelNumber("a20421", "100", "1");
    }
    • a20421-100-1로 모델넘버를 그대로 사용하면, a20421, 100, 1이 각각 무엇을 의미하는지 알 수 없다. 추가 개발이 필요할 때 각각이 무엇을 의미하는지를 제대로 알고 개발해야하는 경우, 문서를 직접 뒤지며 개발해야한다. 
    • ModelNumber 클래스를 만들어, 필드 이름으로 이것을 표현해주면 문서를 검색할 필요가 없다. 즉, 값객체를 사용하면 표현력이 좋아짐. 

     

    무결성의 유지

    // 값객체 생성 시, 무결성 제약조건 추가. 
    // 규칙이 바뀌면, 이 부분의 코드만 수정하면 됨. 
    public ModelNumber(String productCode, String branch, String lot) {
        // 무결성 제약조건 검사하기
        if (!(StringUtils.hasText(productCode) ||
                StringUtils.hasText(branch) ||
                StringUtils.hasText(lot))) {
            throw new IllegalArgumentException("잘못된 인수입니다.");
        }
                
        this.productCode = productCode;
        this.branch = branch;
        this.lot = lot;
    }
    • 각 값마다 지켜야 하는 규칙들이 있을 것이다. 이런 규칙의 예로 이메일 주소는 @가 반드시 포함되어야함 이라는 것이 있을 것이다.
    • 이런 규칙들은 값 객체를 생성할 때 유효성 검사를 추가해서 처리할 수 있다. 규칙이 바뀌면, 값객체 내부의 유효성 검사 관련 메서드만 수정하면 된다. (수정범위가 좁다) 
    • 값객체 바깥에서 유효성 검사가 추가되면, 전체 코드를 다 뒤져서 이런 부분을 고쳐야한다. 코드 수정 범위가 넓어진다는 단점이 있다. 

     

    잘못된 대입 방지하기

    String lastName = tokens[0];
    String firstName = tokens[1];
    
    // 같은 String 타입이라 대입해도 컴파일 에러가 나지 않음. 
    lastname = firstName;

    Primitive 타입을 이용하는 경우, 서로 다른 값을 나타내는 것들끼리 대입할 수 있다. 이런 경우 컴파일 에러가 발생하지 않고 런타임 에러조차 발생하지 않을 수도 있다. 

    FirstName firstname = fullName.getFirstName();
    firstName = new LastName("lastName"); --> 컴파일 에러 발생.

    만약 값객체를 이용한다면 컴파일 에러로 해당 부분을 처리할 수 있다. 

     

    로직을 한 곳에 모아두기

    public ModelNumber(String productCode, String branch, String lot) {
        // 무결성 제약조건 검사하기
        if (!(StringUtils.hasText(productCode) ||
                StringUtils.hasText(branch) ||
                StringUtils.hasText(lot))) {
            throw new IllegalArgumentException("잘못된 인수입니다.");
        }
                
        this.productCode = productCode;
        this.branch = branch;
        this.lot = lot;
    }
    • 값객체가 필요로 하는 메서드들을 값객체 내에 선언할 수 있다. 이 덕분에 응집도있게 코드가 작성된다. 
    • 값객체와 관련된 모든 코드들을 이곳에 모아둘 수 있다. 예를 들어 무결성 확인 코드를 모아둘 수 있다.
      • create, update 메서드가 있을 때, 무결성 코드를 이곳에 넣을 수 있다. 이 값객체를 사용하는 사람들은 무결성 조건을 신경쓰지 않고, 단순히 호출하기만 하면 된다. 

    응집도 있게 작성되면 코드의 변경지점이 최소화 되고, 살펴봐야 할 클래스도 줄어들게 된다. 

    댓글

    Designed by JB FACTORY