chapter 6

죄 지우기

이 전에(ch5) 저지른 죄(복붙!)를 청소해야 한다.
가능한 방법은 Francdollar를 상속하거나, 공통 부모를 갖도록 하는 방법이 있을 것이다.
프랑이 달러를 상속해? 말도 안되보이고, 실제 구현해보면 얻을 이득이 없다는 것을 깨달을 것이다. 공통 분모를 갖는 방향으로 수정을 해 나간다.

class Money {
    protected int amount;
}

class Dollar extends Money {
}

죄 지우기2

ch3에서 만든 equals()는 Dollar와 Franc에 모두 남아있을 것이다. 이 함수를 Dollar class에서 지우고, Money class에서 아래와 같이 수정한다.

public boolean equals(Object object) {
    Money money = (Money) Object;
    return amount == money.amount;
}

그러면 뭐가 안됐나 보니까, Franc.equals()에 대한 테스트가 없었으므로 ch3에서 수정한 테스트 코드도 아래와 같이 수정해준다.

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
    assertFalse(new Dollar(5).equals(new Dollar(6)));
    assertTrue(new Franc(5).equals(new Franc(5)));
    assertFalse(new Franc(5).equals(new Franc(6)));
}

왜 하는지 모르겠더라도 이런 테스트들을 작성한 후에 코드를 수정하도록 해야한다. 작성하고 보니, Dollar와 Franc가 거의 동일한 테스트를 중복해서 하고 있다. 이런 죄들을 반드시 지워야만 한다. Franc도 Dollarclass와 동일하게 equals()를 고칠 수 있고, 결국 Money class의 equals와 동일한 함수가 나와 코드를 제거할 수 있을 것이다.

근데 참 찝찝하다.. Franc와 Dollar의 비교는 어찌할 것인가.. 일단 뒤로 미루도록 한다.

여태까지

  • 공통된 코드를 첫 번째 클래스(Dollar)에서 상위 클래스(Money)로 단계적으로 옮겼다.
  • 두 번째 클래스(Franc)도 Money의 하위 클래스로 만들었다.
  • 불필요한 구현을 제거하기 전에 두 equals() 구현을 일치시켰다.

chapter 7

미뤄둔 죄

앞서 미뤄둔 죄를 해결하기 위한 테스트를 작성한다.

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
    assertFalse(new Dollar(5).equals(new Dollar(6)));
    assertTrue(new Franc(5).equals(new Franc(5)));
    assertFalse(new Franc(5).equals(new Dollar(6)));
}

당연히 테스트는 실패한다. 우선 빠르게 문제를 해결하면 아래와 같이 수정할 수 있다.

public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount && getClass().equals(money.getClass());
}

여태까지

  • 결함을 끄집어내서 테스트로 만들었다.
  • 완벽하지 않지만 봐줄만하게 테스트를 통과시켰다.
  • 더 많은 동기가 있기 전에 더 많은 설계를 도입하지 않기로 했다.

그런데 참.. 한 챕터에 별로 하는게 없는 것 같다.

chapter 8

Money class의 하위 클래스 Dollar, Franc가 하는 일이 별로 없으므로, 아예 제거하고 싶다. 이를 제거하기 위해서 단계적으로 진행해본다.

Method 통합

//Franc class
Franc times(int multi) {
    return new Franc(amount * multi);
}
//Dollar class
Dollar times(int multi) {
    return new Dollar(amount * multi);
}

//Money class
abstract Money times(int multi);

이렇게 method 선언이라도 공통부로 옮김으로써, 다음 단계에서 times를 Money class 단에서 쓸 수 있어 통합할 수 있게 된다.

Factory Method

하위 클래스에 대한 직접적인 참조가 적어진다면 하위 클래스를 제거하기 위한 방향이라고 볼 수 있다. Money class에 Dollar를 반환하는 Factory Method를 도입할 수 있다.

// Money Class
static Dollar dollar(int amount) {
    return new Dollar(amount);
}

public void testMultiplication() {
    Money five = Money.dollar(5);
    assertEquals(Money.dollar(10), five.times(2));
    assertEquals(Money.dollar(15), five.times(3));
}

