티스토리 뷰

SPRING

토비의 스프링 6. AOP (1)

짜비 2022. 5. 27. 22:09

이전 코드 개선

이전 코드

public void upgradeLevels() throws Exception{
    // 트랜잭션 시작
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
        //정상적으로 작업을 마치면 트랜잭션 커밋
        transactionManager.commit(status);
    } catch (Exception e) {
        //예외 발생시 롤백
        transactionManager.rollback(status);
        throw e;
    }
}
  • 문제점
    • 트랜잭션 코드 - 서비스 로직 혼재

개선된 코드

public interface UserService {
    void add(User user);

    void upgradeLevels();
}


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

public class UserServiceTx implements UserService {
  public void upgradeLevels() {
    // 트랜잭션 시작
      TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
      try {
          userService.upgradeLevels();

          //정상적으로 작업을 마치면 트랜잭션 커밋
          transactionManager.commit(status);
      } catch (Exception e) {
        //예외 발생시 롤백
          transactionManager.rollback(status);
          throw e;
      }

  }
}


@Bean
public UserService userService() {
    UserService userService = new UserServiceTx(userServiceImpl() ,transactionManager());
    return userService;
}

@Bean
public UserServiceImpl userServiceImpl() {
    UserServiceImpl userServiceImpl = new UserServiceImpl(userDao(), mailSender());
    return userServiceImpl;
}
  • UserService 인터페이스를 만들고 구현 클래스를 두 개 생성
  • 비즈니스 로직은 UserServiceImpl 에 위임
    • UserServiceImpl 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에 대해 전혀 신경쓰지 않아도 됨
  • 트랜잭션이 가미된 로직은 UserServiceTx 에만 존재

고립된 단위 테스트

기존 upgradeLevels() 테스트 코드


public void upgradeLevels() throws Exception {
    //DB test data 준비
    userDao.deleteAll();
    for (User user : users) {
        userDao.add(user);
    }

    //테스트 대상 실행
    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> requests = mockMailSender.getRequests();
    assertThat(requests.size()).isEqualTo(2);
    assertThat(requests.get(0)).isEqualTo(users.get(1).getEmail());

}
  • 위 코드는 테스트용 DB (userDao) 에 의존하고 있다. 이러한 의존 관계를 없애보자.

UserDao Mock 오브젝트

public class MockUserDao implements UserDao {

    //레벨 업그레이드 후보 User 오브젝트 목록
    private List<User> users;
    //업그레이드 대상 오브젝트를 저장해둘 목록
    private List<User> updated = new ArrayList<>();

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

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

    //목 오브젝트 기능 제공
    @Override
    public void update(User user1) {
        updated.add(user1);
    }

    //스텁 기능 제공
    @Override
    public List<User> getAll() {
        return this.users;
    }

    @Override
    public void add(User user) {
        throw new UnsupportedOperationException();
    }

    //...
}
@Test
void upgradeLevels() throws Exception {
    MockUserDao mockUserDao = new MockUserDao(users);

    //고립된 테스트에서는 테스트 대상 오브젝트를 직접 생성, Mock UserDao DI 
    UserServiceImpl userServiceImpl = new UserServiceImpl(mockUserDao, mockMailSender);

    userServiceImpl.upgradeLevels();

    List<User> updated = mockUserDao.getUpdated();
    assertThat(updated.size()).isEqualTo(2);    //업데이트 횟수 확인
    checkUserAndLevel(updated.get(0), "id2", SILVER); //업데이트 정보 확인 
    checkUserAndLevel(updated.get(1), "id4", GOLD);

    List<String> requests = mockMailSender.getRequests();
    assertThat(requests.size()).isEqualTo(2);
    assertThat(requests.get(0)).isEqualTo(users.get(1).getEmail());
    assertThat(requests.get(1)).isEqualTo(users.get(3).getEmail());
}

