6장. AOP

지하나·2021년 10월 6일
1

토비의 스프링 v1

목록 보기
6/6
post-thumbnail
  • 출처: 토비의 스프링 3.1 vol.1 스프링의 이해와 원리

AOP를 바르게 이용하려면 OOP를 대체하려고 하는 것처럼 보이는 AOP라는 이름 뒤에 감춰진, 그 필연적인 등장배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지에 대한 충분한 이해가 필요하다.


6.1 트랜잭션 코드의 분리

5장에서의 UserService 코드를 한번 더 자세히 보면 문제점이 있다. 비즈니스 로직이 주인이어야 할 자리에 트랜잭션 기능이 앞뒤로 많은 부분을 차지하고 있는 것이다. 여기서 어떻게 하면 코드를 좀 더 깔끔하게 만들 수 있을까?

public class UserService {
    private UserDao userDao;
    private PlatformTransactionManager txManager;
    
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }
    
    public void setTxManager(PlatfromTransactionManager txManager) {
        this.txManager = txManager;
    }

    public void upgradeLevels() {
        TransactionStatus status = this.txManager.getTransacion(new DefaultTransactionDefinition());
        try {
            InternalUpgradeLevels();
            this.txManager.commit(status)
        } catch (RuntimeException e) {
            this.txManager.rollback(status)
            throw e;
        }
    }
    
    private void InternalUpgradeLevels() {
        List<Users> users = userDao.getAll();
        for(User user : users) {
            if (canUpgradeLvel(user)) {
                 upgradeLevel(user);
            }
        }
    }
    
    private void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }
    
    private boolean canUpgradeLevel(User user) {
        // 생략
    }
}

한 가지 팁이 될 수 있는 특징은.. 트랜잭션 관련 설정 부분과 비즈니스 로직 부분은 서로 주고 받는 것들이 없다는 점이다.

사용자 정보를 업데이트 치는 것도 UserDao를 통하기 때문에 UserService가 DB 커넥션 정보를 직접 참조하는 것이 없다. 트랜잭션 설정 코드와 비즈니스 로직 코드는 서로 담당하는 기능의 성격도 다르고, 주고 받는 정보도 없이.. 완벽히 독립적인 코드라고 할 수 있다. 그저 비즈니스 로직 코드가 트랜잭션의 시작과 종료 안에서 수행되어야 한다는 것뿐.

그러니 트랜잭션 부분을 UserService에서 분리시켜도 될 것 같다. UserService에는 순수하게 비즈니스 로직만 놔두고 트랜잭션 부분은 왼부로 빼내도록 하자. 앞에서 계속 해왔던 것처럼 DI로 주입시키고 UserService에서 완전히 들어내서 분리시키도록 한다. 각 클래스간에 의존 관계를 그려보면 아래와 같을 것이다.

UserService 인터페이스 도입

그럼 우선 UserService는 지금 상태로 사용하면 클라이언트가 직접 참조하게 되어 있어서 결합도가 높고, 이 사이에 어떠한 기능(트랜잭션 기능과 같이..)을 넣을 틈이 없다. UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현체로 만들자.

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

public class UserServiceImpl implements UserService {
    UserDao userDao;
    
    public void upgradeLevels() {
        List<Users> users = userDao.getAll();
        for(User user : users) {
            if (canUpgradeLvel(user)) {
                 upgradeLevel(user);
            }
        }
    }
    //이하 코드 생략
}

트랜잭션 기능을 담당할 클래스도 동일하게 UserService 인터페이스를 받아와서 구현한다. 이렇게 동일하게 UserSerivce를 가지고 오는 이유가 무엇일까?

앞에서도 살짝 언급했듯.. UserService를 사용하는 클라이언트 입장에서 UserService에 직접적인 의존관계를 설정하지 않기 위해서다. 만약 여기서 UserService가 아니라 다른 어떠한 클래스로 트랜잭션 담당 클래스를 구현했다고 생각해보자. 클라이언트는 어떠한 클래스를 불러야 하는지를 다 알아야 할 것이다.

하지만 트랜잭션 담당 클래스를 UserService로 구현하면.. 클라이언트 입장에서는 "난 그냥 서비스 클래스 부른 건뒈?"가 되고.. 트랜잭션 로직은 서비스 로직인 UserService 뒤에 숨을 수 있게 되는 것이다. 그러고 실제 서비스 구현체는 이 트랜잭션 클래스가 불러다가 쓰면.. 비즈니스 담당 클래스는 트랜잭션 기능에 관해 독립적이게 된다.

얼핏 생각해서 추상화를 통한 전략 패턴을 적용하는 것은 안될지..가 떠오를 수도 있다. 하지만 템플릿/콜백 패턴을 적용하게 되면 트랜잭션 코드를 분리하긴 하지만 여전히 서비스 로직 클래스에 코드가 남게 된다. 우리가 하고자 하는 것은 UserService가 트랜잭션 기능을 완전히 모르도록 분리시키는 것이다.

