티스토리 뷰

6. AOP

AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술의 하나다. AOP를 바르게 이용하려면 OOP를 대체하려고 하는 것처럼 보이는 AOP라는 이름 뒤에 감춰진, 그 필연적인 등장배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지에 대한 충분한 이해가 필요하다.

스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔끔한 방식으로 바꿔보자.

트랜잭션 코드의 분리

메소드 분리

public void upgradeLevels() throws Exception {
    TransactionStatus status = this.transactionManager
            .getTransaction(new DefaultTransactionDefinition());
    try {
        List<User> users = userDao.getAll();
        for(User user : users) {
            if(canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }

        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는 듯이 보이지만, 자세히 살펴보면 뚜렷하게 두 가지 종류의 코드가 구분되어 있음을 알 수 있다.

또한, 두 가지 코드 간에 서로 주고받는 정보가 없기 때문에 완벽하게 독립된 코드라고 할 수 있다.

그렇기 때문에, 아래와 같이 성격이 다른 코드를 두 개의 메소드로 분리할 수 있다.

public void upgradeLevels() throws Exception {
    TransactionStatus status = this.transactionManager
            .getTransaction(new DefaultTransactionDefinition());
    try {
        upgradeLevelsInternal();

        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

private void upgradeLevelsInternal() {
    List<User> users = userDao.getAll();
    for(User user : users) {
        if(canUpgradeLevel(user)) {
            upgradeLevel(user);
        }
    }
}

DI를 이용한 클래스의 분리

비즈니스 로직을 담당하는 코드를 깔끔하게 분리하니 보기 좋긴 하다. 그렇지만 여전히 트랜잭션을 담당하는 기술적인 코드가 버젓이 UserService 안에 자리 잡고 있다.

UserService에서는 보이지 않게 트랜잭션 코드를 클래스 밖으로 뽑아내 보자.

DI 적용을 이용한 트랜잭션 분리

현재 UserService 클래스와 그 사용 클라이언트 간의 관계가 강한 결합도로 고정되어 있다 이 사이를 비집고 다른 무엇인가를 추가하기는 힘들다.

그래서 UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들도록 한다. 그러면 결함이 약해지고, 직접 구현 클래스에 의존하고 있지 않기 때문에 유연한 확장이 가능해진다.

이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이유는, 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서다.

보통 한 번에 한 가지 클래스를 선택해서 사용하는데 꼭 그래야 한다는 제약은 없다. 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 이용한다면 어떨까? 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내는 것이다. 하지만 클라이언트가 UserService의 기능을 제대로 이용하려면 트랜잭션이 적용돼야 한다.

그래서 트랜잭션을 경계설정의 책임을 맡을 UserService를 구현한 또 다른 구현클래스를 만들어 줄 것이다.

UserService 인터페이스 도입

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

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

UserService 인터페이스의 구현 클래스인 UserServiceImpl은 기존 UserService 클래스의 내용을 대부분 유지하면 된다. 단, 트랜잭션과 관련된 코드는 독립시키기로 했으니 모두 제거해도 좋다.

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

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

    ...
}

분리된 트랜잭션 기능

이제 비즈니스 트랜잭션 처리를 담은 UserServiceTx를 만들어보자.

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

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

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

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

    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;
        }
    }
}

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

  • 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경쓰지 않아도 된다.

  • 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.

고립된 단위 테스트

가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다.

작은 단위의 테스트가 좋은 이유는 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문이다. 반대로 테스트에서 오류가 발견됐을 때 그 테스트가 진행되는 동안 실행된 코드의 양이 많다면 그 원인을 찾기가 매우 힘들어질 수 있다. 또한 테스트 단위가 작아야 테스트의 의도나 내용이 분명해지고, 만들기도 쉬워진다. 테스트할 대상이 크고 복잡하면 테스트를 만들기도 그만큼 어렵고, 만들었다 해도 충분하지 못할 수 있다.

단위 테스트와 통합 테스트

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

  • 통합 테스트 : 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트

목 프레임워크

단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용이 필수적이다. 의존관계가 없는 단순한 클래스나 세부 로직을 검증하기 위해 메소드 단위로 테스트할 때가 아니라면, 대부분 의존 오브젝트를 필요로 하는 코드를 테스트하게 되기 때문이다.

목 오브젝트를 만드는 일은 번거로울 수 있다. 그러나 이런 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다.

Mockito 프레임워크

Mockito라는 프레임워크는 사용하기도 편리하고, 코드도 직관직이라 최근 많은 인기를 끌고 있다.

Mockito와 같은 목 프레임워크의 특징은 목 클래스를 일일이 준비해둘 필요가 없다는 것이다. 간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.

USerDao 인터페이스를 구현한 테스트용 목 오브젝트는 다음과 같이 Mockito의 스태틱 메소드를 한 번 호출해주면 만들어진다.

UserDao mockUserDao = mock(UserDao.class);

이렇게 만들어진 목 오브젝트는 아직 아무런 기능이 없다. 여기에 먼저 getAll() 메소드가 불려올 때 사용자 목록을 리턴하도록 스텁 기능을 추가해줘야 한다.

when(mockUserDao.getAll()).thenReturn(this.users);

mockUserDao.getAll()이 호출됐을 때(when), users 리스트를 리턴해주라(thenReturn)는 선언이다.

Mocktio를 통해 만들어진 목 오브젝트는 메소드의 호출과 관련된 모든 내용을 자동으로 저장해두고, 이를 간단한 메소드로 검증할 수 있게 해준다.

테스트를 진행하는 동안 mockUserDao의 update() 메소드가 두 번 호출됐는지 확인하고 싶다면, 다음과 같이 검증 코드를 넣어주면 된다.

verify(mockUserDao, times(2)).update(any(User.class));

User 타입의 오브젝트를 파라미터로 받으며 update() 메소드가 두 번 호출됐는지(times(2)) 확인하라(verify)는 것이다.

Mockito 목 오브젝트는 다음의 네 단계를 거쳐서 사용하면 된다. 두 번째와 네 번째는 각각 필요할 경우에만 사용할 수 있다.

  • 인터페이스를 이용해 목 오브젝트를 만든다.

  • 목 오브젝트가 리턴할 값이 있으면 이를 지정해준다. 메소드가 호출되면 예외를 강제로 던지게 만들 수도 있다.

  • 테스트 대상 오브젝트에 DI 해서 목 오브젝트가 테스트 중에 사용되도록 만든다.

  • 테스트 대상 오브젝트를 사용한 후에 목 오브젝트의 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지를 검증한다.

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

프록시와 프록시 패턴, 데코레이터 패턴

데코레이터 패턴

데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.

데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다. 같은 인터페이스를 구현한 타깃과 여러 개의 프록시를 사용할 수 있다. 프록시가 여러 개인 만큼 순서를 정해서 단계적으로 위임하는 구조로 만들면 된다.

프록시 패턴

프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우이다.

프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다. 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않은 경우에는 꼭 필요한 시점까지 오브젝트를 생성하지 않는 편이 좋다. 그런데 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있다. 이때 프록시 패턴을 적용해 클라이언트에게 실제 타깃 오브젝트를 생성하는 대신 프록시를 넘겨줄 수 있다. 그리고 프록시의 메소드를 통해 타깃을 사용하려고 시도하면, 그때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해주는 식이다. 이렇게 프록시를 통해 생성을 최대한 늦춤으로써 얻는 장점이 많다.

구조적으로 보자면 프록시와 데코레이터는 유사하다. 다만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 하기 때문이다.

다이내믹 프록시

프록시는 매번 새로운 클래스를 정의해야 하고, 인터페이스의 구현해야 할 메소드를 일일이 구현해서 위임하는 코드를 넣어야하기 때문에 번거롭다. 하지만 자바에는 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들 수 있도록 지원해주는 클래스들이 있다.

프록시의 구성과 프록시 작성의 문제점

프록시는 다음의 두가지 기능으로 구성된다.

  • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.

  • 지정된 요청에 대해서는 부가기능을 수행한다.

public class UserServiceTx implements UserService {
    UserService userService; //타깃 오브젝트
    ...

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

    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;
        }
    }
}

