테스트 하기 좋은 아키텍쳐

Picbel·2022년 8월 9일
1

Testing Programming

목록 보기
3/3
post-thumbnail

평소 테스트코드를 작성하며 느낀점이 하나있습니다.

given이 복잡하거나 then, expect가 복잡할순 있지만 둘다 복잡하면 안된다.

또한 최근 TDD를 하며 느낀 생각은 TDD는 버그를 잡아내는 개발법보단 좋은 아키텍쳐 구조를 가지게 해주는 개발방법론이라는 생각이 들게됩니다.
사내 테크리드분께서는 TDD라는 말보단 메서드의 또는 api의 기능(spce)을 먼저 정의하고 들어가는 SDD가 좀더 맞지않냐는 말도 합니다.

테스트하기 좋은 구조

본론으로 다시 돌아와서 어떤 아키텍쳐가 테스트 하기 좋은걸까요?
가장 간단하게 말하면 SRP(단일 책임 원칙)을 잘지킨 구조면 테스트하기 굉장히 용이합니다.

SRP(단일 책임 원칙) Single Responsibility Principle

단일 책임 원칙(single responsibility principle)이란 모든 클래스는 하나의 책임만 가지며, 클 래스는 그 책임을 완전히 캡슐화해야 함을 일컫는다.
책임을 변경하려는 이유로 정의하고, 어떤 클래스나 모듈은 변경하려는 단 하나 이유만을 가져야 한다고 결론 짓는다.

출처:위키백과

다음과 같은 요구사항이 있다 해보겠습니다.

요구사항 : 유저가 상품을 구매합니다. 포인트나 쿠폰이 포함된 구매일 경우 포인트 적립을 하지않습니다. 일반 결제일경우 포인트를 적립합니다. 구매시 상품의 재고를 차감합니다.
(구매시 결제부분은 검증완료후 진행되서 더이상 신경안쓴다는 전제로 하겠습니다)

먼저 해당 요구사항을 계층 분리 없이 모든것을 처리하는 경우를 생각하여 보겠습니다.

class OrderUsecase{
  fun `유저가 상품을 구매한다`(order: 주문정보){
      if(order.payType == 쿠폰){
          val useCoupon = couponRepository.findById(order.couponId)
          useCoupon.isVaild()
          couponRepository.discount
      }else if(order.payType == 포인트){
          val nowPoint = pointRepository.findByUser(order.userId)
          if(order.point < nowPoint){
           	pointRepository.discount(order.userId, order.point)
          }else{
          	// 에러 throw 올바르지않은 포인트 사용 요청
          }
      }else{
          pointUsecase.savePoint(order)
      }
      productRepository.discountProduct(order.productId)
  }
}

위코드를 보면 계층에서 쿠폰, 포인트, 상품의 각각 검증부터 차감까지 비즈니스로직을 전부 담당하고 있습니다.
만약 위 코드를 테스트할려면 테스트하기전에 해야할일이 많습니다 즉 given이 복잡합니다.
(given의 갯수만 하여도 오더, 쿠폰, 포인트별로 작성해야합니다.)
쿠폰, 포인트의 DB단을 모킹해야하고 조회시 데이터 셋팅도 해야합니다
또한 검증시에는 쿠폰결제일땐 쿠폰차감을 하는지 포인트차감을 호출하면 안된다는부분도 테스트 검증에 들어가게됩니다.
추가로 데이터 셋팅시 쿠폰과 포인트의 검증방법에 따라 데이터 셋팅도 따로 해줘야합니다.
즉 테스트코드를 작성후 테스트가 실패하였을때 내가 given을 잘못설정하였는지, 검증을 실수하였는지, 실제 구현로직의 오류인지 구분하기 힘듭니다.

이제 위와 같은 상황을 해결하기 위해 구매계층에서 쿠폰과 포인트를 독립시키고 관리의 책임을 분리하겠습니다.

-domain
	- Order
-usecase
	- OrderUsecase
	- CouponUsecase
    - PointUsecase
-repository
	- CouponRepository
	- PointRepository
    - ProductRepository

