티스토리 뷰

AOP

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

스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다.

트랜잭션 코드를 메소드로 분리

5장의 서비스 추상화 기법을 적용해 스프링이 제공하는 깔끔한 트랜잭션 인터페이스를 썼음에도, 비즈니스 로직이 주인이어야 할 메소드 안에 이름도 길고 무시무시하게 생긴 트랜잭션 코드가 더 많은 자리를 차지하고 있다.

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

얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는 듯이 보이지만, 자세히 살펴보면 뚜렷하게 두 가지 종류의 코드로 나뉘는 것을 알 수 있다. 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다.

이 코드의 특징은 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없다는 점이다. 비즈니스 로직 코드에서 직접 DB를 사용하지 않기 때문에 트랜잭션 준비 과정에서 만들어진 DB 커넥션 정보 등을 직접 참조할 필요가 없기 때문이다. 이 메소드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 활용한다. 따라서 이 두 가지 코드는 성격이 다를 뿐 아니라 서로 주고받는 것도 없는, 완벽하게 독립적인 코드다. 다만 이 비즈니스 로직을 담당하는 코드가 트랜잭션의 시작과 종료 사이에서 수행돼야 한다는 사항만 지켜지면 된다.

따라서 다음과 같이 성격이 다른 코드를 두 개의 메소드로 분리할 수 있다. 분리하고 나면 보기가 한결 깔끔해진다. 비즈니스 로직을 이해하기도 편하고, 수정하기도 쉽다. 실수로 트랜잭션 코드를 건드릴 일도 없어졌다.

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

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

DI를 이용한 클래스의 분리

메소드의 분리로 비즈니스 로직이 깔끔하게 분리되어서 보기가 좋다. 하지만 여전히 기술적인 부분을 담당하는 트랜잭션 관련 코드가 UserDao에 남아 있다. 아예 트랜잭션 코드가 존재하지 않는 것처럼 사라지게 할 수는 없을까?

지금의 구조는 Client인 UserServiceTest에서 UserService를 직접 사용하면서 강한 결합도를 가지고 있는 구조이다. 강한 결합도 탓에 두 클래스 사이에 무엇인가 비집고 들어가기가 어렵다. 트랜잭션 관련 로직을 다른 클래스로 분리하더라도 둘 곳이 없다는 뜻이다. 지금까지 연습해왔던 것처럼, DI를 사용해 UserService를 추상화시키고 결합도를 낮춰보자.

보통 DI를 사용하는 이유는 일반적으로 구현 클래스를 상황에 맞게 변경해가면서 사용하기 위함이다. 테스트 때는 필요에 따라 테스트 구현 클래스를, 비즈니스 로직에서는 실제 비즈니스 로직 구현 클래스를 DI 해주는 방법처럼 한 번에 한 가지 클래스를 선택해서 적용하도록 되어 있다.

하지만 꼭 그래야 한다는 제약은 없다. 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 이용한다면 어떨까? 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내는 것이기 때문에, 같은 인터페이스를 구현한 또 다른 구현 클래스를 만들어 그곳에서 트랜잭션 경계설정의 책임을 담당하게 할 것이다.

먼저 UserService라는 이름을 구현체가 아니라 인터페이스로 정의하고, 실제 구현체는 UserServiceImpl이라는 이름으로 수정해보자. 구현체에서 트랜잭션 코드를 제거하기로 했으니, 비즈니스 로직만 남겨두고 모두 제거하자.

public interface UserService {
    void add(User user);
    void upgradeLevels();
}
public class UserServiceImpl implements UserService {
    UserDao userDao;

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

이제 트랜잭션 처리를 담은 UserServiceTx를 만들어보자. UserServiceTx는 UserService를 구현하게 하고, 같은 인터페이스를 구현한 다른 오브젝트에게 고스란히 작업을 위임하게 만들면 된다. 적어도 비즈니스 로직에 대해서는 UserServiceTx가 아무런 관여도 하지 않는다. 그리고 거기에 트랜잭션 경계설정 책임만 가지도록 하면 된다.

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