위의 코드에서 UserServiceTx 코드는 UserService 인터페이스를 구현하고 타깃으로 요청을 위임하는 트랜잭션 부가기능을 수행하는 코드로 구분할 수 있다. 이렇게 프록시의 역할은 위임, 부가작업이라는 두 가지 기능으로 구분할 수 있다. 프록시가 번거로운 이유는 다음과 같다.

  • 첫 번째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다. 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다. 복잡하진 않지만 인터페이스의 메소드가 많아지거나 변경될 때마다 수정해줘야 하는, 상당히 부담스러운 작업이 될 수 있다.

  • 두 번째는 부가기능 코드가 중복될 가능성이 많다는 점이다. 하나의 부가기능을 여러 개의 메소드에 중복으로 적용할 가능성이 높다. 메소드의 개수가 많다면 상황이 더욱 심각할 것이다.

바로 이런 문제를 해결하는 데 유용한 것이 바로 JDK의 다이내믹 프록시다. 다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.

리플렉션

이러한 문제들을 해결하기 위해 유용한 것이 다이내믹 프록시이다. 다이내믹 프록시는 리플랙션 기능을 이용해서 프록시를 만들어준다.

리플랙션(java.lang.reflect): 자바의 코드 자체를 추상화해서 접근하도록 만든 것