private void checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
    assertThat(updated.getId()).isEqualTo(expectedId);
    assertThat(updated.getLevel()).isEqualTo(expectedLevel);
}
  • 기존에는 test DB 에 실제로 데이터를 넣고 업데이트 했다면 이제는 in-memory mock 오브젝트를 이용해서 업데이트한 대상이 누구인지, 업데이트가 몇 번 일어났는지 확인 가능
  • DB data 전처리 작업(DB 데이터 전체 삭제, DB 데이터 등록) 작업이 필요 없게 됨
  • Spring Container (SpringExtension) 또한 불필요
  • 테스트 수행 시간 감축

Mock 프레임워크 : Mockito

@Test
void upgradeLevels() throws Exception {
    UserDao mockUserDao = mock(UserDao.class);
    when(mockUserDao.getAll()).thenReturn(users);

    //고립된 테스트에서는 테스트 대상 오브젝트를 직접 생성, Mock UserDao DI
    UserServiceImpl userServiceImpl = new UserServiceImpl(mockUserDao, mockMailSender);

    userServiceImpl.upgradeLevels();

    verify(mockUserDao, times(2)).update(any(User.class));
    verify(mockUserDao, times(2)).update(any(User.class));
    //users.get(1) 을 파라미터로 Update()가 호출된 적이 있는지를 확인
    verify(mockUserDao).update(users.get(1));
    assertThat(users.get(1).getLevel()).isEqualTo(SILVER);
    verify(mockUserDao).update(users.get(3));
    assertThat(users.get(3).getLevel()).isEqualTo(GOLD);

    // MockMailSender 에 전달된 파라미터를 가져와 내용을 검증
    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]).isEqualTo(users.get(1).getEmail());
    assertThat(mailMessages.get(1).getTo()[0]).isEqualTo(users.get(3).getEmail());
}

프록시

프록시

  • 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아줌. 대리자, 대리인과 같은 역할
  • 타깃과 같은 인터페이스를 구현
  • 프록시가 타깃을 제어할 수 있는 위치에 있음

타깃 (target, real subject)

  • 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트

데코레이터 패턴

  • 타깃에 부가적인 기능을 런타임다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴
    • 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않음
  • e.g. UserServiceTx
    • UserService 타입 오브젝트를 DI 받아서 기능을 위임
    • 동시에 트랜잭션 경계설정 기능을 부여
@Bean
public UserService userService() {
    UserService userService = new UserServiceTx(userServiceImpl() ,transactionManager());
    return userService;
}

프록시 패턴

  • 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우
  • 타깃의 기능 자체에는 관여하지 않음
  • 데코레이터 패턴과 유사하지만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많음

이 책에서 프록시 정의

  • 타깃과 동일한 인터페이스를 구현
  • 기능 부가 혹은 접근 제어를 담당하는 오브젝트

다이내믹 프록시

프록시 기능

  • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임
  • 지정된 요청에 대해서는 부가기능을 수행

프록시 만드는 것이 번거로운 이유

  • 인터페이스를 구현하고 위임하는 코드 작성 번거로움
    • 해결 : JDK 다이내믹 프록시 -> 리플렉션 기능 활용
  • 부가기능 코드가 중복될 가능성이 많음
    • e.g. 트랜잭션 경계 설정 코드 -> 메소드가 추가될 때 마다 try-catch 반복 …

리플렉션

  • 자바의 모든 클래스는 그 클래스의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 가지고 있음
  • 클래스 오브젝트를 이용하면 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있음

프록시 예제 코드

public interface Hello {
    String sayHello(String name);

    String sayHi(String name);

    String sayThankYou(String name);
}

public class HelloTarget implements Hello{
    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }

    @Override
    public String sayHi(String name) {
        return "Hi " + name;
    }

    @Override
    public String sayThankYou(String name) {
        return "Thank you " + name;
    }
}


