본문으로 건너뛰기

1부 화폐 예제

1부에서는 완전히 테스트에 의해 주도되는 전형적 모델 코드를 개발할 것이다. 내 목표는 여러분이 테스트 주도 개발의 리듬을 보도록 하는 것이다. 그 리듬은 다음과 같이 요약할 수 있다.

  1. 재빨리 테스트를 하나 추가한다.
  2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
  3. 코드를 조금 바꾼다.
  4. 모든 테스트를 실행하고 전부 성공하는지 확인한다.
  5. 리팩토링을 통해 중복을 제거한다.

1장. 다중 통화를 지원하는 Money 객체

다중 통화를 지원하는 Money 객체부터 시작해보자. 다음과 같은 보고서가 있다고 하자.

종목가격합계
IBM10002525000
GE40010040000
합계65000

다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야 한다.

종목가격합계
IBM100025USD25000USD
Novartis400150CHF60000CHF
합계65000USD

또한 환율도 명시해야 한다.

기준변환환율
CHFUSD1.5

새로운 보고서를 생성하려면 어떤 기능들이 있어야 할까? 즉 어떤 테스트들이 있어야 보고서에 제대로 계산되도록 하는 코드가 완성됐다는 걸 확신할 수 있을까?

  • 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변환 금액을 결과로 얻을 수 있어야 한다.
  • 어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.

테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에 대해 상상해보는 것이 좋다. 우리는 지금 오퍼레이션이 외부에서 어떤 식으로 보일지에 대한 이야기를 테스트 코드에 적고 있는 것이다. 가능한 최선의 API에서 시작해서 거꾸로 작업하는 것이 애초부터 일을 복잡하고 보기 흉하며 '현실적' 이게 하는 것보다 낫다.

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
public void testMultiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}

우리가 작성한 테스트는 아직 컴파일조차 되지 않는다. 현재 네 개의 컴파일 에러가 있다.

  • Dollar 클래스가 없음
  • 생성자가 없음
  • times(int) 메서드가 없음
  • amount 필드가 없음

한 번에 하나씩 정복하기로 하자. Dollar 클래스를 정의하면 에러 하나는 없앨 수 있다.

class Dollar

에러가 하나 없으졌으니 이제 세 개 남았다. 이제 생성자를 만들어보자. 그냥 컴파일만 되게 할 꺼니까 생성자 안에서는 아무 일도 안 해도 된다.

Dollar(int amount) {
}

이제 에러가 두 개 남았다. 이제 times()의 스텁 구현이 필요하다. 이번에도 역시 컴파일만 될 수 있게 해주는 최소한의 구현만 할 것이다.

void times(int multiplier) {
}

이제 하나 남았다. 마지막으로 amount 필드를 추가하자.

int amount;

이제 테스트를 실행해서 테스트가 실패하는 모습을 볼 수 있게 됐다.

우린 지금 공포의 빨간 막대를 보고 있다. 이것도 일종의 진척이다. 이제 실패에 대한 구체적인 척도를 갖게 된 것이다. 우리 문제는 '다중 통화 구현'에서 '이 테스트를 통과시킨 후 나머지 테스트들도 통과시키기'로 변형된 것이다. 훨씬 간단하다.

내가 상상할 수 있는 최소 작업은 다음과 같다.

int amount = 10;

이제 초록 막대를 보게 된다. 계속 진행하기 전에 일반화해야 한다. 주기는 다음과 같다.

  1. 작은 테스트를 하나 추가한다.
  2. 모든 테스트를 실행해서 테스트가 실패하는 것을 확인한다.
  3. 조금 수정한다.
  4. 모든 테스트를 실행해서 테스트가 성공하는 것을 확인한다.
  5. 중복을 제거하기 위해 리팩토링을 한다.
의존성과 중복

스티브 프리만은 테스트와 코드 간의 문제는 중복이 아님을 지적한 바 있다. 문제는 테스트와 코드 사이에 존재하는 의존성이다. 즉 코드나 테스트 중 한쪽을 수정하면 반드시 다른 한쪽도 수정해야만 한다는 것이다. 우리의 목표는 코드를 바꾸지 않으면서도 뭔가 의미 있는 테스트를 하나 더 작성하는 것인데, 현재의 구현으로는 불가능하다.

