Iyoungman Back-end Developer

토비의 스프링 - AOP

2020-07-23

DI를 이용한 클래스의 분리

[1] 비즈니스 로직과 트랜잭션 경계설정의 분리

public void upgradeLevels() throws SQLException {
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        upgradeLevelsInternal();
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        transactionManager.rollback(status);
        throw e;
    }
}

private void upgradeLevelsInternal() {
    List<User> users = userDao.getAll();
    for (User user : users) {
        if (canUpgradeLevel(user)) {
            upgradeLevel(user);
        }
    }
}
  • UserService 객체안에 비즈니스 로직 이외의 트랜잭션 관련 코드가 자리 잡고 있다.


[2] DI를 이용한 클래스의 분리

public interface UserService {
    void add(User user);

    void upgradeLevels();
}

public class UserServiceImpl implements UserService {

    ...

    @Override
    public void upgradeLevels() {
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
    }

    ...
}

public class UserServiceTx implements UserService {
    private UserService userService;
    private PlatformTransactionManager transactionManager;

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public void add(User user) {
        userService.add(user);
    }

    @Override
    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를 클래스 -> 인터페이스로 변경한다.
  • 일반적으로 DI를 적용하는 이유는 구현 클래스를 바꿔가며 사용하기 위함이지만
    다음과 같이 한 번에 두 개의 UserSerivce 구현 클래스를 동시에 이용하게 할 수 있다.

image

  • [장점1] 비즈니스 로직(UserServiceImpl)과 트랜잭션 로직(UserServiceTx)를 분리.
  • [장점2] 비즈니스 로직 테스트를 보다 손쉽게 할 수 있다.


고립된 단위 테스트

기존의 UserService

image

  • UserService는 여러 의존관계를 갖고 있다.
  • UserService의 로직만 테스트 하는것이 목적이지만
    그 뒤에 존재하는 많은 오브젝트와 환경, 서비스, 서버, 네트워크까지 함께 테스트하게 된다.


테스트를 위한 UserSerivceImpl 고립

image

  • 트랜잭션 코드를 독립시킨 후의 구조이다.
  • UserServiceImpl은 TransactionManager를 신경쓰지 않아도 된다.
  • 또한 Mock 객체를 이용해 DB에 독립되지 않은 상태로 바꿀 수 있다.


단위 테스트와 통합 테스트

단위 테스트

  • 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜 테스트.


통합 테스트

  • 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하는 테스트.
  • 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트.
  • 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트(Spring DI).


테스트 가이드라인

  • 항상 단위 테스트를 먼저 고려한다.
  • 외부 리소스를 반드시 사용해야만 하는 경우 통합 테스트로 만든다.
  • DAO와 같이 단위 테스트로 만들기 어려운 코드도 있다. SQL을 통해 DB와 연동되기 때문이다.
    DAO의 경우 DB까지 연동하는 테스트로 만드는 편이 효과적이다.
  • DAO를 충분히 검증해두면 DAO를 이용하는 코드는 이를 목 오브젝트로 대체하여 테스트하면 된다.


다이내믹 프록시와 팩토리 빈

UserServiceTx과 UserServiceImpl의 관계

image

  • 핵심기능 인터페이스는 UserService를 의미한다.
  • 부가기능은 UserServiceTx를 의미한다.
  • 핵심기능은 UserServiceImpl을 의미한다.

  • 부가기능 코드에서는 핵심기능으로 요청을 위임하는 과정에서
    자신의 부가적인 기능을 적용시킨다.
  • 프록시 : 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받는 것.
  • 타깃 : 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 객체.


데코레이터 패턴

  • 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴.
  • 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 주입받는다.
  • ex) UserServiceTx와 UserServiceImpl.

image


프록시 패턴

  • 프록시 패턴에서 프록시는 타킷의 기능을 확장하거나 추가하지 않는다.
  • 대신 클라이언트가 타킷에 접근하는 방식을 변경해준다.

Collections.unmodifiableCollection()


다이내믹 프록시

프록시의 문제점

public class UserServiceTx implements UserService {
    private UserService userService;

    ...

    public void add(User user) {
        userService.add(user);//[1] 메소드 구현과 위임
    }

