춤추는 개발자

[Spring] 다양한 관점에서 개발하는 스프링의 AOP - 1 본문

Developer's_til/스프링 프레임워크

[Spring] 다양한 관점에서 개발하는 스프링의 AOP - 1

Heon_9u 2021. 8. 23. 00:52
728x90
반응형

 

 

✅ AOP란?

 AOP는 스프링의 IoC/DI, PSA와 더불어 3대 요소 중 하나입니다. Aspected Oriented Programming의 약자로 여러 객체에 공통으로 적용할 수 있는 기능을 구분함으로써 재사용성을 높여주는 기법입니다.

 

 AOP를 공부하면서 이론 설명도 많았고, 처음 보는 용어가 많다보니 이해하기 어려웠습니다. 먼저, AOP를 적용함으로써 어떤 효과를 볼 수 있을지, 코드를 얼마나 개선할 수 있을지 확인하며 장점에 대해 알아보겠습니다.

 

 스프링에 적용된 가장 인기있는 AOP의 적용 대상은 선언적 트랜잭션 기능입니다. 앞에서 PSA를 통해 UserService의 트랜잭션 경계설정 기능을 개선했지만, AOP를 이용하여 더욱 깔끔하게 바꿔보겠습니다.

 또한, 트랜잭션의 단위테스트, 통합테스트와 프록시 설정 등으로 코드를 개선하며 AOP의 장점들에 대해 알아보겠습니다.

 

✅ 트랜잭션 코드의 분리

 UserService 코드를 살펴보면 트랜잭션 경계설정 코드가 남아있습니다. 이를 인터페이스와 UserServiceImpl, UserServiceTx, DI를 활용하여 더욱 철저한 관심사의 분리를 진행하겠습니다. 기존 UserService의 upgradeLevels() 메서드는 아래와 같습니다.

public void upgradeLevels() {
    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 (RuntimeException e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

 

 위 코드를 보면, 비즈니스 로직을 담당하는 코드와 트랜잭션 경계설정 코드가 함께 있습니다. 이를 분리하기 위해 트랜잭션 코드를 클래스 밖으로 뽑아내어 하나의 책임만 맡게 합니다. 또한, UserService를 인터페이스로 만들어서 유연한 확장이 가능한 구조로 만들어 줍니다.

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

 

그리고 UserService를 상속받은 UserServiceImplUserServiceTx를 아래와 같이 작성합니다.

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);
                }
            }
        }
    }
    ...
}
public class UserServiceTx implements UserService {
    UserService userService;
    PlatformTransactionManager transactionManager;

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

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