의존성이 문제 그 자체라면 중복은 그 문제의 징후다. 중복의 가장 흔한 예는 로직의 중복이다. 중복된 로직을 하나로 끄집어내는 일엔 객체를 이용하는 것이 최고다.

문제 자체는 남겨둔 채로 징후만 제거하면 다른 어딘가에서 최악의 형태로 문제가 드러나곤 하는 현실 세계의 일반적인 양상과는 달리, 프로그램에서는 중복만 제거해 주면 의존성도 제거된다. 이게 바로 TDD의 두 번째 규칙이 존재하는 이유다. 다음 테스트로 진행하기 전에 중복을 제거함으로써, 오직 한 가지의 코드 수정을 통해 다음 테스트도 통과되게 만들 가능성을 최대화하는 것이다.

주기의 1번부터 4번 항목까지를 수행했다. 이제 중복을 제거할 차례다. 그런데 어디가 중복이란 말인가? 보통 여러분은 중복을 찾기 위해 코드를 비교할 것이다. 하지만 이번 경우엔 중복이 테스트에 있는 데이터와 코드에 있는 데이터 사이에 존재한다. 코드를 다음과 같이 썼다면 어땠을까?

int amount = 5 * 2;

여기에서 10은 다른 어딘가에서 넘어온 값이다. 사실 우린 우리가 인식조차 할 수 없을 만큼 빨리 머릿속으로 곱셈을 수행한 것이다. 이제 5와 2가 두 곳에 존재한다. 따라서 우린 무자비하게 이 중복을 제거해야 한다.

5와 2를 한 번에 제거할 수 있는 방법은 없다. 하지만 객체 초기화 단계에 있는 설정 코드를 times() 메서드 안으로 옮겨보면 어떨까?

int amount;

void times(int multiplier) {
amount = 5 * 2;
}

테스트는 여전히 통과하고 테스트 막대 역시 초록색이다.

이 단계가 너무 작게 느껴지는가? 하지만 기억하기 바란다. TDD의 핵심은 이런 작은 단계를 밟아야 한다는 것이 아니라, 이런 작은 단계를 밟을 능력을 갖추어야 한다는 것이다.

다시 시작해보자. 5를 어디서 얻을 수 있을까? 이건 생성자에서 넘어오는 값이니 이걸 다음과 같이 amount 변수에 저장하면,

Dollar(int amount) {
this.amount = amount;
}

그걸 times()에서 사용할 수 있다.

void times(int multiplier) {
amount = amount * 2;
}

인자 multiplier의 값이 2 이므로, 상수를 이 인자로 대체할 수 있다.

void times(int multiplier) {
amount *= multiplier;
}

이제 첫 번째 테스트에 완료 표시를 할 수 있게 됐다.

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?

우리는 다음 작업들을 해냈다.

  • 우리가 알고 있는 작업해야 할 테스트 목록을 만들었다.
  • 오퍼레이션이 외부에서 어떻게 보이길 원하는지 말해주는 이야기를 코드로 표현했다.
  • JUnit에 대한 상세한 사항들은 잠시 무시하기로 했다.
  • 스텁 구현을 통해 테스트를 컴파일했다.
  • 끔찍한 죄악을 범하여 테스트를 통과시켰다.
  • 돌아가는 코드에서 상수를 변수로 변경하여 점진적으로 일반화했다.

2장. 타락한 객체

일반적인 TDD 주기는 다음과 같다.

  1. 테스트를 작성한다. 마음속에 있는 오퍼레이션이 코드에 어떤 식으로 나타나길 원하는지 생각해보라. 이야기를 써내려가는 것이다. 원하는 인터페이스를 개발하라. 올바른 답을 얻기 위해 필요한 이야기의 모든 요소를 포함시켜라.
  2. 실행 가능하게 만든다. 다른 무엇보다도 중요한 것은 빨리 초록 막대를 보는 것이다. 깔끔하고 단순한 해법이 명백히 보인다면 그것을 입력하라.
  3. 올바르게 만든다. 이제 시스템이 작동하므로 직전에 저질렀던 죄악을 수습하자. 중복을 제거하고 초록 막대로 되돌리자.

