전 포스팅에서는 또 다른 통화인 Franc 을 만들고, 그에 발생하는 중복을 제거하기 위해 상위 클래스인 Money 클래스를 정의하였다.
그리고 Franc 와 Dollar 클래스에 중복되는 amount 변수나 equals() 메서드를 상위 클래스에 올리면서 리팩토링을 진행하고 있었다. 또, 그에 따라 발생하는 테스트 케이스의 빨간불을 해결하였다. 이제 해야할 일은 무엇일까.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
Dollar 부작용(side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 equals- 공용 times
Franc 와 Dollar 비교하기- 통화?
times()
메서드를 상위 클래스로 올려 리팩토링하는 것을 목표로 진행해보자.
코드를 살펴보면 times()
들이 거의 똑같다는 것을 확인할 수 있다.
(사실 생성자도 매우 비슷하긴 한데 나중에 살펴보자)
이를 같은 코드로 만들려면 어떻게 코드를 바꿔야 할까.
일단 둘다 슈퍼클래스인 Money 를 반환하도록 해보자.
Money times (int multiplier){
return new Franc(amount * multiplier);
}
Money times (int multiplier){
return new Dollar(amount * multiplier);
}
그 다음 단계로 뭘 해야할까... 일단 Money 의 두 하위 클래스가 equals 도 올려버리고, times 도 올려버리면 진짜 아무것도 안하는 클래스일 것 같다. 아예 없애버리고 싶다.
TDD 답게 차근차근 진행해 보도록 하자. 일단 하위 클래스에 대한 직접적인 참조를 하나하나 없애보도록 하자.
테스트에서 생성자로 직접 객체를 할당하는 것 보다는 팩토리 메서드를 활용해서 객체를 반환하도록 하자.
일단 테스트부터 작성(수정)
@Test
public void testMultiplication2(){
Dollar five= Money.dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
해당 메서드를 구현해보자
public static Dollar dollar(int amount) {
return new Dollar(amount);
}
컴파일러가 Money 에는 times()
가 정의되어 있지 않다는 것을 말한다. 아직 구현할 준비가 안돼있으므로 Money 를 추상클래스로 변경하고 Money.times()
를 선언하자
public abstract class Money {
protected int amount;
public static Dollar dollar(int amount) {
return new Dollar(amount);
}
public boolean equals(Object object){
Money money = (Money) object;
return amount == money.amount
&&getClass().equals(money.getClass());
}
public abstract Dollar times(int multiplier);
}
바꾼 구현에 맞게 테스트코드들을 바꾸도록 하자.
public class MoneyTest {
@Test
public void testMultiplication2(){
Money five= Money.dollar(5);
assertEquals(Money.dollar(10), five.times(2));
assertEquals(Money.dollar(15), five.times(3));
}
@Test
public void testFrancMultiplication(){
Franc five= new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}
@Test
public void testEquality(){
assertTrue(Money.dollar(5).equals(Money.dollar(5)));
assertFalse(Money.dollar(5).equals(Money.dollar(6)));
assertTrue(new Franc(5).equals(new Franc(5)));
assertFalse(new Franc(5).equals(new Franc(6)));
assertFalse(new Franc(5).equals(Money.dollar(5)));
}
}
Frnac 도 마찬가지이다.
public static Franc franc(int amount) {
return new Franc(amount);
}
팩토리 메서드까지 적용시키고 나서 무엇을 하면 하위 클래스들의 코드를 공동화시켜서 하위 클래스를 없앨 수 있을까 고민해봤다(책에서).
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
Dollar 부작용(side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 equals- 공용 times
Franc 와 Dollar 비교하기- 통화?
- testFrancMultiplication 제거
앞서서 우리는 Dollar 객체와 Franc 객체의 equals() 연산의 오류를 찾아내며 코드를 getClass().equals()
로 처리하여 같은 클래스일 때에만 equals 가 true 가 도출되도록 설정하였다.
이 문제를 해결하며 통화 의 개념을 클래스에 담아 낼 수 있었으면 좋겠다고 생각하고 checklist 에 담아놨었다.
이제 이 통화 개념을 한번 구현, 아니 통화 개념을 테스트해보도록 하자. (정확히 이렇게 하는 이유를 분석해보자면 통화 개념을 사용하면 서로 다른 생성자를 가지고 있는 Dollar 와 Franc 생성자 코드를 똑같게 만들 수 있겠다는 계산에서 나온 생각임.)
@Test
public void testCurrency(){
assertEquals("USD", Money.dollar(1).currency());
assertEquals("CHF", Money.franc(1).currency());
}
일단 Money 클래스에 currency()
메서드를 선언한다.
public abstract String currency();
그다음 두 하위 클래스에서 구현
String currency(){
return "CHF";
}
String currency(){
return "USD";
}
우리는 이렇게 구현한 두 메서드를 최대한 비슷하게(가능하면 동일하게) 만들어서 중복을 없애는 것이 목표이므로 이에 대해 고민해보자.
일단 string 으로 "CHF", "USD" 이렇게 하드하게 넣는거보단 해당 객체가 어떤 통화를 가지고 있을지 동적으로 받아와서 반환하는 방법이 생각난다.
private String currency;
String currency(){
return currency;
}
이렇게 동일해졌으므로, currency()
를 위로 올리자.
public abstract class Money {
protected String currency;
String currency(){
return currency;
}
...
}
일단 팩토리 메서드로 해당 객체들을 생성하면 currency를 설정할 수 있도록 해야한다.
public Franc(int amount, String currency){
this.amount = amount;
this.currency = currency;
}
그에 따른 Money 코드와 times를 바꿔주자.
Money
public static Dollar dollar(int amount) {
return new Dollar(amount, "USD");
}
public static Franc franc(int amount) {
return new Franc(amount, "CHF");
}
Franc (Dollar의 경우에도 같음)
Money times (int multiplier){
return Money.franc(amount * multiplier);
}
그래서 드디어 생성자가 같아졌으므로 슈퍼클래스(Money) 로 올리자
public Franc(int amount, String currency){
super(amount, currency);
}
Money(int amount, String currency){
this.amount = amount;
this.currency = currency;
}
앞에서 times()
메서드를 통일시킬려다가 갑자기 하위 클래스를 제거하는 방식으로 스케일이 좀 커졌었다. 그래서 currency 개념을 구현하여 Money 클래스에 올려 슈퍼클래스 단에서 각 객체가 어떤 통화를 가지고 있는지 명시하도록 했다.
그럼 다시 times()
메서드를 통일시켜보자.
현재 times()
의 구현들은 거의 비슷하지만 아직 완전히 동일하진 않다.
Money times (int multiplier){
return Money.franc(amount * multiplier);
}
Money times (int multiplier){
return Money.dollar(amount * multiplier);
}
전에 팩토리 메서드를 활용하여 Money 클래스의 franc()
, dollar()
함수로 각 객체를 반환하도록 하였지만, 각 클래스의 생성자가 좀 달라졌으므로 다시 되돌려보자.
Money times (int multiplier){
return new Franc(amount * multiplier, "CHF");
}
Money times (int multiplier) {
return new Dollar(amount * multiplier, "USD");
}
Franc 클래스의 currency 변수는 항상 "CHF" 일 것이고, Dollar 도 마찬가지로 "USD" 일 것이므로 이렇게 통일시킬 수 있다.
Money times (int multiplier){
return new Franc(amount * multiplier, currency);
}
이제 과연 저 times()
함수가 Franc 를 반환할 것인지 Money 를 반환할 것인지가 중요할까? 이미 Money 객체에 currency 라는 통화 개념을 도입했으니 currency="CHF" 로 값을 가지고 있는 Money 객체는 Franc 라고 볼 수 있지 않을까?
바꿔보자.
Money times (int multiplier){
return new Money(amount * multiplier, currency);
}
Money 를 콘크리트 클래스로 만들라한다. 만들어주자.
public class Money {
Money times(int multiplier){
return null;
}
테스트에서 빨간막대가 뜬다.
우리가 전에 equals()
함수에서 두 클래스를 비교해 준게 문제였던 것 같다.
사실 비교할 점은 지금보면 "클래스" 자체가 아니라 "통화" 개념인 currency 를 비교해주면 된다는 것을 알 수 있다.
public boolean equals(Object object){
Money money = (Money) object;
return amount == money.amount
&¤cy().equals(money.currency());
}
그리고 이제 진정으로 방해하는 것들이 다 사라지고, times()
함수마저 같으니 Money 클래스로 올려줄 일만 남았다.
public class Money {
protected int amount;
protected String currency;
Money(int amount, String currency){
this.amount = amount;
this.currency = currency;
}
public static Dollar dollar(int amount) {
return new Dollar(amount, "USD");
}
public static Franc franc(int amount) {
return new Franc(amount, "CHF");
}
public boolean equals(Object object){
Money money = (Money) object;
return amount == money.amount
&¤cy().equals(money.currency());
}
Money times(int multiplier){
return new Money(amount * multiplier, currency);
}
String currency(){
return currency;
}
}
public class Franc extends Money{
public Franc(int amount, String currency){
super(amount, currency);
}
}
public class Dollar extends Money{
public Dollar(int amount, String currency){
super(amount, currency);
}
}
곱하기도 없앴겠다, 이제 두 하위 클래스에는 생성자밖에 남아있지 않다.
이번엔 진짜로 Dollar, Franc 클래스를 제거할 수 있을 것 같다.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
Dollar 부작용(side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 equals- 공용 times
Franc 와 Dollar 비교하기통화?- testFrancMultiplication 제거
이제 Dollar, Franc 에 관한 참조들을 다 제거해 보도록 하자.
그리고 전에 우리고 Franc 에 관해 Money 와 Franc 객체를 비교하는 테스트 코드를 쓴 적이 있었다. 이를 없애도 될까??
다른 동치성 테스트를 확인해보자.
@Test
public void testEquality(){
assertTrue(Money.dollar(5).equals(Money.dollar(5)));
assertFalse(Money.dollar(5).equals(Money.dollar(6)));
assertTrue(Money.franc(5).equals(Money.franc(5)));
assertFalse(Money.franc(5).equals(Money.franc(6)));
assertFalse(Money.franc(5).equals(Money.dollar(5)));
}
이 테스트도 과하다. 3,4 번째 줄은 1,2 번째 줄이랑 테스트하는 것이 똑같기 때문에 없애주도록하자.
@Test
public void testEquality(){
assertTrue(Money.dollar(5).equals(Money.dollar(5)));
assertFalse(Money.dollar(5).equals(Money.dollar(6)));
assertFalse(Money.franc(5).equals(Money.dollar(5)));
}
잠시 한눈이 팔렸다. 다시 Franc 가 참조된 테스트를 들여다보자.
@Test
public void testDifferenctClassEquality(){
assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
}
이 테스트에서 equals()
는 클래스 말고 currency 를 비교하도록 한다. 그리고 서로 다른 클래스에서도 equals()
가 생각한대로 돌아가는지 테스트를 작성한 것이었다.
우리는 현재 Franc 클래스를 제거하려는 중이기 때문에 삭제해주도록 하자.
이번 포스팅에서 한 일들을 정리해 보자.
1. "통화" 개념으로 currency 도입
2. currency 도입에 따른 times() 의 통일화
3. currency 도입에 따른 각 클래스 생성자 제거
4. 하위 클래스에 아무것도 남지 않아서 제거
5. 하위 클래스에 관한 테스트 제거