String name = "Spring";

위의 코드에서 길이를 알고 싶으면 name.length()를 호출하면 된다. 자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 class 타입의 오브젝트를 하나씩 갖고 있다. 클래스이름.class, getClass() 메소드를 호출하면 클래스 정보를 담은 Class 타입의 오브젝트를 가져올 수 있다. 클래스 오브젝트를 이용하면 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다.

클래스의 이름이 무엇이고, 어떤 클래스를 상속하고, 어떤 인터페이스를 구현했는지, 어떤 필드를 갖고 있는지, 각각의 타입이 무엇인지, 메소드가 어떤게 있는지 등 더 나아가 오브젝트 필드의 값을 읽고 수정할 수도 있고, 원하는 파라미터 값을 이용해 메소드를 호출할 수도 있다.

Method langthMethod = String.class.getMethod("length");
//length 메소드를 가져와 invoke로 실행시키기
int length = lengthMethod.invoke(name) //int length = name.length();

프록시 클래스

다이내믹 프록시를 이용한 프록시를 만들어보자. 프록시를 적용할 간단한 타깃 클래스와 인터페이스를 다음과 같이 정의한다.

interface Hello {
    String sayHello(String name);
    String sayHi(String name);
    String sayThankYou(String name);
}
public class HelloTarget implements Hello {
    public String sayHello(String name) {
        return "Hello " + name;
    }

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

    public String sayThankYou(String name) {
        return "Thank You " + name;
    }
}

이제 Hello 인터페이스를 구현할 프록시를 만들어보자. 프록시에는 데코레이터 패턴을 적용해서 타깃인 HelloTarget에 리턴하는 문자를 모두 대문자로 바꿔주는 부가기능을 추가하겠다.

다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어준다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문에 사용자가 클래스를 정의하는 수고를 덜 수 있다.

다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 작성해야 한다. 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler는 다음과 같은 메소드 한 개만 가진 간단한 인터페이스다.

public Object invoke(Object proxy, Method method, Object[] args);

invoke() 메소드는 리플렉션의 Method 인터페이스를 파라미터로 받는다. 메소드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.

먼저 다이내믹 프록시로부터 메소드 호출 정보를 받아서 처리하는 InvocationHandler를 만들어보자. 다음은 모든 요청을 타깃에 위임하면서 리턴 값을 대문자로 바꿔주는 부가기능을 가진 InvocationHandler 구현 클래스다.

public class UppercaseHandler implements InvocationHandler {
    Hello target;

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

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String ret = (String) method.invoke(target, args); // 타깃으로 위임한다. 모든 인터페이스의 메소드 호출에 적용된다.
        return ret.toUpperCase(); // 부가기능 제공
    }
}

다이내믹 프록시가 클라이언트로부터 받는 모든 요청은 invoke() 메소드로 전달된다. 다이내믹 프록시를 통해 요청이 전달되면 리플렉션 API를 이용해 타깃 오브젝트의 메소드를 호출한다. 타깃 오브젝트는 생성자를 통해 미리 전달받아 둔다.

이제 이 InvocationHandler를 사용하고 Hello 인터페이스를 구현하는 프록시를 만들어보자. 다이내믹 프록시의 생성은 Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메소드를 이용하면 된다.

