ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 6장) 6.1 트랜잭션 코드의 분리 ~ 6.2 고립된 단위 테스트
    Java & Spring/토비의 스프링 3.1 2021. 7. 11. 17:32
    반응형

    6장 AOP

    • 스프링에 적용된 가장 인기있는 AOP 적용 대상은 선언적 트랜잭션 기능

    6.1 트랜잭션 코드의 분리

    • 메소드 분리
      • 기존에 작성했던 UserService는 트랜잭션 경계설정 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없음
        • 비즈니스 로직 코드에서 직접 DB를 사용하지 않기 때문
        • upgradeLevels 메소드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 활용
        • 완벽하게 독립된 코드
    • DI를 이용한 클래스의 분리
      • DI 적용을 이용한 트랜잭션 분리
        • DI는 실제 사용할 오브젝트 클래스 정체를 감추고 인터페이스로 간접 접근하도록 사용
          • 구현 클래스를 외부에서 변경 가능
        • UserService를 인터페이스로 만들고 기존 코드를 구현 클래스로 수정
        • 클라이언트와 결합이 약해지고 직접 구현 클래스에 의존하지 않아 유연한 확장 가능
          • // 기존 구조
            Client -----> UserService
            
            // 수정할 구조
            Client -----> UserService <---- UserServiceImpl
        • 런타임 시 DI를 통해 적용하는 방법을 쓰는 이유는 일반적으로 구현 클래스를 바꾸며 사용하기 위함
          • 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 사용한다면 ??
            • 트랜잭션 경계설정 로직과 비즈니스 로직으로 구현 클래스를 구현
            • Client ----> UserService <----------- UserServiceImpl
                                            |
                                            |
                                            \------ UserServiceTx
            • UserServiceTx는 트랜잭션 경계설정 책임만 갖고, 비즈니스 로직을 담고있는 UserServiceImpl 구현체에게 실질적인 로직 처리 작업 위임
      • UserService 인터페이스 도입
        • public interface UserService {
              void add(User user);
              void upgradeLevels();
          }
        • public class UserServiceImpl implements UserService {
              UserDao userDao;
              MailSender mailSender;
          
              public void upgradeLevels() {
                  userDao.getAll()
                      .filter(user -> canUpgradeLevel(user))
                      .forEach(user -> upgradeLevel(user));
              }
          }
        • upgradeLevelsInternal()로 분리했던 코드를 다시 원래대로 upgradeLevels에 추가
      • 분리된 트랜잭션 기능
        • 비즈니스 트랜잭션 처리를 담은 UserServiceTx 생성
        • @AllArgsConstructor
          public class UserServiceTx implements UserService {
              UserService userService;
              PlatformTransactionManager transactionManager;
          
              public void add(User user) {
                  this.userService.add(user); // DI 받은 UserService에 기능 위임
              }
          
              public void upgradeLevels() {
                  TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
          
                  try {
                      userService.upgradeLevels(); // DI 받은 UserService에 기능 위임
                      this.transactionManager.commit(status);
                  } catch (RuntimeException e) {
                      this.transactionManager.rollback(status);
                      throw e;
                  }
              }
          }
      • 트랜잭션 적용을 위한 DI 설정
        • 의존 관계
          • Client ----> UserServiceTx ----> UserServiceImpl
      • 트랜잭션 분리에 따른 테스트 수정
        • @Autowired
          • 기본적으로 타입이 일치하는 빈을 찾아 주입
            • 타입으로 하나의 빈을 결정할 수 없는 경우는 필드 이름을 이용해서 찾아 주입
            • 따라서, 기존 코드대로 UserService에 @Autowired를 걸어두면, 아이디가 userService인 빈이 주입됨
        • UserServiceTest는 두 개의 빈이 필요
          • Mock 객체로 만든 MailSender 구현체를 UserServiceImpl에 직접 DI 해줘야함
        • //UserServiceTest
          @Autowired UserServiceTx;
          @Autowired UserServiceImpl
          
          @Test
          public void upgradeLevels() throws Exception { // 메일링 검증
              ...
              MockMailSender mockMailSender = new MockMailSender();
              userServiceImpl.setMailSender(mockMailSender);
          }
          
          @Test
          public void upgardeAllOrNothig() { // 트랜잭션 검증
              TestUserService testUserService = TestUserService(users.get(3).getId());
              ...
          
              UserServiceTx txUserService = new UserServiceTx();
              txUserService.setTranscationManager(transactionManager);
              txUserSerive.setUserService(testUserService);
              ...
          }
      • 트랜잭션 경계 코드 분리의 장점
        • 비즈니스 로직을 담당하는 코드는 트랜잭션과 같은 기술적인 내용에 신경쓰지 않아도 됨
          • 언제든 트랜잭션 도입이 가능
        • 비즈니스 로직에 대한 테스트를 쉽게 만들 수 있음

    6.2 고립된 단위 테스트

    • 가능한 작은 단위로 쪼개서 테스트하는 것이 가장 좋음
      • 테스트가 실패했을 때 원인을 찾기 쉬움
      • 테스트 의도와 내용이 분명해지고 만들기 쉬움
    • 복잡한 의존관계 속의 테스트
      • UserService의 경우, 세 가지 타입의 의존 오브젝트가 필요
        • UserDao, MailSender, PlatformTransactionManager
      • UserServiceTest는 UserService는 비즈니스 로직을 테스트
        • 위의 세 의존관계를 갖는 오브젝트들이 테스트 중 같이 실행되는 문제점 존재
        • 위의 세 오브젝트들이 다른 많은 리소스에 의존하고 있어 더 큰 문제
        • 환경이 달라지면 다른 테스트 결과 발생
        • 수행속도가 느리고, UserService의 책임이 아닌 문제점을 파악할 가능성이 큼
    • 테스트 대상 오브젝트 고립시키기
      • 테스트 대역 사용(Test Double)
        • 테스트 스텁, 목 오브젝트
      • 테스트를 위한 UserServiceImpl 고립
        • 이전에 구현한 MockUserDao는 정상적으로 수행되도록 도와주는 스텁의 기능에 더불어 부가적인 검증 기능까지 가진 목 오브젝트로 생성
          • 테스트 메소드 검증 방법이 필요하기 때문
        • MockUserDao, MockMailSender, UserServiceTx를 이용하여 고립시키기
      • 고립된 단위 테스트 활용
        • @Test
          public void upgradeLevels() throws Expcetion {
              userDao.deleteAll();
              users.forEach(user -> userDao.add(user)); // DB Test Data Set-Up
          
              MockMailSender mockMailSender = new MockMailSender();
              userServiceImpl.setMailSender(mockMailSender); // Mock Object DI
          
              userService.upgradeLevels();
          
              checkLevelUpgraded(user.get(0), false);
              checkLevelUpgraded(user.get(1), true);
              checkLevelUpgraded(user.get(2), false);
              checkLevelUpgraded(user.get(3), true);
              checkLevelUpgraded(user.get(4), false); // DB에 저장된 결과 확인
          
              List<String> request = mockMailSender.getRequests();
              assertThat(request.size(), is(2));
              assertThat(request.get(0), is(user.get(1).getEmail()));
              assertThat(request.get(1), is(user.get(3).getEmail())); // 결과 확인
          }
      • UserDao 목 오브젝트
        • 실제 UserDao와 DB까지 의존하고있는 테스트도 목 오브젝트를 만들어서 적용하기
        • public void upgardeLevels() {
              userDao.getAll() // 스텁으로서 역할 필요
                  .filter(user -> canUpgradeLevel(user))
                  .forEach(user -> upgradeLevel(user));
          }
          
          protected void upgradeLevel(User user) {
              user.upgradeLevel();
              userDao.update(user); // 목 오브젝트로서 역할 필요
              sendUpgradeEmail();
          }
        • @RequiredContructor
          static class MockUserDao implements UserDao {
              private final List<User> users;
              private List<User> updated = new ArrayList<>();
          
              public List<User> getUpdated() {
                  return this.updated;
              }
          
              public List<User> getAll() {
                  return this.users;
              }
          
              public void update(User user) {
                  updated.add(user;)
              }
          
              public void add(User user) { throw new UnsupportedOperationException(); }
              public void deleteAll(User user) { throw new UnsupportedOperationException(); }
              public User get(String id) { throw new UnsupportedOperationException(); }
              public int getCount() { throw new UnsupportedOperationException(); }
          }
          • 사용하지 않을 메소드는 UnsupportedOperationException을 던져서 지원하지 않는 기능임을 명시해주기
            • null로 반환하거나 빈 메소드로 둬도 되지만, 안전한 사용성을 위해 위처럼 적용
        • @Test
          public void upgradeLevels() throws Exception {
              UserServiceImpl userServiceImpl = new UserServiceImpl();
          
              MockUserDao mockUserDao = new MockUserDao(this.users);
              userServiceImpl.setUserDao(mockUserDao);
          
              // mailSender 주입 및 upgradeLevels 실행
          
              List<User> updated = mockUserDao.getUpdated(); // 목 오브젝트 리턴
              assertThat(updated.size(), is(2));
              checkUserAndLevel(update.get(0), "zin0", Level.SILVER);
              checkUserAndLevel(updated.get(1), "jinyoung", Level.GOLD);
          
              // mailSender 검증
          }
          
          private void checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
              assertThat(updated.getId(), is(expectedId));
              assertThat(updated.getLevel(), is(expectedLevel));
          }
      • 테스트 수행 성능의 향상
        • DB를 이용하는 테스트와 목 오브젝트를 이용하는 테스트 수행시간에는 큰 차이가 존재
          • 진행 과정에서 수많은 DB 업데이트가 일어날수록 테스트 수행시간에 큰 차이를 보임
          • 고립된 테스트를 만들기 위해서는 번거롭지만 목 오브젝트 작성하면, 시간 투자 대비 큰 효율을 볼 수 있음
    • 단위 테스트와 통합 테스트
      • 단위 테스트
        • 하나의 단위에 초점을 맞춘 테스트(클래스, 메소드 등이 단위가 됨)
        • 테스트 대역을 이용해 의존 오브젝트나 외부 리소스를 사용하지 않도록 고립시켜 테스트
      • 통합 테스트
        • 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 테스트
        • 외부의 리소스를 사용하는 테스트
      • 단위 테스트 vs 통합 테스트
        • 항상 단위 테스트를 먼저 고려
          • 단위 테스트를 만들기 너무 복잡하면 통합테스트를 고려하지만, 가능한 많은 부분을 단위 테스트로 검증
            • 그러기 위해서는 기능 분리가 잘 된 코드를 작성하는 것이 우선
        • 테스트 대역을 이용하도록 테스트를 구축
        • 외부 리소스를 사용해야만 하는 테스트는 통합 테스트로 구축
        • DAO는 DB까지 연동하는 테스트로 만드는 것이 효과적
          • DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류
          • 코드 레벨은 하나의 기능 단위를 테스트
          • DAO 테스트로 충분히 검증하면, DAO를 이용하는 코드는 DAO 역할을 스텁 or 목 오브젝트로 대체 가능
        • 단위 테스트를 충분히 거치면 통합 테스트의 부담이 감소
        • 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트
          • 직접 코드 레벨의 DI를 사용하면서 단위 테스트를 하는게 좋지만, 추상적인 레벨을 테스트하는 경우 스프링 테스트 컨텍스트 프레임워크를 이용하여 통합 테스트 작성
      • 테스트하기 편한 코드는 깔끔하고 좋은 코드가 될 수 있고 리팩토링과 개선에 좋은 영향을 미침
    • 목 프레임 워크
      • 단위 테스트는 목 오브젝트를 만들거나 스텁을 만들어야함
        • 번거로움
      • Mockito 프레임워크
        • 목 클래스를 준비할 필요 없음
          • 인터페이스를 이용해 목 오브젝트 생성
          • 리턴 값을 지정하거나 강제로 예외를 던지게 설정
          • DI로 테스트 도중 사용되도록 설정
          • 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지 검증
        • @Test
          public void mockUpgradeLevels() throws Exception {
              UserServiceImpl userServiceImpl = new UserServiceImpl();
          
              UserDao mockUserDao = mock(UserDao.class);
              when(mockUserDao.getAll()).thenReturn(this.users);
              userServiceImpl
          }
    반응형

    댓글

Designed by Tistory.