본문 바로가기
기타

TDD 스터디 #4 - 화폐 예제 (유연한 통화 구현)

by 유세지 2023. 8. 22.

 

 

 

지난 포스트서 이어집니다. 저번 시간까지 우리는 Dollar 하나만 존재했던 돈의 단위를 Dollar와 Franc 두 가지로 나누었고, Money 클래스를 통해 공통 부분을 상속받도록 하였습니다. 이번 시간에는 좀 더 유연한 통화 클래스를 구현해 보도록 하겠습니다.

 

 

Dollar vs Franc

 

우선 지난 시간의 마지막 테스트 코드를 보겠습니다.

 

 

example.test.js

it("equals() 테스트", () => {
  expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
  expect(new Dollar(5).equals(new Dollar(6))).toBe(false);
  expect(new Franc(5).equals(new Franc(5))).toBe(true);
  expect(new Franc(5).equals(new Franc(6))).toBe(false);
});

 

두 비교 대상이 서로 같은 amount를 가지면 true를, 아니라면 false를 반환하도록 하는 테스트 코드입니다. 그런데, 자세히 보면 테스트 코드에서는 같은 통화끼리만 비교하고 있습니다. 만약 Dollar와 Franc을 비교하려고 하면 어떻게 되어야 할까요?

 

 

example.test.js

it("equals() 테스트", () => {
  expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
  expect(new Dollar(5).equals(new Dollar(6))).toBe(false);
  expect(new Franc(5).equals(new Franc(5))).toBe(true);
  expect(new Franc(5).equals(new Franc(6))).toBe(false);
  expect(new Franc(5).equals(new Dollar(5))).toBe(false);
});

 

Franc과 Dollar는 서로 다른 통화이니, 마땅히 false를 반환해야 할 것입니다.

그러나 테스트를 돌려보면,

 

 

통화가 다른데도 true를 반환하고 있습니다.

 

 

equals() 메서드에서 별다른 검사를 하지 않기 때문에, 클래스가 다르지만 true를 반환한 모습입니다. 따라서 메서드 내부에 클래스를 비교하는 로직을 추가해주어야 합니다.

 

자바스크립트에서는 두 대상간의 constructor를 비교하여 같은 클래스인지 확인할 수 있는 instanceof 키워드가 있습니다. 이 키워드는 Left-side 객체의 prototype chain에 비교 대상의 constructor가 존재한다면 true를 반환해주는 역할을 합니다.

 

그럼 equals() 메서드를 아래처럼 수정해주겠습니다.

 

 

src/money.js

equals(object) {
  return object instanceof this.constructor && this.amount === object.amount;
}

 

이제 테스트를 돌려보겠습니다.

 

 

성공적입니다.

 

 

이렇게 equals() 메서드는 미숙하게나마 통화 단위를 구분하게 되었습니다. 이후에 좀 더 똑똑한 비교를 할 수 있도록 만들어주기로 하고, 이번엔 각 서브클래스의 times() 메서드를 보겠습니다.

 

 

times() 중복 제거하기 : 결합도 낮추기

지금 Money의 서브클래스들을 보면, 각자의 times() 메서드만 선언되어있을 뿐 구현상 둘 간의 차이가 없어 보입니다. 굳이 나누어 줄 필요는 없어보이니 점진적으로 합치는게 맞을 것 같습니다.

 

이때 두 서브클래스인 Dollar와 Franc에 선언된 times()의 유일한 차이점은 반환하는 값이 어떤 클래스로 선언되었는가 입니다. 이 점에 착안해서 테스트 코드를 수정해보겠습니다.

 

어떤 코드를 걷어내려면 먼저 그 코드에 의존하는 부분을 최대한 없애는 것으로 시작하는게 좋습니다. 테스트 코드 상에서 Dollar와 Franc에 의존하고 있는 부분을 찾아봅시다.

 

 

example.test.js

it("Dollar 테스트", () => {
  const five = new Dollar(5);
  expect(new Dollar(10)).toStrictEqual(five.times(2));
  expect(new Dollar(15)).toStrictEqual(five.times(3));
});

 

