1. 개요
AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3개 기반 중 하나이며, 이해하기 어려운 기술 중 하나이다. 스프링에 적용된 가장 인기있는 AOP의 적용 대상은 선언적 트랜잭션 기능이다. 서비스 추상화를 적용한 트랜잭션 경계설정 기능을 AOP를 사용해 더욱 깔끔한 코드로 개선함과 동시에 AOP라는 기술을 이해해보도록 하자.
2. 트랜잭션 코드의 분리
현재 트랜잰션 관련 코드는 아래와 같이 UserService에 비지니스 로직과 공존한다. 트랜잭션 경계 설정은 비지니스 로직 전/후에 설정되어야 하므로 틀렸다고 할 순 없지만, 트랜잭션 관련 코드와 비지니스 로직 관련 코드가 함께 존재하므로 리팩토링이 필요해 보인다. 트랜잭션 코드 분리를 통해 리팩토링 해보자.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
); // 트랜잭션 관련 코드
try{
List<User> users = userDao.getAll(); // 비지니스 로직 관련코드
for(User user : users) { // 비지니스 로직 관련코드
if (userLevelUpgradePolicy.canUpgradeLevel(user)) { // 비지니스 로직 관련코드
userLevelUpgradePolicy.upgradeLevel(user); // 비지니스 로직 관련코드
}
}
transactionManager.commit(status); // 트랜잭션 관련 코드
}catch(Exception e){
transactionManager.rollback(status); // 트랜잭션 관련 코드
}
}
2.1. 메서드 분리
트랜잭션 경계 설정의 코드와 비지니스 로직 코드 간에는 서로 주고받는 정보가 없다. 이 메서드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 사용한다. 즉, 이 두 가지 코드는 성격이 다르고, 주고받는 것도 없는 독립적인 코드이므로 아래와 같이 메서드로 분리할 수 있다.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
upgradeLevelsInternal();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
private void upgradeLevelsInternal(){
List<User> users = userDao.getAll();
for(User user : users) {
if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
internal 메서드를 만들어 분리하긴 했지만 여전히 트랜잭션을 담당하는 코드가 UserService에 포함되어 있다. 이번에는 트랜잭션 코드를UserService 밖으로 뽑아내보자.
2.2. DI를 통한 트랜잭션 분리
DI를 사용한다는 것은 인터페이스를 도입한 후 런타임에 구현체 클래스를 설정해주어 확장과 변경을 용이하게 하기 위함이다. 현재 클라이언트는 UserServiceTest이며, UserService에 직접 접근하고 있으나 인터페이스를 도입하면 다음과 같은 형태로 구현 가능하다.
그런데 이 작업의 목표는 트랜잭션 로직을 분리하는 것이다. 이 상태라면 UserServiceImpl에 기존 UserService의 코드가 들어가야 하기에 결국 트랜잭션 로직과 비지니스 로직이 아직도 한 클래스에 들어가 있는 상태이다. 때문에 아래와 같이 트랜잭션 기능을 처리하는 UserService의 구현체 클래스를 새로 생성해야한다.
키포인트는 UserServiceImpl는 비지니스 로직을, UserServiceTx는 트랜잭션 로직을 담당하게 하는 것이다. 그리고 UserServiceTx 에서 UserService의 구현체를 사용하고 있는데, 이때 사용하는 UserService의 구현체를 UserServiceImpl로 설정하는 것이다. 이런 구조를 채택한 이유는 의존관계를 아래와 같이 설정하여 트랜잭션 로직과 비지니스 로직을 분리하기 위함이다. 이제 코드를 수정하자.
2.2.1. UserService.java
public interface UserService {
void upgradeLevels();
void add(User user);
}
UserService 인터페이스를 생성한다.
2.2.2. UserServiceImpl.java
public class UserServiceImpl implements UserService {
private IUserDao userDao;
private UserLevelUpgradePolicy userLevelUpgradePolicy;
public void setUserDao(IUserDao userDao){
this.userDao = userDao;
}
public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy){
this.userLevelUpgradePolicy = userLevelUpgradePolicy;
}
public void upgradeLevels() {
List<User> users = userDao.getAll(); // DB /
for(User user : users) {
if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
public void add(User user) {
if(user.getLevel() == null){
user.setLevel(Level.BASIC);
}
userDao.add(user);
}
}
UserServiceImpl 클래스를 생성한다. 기존 트랜잭션 관련 코드는 UserServiceTx 클래스에 넣을 예정이므로 제거한다. 그와 동시에 트랜잭션과 관련된 멤버필드인 transactionManager도 제거한다.
2.2.3. UserServiceTx.java
public class UserServiceTx implements UserService{
private PlatformTransactionManager transactionManager;
private UserService userService;
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
}
public void setUserService(UserService userService){
this.userService = userService;
}
@Override
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.upgradeLevels();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
@Override
public void add(User user) {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
userService.add(user);
try{
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
}
UserServiceTx 클래스를 생성한다. 각각의 메서드에 트랜잭션 관련 코드를 넣는다. 그리고 비지니스 로직을 담당하는 UserServiceImpl을 DI받기 위해 UserService에 대한 멤버필드와 수정자 메서드를 추가한다.
2.2.4. application-context.xml
<bean id = "userService" class ="org.example.user.service.UserServiceTx">
<property name="transactionManager" ref = "transactionManager"></property>
<property name="userService" ref = "userServiceImpl"></property>
</bean>
<bean id = "userServiceImpl" class = "org.example.user.service.UserServiceImpl">
<property name="userDao" ref = "userDao"></property>
<property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
</bean>
이제 DI 정보를 위와같이 수정한다. userService 빈 구현체는 UserServiceTx로 DI하고, UserServiceTx에서 사용하는 userService 멤버필드는 userServiceImpl 빈을 DI한다.
2.2.5. 테스트
이로써 같은 인터페이스에 대한 두 개의 구현체 클래스를 사용하여 비지니스 로직과 트랜잭션 로직을 분리하였다. 이제 작성한 테스트 코드를 위 구조에 맞게 수정한 후 테스트를 통해 확인해보자.
2.3. 트랜잭션 코드 분리의 장점
위와 같은 작업을 통해 얻을 수 있는 장점은 뭘까?
첫째, 비지니스 로직을 담당하는 코드를 작성할 때에는 트랜잭션 로직에 대해 신경쓰지 않아도 된다. 또 트랜잭션 적용이 필요한지도 신경쓰지 않아도 된다. 트랜잭션은 모두 UserServiceTx와 같은 트랜잭션 관련 클래스가 신경쓸 일이다.
둘째, 비지니스 로직에 대한 테스트를 쉽게 만들 수 있다. 이에 대한 내용은 아래에서 더 자세하게 다룬다.
3. 고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 테스트가 실패했을때 원인을 찾기 쉽기 때문이다. 클래스 하나에 대한 테스트와 여러 클래스에 대한 테스트 중 전자가 오류를 찾기 쉽다는건 매우 당연하다.
3.1. 복잡한 의존관계 속의 테스트
UserService를 테스트하기 위해선 아래와 같이 의존하고 있는 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy를 설정해야 한다. UserServiceTest 클래스는 비지니스 로직인 UserServiceImpl 클래스를 테스트하기 위함이나, 의존 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy 클래스도 테스트하게 되는 격이다. 왜? 언급한 4개의 클래스 중 한 부분에서 에러가 날 경우 UserServiceTest 의 테스트는 실패하기 때문이다.
이럴때 Mock과 같은 테스트 대역을 사용하여 테스트 대상이나 환경이 다른 클래스에 영향을 받지 않도록 고립시켜야 한다.
3.2. UserServiceImpl 고립
현재 필자의 UserServiceImpl은 UserDao와 UserLevelUpgradePolicy를 의존하고 있다. 때문에 이 두 클래스에 대한 테스트 대역인 Mock을 사용하거나 UserDao의 경우 Fake 대역을 사용하는 방법으로 구성할 수 있다. 필자는 Mockito에서 제공하는 MockBean을 사용하여 구현하였다.
class UserServiceTest {
private final UserServiceImpl userServiceImpl = new UserServiceImpl();
private final IUserDao userDao = mock(IUserDao.class);
private final UserLevelUpgradePolicy userLevelUpgradePolicy = mock(UserLevelUpgradePolicy.class);
private final List<User> users = Arrays.asList(
new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
);
@BeforeEach
void setUp(){
userServiceImpl.setUserDao(userDao);
userServiceImpl.setUserLevelUpgradePolicy(userLevelUpgradePolicy);
given(userDao.getAll()).willReturn(users);
willDoNothing().given(userDao).add(any(User.class));
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).willReturn(false);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).willReturn(true);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).willReturn(false);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).willReturn(true);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).willReturn(false);
given(userLevelUpgradePolicy.upgradeLevel(any(User.class))).will(invocation -> {
User source = invocation.getArgument(0);
return source.getId();
});
}
@Test
@DisplayName("업그레이드 레벨 테스트")
void upgradeLevels(){
userServiceImpl.upgradeLevels();
verify(userDao).getAll();
verify(userLevelUpgradePolicy,times(5)).canUpgradeLevel(any(User.class));
verify(userLevelUpgradePolicy).upgradeLevel(users.get(1));
verify(userLevelUpgradePolicy).upgradeLevel(users.get(3));
}
@Test
@DisplayName("레벨이 할당되지 않은 User 등록")
void addWithNotAssignLevel(){
User user = users.get(0);
user.setLevel(null);
userServiceImpl.add(user);
assertThat(user.getLevel()).isEqualTo(Level.BASIC);
verify(userDao).add(any(User.class));
}
@Test
@DisplayName("레벨이 할당된 User 등록")
void addWithAssignLevel(){
User user = users.get(0);
Level userLevel = user.getLevel();
userServiceImpl.add(user);
assertThat(user.getLevel()).isEqualTo(userLevel);
}
}
given을 통해 Mock에 대한 Stub을 설정하였다. 그리고 userLevelUpgrade가 실제로 이루어졌는지에 대한 테스트코드는 모두 삭제했는데, 그 이유는 해당 테스트는 UserSeviceImpl이 아닌 UserLevelUpgradePolicy에서 이루어져야 하기 때문이다.
4. 단위 테스트와 통합 테스트
4.1. 단위테스트
테스트 대상 클래스를 테스트 대역을 이용해 고립시키는 테스트이다.
4.2. 통합 테스트
두 개 이상의 성격이나 계층이 다른 오브젝트를 연동하여 테스트하거나, 외부 파일, DB 등의 리소스가 참여하는 테스트이다. 스프링 컨텍스트에서 DI된 오브젝트를 테스트하는 것도 통합 테스트이다.
4.3. 테스트 선택 가이드라인
1) 항상 단위 테스트를 먼저 고려한다.
2) 테스트 코드에서의 의존관계는 모두 차단하고 Stubbing이나 목 오브젝트 등의 테스트 대역을 사용하여 테스트한다.
3) 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
4) DAO의 경우도 Stubbing이나 목 오브젝트로 대처해서 테스트한다.
5) 여러 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 줄어든다.
6) 가능하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하여 단위테스트를 하는게 좋지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트를 해야할 경우는 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.
5. Mockito 프레임워크
목 클래스를 생성할 필요 없이 메서드 호출만으로 테스트용 목 오브젝트를 구현할 수 있는 프레임워크이다. 이를 통해 생성된 목 오브젝트는 아무 기능이 없기때문에 특정 메서드가 호출됐을 때 어떤 값을 리턴해야하는지와 같은 Stub 기능을 추가해야한다. 이를 Stubbing 이라고 한다.
애초에 테스트 코드를 Mockito로 작성하고 있던 터라 어렵지 않게 이해할 수 있었다.
'공부 > 토비의 스프링 스터디' 카테고리의 다른 글
[토비의 스프링 스터디] 애플리케이션 아키텍처 (0) | 2023.09.13 |
---|---|
[토비의 스프링 스터디] 스프링이란 무엇인가 (0) | 2023.08.29 |
[토비의 스프링 스터디] 10주차 / 서비스 추상화(3) / 단일책임 원칙 / 테스트 대역 (0) | 2023.06.18 |
[토비의 스프링 스터디] 9주차 / 서비스 추상화(2) / 트랜잭션 (1) | 2023.06.13 |
[토비의 스프링 스터디] 8주차 / 서비스 추상화 (0) | 2023.06.06 |