    public void add(User user) {
        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은 기존 UserService가 수행하던 작업을 그대로 처리합니다. 대신, 트랜잭션 처리는 UserServiceTx가 책임지며 코드를 분리합니다.

 만약, 이러한 구조에 테스트 코드인 UserServiceTest가 UserService에 접근한다면 아래와 같은 구조가 만들어집니다.

 

 위처럼 트랜잭션 기능이 분리되었으며 UserServiceTx는 수정자 메서드로 DI가 설정되었으므로 빈 오브젝트와 그 의존관계는 아래와 같습니다.

 

 

✅ 고립된 단위 테스트

 가장 편하고 좋은 테스트 방법은 가능한 작은 단위로 쪼개서 테스트하는 것입니다. 그 이유는, 테스트가 실패했을 때의 원인을 찾기 쉽기 때문입니다. 또한, 테스트의 의도나 내용이 분명해지고, 만들기도 쉬워집니다.

 

 하지만, 위에서 만든 UserService를 테스트 한다면, 복잡 의존관계 속에서 진행하게 됩니다.

 

 UserService는 위처럼 3가지의 의존관계를 갖고 있습니다. 이런 경우, 테스트하고자 하는 작은 대상이 환경이나 외부서버, 다른 클래스의 코드에 종속되고 영향받지 않도록 고립시킬 필요가 있습니다.

 

 이를 위해 '테스트를 위한 대역'을 사용할 것입니다. 아래와 같이 UserService를 재구성한다면 필요한 테스트만 진행하는 고립된 구조를 만들 수 있습니다.

 

 UserDao를 테스트 대상의 코드가 정상적으로 수행되도록 하는 스텁의 기능에 검증 기능까지 가진 목 오브젝트로 만들었습니다. 그 이유는 리턴 값이 없는 upgradeLevels() 메서드를 검증하기 위해서입니다.

 

 테스트 코드에서는 user 목록을 가져오는 getAll() 메서드는 스텁으로, update()에 대해서는 목 오브젝트로서 동작하는 UserDao 타입의 테스트 대역을 사용합니다.

 

 이렇게 작성된 MockUserDao에서는 두 개의 User 타입 리스트를 사용합니다. 하나는 생성자를 통해 전달받은 사용자 목록을 저장하고, 나머지 하나는 업그레이드 대상인 유저 목록을 저장합니다. 이렇게 한다면, 매번 DB에 접근하는 과정을 스킵하여 테스트 수행 시간을 단축시킬 수 있게 됩니다.

@Test
public void upgradeLevels() throws Exception {
    UserServiceImpl userServiceImpl = new UserServiceImpl();

    MockUserDao mockUserDao = new MockUserDao(this.users);
    userServiceImpl.setUserDao(mockUserDao);

    MockMailSender mockMailSender = new MockMailSender();
    userServiceImpl.setMailSender(mockMailSender);

    userServiceImpl.upgradeLevels();

    List<User> updated = mockUserDao.getUpdated();
    assertThat(updated.size(), is(2));
    checkUserAndLevel(updated.get(0), "user1", Level.SILVER);
    checkUserAndLevel(updated.get(1), "user2", Level.GOLD);

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

private void checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
    assertThat(updated.getId(), is(expectedId));
    assertThat(updated.getLevel(), is(expectedLevel));
}

 

[ 단위 테스트와 통합 테스트 ]

 단위 테스트의 단위는 개발자가 정하기 나름입니다. 중요한 것은 하나의 초점을 맞춘 테스트라는 점입니다. 반면에, 2개 이상의, 성격이나 계층이 다른 오브젝트가 연동되거나, DB나 서비스 등의 리소스가 참여하는 테스트는 통합 테스트라고 부릅니다. 단위 테스트와 통합 테스트 중에서 어떤 방법을 쓸지 아래와 같은 가이드라인이 있습니다.

 

  • 항상 단위 테스트를 먼저 고려한다.
  • 하나의 클래스 또는 성격과 목적이 같은 클래스 몇 개를 모아서 외부 의존관계를 모두 차단하고, 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역을 이용하여 테스트를 만든다.
  • 외부 리소스를 사용해야만 가능한 경우, 통합 테스트로 만든다.
  • DAO테스트는 DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류된다.
  • 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트다. 가능하면 직접 코드 레벨의 DI를 사용하면서 단위 테스를 하는 것이 좋겠지만, 스프링을 이용해 추상적인 레벨에서 테스트해야 할 경우 통합 테스트로 작성한다.

 

[ 목 프레임워크 ]

 스텁이나 목 오브젝트로 테스트 대역을 만드는 것은 단위 테스트를 진행할 때, 필수적입니다. 다만, 작성이 번거롭다는 문제가 있습니다. 이런 불편함을 해소해줄 수 있는 목 오브젝트 지원 프레임워크가 바로 Mockito 프레임워크입니다.

 

 간단하 메서드 호출로 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있습니다. 위에서 구현한 MockUserDao를 mock() 메서드로 만들 수 있습니다. 하지만 이렇게 만들어진 목 오브젝트는 아무 기능이 없기 때문에 스텁 기능을 추가해야 합니다.

UserDao mockUserDao = mock(UserDao.class);
when(mockUserDao.getAll()).thenReturn(this.users);
verify(mockUserDao, times(2)).update(any(User.class));

 

 

 위에서 작성한 테스트 코드에 Mokito를 적용하여 아래처럼 작성할 수 있습니다.

@Test
public void mockUpgradeLevels() throws Exception {
    UserServiceImpl userServiceImpl = new UserServiceImpl();

    UserDao mockUserDao = mock(UserDao.class);
    when(mockUserDao.getAll()).thenReturn(this.users);
    userServiceImpl.setUserDao(mockUserDao);

    MailSender mockMailSender = mock(MailSender.class);
    userServiceImpl.setMailSender(mockMailSender);

    userServiceImpl.upgradeLevels();

    verify(mockUserDao, times(2)).update(any(User.class));
    verify(mockUserDao, times(2)).update(any(User.class));
    verify(mockUserDao).update(users.get(1));
    assertThat(users.get(1).getLevel(), is(Level.SILVER));
    verify(mockUserDao).update(users.get(3));
    assertThat(users.get(3).getLevel(), is(Level.GOLD));

    ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
    ...
}

 

 

 목 오브젝트의 메서드를 검증하는 verify(), times(), any() 등을 활용했습니다.

 

  • times(): 메서들 호출 횟수를 검증.
  • any(): 파라미터의 내용은 무시하고 호출 횟수만 확인.
  • verify(): 메서드가 호출됐는지 검증.

 

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

 위에서 UserService를 UserServiceTx와 UserServiceImpl로 분리하였습니다. 이는 부가기능을 핵심기능에서 분리시킨 구조로 볼 수 있습니다. 이러한 구조는 부가기능 외에 나머지 모든 기능을 원래 핵심 기능을 가진 클래스로 위임해줘야 합니다.

 

 다만, 클라이언트가 핵심기능을 가진 클래스를 직접 사용한다면 부가기능이 적용될 기회가 없어집니다. 그래서 클라이언트가 인터페이스를 통해서 핵심기능을 사용하게끔 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 클라이언트와 핵심기능 인터페이스 사이에 들어가야 합니다. 

 

  이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 요청을 받아주는 것을 프록시라고 부릅니다. 그리고 실제 오브젝트를 타깃이라고 부릅니다.

 

 프록시는 사용 목적에 따라 두 가지로 구분할 수 있습니다.

 

[ 데코레이터 패턴 ]

 타깃에 부가적인 기능을 런타임 시 다이나믹하게 부여해주기 위해 프록시를 사용하는 패턴을 의미합니다. 데코레이터 패턴을 여러 개의 부가 기능을 타깃에게 부여할 수 있으며, 프록시가 직접 타깃을 고정시킬 필요도 없습니다. 프록시가 여러 개라면 단계적으로 위임하는 구조를 만들면 됩니다.

 

 

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

 InputStream Is = new BufferedInputStream(new FileInputStream("input.txt"));

 

 앞에서 UserService도 인터페이스를 구현한 타깃인 UserServiceImpl에 트랜잭션 부가기능을 제공해주는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것입니다. 이 경우, 수정자 메서드로 데코레이터인 UserServiceTx에 위임한 타깃은 UserServiceImpl을 주입했습니다.

 

 데코레이터 패턴은 인터페이스를 통해 위임하는 방식이기 때문에 어느 데코레이터에서 타깃으로 연결될지 코드 레벨에서는 알 수 없습니다. 데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법입니다.

 

[ 프록시 패턴 ]

 일반적으로 사용하는 프록시라는 용어는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 총칭합니다. 하지만, 프록시 패턴이란 타깃에 대한 접근 방법을 제어하려는 목적을 가지고 있습니다.

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

 

 예를 들어, 수정 가능한 오브젝트가 특정 레이어에서 읽기 전용으로 동작하게 하려면 오브젝트의 프록시를 만들어서 제어할 수 있습니다. 프록시의 특정 메서드를 사용하려고 할 때, 예외처리를 만들면 됩니다.

 

[ 다이내믹 프록시 ]

 자바에는 java.lang.reflect 패키지 안에 프록시를 쉽게 만들 수 있도록 지원해주는 클래스가 있습니다. 기본적인 아이디어는 앞에서 목 오브젝트를 쉽게 구현한 목 프레임워크와 비슷합니다.

 프록시는 두 가지 기능으로 구성됩니다.

 

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

 리플렉션 API 중에서 메서드에 대한 정의를 담은 Method 인터페이스를 이용해 메서드를 호출하는 방법을 살펴보겠습니다. String 클래스의 정보를 담은 Class 타입의 정보는 String.class라고 하면 가져올 수 있습니다. 또는 String 오브젝트가 있으면 변수이름.getClass()라고 해도 됩니다.

Method lengthMethod = String.class.getMethod("length");

 String이 가진 메서드 중에서 "length"라는 이름을 갖고 있고, 파라미터는 없는 메서드의 정보를 위처럼 가져올 수 있습니다. 이런 방식을 이용해 특정 오브젝트의 메서드를 실행시킬 수 있습니다.

 

 Method 인터페이스에 정의된 invoke() 메서드를 사용하면 메서드를 실행시킬 대상 오브젝트와 파라미터 목록을 받아서 메서드를 호출한 뒤에 그 결과를 Objects 타입으로 반환합니다. 이를 이용해 아래처럼 length() 메서드를 실행할 수 있습니다.

public Object invoke(Object obj, Object ... args)

int length = lengthMethod.invoke(name);

 

[ 프록시 클래스 ]

 다이내믹 프록시를 적용할 간단한 타깃 클래스인터페이스를 정의하겠습니다.

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

 

 

 이제 HelloTarget에 부가기능을 추가하는 프록시를 만들겠습니다. 문자를 모두 대문자로 만들어주는 HelloUppercase 프록시를 통해 HelloTarget에 기능을 추가하겠습니다.

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

 하지만, 한 눈에 봐도 프록시 적용에 문제점 두가지를 발견할 수 있습니다. 인터페이스의 모든 메서드를 구현해 위임하도록 코드를 만들어야 하며, 부가기능인 리턴 값을 대문자로 바꾸는 기능이 모든 메서드에 중복돼서 나타납니다.

 

[ 다이내믹 프록시 적용 ]

 다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로 필요한 부가기능 제공 코드는 직접 작성해야 합니다. 부가기능은 프록시 오브젝트와 독립적으로 구현한 InvocationHandler를 구현한 오브젝트에 담습니다. InvocationHandler 인터페이스invoke()라는 메서드 하나만 가지고 있습니다.

 

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

 

 게다가 InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이내믹 프록시가 받는 모든 요청을 InvocationHandler의 invoke() 메서드로 보내줍니다. 즉, 각 메서드의 요청을 invoke() 메서드 하나로 처리할 수 있게됩니다.

 

 다이내믹 프록시로부터 요청을 전달받으려면 아래와 같은 InvocationHandler를 구현해야 합니다. 클라이언트로부터 받는 모든 요청은 invoke() 메서드로 전달됩니다.

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 = (String)method.invoke(target, args);
        if(ret instanceof String) {
            return ((String)ret).toUpperCase();
        } else {
            return ret;
        }
    }
}

 

 이제 InvocationHandler를 사용하고 Hello 인터페이스를 구현하는 프록시를 만들어 봅니다. 다이내믹 프록시의 생성은 Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메서드를 이용합니다. 대신, 파라미터가 많으니 주의해야 합니다.

 

 첫번째, 클래스 로더

 두번째, 다이내믹 프록시가 구현해야 할 인터페이스

 세번째, 부가기능과 위임 관련 코드를 담고있는 InvocationHandler 구현 오브젝트를 제공

