본문 바로가기
OOP

값 객체(VO), 뭔지 알고 쓰자! 그리고 쓰는 이유는??

by nak_honest 2023. 10. 29.

값 객체(Value Object, VO)에 대해서는 객체지향 생활 체조 원칙 3번인 "모든 원시값과 문자열을 포장(wrap)한다." 내용에서 처음 알게 되었다. 처음에는 객체지향 생활 체조 원칙을 지키기 위해 정확한 개념과, 왜 쓰는지 모른채 원시값을 값 객체로 포장하려고 했었다.

하지만 그러다보니 엔티티와 값객체의 차이도 뭔지 몰랐고, 여러모로 공부가 필요하다고 느껴 추가적으로 공부했다.

먼저 도메인 주도 설계 철저 입문 책에서는 값 객체를 다음과 같이 설명한다.

값 객체는 시스템 특유의 값에 대한 표현이며, 값의 한 종류다.
-17p-

 

값 객체가 아닌 원시 타입으로 처리했을때(feat. 우테코 프리코스 1주차)


예를들어 숫자 야구 게임에서 사용하는 숫자를 단순히 int 변수로 처리한다고 해보자.
그런데 이 숫자에는 다음과 같은 도메인 규칙이 존재한다.

  • 게임에서 사용하는 숫자는 3자리 숫자이다.
  • 게임에서 사용하는 숫자는 서로 다른 수로 이루어진다.
  • 게임에서 사용하는 숫자는 1 ~ 9 의 숫자로 이루어진다.
    ...

만약 숫자를 원시 타입인 int 변수로 처리한다면, 해당 변수의 값을 초기화하거나 대입할때마다 위의 규칙을 만족하는지 매번 검증해야 한다.
이러한 코드가 여러군데 있다면, 위의 규칙을 적용하는 코드가 중복이 된다.
또한 도메인 규칙에 수정이 일어나는 경우 위의 규칙을 적용하는 모든 코드를 수정해야 하기 때문에 유지보수를 어렵게 만든다.

또한 객체지향 관점에서 보았을때 관련된 데이터를 묶어두지 않고 흩어두게 되면, 해당 도메인에 대한 개념이 외부로 공개되고 이는 캡슐화를 저하시키게 된다.

따라서 시스템에서 사용하는 특유의 값을 원시 타입이 아닌 객체로 다룰 수 있는데, 이러한 객체를 값 객체라고 한다.
다음은 위에서 살펴본 게임 숫자에 대한 값 객체 코드의 일부분이다.

public final class GameNumber {
    private static final int LENGTH = 3;

    private final int number;

    public GameNumber(int number) {
        this.number = number;
        validate();
    }

    private void validate() {
        validatePositiveNumber();
        String digits = Integer.toString(number);
        validateThreeDigit(digits);
        validateNumberHasZero(digits);
        validateDuplicate(digits);
    }

    private void validatePositiveNumber() {
        if (number <= 0) {
            throw new IllegalArgumentException("숫자가 음수입니다: " + number);
        }
    }

    private void validateThreeDigit(String digits) {
        if (digits.length() != LENGTH) {
            throw new IllegalArgumentException("세자리 숫자가 아닙니다: "+ digits);
        }
    }

    private void validateNumberHasZero(String digits) {
        if (digits.contains("0")) {
            throw new IllegalArgumentException("숫자에 0이 포함되어 있습니다: " + digits);
        }
    }

    private void validateDuplicate(String digits) {
        String[] uniqueDigits = Arrays.stream(digits.split(""))
                .distinct()
                .toArray(String[]::new);

        if (uniqueDigits.length != LENGTH) {
            throw new IllegalArgumentException("같은 숫자가 중복됩니다: " + digits);
        }
    }
    // ...
}

 

여기서 게임 숫자에 적용되는 규칙을 객체 내부에서 검증하기 때문에 위에서 발생한 유지보수에 대한 문제점이 해결된다.

값 객체의 특징

그렇다면 값 객체의 특징을 살펴보겠다.
먼저 값 객체는 이름에서 그 의미가 나타나듯이 값이면서 동시에 객체이다. 따라서 값의 특징을 값 객체 역시 보유하게 된다.
값 객체의 특징은 다음과 같다.

  • 불변성
  • 등가성(동등성)
  • 자가 유효성 검증

불변성

먼저 값 객체는 변화하지 않는다는 성질을 갖는다. 이는 값의 성질이기도 하다.
여기서 값 객체가 불변성을 가진다는 것은 값 객체가 알아서 변하지 않는 성질을 가진다는 것이 아니며, 값 객체를 불변 객체로 만들어야 한다는 것이다.
따라서 setter가 존재해서는 안되며, 가지고 있는 상태를 외부에 공개해서는 안된다.
불변 객체에 대한 내용은 추후에 다시 정리하겠다.

값 객체는 불변이라는 특징을 가지기 때문에 상태가 변화되지 않는다. 이는 프로그램에서 버그를 줄이는 강력한 방어책이 된다.
해당 객체의 상태가 변하지 않기 때문에 안심하고 값 객체를 공유할 수 있으며, 멀티 스레드, 멀티 프로세스 환경 등에서도 병렬 처리를 손 쉽게 할 수 있게 된다.

등가성(동등성)