그래서 결국 UserServiceTxUserService 인터페이스를 받아와서 구현하면..

public class UserServiceTx implements UserService {
    private UserService userService;
    private PlatformTransactionManager txManager;
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
    public void setTxManager(PlatfromTransactionManager txManager) {
        this.txManager = txManager;
    }
    
    public void upgradeLevels() {
        TransactionStatus status = txManager.getTransacion(new DefaultTransactionDefinition());
        try {
            userService.upgradeLevels();
            txManager.commit(status)
        } catch (RuntimeException e) {
            txManager.rollback(status)
            throw e;
        }
    }
}

트랜잭션 경계설정 코드 분리의 장점

이제 비즈니스 로직을 담당하는 UserServiceImpl은 트랜잭션 기능에는 관여하지 않고 비즈니스 로직에만 관여하게 되었다. 트랜잭션은 DI를 이용하면 어느 곳에서든 트랜잭션을 도입할 수 있다.

그리고 이렇게 두 기능을 분리함으로써 UserSerivce를 테스트하기도 수훨해졌다는 장점이 있다. UserSerivce는 의존 관계가 복잡한 클래스여서 단위 테스트라고 해도 사실은 그 뒤에 연결된 오브젝트와 환경 등이 합쳐져 통합적인 테스트가 이루어져야 했다. 따라서 단독으로 테스트를 수행하기 복잡한 구조를 가지고 있었다. 이제는 DI를 통해서 다른 의존 오브젝트와 의존하게 바뀌었고, 따라서 외부 환경으로부터 고립된 단위 테스트를 수행할 수 있다는 장점을 얻게 되었다.


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

UserService가 핵심기능이라고 한다면 UserServceTx는 부가기능이 된다. 그리고 UserServiceUserServiceTx의 존재 자체를 모른다. 다시 말하면.. 부가기능이 핵심기능을 불러다가 사용하는 구조다.

만약 클라이언트가 부가기능을 거치지 않고 핵심기능을 바로 사용해버리면 부가기능은 자신이 적용될 기회를 잃는다. 따라서 부가기능은 마치 자신이 핵심시능을 가진 클래스인 것처럼 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다.

즉, 클라이언트는 인터페이스를 통해서만 핵심기능을 호출하도록 하고, 부가기능 자신은 해당 인터페이스를 구현하여 자신이 클라이언트와 핵심기능 사이에 문지기 역할을 해야 한다.

이처럼 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시(Proxy)라고 부른다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃(target) 또는 실체(object)라고 부른다. 프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다. -본문p430

프록시는 사용 목적에 따라 두 가지로 구분할 수 있다. 첫째는 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서다. 두 번째는 타깃에 부가적인 기능을 부여해주기 위해서다. 두 가지 모두 대리 오브젝트라는 개념의 프록시를 두고 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서는 다른 패턴으로 구분한다. -본문 p431

데코레이터 패턴

데코레이터 패턴은 프록시를 사용하는데 그 사용 목적이 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 사용하는 패턴이다. 여기서 다이내믹하게 기능을 부여한다는 말의 의미는 컴파일 시점에 정해져 있지 않고, 런타임 시 정해진다는 뜻이다.

데코레이터 패턴은 프록시를 여러개 두고 여러 개의 프록시를 순차적으로 사용하기도 한다. 이때도 인터페이스를 통해 위임하는 방식이기 때문에 각각의 데코레이터는 자신이 최종 타깃으로 위임하는지, 또는 다음 단계의 데코레이터 프록시로 위임하는지는 서로 알지 못한다.

프록시 패턴

프록시 패턴의 프록시는 데코레이터 패턴과 달리 타깃의 기능을 확장하거나 추가하지 않고, 클라이언트가 타깃에 접근하는 방식을 제어한다.

사용 예시를 들어보면.. 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않은 경우, 클라이언트 단에 타깃에 대한 레퍼런스를 바로 넘기는 것이 아니라 프록시를 대신 넘겨준다. 그러고 프록시의 메소드를 통해 타깃을 호출해야할 때가 오면 그 때 프록시가 타깃 오브젝트를 생성해서 요청을 넘겨준다.

또한 만약 원격 오브젝트를 이용하는 경우라면, 원격 오브젝트에 대한 프록시를 만들고, 클라이언트가 요청하면 프록시가 네트워크를 통해 원격의 오브젝트를 실행하고 결과를 받아서 넘겨준다. 또는 특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 프록시 패턴을 사용할 수도 있다.

이렇듯 프록시 패턴에서의 프록시는 타깃의 기능 자체에는 관여하지 않고 타깃에 대한 접근 방법을 제어한다. 그렇기에 프록시는 코드에서 자신이 접근해야할 타깃 클래스의 정보를 알고 있어야 한다는 차이가 있다.

데코레이터 패턴과 프록시 패턴에서의 프록시는 각각 조금 다른 의미를 가지지만 앞으로는 두 가지 경우를 통칭하여 프록시라고 부르도록 하겠다. 앞에서 보았던 UserServiceTx는 타깃으로 요청을 위힘하고, 트랜잭션이라는 부가기능을 수행하는 두 가지 역할을 수행하고 있다.

