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

정훈희·2022년 11월 12일
0

Spring

목록 보기
19/24
post-thumbnail

참조

  • 토비의 스프링 vol.1 401p~429p

메소드 분리

public void upgradeLevels() throws Exception {
	// 트랜잭션 경계설정
	TransactionStatus status = this.transactionManager
			.getTransaction(new DefaultTransactionDefinition());
	try {
	// 비즈니스 로직
		List<User> users = userDao.getAll();
		for (User user : users) {
			if (canUpgradeLevel(user)) {
				upgradeLevel(user);
			}
		}
	// 트랜잭션 경계설정
		this.transactionManager.commit(status);
	} catch (Exception e) {
		this.transactionManager.rollback(status);
		throw e;
	}
}

위의 코드에는 트랜잭션 경계설정과 비즈니스 로직이 공존한다. 하지만, 트랜잭션 경계설정과 비즈니스 로직사이에는 주고받는 정보가 없고, 두 코드가 복잡하게 얽혀 있지 않고, 뚜렷하게 구분되어 있다.

→ 비즈니스 로직 부분을 아래와 같이따로 메소드로 빼서 분리할 수 있다.

public void upgradeLevels() throws Exception {
	// 트랜잭션 경계설정
	TransactionStatus status = this.transactionManager
			.getTransaction(new DefaultTransactionDefinition());
	try {
	// 비즈니스 로직
		upgradeLevelslnternal();
	// 트랜잭션 경계설정
		this.transactionManager.commit(status);
	} catch (Exception e) {
		this.transactionManager.rollback(status);
		throw e;
	}
}

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

DI를 이용한 클래스의 분리

비즈니스 로직을 담당하는 코드는 잘 분리되었지만, 여전히 트랜잭션을 담당하는 코드가 UserService에 있다. → DI를 적용해서 분리해보자

현재는 UserService가 클래스이므로 트랜잭션을 담당하는 부분을 따로 클래스로 분리한다면 UserService를 사용하는 클라이언트는 트랜잭션 기능이 없는 UserService를 사용하게 된다.

→ UserService를 인터페이스로 만들고, 사용자 관리 로직을 담은 구현체와 트랜잭션을 담당하는 구현체를 만든다. 트랜잭션을 담당하는 구현체는 사용자 관리 로직에 관한 부분은 전부 사용자 관리 로직을 담은 구현체에게 위임한다.

이를 그림으로 나타내면 아래와 같다.

일단 UserService 인터페이스를 만들어보자.

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

그리고, 트랜잭션 코드를 제거하고 비즈니스 로직만 남긴 UserService 구현 클래스를 만들어보자.

public class UserServiceImpl implements UserService {
	UserDao userDao;
	MailSender mailSender;
	public void upgradeLevels() {
		List<User> users = userDao.getAll();
		for (User user users) {
			if (canUpgradeLevel(user)) {
				upgradeLevel(user);
			}
		}
	}
	...
}

그리고, 트랜잭션 처리를 담은 클래스를 만들어보자

public class UserServiceTx implements UserService {
	UserService userService;
	PlatformTransactionManager transactionManager;
	public void setTransactionManager(
			PlatformTransactionManager transactionManager) {
		this.transactionManager =transactionManager;
	}
	public void setUserService(UserService userService) {
		this.userService =userService;
	}
	public void add(User user) {
		this.userService.add(user);
	}
	public void upgradeLevels() {
		TransactionStatus status =this.transactionManager
				.getTransaction(new DefaultTransactionDefinition());
		try {
			userService.upgradeLevels();
			this.transactionManager .commit(status);
		} catch (RuntimeException e) {
			this.transactionManager.rollback(status);
			throw e;
		}
	}
}

위의 코드는 비즈니스 로직을 담은 UserService 구현체를 주입받아서 비즈니스 로직과 관련된 부분을 전부 넘기고, 트랜잭션 관련된 부분만 맡아서 처리해준다.

이렇게 트랜잭션 코드를 분리했을 때의 장점은

  1. 비즈니스 로직을 담당하는 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 신경쓸 필요가 없다.
  2. 비즈니스 로직에 대한 테스트를 만들기 쉽다.

고립된 단위테스트

아래 그림은 위와 같이 UserService를 분리하기 전의 테스트가 동작하는 모습이다.

하지만 비즈니스 로직만 고립시킨 UserServiceImpl을 테스트하는 과정은 아래와 같다.

이 방법을 upgradeLevels() 메소드의 테스트에 적용해보자.

@Test
public void upgradeLevels() throws Exception {
	// DB 테스트 데이터 준비
	userDao.deleteAll();
	for(User user : users) userDao.add(user);
	
	// Mock 오브젝트 DI
	MockMailSender mockMailSender = new MockMailSender();
	userServicelmpl.setMailSender(mockMailSender);

	userService.upgradeLevels(); // 테스트 대상 실행

	// 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> reuqest = 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()));
}

