본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

Step1. 데코레이터 패턴을 적용하는 트랜잭션 코드 분리와 고립된 단위 테스트

by 민휘 2023. 4. 11.

트랜잭션 코드 분리 요구사항

비즈니스 로직을 담는 UserService에 여전히 트랜잭션 처리 코드가 남아있다. 이 트랜잭션 처리를 분리하는 작업을 해보자. 단, 클라이언트가 비즈니스 로직만 남도록 개선하더라도 UserService의 클라이언트는 트랜잭션이 적용된 기능을 사용할 수 있어야 한다.

 

UserService에 트랜잭션을 적용한 upgradeLevels 코드를 살펴보면 크게 트랜잭션 관리(시작, 종료)와 비즈니스 로직(레벨 업그레이드) 부분으로 구분할 수 있다. 관심사도 다르고 주고받는 데이터도 없으므로 완전히 분리가 가능하다. 비즈니스 로직을 우선 메소드로 추출해보자.

public void upgradeLevels() {
	// 트랜잭션 생성과 시작
	TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
		try {
			upgradeLevelsInternal(); // 메소드 추출
			transactionManager.commit(status); // 정상 종료
		} catch (Exception e) {
			transactionManager.rollback(status); // 비정상 종료
			throw e;
		}
}

private void upgradeLevelsInternal() {
	List<User> users = userDao.getAll();
		for(User user: users) {
			if(canUpgradeLevel(user)) upgradeLevel(user);
		}
}

이제 upgradeLevels에 트랜잭션 코드가 남았다. UserService 안에 트랜잭션 처리 코드가 남지 않도록 upgradeLevels 내부의 트랜잭션 코드를 다른 클래스에 위임할 것이다. 그런데 어느 클래스에 위임해야할까?

 

 

DI를 사용한 트랜잭션 코드 분리 아이디어

UserService를 사용하는 클라이언트는 UserService가 이해할 수 있는 메시지인 add와 upgradeLevels를 통해 작업을 요청한다. 트랜잭션이 적용되는 upgradeLevels의 경우, 비즈니스 로직을 담은 UserService보다 트랜잭션 처리가 먼저 호출되어야 한다. 정리해보면 트랜잭션 처리가 비즈니스 로직보다 먼저 실행되어야 하고, 클라이언트 입장에서 트랜잭션 처리는 비즈니스 로직과 동일한 메시지를 이해하는 오브젝트여야 한다. 그렇다면 적용할 수 있는 것이 무엇인가? DI다.

 

DI를 사용해 UserService가 이해하는 인터페이스를 분리한다. 이 인터페이스를 구현하는 클래스는 동일한 메시지를 이해할 수 있다. 이 인터페이스를 각각 비즈니스 로직, 트랜잭션 로직을 담은 클래스로 구현한다. 트랜잭션 로직은 비즈니스 로직을 호출해야하므로 내부에서 UserService를 주입받아야 한다. 그러면 아래와 같은 구조가 완성된다. 컴파일 시점에서 클라이언트는 UserService 인터페이스로 메시지 요청을 보내고, 런타임 시점에서는 트랜잭션 로직을 가진 UserServiceTx가 먼저 트랜잭션을 시작하고 비즈니스 로직을 가진 UserServiceImple의 메소드를 호출하여, 클라이언트가 트랜잭션이 적용된 비즈니스 로직을 사용할 수 있게 한다.

 

 

여기서 중요한 것은 트랜잭션 로직 담당 클래스든 비즈니스 로직 담당 클래스든 클라이언트 입장에서 동일한 메시지를 이해할 수 있는 객체라는 것이다. 동일한 메시지를 이해할 수 있기 때문에 동일한 인터페이스로 추출하고 구현은 내부에서 맡긴 것이다. 이 아이디어는 “무엇”을 수행하는지에 집중하고, “어떻게” 수행하는지는 숨긴다는 캡슐화에 충실하다. 클라이언트 입장에서 원하는 것은 유저를 추가하거나 유저의 레벨을 업그레이드하는 것이다. 어떻게 구현할지는 구체적인 클래스에서 작성한다. UserServiceTx는 트랜잭션을 적용해서 비즈니스 로직을 호출하고, UserServiceImpl은 데이터 액세스를 담당하는 UserDao와 협력하여 비즈니스 로직을 수행한다.

 