우리의 목적은 작동하는 깔끔한 코드를 얻는 것이다. 하지만 도달하기 힘든 목표이기에 나누어서 정복해야한다. 일단 '작동하는 깔끔한 코드'를 얻어야 한다는 전체 문제 중에서 '작동하는'에 해당하는 부분을 먼저 해결하라. 그러고 나서 '깔끔한 코드' 부분을 해결하는 것이다. 이러한 접근 방식은 '깔끔한 코드' 부분을 먼저 해결한 후에, '작동하는' 부분을 해결해 가면서 배운 것들을 설계에 반영하느라 허둥거리는 아키텍처 주도 개발과 정반대다.

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?

Dollar에 대해 연산을 수행한 후에 해당 Dollar의 값이 바뀌는 점을 수정해야 한다. 나는 다음과 같이 쓸 수 있길 바란다.

public void testMultiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
}

Dollar.times()를 아래와 같이 수정하기 전엔 새 테스트는 컴파일조차 되지 않을 것이다.

Dollar times(int multiplier) {
amount *= multiplier;
return null;
}

이제 테스트가 컴파일된다. 하지만 실행되지는 않는다. 그래도 한 걸음 나아갔다. 테스트를 통과하기 위해 올바른 금액을 갖는 새 Dollar를 반환해야 한다.

Dollar times(int multiplier) {
return new Dollar(amount * multiplier);
}
노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?

다음은 최대한 빨리 초록색을 보기 위해 취할 수 있는 내가 아는 세 전략 중 두 가지다.

  • 가짜로 구현하기: 상수를 반환하게 만들고 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 바꾸어 간다.
  • 명백한 구현 사용하기: 실제 구현을 입력한다.

나는 보통 실무에서 TDD를 사용할 때 두 방법을 번갈아가며 사용한다. 모든 일이 자연스럽게 잘 진행되고 내가 뭘 입력해야 할지 알 때는 명백한 구현을 계속 더해 나간다(나에게 명백한 사실이 컴퓨터에게도 명백한 사실인지 확인하기 위해 구현 사이에 테스트를 한 번씩 실행한다.)

지금까지 배운 것을 검토해보자.

  • 설계상의 결함(Dollar 부작용)을 그 결함으로 인해 실패하는 테스트로 변환했다.
  • 스텁 구현으로 빠르게 컴파일을 통과하도록 만들었다.
  • 올바르다고 생각하는 코드를 입력하여 테스트를 통과했다.

3장. 모두를 위한 평등

지금의 Dollar 객체같이 객체를 값처럼 쓸 수 있는데 이것을 값 객체 패턴(value object pattern)이라고 한다. 값 객체에 대한 제약사항 중 하나는 객체의 인스턴스 변수가 생성자를 통해서 일단 설정된 후에는 결코 변하지 않는다는 것이다.

값 객체를 사용하면 별칭 문제에 대해 걱정할 필요가 없다는 아주 큰 장점이 있다. $5가 있을 때 그것이 영원히 $5임을 보장 받을 수 있다. 누군가가 $7를 원한다면 새로운 객체를 만들어야 할 것이다.

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()

값 객체가 암시하는 것 중 하나는 모든 연산은 새 객체를 반환해야 한다는 것이다. 또다른 암시는 값 객체는 equals()를 구현해야 한다는 것인데, 왜냐하면 $5라는 것은 항상 다른 $5만큼이나 똑같이 좋은 것이기 때문이다.

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
}

빨간 막대다. 가짜로 구현하는 방법은 단순히 true를 반환하는 것이다.

public boolean equals(Object object) {
return true;
}

삼각측량을 위해 두 번째 예제가 필요하다. $5 != $6을 해보는게 어떨까?

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

이제 동치성(equality)을 일반화해야 한다.

public boolean equals(Object object) {
Dollar dollar = (Dollar) object;
return amount == dollar.amount;
}
노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()

내가 생각하기에 삼각측량은 조금 이상한 면이 있다. 그래서 나는 어떻게 리팩토링해야 하는지 전혀 감이 안 올 때만 삼각측량을 사용한다. 코드와 테스트 사이의 중복을 제거하고 일반적인 해법을 구할 방법이 보이면 그냥 그 방법대로 구현한다. 왜 한번에 끝낼 수 있는 일을 두고 또다른 테스트를 만들어야 하는가?

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object

동질성 기능을 구현했으므로 Dollar와 Dollar를 직접 비교할 수 있게 됐다. 따라서 모든 올바른 인스턴스 변수들이 그럿듯 amount를 private으로 만들 수 있게 됐다.