//프록시 클래스
public class HelloUppercase implements Hello{
    //위임할 타깃 오브젝트. 다른 프록시를 추가할 수도 있으므로 인터페이스로 접근.
    Hello hello;

    public HelloUppercase(Hello hello) {
        this.hello = hello;
    }

    @Override
    public String sayHello(String name) {
        return hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(String name) {
        return hello.sayHi(name).toUpperCase();
    }

    @Override
    public String sayThankYou(String name) {
        return hello.sayThankYou(name).toUpperCase();
    }
}

HelloUppercase (프록시 클래스) 문제점

  • 인터페이스의 모든 메소드를 구현해 위임해야 함 -> 번거로움
  • 부가 기능 코드가 모든 메소드에 중복되어 나타남

문제 해결 : 다이내믹 프록시

  • 프록시 팩토리에 의해 런타임 시 동적으로 만들어지는 오브젝트
public class UppercaseHandler implements InvocationHandler {
    Hello target;

    //다이내믹 프록시로부터 전달받은 요청을 다시 타깃 오브젝트에 위임해야 하기 때문에 타깃 오브젝트를 주입받는다.
    public UppercaseHandler(Hello target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //e.g. target.sayHi(args)
        String ret = (String)method.invoke(target, args);
        return ret.toUpperCase();
    }
}


@Test
void simpleProxy() {
  //프록시 팩토리에게 다이내믹 프록시를 만들어달라고 요청
    Hello proxiedHello = (Hello)Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] {Hello.class},
            new UppercaseHandler(new HelloTarget())
    );
    assertThat(proxiedHello.sayHello("Toby")).isEqualTo("Hello Toby");
    assertThat(proxiedHello.sayHi("Toby")).isEqualTo("Hi Toby");
    assertThat(proxiedHello.sayThankYou("Toby")).isEqualTo("Thank you Toby");

}

다이내믹 프록시 장점

  • 인터페이스의 메소드가 늘어나더라도 코드를 추가하지 않아도 됨

다이내믹 프록시 확장

public class UppercaseHandler implements InvocationHandler {
    Object target;

    public UppercaseHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //e.g. target.sayHi(args)
        Object ret = method.invoke(target, args);
        if (ret instanceof String && method.getName().startsWith("say")) {
            return ((String)ret).toUpperCase();
        } else {
            return ret;
        }
    }
}
  • 스트링 이외의 리턴 타입을 갖는 메소드 추가 가능
  • 타깃의 종류와 관계 없이(어떤 종류의 인터페이스를 구현한 타깃이든 상관 없이) 적용 가능
  • 어떤 메소드에 어떤 기능을 적용할지 설정 가능.
    • e.g. 메소드 이름으로 분기 처리

다이내믹 프록시를 이용한 트랜잭션 부가기능

public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern;

    public TransactionHandler(Object target, PlatformTransactionManager transactionManager, String pattern) {
        this.target = target;
        this.transactionManager = transactionManager;
        this.pattern = pattern;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    //트랜잭션 적용 대상 메소드 선별
        if (method.getName().startsWith(pattern)) {
            return invokeInTransaction(method, args);
        } else {
            return method.invoke(target, args);
        }
    }

    private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object ret = method.invoke(target, args);
            this.transactionManager.commit(status);
            return ret;
        } catch (InvocationTargetException e) {
            this.transactionManager.rollback(status);
            throw e.getTargetException();
        }
    }
}
@Test
void upgradeAllOrNothing() throws Exception{
    TestUserService testUserService = new TestUserService(userDao,mockMailSender, users.get(3).getId());
    //UserServiceTx userServiceTx = new UserServiceTx(testUserService, transactionManager);

    TransactionHandler txHandler = new TransactionHandler(testUserService, transactionManager, "upgradeLevels");
    UserService userServiceTx = (UserService)Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] {UserService.class},
            txHandler
    );

    userDao.deleteAll();
    for (User user : users) {
        userDao.add(user);
    }
    try {
        userServiceTx.upgradeLevels();
        Assertions.fail("TestUserServiceException expected");
    } catch (TestUserServiceException e) {
    }
    //예외 발생 이전에 이미 레벨이 변경된 사용자 레벨이 처음으로 롤백 되었는지 확인
    checkLevelUpgraded(users.get(1), false);
}
  • UserServiceTx 를 다이내믹 프록시 방식으로 변경
    • 인터페이스 메소드를 모두 구현할 필요가 없어짐
    • 트랜잭션 처리 코드가 중복되지 않음