그리고 이 구조, 어디서 많이 보지 않았는가? 맞다. 바로 데코레이터 패턴이다. 데코레이터 역시 클라이언트 입장에서 무엇을 처리할지만 기대하고, 어떻게 처리하는지는 구현체의 책임이다. 그래서 동일한 인터페이스를 가진 데코레이터에게 우선 요청을 보내서 사전 작업을 수행하고, 실제 핵심 로직이 있는 객체에게 기능 실행을 위임할 수 있다.

 

 

 

DI를 사용한 트랜잭션 코드 분리 구현

UserSevice 인터페이스

public interface UserService {
    void add(User user);
    void upgradeLevels();
}

UserServiceTx : 트랜잭션 코드 담당, UserServiceImpl 위임

public class UserServiceTx implements UserService {
    UserService userService;
    PlatformTransactionManager transactionManager;

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public void add(User user) {
        userService.add(user); // 기능 위임
    }

    @Override
    public void upgradeLevels() {
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            userService.upgradeLevels(); // 기능 위임
            transactionManager.commit(status); // 정상 종료
        } catch (Exception e) {
            transactionManager.rollback(status); // 비정상 종료
            throw e;
        }
    }

}

UserServiceImpl : 비즈니스 로직 담당

public class UserServiceImpl implements UserService {
    UserDao userDao;

    MailSender mailSender;

    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }

    public void setMailSender(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void upgradeLevels() {
        List<User> users = userDao.getAll();
        for(User user: users) {
            if(canUpgradeLevel(user)) upgradeLevel(user);
        }
    }

    public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
    public static final int MIN_RECOMMEND_FOR_GOLR = 30;

    private boolean canUpgradeLevel(User user) {
        Level currentLevel = user.getLevel();
        switch (currentLevel) {
            case BASIC : return user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER;
            case SILVER: return user.getRecommend() >= MIN_RECOMMEND_FOR_GOLR;
            case GOLD: return false;
            default: throw new IllegalStateException("Unknown Level: "+currentLevel);
        }
    }

    protected void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
        sendUpgradeEmail(user);
    }

    private void sendUpgradeEmail(User user) {
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("useradmin@ksug.org");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이 "+user.getLevel().name());

        mailSender.send(mailMessage);
    }

    public void add(User user) {
        if (user.getLevel() == null) user.setLevel(Level.BASIC);
        userDao.add(user);
    }

}

트랜잭션 코드 분리의 장점 - 고립된 단위 테스트

트랜잭션 코드를 분리함으로써 트랜잭션 처리는 다른 클래스에 위임하고 비즈니스 로직에만 집중하는 클래스를 만들 수 있었다. 또다른 장점으로는 비즈니스 로직에 대한 테스트를 손쉽게 만들 수 있다는 것이다.

 

 

의존관계를 함께 테스트할 때 단점

기존의 테스트를 살펴보면, UserService 안에 트랜잭션과 비즈니스 로직이 섞여있었기 때문에 트랜잭션 기능과 비즈니스 로직이 함께 실행되고 검증되었다.

public class UserServiceTest {
		@Test
    public void upgradeLevels() {
        userDao.deleteAll();
        for(User user:users) userDao.add(user);

        MockMailSender mockMailSender = new MockMailSender();
        userService.setMailSender(mockMailSender);

        userService.upgradeLevels(); // DB 트랜잭션 & 유저 업그레이드

				// DB에서 결과 확인
        checkLevelUpgraded(users.get(0), false);
        checkLevelUpgraded(users.get(1), true);
        checkLevelUpgraded(users.get(2), false);
        checkLevelUpgraded(users.get(3), true);
        checkLevelUpgraded(users.get(4), false);

				// 메일 기능은 mock으로 대체 - 메일 서버 없이 테스트 동작
        List<String> request = mockMailSender.getRequests();
        assertThat(request.size(), is(2));
        assertThat(request.get(0), is(users.get(1).getEmail()));
        assertThat(request.get(1), is(users.get(3).getEmail()));
    }
}