사실 프록시는 구현하는데 꾀나 번거로운 작업이라고 말할 수도 있다.

앞에서 해왔던 것처럼 인터페이스 구현하고, 위임하는 코드를 작성하는 이 과정들이 번거롭다. 또한 타깃에 기능이 추가되거나 변경될 때마다 같은 인터페이스를 받아오는 프록시도 함께 수정해주어야 한다. 또한 부가기능은 코드가 중복되기 쉽다. 쉬운 예시가.. UserService에서 add() 메소드도 마저 구현한다고 생각해보자. 여기에도 마찬가지로 트랜잭션을 넣어줘야 하는데 코드가 중복된다. 메소드가 더 많아지고 트랜잭션 적용 범위가 넓어질수록 유사한 코드가 중복될 것이다.

리플렉션

우선 첫번째 문제를 해결해주기 위해 다이내믹 프록시를 적용해볼 수 있다. 다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만드는 것인데, 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.

리플렉션은 자바의 String으로 쉽게 설명할 수 있다. 어떠한 스트링의 길이를 알고 싶다면 length()을 호출하면 되지만, 리플렉션 API를 이용해서도 구할 수가 있다.

String words = "Hello World";
Method lengthMethod = String.class.getMethod("length");
int length = lengthMethod.invoke(words);

위와 같이 클래스가 가진 메소드 중 "length"라는 이름을 갖는 메소드의 정보를 java.lang.reflect.Method 인터페이스로 받아와 메소드에 대한 정보를 담는다. 그리고 이를 invoke() 를 통해 메소드 호출이 가능한다.

이렇듯 리플렉션 API를 이용해서 프록시를 만든다고 하면.. 프록시가 부르려고 하는 타깃의 인터페이스를 가져와서 기능을 부가하고 위임하면 매번 프록시 클래스를 따로 만들지 않아도 된다.

다이내믹 프록시 적용

다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트를 뜻한다. 프록시 팩토리에게 인터페이스 정보를 제공해주면 인터페이스를 구현한 구현체를 자동으로 만들어준다.

좀 더 쉬운 예제로 이해해보자. 아래와 같이 이름에 hello 문구를 붙여주는 기능이 있다고 하자.

interface Hello {
    public String sayHello(String name);
    public String sayHi(String name);
}

public class HelloImpl implements Hello {
    public String sayHello(String name) {
        return "Hello " + name;
    }
    public String sayHi(String name) {
        return "Hi " + name;
    }
}

그리고 여기에 문구를 대문자로 바꿔주는 프록시를 추가한다. 일단 단순한 프록시를 붙이는 방법을 먼저 보면 아래와 같을 것이다. 코드에서도 바로 보이듯이 부가 기능 코드가 계속 중복되고 있다.

public class HelloProxy implements Hello {
    private Hello hello;
    public HelloProxy(Hello hello) {
        this.hello = hello;
    }
    
    public String sayHello(String name) {
        String result = this.hello.sayHello(name);
        return result.toUpperCase();
    }
    public String sayHi(String name) {
        String result = this.hello.sayHi(name);
        return result.toUpperCase();
    }
}

이제 HelloProxy 대신 다이내믹 프록시를 적용해보자. 다이내믹 프록시를 적용하려면.. 우선 프록시 자체를 프록시 팩토리를 통해 생성하는데 Proxy 클래스의 newProxyInstance()를 사용한다.

public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException

파라미터를 하나씩 살펴보면.. 첫 번째로 동적으로 생성될 프록시 클래스의 로딩에 사용할 클래스 로더를 제공하고, 두 번째는 생성할 프록시가 받아와야할 인터페이스 클래스를 넣어주고, 세 번째는 프록시가 가질 위임 또는 부가기능의 코드를 담고 있는 InvocationHandler 구현체를 넣어준다.

InvocationHandler 인터페이스invoke()라는 메소드 하나만을 갖는데 이 메소드가 앞에서의 Method 인터페이스를 인자로 받아서 타깃 오브젝트의 메소드를 호출해주는 것이다. 메소드가 아무리 많더라도 invoke() 메소드 하나로 모든 처리가 가능해진다.

// 부가기능 구현
public class UppercaseHandler implements InvocationHandler {
    private Hello target;
    public UppsercaseHandler(Hello target) {
        this.target = target;
    }
    
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String result = (String)method.invoke(target, args);
        return result.toUpperCase();
    }
}

그리고 이 InvocationHandler를 newProxyInstance() 메소드에 파라미터로 넘겨주어 프록시를 생성해주는 것이다.

// 프록시 생성 
Hello proxiedHello = (Hello)Proxy.newProxyInstance(        
        getClass().getClassLoader(), new Class[] {Hello.class}, new UppercaseHandler(new HelloImpl()));

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