다음과 같은 구조로 생각하시면됩니다.
쿠폰, 포인트가 유즈케이스로 독립되었고 그에 따라 검증 및 차감의 책임을 지게됩니다.
이제 OrderUsecase의 구매기능을 변경시켜보겠습니다
(결제방법을 enum으로 처리하는 방법도있지만 예제의 간단화를 위해 if문으로 하겠습니다.)

class OrderUsecase{
  fun `유저가 상품을 구매한다`(order: 주문정보){
      if(order.isCoupon()){
          couponUsecase.deduct(order)
      }else if(order.isPoint()){
          pointUsecase.deduct(order)
      }else{
          pointUsecase.savePoint(order)
      }
      productRepository.discountProduct(order.productId)
  }
}

이제 테스트로 검증할게 각 케이스별로 각 서비스의 결과를 검증만 하면됩니다.
먼저 리펙토링전 코드와 다르게 given이 굉장히 줄어듭니다. 주문정보인 order만 신경쓰면 됩니다.
그리고 각 케이스별 서비스 호출을 검사만 하면됩니다.

대략적인 감이 잡히셧나요?
좋은 예제코드가 생각이 안나 억지로 좀 만든거 같아 표현이 잘 되었는지 모르겠네요
말하고자 하는 요약은 다음과 같습니다

계층별로 책임을 분리하면 테스트가 간단해진다.
만약 굉장히 복잡한 테스트가 있다면 그 계층이 너무 많은 일을 하는건 아닌지 의심해보아야 한다.

번외 : DDD 친화적으로 구조 변화

위 예제를 보면 useCase를 많이 만드는 방식으로 해결했습니다.
하지만 과연 그래야할까요?
그래서 다른방식으로 많이 하는 도메인 중심 개발식으로 코드를 구성해 보았습니다
패키지 구조는 다음과 같습니다.

-domain
	- Order // 상품정보 + 결제정보(금액, 사용한 쿠폰, 포인트 정보)
    - User // 유저정보 및 보유중인 쿠폰, 포인트정보
-usecase
	- OrderUsecase
-repository
	- OrderReposiotry
    - UserReposiotry
    - UserDao
    - ProductDao

이제 기존 단순 검증, 계산 등 비즈니스 로직을 도메인쪽으로 밀어넣고 유즈케이스는 그런 도메인의 함수들을 호출해서 조합하는 형태로 진행합니다.
Reposiotry는 그런 도메인을 저장하는 하나의 계층을 담당합니다.
(Reposiotry는 도메인을 받고 도메인을 리턴하는 계층으로 변화합니다.)
기존에 usecase에서 검증 후 저장처리하는 것을 한번 더 분리하였습니다.
도메인중심으로 개편후 usecase의 모습입니다.

class OrderUsecase{
  fun `유저가 상품을 구매한다`(order: Order){
    val user = userReposiotry.findById(order.userId)
    if(order.isCoupon || order.isPoint){
      user.deduct(order)
    }else{
      user.receivePoint(order)
    }
  	
    userRepository.save(user)	// order값에 따라 변한 user값을 저장
    orderRepository.save(order)  // order 안에서 상품정보 저장
  }
}

위의 개선전 로직과 비교하면 CouponUsecase, PointUsecase가 사라지고 해당 쿠폰과 포인트의 검증 및 책임은 관련 프로퍼티를 들고있는 user에서 책임을 집니다.
만약 쿠폰이나 포인트 결제라서 차감이 필요하다면 user에서 order의 정보를 받아서 검증후 user내부의 프로퍼티를 수정합니다.
그후 각 repository에서 도메인을 저장하도록 구현합니다.
기존에 쿠폰, 포인트, 상품별로 usecase와 repository를 구현하면 테스트코드를 짜는것은 편할지 몰라도 관리포인트가 여러곳으로 분산되고 코드의 응집성또한 떨어집니다.

도메인에 검증 및 차감같은 비즈니스 로직의 책임을 넣게되면 꼭 usecase에서 모든 테스트를 할 필요는 없어지기때문에 deduct, receivePoint같은 함수는 도메인의 단위테스트에서 더 적은 비용으로 테스트 할 수 있습니다.
usecase는 이제 user와 order만 given으로 넘겨주고 각 repository의 save가 잘 호출되는지만 검사한다면 usecase의 단위테스트도 쉽게 끝낼수 있습니다.

profile
Software Developer

0개의 댓글