다이내믹 프록시를 위한 팩토리 빈

다이내믹 프록시 문제점

  • 다이내믹 프록시 오브젝트를 스프링 빈으로 등록할 방법이 없다.
    • 다이내믹 프록시 오브젝트의 클래스는 Object 로 만들어져서 다이내믹하게 새로 정의한 후 사용하기 때문

문제 해결 : 팩토리 빈

  • 팩토리 빈
    • 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈
    • 팩토리 빈을 만드는 간단한 방법으로, FactoryBean 인터페이스를 구현하는 방법이 있다.
  • 스프링은 FactoryBean 인터페이스를 구현한 클래스가 빈의 클래스로 지정되면, 팩토리 빈 클래스 오브젝트의 getObject() 메소드를 이용해 오브젝트를 가져오고, 이를 빈 오브젝트로 사용한다.
  • 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어줄 수 있다. getObject() 메소드에 다이내믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되기 때문.
public class TxProxyFactoryBean implements FactoryBean<Object> {

    //TransactionHandler 생성시 필요.
    Object target;
    PlatformTransactionManager transactionManager;
    String pattern;

    //다이나믹 프록시 생성시 필요.
    Class<?> serviceInterface;

    public TxProxyFactoryBean(Object target, PlatformTransactionManager transactionManager, String pattern, Class<?> serviceInterface) {
        this.target = target;
        this.transactionManager = transactionManager;
        this.pattern = pattern;
        this.serviceInterface = serviceInterface;
    }

    @Override
    public Object getObject() throws Exception {
        TransactionHandler txHandler = new TransactionHandler(target, transactionManager, pattern);
        return Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[] {serviceInterface}, txHandler
        );
    }

    //팩토리 빈이 생성하는 오브젝트의 타입은 DI 받은 인터페이스 타입에 따라 달라진다.
    //따라서 다양한 타입의 프록시 오브젝트 생성에 재사용할 수 있다.
    @Override
    public Class<?> getObjectType() {
        return serviceInterface;
    }

    //싱글톤 빈이 아니라는 뜻이 아니라 getObject()가 매번 같은 오브젝트를 리턴하지 않는다는 의미.
    @Override
    public boolean isSingleton() {
        return false;
    }
}
// @Bean
// public UserService userService() {
//     UserService userService = new UserServiceTx(userServiceImpl() ,transactionManager());
//     return userService;
// }

@Bean(name = "userService")
public TxProxyFactoryBean txProxyFactoryBean() {
    TxProxyFactoryBean txProxyFactoryBean = new TxProxyFactoryBean(userServiceImpl(), transactionManager(), "upgradeLevels", UserService.class);
    return txProxyFactoryBean;
}
  • 팩토리 빈이 만드는 다이내믹 프록시는 구현 인터페이스나 타깃 종류에 제한이 없다. 따라서 UserService 외에도 트랜잭션 부가기능이 필요한 오브젝트를 위한 프록시를 만들 때 얼마든지 재사용이 가능하다. -> 설정이 다른 여러 개의 TxProxyFactoryBean 을 등록하면 됨
    • e.g. 트랜잭션 부가기능이 필요한 빈이 추가되면 빈 설정만 추가해주면 된다. 즉 매번 UserServiceTx 와 같은 프록시 클래스를 작성할 필요가 없다.

프록시 팩토리 빈 장점

프록시 팩토리 빈 재사용 가능

@Bean(name = "coreService")
public TxProxyFactoryBean txProxyFactoryBean() {
    TxProxyFactoryBean txProxyFactoryBean = new TxProxyFactoryBean(coreServiceTarget(), transactionManager(), "", CoreService.class);
    return txProxyFactoryBean;
}
  • 가령, CoreService라는 클래스에 트랜잭션 경계설정 기능을 부여해야 한다면 핵심 비즈니스 로직을 구현한 후 위와 같이 프록시 팩토리 빈 설정을 추가하는 것만으로 충분하다.

(데코레이터 패턴이 적용된 프록시) 단점 극복

  • 인터페이스를 구현하는 프록시 클래스 작성 번거로움
    • 다이내믹 프록시로 해결. 인터페이스 메소드를 일일이 오버라이드할 필요 없음
  • 부가기능 구현 코드 반복
    • 다이내믹 프록시로 해결. 핸들러 메소드만 구현하면 된다.

번거로운 다이내믹 프록시 생성 코드 제거

프록시 팩토리 빈 한계 (?)

  • 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공 불가능
  • 하나의 타깃에 여러 개의 부가기능을 적용시 설정 복잡해짐
    • 타깃과 인터페이스만 다른, 비슷한 설정이 반복됨
  • TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어짐

스프링의 프록시 팩토리 빈

ProxyFactoryBean

  • 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈
  • 프록시를 생성하는 작업만 담당
    • 프록시로 제공하는 부가기능은 별도 빈으로 둔다.

MethodInterceptor

  • InvocationHandler 와 유사하게 부가기능 구현할 때 implement하는 인터페이스
  • ProxyFactoryBean 으로부터 타깃 오브젝트에 대한 정보도 함께 제공 받음
    • 타깃 오브젝트와 독립적으로 만들어질 수 있다. 즉 싱글톤 빈으로 등록 가능

어드바이스 (Advice)

  • 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트
  • 스프링 싱글톤 빈으로 등록해서 여러 프록시에서 공유 가능

포인트컷 (Pointcut)

  • 부가기능 적용 대상 메소드 선정 방법을 담은 오브젝트
  • 메소드 선별 기능을 프록시로부터 분리
  • 스프링 싱글톤 빈으로 등록해서 여러 프록시에서 공유 가능

어드바이저

  • 어드바이스와 포인트컷을 묶은 오브젝트

ProxyFactoryBean -> 트랜잭션 경계설정 기능에 적용

public class TransactionAdvice implements MethodInterceptor {

    PlatformTransactionManager transactionManager;

    public TransactionAdvice(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object ret = invocation.proceed();
            this.transactionManager.commit(status);
            return ret;
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}
  • 타깃 메소드 호출 코드가 간결해짐

@Bean
public TransactionAdvice transactionAdvice() {
    TransactionAdvice transactionAdvice = new TransactionAdvice(transactionManager());
    return transactionAdvice;
}

@Bean(name = "transactionPointcut")
public NameMatchMethodPointcut transactionPointcut() {
    NameMatchMethodPointcut transactionPointcut = new NameMatchMethodPointcut();
    transactionPointcut.setMappedName("upgrade*");
    return transactionPointcut;
}

@Bean(name = "transactionAdvisor")
public PointcutAdvisor transactionAdvisor() {
    PointcutAdvisor transactionAdvisor = new DefaultPointcutAdvisor( transactionPointcut(), transactionAdvice());
    return transactionAdvisor;
}

@Bean(name = "userService")
public ProxyFactoryBean proxyFactoryBean() {
    ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
    proxyFactoryBean.setTarget(userServiceImpl());
    proxyFactoryBean.addAdvisor(transactionAdvisor());
    return proxyFactoryBean;
}
  • UserService 외에 새로운 비즈니스 로직을 담은 서비스 클래스가 만들어져도 이미 만들어둔 TransactionAdvice 를 재사용할 수 있음
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함