이제 UserServiceTx에 다이내믹 프록시을 적용해보자. 먼저 InvoationHandler 구현체를 만들어보도록 한다.

public class TxHandler implements InvocationHandler {
    // 부가기능을 제공할 타깃 오브젝트. userServiceImpl
    private Object target;
    private PlatformTransactionManager txManager;
    // 트랜잭션을 적용할 메소드명 패턴 
    private String pattern;
    
    public TxHandler(Object target, PlatformTransactionManager txManager, String pattern) {
        this.target = target;
        this.txManager = txManager;
        this.pattern = pattern;
    }
    
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return method.getName().startsWith(pattern) ? invokeInTransaction(method, args) : method.invoke(target, args);
    }
    
    private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = txManager.getTransacion(new DefaultTransactionDefinition());
        try {
            //userService.upgradeLevels();
            Object result = method.invoke(target, args);
            
            txManager.commit(status)
            return result;
        } catch (InvocationTargetException e) {
            txManager.rollback(status)
            throw e;
        }
    }
}

위의 TxHandler는 트랜잭션 프록시의 기능 코드를 담고있는 InvocationHandler 구현체가 될 것이다. 프록시 팩토리에 이걸 파라미터로 넘겨주면 이제 클라이언트에서.. UserService 인터페이스 타입의 다이내믹 프록시 생성 프록시가 만들어진다.

TxHandler txHandler = new TxHandler(UserServiceImpl, txManager, "upgradeLevels");
UserService UserServiceTx = (UserService)Proxy.newProxyInstance(
        getClass().getClassLoader(), new Class[] {UserService.class}, txHanlder);

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

그런데 DI의 대상이 되는 다이내믹 프록시 오브젝트는 일반적인 방법으로 스프링 빈으로 등록할 수 없다. 스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성하는데 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다.

스프링 빈은 기본적으로 지정된 클래스 이름을 가지고 리플렉션을 이용해서 빈 오브젝트를 만든다. 그런데 이 방법 외에도 빈을 만들 수 있는 여러가지 방법이 존재한다. 그 중에서 팩토리 빈은.. 스프링을 대신해서 오브젝트의 생성 로직을 담당하고 있다.

public interface FactoryBean<T> {
    T getObject() throws Exception;
    class<? extends T> getObjectType();
    boolean isSingleton();
}

public class TxProxyFactoryBean implements FactoryBean<Object> {
    private Object target;
    private PlatformTransactionManager txManager;
    private String pattern;
    Class<?> serviceInterface;
    
    public void setTarget(Object target) {
    	this.target = target;
    }
    public void setTxManager(PlatformTransactionManager txManager) {
        this.txManager = txManager;
    }
    public void setTarget(String pattern) {
        this.pattern = pattern;
    }
    
    //다이내믹 프록시를 빈으로 생성해줌
    public Object getObject() throws Exception {
        TxHandler txHandler = new TxHandler(UserServiceImpl, txManager, pattern);
        return Proxy.newProxyInstance(getClass().getClassLoader(), 
        			new Class[] { serviceInterface }, txHanlder);
    }
    
    public Class<?> getObjectType() {
        return serviceInterface;
    }
    
    //getObject()가 매번 같은 오브젝트를 리턴하지 않는다는 의미. 암턴 싱글톤 빈임
    public boolean isSingleton() {
        return false;
    }
}

따라서 다이내믹 프록시는 위의 팩토리 빈 클래스를 스프링 빈으로 등록하면 되고, 팩토리 빈을 가져와서 프록시 클래스를 만들어주도록 한다.

// &을 빈 이름 앞에 붙여주면 팩토리 빈 자체를 반환해준다
TxProxyFactoryBean txProxyFactoryBean = context.getBean("&userService", TxProxyFactoryBean.class);
txProxyFactoryBean.setTarget(usesrServiceImpl);
UserService userServiceTx = (UserService) txProxyFactoryBean.getObject()

코드의 수정 없이 다양한 클래스에 TxProxyFactoryBean을 계속 재사용할 수 있다. 만약에 UserService 외에 다른 클래스에도 트랜잭션이 필요하다면 동일하게 TxProxyFactoryBean을 적용해주면 된다. targetserviceInterface 프로퍼티를 설정에 맞게 넣어주면 된다.

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

앞에서 언급한 프록시 패턴의 단점이 무엇이었는지 다시 기억해보면.. 프록시를 구현해주는 번거로운 작업들. 또한 타깃에 수정 사항이 생길때마다 프록시도 같이 바뀌는 의존성. 부가기능 구현 시의 중복 코드가 있었다.

여기에 다이나믹 프록시를 적용함으로써.. 프록시 클래스를 만들어야하는 번거로움이 사라졌고, 핸들러 메소드를 통해 중복 코드 문제도 해결됐다. 게다가 타깃 오브젝트는 DI 설정으로 활용성이 더 자유로워졌다.