현재 테스트되고 있는 UserService는 UserDaoJdbc와 DSTransactionManager에 의존하고 있으므로, DB 리소스를 직접 사용해서 테스트할 수 밖에 없다. 하지만 이렇게 의존관계를 갖는 오브젝트들이 테스트를 진행하는 동안 같이 실행되면 여러가지 문제가 있는데 다음과 같다.

  • 테스트 실패시 원인을 찾기 어렵다
  • 외부 리소스에 의존하므로 수행 속도가 느려지고, 실행 빈도는 줄어든다
  • 테스트 대상인 레벨 업그레이드 테스트를 위한 준비는 간단한데, db 확인하는 작업은 복잡하고 비용이 커서 배보다 배꼽이 더 크다

 

고립된 단위 테스트의 장점

테스트 대상이 외부 서버, 환경 등에 종속되지 않도록 만들면 테스트가 테스트 대상에만 집중할 수 있다. 그러면 다음과 같은 장점이 있다.

  • 테스트 실패 원인을 찾기 쉬움
  • 테스트가 빨리 돌아가서 테스트 수행 성능이 향상됨
  • 테스트 대상의 동작 검증에만 집중 가능

이렇게 향상된 테스트는 코드의 품질을 높여주고, 리팩터링과 개선에 대한 용기를 줄 수 있다.

 

 

고립된 단위 테스트 구현

유저 테스트의 테스트 대상은 비즈니스 로직이다. UserService는 비즈니스 로직을 수행하기 위해 트랜잭션과 UserDao로 DB에 의존하고 있고, MailSender를 통해 메일 서버에 의존하고 있다. 그런데 메일 서버는 저번 장에서 목 오브젝트로 의존성을 이미 제거했다. 트랜잭션을 통한 DB 의존성은 UserServiceImpl과 UserTransaction를 분리함으로써, 유저 테스트에서 UserServiceImpl만 사용하면 해결되는 의존성이다. (앞에서 분리한 코드가 고립된 단위 테스트를 만들어주었다고 볼 수 있다)

이제 완전한 고립 단위 테스트를 만드려면 UserDao의 의존성만 끊어주면 된다. 이때 테스트에서 사용할 UserDao는 목 오브젝트로 두어 UserServiceImpl과 UserDao 사이에 메소드 호출이 있었음을 검증할 것이다. DB 의존성을 끊으면 UserServiceImpl 레벨 업그레이드를 검증할 다른 방법이 필요하기 때문이다.

public class MockUserDao implements UserDao {

		private List<User> users; // 레벨 업그레이드 후보

    private List<User> updated = new ArrayList<>(); // 업그레이드 대상 오브젝트

    public MockUserDao(List<User> users) {
        this.users = users;
    }

    public List<User> getAll() {
        return users;
    }

    public List<User> getUpdated() {
        return updated;
    }

    // 업데이트가 호출되면 해당 User을 업그레이드 요청 목록에 추가하여
    // 간접적으로 업데이트 수행됨을 알림
    @Override
    public void update(User user) {
        updated.add(user);
    }

		// ..
}
public class UserServiceTest {

		@Test
    public void upgradeLevels() {
        userDao.deleteAll();
        for(User user:users) userDao.add(user);
        // 목 오브젝트 DI
        MockUserDao mockUserDao = new MockUserDao(this.users);
        userServiceImpl.setUserDao(mockUserDao);
        // 목 오브젝트 DI
        MockMailSender mockMailSender = new MockMailSender();
        userServiceImpl.setMailSender(mockMailSender);
        // 테스트 대상의 동작 실행
        userServiceImpl.upgradeLevels();

        // Mock UserDao 검증
        List<User> updated = mockUserDao.getUpdated();
        assertThat(updated.size(), is(2));
        checkUserAndLevel(updated.get(0), "2seeun", Level.SILVER);
        checkUserAndLevel(updated.get(1), "4nyaong", Level.GOLD);

        // Mock MailSender 검증
        List<String> request = mockMailSender.getRequests();
        assertThat(request.size(), is(2));
        assertThat(request.get(0), is(users.get(1).getEmail()));
        assertThat(request.get(1), is(users.get(3).getEmail()));
    }

}