// testFrancMultiplication()도 동일하게 남아 있음.

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.dollar(5).equals(Money.franc(5)));
}
  1. five의 생성이 Money의 Factory Method를 참조한다.
  2. Dollar에 대한 참조가 사라지길 바라므로, Dollar five = ~Money five = ~로 바꾼다.
  3. 앗.. Money.times() 를 구현해야 된다.
  4. assertEquals에서 사용하는 Dollar의 생성도 Factory Method를 참조한다.
  5. 이제 어떤 client code도 Dollar라는 이름의 하위 클래스가 있다는 사실을 알지 못한다.
  6. 하위 클래스의 존재를 테스트에서 분리하여 어떤 모델 코드에도 영향을 주지 않고 상속 구조를 변경할 수 있게 되었다.

여태까지

  • times()의 메서드 서명부를 통일시킴으로써 중복 제거를 향해 한 단계 더 전진했다.
  • 최소한의 method 선언부라도 공통 superclass로 옮겼다.
  • Factory method를 통해 테스트 코드에서 콘크리트 하위 클래스의 존재 사실을 분리했다.
  • 하위 클래스가 사라지면서 몇몇 테스트가 불필요한 여분의 것이 된다고 생각했지만 일단 뒀다. (testFrancMultiplication)

chapter 9

할 일 목록 체크

TDD를 진행하면서 TDD의 기본은 빠른 구현과 테스트 통과, 코드 중복 제거에 있기 때문에, 하나의 일을 해냄으로써 파생되는 할 일들을 계속해서 목록화 하여 관리하는 것이 필요하다.
예를 들면 현재까지 할일은 다음과 같다.

  • $5 + 10CHF = $10 (환율이 2:1 이라면)
  • Money의 반올림?
  • hashCode()
  • Equal null
  • Equal Object
  • Dollar/Franc 중복
  • 공용 times
  • 통화?
  • testFancMultiplication 제거

할 일 목록에서 귀찮고 불필요한 하위 클래스를 제거하는데 도움이 될 것을 찾아본다. 통화 개념을 도입해보면 어떨까?
통화 개념을 어떻게 구현하길 원하는가? 아니, 통화 개념을 어떻게 테스트하길 원하는가?

통화개념

통화 개념을 위해 복잡한 객체들을 사용하고, 필요한 만큼 만들어지도록 하기 위해 경량 팩토리(flyweight factories)를 사용할 수 있지만, 우선 문자열을 대신 쓰도록 한다.

// 통화개념을 위한 간단한 테스트
public void testCurrency() {
    assertEquals("USD", Money.dollar(1).currency());
    assertEquals("CHF", Money.franc(1).currency());
}
// 통화개념을 위한 테스트 작성 후 테스트 통과를 위한 코드
// Money class
abstract String currency();

// Dollar class, Franc도 똑같다.
String currency() {
    return "USD";
}

통화개념 refactoring

Dollar와 Franc를 모두 통합할 수 있는 동일한 구현이 가능할까? 리팩토링이 가능하지 않을까?

//Dollar class, Franc도 똑같다.
private String currency;
Dollar(int amount) {
    this.amount = amount;
    currency = "USD";
}
String currency() {
    return currency;
}

위와 같이 Dollar와 Franc의 구현이 끝나면, currency와 currency()를 Money로 가져올 수 있다.

// Money class
protected String currency;
String currency() {
    return currency;
}

통화개념 refactoring 2

문자열 USD와 CHF를 정적 팩토리 메서드로 옮긴다면 두 생성자가 동일해질 것이고 공통 구현을 만들 수 있을 것이다. 우선 constructor에 인자를 추가한다.

// Dollar class
Dollar(int amount, String currency) {
    this.amount = amount;
    this.currency = "USD";
}

그러면 constructor를 호출하는 곳에 에러가 발생하고, 이를 수정한다.

// Money class
static Money Dollar(int amount) {
    return new Dollar(amount, null);
}
// Dollar class
Money times(int multi) {
    return new Dollar(amount * multi, null);
}

이제 factory method가 “USD”를 전달할 수 있다.

// Money class
static Money dollar(int amount) {
    return new Dollar(amount, "USD");
}
// Dollar class, Franc도 똑같다.
Dollar(int amount, String currency) {
    this.amount = amount;
    this.currency = currency;
}

이렇게 작은 단계를 밟아가는 것이(한 방에 해도 되는데) 꼭 이렇게 해야하는 것은 아니다. 하지만, 중요한 점은 이렇게도 일할 수 있는 능력이 있어야 한다는 것이다.
얼마나의 보폭으로 작업할지는 직접 판단하며 줄였나 들였다 해야한다.

통화개념 refactoring 3

지금의 수정을 확인하면 Dollar와 Franc의 constructor가 동일하다. 즉 상위 클래스로 옮길 수 있다.