Dollar 테스트 부분 전체가 Dollar 클래스에 의존하고 있습니다. 아래의 Franc 테스트도, equals() 테스트도 마찬가지입니다. Dollar의 개념 자체는 분명 필요하지만, 클라이언트에서 직접 가져와서 사용할 필요는 없습니다. 슈퍼클래스인 Money를 통해 Dollar 인스턴스를 생성하도록 하고, 클라이언트에서는 해당 메서드를 가져와서 사용하는 방식으로 수정해보겠습니다.

 

 

example.test.js

it("Dollar 테스트", () => {
  const five = Money.dollar(5);
  expect(Money.dollar(10)).toStrictEqual(five.times(2));
  expect(Money.dollar(15)).toStrictEqual(five.times(3));
});

 

이런식으로 Money를 가져와서 dollar() 메서드를 통해 인스턴스를 얻어내면 될 것 같습니다. 우선 테스트를 돌려 빨간불을 확인해봅시다.

 

 

Money를 찾을 수 없습니다.

 

 

당연히 실패합니다. Money에서 dollar() 메서드를 만들지 않은건 둘째치고, Money를 import 조차 하지 않았습니다. 빠르게 녹색 불을 띄우기 위해 간단히만 만들어주겠습니다.

 

src/money.js

/* class Money */
static dollar(amount) {
  return new Dollar(amount);
}

// ...

export { Money, Dollar, Franc };

 

example.test.js

import { Money, Dollar, Franc } from "../src/money";

// ...

 

이제 녹색불을 확인해봅시다.

 

 

money를 잘 찾아온듯 하네요.

 

 

테스트가 잘 통과하는걸 확인했으니, Franc도 같은 방식으로 모두 바꾸어주겠습니다.

 

 

example.test.js

it("equals() 테스트", () => {
  expect(Money.dollar(5).equals(Money.dollar(5))).toBe(true);
  expect(Money.dollar(5).equals(Money.dollar(6))).toBe(false);
  expect(Money.franc(5).equals(Money.franc(5))).toBe(true);
  expect(Money.franc(5).equals(Money.franc(6))).toBe(false);
  expect(Money.franc(5).equals(Money.dollar(5))).toBe(false);
});

 

 

 

 

테스트 결과 또한 같았습니다. 완전히 동일하니 굳이 첨부하지 않겠습니다.

 

이것으로 클라이언트와 각 서브클래스간의 결합도가 상당히 줄어들었습니다. times() 메서드의 중복을 제거할 준비가 끝났다고 보아도 되겠네요. 그렇다면 진짜 중복을 제거해 줄 차례입니다.

 

 

times() 중복 제거하기 : 생성자 제거하기

지금까지는 클래스를 통해 현재의 Money가 Dollar인지 Franc인지 구분해왔었습니다. 그러나 현재까지의 상황으로 볼때, 클래스를 통해 통화를 구분하는 것은 많은 중복을 낳습니다. 이러한 중복을 제거해나가며 최종적으로는 서브클래스를 모두 슈퍼클래스에 통합시키는 쪽으로 코드를 수정해보겠습니다.

 

클래스를 사용하지 않는다면, 현재 단위를 저장할 변수가 하나 필요할 것입니다. 슈퍼클래스에 통화(currency)라는 이름의 변수를 하나 가지고 있다고 가정하고, 테스트 코드를 작성하겠습니다.

 

 

example.test.js

it("Currency 테스트", () => {
  expect(Money.dollar(1).currency).toBe("USD");
  expect(Money.franc(1).currency).toBe("CHF");
});

 

 

이제 currency() 메서드를 구현해주어야 합니다.

 

 

 

빠르게 테스트를 실패한 후, money.js에서 currency() 메서드를 구현해보겠습니다.

 

src/money.js

/* Money class */
class Money {
  amount;
  currency;

  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  static dollar(amount) {
    return new Dollar(amount, "USD");
  }

  static franc(amount) {
    return new Franc(amount, "CHF");
  }

  get currency() {
    return this.currency;
  }
  
  // ...
}

 

멤버 변수의 값을 불러오는 메서드이므로, getter를 이용하겠습니다.

 

src/money.js

/* dollar class */
class Dollar extends Money {
  amount;
  currency;

  constructor(amount, currency) {
    super(amount, currency);
    this.amount = amount;
    this.currency = currency;
  }
  