외부 리소스를 사용하는 의존 오브젝트의 의존성을 끊어서 테스트 수행 속도를 높이고, 테스트 대상에만 집중할 수 있도록 만들었다.

테스트 대상과 의존 오브젝트의 의존성을 끊은 것이 아니고, 외부 리소스를 사용하는 의존 오브젝트의 의존성을 끊은 것이다. 그래서 외부 리소스를 사용하지 않는 의존 오브젝트로 대체한 것이고, 이것은 의존 오브젝트가 동일한 인터페이스를 공유하며 DI될 수 있기 때문이다.

 

 

단위 테스트 vs 통합 테스트

단위 테스트는 테스트 대상이 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜 테스트하는 것이다. 반면 통합 테스트는 두개 이상의 성격이나 계층이 다른 오브젝트나 외부 리소스가 참여하는 테스트이다. 단위 테스트와 통합 테스트는 언제 사용할까?

단위 테스트

  • 항상 단위 테스트를 먼저 고려한다.
  • 빠르게 테스트를 작성하고 빠르게 피드백 받을 수 있다.

통합 테스트

  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
  • 테스트 대상의 동작이 외부 리소스의 결과에 직접적으로 의존하는 경우 통합 테스트로 만든다. 가령 Dao 등이 있다.
  • 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만 단위 테스트를 충분히 거쳤다면 오류의 부담이 줄어든다.
  • 스프링 컨텍스트를 이용하는 테스트는 통합 테스트이다.

결론 : 단위 테스트로 충분히 검증하고 필요하다면 통합 테스트 만들어라.

 

 

목 오브젝트 프레임워크

목 오브젝트를 만드는 것은 꽤 번거로운 일이다. 테스트에서 사용하지 않는 인터페이스도 모두 구현을 해주어야하고, 검증 기능을 구현하려면 호출 내용을 저장해야하고, 테스트 별로 다른 검증 기능이 필요하다면 여러개의 목 클래스가 필요하다. 목 오브젝트를 직접 만들면 목 오브젝트 클래스 파일이 많아지고, 내부에 쓸데없는 코드도 생긴다. 이럴 때는 목 오브젝트 생성을 도와주는 목 오브젝트 지원 프레임워크인 Mockito를 사용할 수 있다.

 

다음과 같은 방법으로 사용할 수 있다.

  1. 인터페이스 타입으로 목 오브젝트 만들기
  2. 스텁의 기능을 정의한다. 반환값을 정하거나 예외를 던진다. 반환값이 없다면 정의를 생략한다.
  3. 테스트 대상 오브젝트에 DI해서 목 오브젝트가 테스트 중에 사용되도록 만든다.
  4. 특정 메소드가 어떤 값을 가지고 몇번 호출됐는지 검증한다.

 

다음과 같이 사용할 수 있다.

public class UserServiceTest {

		@Test
    public void mockUpgradeLevels() throws Exception {
        UserServiceImpl userServiceImpl = new UserServiceImpl();

        // Mock UserDao
        UserDao mockUserDao = mock(UserDao.class);
        when(mockUserDao.getAll()).thenReturn(this.users); // 반환값 정의
        userServiceImpl.setUserDao(mockUserDao);

        // Mock MailSender
        MailSender mockMailSender = mock(MailSender.class);
        userServiceImpl.setMailSender(mockMailSender); // 반환값이 필요하지 않다면 정의 x

        userServiceImpl.upgradeLevels();

        // mockUserDao의 update가 두번 호출됐는지 검증
        verify(mockUserDao, times(2)).update(any(User.class));
        verify(mockUserDao).update(users.get(1)); // update에 넘겨준 파라미터는 두번째 User
        assertThat(users.get(1).getLevel(), is(Level.SILVER));
        verify(mockUserDao).update(users.get(3)); // update에 넘겨준 파라미터는 네번째 User
        assertThat(users.get(3).getLevel(), is(Level.GOLD));

        // 목 오브젝트에 전달된 파라미터를 가져와 내용 검증
        ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
        verify(mockMailSender, times(2)).send(mailMessageArg.capture());
        List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();
        assertThat(mailMessages.get(0).getTo()[0], is(users.get(1).getEmail()));
        assertThat(mailMessages.get(1).getTo()[0], is(users.get(3).getEmail()));
    }
}