Hello proxyHello = (Hello) Proxy.newProxyInstance(
        getClass().getClassLoader(),
        new Class[] { Hello.class },
        new UppercaseHandler(new HelloTarget())
);

 

물론, InvocationHandler는 단일 메서드에서 모든 요청을 처리하기 때문에 특정 메서드에 어떤 기능을 적용할지 선택하는 과정이 필요합니다. 호출하는 메서드의 이름, 파라미터의 개수와 타입, 리턴 타입 등의 정보를 가지고 부가기능을 적용할 메서드를 선택할 수 있습니다. 또한, 아래처럼 메서드의 이름도 조건으로 걸 수 있습니다.

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object ret = (String)method.invoke(target, args);
    if(ret instanceof String && method.getName().startsWith("say")) {
        return ((String)ret).toUpperCase();
    } else {
        return ret;
    }
}

 

 

 

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

 Hello에 부가기능을 적용하는데 이용한 다이내믹 프록시를 UserService에 적용하겠습니다. 현재 UserServiceTx는 서비스 인터페이스의 모든 메서드를 구현해야 하고 트랜잭션이 필요한 메서드마다 트랜잭션 처리 코드가 중복돼서 나타나는 비효율적인 방법으로 만들어져 있습니다.

 다이내믹 프록시와 연동해서 트랜잭션 기능을 부가해주는 InvocationHandler를 상속한 TransactionHandler를 아래와 같이 정의할 수 있습니다.

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

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

    public 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);
            return e.getTargetException();
        }
    }
}

 

 요청을 위임한 타깃을 DI로 제공받도록 합니다. 타깃 오브젝트의 모든 메서드에서 무조건 트랜잭션이 적용되지 않도록 트랜잭션을 적용할 메서드 이름의 패턴을 DI 받습니다. 이렇게 한다면 어떤 타깃에서도 적용 가능한 트랜잭션 부가기능을 담은 TransactionHandler를 완성했습니다.

 

 

 이제 TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 차례입니다. 하지만, DI의 대상이 되는 다이내믹 프록시 오브젝트는 일반적인 스프링으 빈으로는 등록할 방법이 없다는 문제가 있습니다. 일반적으로 스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성합니다.

 반면에 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메서드를 통해서만 만들 수 있습니다.

 

