2 분 소요

회사에서 테스트코드를 작성하다 굉장한 피로감을 느끼고 아키텍처 개선으로 더 테스트하기 쉬운 구조로 개선한 경험에 대한 포스트입니다.

문제점

문제를 인지한것은 테스트코드를 도입하며 테스트코드를 짜며 기존 코드들을 개선해 나가던 중
단위테스트를 작성 할려 했지만 완성된 테스트를 보면 단위테스트보다는 통합테스트에 더 가까운 모습으로 보였다.
가장 간단해야할 테스트인 단위테스트를 하기위해 DB모킹을 해서 수많은 데이터를 셋팅하고 그 데이터를 셋팅한거에따라 결과를 예측해야합니다.
무엇보다 테스트의 실패시 내가 데이터를 잘못넣은건지 실제로 구현로직이 잘못된건지 구분할수없어 확인해야합니다.

아키텍처 개선

// 서비스계층에서 DB를 관리하는 계층을 직접 주입받는경우
public class MemberService {

    @Autowired
    MemberMapper memberMapper; 
    // 서비스에서 마이바티스에 종속되는 맵퍼를 의존하는 형태 또는
    // jpa를 사용하는 repository를 직접 의존하는 경우
}

만약 여러분의 코드에서 다음과 같은 모습을 보이고 해당 코드에 테스트코드를 붙히거나 혹은 해당형태로 개발을 하면서 테스트코드를 작성을 한다면 위에서 말한 문제점을 겪게 될 확률이 매우 높다.
이런 경우 DB계층에서 DTO나 VO를 받아 서비스 계층에서 작업한다. 자연스럽게 모든 계산 및 비즈니스로직은 서비스계층에서 처리된다.
다음은 서비스 계층에서 모든 과정을 처리하는 서비스 메서드를 테스트하는 코드이다.

  @DisplayName("패키지 해지 테스트")
  @Test
  void terminationTest() throws Exception {

      //given
      List<MembershipVO> membershipList = getMembershipList();
      List<ClientMembershipDTO> devData = getDevClientMembershipData();
     
      given(clientMemberShipMapper.getMembership())
              .willReturn(membershipList);

      given(clientMemberShipMapper.selectPaymentTarget(any()))
              .willReturn(devData);

      ArgumentCaptor<ClientMembershipDTO> param = ArgumentCaptor.forClass(ClientMembershipDTO.class);

      //when
      BatchSubscriptionService batchSubscriptionService = new BatchSubscriptionService(clientMemberShipMapper,paymentService, messageService);
			assertAll(
              () -> assertDoesNotThrow(batchSubscriptionService::batchPayment)
      );

      //then
      verify(paymentService, times(0)).paymentProcess(any(),any());
      verify(paymentService, times(0)).refundPackage(any(),any(),any());
      verify(clientMemberShipMapper, times(1)).updateClientMembership(param.capture());
      assertEquals(MembershipCode.FREE.getMembershipCode(),param.getValue().getMembership_code());

  }

아키텍처적으로 객체지향적이지 않은 문제와 SRP원칙을 지키지 않아서 생긴 대참사의 모습이다… 모든 단위테스트를 이렇게 해야한다면 테스트에 대한 피로도가 너무 증가한다.

개선안

  1. domain 계층의 신설
  2. DB계층 고립화 및 domain에 대한 접근은 DB계층으로만 가능
예시 패키지 구조

-domain
	- Member.Class
-repository
	- MemberRepository // 최상위 인터페이스
	- MemberJpaRepository
	- MemberMapper // jpa의 엔티티를 domain으로 변경하요 맵핑하는 클래스
	- MemberRdmsRepository // jpa나 쿼리Dsl을 조합하여 설제 db접근을 하는 구현클래스
	- MemberEntity // jpa용 엔티티
  • domain.Member는 setter가 없어야한다.
  • domain.Member 에 대한 접근은 DB계층을 통해서만 가능해야하고 Repository는 항상 domain을 리턴해야합니다.
  • DB에 대한 접근도 domain을 repository계층에 넘기는걸로 시작해야한다.
  • 모든 계산 및 변경은 domain에서 처리합니다.
  • 서비스 계층은 이제 MemberRepository같은 db의 최상위 인터페이스만 의존합니다.

개선으로 얻는 장점

  • 단위테스트가 쉬워집니다.
    • 단위테스트의 주 대상이 서비스에서 도메인 테스트로 변경됩니다. 도메인에서 처리하는 가장 작은부분들만 따로 처리하면 됩니다. 테스트할때 필요한 데이터가 적어집니다. 해당 메서드의 동작이 필요한 데이터가 아니면 셋팅할 이유가 없습니다.
    • 서비스 레이어에서는 도메인이 계산 처리하는 메서드를 묶고 트랜잭션을 관리하는 역할을 주로 맡게됩니다. 이 효과로 서비스 레이어의 테스트는 이제 통합테스트의 성격을 띄게 됩니다. (물론 서비스테스트도 단위테스트 작성이 필요합니다.)

개선후 테스트코드의 모습

기존 코드 예제
요구사항 : 멤버십 리셋일자에 유료회원이면 결제를 시키고 무료회원이라면 결제를 하지않는다.
public void changeMembership(MemberDTO member){
  if(member.getStatus().equals("FREE")){
    // 무료회 멤버십 충전
  }else{
    // 결제후 멤버십 충전
  }
	
}

개선후

// 리펙토링 예제
// 서비스 로직
public void changeMembership(Member member){
  if(member.isFreeUser()){
    // 무료회 멤버십 충전
  }else{
    // 결제후 멤버십 충전
  }
}

public class Member(){
  private String status;
  
  public boolean isFreeUser(){
    return member.getStatus().equals("FREE");
  }
}

member 도메인에서 조건문을 직접 처리합니다.
도메인테스트에서 이제 조건문에 대한 상세한 테스트를 더 적은량의 데이터 셋팅만으로 가능합니다.
서비스의 경우도 이제 도메인과 다른 서비스를 묶는 형태를 보여주며 단위테스트가 훨씬 간단해집니다.

// 도메인 테스트 예제
  @Test
  void freeChangeMembership() throws Exception{

    //given
    Member member = Member.builder()
            .stauts("FREE")
            .build();
    //when
    boolean isFreeUser = member.isFreeUser();
    
    //then
    assertThat(isFree).isTrue();
}

서비스계층은 이제 도메인의 행동을 묶고 결합시키는 행위를 합니다.
단위테스트는 이제 이름에 걸맞게 가장 작은 단위로 쪼개져 테스트하기만 하면됩니다.
아마 단위테스트는 주로 도메인에서 담당하게 될것이고 통합테스트는 서비스테스트,컨트롤러테스트에서 더 많이 담당하게 하는 구조로 가능합니다.

구조를 변경하고 느낀점은 기존에 테스트코드작성과 달라지니 테스트에 대한 피로도가 굉장히 감소하였습니다.
자연스럽게 더 TDD에 가깝게 코딩스타일을 적응 할 수 있엇습니다.

댓글남기기