  // ...
}

// Franc도 동일하게 수정

 

이제 테스트를 돌려보겠습니다.

 

 

Dollar와 Franc 생성에서 빨간불이 들어왔습니다.

 

 

Currency 테스트는 성공적으로 통과하였지만, Dollar와 Franc 테스트에서 문제가 생겼습니다. times() 메서드 부분에서 직접 생성자를 호출하고 있기 때문에 currency 값이 제대로 들어가지 않아 발생한 문제입니다.

 

원래는 지금 하던 작업을 마무리 지은 뒤 다시 돌아와 테스트를 작성하고 고쳐나가는게 맞지만, 이렇게 문제가 발생했을경우 하던 작업을 잠시 멈추고 문제를 들여다보는 것도 괜찮습니다.

 

다행히 금방 해결할 수 있는 문제이니 빠르게 고치고 넘어가겠습니다.

 

src/money.js

/* Dollar class */
times(multiplier) {
  return Money.dollar(this.amount * multiplier);
}

/* Franc class */
times(multiplier) {
  return Money.franc(this.amount * multiplier);
}

 

생성자 함수 대신, 위에서 만든 팩토리 메서드를 사용하도록 times() 메서드를 수정한 뒤 다시 테스트를 진행합니다.

 

 

모두 녹색불이 들어왔습니다.

 

 

이 시점에서 각 서브 클래스의 생성자 함수를 보면, 완전히 동일함을 알 수 있습니다.

드디어 생성자들을 지워줄 수 있겠네요.

 

 

src/money.js

class Dollar extends Money {
  times(multiplier) {
    return Money.dollar(this.amount * multiplier);
  }
}

class Franc extends Money {
  times(multiplier) {
    return Money.franc(this.amount * multiplier);
  }
}

 

놓치기 쉬운 부분인데, 생성자를 지울때 서브클래스에 선언한 멤버 변수들도 함께 지워주어야 합니다. 자바스크립트에서는 변수를 찾을때 가장 가까운 체인에 위치한 변수를 찾아가므로 서브클래스에 멤버 변수가 선언되어 있으면 부모클래스가 아닌 해당 서브클래스의 멤버 변수를 사용합니다.

 

위의 경우 초기화는 super. 즉 부모클래스 멤버 변수에서 이루어졌으므로, 정작 해당 값을 사용하려 하면 초기화가 되지 않은 서브클래스의 엉뚱한 값을 사용하게 됩니다. 실제로 콘솔로그를 통해 확인해보면 undefined가 출력되는 것을 알 수 있습니다.

 

 

멤버 변수를 지우지 않으면 엉뚱한 값을 사용하게 됩니다.

 

 

여기서 멤버 변수까지 모두 지운채로 다시 테스트를 진행해보면,

 

 

초록불을 확인할 수 있습니다.

 

 

이렇게 amount와 currency 값을 제대로 불러오고, 초록불도 보게 됩니다.

 

 

times() 중복 제거하기 : 서브클래스 제거하기

긴 과정 함께 보시느라 고생하셨습니다. 이제 정말 마지막 단계입니다.

이번에야말로 times() 중복을 제거하고, 나아가 서브클래스까지 제거해보도록 하겠습니다.

 

times() 메서드를 제거하려고 보니, 아직 제거하기엔 모양이 조금 다릅니다.

먼저 모양부터 동일하게 맞춰주도록 하겠습니다.

 

그런데 Money의 팩토리 메서드를 호출하여 생성하는 지금 구조에서는 섣불리 times를 지우기 어려워 보입니다.

해당 부분을 다시 인라인 시켜보도록 하겠습니다.

 

 

src/money.js

class Dollar extends Money {
  times(multiplier) {
    return new Dollar(this.amount * multiplier, this.currency);
  }
}

class Franc extends Money {
  times(multiplier) {
    return new Franc(this.amount * multiplier, this.currency);
  }
}

 

각 서브클래스 내에서 currency는 변하지 않으므로, this.currency를 이용해 곧바로 생성해주었습니다.

 

 

테스트는 여전히 초록불입니다.

 

테스트에서도 정상적으로 초록불이 들어오는걸 보니, 문제 없이 잘 된 것 같아 보이네요.

 

 