하지만 아직 남은 한계점들도 있는데.. 예를 들어 트랜잭션과 같이 비즈니스 로직을 담은 많은 클래스의 메소드에 적용해야 하는 경우라면 비슷한 프록시 팩토리 빈을 계속 만들어주어야 한다. 프록시 하나에 여러 개의 클래스에 한번에 제공할 수가 없다. 또한 거꾸로 하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때에도 문제가 된다. 이 경우, 프록시 팩토리 빈 설정에 부가기능의 개수만큼 설정을 줄줄이 붙게 될 것이다.

게다가 TxHandler는 타깃 오브젝트를 프로퍼티로 갖기 때문에 동일한 부가기능을 제공해도 타깃 오브젝트가 달라지면 새로운 TxHandler 오브젝트를 만들어주어야 한다. 즉, 프록시 팩토리 빈 개수만큼 만들어지는 것이다.


6.4 스프링의 프록시 팩토리 빈

지금까지 프록시 패턴을 알아보았고, 여기에 자바의 리플렉션을 적용하여 다이내믹 프록시, 그리고 이를 빈으로 관리하는 프록시 팩토리 빈까지 알아보았다. 그럼 스프링에서 제공하는 ProxyFactoryBean은 어떨까?

스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다. 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록까지 해주는 팩토리 빈인데, 순수하게 프록시를 생성해주는 작업만을 담당한다.

프록시를 통해 제공한 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptorinvoke() 메소드는 타깃 오브젝트에 대해 몰라도 된다. InvocationHandler에서는 이를 구현한 클래스가 타깃을 직접 주입받아서 알고 있어야 했는데 MethodInteceptor는 파라미터로 받는 MethodInvocation가 타깃 오브젝트를 알고 있기 때문이다.

## InvocationHandler는 리플렉션 Method를 받기 때문에 타깃 오브젝트의 정보가 필요함
public class UppercaseHandler implements InvocationHandler {
    private Hello target;
    public UppsercaseHandler(Hello target) {
        this.target = target;
    }
    
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String result = (String)method.invoke(target, args);
        return result.toUpperCase();
    }
}

## MethodInterceptor는 타깃 오브젝트를 모름
public class UppercaseAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String result = (String)invocation.proceed();
        return result.toUpperCase();
    }
}

ProxyFactoryBean을 사용하는 예시는 아래와 같다.

ProxyFactoryBean pfBean = new ProxyFactoryBean();
pfBean.setTarget(new HelloImpl());
pfBean.addAdvice(new UppercaseAdvice());

Hello HelloProxy = (Hello)pfBean.getObject();

다시 한번 강조하지만, MethodInterceptor 인터페이스의 구현체에는 타깃 오브젝트가 등장하지 않는다. 이 때문에 타깃이 다른 여러 프록시에 적용이 가능하고 이는 addAdvice()라는 메소드 명에서도 그 특징이 나타난다. (MethodInterceptorAdvice 인터페이스를 상속하고 있는 서브 인터페이스이다.) MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 한다.

ProxyFactoryBean 하나만으로 여러 개의 부가기능을 제공하는 프록시를 새로운 프록시 클래스와 프록시 팩토리 빈의 추가 없이도 만드는 것이 가능하다. 따라서 앞에서 언급한 프록시 팩토리 빈의 단점 중의 하나였던.. 새로운 부가기능이 추가할 때마다 프록시와 프록시 팩토리빈을 추가해주어야 하는 문제점이 해결되었다.

또한 JDK의 다이내믹 프록시와 달리 프링의 ProxyFactoryBean은 프록시가 받아와야할 인터페이스 또한 제공하지 않는다. ProxyFactoryBean에 있는 인터페이스 자동검출 기능을 사용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낼 수 있기 때문이다. 이 외에도 ProxyFactoryBean는 프록시를 구현하고 빈으로 등록해서 사용하는 데 필요한 다양한 기능들을 제공한다.

게다가 InvocationHandler는 타깃 오브젝트 뿐아니라 부가기능을 적용할 대상 메소드를 선정하는 부분까지 맡고 있었다. 때문에 타깃이 다르거나 메소드 선정 방식이 바뀌면 InvocationHandler 오브젝트를 재사용할 수 없는 문제가 있었다. 그러나 MethodInterceptor는 부가기능과 메소드 선정 기능이 구분되어 보다 유연한 구조를 제공한다.


6.5 스프링 AOP

스프링의 ProxyFactoryBean의 어드바이저를 통해 부가기능이 타깃 오브젝트마다 새로 만들어지는 문제를 해결했다. 남은 것은 타깃 오브젝트에 부가기능을 적용해줄 때 빈 설정 정보를 추가하는 부분에서의 중복이다. 이 문제는 어떻게 해결할 수 있을까?

자동 프록시 생성

스프링에는 BeanPostProcessor 인터페이스를 구현해서 만드는 빈 후처리기가 있다. 빈 후처리기는 스프링 빈 오브젝트로 만들어지고 난 후에 빈 오브젝트를 다시 가공할 수 있게 해준다.

