본문 바로가기
학습/Java

Value Object 패턴

코동이 2023. 7. 7.

개요

value object를 왜 사용하는지, spring boot에서 어떻게 사용할 수 있는지 코드를 중점으로 알아보겠습니다.

 

 

문제 상황


 buy() 메서드를 사용해 물건을 판매하는 어플리케이션이 있다고 가정합니다.  물건 가격과 나의 잔고 금액 2가지를 매개변수로 가집니다.

public class Customer {
    private void buy(Money wallet, Money cost) {
        // nothing to do
    }
}

 

현재 Money와 Currency 클래스를 추가해야 합니다.

  • Money 클래스는 현재 가지고 있는 금액을 가집니다
  • Currency는 USD, JPN, EURO와 같이 국가에서 사용되는 화폐단위를 가집니다.

 

public class Money implements Comparable<Money> {
    private BigDecimal amount;
    private Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
        this.currency = currency;
    }

    /**
     * It is used to scale the amount stored in the Money object by a given factor
     *
     */
    public void scale(double factor) {
        this.amount = this.amount.multiply(new BigDecimal(factor)).setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public int compareTo(Money other) {
        return this.compareAmountTo(this.currency.compareTo(other.currency), other);
    }

    private int compareAmountTo(int currencyCompare, Money other) {
        return currencyCompare == 0 ? this.amount.compareTo(other.amount) : currencyCompare;
    }

    @Override
    public String toString() {
        return this.amount + " " + this.currency;
    }
}

public class Currency implements Comparable<Currency> {
    private String symbol;

    public Currency(String symbol) {
        this.symbol = symbol;
    }

    public Money zero() {
        return new Money(BigDecimal.ZERO, this);
    }

    @Override
    public int compareTo(Currency other) {
        return this.symbol.compareTo(other.symbol);
    }

    @Override
    public String toString() {
        return this.symbol;
    }
}

 

buy() 메서드에서 reserve() 메서드로 상품 판매 이전에 예약 기능을 만든다고 가정합니다.

 

public class Customer {
    private void reserve(Money cost) {
        System.out.println("Reserving an item costing " + cost);
    }

    // The customer's request is to check whether the buyer has enough Money. Then, we reserve goods.
    private void buy(Money wallet, Money cost) {
        boolean enoughMoney = wallet.compareTo(cost) >= 0;
        this.reserve(cost);

        if (enoughMoney) {
            System.out.println("You will pay " + cost + " with your " + wallet);
        } else {
            System.out.println("You cannot pay " + cost + " with your " + wallet);
        }
    }

    public void run() {
        Currency usd = new Currency("USD");
        Money usd12 = new Money(new BigDecimal(12), usd);
        Money usd10 = new Money(new BigDecimal(10), usd);
        Money usd7 = new Money(new BigDecimal(7), usd);

        this.buy(usd12, usd10);

        System.out.println();
        this.buy(usd7, usd10);
    }
}

 

이 경우, reserve() 메서드는 단순한 예약으로 매개변수의 어떤한 값도 수정하지 않습니다. 그러나 갑작스럽게(?) 휴일에는 세일을 하라는 요구사항이 추가되었다고 합시다. isHappyHour 변수를 사용해서 reserve() 메서드에서 판매 기간 동안에는 가격을 절반으로 깎도록 해야합니다.

 

public class Customer {
    private boolean isHappyHour;

    private void reserve(Money cost) {
        if (this.isHappyHour) {
            cost.scale(0.5);
        }

        System.out.println("Reserving an item costing " + cost);
    }

    public void run() {
        Currency usd = new Currency("USD");
        Money usd12 = new Money(new BigDecimal(12), usd);
        Money usd10 = new Money(new BigDecimal(10), usd);
        Money usd7 = new Money(new BigDecimal(7), usd);

        //this.buy(usd12, usd10);

        //System.out.println();
        //this.buy(usd7, usd10);

        //System.out.println();
        this.isHappyHour = true;
        this.buy(usd7, usd10);
    }
}

 

결과는 다음과 같습니다.

Reserving an item costing 5.00 USD
You cannot pay 5.00 USD with your 7.00 USD

 

문제가 발생합니다. 분명 7달러를 가지고 있지만, 5달러 상품을 예약하지 못합니다. 왜냐하면 Money와 Currency가 가변성이기 때문입니다. 객체가 내부적으로 수정이 되었습니다.

 

 

Value Object로 해결하기


 이런 경우 가장 큰 문제는 어디 코드가 잘못되었는지 빠르게 파악할 수 없다는 것입니다. 정말 자세히 확인하지 않으면 각각의 기능이 제대로 동작하는 것처럼 느껴집니다. 해당 문제는 동일한 객체에 별칭 cost로 2개가 참조해서 발생한 문제입니다. 같은 객체에 동일한 이름을 사용하기 때문에 실제는 2개가 참조하고 있지만 1개만 참조하는 것처럼 보입니다.

 

 

 