4장. 프라이버시

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object

동치성 문제를 정의했으므로 이를 이용하여 테스트가 조금 더 많은 이야기를 해줄 수 있도록 만들자. 개념적으로 Dollar.times() 연산은 호출을 받은 객체의 값에 인자로 받은 곱수만큼 곱한 값을 갖는 Dollar를 반환해야 한다. 하지만 테스트가 정확히 그것을 말하지는 않는다.

public void testMultiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
}

첫 번째 단언을 Dollar와 Dollar를 비교하는 것으로 재작성할 수 있다.

public void testMultiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(new Dollar(10), product);
product = five.times(3);
assertEquals(15, product.amount);
}

이게 더 좋아보이므로 두 번째 단언도 마찬가지로 고쳐보자.

public void testMultiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(new Dollar(10), product);
product = five.times(3);
assertEquals(new Dollar(15), product);
}

이제 임시 변수인 product는 더 이상 쓸모없어 보인다. 인라인시켜보자.

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

이 테스트는 일련의 오퍼레이션이 아니라 참인 명제에 대한 단언들이므로 우리의 의도를 더 명확하게 이야기해준다.

테스트를 고치고 나니 이제 Dollar의 amount 인스턴스 변수를 사용하는 코드는 Dollar 자신밖에 없게 됐다. 따라서 변수를 private으로 변경할 수 있다.

private int amount;
노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object

우리는 완벽함을 위해 노력하지는 않는다. 모든 것을 두 번 말함으로써(코드와 테스트로 한 번씩) 자신감을 가지고 전진할 수 있을 만큼만 결함의 정도를 낮추기를 희망할 뿐이다. 때때로 우리의 추론이 맞지 않아서 결함이 발생할 수 있다. 그럴 때면 테스트를 어떻게 작성해야 했는지에 대한 교훈을 얻고 다시 앞으로 나아간다. 그 이후에는 초록 막대 아래서 대담하게 나아갈 수 있다.

  • 오직 테스트를 향상시키기 위해서만 개발된 기능을 사용했다.
  • 두 테스트가 동시에 실패하면 망한다는 점을 인식했다.
  • 위험 요소가 있음에도 계속 진행했다.
  • 테스트와 코드 사이의 결합도를 낮추기 위해, 테스트하는 객체의 새 기능을 사용했다.

5장. 솔직히 말하자면

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF x 2 = 10CHF

첫 번째 테스트에 어떤 식으로 접근하는게 좋을까? 너무 큰 것 같아 작은 단계 하나로 구현하는 테스트를 작성해낼 수 있을지 확실치 않다. 우선은 Dollar 객체와 비슷하지만 달러 대신 프랑(Franc)을 표현할 수 있는 객체가 필요할 것 같다. Dollar 객체와 비슷하게 작동하는 Franc 객체를 만든다면 단위가 섞인 덧셈 테스트를 작성하고 돌려보는 데 더 가까워질 것이다.

public void testFrancMultiplication() {
Franc five = new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}

우선 Dollar 코드를 복사해서 Dollar를 Franc으로 바꾸어 보자.

class Franc {
private int amount;

Franc(int amount) {
this.amount = amount;
}

Franc times(int multiplier) {
return new Franc(amount * multiplier);
}

public boolean equals(Object object) {
Franc franc = (Franc) object;
return amount == franc.amount;
}
}
노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF x 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times

코드를 실행시키기까지의 단계가 짧았기 때문에 '컴파일되게 하기' 단계도 넘어갈 수 있었다.

중복이 엄청나게 많기 때문에 다음 테스트를 작성하기 전에 이것들을 제거해야 한다. equals()를 일반화하는 것부터 시작하자.

  • 큰 테스트를 공략할 수 없다. 그래서 진전을 나타낼 수 있는 자그마한 테스트를 만들었다.
  • 뻔뻔스럽게도 중복을 만들고 조금 고쳐서 테스트를 작성했다.
  • 설상가상으로 모델 코드까지 도매금으로 복사하고 수정해서 테스트를 통과했다.
  • 중복이 사라지기 전에는 집에 가지 않겠다고 약속했다.

6장. 돌아온 '모두를 위한 평등'

노트
  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 x 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF x 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times