앞서 트랜잭션 경계를 설정하기 위해 서비스 추상화 기법을 사용하였고, UserService안에 트랜잭션 관련한 코드를 기입하였다. AOP를 사용하는 이유 중 가장 대표적인 것은 선언적 트랜잭션 기능이다. 트랜잭션 경계 설정을 조금 더 세련되게 수정하는 과정에서 AOP를 사용해보는 파트이다.
우선, 이전 시간에서 UserServiceTest에서 UserDao와 MailSender 오브젝트를 호출하여 통합 테스트를 진행하는 것을 실습하였다. 각각의 오브젝트를 직접 고립시켜보고, 외부 DB 등과 같은 환경에 종속되지 않는 단위 테스트를 구현해보자.
트랜잭션 코드의 분리
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 {
// 트랜잭션 마무리
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevelsInternal() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
그러나 여전히 UserService에는 비즈니스 로직과 무관한 트랜잭션 로직이 자리잡고 있다.
UserService의 클라이언트는 UserServiceTest이다. UserService는 인터페이스가 아니라 클래스이므로, UserService와 UserServiceTest는 강한 결합으로 연결되어있다. 따라서, UserService에서 트랜잭션 코드를 아예 빼버리면 클라이언트에서는 트랜잭션과 관련한 어떠한 기능도 사용할 수 없게 된다.
UserService를 인터페이스로 선언하여 느슨한 결합을 통해 트랜잭션 경계 설정이라는 책임을 가지고 있는 또다른 구현 클래스를 슬쩍 끼워넣을 수 있다.
UserServiceTx 도입
이 구조 상에서 UserServiceImpl의 호출작업 이전, 이후에 트랜잭션 경계 설정을 담당하는 UserServiceTx를 동작시키면 비즈니스 로직을 성공적으로 구현할 수 있게 된다.
UserService 인터페이스는 add, upgradeLevels 메서드를 선언하고 UserServiceImpl는 원래대로 UserService를 오버라이딩하여 add와 upgradeLevels를 구현하는 것으로 수정한다.
UserServiceTx는 동일한 인터페이스를 구현한 UserServiceImpl 오브젝트에게 작업을 위임하도록 한다. 추가로 트랜잭션 경계설정이라는 부가적인 작업을 수행하여, 트랜잭션 안에서 메소드가 호출되는 것을 보장하도록 한다.
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) {
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;
}
}
}
결국 클라이언트가 UserServiceTx의 오브젝트를 생성하면 UserServiceTx는 UserServiceImpl의 오브젝트를 생성하는 흐름으로 스프링이 동작하는 것이다.
테스트코드 작성하기
UserService라는 타입의 빈은 이제 총 두가지이다. UserServiceTx 구현 클래스의 아이디는 userService, UserServiceImpl 구현 클래스의 아이디는 userServiceImpl이다.
@Autowired로 필드의 타입을 기반으로 빈을 주입하였지만 빈이 두 가지가 되므로 모호성이 생긴다. 이런 경우에는 아이디를 기반으로 빈을 주입할 수 있도록 한다.
public class UserServiceTest {
@Autowired UserService userService;
@Autowired UserServiceImpl userServiceImpl; // java.mail.MailSender 사용을 위한 주입
}
또한, 테스트 과정에서 트랜잭션을 포함시키려면 UserServiceTest 클래스로부터 만든 TestUserService 오브젝트를 UserServiceTx 오브젝트에 수동 DI시킨 후 메소드를 호출해야한다.
즉 UserServiceTx 오브젝트를 만들기 위해서는 TransactionManager와 UserService 역할을 하는 오브젝트가 필요하다. 트랜잭션 테스트 용으로 특별히 정의한 TestUserService 클래스의 오브젝트를 만든다.
@Test
public void upgradeAllOrNothing() throws Exception {
TestUserService testUserService = new TestUserService(users.get(3).getId());
testUserService.setUserDao(userDao);
testUserService.setMailSender(mailSender);
UserServiceTx userServiceTx = new UserServiceTx();
userServiceTx.setTransactionManager(transactionManager);
userServiceTx.setUserService(testUserService);
....
try {
userServiceTx.upgradeLevels(); // 예외 발생 용 TestUserService가 호출
}
}
이렇게 트랜잭션 경계설정의 코드를 UserServiceTx로 분리하게 되었다. 이 방법의 장점을 정리하자면,
- 비즈니스 로직을 담당하는 UserServiceImpl의 코드를 작성할 때 트랜잭션 경계 설정을 신경쓰지 않아도 된다.
- 비즈니스 로직의 단위 테스트를 작성하는 것이 간편해진다.
UserService를 테스트할 때, 위와 같이 복잡한 구조가 얽혀있다. UserService의 비즈니스 로직을 테스트하고싶었을 뿐인데, DB, Network, 메일 서버 등 너무 다양한 오브젝트가 함께 생성되고 테스트되는 복잡함이 발생한다. UserService를 인터페이스화하고 UserServiceImpl에만 집중해보면 더 고립된 테스트 대상으로 맞출 수 있다.
UserDao를 MockUserDao로 만들어서 실제 DB에는 변경한 정보가 새롭게 등록되지 않는다. 더욱이, upgradeLevels()와 같이 void 형식의 메소드를 테스트하는 경우라면 더 그렇다.
언급한 내용을 구현하기 위해 UserDao를 구현한 MockUserDao 클래스를 만들어보자.
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);
}
}
이렇게 테스트 용 UserDao 클래스를 미리 정의해두면, 일일이 DB에서 값을 불러올 것 없이 오브젝트에 저장된 값을 바로 불러올 수 있는 것이다. 이를 활용하여 테스트 코드를 수정하면 아래와 같다 .
...
// Mock Object로 생성한 MockUserDao를 수동 DI하는 부분
MockUserDao mockUserDao = new MockUserDao(this.users);
userServiceImpl.setUserDao(mockUserDao);
...
// 업데이트 후, 업데이트 횟수와 정보를 가져오는 부분
List<User> updated = mockUserDao.getUpdated();
assertThat(updated.size(), is(2));
이를 통해 직접적으로 필요하지 않은 의존 오브젝트와 서비스를 제거하고 테스트 시간을 축소할 수 있었다.
단위 테스트
이렇게 테스트 대상 클래스를 Mock Object 등의 테스트 대역을 이용해 외부 오브젝트나 리소스를 사용하지 않도록 고립시켜 테스트하는 것을 단위 테스트라고 한다.
반 면에 여러 개의 서로 다른 오브젝트가 서로 연동하여 테스트 되거나, 서로 다른 외부 서비스나 리소스가 참여하는 테스트를 통합 테스트라고 한다. DI된 오브젝트를 그대로 함께 테스트하는 것도 통합 테스트이다.
Mockito Framework
일일이 MockUserDao를 만들 필요도 없이, 목 오브젝트를 지원해주는 프레임워크가 따로 있다. 간단한 사용 방법은 아래 코드를 참고하자.
UserDao mockUserDao = mock(UserDao.class);
when(mockUserDao.getAll()).thenReturn(this.users);
verify(mockUserDao, times(2)).update(any(User.class));
- 인터페이스를 이용해서 Mock Object를 생성한다.
- 특정 메서드 호출 시 반환할 값이 있다면 지정해준다.
- 테스트 대상 Object에 DI하여 테스트 중에서 사용될 수 있도록 한다
- 특정 메서드의 호출 횟수와 어떤 값을 가지고 몇 번 호출하였는지 확인한다.
Mockito를 사용해 코드를 수정하면 이렇게 고칠 수 있다.
@Test
public void mockUpgradeLevels() throws Exception {
UserServiceImpl userServiceImpl = new UserServiceImpl();
UserDao mockUserDao = mock(UserDao.class);
when(mockUserDao.getAll()).thenReturn(this.users);
userServiceImpl.setUserDao(mockUserDao);
MailSender mockMailSender = mock(MailSender.class);
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
verify(mockUserDao, time(2)).update(any(User.class));
verify(mockUserDao, time(2)).update(any(User.class));
verify(mockUserDao).update(users.get(1));
assertThat(users.get(1).getLevel(), is(Level.SILVER));
verify(mockUserDao).update(users.get(3));
assertThat(users.get(3).getLevel(), is(Level.GOLD));
ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mockMailSender, time(2)).send(mailMessageArg.captur());
List<simpleMailMessage> mailMessages = mailMessageArg.getAllValues();
asserThat(mailMessages.get(0),getTo()[0], is(users.get(1).getEmail()));
asserThat(mailMessages.get(1),getTo()[0], is(users.get(3).getEmail()));
}
'DEV book > 토비의 스프링 3.1' 카테고리의 다른 글
[토비의 스프링 3.1] 5장 서비스 추상화 - UserService, TransactionService 추상화 (0) | 2024.01.18 |
---|---|
[토비의 스프링 3.1] 4장 예외 - 예외 처리 전략과 DataAccessException을 사용한 JDBC 한계 극복 (0) | 2024.01.16 |
[토비의 스프링 3.1] 3장 템플릿 (3) - 템플릿과 콜백 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (2) - JdbcContext를 UserDao에서 사용하는 두 가지 방법 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (1) - 디자인 패턴과 DI를 이용한 DAO 최적화 (0) | 2023.09.23 |