private void checkLevelUpgraded(User user, boolean upgraded) {
	User userUpdate =userDao.get(user.getld());
	...
}

테스트는 아래와 같이 진행된다.

  1. UserDao를 통해 가져올 테스트용 정보를 DB에 넣는다.
  2. 메일 발송 여부를 확인하기 위해 Mock 오브젝트를 DI 해준다.
  3. 실제 테스트 대상인 userService의 메소드를 실행한다.
  4. 결과가 DB에 반영됐는지 확인하기 위해서 UserDao로 DB의 데이터를 가져와 결과를 확인한다.
  5. Mock 오브젝트를 통해 메일 발송이 되었는지 확인한다.

여기에 MockUserDao를 만들어서 적용시켜보자.

upgradeLevels() 메소드에서 UserDao를 사용하는 경우는 업그레이드 후보 사용자 목록을 DB에서 가져오는 것(userDao.getAll())과, 업그레이드 대상인 user를 업데이트 하는 것(userDao.update(user))이다.

getAll() 의 기능을 지원하기 위해서 테스트용 UserDao에는 DB에서 읽어온 것처럼 준비된 사용자 목록을 제공해줘야 한다.

update() 는 리턴 값은 따로 없지만, 레벨을 변경해주는 부분을 검증할 수 있는 부분이다.

static class MockUserDao implements UserDao {
	private List<User> users;
	private List<User> updated = new ArrayList();
	private MockUserDao(List(User> users) {
		this.users =users;
	}
	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() { throw new UnsupportedOperationException(); }
	public User get(String id) { throw new UnsupportedOperationException(); }
	public int getCount() { throw new UnsupportedOperationException(); }
}

위와 같이 MockUserDao를 만들었다. 일단 UserDao를 대신해야 하기 때문에 UserDao를 상속받았다.

상속을 받았으므로 모든 메소드를 구현해야 하는데, 사용하지 않는 메소드들은 혹시 모를 실수를 대비하여 예외를 던지도록 한다.

usersgetAll() 메소드를 호출했을때 반환할 user 리스트이고, updatedupdate() 로 업데이트된 user들을 저장할 리스트이다.

이제 이렇게 만든 MockUserDao를 기존 테스트코드에 적용시켜보자.

@Test
public void upgradeLevels() throws Exception {
	// 고립된 테스트에서는 테스트대상 오브젝트를 직접 생성하면 된다.
	UserServicelmpl userServicelmpl = new UserServicelmpl();

	// Mock오브젝트로 만든 UserDao를 직접 DI해준다.
	MockUserDao mockUserDao = new MockUserDao(this.users);
	userServiceImpl.setUserDao(mockUserDao);

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

	userServicelmpl.upgradeLevels();
	
	// MockUserDao로부터 업데이트 결과를 가져온다.
	List<User> updated = mockUserDao.getUpdated();
	// 업데이트 횟수와 정보를 확인한다.
	assertThat(updated.size(), is(2));
	checkUserAndLevel(updated.get(0), "joytouch", Level.SILVER);
	checkUserAndLevel(updated.get(1), "madnitel", Level.GOLD);

	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()));
}
private void checkUserAndLevel(User updated, String expectedId,
			Level expectedLevel) {
	assertThat(updated.getId(), is(expectedId));
	assertThat(updated.getLevel(), is(expectedLevel));
}

단위 테스트 VS 통합 테스트

단위 테스트: 테스트 대상 클래스를 Mock 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트 하는것

통합 테스트: 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 드으이 리소스가 참여하는 테스트


Mockito

매번 테스트코드를 작성하기 위해서 Mock 오브젝트를 따로 만드는것은 매우 번거롭다. 이를 해결하기 위한 Mock 오브젝트 지원 프레임워크가 있는데 그중 가장 인기가 많은 것이 Mockito이다.

기존에 따로 만들던 Mock 오브젝트를 Mockito는 아래와 같이 간단히 만들 수 있다.

UserDao mockUserDao = mock(UserDao.class);

이렇게 만든 Mock 오브젝트는 아무런 기능이 없다. 일단 getAll() 메소드가 불려졌을 때 사용자 목록을 리턴하도록 아래와 같은 코드를 작성해야한다.

when(mockUserDao.getAll()).thenReturn(this.users);

다음은 update() 호출이 있었는지 검증하는 부분이다. 아래와 같은 코드를 작성하면 테스트를 진행하는 동안 mockUserDao 의 update() 메소드가 두 번 호출됐는지 확인할 수 있다.

verify(mockUserDao, times(2)).update(any(User.class));

profile
DB를 사랑하는 백엔드 개발자입니다. 열심히 공부하고 열심히 기록합니다.

0개의 댓글