[ 팩토리 빈 ]

 팩토리 빈이란 스프링을 대신해서 오브젝트의 생성 로직을 담당하도록 만들어진 특별한 빈을 말합니다. 팩토리 빈을 만드는 방법에도 여러가지 있는데, 가장 간단한 방법은 스프링의 FactoryBean이라는 인터페이스를 구현하는 것입니다.

public interface FactoryBean<T> {
    T getObject throws Exception; // 빈 으브젝트를 생성해서 반환.
    Class<? extends T> getObjectType(); // 생성되는 오브젝트의 타입을 반환.
    boolean isSingleton(); // getObject()가 반환하는 오브젝트가 항상 같은 싱글톤인지 확인.
}

 

 예시를 보는 대신, 바로 트랜잭션에 프록시 팩토리 빈을 적용해보겠습니다.

public class TxProxyFactoryBean implements FactoryBean<Object> {

    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern;
    Class<?> serviceInterface;

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

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

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

    public void setServiceInterface(Class<?> serviceInterface) {
        this.serviceInterface = serviceInterface;
    }

    @Override
    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
        );
    }

    @Override
    public Class<?> getObjectType() {
        return serviceInterface;
    }

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

 팩토리 빈이 만드는 다이내믹 프록시는 구현 인터페이스나 타깃의 종류에 제한이 없습니다. 따라서 UserService 외에도 트랜잭션 부가 기능이 필요한 오브젝트를 위한 프록시를 만들 때, 재사용이 가능합니다. 단지, 설정이 다른 여러 개의 TxProxyFactroyBean 빈을 등록하면 됩니다.

 

 여기까지 트랙잰션 프록시 팩토리 빈이 완성됐습니다. 이제 UserServiceTest에서 TxProxyFactoryBean이 다이내믹 프록시를 기대한 대로 구성해서 만들어주는지 확인하기 위해 트랜잭션 기능을 테스트해봐야 합니다.

 

 하지만, 기존 UserServiceTest의 경우 TestUserServie 오브젝트를 타깃으로 삼고 있습니다. 타깃 오브젝트에 대한 레퍼런스는 TransactionHanlder 오브젝트가 갖고 있는데, 이는 TxProxyFactoryBean 내부에서 만들어져 사용됩니다. 따라서 트랜잭션 기능을 테스트하기 위해 빈으로 등록된 TxProxyFactoryBean을 직접 가져옵니다.

 

 즉, 스프링 빈으로 등록된 TxProxyFactoryBean을 가져와서 타겟 프로퍼티를 재구성해준 뒤에 다시 프록시 오브젝트를 생성하도록 요청하는 것입니다. 그러면 컨텍스트의 설정을 변경해버리지만, 트랜잭션 기능에 대한 테스트를 진행해야 하므로 @DirtiesContext를 등록해주는 것으로 해당 챕터를 넘어가겠습니다.