메서드 매개변수 공유 참조로 예상하지 못한 변경이 발생합니다


 

첫번째 객체가 공유된 객체를 1.read하고 두번째 객체가 2.mutate를 합니다.

 

 

 첫번째 객체는 1.read를 통해 compareTo() 비교를 합니다. 2.mutate에서 휴일이므로 Money cost는 slae()함수로 50%가 할인됩니다. 불행하게 이 순간 의도치 않게 공유 객체의 상태가 변경됩니다. 따라서 가격이 할인되어 예약이 가능함에도 불구하고 1.read에서 enoughMoney는 false이기 때문에 예약이 불가능하다는 결과가 나옵니다.

 

 

해당 오류를 피하는 간단한 방법은 공유 객체를 변경하지 않는 것입니다.(불변 클래스 만들기) 메서드 매개변수는 공유 참조의 전형적인 예시입니다. cost 매개변수는 외부에서 전달된 참조입니다. reserve() 메서드는 여전히 동일한 객체에 유효한 참조를 할 가능성을 가지고 있습니다. 위험을 줄이기 위해서느 이 메서드는 매개변수 변경을 막아야 합니다. 

 

public class Money implements Comparable<Money> {
    private BigDecimal amount;
    private Currency currency;

    // ...

    // Does not mutate the amount field
    public Money scale(double factor) {
        return new Money(this.amount.multiply(new BigDecimal(factor)), this.currency);
    }

    // Base on the addition of the primitive values, Money class also provide add() method
    public Money add(Money other) {
        if (other.currency.compareTo(this.currency) != 0) {
            throw new IllegalArgumentException();
        }

        return new Money(this.amount.add(other.amount), this.currency);
    }

    // ...
}

public class Customer {
    private boolean isHoliday;

    private Money reserve(Money cost) {
        Money finalCost = this.isHoliday ? cost.scale(0.5) : cost;
        System.out.println("Reserving an item costing " + finalCost);

        return finalCost;
    }

    private void buy(Money wallet, Money cost) {
        boolean enoughMoney = wallet.compareTo(cost) >= 0;
        Money finalCost = this.reserve(cost);
        boolean finalEnough = wallet.compareTo(finalCost) >= 0;

        if (finalEnough && !enoughMoney) {
            System.out.println("Only this time, you will pay " + finalCost + " with your " + wallet);
        } else if (finalEnough) {
            System.out.println("You will pay " + finalCost + " with your " + wallet);
        } else {
            System.out.println("You cannot pay " + finalCost + " with your " + wallet);
        }
    }
}

 

원시 데이터 타입으로 덧셈, 뺄셈, 나눗셈, 곱셈 등의 연산을 할 수 있습니다. 마찬가지로 Money 클래스에서 add(), scale() 메서드 등으로 가능합니다. Money 클래스가 int보다 특별하게 어려울 이유는 없습니다. 이렇게 Value Object가 탄생합니다.

 

 

해결책 : 불변객체를 활용하여 계산할때마다 새로운 객체를 반환합니다


불변 객체를 활용해 문제있는 코드의 두 부분을 해결했습니다.

 

this.reserve(cost)는 Money cost 공유 참조로 두어 변경 될 수 있는 여지를 남기지 않고 Money finalCost = this.reserve(cost)로 새로운 객체를 반환합니다.

 

cost.scale(0.5)로 Money cost가 절반이 되도록 두지 않고 Money finalCost = this.isHoliday ? cost.scale(0.5) : cost 로 계산된 최종 객체를 반환합니다.

 

Money가 변경된다면, 직접 접근하여 값을 바꾸지 못하고 무조건 새로운 객체를 생성 후 반환하여 문제를 방지합니다.

 

정리

  • 장점

1. 불변성은 구현하기 쉽다.

2. 불변 객체를 사용하면 메서드 매개변수 공유 참조를 막을 수 있다.

3. 유지보수에 용이하다.

 

  • 단점

1. 값을 변경하기 위해 설정할 코드들이 많으며 성능 이슈가 있을 수 있다.

 

*참고

Value Object pattern

반응형

'학습 > Java' 카테고리의 다른 글

Generics의 Type Erasure(타입 소거)  (0) 2024.03.08
Generics의 Wildcards(와일드카드)  (0) 2024.03.07
Entity vs Value Object 차이점  (0) 2023.06.30
스프링 AOP (1) - 동적 프록시  (0) 2023.01.11
mutable vs immutable  (0) 2022.11.01