Hello proxiedHello = (Hello) Proxy.newProxyInstance(
        getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
        new Class[] {Hello.class}, // 구현할 인터페이스
        new UppercaseHandler(new HelloTarget())); // 부가기능과 위임 코드를 담은 InvocationHandler

이렇게 복잡한 다이내믹 프록시 생성이 과연 기존의 수동적인 프록시 생성 방식보다 나은 점은 무엇일까?

먼저, 인터페이스의 메소드가 늘어나도 걱정할 필요가 없다. 모든 메소드는 InvocationHandler의 invoke 메소드를 거치게 되어 있다. 다만 지금은 String 이외의 타입이 리턴된다면 문제가 생길 수 있으므로 주의해서 수정해보자. 또다른 장점은 타깃의 종류에 상관없이도 적용이 가능하다는 것이다. 어차피 리플렉션의 Method 인터페이스를 이용해 타깃의 메소드를 호출하는 것이니 Hello 타입의 타깃으로 제한할 필요도 없다.

어떤 종류의 인터페이스를 구현한 타깃이든 상관없이 재사용할 수 있고, 메소드의 리턴 타입이 스트링인 경우만 대문자로 결과를 바꿔주도록 UppercaseHandler를 만들 수 있다. 뿐만 아니라 메소드의 이름도 조건으로 걸 수 있다.

public class UppercaseHandler implements InvocationHandler {
    Object target;

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

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args);
        if (ret instanceof String && method.getName().startsWith("say")) { // 리턴 타입과 메소드 이름이 일치하는 경우에만 부가기능 적용
            return ((String) ret).toUpperCase();
        }
        return ret;
    }
}

이제 UserServiceTx를 다이내믹 프록시 방식으로 변경해보자.

public class TransactionHandler implements InvocationHandler {
    private Object target; // 부가기능을 제공할 타깃 오브젝트
    private PlatformTransactionManager transactionManager; // 트랜잭션 기능을 제공하는 데 필요한 트랜잭션 매니저
    private String pattern; // 트랜잭션을 적용할 메소드 이름 패턴

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

    // setter
}

테스트 쪽에서는 다음과 같이 사용할 수 있다.

@Test
public void upgradeAllOrNothing() throws Exception {
    // ...
    TransactionHandler txHandler = new TransactionHandler();
    txHandler.setTarget(testUserService);
    txHandler.setTransactionManager(transactionManager);
    txHandler.setPattern("upgradeLevels");

    UserService txUserService = (UserService) Proxy.newProxyInstance(
        getClass().getClassLoader(), new Class[] { UserService.class }, txHandler);
    // ...
}

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

앞서서 어떤 타깃에도 적용 가능한 트랜잭션 부가기능을 담은 TransactionHandler를 만들어봤다. 이제 TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 수 있도록 만들어야 할 차례다.

그런데 문제는 DI의 대상이 되는 다이내믹 프록시 오브젝트는 일반적인 스프링의 빈으로는 등록할 방법이 없다는 점이다. 스프링은 지정된 클래스 이름을 가지고 리플렉션을 이용해서 해당 클래스의 오브젝트를 만든다. 하지만 다이내믹 프록시 오브젝트는 스프링의 빈에 정의할 방법이 없다. 오브젝트의 클래스가 어떤 것인지 알 수도 없고, 클래스 자체도 내부적으로 다이내믹하게 새로 정의해서 사용하기 때문이다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.

사실 스프링은 클래스 정보를 가지고 디폴트 생성자를 통해 오브젝트를 만드는 방법 외에도 빈을 만들 수 있는 여러 가지 방법을 제공한다. 대표적으로 팩토리 빈을 이용한 빈 생성 방법을 들 수 있다. 팩토리 빈이란 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다. 팩토리 빈을 만드는 가장 간단한 방법은 스프링의 FactoryBean이라는 인터페이스를 구현하는 것이다.

package org.springframework.beans.factory;
public interface FactoryBean<T> {
    T getObject() throws Exception; // 빈 오브젝트를 생성해서 돌려준다.
    Class<? extends T> getObjectType(); // 생성되는 오브젝트의 타입을 알려준다.
    boolean isSingleTon(); // getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
}

