elevne's Study Note

토비의 Spring (9: AOP(1)) 본문

Backend/Toby Spring

토비의 Spring (9: AOP(1))

elevne 2023. 5. 22. 18:28

AOP 는 IoC/DI, 서비스 추상화와 더불어서 Spring 의 3 대 기반 기술 중 하나이다. 스프링에 적용된 AOP 의 적용 대상은 선언적 트랜잭션 기능이다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP 를 이용하여 더욱 깔끔하게 작성해볼 수 있다.

 

 

 

이전까지 작성한 UserService 는 스프링이 제공하는 트랜잭션 인터페이스를 사용했지만 비즈니스 로직이 주인이어야 할 때에도 메소드 내에는 트랜잭션 코드가 더욱 많은 자리를 차지하고 있다. 트랜잭션 경계설정 코드와 비즈니스 로직 코드 간에는 서로 주고받는 정보도 없다. 이럴 때, 트랜잭션 코드를 간단하게 클래스 밖으로 뽑아내 버릴 수 있다.

 

 

UserService 클래스를 다른 클래스에서 참조하여 사용할 수 있게끔 하면 될 것이다. 하지만 구체적인 구현 클래스를 직접 참조하게 되면 문제가 발생할 수 있다. 트랜잭션 코드를 밖으로 빼버린 UserService 클래스를 직접 사용하는 다른 클라이언트 코드에서는 트랜잭션 기능이 빠진 UserService 를 사용하게 된다. 이 때에도 인터페이스를 통해 간접으로 접근하면 된다. UserService 를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들어 넣도록 하면 될 것이다. 

 

 

 

먼저 기존의 UserService 클래스를 UserServiceImpl 로 이름을 변경하고, 클라이언트가 사용할 로직을 담은 핵심 메소드만 UserService 인터페이스로 만든 후 UserServiceImpl 이 구현하도록 만든다.

 

 

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

 

 

public class UserServiceImpl implements UserService {
    UserDao userDao;
    MailSender mailSender;
    
    ...

 

 

 

위와 같이 수정하게 되면 메일 발송 기능을 추가한 것을 제외하면 트랜잭션을 고려하지 않고 단순하게 로직만을 구현했던 처음 모습과 같아진다. 그 다음으로 비즈니스 트랜잭션 처리를 담은 UserServiceTx 를 만든다. 이 또한 UserService 를 구현하도록 만들고, 같은 인터페이스를 구현한 다른 오브젝트에게 작업을 위임하게 만들면 된다. UserServiceTxUserSerivce 오브젝트를 DI 받을 수 있도록 작성하고, 트랜잭션 경계설정이라는 작업만 진행해주는 것이다.

 

 

public class UserServiceTx implements UserService {

    UserService userService;
    PlatformTransactionManager transactionManager;
    public UserServiceTx(PlatformTransactionManager transactionManager, UserService userService){
        this.transactionManager = transactionManager;
        this.userService = userService;
    }

    public void UserService(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 (Exception e) {
            this.transactionManager.rollback(status);
            throw e;
        }

    }
}

 

 

 

위 코드가 잘 동작하는지 아래 Test 코드를 통해서 확인해볼 수 있다.

 

 

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = UserConfig.class)
@DirtiesContext
public class AOPTest {

    @Autowired
    private UserService userServiceTx;

    @Autowired
    private UserService userServiceImpl;

    @Autowired
    private UserDao userDao;

    private User user1;
    private List<User> users;

    @Before
    public void setUp() {
        userDao.deleteAll();
        this.user1 = new User("user1", "박인범", "spring1", Level.BASIC, 1, 0, "inspring@naver.com");
        this.users = Arrays.asList(
                new User("inbeom", "박인범", "p1", Level.BASIC, org.example.fifthchapter.UserService.MIN_LOG_COUNT_FOR_SILVER-1, 0, ""),
                new User("inbemo2", "박인범2", "p2", Level.BASIC, org.example.fifthchapter.UserService.MIN_LOG_COUNT_FOR_SILVER, 0, ""),
                new User("inbeom3", "박인범3", "p3", Level.SILVER, 60, org.example.fifthchapter.UserService.MIN_RECOMMEND_FOR_GOLD-1, ""),
                new User("inbeom4", "박인범4", "p4", Level.SILVER, 60, org.example.fifthchapter.UserService.MIN_RECOMMEND_FOR_GOLD, ""),
                new User("inbeom5", "박인범5", "p5", Level.GOLD, 100, 100, "")
        );
    }

    @Test
    public void 업그레이드AOP테스트() throws Exception {
         try {
             for (User user : this.users) userDao.add(user);
             userServiceTx.upgradeLevels();
             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);
         } catch (Exception e) {}
    }

    private void checkLevelUpgraded(User user, boolean upgraded) {
        User userUpdated = userDao.get(user.getId());
        if (upgraded) {
            assertThat(userUpdated.getLevel(), is(user.getLevel().next()));
        } else {
            assertThat(userUpdated.getLevel(), is(user.getLevel()));
        }
    }

}

 

 

 

이러한 작업을 진행해줌으로써 비즈니스 로직을 담당하고 있는 UserServiceImpl 의 코드를 작성할 때는 트랜잭션과 같으 기술적인 내용에 신경쓰지 않아도 된다. 또, 위와 같은 구조의 코드를 사용하면 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.

 

 

 

테스트는 가능한 작은 단위로 쪼개서 하는 편이 실패했을 때 그 원인을 찾기 쉽기 때문에 좋다. 또 테스트 단위가 작아야 테스트의 의도, 내용이 분명해지고 만들기도 쉽다고 한다. 지금까지 작성한 UserService 는 매우 간단한 기능들만을 가지고 있음에도 불구하고 구현 클래스들이 동작하기 위해서는 UserDao, MailSender, PlatformTransactionManager 세 가지 타입의 의존 오브젝트가 필요하다.

 

 