스프링의 빈 후처리기 중의 하나인 DefaultAdvisorAutoProxyCreator는 어드바이저를 이용한 자동 프록시 생성기이다. 이 빈 후처리기를 빈으로 등록해두면, 스프링이 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 그리고 이를 이용하면 자동 프록시 생성 빈 후처리기를 만들 수 있다.

빈 후처리기를 통해 프록시가 자동으로 생성되는 과정은 다음과 같다.

빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 이 후처리기로 보낸다. 그럼 빈 후처리기는 빈으로 등록되어 있는 모든 어드바이저 내의 포인트컷을 기반으로 해당 빈이 프록시 적용 대상인지를 확인한다. 적용 대상일 경우 프록시 생성기에게 해당 빈에 대한 프록시를 만들도록 어드바이저를 연결해준다. 프록시가 생성되면.. 빈 후처리기는 컨테이너로부터 전달받은 빈 오브젝트 대신 프록시 오브젝트를 반환한다. 최종적으로 컨테이너에 프록시 오브젝트가 빈으로 등록되어 사용되는 것이다.

사실 위에서는 설명한 포인트컷은.. 마치 메소드 명을 기준으로 부가 기능을 적용할지 선정해주는 역할만을 말했지만.. 사실 포인트컷은 메소드 매처 뿐 아니라 클래스 필터의 기능도 가지고 있다.

public interface Pointcut {
    ClassFilter getClassFilter();
    MethodMatcher getMethodMatcher();
}

포인트컷은 먼저 프록시를 적용할 클래스인지를 판단하고 나서, 대상 클래스에서 다시 어드바이스를 적용할 메소드인지를 확인한다.

암턴간 중요한 것은.. 그래서 빈 후처리기를 사용하면 매번 ProxyFactoryBean 빈을 등록하지 않아도 자동으로 프록시가 생성되어 적용될 수 있다.

AOP란 무엇인가?

지금까지 했던 작업들을 다시 정리해보면..

  1. 트랜잭션 서비스 추상화
    비즈니스 로직에 트랜잭션 경계설정 코드를 넣으면서.. 코드가 특정 트랜잭션 기술에 종속되어 버리는 문제가 있었다.
    -> 서비스 추상화 기법을 적용하여 UserService 인터페이스를 만들고, 트랜잭션 기능을 담당할 클래스와 서비스 로직 구현체가 이 인터페이스를 받아오는 구조를 만들었다. 인터페이스DI. 두 개의 핵심 키워드로 비즈니스 로직에서 무엇을 하는지를 남기고.. 어떻게 하는지를 분리해낸 것이다.

  2. 프록시와 데코레이터 패턴
    추상화를 통해 비즈니스 로직에서 어떤 트랜잭션을 연결할지는 분리해냈지만, 여전히 트랜잭션 코드를 완전히 분리시키지는 못하는 문제가 있었다.
    -> 이 때문에 클라이언트와 서비스로직 사이에 프록시 두는 프록시 패턴을 적용하여.. 트랜잭션을 처리하는 기능은 프록시(일종의 데코레이터)에 담아서 처리하도록 하여 완전히 분리시킬 수 있었다.

  3. 다이내믹 프록시와 프록시 팩토리 빈
    비즈니스 코드와 트랜잭션 코드를 완전히 분리하는 데에는 성공했지만 프록시 패턴을 적용하면서 또 하나의 문제가 발생했었다. 부가 기능이 필요할 때마다 매번 프록시 클래스를 만들어주어야 한다는 중복의 문제였다.
    -> JDK 다이내믹 프록시 기술을 적용하여 프록시 클래스가 없이도 런타임시에 다이나믹하게 만들어주는 방식이 소개되었다.
    -> 그러나 프록시 하나로 여러 개의 클래스에 한번에 적용하지 못한다는 점. 하나의 타깃에 여러 개의 부가기능을 적용하고 싶은 경우 부가 기능만큼 프록시 팩토리 빈을 설정해야 한다는 점 등의 문제가 있었다.
    -> 때문에 스프링의 프록시 팩토리 빈을 이용하여.. 프록시로부터 어드바이저와 분리해내고 여러 프록시에서 공유할 수 있도록 하였다.

  4. 자동 프록시 생성 방법과 포인트컷
    그리고 마지막으로 트랜잭션 기능을 적용할 빈마다 프록시 팩토리 빈을 설정해주어야 했던 문제를 스프링 컨테이너의 빈 생성 후처리 기법을 적용하여 설정을 수작업으로.. 일일이 지정해주지 않아도 포인트컷 표현식을 이용해 자동으로 선정될 수 있도록 하였다.

이렇게 복잡한 과정을 거쳤던 이유는 트랜잭션 기능이 모듈화를 하기가 쉽지 않기 때문이다. 트랜잭션 기능은 부가적인 기능이기에 완전히 독립적인 방식으로 존재할 수 없고 기능을 부가할 타깃이 있어야 존재의 의미가 생긴다. 어쩔 수 없이 타깃과 긴밀한 관계를 맺게 되는 것이다.

이러한 부가기능을 독립적인 모듈로 만드는 기술을 다시 정리하면 DI, 데코레이터 패턴, 다이내믹 프록시, 오브젝트 생성 후처리, 자동 프록시 생성, 포인트컷이 있었다. 결국은 이러한 모든 작업이 트랜잭션과 같이 핵심 기능과 긴밀한 관계가 형성되는 부가 기능을 어떻게 효과적으로 모듈화하는가에 대한 과정이었다고 정리할 수 있다.

전통적인 객체지향 기술의 설계 방법으로는 독립적인 모듈화가 불가능한 트랜잭션 경계설정과 같은 부가기능을 어떻게 모듈화할 것인가를 연구해온 사람들은, 이 부가기능 모듈화 작업은 기존의 객체지향 설계 패러다임과는 구분되는 새로운 특성이 있다고 생각했다. 그래서 이런 부가기능 모듈을 객체지향 기술에서 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르기 시작했다. 그것이 바로 애스팩트이다. -p504

애스팩트(aspect)란 그 자체로 애플리케이션의 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다. (중략) 애스펙트는 그 단어의 의미대로 애플리케이션을 구성하는 한 가지 측면이라고 생각할 수 있다. -p504

애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스팩트 지향 프로그래밍(Aspect Oriented Programming) 또는 약자로 AOP라고 부른다. -p505

AOP는 OOP를 돕는 보조적인 기술이지 OOP를 완전히 대체하는 새로운 개념은 아니다. -p505

AOP는 결국 애플리케이션을 다양한 측면에서 독립적으로 모델링하고, 설계하고, 개발할 수 있도록 만들어주는 것이다. 그래서 애플리케이션을 다양한 관점에서 바라보며 개발할 수 있게 도와준다. 애플리케이션을 사용자 관리라는 핵심 로직 대신 트랜잭션 경계설정이라는 관점에서 바라보고 그 부분에 집중해서 설계하고 개발할 수 있게 된다는 뜻이다. (중략) 이렇게 애플리케이션을 특정한 관점을 기준으로 바라볼 수 있게 해준다는 의미에서 AOP를 관점 지향 프로그래밍이라고도 한다. -p506

AOP 네임스페이스

앞에서 적용했던 스프링의 프록시 방식 AOP를 적용하려면 자동 프록시 생성기, 어드바이저(어드바이스와 포인트컷)이 빈으로 필수적으로 등록되어야 한다. 스프링에서는 이렇게 AOP를 위해 기계적으로 적용하는 빈들을 간편하게 등록하는 방법인 aop 스키마를 제공한다.

<aop:config>
  <aop:pointcut id-"txPointcut" expression="execution(* *..*ServiceImpl.upgreade*(..))" />
  <aop:advisor advice-ref="txAdvice" pointcut-ref="txPointcut" />
</aop:config>

따라서 위의 예시와 같이 <aop:config>, <aop:pointcut>, <aop:advisor> 세 가지 태그를 정의해두면 그에 따라 세 개의 빈이 자동으로 등록될 수 있다.


6.6 트랜잭션 속성

사실 트랜잭션이라고 모두 같은 방식으로 동작하지는 않는다. 트랜잭션 기능 자체도 동작 방식을 좀 더 세분하게 제어할 수 있는데 그러한 영역을 담당하는 클래스가 바로 아래의 DefaultTransactionDefinition이다.

TransactionStatus status = txManager.getTransacion(new DefaultTransactionDefinition());

트랜잭션 정의

DefaultTransactionDefinitionTransactionDefinition 인터페이스의 구현체로 트랜잭션의 동작 방식의 네 가지 속성을 정의한다. 만약 다음의 속성을 변경하려면, 기본 속성인 DefaultTransactionDefinition대신 TransactionDefinition을 새로 정의하여 DI 받으면 된다.

  1. 트랜잭션 전파
    트랜잭션 전파(transaction propagation)란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정한다. 각각 독립적인 트랜잭션 경계를 가진 두 개의 코드가 있을 때 A의 트랜잭션이 시작되고 아직 끝나지 않은 시점에서 B를 호출한다면 B의 코드는 어떤 트랜잭션 안에서 동작해야 하는가? 이와 같이 독자적인 트랜잭션 경계를 가진 코드에 대해 이미 진행 중이 트랜잭션이 어떻게 영향을 줄 것인가 대해 다음과 같이 전파 속성을 정할 수 있다.
    - PROPAGATION_REQUIRED
    진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작한 트랜잭션이 있으면 이에 참여함.
    트랜잭션 경계의 끝에서 트랜잭션을 커밋하지 않고, 이전 트랜잭션이 정상적으로 완료되어야 커밋이 이루어짐.
    - PROPAGATION_REQUIRES_NEW
    기존에 시작한 트랜잭션이 있든 없든, 항상 새로운 트랜잭션을 만들어서 독자적으로 동작함
    - PROPAGATION_NOT_SUPPORTED
    트랜잭션 없이 동작함
  1. 격리수준
    가능한 한 많은 트랜잭션을 동시에 진행시키면서도 문제가 발생하지 않도록 제어한다. 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서도 재설정할 수 있고, 트랜잭션 단위로도 조정이 가능한다.

  2. 제한시간
    트랜잭션을 수행하는 제한 시간을 설정한다.

  3. 읽기전용
    읽기 전용의 트랜잭션 내에서는 데이터 조작을 불가하게 한다.

트랜잭션 인터셉터와 트랜잭션 속성

만약 메소드별로 다른 트랜잭션 정의를 적용하려면 어드바이스의 기능을 확장해야 한다. 이 때는 TransactionInterceptor 어드바이스를 사용하는데 트랜잭션 정의를 메소드 이름 패턴을 이용해서 다르게 지정할 수 있다.


6.7 애노테이션 트랜잭션 속성과 포인트컷

지금까지 살펴본 내용은 트랜잭션을 일괄적으로 적용하는 방식에 대한 내용이었다. 만약 클래스나 메소드에 따라 좀 더 세밀하게 트랜잭션을 적용하려면 @Transactional 어노테이션을 사용할 수 있다.

@Transactional
public interface UserService {
    void add(User user);
    void upgradeLevels();
    void update(User user);
    
    @Transactional(readOnly=true)
    User get(String id);
    
    @Transactional(readOnly=true)
    List<User> getAll();
}

트랜잭션 어노테이션은 메소드, 클래스, 인터페이스에 적용이 가능하고, @Transactional 애노테이션을 트랜잭션 속성 정보로 사용하도록 지정하면 @Transactional이 부여된 모든 오브젝트가 타깃으로 인식된다.

트랜잭션이 적용되는 데에도 4단계의 우선 순위(대체 정책)가 존재한다. 트랜잭션을 적용할 메소드의 속성을 확인할 때, 타깃 메소드 > 타깃 클래스 > 선언 메소드 > 선언 타입(클래스 or 인터페이스)의 순서에 따라 진행된다는 것이다. 이 순서에 따라서 트랜잭션 애노테이션이 적용됐는지를 확인하고 적용 범위가 정해진다.


6.8 트랜잭션 지원 테스트

AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여하고 속성을 지정할 수 있게 하는 방법을 선언적 트랜잭션(declarative transaction)이라고 한다. 반대로 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 적용하는 방법은 프로그램에 의한 트랜잭션(programmatic transaction)이라고 한다.

프로그램에 의한 트랜잭션 적용은 다음과 같다. 예를 들어 다음의 테스트 코드를 실행한다고 하면 만들어지는 트랜잭션은 3개가 될 것이다.

@Test
public void txSyncTest() {
    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));
}

만약 위의 코드를 하나의 트랜잭션으로 묶어서 실행하고 싶다면? 세 개의 메소드 모두 트랜잭션 전파 속성이 REQUIRED이므로 UserService의 메소드가 실행되기 전에 트랜잭션을 미리 시작해주면 된다.

@Test
public void txSyncTest() {
    DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
    TransactionStatus txStatus = txManager.getTransaction(txDefinition);

    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));
    
    txManager.commit(txStatus);
}

만약 위와 같이 검증을 위한 테스트라면 마지막에 커밋을 하는 대신 롤백으로 처리해도 괜찮다. 이렇게 테스트 수행 후 롤백해버리는 테스트를 롤백 테스트라고 하는데, DB 작업이 포함된 테스트의 경우 테스트를 수행해도 DB에 영향을 주지 않게 해주는 장점이 있다.

테스트를 위한 트랜잭션 애노테이션

앞에서 설명한 @Transactional 애노테이션 테스트 코드에도 동일하게 사용할 수 있다. 다만 테스트에 적용되는 @Trasnactional은 기본적으로 강제 롤백시키도록 설정되어 있다. 만약 롤백을 원하지 않는다면 @Rollback 어노테이션을 붙여주면 된다.

이 때 @Rollback 애노테이션은 메소드 레벨에만 적용되는데 만약 테스트 클래스의 모든 메소드에 트랜잭션을 적용하려 할 때는 @TransactionConfiguration을 사용하면 롤백에 대한 공통 속성을 지정할 수 있다. 그래서 만약 전체 공통 속성은 롤백 시키지 않고 특정 메소드만 롤백을 적용하고 싶다면 아래와 같이 설정할 수 있다.

@Transactional
@TransactionConfiguration(defaultRollbaco=false)
public class UserServiceTest {
    @Test
    @Rollback
    public void enableRollbackTest() { }
}

마지막으로 테스트 클래스 안에서 특정 메소드만을 트랜잭션을 시작하지 않은 채로 시작을 하고 싶을 때는 @Transactional(propatation=Propagation.NEVER)를 사용할 수 있다.


"개인적으로 공부하면서 정리한 자료입니다. 오타와 잘못된 내용이 있을 수 있습니다."

profile
개발은 즐겁게🎶

0개의 댓글