팩토리 빈을 사용하면 getObjectType() 메소드가 돌려주는 타입으로 빈을 생성할 수 있다. 생성할 때는 getObject() 메소드로 빈을 생성한다. 트랜잭션 프록시 팩토리 빈을 만들어보자.

public class TxProxyFactoryBean implements FactoryBean<Object> { // 범용적으로 사용하기 위해 Object로 지정했다.
    Object target;
    PlatformTransactionManager transactionManager;
    String pattern;
    Class<?> serviceInterface; // 다이내믹 프록시를 생성할 때 필요하다.

    public Object getObject() throws Exception {
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(target);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern(pattern);

        return Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { serviceInterface }, txHandler);
    }

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

    public boolean isSingleTon() {
        return false; // 싱글톤 빈이 아니라는 뜻이 아니라 getObject()가 매번 같은 오브젝트를 리턴하지 않는다는 뜻이다.
    }

    // setter
}

이러한 프록시 팩토리 빈 방식의 장점과 한계를 정리해보자.

앞에서 데코레이터 패턴이 적용된 프록시를 사용하면 많은 장점이 있음에도 적극적으로 활용되지 못하는 데는 두 가지 문제점이 있다고 설명했다. 첫째는 프록시를 적용할 대상이 구현하고 있는 인터페이스를 구현하는, 프록시 클래스를 일일이 만들어야 한다는 번거로움이고, 둘째는 부가적인 기능이 여러 메소드에 반복적으로 나타나게 돼서 코드 중복의 문제가 발생한다는 것이다.

프록시 팩토리 빈은 이 두 가지 문제를 해결해준다. 다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있다. 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다.

하나의 클래스 안에 존재하는 여러 개의 메소드에 부가기능을 한 번에 제공하는 건 어렵지 않게 가능했지만, 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능하다. 트랜잭션과 같이 비즈니스 로직을 담은 많은 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 중복되는 것을 막을 수 없다.

하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다. 여러 개의 부가기능을 수백 개의 클래스에 적용한다고 했을 때의 설정 파일은 어마무시하게 복잡해진다.

또 한 가지 문제점은 TransactionHandler의 타깃 오브젝트가 변경될 때마다 새로운 오브젝트를 만들어야 한다는 점이다.

스프링의 프록시 팩토리 빈

ProxyFactoryBean

스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다. 생성된 프록시는 스프링의 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다.

스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 재공해줄 부가기능은 별도의 빈에 둘 수 있다.

ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptorinvoke() 메소드가 InvocationHandlerinvoke()와 다른 점은 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다는 것이다. 그 차이 덕분에 MethodInterceptor는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있고, 타깃이 다른 여러 프록시에서 함께 사용할 수 있다. 싱글톤 빈으로도 등록이 가능하다.

어드바이스: 타깃이 필요 없는 순사한 부가기능

InvocationHandler를 구현했을 때와 달리 MethodInterceptor를 구현한 UppercaseAdvice에는 타깃 오브젝트가 등장하지 않는다. MethodInterceptor로는 메소드 정보와 함께 타깃 오브젝트가 담긴 MethodInvocation 오브젝트가 전달된다. MethodInvocation은 타깃 오브젝트의 메소드를 실행할 수 있는 기능이 있기 때문에 MethodInterceptor는 부가기능을 제공하는 데만 집중할 수 있다.

MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. 그렇다면 MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것이다. 바로 이 점이 JDK의 다이내믹 프록시를 직접 사용하는 코드와 스프링이 제공해주는 프록시 추상화 기능인 ProxyFactoryBean을 사용하는 코드의 가장 큰 차이점이자 ProxyFactoryBean의 장점이다. ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다.

ProxyFactoryBean에 MethodInterceptor를 설정해줄 때는 addAdvice()라는 메소드를 사용하는데, 이는 여러 개의 MethodInterceptor를 추가할 수 있다는 의미를 담고 있다. ProxyFactoryBean 하나만으로 여러 개의 부가기능을 제공해주는 프록시를 만들 수 있다는 뜻이다. 따라서 앞에서 살펴봤던 프록시 팩토리 빈의 단점 중의 하나였던, 새로운 부가기능을 추가할 때마다 프록시와 프록시 팩토리 빈도 추가해줘야 한다는 문제를 해결할 수 있다.