    public void setUserService(UserService userService) { // 비즈니스 로직을 담은 userService를 DI 받는다.
        this.userService = userService;
    }

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

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

실제 Client인 UserServiceTest에서는 UserServiceTx에 UserServiceImpl을 주입해서 사용하면 된다.

// ...

UserServiceTx txUserService = new UserServiceTx();
txUserService.setTransactionManager(transactionManager);
txUserService.setUserService(userServiceImpl);

// ...
try {
    txUserService.upgradeLevels();
    // ...

지금까지의 트랜잭션 경계설정 코드의 분리와 DI를 통한 연결은 꽤나 복잡한 작업이었다. 이 수고로 얻을 수 있는 장점은 무엇인가?

첫째는, 이제 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경쓰지 않아도 된다는 것이다. 트랜잭션의 적용이 필요한지도 신경 쓰지 않아도 된다. 스프링의 JDBC나 JTA 같은 로우레벨의 트랜잭션 API는 물론이고 스프링의 트랜잭션 추상화 API조차 필요 없다. 트랜잭션은 DI를 이용해 UserServiceTx와 같은 트랜잭션 기능을 가진 오브젝트가 먼저 실행되도록 만들기만 하면 된다. 따라서 언제든지 트랜잭션을 도입할 수 있다.

두 번째 장점은 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다는 것이다. 다음 섹션에서 알아보자.


단위 테스트와 통합 테스트

고립된 단위 테스트

가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 작은 단위의 테스트가 좋은 이유는 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문이다. 그러나 테스트 대상이 다른 오브젝트와 환경에 의존하고 있다면 작은 단위의 테스트가 주는 장점을 얻기 힘들다.

기존의 UserService는 UserDao를 통해 DB와 데이터를 주고 받고, TransactionManager를 통해 트랜잭션 처리를 한다. UserService를 테스트하는 로직은 UserService를 테스트하는 것처럼 보이지만 사실은 그 뒤에 존재하는 훨씬 더 많은 오브젝트와 환경, 서비스, 서버까지 함께 테스트하는 셈이 된다. 따라서 이런 경우의 테스트는 준비하기 힘들고, 환경이 조금이라도 달라지면 동일한 테스트 결과를 내지 못할 수도 있으며, 수행 속도는 느리고 그에 따라 테스트를 작성하고 실행하는 빈도가 점차로 떨어질 것이 분명하다.

그래서 테스트의 대상이 환경이나, 외부 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 테스트를 의존 대상에서 분리해서 고립시키는 방법은 이전에 적용해봤던 대로 테스트를 위한 대역을 사용하는 것이다.

단위 테스트

단위 테스트는 하나의 단위에 초점을 맞춘 테스트이다. 여기서는 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트하는 것을 단위 테스트라고 부른다.

통합 테스트

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

단위 테스트와 통합 테스트는 언제 사용해야 할까?

  • 항상 단위 테스트를 먼저 고려한다.
  • 하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개를 모아서 외부와의 의존관계를 모두 차단하고 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역을 이용하도록 테스트를 만든다. 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향을 받지도 않기 때문에 가장 빠른 시간에 효과적인 테스트를 작성하기에 유리하다.
  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
  • 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.

단위 테스트가 많은 장점이 있고 가장 우선시해야 할 테스트 방법인 건 사실이지만 작성이 번거롭다는 점이 문제다. 특히 목 오브젝트를 만드는 일이 가장 큰 짐이다. 다행히도, 이런 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다. 그중에서도 Mockito라는 프레임워크는 사용하기도 편리하고, 코드도 직관적이다. Mockito 목 오브젝트는 다음의 네 단계를 거쳐서 사용하면 된다.

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

프록시

프록시란?

트랜잭션 경계설정 코드를 비즈니스 로직 코드에서 분리해낼 때 적용했던 기법을 다시 검토해보자.

단순히 확장성을 고려해서 한 가지 기능을 분리한다면 전형적인 전략 패턴을 사용하면 된다. 하지만 전략 패턴으로는 트랜잭션 기능의 구현 내용을 분리해냈을 뿐이다. 트랜잭션을 적용한다는 사실은 코드에 그대로 남아 있다. 구체적인 구현 코드는 제거했을지라도 위임을 통해 기능을 사용하는 코드는 핵심 코드와 함께 남아 있다.

트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있다. UserServiceTx를 만들어 부가기능 전부를 핵심 코드가 담긴 UserServiceImpl에서 분리해 냈다. 이렇게 분리된 부가기능을 담은 클래스는 부가기능 외의 나머지 모든 기능은 원래 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것이다.

문제는 이렇게 구성했더라도 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다. 그러기 위해서는 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.

이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시(proxy)라고 부른다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트타깃(target) 또는 실체(real subject)라고 부른다.

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

프록시 패턴과 데코레이터 패턴

데코레이터 패턴

데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다. 데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다. 같은 인터페이스를 구현한 타깃과 여러 개의 프록시를 사용할 수 있다. 프록시가 여러 개인 만큼 순서를 정해서 단계적으로 위임하는 구조로 만들면 된다.

자바 IO 패키지의 InputStream과 OutputStream 구현 클래스는 데코레이터 패턴이 사용된 대표적인 예다. 다음 코드는 InputStream 인터페이스를 구현한 타깃인 FileInputStream에 버퍼 읽기 기능을 제공해주는 BufferedInputStream이라는 데코레이터를 적용한 예다.

InputStream is = new BufferedInputStream(new FileInputStream("a.txt"));

기존에 구현했던 UserServiceTx도 UserServiceImpl에 트랜잭션 부가기능을 부여해주기 위한 데코레이터 패턴의 프록시이다.

프록시 패턴

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

Collections의 unmodifiableCollection()을 통해 만들어지는 오브젝트가 전형적인 접근권한 제어용 프록시라고 볼 수 있다. 정보를 수정하는 메소드를 호출할 경우 UnsupportedOperationException 예외가 발생하게 해준다.

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

다이내믹 프록시

프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다. 하지만 프록시를 만드는 일은 상당히 번거롭고 귀찮은 일이다. 매번 새로운 클래스를 정의해야 하고, 인터페이스의 구현해야 할 메소드는 많으면 모든 메소드를 일일히 구현해서 위임하는 코드를 넣어야 하기 때문이다.

그러나 목 오브젝트를 목 프레임워크를 통해 편리하게 생성했던 것처럼, 프록시도 java.lang.reflect 패키지의 API를 통해 손쉽게 만들어낼 수 있다. 일일히 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성하는 것이다.

UserServiceTx를 찬찬히 살펴보면 알 수 있듯이, 프록시의 역할은 위임부가작업이라는 두 가지로 구분할 수 있다. 프록시를 만들기가 번거로운 이유도 다음 두 가지로 찾아볼 수 있다.

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

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

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

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

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의 타깃 오브젝트가 변경될 때마다 새로운 오브젝트를 만들어야 한다는 점이다. TransactionHandler의 중복을 없애고 모든 타깃에 적용 가능한 싱글톤 빈으로 만들어서 적용할 수는 없을까?

스프링의 프록시 팩토리 빈

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

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

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

앞서 만들었던 Hello 예제를 ProxyFactoryBean을 이용하도록 수정해보자.

@Test
public void simpleProxy() { // JDK 다이내믹 프록시 생성하기
    Hello proxiedHello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] { Hello.class },
            new UppercaseHandler(new HelloTarget()));
}

@Test
public void proxyFactoryBean() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget()); // 타깃 설정
    pfBean.addAdvice(new UppercaseAdvice()); // 부가기능을 담은 어드바이스를 추가한다. 여러 개를 추가할 수도 있다.

    Hello proxiedHello = (Hello) pfBean.getObject(); // FactoryBean이므로 getObject()로 생성된 프록시를 가져온다.

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

static class UppercaseAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String ret = (String) invocation.proceed(); // 리플렉션의 Method와 달리 메소드 실행 시 타깃 오브젝트를 전달할 필요가 없다. MethodInvocation은 메소드 정보와 함께 타깃 오브젝트를 알고 있기 때문이다.
        return ret.toUpperCase(); // 부가기능 적용
    }
}

어드바이스와 포인트컷

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

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*"); // 이름 비교조건 설정

    pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice())); // 포인트컷과 어드바이스를 Advisor로 묶어서 한 번에 추가

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

    assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
    assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
    assertThat(proxiedHello.sayThankYou("Toby"), is("Thank You Toby")); // 포인트컷의 조건에 맞지 않는다.
}

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

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


참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday