VO(Value Object)

김민우·2023년 12월 12일
0

잡동사니

목록 보기
15/22

Value Object(값 객체, 이하 VO) 객체에 대해 알아보자. 내가 생각하는 VO 도입 이유와 장점을 서술해보려 한다.

시작하기 앞서 VO와 DTO(Domain Transfer Object)를 헷갈려하는 분들이 많은데 이는 [10분 테코톡] 📍인비의 DTO vs VO 을 참고하면 좋을 것 같다!

VO와 DTO는 엄연히 다른 개념인데 이들의 차이점을 외우기보단 실제로 활용해보면서 직접적으로 차이점을 느껴보는게 좋다고 생각한다.

VO란?


VO는 단어 그대로 값을 저장하는 객체이다. 특정 또는 여러 도메인의 값을 나타내는 역할이다.

자동차 경주를 생각해보자. Car 객체는 크게 이름, 움직인 거리 2가지 필드를 가진다.

public class Car {
    private final String name;
    private int position;
    
    ...
    
    public void move(final int number) {
        if (number > 4) {
            position++;
        }
    }
}
  • 매 시행마다 0 ~ 9 사이 랜덤값이 4보다 크면 position 값이 1 증가한다.

만약, 자동차에 연식같은 상태가 추가된다면 어떨까? 그만큼 Car 객체는 무거워질 것이다. 더 나아가 position 관련 값이 추가된다면 어떨까? (좌표축이 추가된다거나...)

객체가 무거워지면 유지보수성이 떨어질 것이다. 이는 객체를 분리해야 된다는 신호다. position 의 상태가 달라진다면 비지니스 로직부터 시작해서 대대적인 코드 수정이 필요해보인다.

과연 움직인 거리를 나타내는 값을 Car가 책임을 지는게 맞을지 고민해볼 필요가 있다. (지금 같은 확장 외에 값을 검증한다거나 하는 등) 움직인 거리를 나타내는 별도의 객체를 분리해야 될 것 같다. 이 때, 사용하기 적합한 것이 바로 VO다.

VO의 조건


자동차가 움직인 거리를 나타내는 객체의 동일성에 대해 생각해보자. 레퍼런스끼리 같으면 같은 걸로 간주하는건 말이 안된다. 이 객체의 상태가 같으면 같은 걸로 간주해야 되는게 당연하다.

따라서, JAVA에서 객체의 동일성을 판단해주는 메서드 equals(), hashCode()를 재정의한다.

추가로, 우승자를 판별해야 되므로 움직인 거리에 대한 비교 기준이 필요하다. Comparable<T> 인터페이스를 구현함으로써 비교 기준을 부여하자.

public class Position implements Comparable<Position> {
    private final int number;

    private Position(final int number) {
        this.number = number;
    }

    @Override
    public boolean equals(final Object other) {
        if (this == other) {
            return true;
        }
        if (other == null || getClass() != other.getClass()) {
            return false;
        }

        final Position position = (Position) other;
        return number == position.number;
    }

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

    @Override
    public int compareTo(final Position position) {
        return Integer.compare(this.number, position.number);
    }
}

이는 분명 오버 엔지니어링같이 보이지만, 움직인 위치에 대한 확장을 고려한다면 이러한 설계는 필수라 생각한다.

추가로, 움직인 거리에 대한 검증을 여기서 수행함으로써 Car의 책임을 분산시킬 수 있다.

public class Position implements Comparable<Position> {
    private final int number;
    
    private Position(final int number) {
        validateRange(number);
        this.number = number;
    }

    private void validateRange(final int number) {
        if (number < POSITION_INIT_NUMBER) {
            // 예외 처리...
        }
    }
}

캐싱 도입


여기서 하나의 의문이 들 수 있다. 이러면 경주 진행마다 새로운 Position 객체가 생성되니 리소스가 낭비되는거 아닐까? 맞다. 이 설계는 분명 원시값을 사용하는 것보다 효율성이 떨어진다.

Car 마다 움직인 거리가 같으면 레퍼런스가 같은 VO 객체를 사용해도 되지 않을까? 필드에 대한 불변성을 보장한다면 이는 전혀 문제될 것이 없다!

public class Position implements Comparable<Position> {
    private static final int START_POSITION_NUMBER = 0;
    private static final Map<Integer, Position> positionCache = new ConcurrentHashMap<>();
    private final int number;

    static {
        IntStream.range(START_POSITION_NUMBER, POSITION_CACHE_SIZE)
                .forEach(i -> positionCache.put(i, new Position(i)));
    }

    private Position(final int number) {
        validateRange(number);
        this.number = number;
    }

    public static Position from(final int number) {
        if (positionCache.containsKey(number)) {
            return positionCache.get(number);
        }

        return positionCache.put(number, new Position(number));
    }

    public Position plus(final int movement) {
        return from(number + movement);
    }
    ...
}

필드에 대한 불변성이 보장되므로 캐싱을 사용해보자.

POSITION_CACHE_SIZE 만큼의 객체를 어플리케이션 실행시 Map<K, V> 에 미리 넣어놓자. static 블록을 활용하면 이를 간단히 표현할 수 있다.

이후, 정적 팩터리 메서드를 통해 객체를 제공한다. (생성자를 private으로 막아야 무분별한 객체 생성을 방지한다.)

만약, 캐시(Map<K, V>)에 없는 객체가 필요한 경우 객체 생성 후 캐시에 저장한다.

이를 활용하면 Car.move() 메서드를 아래와 같이 수정할 수 있다.

public class Car {
	private final String name;
    private Position position;
    
    ...

    public void move(final int number) {
        if (number > MIN_TO_MOVE) {
            this.position = position.plus(PER_MOVEMENT);
        }
    }

만약, 움직인 거리에 대한 수정이 필요하다면 Car는 전혀 건들 필요가 없을 것이다.

결론


VO 객체를 도입하면서 도메인에 대한 책임을 어느정도 분산시킬 수 있었다. 또한, 기능 확장시 도메인이 무거워지는 것을 막을 수도 있다.

현 요구사항에선 움직인 거리를 단순히 int 값으로 나타내지만 이에 대한 확장 가능성을 항상 염두해두고 설계를 해야된다고 생각한다. 처음 VO를 도입할 땐 굳이? 라는 생각이 들정도로 오버 엔지니어링이라 생각했지만 시간이 지날수록 정말 좋은 선택이란 생각이 들었다.

추가로 자동차의 이름 외 다른 정보가 추가되는 것을 대비하여 name이라는 원시 필드대신 Player 라는 객체의 도입도 좋아보인다.

항상 설계를 진행할 때 확장을 고려하는 습관을 들인다면 어떤 곳에서 VO 객체를 써야 될지 감을 잡기가 쉬워지는 것 같다. 또한, 자체적으로 캐싱을 구현하여 인스턴스 생성을 방지하는 것은 VO 외에 다른 곳에서도 사용될 수 있다고 생각한다.

0개의 댓글