메소드 이름이 addAdvice()인 이유는 MethodInterceptor가 Advice 인터페이스를 상속하고 있는 서브인터페이스이기 때문이다. 이처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트어드바이스(Advice)라고 부른다.

마지막으로 기존 다이내믹 프록시를 생성할 때 필요했던 Hello 인터페이스를 제공해주는 부분이 ProxyFactoryBean을 적용한 부분에서는 보이지 않는 것을 알 수 있다. 물론 ProxyFactoryBean도 setInterfaces() 메소드를 통해서 구현해야 할 인터페이스를 지정할 수 있다. 하지만 인터페이스를 굳이 알려주지 않아도 ProxyFactoryBean에 있는 인터페이스 자동검출 기능을 사용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸다. 그리고 알아낸 인터페이스를 모두 구현하는 프록시를 만들어준다. 인터페이스의 일부만 적용하기를 원한다면 인터페이스 정보를 직접 제공해줘도 된다.

포인트컷: 부가기능 적용 대상 메소드 선정 방법

기존에 InvocationHandler를 직접 구현했을 때 해주던 또 한 가지 작업은, 메소드의 이름을 가지고 부가기능 적용 대상 메소드를 선정하는 것이었다. 그러나 MethodInterceptor는 여러 프록시가 공유해서 사용하고, 그러기 위해 타깃 정보를 갖고 있지 않도록 만들었기 때문에 특정 프록시에만 적용되는 부가기능 적용 메소드 패턴을 넣으면 문제가 될 수 있다. 이런 문제는 어떻게 해결해야 할까?

InvocationHandler에 있던 메소드 선정 알고리즘의 책임을 프록시에게로 넘기자. 대신 프록시에게도 DI로 주입하는 전략 패턴을 사용하자. 스프링의 ProxyFactoryBean 방식은 두 가지 확장 기능인 부가기능(Advice)과 메소드 선정 알고리즘(Pointcut)을 활용하는 유연한 구조를 제공한다.

스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.

프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지를 확인해달라고 요청한다. 포인트컷은 Pointcut 인터페이스를 구현해서 만들면 된다. 프록시는 포인트컷으로부터 부가기능을 적용할 대상 메소드인지 확인받으면, MethodInterceptor 타입의 어드바이스를 호출한다. 어드바이스는 InvocationHandler와 달리 일종의 템플릿 구조로 설계되어 있어 직접 타깃을 호출하지 않고, 타깃의 호출이 필요하면 프록시로부터 전달받은 MethodInvocation 타입 콜백 오브젝트의 proceed() 메소드를 호출해준다.

프록시로부터 어드바이스와 포인트컷을 독립시키고 DI를 사용하게 한 것은 전형적인 전략 패턴 구조다. 덕분에 여러 프록시가 공유해서 사용할 수도 있고, 또 구체적인 부가기능 방식이나 메소드 선정 알고리즘이 바뀌면 구현 클래스만 바꿔서 설정에 넣어주면 된다.

@Test
public void pointcutAdvisor() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget());

    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); //메소드이름을 비교대상으로 선정하는 포인트컷 생성
    pointcut.setMappedName("sayH*"); //이름 비교조건 설정(sayH로 시작하는 모든 메소드 선택)
    pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice())); //포인트컷과 어드바이스를 Advisor로 묶어서 한 번에 추가

    Hello proxiedHello = (Hello) pfBean.getObject();
    assertThat(ProxiedHello.sayHello("Havi"), is("Hello Havi"));
    assertThat(ProxiedHello.sayThankYou("Havi"), is("Thank You Havi")); //적용 안됨
}

포인트컷을 함께 등록할 때는 어드바이스와 포인트컷을 Advisor 타입으로 묶어서 addAdvisor() 메소드를 호출해야 한다. 여러 개의 어드바이스와 포인트컷이 등록될 수 있으므로 조합을 만들어서 등록해야 하기 때문이다. 이렇게 어드바이스포인트컷을 묶은 오브젝트를 어드바이저라고 부른다.

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

어드바이스와 포인트컷의 재사용

ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용된 것이다. 그 덕분에 독립적이며, 여러 프록시가 공유할 수 있는 어드바이스와 포인트컷으로 확장 기능을 분리할 수 있었다.


참고

  • 토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리
공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함