목차
- TDD 스터디에서 라이브코딩으로 TDD를 통한 도시 가스 요금 도메인을 만들면서, Mock과 객체지향의 SOLID 일부를 섞어서 설명한 내용입니다. 비싸진 도시가스 요금에 맞춰 도시가스 요금 관련으로 준비했습니다. 작년 11월부터 스터디했던 TDD, 클린코드, 오브젝트에서 배운걸 섞어서 시나리오를 만들어봤습니다. 개선점이 있다면 알려주세요.
- 요구사항 : 도시가스 요금을 계산하는 간단한 로직이 필요함. '단위 요금 x 사용량' 으로 금액을 계산할 것임. 다만 다른 요금 계산 방식이 추가될 예정이다(취약계층 할인 등등). 추가로 아직 DB 부분이 정해지지 않은 상태에서 우선 요금 계산 하는 로직부터 테스트해보며 짜려 하는 상황이다.
github 코드 (이하 단계에 따라 커밋되어 있어요)
1. 테스트코드로 전반적인 예상 코드 구조를 짜본다.
단위요금(unitPrice)와 사용량(usage)를 가진 사용자(CityGasUser)가 있다.
이 때, CityGasChargeService의 calculateCharge에 뭔가 유저 ID라는걸 넣으면 요금이 계산되게 하고 싶다.
기본요금의 경우 요구사항에 따라 단순히 단위요금x사용량으로 계산될테니, 계산 결과는 50이 되어야 한다.
아직은 어떤식으로 고객정보를 CityGasChargeService로 넘길지 모르겠다.
일단 컴파일은 되도록 해보려 한다. 우선 CityGasUser는 고객 정보에 대한 도메인 객체로 판단했다.
CityGasChargeService는 요금 계산에 대한 서비스 객체로 판단했다.
추가로 아직 DB 구조 등이 정해지진 않았지만, 일단 사용자 정보를 넣고 가져올 수 있는 인터페이스 정도는 만들어둬야 원하는대로 calculateCharge에서 targetUserId를 넘겼을 때 사용자 데이터를 가져올 수 있을 것 같다. 이에 따라 테스트 코드를 수정해줬다.
테스트 코드에 맞춰 우선 컴파일만 가능하게 해준다.
2. 테스트가 통과하도록 구현해주자.
이제 실제 동작이 가능하도록 해줘야 한다. 물론 원래 TDD책에 나온대로면 우선 성공만 하게 하드코딩을 하던 뭘 하던 성공시킨 뒤 다른걸 진행해야겠지만, 일단 스터디에서 라이브코딩 시에는 일반적인 프로젝트 구조대로 바로 진행했다.
calculateCharge에서 targetUserId를 기준으로 유저 데이터를 가져오려면 CityGasUserService를 가지고 있어야 할 것 같다. 일단은 생성자에서 주입받기로 한다. 이에 따라 테스트 코드도 수정되었다.
calculateCharge와 CityGasUser의 구현은 아래처럼 해줬다. 아직 CityGasUserService는 정의하지 않았다.
이제 CityGasUserService를 짜보려 했는데, 아직 정해지진 않았지만 차후 DB가 붙을것이다. 그럼 미리 그 부분도 생각해서 Repository 객체를 붙여두려고 한다. 차후 DB가 정해졌을 때 갈아끼우기 좋게 interface로 두고, JpaRepository를 사용해보기로 했다.
그럼 CityGasUserService는 아래처럼 구현해주면 될 것 같다. 차후 DB가 정해지면 스프링부트가 알아서 repository를 주입해줄테지만, 현재는 interface밖에 없는 상황이라 불가능하다. 그래서 테스트코드에서 repository를 어떻게 주입을 해줄 수 있을진 아직 모르겠다.
3. Mock 객체를 만들어 테스트 통과시키기
이제 repository만 주입해주면 테스트를 해볼 수 있다. 간단하게는 그냥 구현체를 바로 만들어서 넣어주면 된다. 많이 지저분하지만 뭐 이래도 상관 없을 것 같긴하다.
테스트를 해보니 실패한다. 위 구현체에 적어도 save랑 findById는 직접 구현을 해줘야할 것 같다.
이런 상황에서 쓰기 좋은게 Mock 객체이다. Mockito를 사용하면 위와 같은 상황에서 매우 편리하다. 추가로 DB가 정해져 있더라도, 의존성을 빼고 요금 계산 로직 자체만 검증하고 싶거나, 실제 객체를 사용하면 테스트가 너무 느릴 때 등 필요할 때 Mock 객체를 사용할 수 있다. 우선 Mockito를 써서 Mock 객체를 만들어보자. Mock 객체의 경우 위에서 만든 것 처럼, 객체를 리턴할 땐 null (Optional을 리턴할땐 empty), primitive type을 리턴할 땐 해당 타입의 기본값이 들어가는 등 기초적인 구현만 되어 있으므로 아직은 마찬가지로 테스트가 실패한다.
하지만 interface를 직접 구현해서 넣어줄때와 달리, Mockito에서 제공하는 함수들을 사용하면 편하게 원하는 동작을 만들어낼 수 있다. Mockito의 when()을 사용해 Mock 객체로 만든 repository에 findById가 인자 1L로 호출될 시, 테스트코드에 정의해둔 CityGasUser를 리턴하도록 했다. 이제 테스트는 통과한다!
추가로 Mockito로 다양한 방식의 테스트가 가능한데, 어떠한 함수가 불렸는지도 확인 가능하다. findById로 유저 정보를 가져오려면 실제론 데이터가 DB에 들어가있었어야하는데, 우린 Mock 객체에 when()을 통해 해당 데이터를 리턴하도록 정의했으므로 실제 save()가 불리지 않았어도 리턴은 될 거다. 그럼 차후 DB를 실제로 구현했을 때 테스트가 문제가 될 수 있으므로 verify()를 통해 테스트 로직 중에 repository의 save()에 인자로 테스트코드에서 정의한 CityGasUser가 들어간게 1번 실행 됬는지 확인한다.
추가로 테스트코드를 given, when, then으로 나누어두었는데, 위 코드를 보면 given부분에 when() 이라는 이름이 들어가있으므로 뭔가 별로다. 이 경우 BDD Mockito를 사용해서 수정해주면 된다(MockIto와 BDD Mockito 모두 spring-boot-starter-test에 이미 포함되어 있다.). BDD Mockito로 바꾸면 다음과 같이 when() -> given(), verify() -> then() 으로 사용할 수 있다.
여기까지 테스트는 정상적으로 동작한다!
4. 취약계층 할인 요구사항 추가
이제 요구사항에 있던대로 다른 요금 계산 방식을 추가해보자. 우선 취약계층에 20%할인(소수점 내림)을 해주는 요구사항을 추가할거다. 우선 테스트코드를 추가해준다. 20% 할인된 40L이 리턴되길 바랬는데, 당연하게도 테스트는 실패한다.
객체지향에 아직 익숙하지 않은 개발자라면 여기서 가장 쉽게 생각할 수 있는 방식은, 계산하는 부분에 상태값을 넣어줘서 분기로 처리하는 것이다. 우선 상태값을 넣어주도록 바꿔보자.
그에 맞춰 구현을 바꿔준다. enum을 추가해주고, 계산하는 부분에서 상태값에 따라 분기쳐서 요금계산을 해준다. 테스트코드쪽에 기존 일반 요금엔 CityGasChargeType.REGULAR를 넣어준다.
그럼 테스트는 통과한다!
위 코드의 문제점
1. 변경의 주기가 서로 다른 코드가 한 클래스 안에 뭉쳐있다. CityGasChargeService는 일반적인 요금 계산 로직이 수정되도 수정되야 하고, 취약계층 요금 계산 로직이 변경되도 수정된다. -> SRP(단일 책임 원칙) 위배
2. 요구사항에 따라 취약계층 요금 계산 방식 뿐 아니라 차후 또다른 요금 계산 방식이 추가될 예정이다. 현재 짠 코드의 경우 새로운 요금 계산 방식이 추가될 때 마다 실제 구현부에 if문에 계속 추가되고, CityGasChargeType도 계속해서 수정되어야 한다. -> OCP (개방-폐쇄 원칙) 또한 위배된다.
3. 실제 구현 클래스 자체를 사용하고 있으므로 DIP(의존 관계 역전 원칙) 위배는 당연하다.
5. 리팩토링 1 - 상속을 통한 코드 재사용
우선 상태값을 통해 분기치는 로직은 당연하게도 없애줘야 한다. 그리고 어차피 기본 요금 계산 로직에 비해 취약계층 계산 로직에 바뀐거라고는 20% 할인해주는 부분밖에 없다. 그럼 상속을 통해 기본 요금 계산 로직의 코드를 재사용(서브클래싱)해서 해결하면 된다고 생각해볼 수 있다. 우선 테스트코드는 상태값을 넣기 전으로 돌리고, 취약계층은 기본 요금 계산 로직을 재사용한 새로운 클래스로 만들테니 테스트코드에 반영한다. 그리고 CityGasChargeType은 이제 안쓸거니 지워줬다. 기본 요금 계산 로직도 원래대로 돌려준다.
서브클래싱으로 취약계층 요금을 담당할 클래스를 만들어줬다. 기본 요금 계산 로직에서 받은 regularCharge를 20% 할인해서 리턴해주면 된다.
테스트는 통과했고, 어쨌든 기존 상태값으로 분기치던 때 보다는 많이 좋아진 것 같다.
위 코드의 문제점
1. 얼핏 이제 요금 계산 변경 시 각 클래스만 수정하면 되니 SRP를 만족하는 것으로 보이고, 새로운 계산 요구사항이 추가될 시 매번 기본 요금 계산 로직을 상속을 통해 재사용하면 될테니 OCP도 만족하는 것 같다! 하지만 사실 기본 요금 계산 로직이 변경될 시 해당 코드를 서브클래싱한 모든 객체도 전부 변경해야한다. 예를들어 취약계층을 제외한 기본 요금을 10% 할인해주는 요구사항이 추가된다면? 취약계층 로직에서는 10%를 다시 추가해준 뒤 20%를 할인하도록 변경해줘야 한다. -> 따라서 여전히 SRP 위배이다.
2. 취약 계층 클래스를 위처럼 구현한건 사실 기본 요금 계산 로직이 어떻게 되는지 부모 클래스의 구현을 모두 알고 있기 때문에 가능한 것이다. 따라서 캡슐화가 실패했다고 볼 수 있다.
6. 리팩토링 2 - 디자인 패턴을 적용해보자!
소프트웨어 설계에서 반복적으로 발생하는 문제에 대해 반복적으로 적용할 수 있는 해결 방법을 디자인 패턴이라고 부른다. 위 문제를 해결하기 위해 이미 검증된 지혜를 활용해보면 어떨까?
이 경우 적합해보이는건 TEMPLATE METHOD 패턴이다. 두 클래스의 공통적인 부분을 모아 또 다른 부모 클래스에 모으고, 부모 클래스와 자식 모두가 추상화에 의존하도록 해보자. 테스트코드는 다음과 같이 변경될 것이다.
공통적인 부분을 모으고 자식 클래스의 추상화에 의존하게 해보면 다음과 같이 짤 수 있다.
AbstractCityGasChargeService를 상속받아 기존 두 계산 로직을 변경하면 다음과 같다.
테스트는 잘 된다!
이 코드는 이제 문제 없는지 얘기해보기 전에, 우선 추가로 눈에 띄는 부분을 리팩토링 해보자.
1. 할인율이 20%라는걸 나중에 코드 보는 사람이 어떻게 알 수 있을까? 코드를 다 까보는 수밖에 없다. 이것 또한 캡슐화 실패로 볼 수 있다. 좋은 방법은, 할인율을 실제 사용하는 곳에서 주입하도록 변경하는것이다.
2. AbstractCityGasChargeService의 경우 기본요금 및 취약계층 요금 로직의 취상위 부모임을 유추하기 어렵다. 또한 Abstract~~~ 같은 형태나 인터페이스의 경우 예전에 많이 사용하던 I~~~ 식의 작명 방법은 인터페이스에서 자기 자신을 드러내게 되므로 일종의 캡슐화 실패이다. AbstractCityGasChargeService -> CityGasChargeService, CityGasChargeService -> RegularCityGasChargeService로 변경하는게 더 좋을 것 같다.
여전히 테스트는 잘 통과하고 코드는 처음보다 많이 나아진 것 같다.
1. 이제 각각 하나의 변경 이유만을 가진다. -> SRP 만족
- 고객 정보를 얻어오는 방식이 변경되는 등 전반적인 도시가스 계산 로직이 변경된 경우 CityGasChargeService를 수정하면 된다. 예를 들어 모든 도시가스 요금은 VAT를 추가해서 보여줘야 한다면 자식 클래스쪽을 건드릴 필요 없이 CityGasChargeService에서 최종적으로 VAT 계산 로직을 넣어주면 모두에게 적용된다.
- 기본 계산 로직이 변경될 시 RegularCityGasChargeService를 변경하면 된다.
- 취약계층 계산 로직이 변경될 시 VulnerableCityGasChargeService를 변경하면 된다.
2. 요구사항에 따라 새로운 요금 계산 방식을 추가할 때, 이제 컴파일 의존성 변경 없이 런타임 의존성 변경이 가능하다. 즉, 기존 코드 수정 없이 확장 가능하다. -> OCP 만족
- 고정된 금액으로 1000원을 할인해주는 계산 로직이 있다면 기존 코드 변경 없이 다음과 같이 클래스만 추가해주면 끝난다.
3. 부모와 자식 모두 추상화에 의존하고 있으므로 DIP도 만족한다.
7. 마무리
위에서 기존에 테스트를 통과한 후에 꽤 많은 리팩토링 과정을 거쳤다. 이렇게 마구 변경하며 리팩토링이 가능했던 이유는, 테스트코드가 있으므로 언제든 잘 짜고 있는건지 검증이 가능하기 때문이다. ("테스트 케이스가 없다면 모든 변경이 잠정적인 버그다.")
물론 한계는 있다.
1. 예를들어 결제할 때 특정 은행의 카드를 사용 시 할인을 해줘야 한다고 해보자. 이런 경우 다음과 같이 요금 계산 후에 추가 로직도 추상화를 통해 자식에게 책임을 넘길 수 있다. 이런식으로 부모 클래스에 로직이 추가되는 경우 자식 클래스 모두가 변경되어야 한다. 이 경우 더 좋은 방법은 discountAfterCharge에 해당하는 부분도 추상화로 빼내고, 각 요금 계산 클래스에서 합성을 통해사용하는 방식일 것이다.
2. 위 '1'의 경우에서, 고객이 어떤 카드를 사용하는지에 대한 정보를 CityGasChargeService에서 모르게 하고 싶다. 그렇다면 CityGasChargeService의 조합을 더 늘리고 사용하는 책임을 가진 쪽에서 고르게 하는 수밖에 없다. 현재는 기본적인 요금 계산 로직 클래스가 2개 뿐이고, 은행 또한 카카오뱅크 사용자만 할인해주므로 할인을 하냐, 안하냐의 2가지 뿐이다. 그래서 2x2의 4가지 클래스면 된다. 하지만 뭐 쿠폰이 있으면 추가 할인을 받을 수 있다거나, 야간 할증 요금제가 추가된다거나 등등 조합이 점점 늘어날 수 있다. 이 경우 우리가 짠 기존 설계대로면 조합의 경우의 수 갯수만큼 모두 만들어줘야한다. (클래스 폭발(class explosion) 또는 조합의 폭발(combinational explosion) 문제)
'2'의 해결책 또한 합성(composition)으로 설계를 변경하는 것이다. 디자인 패턴으로 따지면 TEMPLATE METHOD 패턴에서 STRATEGY 패턴으로 변경하는 것이 더 좋을 것 같다. 하지만 아직 일어나지 않은 일이고(YAGNI - You Ain't Gonna Need It), 현재 요구사항으로만 본다면 현재까지의 리팩토링이면 충분한 것 같다. 일반적으로 설계가 유연해질수록 코드의 복잡성은 올라간다. 일반적으로 중간에 알고리즘 교체가 없는 경우 STRATEGY보다 TEMPLATE METHOD가 복잡성이 더 낮다.
'Study > 테스트 주도 개발' 카테고리의 다른 글
[TDD] 스터디 4주차 (25~28장 정리) (0) | 2023.01.20 |
---|---|
[TDD] 스터디 3주차 (테스트 반복, 순서 지정, Extension 관련) (0) | 2023.01.10 |
[TDD] 스터디 2주차 (JUnit 관련 내용) (0) | 2023.01.09 |
[TDD] 스터디 1주차 (기본적인 테스트 방법, 1~2장 정리) (0) | 2022.12.18 |
댓글