// Money class
Money(int amount, String currency) {
    this.amount = amount;
    this.currency = currency;
}
// Dollar class, Franc도 똑같다.
Dollar(int amount, STring currency) {
    super(amount, currency);
}

chapter 10

times()

9장의 times()를 보면 Dollar의 currency는 항상 Dollar이므로 아래와 같이 수정할 수 있다.

// Dollar class, Franc도 똑같다.
Money times(int multi) {
    return new Dollar(amount * multi, currency);
}

근데, Dollar를 갖는지, Money를 갖는지가 중요한가?
이런 질문에 시스템 기반으로 깊은 생각이 필요할 것이다.
하지만 우리가 가지고 있는 테스트 코드들을 통해 컴퓨터에게 10초 이내의 답을 얻을 수도 있을 것이다.
수정하고 테스트를 돌려 컴퓨터에게 답을 얻자. TDD에서 가끔은 컴퓨터가 10초면 대답할 수 있는 것을 엔지니어가 몇 분 동안 고민하지 않고 테스트를 하기도 한다.

컴퓨터에게 답 얻기

// Dollar class
Money times(int multi) {
    return new Money(amount * multi, currency);
}

위와 같이 수정을 진행하고, 컴퓨터에게 오류가 있는지 묻는다.

오류:

  • Money를 콘크리트 클래스로 바꿔야 한다.
  • 그 후, 다시하면 이해하기 어려운 에러 메세지들이 출력된다. (test fail error)
  • 에러 메세지를 이해하기 쉽게 toString()을 정의한다.
    // Money class
    public String toString() {
        return amount + " " + currency;
    }
    
  • 아니 테스트 없이 코드를 생성한다고?? 말이 되는가?
    • 원래는 테스트 작성 후 toString()을 작성하는 것이 맞다. 하지만,
      1. 화면의 결과를 보려던 참이다.
      2. toString()은 디버그 출력에만 쓰이기 때문에 잘못 구현되어도 리스크는 적다.
      3. 이미 에러 상태인데 새로운 테스트를 작성하는 것은 좋지 않을 것 같다.
  • 이렇게 조금 더 구체적으로 오류의 방향들을 찾아갈 수 있다.

times refactoring

위에서 에러가 되는 이유는 chapter 7에서 구현한 equals() 함수에 있다.
equals()에서는 class가 같은 지를 확인하고 있는데, 정말 해야할 것은 class가 같은 것이 아니라, currency가 같은 지에 대한 판단이다.
currency의 판단에 대한 test를 작성하고 싶지만, 이미 에러가 난 상태에서 테스트를 작성하지 않는 것이 좋을 것 같다.

times refactoring 2

차근차근 단계를 밟아간다.

  1. 컴퓨터에게 답 얻기에서 수정한 코드를 다시 times()에서 수정한 원래 코드로 돌려는다.
  2. test가 다시 통과한다.
  3. currencyt가 같은 지를 체크할 수 있는 새로운 테스트를 작성한다.
      public void testDifferentClassEquality() {
       assertTrue(new Money(10, "USD").equals(new Dollar(10, "USD")));
      }
    
  4. 실패한다. equals() 코드가 클래스가 아니라 currency를 비교하도록 해야할 것 같다.
  5. equals를 수정한다.
      // Money class
      public boolean equals(Object object) {
       Money money = (Money) object;
       return amount == money.amount && currency().equals(money.currency());
      }
    
  6. test가 통과한다.
  7. Dollar.times()가 Money를 return하도록 수정한다.
      // Dollar class, Franc도 똑같다.
      Money times(int multi) {
       return new Money(amount * multi, currency);
      }
    
  8. test가 통과한다.
  9. 동일한 구현이 Franc(“CHF”)에서도 적용되는 것을 확인한다.
  10. 7번에서 구현한 함수를 상위 클래스(Money)로 끌어 올린다.

여태까지

  • times()를 일치시키기 위해 그 함수들이 호출하는 다른 함수들을 맞추어주고 상수를 변수로 바꿔주었다.
  • 단지 디버깅을 위해 테스트 없이 toString()을 작성했다.
  • Dollar 대신 Money를 반환하는 변경을 시도한 뒤 그것이 잘 작동할지를 테스트가 말하도록 했다.
  • 실험해본 것을 되돌리고 또 다른 테스트를 작성했다. 테스트를 작동했더니, 실험도 제대로 작동했다.