주가를 곱하는 기능 구현
기능 요구사항
- 다중 화폐를 다루는 보고서를 만들기 위해 화폐 연산 기능을 구현한다.
기능 구현 목록
- 통화가 다른 두 금액을 더해서 환율에 맞게 변환한 금액을 구한다.
- 주가를 주식의 수로 곱한 금액을 구한다.
두 번째 기능부터 구현하기 위해 테스트 코드를 작성한다.
class MoneyTest {
@Test
void testMultiplication() {
Dollar five = new Dollar(5);
five.times();
assertEquals(10, five.amount());
}
}
컴파일 오류가 생기는 원인을 정리해보자.
- Dollar 클래스가 없음
- 생성자가 없음
- times() 메서드가 없음
- amount 필드가 없음
public class Dollar {
int amount;
Dollar(int times) {
}
public void times() {
}
}
이렇게 깡통 클래스를 만들어주면 테스트 실행은 실패하지만 컴파일은 성공한다.
int amount = 5 * 2;
이렇게 하면 실행이 성공한다.
그럼 이제 중복을 제거해보자.
5와 2가 중복이다. 이를 제거하기 위해 times() 메서드 안으로 코드를 옮겨보자.
public void times(int multiplier) {
amount = 5 * 2;
}
public void times(int multiplier) {
amount *= multiplier;
} // 상수를 인자로 대체한다.
테스트는 다행히 성공한다. 두 번째 기능을 체크하고 다음으로 넘어가보자.
값 객체 패턴
곱셉 연산 후 five 객체는 자꾸 값이 달라진다. 그럼 다른 연산에 있어서 5라는 값을 제공하지 못한다.
그럼 매번 곱셈 연산 후 새로운 객체를 반환하게 하면 어떨까?
@Test
void testMultiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, five.amount);
product = five.times(3);
assertEquals(15, five.amount);
}
이렇게 테스트 코드를 고치면 컴파일 에러가 생긴다.
public Dollar times(int multiplier) {
return new Dollar(amount * multiplier);
}
이런 값 객체 패턴을 사용하면 별칭 문제를 신경쓰지 않아도 된다. 두 개의 객체의 주솟값이 같아져서 하나를 변경하는 것이 다른 객체에게 영향을 끼치는 문제이다.
값 객체에서는 값이 같으면 같은 객체로 판단해야한다. 즉 동등성을 보장해야한다.
@Test
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
}
테스트가 실패한다.
어떻게 초록불을 띄울 수 있지? 모르겠다. 일단 참을 반환하도록 만들어보자.
public boolean equals(Object object) {
return true;
}
그럼 동등성을 일반화해서 테스트해보자.
@Test
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dollar(6)));
}
public boolean equals(Object object) {
Dollar dollar = (Dollar)object;
return dollar.amount == amount;
}
진짜 구현은 이렇게 하면 되겠지.
빨간 막대를 없애기 위해서 가짜 구현을 사용할 수 있고, 진짜 구현을 사용할 수 있다.
어떤 방법을 선택하든 지 모든 건 내 마음이다. 내 구현에 자신감이 있으면 바로 진짜 구현에 들어가도 된다.
amount를 private로 변경
Dollar의 amount 필드를 private로 변경해보자.
@Test
void testMultiplication() {
Dollar five = new Dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
그럼 테스트 케이스를 이렇게 고쳐도 파란 막대가 뜬다.
Franc 객체 생성
Dollar와 일맥상통한 기능을 하는 Franc을 설계해보겠다.
public class FrancTest {
@Test
void testFrancMultiplication() {
Franc five = new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}
}
우선 Franc의 곱셈 기능이 어떤 역할을 수행하는 지 테스트코드로 작성해보자.
다행히도 빨간 막대가 뜬다. Equals()를 오버라이딩하지 않았기 때문이다. 지금 Dollar와 Franc를 나누면서 중복 코드가 너무 많아졌다. 이거 유지보수하기 너무 어렵겠는데? 라는 생각이 든다면, 중복 코드를 찾아내본다.
- Franc 객체가 Dollar와 동일하게 작동할 수 있도록 하기
- Dollar와 Franc의 중복 제거
- 공통 equals 구현
- 공통 times 구현
위의 사항을 적용해보면 좋을 것 같다.
공통 상위 클래스 Money
class Money {
protected int amount;
}
이렇게 Money를 정의했다. 하위 클래스도 amount 변수를 알 수 있게 protected 접근 제어자를 지정했다.
class Money {
protected int amount;
public boolean equals(Object object) {
Money money = (Money)object;
return money.amount == amount;
}
}
..
이렇게 Money 클래스에 equals를 정의하고 Franc에도 상속을 적용해주면 Franc 에 공통 equals가 정의되면서 테스트가 통과한다.
그런데 이렇게 되면 Franc와 Dollar 객체가 값이 같으면 같은 객체라고 판단해버리게 된다.
public class MoneyTest {
@Test // 실패
void testEquality() {
assertFalse(new Dollar(5).equals(new Franc(5)));
}
}
이를 위해 equals 코드를 아래와 같이 변경할 수 있다.
public boolean equals(Object object) {
Money money = (Money) object;
return money.amount == amount && money.getClass() == this.getClass();
// 혹은
// return money.amount == amount && getClass().equals(money.getClass());
}
Franc와 Dollar 중복 코드 제거
times() 구현 코드의 중복을 제거하자.
사실 이 시점에서 뭘 해야할 지 감이 안와서 책을 그대로 따라했다.
우선 Dollar의 생성자를 Money의 정적 팩토리 메서드에서 호출하도록 한다.
class Money {
protected int amount;
public static Dollar dollar(int amount) {
return new Dollar(amount);
}
}
하위 클래스에 대한 직접 참조를 없애려는 의도이다. 테스트 코드에서도 이를 반영해보자.
@Test
void testMultiplication() {
Money five = new Dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
Money에는 times() 메서드가 없어서 테스트가 실패한다.
class Money {
protected int amount;
Money() {
}
public Money(int amount) {
this.amount = amount;
}
public Money times(int multiplier) {
return new Money(multiplier * amount);
}
}
이렇게 Money 클래스에서 기본 생성자와 times()에서 호출할 생성자를 정의하였다.
나는 이렇게 바로 Money 클래스에 하나의 메서드를 정의하였지만, 하위 클래스의 서로 다른 메서드를 합해서 상위 클래스로 이동하기 위해서는 두 하위 클래스의 메서드를 완전히 동일하게 만들어야 하는 것이 순서다.
왜냐하면, 서로 다른 구현이기에 이 두 싱크를 맞추는 과정이 포함되어야 합친 메서드가 완전하다는 것을 보장할 수 있다.
이렇게 하위 클래스의 싱크를 맞추다보면 하위 클래스의 존재는 불필요하고 귀찮아질 수 있다. 통화 개념을 도입해서 두 하위 클래스의 기능이 Money에서 공통으로 동작되게 할 수 있다.
public class Money {
private final int amount;
protected String currency;
public Money(final int amount, final String currency) {
this.amount = amount;
this.currency = currency;
}
public static Dollar dollar(final int amount) {
return new Dollar(amount, "USD");
}
public static Franc franc(final int amount) {
return new Franc(amount, "CHF");
}
Money 클래스에 currency 필드를 추가하고 정적 팩토리 메서드에 문자열을 추가하여 Dollar, Franc 객체를 생성한다.
Dollar와 Franc 안에 각각 존재하던 times() 메서드마저 Money 클래스로 중복 제거하였다.그러면 Dollar, Franc 클래스에는 생성자만 있다.
public static Money dollar(final int amount) {
return new Money(amount, "USD");
}
public static Money franc(final int amount) {
return new Money(amount, "CHF");
}
이렇게 고치면 Dollar, Franc에 대한 참조가 사라진다.
서로 다른 통화를 더하기
$5 + 10CHF = $10이라고 했을 때, 이 연산의 결과를 Expression이라고 하자.
서로 다른 통화를 더한 결과를 Expression에 저장하여 단일 통화로 축약하는 것이다.
그리고 이렇게 서로 다른 통화를 더해서 단일 통화로 축약(reduce) 하는 역할은 은행이다.
@Test
public void testSimpleAddition() {
Money five = Money.dollar(5);
Money ten = Money.franc(10);
Expression sum = five.plus(ten);
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD"); // 연산 결과를 달러로 변환
Assertions.assertThat(Money.dollar(10)).isEqualTo(reduced);
}
Money는 Expression을 반환하는 plus() 메서드를 가져야 한다.
public Expression plus(final Money addend) {
return new Money(amount + addend.amount, currency);
}
그리고 은행은 더한 값을 특정 화폐로 반환하는 함수를 가지고 있어야 한다.
public class Bank {
public Money reduce(final Expression source, final String to) {
return Money.dollar(10); // 가짜 구현
}
}
이렇게 하면 테스트가 통과한다!
이제 진짜 구현을 해보자.
두 달러의 연산의 결과를 Sum이라고 하자.
@Test
void testPlusReturnSum() {
Money five = Money.dollar(5);
Expression expression = five.plus(five);
Sum sum = (Sum) expression;
Assertions.assertThat(sum.augend).isEqualTo(five);
Assertions.assertThat(sum.addend).isEqualTo(five);
}
형변환을 이용해서 더하는 숫자와 더해지는 숫자가 저장된 숫자와 동일함을 증명하는 테스트코드이다.
public class Sum implements Expression {
public Money augend;
public Money addend;
public Sum(Money augend, Money addend) {
this.augend = augend;
this.addend = addend;
}
public Money reduce(final String to) {
int amount = augend.amount + addend.amount;
return new Money(amount, to);
}
}