@Test
@DirtiesContext
public void upgradeAllOrNothing() throws Exception {
    TestUserService testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(userDao);
    testUserService.setMailSender(mailSender);

    // 테스트용 타깃 주입
    TxProxyFactoryBean txProxyFactoryBean = context.getBean("&userService", TxProxyFactoryBean.class);
    txProxyFactoryBean.setTarget(testUserService);
    UserService txUserService = (UserService) txProxyFactoryBean.getObject();

    userDao.deleteAll();
    for(User user: users) userDao.add(user);

    try {
        txUserService.upgradeLevels();
        fail("TestUserServiceException expected");
    } catch (TestUserServiceException e) {

    }

    checkLevelUpgraded(users.get(1), false);
}

 

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

[ 프록시 팩토리 빈의 재사용 ]

 트랜잭션 부가기능을 담당하는 TxProxyFactroyBean은 코드 수정없이 다양한 클래스에 적용할 수 있습니다. 타깃 오브젝트에 맞는 프로퍼티를 설정해서 빈으로 등록해주기만 하면 됩니다. 만약 특정 Service의 인터페이스에 정의된 모든 메서드에 트랜잭션 기능을 적용하려면 pattern값을 빈 문자열로 설정해주면 됩니다.

 

[ 프록시 팬토리 빈의 장점 ]

 다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있습니다. 하나의 핸들러 메서드를 구현하면 수많은 메서드에 부가기능을 부여해줄 수 있어서 코드의 중복 문제도 사라집니다.

 