일반적인 값은 동등성을 가진다. 예를 들어 int x = 1;int y = 1;에서 x가 가지고 있는 값과 y가 가지고 있는 값을 우리는 같다고 표현한다.
이것이 바로 값의 동등성이다.

값 객체 또한 마찬가지로 동등성을 가진다.
즉, 두 값 객체가 가지고 있는 상태가 동일하다면 둘을 동등하다고 한다.

이 동등성은 돈에 대해 생각하면 쉽게 이해할 수 있다.
나의 지갑에 있는 만원권 지폐나 은행에 있는 만원권 지폐나 우리는 두 지폐를 동등하다고 한다. 두 지폐는 실제로 다른 물체이지만 가지고 있는 상태(10000원)이 동등하기 때문에 우리는 두 지폐를 동등하다고 한다.

이는 소프트웨어 세계에서도 마찬가지로 적용되어야 한다.
만약 두 값 객체가 가지는 상태가 같은데, 서로 다른 객체(서로 다른 주소를 가지는 객체)라는 이유로 두 객체가 동등하지 않다고 하는 것은 시스템을 더 복잡하게 만들고 우리의 머리를 아프게 만든다.

따라서 두 값 객체가 가지는 상태가 같다면 우리는 두 객체를 동등한 것으로 보아야 한다.
그런데 이는 값 객체를 만든다고 알아서 적용 되는 것이 아니다.
값 객체를 비교하는 equals 메서드를 따로 제공해 주어야 하는데, 자바로 예시를 들면 다음과 같다.

public final class GameNumber {
    private static final int LENGTH = 3;

    private final int number;

    public GameNumber(int number) {
        this.number = number;
        validate();
    }

    // ...

    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }
        if (!(object instanceof GameNumber)) {
            return false;
        }

        GameNumber gameNumber = (GameNumber) object;
        return number == gameNumber.number;
    }

    @Override
    public int hashCode() {
        return Objects.hash(number);
    }

    public static int getLength() {
        return LENGTH;
    }
}

 

즉 위와 같이 equals 메소드를 오버라이드 해서 두 객체의 상태가 같다면 두 객체가 동등하다는 것을 비교할 수 있게 해주어야 한다.
여기서 주의할 점으로 equals 메소드를 오버라이드 할때 hashCode 메소드도 같이 오버라이드 해 주어야 한다.
이에 대한 부분은 "이펙티브 자바 아이템 11"을 참고하면 된다.

자가 유효성 검증

초반에 설명했던 것 처럼 값 객체 내부에 값에 대한 검증 로직을 둠으로써 도메인 규칙을 쉽게 만족 시킬수 있다고 하였다.
이는 값 객체가 가져야하는 책임이기도 하다. 객체는 자율적인 존재이기 때문에 자신의 상태를 스스로 책임져야 한다.
따라서 값 객체가 생성될때 해당 값에 대한 유효성 검사는 객체 내부에서 이루어져야 한다.

즉 어떤 상태를 객체가 가질 수 있는지 없는지에 대한 판단은 객체가 해야 한다는 것이다.

이렇게 하면 값 객체를 사용할때마다 도메인 규칙이 잘 적용되는지 걱정할 필요가 없게 되고,
도메인 규칙에 대한 코드가 여러 군데 흩어지지 않기 때문에 유지보수성도 향상된다.

객체로서의 값 객체

값 객체는 객체이기 때문에 유효성에 대한 검증 뿐만 아니라 해당 객체의 책임을 수행하는 행동을 정의할 수 있다.
예를들어 위의 숫자 야구 게임에서 스트라이크나 볼의 개수를 구하고 싶을때 이에 대한 메소드를 제공할 수 있다.

즉 해당 객체가 단순히 데이터를 저장하는 역할만 하는 것이 아니라 그 상태에 대한 행동 또한 가질 수 있다는 것이다.
이를 통해 해당 객체가 가져야하는 책임을 적절하게 부여할 수 있게된다.

또한 값 객체는 그 자체로 문서화가 될 수 있다.
그 객체가 지녀야 하는 책임과 역할을 잘 정의하고 이를 코드로 표현하면, 해당 코드가 그 객체에 대한 "자기 문서화"가 된다.
만약 단순한 원시 타입으로 표현했다면 우리는 해당 변수의 이름이나 주석을 통해서만 그 값에 대한 정보를 얻을 수밖에 없을 것이다.

VO는 DTO와 다르다.

사실 아직 DTO에 대한 개념이 잘 잡혀 있지 않아 추가적인 공부가 필요하지만, VO와 DTO는 같지 않다고 한다.
그런데도 인터넷에서 VO와 DTO를 혼용하는 사람이 많다고 한다.
VO를 DTO로 사용할 수 있기는 하지만, 두 개념을 같지 않다는 것 정도만 알고 넘어가면 좋겠다.
DTO는 다음에 사용해보고 나서 정리해보겠다!!



값 객체가 무엇인지에 대해 살펴보았다. 앞으로 개발을 하면서 값 객체로 정의할 필요가 있는지를 고민하고, 필요하다고 판단 되면 대담하게 행동으로 옮기자.
다음에는 엔티티에 대해 다루어 보겠다.

출처

'OOP' 카테고리의 다른 글

왜 객체지향을 써야하는 것일까?(feat. 앨런 케이)  (0) 2023.08.21