이제 각각의 times() 메서드를 보니, new 키워드 이후의 Dollar와 Franc 밖에 다른 점이 없습니다.

이미 멤버 변수로 currency를 가지고 있는데, 이제 별도의 클래스로 나누어 줄 필요가 사라졌습니다.

이대로 끌어올려도 괜찮을지는 테스트가 판별해줄테니, 고민하지 않고 Money 클래스에서 생성하도록 수정해보겠습니다.

 

 

src/money.js

/* Money class */
class Money {
  // ...
  
  static dollar(amount) {
    return new Money(amount, "USD");
 }

  static franc(amount) {
    return new Money(amount, "CHF");
  }
  
  // ...
  
  times(multiplier) {
    return new Money(this.amount * multiplier, this.currency);
  }
}
// Dollar, Franc class 삭제
export { Money };

 

이제 테스트를 돌려보겠습니다.

 

 

equals() 테스트가 실패하였습니다.

 

 

equals() 메서드에서 테스트가 실패했습니다. 다른 통화를 비교하는 부분에서 같은 클래스인지 확인하는 로직이 문제였습니다. 이제 하나의 클래스에서 모두 생성하니 클래스 자체를 비교하는 로직은 필요하지 않습니다. 두 인스턴스의 currency를 비교하도록 변경하고, 다시 테스트를 해봅시다.

 

 

src/money.js

/* Money class */
equals(object) {
  return this.currency === object.currency && this.amount === object.amount;
}

 

 

성공했습니다!

 

 

드디어 모든 중복을 제거하고, 불필요한 Dollar, Franc 클래스를 삭제하였습니다. Money 클래스 하나에서 모든 동작을 처리하도록 변경된 상태입니다. 물론 현재 이 구조가 앞으로도 변하지 않을 정답이라고는 할 수 없습니다. 필요에 따라 얼마든지 변경될 수 있다는 것을 항상 마음속에 새기고, TDD를 통해 안전하게 변경을 처리하는 과정을 기억해두고 넘어가면 좋을 것 같습니다.

 

 

작업을 마치고 테스트들을 살펴보니, 테스트에서도 중복이 존재하고 있었습니다.

마저 제거해주도록 하겠습니다.

 

example.test.js

it("equals() 테스트", () => {
  expect(Money.dollar(5).equals(Money.dollar(5))).toBe(true);
  expect(Money.dollar(5).equals(Money.dollar(6))).toBe(false);
  expect(Money.franc(5).equals(Money.dollar(5))).toBe(false);
});

 

모두 성공하는 테스트에서 몇 개를 지웠으니, 당연히 성공합니다.

 

중복된 테스트를 지운것 뿐이라 해당 테스트가 존재하지 않는다고 해서 문제가 생길 부분은 없을 것 같습니다.

테스트까지 정리를 끝냈으니, 이번 작업을 마쳐도 될 것 같네요.

 

 

 

마치며

오늘은 이전에 진행했던 코드들의 중복을 제거하는 부분을 중점적으로 진행해보았습니다. 작게는 이번 포스팅의 times() 메서드 부분부터, 크게는 지난 포스팅에서 만들었던 Dollar, Franc 클래스들까지 제거를 완료했습니다.

 

마지막 부분에서 times() 메서드를 끌어올리고, 클래스를 아예 삭제해버리는 부분의 경우 실제 서비스였다면 저 클래스들에 미처 파악하지 못한 수 많은 로직들이 얽혀 있을 것으로 예상이 되어 함부로 건드리기 두려웠을것 같은데, TDD를 이용하니 잘 짜여진 테스트들이 안전 장치 역할을 해주고 있다고 느껴져서 안심이 되었습니다.

 

다음 포스팅에서는 서로 다른 Money에 대한 계산을 처리해보도록 하겠습니다. times() 메서드의 경우 하나의 Money에서 곱연산을 수행하였으니, 다음에는 다른 Money들끼리 합연산을 수행하는 것을 목표로 진행하면 될 것 같습니다.

 

포스팅에서 사용된 예제 코드들은 아래 레포지토리에서 확인할 수 있습니다.

읽어주셔서 감사합니다.

 

반응형

댓글