UserService 에 대한 테스트에서 확인해야하는 것은 오직 사용자 정보 관리 비즈니스 로직이다. 그런데 UserService 는 세 가지 의존관계를 갖고 있어서, 이 오브젝트들이 테스트가 진행되는 동안에 같이 실행된다. 이렇게 되면 UserService 의 의존관계 오브젝트와 서비스, 환경 등이 모두 합쳐져 테스트 대상이 되어버린다. 테스트 대상이 환경이나 외부 서버, 다른 클래스 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 이전에 MailSender 에서 작성했던 것처럼 테스트를 위한 대역을 사용할 수 있다. UserDao 의 대역을 작성해본다.

 

 

public 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();}

}

 

 

upgradeLevels() 의 테스트를 위해서 사용할 UserDao 의 메소드는 getAll() 과 update() 뿐이니 그 외의 메소드들은 혹시라도 사용하지 않도록 전부 UnsupportedOperationException 을 발생시키도록 만든다. 이제 위 클래스를 이용하여 UserService 의 테스트를 해볼 수 있다.

 

 

@Test
public void 업그레이드테스트() throws Exception {
    MockUserDao userDao = new MockUserDao(this.users);
    MockMailSender mailSender = new MockMailSender();
    UserServiceImpl userService = new UserServiceImpl(userDao, mailSender);

    userService.upgradeLevels();
    List<User> updated = userDao.getUpdated();
    assertThat(updated.size(), is(2));
    checkUserAndLevel(updated.get(0), "inbemo2", Level.SILVER);
    checkUserAndLevel(updated.get(1), "inbeom4", Level.GOLD);

    List<String> request = mailSender.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));
}

 

 

result

 

 

 

테스트가 잘 통과하는 것을 확인할 수 있고, 테스트 속도도 훨씬 빨라진다. 위와 같이 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용하여 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트하는 것단위테스트라고 할 수 있는 것이다. 반면에 DB 나 파일, 서비스 등의 리소스가 참여하는 테스트는 통합테스트라고 부른다. 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합테스트는 필요하지만, 단위 테스트를 충분히 거치고 난다면 통합테스트의 부담이 상대적으로 줄어들게 된다. 

 

 

 

단위테스트 진행 시 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다. 그 주에서 Mockito 라는 프레임워크가 사용하기도 편리하며 코드가 직관적이라 인기가 많다고 한다. 이를 이용하여 지금까지 작성한 Mock 클래스들을 변경해본다. 

 

 

Mockito 와 같은 Mock 프레임워크의 특징은 Mock 클래스를 일일이 준비할 필요 없이, 간단히 메소드 호출만으로 특정 인터페이스를 구현한 테스트용 Mock 오브젝트를 만들 수 있다는 점이 있다. org.mockito.Matchers 클래스의 mock() 정적메소드를 통해 Mock 클래스를 만들어줄 수 있다. 그리고 when(), thenReturn() 메소드를 통해서 스텁 기능을 추가해졸 수 있다. 또, verify() 메소드를 통해 메소드가 몇 번 호출되었는지 검증해볼 수 있다. 정리하자면 Mockito 는 다음의 네 단계를 거쳐서 사용하면 되는 것이다.

 

 

  1. 인터페이스를 이용해 Mock Object 를 생성한다.
  2. Mock Object 가 리턴할 값이 있으면 이를 지정한다. (메소드가 호출되면 강제로 예외를 던지게 만들 수도 있다)
  3. 테스트 대상 오브젝트에 DI 해서 Mock 오브젝트가 테스트 중에 사용되도록 만든다.
  4. 테스트 대상 오브젝트를 사용한 후 Mock 오브젝트의 특정 메소드가 호출되었는지, 어떤 값을 가지고 몇 번 호출되었는지 검증한다.

 

 

public void Mockito로테스트() throws Exception {

    UserDao mockUserDao = mock(UserDao.class);
    when(mockUserDao.getAll()).thenReturn(this.users);
    MailSender mockMailSender = mock(MailSender.class);

    UserServiceImpl userService = new UserServiceImpl(mockUserDao, mockMailSender);
    userService.upgradeLevels();

    verify(mockUserDao, times(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, 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()));
}

 

 

result

 

 

 

위 코드를 보면 우선 UserDao 의 Mock Object 를 생성하고 getAll() 이 호출됐을 때의 리턴 값을 설정한다. times() 는 메소드 호출 횟수를 검증, any() 를 사용하면 파라미터 내용은 무시하고 호출 횟수만 확인할 수 있다. 위 테스트에서 확인한 것은 UserDao 의 update() 메소드가 두 번 호출됐고, 그 때의 파라미터는 getAll() 에서 넘겨준 User 목록의 두 번째와 네 번째여야 한다는 것이다. verify() 를 통해 1, 3 번 인덱스의 User 오브젝트에 update() 가 호출되었는지 검증한다. 또, 각각 오브젝트는 화인했지만 레벨의 변화는 파라미터의 직접 비교로는 확인되지 않기에 getAll() 을 통해 전달했던 User 목록의 내용을 가지고 레벨이 변경됐는지를 직접 확인한다.

 

 

MailSender 의 경우는 ArgumentCaptor 이라는 클래스를 사용하여 실제 MailSender Mock Object 에 전달된 파라미터를 가져와 내용을 검증하는 방법을 사용한다. 파라미터를 직접 비교하기보다 파라미터의 내부 정보를 확인해야하는 경우에 유용하다고 한다.

 

 

 

 

 

 

Reference:

토비의 스프링