[ 프록시 팩토리 빈의 한계 ]

 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하려면 비슷한 프록시 팩토리 빈의 설정이 중복된다. 하나의 타깃에 여러 개의 부가기능을 적용할 경우, 적용 대상인 서비스 클래스가 수 백개라면 그만큼 XML 설정이 늘어나게 됩니다.

 또한, TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어집니다. 타깃 오브젝트가 달라지면 새로운 TransactionHandler를 만들어야 하므로 그만큼 중복된 코드가 발생한다.

 

🙋‍♂️ 느낀점

 이번에는 하나의 기술을 특정한 관점에서 바라보며 어떻게 코드를 분리하고 확장하는지 알아봤다. 이번 포스팅은 트랜잭션 기능과 서비스 로직이 분리된 UserService를 프록시에 따라 부가기능과 핵심기능으로 다시 한번 더 분리했다. 프록시와 타깃으로 기능을 나누는 파트까지 빠르게 이해할 수 있었지만, 테스트 코드를 위해 다이내믹 프록시와 팩토리 빈을 사용하면서 한번에 너무 많은 지식을 습득하게 됐다.

 

 테스트 코드의 중요성을 알기 때문에 코드를 분리하고 새로운 빈으로 설정하는 과정이 당연하게 여겨졌다. 하지만, 기능 개발부터 테스트까지 단계가 너무 많다는 느낌이 들었다. 실제 프로젝트에서 이렇게 하나하나 생각하며 코드를 리팩토링한다면 얼마나 많은 시간과 노력이 필요할지..🤦‍♂️

 

 게다가 아직 AOP 챕터를 절반 밖에 보지 못해서 2편으로 나눠서 진행한다..

 

 

 

 

 

728x90
반응형