    public void upgradeLevels() {
        //[2] 부가기능 수행
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {

            userService.upgradeLevels();//위임

            this.transactionManager.commit(status);
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}
  • [1] 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기 번거롭다. 부가기능이 필요없는 메소드도 모두 구현해야하기 때문이다.
  • [2] 부가기능 코드가 중복될 수도 있다. 물론 메소드 분리 등으로 해결할 수는 있다.


리플렉션

  • 프록시의 문제는 JDK의 다이내믹 프록시를 이용하여 해결할 수 있다.
  • 다이내믹 프록시는 리플렉션을 이용해서 프록시를 만든다.


간단한 예제를 이용한 다이내믹 프록시 적용

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 UppercaseHandler implements InvocationHandler {
    //Hello 대신 Obejct로 선언해 어떤 종류의 인터페이스를 구현한 타깃에도 적용 가능
    private Object target;

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object returnValue = method.invoke(target, args);

        //부가기능 수행
        if (returnValue instanceof String && method.getName().startsWith("say")) {
            return ((String) returnValue).toUpperCase();
        }
        return returnValue;
    }
}

public void dynamicProxyTest() throws Exception {
    //클래스 로더, 구현할 인터페이스, InvocationHandler
    Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{Hello.class}, new UppercaseHandler(new HelloTarget()));

    assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
    assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
    assertThat(proxiedHello.sayThankYou("Toby"), is("THANK YOU TOBY"));
}

image

  • [Before] 인터페이스의 메소드가 추가될때마다 프록시 클래스에서 구현해줘야했다.
    프록시 클래스 역시 해당 인터페이스를 구현한 클래스였기 때문이다.
  • [After] 인터페이스의 메소드가 추가되도 별도의 작업이 필요없다.


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

  • 위의 Hello 예제를 바탕으로 트랜잭션 적용 기능을 다이내믹 프록시로 만든다.
  • UserService 인터페이스 메소드가 추가되어도 변경할 필요가 없으며
    UserSerivceImpl 이외에도 트랜잭션 적용이 필요한 어떠한 타깃 오브젝트에도 적용 가능하다.
public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern;

    public void setTarget(Object target) {
        this.target = target;
    }

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    public void setPattern(String pattern) {
        this.pattern = pattern;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getName().startsWith(pattern)) {
            return invokeInTransaction(method, args);
        }
        return method.invoke(target, args);
    }

    private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            Object returnValue = method.invoke(target, args);
            this.transactionManager.commit(status);
            return returnValue;
        } catch (InvocationTargetException e) {
            this.transactionManager.rollback(status);
            throw e.getTargetException();
        }
    }
}

//Test
public void upgradeAllOrNothing() throws Exception {
    ...

    TransactionHandler transactionHandler = new TransactionHandler();
    transactionHandler.setTarget(testUserService);
    transactionHandler.setTransactionManager(transactionManager);
    transactionHandler.setPattern("upgradeLevels");

    UserService userServiceTx = (UserService) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{UserService.class}, transactionHandler);

    ...
}


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

현재 다이내믹 프록시의 문제점

  • 다이내믹 프록시 오브젝트(TransactionHandler)는 일반적인 스프링의 빈으로 등록할 수 없다.
    따라서 스프링 DI 작업을 할 수 없다.
  • 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 메소드를 통해서만 만들 수 있다.


팩토리 빈

  • 스프링에서는 빈을 만드는 여러 방법이 있는데 그중 팩토리 빈이 있다.
  • 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈이다.

  • FactoryBean 인터페이스를 구현한 클래스를 만들어 스프링 빈으로 만들어두면
    getObject()라는 메소드가 생성해주는 오브젝트가 실제 빈의 오브젝트로 대치된다.
  • 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어준다.
    팩토리 빈의 getObejct()에 다이내믹 프록시 오브젝트 생성코드 기입.

image


프록시 팩토리 빈의 장점과 한계

  • [장점] 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면
    타깃의 타입에 상관없이 재사용 할 수 있다.

  • [한계1] 여러 클래스에 공통적인 부가기능을 여러 개 정의하게되면 매번 빈 설정을 해야하므로 설정이 중복된다.
  • [한계2] TransactionHandler 오브젝트가 여러개 만들어진다.


스프링의 프록시 팩토리 빈

스프링의 ProxyFactoryBean

  • 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.
  • ProxyFactoryBean은 순수하게 프록시를 만드는 작업만 담당하고
    프록시를 통해 제공해줄 부가기능은 별도의 빈에 둔다.
public class DynamicProxyTest {
    @Test
    public void proxyFactoryBean() {
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(new HelloTarget());//타깃 설정
        proxyFactoryBean.addAdvice(new UppercaseAdvice());//부가기능이 담긴 Advice 설정

        Hello proxiedHello = (Hello) proxyFactoryBean.getObject();
        assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
        assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
        assertEquals(proxiedHello.sayThankYou("Toby"), "THANK YOU TOBY");
    }

    static class UppercaseAdvice implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation methodInvocation) throws Throwable {
            String ret = (String) methodInvocation.proceed();
            return ret.toUpperCase();
        }
    }
}


어드바이스

  • 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트.
  • MethodInterceptor를 구현해서 만든다.
  • MethodInterceptor는 InvocationHandler와 비슷하지만 다른점이 있다.
    MethodInterceptor의 invoke() 메소드는 타깃 오브젝트에 대한 정보까지 함께 제공받는다.
  • 따라서 MethodInterceptor는 타깃이 다른 여러 프록시에 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.


포인트컷

  • 부가기능을 적용할 대상 메소드를 선정하는 방법이다.
@Test
void proxyTest() {
    ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
    proxyFactoryBean.setTarget(new HelloTarget());

    //포인트컷 적용
    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedName("sayH*");

    //프록시 빈에 어드바이저 추가
    proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));

    Hello proxiedHello = (Hello) proxyFactoryBean.getObject();
    assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
    assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
    assertEquals(proxiedHello.sayThankYou("Toby"), "Thank You Toby");
}


어드바이저

  • 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)


기존 JDK 다이내믹 프록시 -> 스프링 ProxyFactoryBean 이용

image

image


스프링 AOP

빈 후처리기를 이용한 자동 프록시 생성기

DefaultAdvisorAutoProxyCreator

  • 어드바이저를 이용한 자동 프록시 생성기
  • 빈 후처리기 자체를 빈으로 등록하면 적용된다.
  • 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트를 생성할 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다.

image

  • 포인트컷은 클래스와 메소드 선정 알고리즘을 갖고있다.
  • DefaultAdvisorAutoProxyCreator는 등록된 빈중에서 Advisor 인터페이스를 구현한 것을 찾은 후 프록시를 적용할 객체인지 찾는다.


트랜잭션 속성

  • TransactionDefinition 인터페이스가 정의하고 있는 트랜잭션의 네가지 속성을 알아본다.


트랜잭션 전파

  • 트랜잭션 경게에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때
    어떻게 동작할 것인가를 결정하는 방식.

image


PROPAGATION_REQUIRED

  • 가장 많이 사용하는 전파 속성.
  • 진행중인 트랜잭션이 없으면 새로 시작하고
    이미 시작된 트랜잭션이 있으면 이에 참여.


PROPAGATION_REQUIRED_NEW

  • 항상 새로운 트랜잭션을 시작한다.
  • 따라서 독자적으로 동작한다.


PROPAGATION_NOT_SUPPORTED

  • 진행 중인 트랜잭션이 있어도 무시한다.
  • 트랜잭션이 적용되지 않는것이다.


격리수준

  • 모든 DB 트랜잭션은 격리수준을 갖고있다.
  • 격리수준은 기본적으로 DataSource의 설정을 따른다.
  • 필요에 따라 별도로 지정한다.


제한시간(timeout)

  • 트랜잭션의 수행시간.
  • Default는 제한시간이 없다.


읽기전용(readOnly)

  • 트랜잭션 내에서 데이터를 조작하는 시도를 막아준다.
  • 트랜잭션 속성 중 readOnly나 timeout은 트랜잭션이 처음 시작될 때 아니면 적용되지 않는다.
    즉, 다른 이미 시작된 다른 트랜잭션에 참여하지 않는다.


Reference


Comments

Content