본문 바로가기
챕터정리방

[6장] AOP

by Jake.. 2022. 1. 3.

AOP는 IOC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술의 하나다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔끔한 방식으로 바꿔보자. 그리고 그 과정에서 스프링이 AOP를 도입해야 헸던 이유도 알아보자.

 

트랜잭션 코드의 분리

-메소드 분리
트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없고, 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있어 메소드로 분리하였다.

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를 이용한 클래스의 분리

아예 트랜잭션 코드도 클래스 밖으로 뽑아내고 DI를 통해 사용한다. 트랜잭션 처리 부분이 분리되어 추상화 되었다.

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

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

-트랜잭션 분리에 따른 테스트 수정
트랜잭션 주입을 위해 UserServiceTx 오브젝트를 수동 DI 시킨 후에 트랜잭션 기능까지 포함해서 테스트를 진행한다. TestUserService 클래스는 이제 UserServiceImpl 클래스를 상속하도록 바꿔주면 된다

 

 

@Test
public void upgradeLevels() throws Exception {
    ...
    MockMailSender mockMailSender = new MockMailSender();
    userServiceImpl.setMailSender(mockMailSencder);
}

@Test
public void upgradeAllorNothing() throws Exception {
    TestUserService testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(userDao);
    testUserService.setMailSender(mailSender);
    
    UserServiceTx txUserService = new UserServiceTx();
    txUserService.setTransactionManager(transactionManager);
    txUserService.setUserService(testUserSerivce);
    
    userDao.deleteAll();
    for(User user : users) userDao.add(user);
    
    try {
        txUserService.upgradeLevels();
        fail("TestUserServiceException expected");
    }
}

static class TestUserService extends UserServiceImpl { ... }

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

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

둘째비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다는 것이다.

 

고립된 단위 테스트

 

 

 

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

 

-Mockito 프레임워크

단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용은 필수적이다. 하지만 목 오브젝트의 작성은 매우 번거롭다. Mockito 프레임워크는 간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 클래스용 오브젝트를 만들 수 있다. 다음과 같이 간단하게 UserDao의 인터페이스를 파라미터로 받아 목 오브젝트를 만들 수 있다.

UserDao mockUserDao = mock(UserDao.class);

목 오브젝트를 생성한 후 getAll() 메서드를 호출하였을 때 사용자 목록을 리턴하도록 스텁 기능을 추가해 준다.

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

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

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

Mockito는 다음과 같은 4 단계를 거쳐서 사용하면 된다.(두 번째와 네 번째는 필요하면 경우에만 사용할 수 있다.)

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

-Mockito를 적용한 테스트코드

@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, time(2)).update(any(User.class));
    verify(mockUserDao, time(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(mockMailSender, time(2)).send(mailMessageArg.captur());
    List<simpleMailMessage> mailMessages = mailMessageArg.getAllValues();
    asserThat(mailMessages.get(0),getTo()[0], is(users.get(1).getEmail()));
    asserThat(mailMessages.get(1),getTo()[0], is(users.get(3).getEmail()));
}

 

  • times()는 메소드 호출 횟수를 검증해 준다. any()를 사용하면 파라미터의 내용은 무시하고 호출 횟수만 확인한다.
  • ArgumentCaptor라는 것을 사용하여 실제 MailSender 목 오브젝트에 전달된 파라미터를 가져와 내용을 검증할 수 있다.
  • 이는 파라미터의 내부 정보를 확인하는 경우 유용하다.

 

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

 

  • 부가기능은 자신이 핵심기능을 가진 것처럼 꾸며서 클라이언트가 자신을 거쳐 핵심기능을 사용하도록 했다.
  • 그렇게 하기 위해 인터페이스를 통해 부가, 핵심기능을 접근도록 하였다.

 

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

-데코레이터 패턴

 

  • 데코레이션 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다.
  • 데코레이터라 불리는 이유는 마치 케익을 여러 겹으로 포장하고 그 위에 장식을 붙이는 것처럼 실제 내용물은 동일 하지만 부가적인 효과를 줄 수 있기 때문이다.
  • 그래서 프록시를 여러 개 쓸 수 있고 순서를 정해서 단계적으로 위임하면 된다.
  • 데코레이터 패턴은 인터페이스를 통해 위임하는 방식이므로 어느 데코레이터에서 타깃으로 연결될지 코드 레벨에선 미리 알 수 없다.
  • 데코레이터 패턴은 타깃의 코드에 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용하다.
  • 소스코드를 출력하는 기능을 핵심기능으로 가지고 여러 데코레이터를 부여한다면 다음과 같이 조합할 수 있다.

 

예를 들어 소스코드를 출력히는 기능을 가진 핵심기능이 있다고 생각해보자. 이 클래스에 데코레이터 개념을 부여해서 타깃과 같은 인터페이스를 구현히는 프록시를 만 들 수 있다. 예를 들어 소스코드에 라인넘버를 붙여준다거나, 문법에 따라 색을 변경해 주거나, 특정 폭으로 소스를 잘라주거나, 페이지를 표시해주는 등의 부가적인 기능을 각각 프록시로 만들어두고 그림 6-11 과 같이 런타임 시에 이를 적절한 순서로 조합해서 사용하면 된다.

 

-프록시 패턴

 

  • 일반적으로 말하는 프록시는 클라이언트와 사용 대상 사이의 대리 역할을 맡은 오브젝트를 두는 방법을 말한다.
  • 프록시 패턴의 프록시는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 말한다.
  • 프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
  • 타깃 오브젝트를 필요한 시점까지 생성하지 않고 있다가 타깃 오브젝트에 대한 레퍼런스가 필요하면 프록시 패턴을 적용하면 된다.(지연 생성)
  • 클라이언트에게 타깃에 대한 레퍼런스를 넘겨야 하는데 실제 타깃 오브젝트 대신 프록시를 넘긴다.
  • 그리고 해당 타깃을 사용하려 할 때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해 주는 식이다.
  • 또는 특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 사용할 수도 있다.
  • 구조적으로 보면 프록시와 데코레이터 패턴은 유사하지만 프록시는 코드에서 자신이 접근할 타깃 클래스 정보를 직접적으로 알야야 한다.

 

-다이내믹 프록시

프록시를 만드는 것은 상당히 번거롭다. 하지만 자바에는 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들게 지원해주는 클래스들이 있다. 마치 목프레임워크와 비슷하다. 이를 통해 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성해 보자. 프록시는 다음의 두 가지 기능으로 구성된다.

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

프록시가 만들기가 번거로운 이유 두가지는 아래와 같다.

• 첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다.

• 두 번째 문제점은 부가기능 코드가 중복될 가능성이 많다는 점이다.

 

-리플렉션

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


-프록시 클래스

프록시는 데코레이터 패턴을 적용해서 타깃인 HelloTarget에 부가기능을 추가했다.

interface Hello { 
    String sayHello(String name);
}
//구현한 타깃 클래스
public class HelloTarget implements Hello {
    public String sayHello(String name) {
        return "Hello " + name;
    }
}
//인터페이스를 구현한 프록시
public class HelloUppercase implements Hello {
    Hello hello; //위임할 타깃 오브젝트(다른 프록시 접근을 위해 인터페이스로 접근)
    
    public HelloUppercase(Hello hello) {
        this.hello = hello
    }
    
    public String sayHello(String name) {
        return hello.sayHello(name).toUpperCase(); //위임과 부가기능 적용
    }
}
@Test
public void simpleProxy() {
    Hello proxiedHello = new HelloUppercase(new HelloTarget());
    asserThat(proxiedHello.sayHello("Havi"), is("HELLO HAVI"));
}

-다이나믹 프록시 적용

 

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

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

 

-팩토리 빈

스프링은 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다. 이를 가장 쉽게 구현하는 방법은 FactoryBean이라는 인터페이스를 구현하는 것이다.

public interface FactoryBean<T> {
    T getObject() throws Exception; //빈 오브젝트를 생성해서 돌려준다.
    Class<? extends T> getObjectType(); //생성되는 오브젝트 타입을 알려준다.
    boolean isSingleton(); //getObject가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
}
public class Message {
    String text;
    
    private Message(String text) { //외부 생성 불가
        this.text = text;
    }
    
    public String getText() {
        return text;
    }
    
    public static Message newMessage(String text) { //생성자 대신 사용할 수 있는 스태틱 팩토리 메소드
        return new Message(text);
    }
}
public class MessageFactoryBean implements FactoryBean<Message> {
    String text;
    
    public void setText(String text) {
        this.text = text;
    }
    
    /*
     * 복잡한 방식의 오브젝트 생성과 초기화 작업 가능
     * 실제 빈으로 사용될 오브젝트 직접 생성
     */
    public Message getObject() throws Exception {
        return Message.newMessage(this.next);
    }
    
    public Class<? extends Message> getObjectType() {
        return Message.class;
    }
   
    /*
     * 이 팩토리 빈은 매번 요청마다 새로운 오브젝트를 만들므로 false로 설정한다.
     * 이는 펙토리 빈의 동작방식 설정이고 싱글톤으로 스프링이 관리해 줄 수 있다.
     */
    public boolean isSingleton() {
        return false;
    }
}

 

-프록시 팩토리 빈 방식의 장점

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

 

지금까지 살펴본 프록시 팩토리 빈은 이 두 가지 문제를 해결해준다.

 

다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있다. 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소 드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다. 다이내믹 프록시에 팩토리 빈을 이용한 DI까지 더해주연 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다. DI 설정만으로 다양한 타깃 오브젝트에 적용도 가능하다. 이 정도라면 프록시를 도입하려고 했을 때 고민했던 문제점을 거의 완벽하게 해결한 듯하다.

 

-프록시 팩토리 빈 방식의 한계

하지만 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능하다. 하나의 타깃 오브젝트에만 부여되는 부가기능이라면 상관없겠지만, 트랜잭션과 같이 비즈니스 로직을 담은 많은 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 증복되는 것을 막을수없다.

하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다.

 

 

 

스프링의 프록시 팩토리 빈

ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. TxProxyFacotryBean과 달리, 순수하게 프록시를 생성하는 작업만을 담당하며 프록시를 제공해줄 부가기능은 별도의 빈에 둘 수 있다. 부가기능의 경우 InvocationHandler의 invoke()와 달리, MethodInterceptor를 사용하여 타깃 오브젝트에 대한 정보를 함께 제공한다. 이를 통해 타깃 오브젝트에 상관없이 독립적으로 만들어 싱글톤 빈으로 등록 가능하다.

public class DynamicProxyTest {
    @Test
    public void simpleProxy() {
        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(); //FacotryBean이므로 생성된 프록시를 가져온다.
        
        assertThat(ProxiedHello.sayHello("Havi"), is("Hello Havi"));
        ...
    }
    
    static class UppercaseAdvice implements MethodInterceptor {
        public Object invoke(MethodInvocation invocation) throws Throwable {
            String ret = (String)invocation.proceed(); //타깃을 알고 있기에 타깃 오브젝트를 전달할 필요가 없다.
            return ret.toUpperCase(); //부가기능 적용
        }
    }
    
    static interface Hello {
        String sayHello(String name);
        String sayHi(String name);
        String sayThankYor(String name);
    }
    
    static class HelloTarget implements Hello {
        public String sayHello(String name) { return "Hello" + name; }
        ...
    }
}

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

MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용하여 적용하였기에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다. 
어드바이스(advice)는 MethodInvocation처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트이다. ProxyFactoryBean은 타깃 오브젝트가 구현하고 있는 모든 인터페이스를 동일하게 구현하는 프록시를 만들어 준다. 그래서 따로 인터페이스 타입을 제공받지 않아도 Hello 인터페이스를 구현한 프록시를 만들 수 있다.

 

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

MethodInterceptor는 타깃에 대한 정보를 들고 있지 않기에 싱글톤 빈으로 등록할 수 있었다. 따라서 확장성까지 고려하면 적용 대상 메소드를 선정하는 로직은 분리하는 것이 올바르다.

타깃 변경과 메소드 선정 알고리즘 변경 같 은 확장이 펼요하면 팩토리 빈 내의 시 생성코드를 직접 변경해야 한다. 결국 확장에는 유연하게 열려 있지 못하고 관련 없는 코드의 변경이 필요할 수 있는 OCP 원칙을 깔끔하게 잘 지커지 못히는 어정쩡한 구조라고 볼 수 있다. 반면에 그림 6-18 에 나타난 스프링의 ProxyFactoryBean 방식은 두 가지 확장 기능 인 부가기능(Advice)과 메소드 선정 알고리즘(Pointcut) 을 활용하는 유연한 구조를 제공한다.

 

 

스프링 AOP

지금까지 해왔던 작업의 목표는 비즈니스 로직에 반복적으로 등장해야만 했던 트랜잭션 코드를 깔끔하고 효과적으로 분리해내는 것이다.

부가기능을 적용하는 과정에서 발견됐던 거의 대부분의 문제는 제거했다. 하지만 프록시 팩토리 빈 방식의 접근 방법의 한계라고 생각했던 두 가지 문제가 있었다. 그중에서 부가기능이 타깃 오브젝트마다 새로 만들어지는 문제는 스프링 ProxyFactoryBean의 어드바이스를 통해 해결됐다. 남은 것은 부가기능의 적용이 필요한 타깃 오브젝트마다 거의 비슷한 내용의 ProxyFactoryBean 빈 설정정보를 추가해주는 부분이다.

다이내믹 프록시가 인터페이스만 제공하면 모든 메소드에 대한 구현 클래스를 자동으로 만들듯이, 일정한 타깃 빈의 목록을 제공하면 자동으로 각 타깃 빈에 대한 프록시를 만들어주는 방법이 있다면 ProxyFactoryBean 타입 빈 설정을 매번 추가해서 프록시를 만들어내는 수고를 덜 수 있을 것 같다.

 

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

스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분 외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다.

그중에서 관심을 가질 만한 확장 포인트는 BeanPostProcessor 인터페이스를 구현해서 만든 빈 후처리기다. 빈 후처리기는 이름 그대로 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.

빈 후처리기를 스프링에 적용하는 방법은 간단하다. 빈 후처리기 자체를 빈으로 등록하는 것이다. 스프링은 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다. 이를 이용하면 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다.

 

아래는 빈 후처리기를 이용한 자동 프록시 생성 방법을 설명한다.

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

DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에 빈을 보낸다. DefaultAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다. 프록시 적용 대상이면 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다. 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

 

확장된 포인트컷

포인트컷은 두 가지 기능을 갖고 있다. 다음 Pointcut 인터페이스를 살펴보자.

public interface Pointcut {
    ClassFilter getClassFilter();	// 프록시를 적용할 클래스인지 확인해준다.
    MethodMatcher getMethodMatcher();	// 어드바이스를 적용할 메소드인지 확인해준다.
}

만약 Pointcut 선정 기능을 모두 적용한다면 먼저 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상 클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는 식으로 동작한다. 이 두가지 조건이 모두 충족되는 타깃의 메소드에 어드바이스가 적용되는 것이다.

포인트컷이 클래스 필터까지 동작해서 클래스를 걸러버리면 아무리 프록시를 적용했다고 해도 부가기능은 전혀 제공되지 않는다는 점에 주의해야 한다. 사실 클래스 필터에서 통과하지 못한 대상은 프록시를 만들 필요조차 없다.

 

 

클래스 필터를 적용한 포인트컷 작성

아래는 클래스 필터 기능이 추가된 포인트컷이다. 메소드 이름만 비교하던 포인트컷을 상속해서 프로퍼티로 주어진 이름 패턴을 가지고 클래스 이름을 비교하는 ClassFilter를 추가하도록 만들었다.

package springbook.learningtest.jdk.proxy;

...
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {
    public void setMappedClassName(String mappedClassName) {
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }
    
    static class SimpleClassFilter implements ClassFilter {
        String mappedName;
        
        private SimpleClassFilter(String mappedName) {
            this.mappedName = mappedName;
        }
        
        public boolean matches(Class<?> clazz) {
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
        }
    }
}

 

어드바이저를 이용하는 자동 프록시 생성기 등록

DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor를 구현한 것을 모두 찾는다. 그리고 생성되는 모든 빈에 대해 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다. 빈 클래스가 프록시 선정 대상이라면 프록시를 만들어 원래 타깃 빈 오브젝트를 프록시 뒤에 연결해서 프록시를 통해서만 접근 가능하게 바꾼다. 그리고 타깃 빈에 의존한다고 정의한 빈들은 프록시 오브젝트를 DI 받게 한다.

 

DefaultAdvisorAutoProxyCreator 등록은 다음 한줄이다.

<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />

다른 빈에서 참조되거나 코드에서 빈 이름으로 조회될 필요가 없는 빈이라면 id를 등록하지 않아도 무방하다.

 

포인트컷 등록

기존의 포인트컷 설정을 삭제하고 새로 만든 클래스 필터 지원 포인트컷을 빈으로 등록한다.

<bean id="transactionPointcut"
      class="springbook.service.NameMatchClassMethodPointcut">
      <property name="mappedClassName" value="*ServiceImpl" />
      <property name="mappedName" value="upgrade*" />
</bean>

 

ProxyFactoryBean 제거와 서비스 빈의 원상복구

userServiceImpl 빈의 아이디를 이제는 userService로 되돌려 놓자. 더 이상 명시적인 프록시 팩토리 빈을 등록하지 않기 때문이다. ProxyFactoryBean 타입의 빈은 삭제해도 좋다.

 

 

자동 프록시 생성기를 사용하는 테스트

기존에 만들어서 사용하던 강제 예외 발생용 TestUserService 클래스를 직접 빈으로 등록해보자.

클래스 이름은 포인트컷이 선정해줄 수 있도록 ServiceImpl로 끝나야 한다. 클래스 이름을 TestUserServiceImpl이라고 변경하자. 또 테스트 코드에서 생성하는 것이 아니기 때문에 픽스처로 만든 users 리스트에서 예외를 발생시킬 기준 id를 가져와 사용할 방법이 없으므로, 예외를 발생시킬 대상 사용자 아이디를 클래스에 넣어버리자.

아래 코드와 같이 수정하면 된다.

static class TestUserServiceImpl extends UserServiceImpl {
    private String id = "madnite1";
    
    protected void upgradeLevel(User user) {
        if(user.getId().equals(this.id)) throws new TestUserServiceException();
        super.upgradeLevel(user);
    }
}

 

이제 TestUserServiceImpl을 빈으로 등록하자.

<bean id="testUserService"
      class="springbook.user.service.UserServiceTest$TestUserServiceImpl"
      parent="userService" />

스태틱 멤버 클래스를 지정할 때는 클래스 이름에 $ 기호를 사용해 지정해준다. 그리고 parent 애트리뷰트를 사용하면 다른 빈 설정의 내용을 상속받을 수 있다.

 

마지막으로 upgradeAllOrNothing() 테스트를 새로 추가한 testUserService 빈을 사용하도록 수정하자.

public class UserServiceTest {
    @Autowired
    UserService userService;
    @Autowired
    UserService testUserService;
    ...
    
    @Test
    public void upgradeAllOrNothing() {
        userDao.deleteAll(0;
        for(User user : users) {
            userDao.add(user);
        }
        
        try {
            this.testUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch(TestUserServiceException e) {
        }
        
        checkLevelUpgraded(users.get(1), false);
    }
}

 

포인트컷 표현식

스프링은 아주 간단하고 효과적인 방법으로 포인트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 정규식이나 JSP의 EL과 비슷한 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 그래서 이것을 포인트컷 표현식(pointcut expression)이라고 부른다.

 

포인트컷 표현식을 지원하는 포인트컷을 적용하려면 AspectJExpressPointcut 클래스를 사용하면 된다.

스프링이 사용하는 포인트컷 표현식은 AspectJ라는 유명한 프레임워크에서 제공하는 것을 가져와 일부 문법을 확장해서 사용하는 것이다. 그래서 이를 AspectJ 포인트컷 포현식이라고도 한다.

 

포인트컷 표현식 문법

AspectJ 포인트컷 표현식은 포인트컷 지시자를 이용해 작성한다. 포인트컷 지시자 중에서 가장 대표적으로 사용되는 것은 execution()이다. excution() 지시자를 사용한 포인트컷 표현식의 문법구조는 기본적으로 다음과 같다.

[] 괄호는 옵션항목이기 때문에 생략이 가능하다는 의미이며, |(파이프)는 OR 조건이다.

 

 

기존 포인트컷과 동일한 기준으로 메소드를 선정하는 알고리즘을 가진 포인트컷 표현식을 만들어보자.

<bean id="transactionPointcut"
      class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
      <property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))" />
</bean>

 

타입 패턴과 클래스 이름 패턴

포인트컷 표현식의 클래스 이름에 적용되는 패턴은 클래스 이름 패턴이 아니라 타입 패턴이다.

포인트컷 표현식의 타입 패턴 항목을 *..UserService라고 직접 인터페이스 이름을 명시하면 userServiceImpl과 testUserService 두 개의 빈이 모두 선정된다. 두 클래스 모두 UserService 인터페이스를 구현하고 있기 때문이다.

포인트컷 표현식에서 타입 패턴이라고 명시된 부분은 모두 동일한 원리가 적용된다는 점을 기억해두자.

 

AOP: 애스펙트 지향 프로그래밍

애스팩트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.

애스펙트는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다. 어드바이저는 아주 단순한 형태의 애스펙트라고 볼 수 있다.

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

AOP는 애스펙트를 분리함으로써 핵심기능을 설계하고 구현할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 것이다. 결국 애플리케이션을 다양한 측면에서 독립적으로 모델링하고, 설계하고, 개발할 수 있도록 만들어주는 것이다.

 

프록시를 이용한 AOP

스프링 AOP의 부가기능을 담은 어드바이스가 적용되는 대상은 오브젝트의 메소드다. 프록시 방식을 사용했기 때문에 메소드 호출 과정에 참여해서 부가기능을 제공해주게 되어 있다. 타깃의 메소드를 호출하는 전후에 다양한 부가기능을 제공할 수 있다.

독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는 게 바로 프록시다. 그래서 스프링 AOP는 프록시 방식의 AOP라고 할 수 있다.

 

바이트코드 생성과 조작을 통한 AOP

AspectJ는 프록시를 사용하지 않는 대표적인 AOP 기술이다. 스프링도 AspectJ의 뛰어난 포인트컷 표현식을 차용해서 사용할 만큼 매우 성숙하고 발전한 AOP 기술이다. AspectJ는 스프링처럼 다이내믹 프록시 방식을 사용하지 않는다.

AspectJ는 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다. 소스코드를 수정하지는 않으므로 개발자는 계속해서 비즈니스 로직에 충실한 코드를 만들 수 있다.

 

AspectJ가 컴파일된 클래스 파일 수정이나 바이트코드 조작과 같은 복잡한 방법을 사용하는 이유는 두 가지가 있다.

 

첫 째는 바이트코드를 조작해서 타깃 오브젝트를 직접 수정해버리면 스프링과 같은 DI 컨테이너의 도움을 받아서 자동 프록시 생성 방식을 사용하지 않아도 되기 때문이다. 스프링 같은 컨테이너가 사용되지 않는 환경에서도 손쉽게 AOP의 적용이 가능해진다.

 

둘째는 프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능하기 때문이다. 프록시를 AOP의 핵심 메커니즘으로 사용하면 부가기능을 부여할 대상은 클라이언트가 호출할 때 사용하는 메소드로 제한된다. 타깃 오브젝트가 생성되는 순간 부가기능을 부여해주고 싶을 때 프록시 방식에서는 이런 작업이 불가능하다. 타깃 오브젝의 생성은 프록시 패턴을 적용할 수 있는 대상이 아니기 때문이다. 또, 프록시 적용이 불가능한 private 메소드의 호출, 스태틱 메소드 호출이나 초기화, 심지어 필드 입출력 등에 부가기능을 부여하려고 하면 클래스 바이트코드를 직접 조작해서 타깃 오브젝트나 호출 클라이언트의 내용을 수정하는 것 밖에는 없기 때문이다.

 

AOP의 용어

  • 타깃 : 타깃은 부가기능을 부여할 대상이다. 핵심기능을 담은 클래스일 수도 있고 경우에 따라서는 다른 부가기능을 제공하는 프록시일 수도 있다.
  • 어드바이스 : 어드바이스는 타깃에게 제공할 부가기능을 담은 모듈이다. 어드바이스는 여러 종류가 있다. 메소드 호출 과정에 전반적으로 참여하는 것도 있지만, 예외가 발생했을 때만 동작하는 어드바이스처럼 메소드 호출 과정의 일부에서만 동작하는 어드바이스도 있다.
  • 조인 포인트 : 조인 포인트란 어드바이스가 적용될 수 있는 위치를 말한다. 타깃 오브젝트가 구현한 인터페이스의 모든 메소드는 조인 포인트가 된다.
  • 포인트컷 : 포인트컷이란 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 
  • 프록시 : 프록시는 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트다. DI를 통해 타깃 대신 클라이언트에게 주입되며, 클라이언트의 메소드 호출을 대신 받아서 타깃에 위임해주면서, 그 과정에서 부가기능을 부여한다.
  • 어드바이저 : 어드바이저는 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트다. 어드바이저는 어떤 부가기능(어드바이스)을 어디에(포인트컷) 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다.
  • 애스펙트 : OOP의 클래스와 마찬가지로 애스펙트는 AOP의 기본 모듈이다. 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 만들어지며 보통 싱글톤 형태의 오브젝트로 존재한다. 따라서 클래스와 같은 모듈 정의와 오브젝트와 같은 실체(인스턴스)의 구분이 특별히 없다.

AOP 네임스페이스

스프링의 프록시 방식 AOP를 적용하려면 최소한 네 가지 빈을 등록해야 한다.

  • 자동 프록시 생성기
  • 어드바이스
  • 포인트컷
  • 어드바이저

이 중에서 부가기능을 담은 코드로 만든 어드바이스를 제외한 나머지 세 가지는 모두 스프링이 직접 제공하는 클래스를 빈으로 등록하고 프로퍼티 설정만 해준 것이다.

 

스프링에서는 이렇게 AOP를 위해 기계적으로 적용하는 빈들을 간편한 방법으로 등록할 수 있다. 스프링은 AOP와 관련된 태그를 정의해둔 aop 스키마를 제공한다. aop 스키마에 정의된 태그를 사용하려면 설정파일에 다음과 같은 aop 네임 스페이스 선언을 설정파일에 추가해줘야 한다.

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop-3.0.xsd">
                           
       ...
</beans>

 

이제 aop 네임스페이스를 이용해 기존의 AOP 관련 빈 설정을 변경해보자.

<aop:config>
    <aop:pointcut id="transactionPointcut"
                  expression="excution(* *..*ServiceImpl.upgrade*(..))" />
    <aop:advisor advice-ref="transactionAdvice" pointcut-ref="transactionPointcut" />              
</aop:config>

<bean> 태그를 사용했을 때와 비교해보면 이해하기도 쉬울뿐더러 코드의 양도 대폭 줄었음을 알 수 있다.

 

어드바이저 내장 포인트컷

aop 스키마의 전용 태그를 사용하는 경우에는 굳이 포인트컷을 독립적인 태그로 두고 어드바이저 태그에서 참조하는 대신 어드바이저 태그와 결합하는 방법도 가능하다.

<aop:config>
    <aop:advisor advice-ref="transactionAdvice"
                 pointcut="execution(* *..*ServiceImpl.upgrade*(..))" />
</aop:config>

포인트컷을 독립적으로 정의하는 것보다 간결해졌다. 하지만 하나의 포인트컷을 여러 개의 어드바이저에서 공유하려고 하는 경우에는 포인트컷을 독립적인 <aop:pointcut> 태그로 등록해야 한다.

 

 

트랜잭션 속성

 

트랜잭션 전파

트랜잭션 전파(transaction propagation)란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. 독자적인 트랜잭션 경계를 가진 코드에 대해 이미 진행 중인 트랜잭션이 어떻게 영향을 미칠 수 있는가를 정의하는 것이 트랜잭션 전파 속성이다.

 

대표적으로 다음과 같은 트랜잭션 전파 속성을 줄 수 있다.

  • PROPAGATION_REQUIRED : 가장 많이 사용되는 트랜잭션 전파 속성이다. 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다.
  • PROPAGATION_REQUIRES_NEW : 항상 새로운 트랜잭션을 시작한다. 즉 앞에서 시작된 트랜잭션이 있든 없든 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 한다. 독립적인 트랜잭션이 보장돼야 하는 코드에 적용할 수 있다.
  • PROPAGATION_NOT_SUPPORTED : 이 속성을 사용하면 트랜잭션 없이 동작하도록 만들 수도 있다. 진행 중인 트랜잭션이 있어도 무시한다.

이 외에도 다양한 트랜잭션 전파 속성을 사용할 수 있다.

 

격리수준

모든 DB 트랜잭션은 격리수준(isolation level)을 갖고 있어야 한다. 기본적으로는 DB나 DataSource에 설정된 디폴트 격리수준을 따르는 편이 좋지만, 특별한 작업을 수행하는 메소드의 경우는 독자적인 격리수준을 지정할 필요가 있다.

 

제한시간

트랜잭션을 수행하는 제한시간(timeout)을 설정할 수 있다. 제한시간은 트랜잭션을 직접 시작할 수 있는 PROPAGATION_REQUIRED나 PROPAGATION_REQUIRES_NEW와 함께 사용해야만 의미가 있다.

 

읽기전용

읽기전용(read only)로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 엑세스 기술에 따라서 성능이 향샹될 수도 있다.

 

 

TransactionIntercepter

TransactionIntercepter는 트랜잭션 정의를 메소드 이름 패턴을 이용해서 다르게 지정할 수 있는 방법을 추가로 제공해준다. 그리고 PlatformTransactionManager와 Properties 타입의 두 가지 프로퍼티를 갖고 있다.

Properties 타입인 프로퍼티 이름은 transactionAttributes로, 트랜잭션 속성을 정의한 프로퍼티다. 트랜잭션 속성은 TransactionDefinition의 네 가지 기본 항목에 rollbackOn()이라는 메소드를 하나 더 갖고 있는 TransactionAttribute 인터페이스로 정의된다. 이를 이용하면 트랜잭션 부가기능의 동작 방식을 모두 제어할 수 있다.

 

TransactionInterceptor에는 기본적으로 두 가지 종류의 예외 처리 방식이 있다. 런타임 예외가 발생하면 트랜잭션을 롤백된다. 반면에 체크 예외가 발생하면 이것을 예외상황으로 해석하지 않고 비즈니스 로직에 따른, 의미가 있는 리턴 방식의 한 가지로 인식해서 트랜잭션을 커밋해버린다.

TransactionAttribute는 rollbackOn()이라는 속성을 둬서 기본 원칙과 다른 예외처리가 가능하게 해준다. 이를 활용하면 특정 체크 예외의 경우는 트랜잭션을 롤백시키고, 특정 런타임 예외에 대해서는 트랜잭션을 커밋시킬 수도 있다.

 

이런 TransactionAttribute를 Properties라는 일종의 맵 타입 오브젝트로 전달받는다. 컬렉션을 사용하는 이유는 메소드 패턴에 따라서 각기 다른 트랜잭션 속성을 부여할 수 있게 하기 위해서다.

 

메소드 이름 패턴을 이용한 트랜잭션 속성 지정

Properties 타입의 transactionAttributes 프로퍼티는 메소드 패턴가 트랜잭션 속성을 키와 값으로 갖는 컬렉션이다.

트랜잭션 속성은 다음과 같은 문자열로 정의할 수 있다.

이 중에서 트랜잭션 전파 항목만 필수이고 나머지는 다 생략 가능하다.

이렇게 속성을 하나의 문자열로 표현하게 만든 이유는 트랜잭션 속성을 메소드 패턴에 따라 여러 개를 지정해줘야 하는데, 일일이 중첩된 태그와 프로퍼티로 설정하게 만들면 번거롭기 때문이다. 또, 대부분은 디폴트를 사용해도 충분하므로 생략 가능하다는 점도 한 가지 이유다.

 

 

포인트컷 표현식과 트랜잭션 속성을 정의할 때 따르면 좋은 몇 가지 전략을 생각해보자.

트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다.

일반적으로 트랜잭션을 적용할  타킷 클래스의 메소드는 모두 트랜잭션 적용 후보가 되는 것이 바람직하다.

쓰기 작업이 없는 단순한 조회 작업만 하는 메소드에도 모두 트랜잭션을 적용하는게 좋다. 조회의 경우에는 읽기전용으로 트랜잭션 속성을 설정해두면 그만큼 성능의 향상을 가져올 수 있다. 따라서 트랜잭션용 포인트컷 표현식에는 메소드나 파라미터, 예외에 대한 패턴을 정의하지 않는 게 바람직하다. 트랜잭션의 경계로 삼을 클래스들이 선정됐다면, 그 클래스들이 모여 있는 패키지를 통째로 선택하거나 클래스 이름에서 일정한 패턴을 찾아서 표현식으로 만들면 된다. 가능하면 클래스보다는 인터페이스 타입을 기준으로 타입 패턴을 적용하는 것이 좋다. 인터페이스는 클래스에 비해 변경 빈도가 적고 일정한 패턴을 유지하기 쉽기 때문이다.

 

공통된 메소드 이름 규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다.

너무 다양하게 트랜잭션 속성을 부여하면 관리만 힘들어질 뿐이다. 따라서 기준이 되는 몇 가지 트랜잭션 속성을 정의하고 그에 따라 적절한 메소드 명명 규칙을 만들어 두면 하나의 어드바이스만으로 애플리케이션의 모든 서비스 빈에 트랜잭션 속성을 지정할 수 있다.

 

가장 간단한 트랜잭션 속성 부여 방법은 모든 메소드에 대해 디폴트 속성을 지정하는 것이다. 일단 트랜잭션 속성의 종류와 메시지 패턴이 결정되지 않았으면 가장 단순한 디폴트 속성으로부터 출발하면 된다. 개발이 진행됨에 따라 단계적으로 속성을 추가해주면 된다.

 

다음으로 트랜잭션 적용 대상 클래스의 메소드는 일정한 명명 규칙을 따르게 해야 한다. 이 때 의미를 좀 더 잘 드러내는 이름으로 명명 규칙을 따르게 하는 것이 좋다.

일반화하기에는 적당하지 않은 특별한 트랜잭션 속성이 필요한 타깃 오브젝트에 대해서는 별도의 어드바이스와 포인트컷 표현식을 사용하는 편이 좋다.

 

프록시 방식 AOP는 같은 타깃 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다.

이건 전략이라기보다는 주의사항이다. 프록시 방식의 AOP에서는 프록시를 통한 부가기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 반대로 타깃 오브젝트가 자기 자신의 메소드를 호출할 때는 프록시를 통한 부가기능의 적용이 일어나지 않는다.

이렇게 같은 타깃 오브젝트 안에서 메소드 호출이 일어나는 경우에는 프록시 AOP를 통해 부여해준 부가기능이 적용되지 않는다는 점을 주의해야 한다.

 

타깃 안에서의 호출에 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가 있다.

하나는 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제하는 방법이다. 하지만 순수한 비즈니스 로직에 스프링 API와 프록시 호출 코드가 등장하는 건 그다지 바람직하지 않다. 따라서 별로 추천되지 않는다.

다른 방법은 AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP기술을 적용하는 것이다. 스프링은 프록시 기반의 AOP를 기본적으로 사용하고 있지만 필요하다면 지금까지 검토했던 대부분의 설정은 그대로 둔 채로 간단한 옵션을 바꿈으로써 AspectJ 방식으로 트랜잭션 AOP가 적용되게 할 수 있다. 하지만 그만큼 다른 불편도 뒤따르기 때문에 꼭 필요한 경우에만 사용해야 한다.

 

 

트랜잭션 속성 적용

트랜잭션 속성과 그에 따른 트랜잭션 전략을 UserService에 적용해보자. 지금까지 살펴봤던 몇 가지 원칙과 전략에 따라 작업을 진행할 것이다.

 

트랜잭션 경계설정의 부가기능을 여러 계층에서 중구난방으로 적용하는 건 좋지 않다.

일반적으로 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하다. 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다.

다음으로 테스트와 같은 특별한 이유가 아니고는 다른 계층이나 모듈에서 DAO에 직접 접근하는 것은 차단해야 한다. 가능하면 서비스 계층을 거치도록 하는 게 안전하고 바람직하다. 그래야만 부가 로직을 적용할 수도 있고, 트랜잭션 속성도 제어할 수 있기 때문이다.

따라서 UserDao 인터페이스의 getCount()를 제외한 4개의 메소드를 다음과 같이 UserService에 추가한다.

public interface UserService {
    void add(User user);
    
    // 신규 추가 메소드
    User get(String id);
    List<User> getAll();
    void deleteAll();
    void update(User user);    
        
    void upgradeLevels();
}

 

다음은 UserServiceImpl 클래스에 추가된 메소드 구현 코드를 넣어준다.

public class UserServiceImpl implements UserService {
    UserDao userDao;
    ...
    
    public void deleteAll() { userDao.deleteAll(); }
    public User get(String id) { return userDao.get(id); }
    public List<User> getAll() { return usrDao.getAll(); }
    public void update(User user) { userDao.update(user); }
}

이제 모든 User 관련 데이터 조작은 UserService라는 트랜잭션 경계를 통해 진행할 경우 모두 트랜잭션을 적용할 수 있게 됐다.

 

기존 포인트컷 표현식을 모든 비즈니스 로직의 서비스 빈에 적용되도록 수정한다.

<aop:config>
    <aop:advisor advice-ref="transactionAdvice" pointcut="bean(*Service)" />
</aop:config>

이제 id가 Service로 끝나는 모든 빈에 transactionAdvice 빈의 부가기능이 적용될 것이다.

 

다음은 어드바이스 빈을 스프링의 TransactionInterceptor를 이용하도록 변경한다.

<tx:advice id="transactionAdvice">
    <tx:attributes>
        <tx:method name="get*" read-only="true"/>
        <tx:method name="*" />
    </tx:attributes>
</tx:advice>

 

 

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

세밀한 트랜잭션 속성의 제어가 필요한 경우를 위해 스프링이 제공하는 다른 방법이 있다. 설정파일에서 패턴으로 분류가능한 그룹을 만들어서 일괄적으로 속성을 부여하는 대신에 직접 타깃에 트랜잭션 속성정보를 가진 애노테이션을 지정하는 방법이다.

 

@Transactional

다음은 @Transactional 애노테이션을 정의한 코드다. 애노테이션 코드는 단순하고 직관적이라서 쉽게 이해할 수 있다.

package org.springframework.transaction.annotation;
...

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
    String value() default "";
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
    boolean readOnly() default false;
    Class<? extends Throwable>[] rollbackFor() default {};
    String[] noRollbackForClassName() default {};
}

@Transactional 애노테이션을 트랜잭셔 속성정보로 사용하도록 지정하면 스프링은 @Transactional이 부여된 모든 오브젝트를 자동으로 타깃 오브젝트로 인식한다. 이때 사용되는 포인트컷은 TransactionAttributeSourcePointcut이다.

@Transactional이 타입 레벨이든 메소드 레벨이든 상관없이 부여된 빈 오브젝트를 모두 찾아서 포인트컷의 선정 결과로 돌려준다. @Transactional은 기본적으로 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다.

 

트랜잭션 속석을 이용하는 포인트컷

아래 그림은 @Transactional 애노테이션을 사용했을 때 어드바이저의 동작방식을 보여준다.

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

이 방식을 이용하면 포인트컷과 트랜잭션 속성을 애노테이션 하나로 지정할 수 있다. 

트랜잭션 부가기능 적용 단위는 메소드다. @Transactional을 부여하고 속성을 지정할 수 있다. 이렇게 하면 유연한 속성 제어는 가능하겠지만 코드는 지저분해지고, 동일한 속성 정보를 가진 애노테이션을 반복적으로 메소드마다 부여해주는 바람직하지 못한 결과를 가져올 수 있다.

 

대체 정책

스프링은 @Transactional을 적용할 때 4단계의 대체(fallback) 정책을 이용하게 해준다. 메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스, 인터페이스)의 순서에 따라서 @Transactional이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성정보를 사용하게 하는 방법이다. 메소드가 선언된 타입까지 단계적으로 확인해서 @Transactional이 발견되면 적용하고, 끝까지 발견되지 않으면 해당 메소드는 트랜잭션 적용 대상이 아니라고 판단한다.

@Transactional을 사용하면 대체 정책을 잘 활용해서 애노테이션 자체는 최소한으로 사용하면서도 세밀한 제어가 가능하다.

@Transactional은 먼저 타입 레벨에 정의되고 공통 속성을 따르지 않는 메소드에 대해서만 메소드 레벨에 다시 @Transactional을 부여해주는 식으로 사용해야 한다.

 

트랜잭션 애노테이션 사용을 위한 설정

@Transactional을 이용한 트랜잭션 속성을 사용하는 데 필요한 설정은 매우 간단하다.

이 태그 하나로 트랜잭션 애노테이션을 이용하는 데 필요한 어드바이저, 어드바이스, 포인트컷, 애노테이션을 이용하는 트랜잭션 속성정보가 등록된다.

<tx:annotation-driven />

 

트랜잭션 애노테이션 적용

@Transactional을 UserService에 적용해보자.

단순하게 트랜잭션이 필요한 타입 또는 메소드에 직접 애노테이션을 부여하는 것이 훨씬 편리하고 코드를 이해하기도 좋다. tx 스키마의 <tx:attributes> 태그를 이용해 설정했던 트랜잭션 속성을 그대로 애노테이션으로 바꿔보자.

@Transactional 애노테이션은 UserServiceImpl 클래스 대신 UserService 인터페이스에 적용하겠다. 그래야 UserServiceImpl과 TestUserService 양쪽에 트랜잭션이 적용될 수 있기 때문이다.

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

 

애노테이션을 이용한 트랜잭션 속성 지정은 tx 스키마를 사용할 때와 마찬가지로 IDE의 자동완성 기능을 활용할 수 있고 속성을 잘못 지정한 경우 컴파일 에러가 발생해서 손쉽게 확인할 수 있다는 장점이 있다.

 

 

트랜잭션 지원 테스트

선언적 트랜잭션과 트랜잭션 전파 속성

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

스프링은 두 가지 방법 모두 지원하고 있다. 특별한 경우가 아니라면 선언적 방식의 트랜잭션을 사용하는 것이 바람직하다.

트랜잭션 경계와 트랜잭션 전파

 

트랜잭션 매니저를 이용한 테스트용 트랜잭션 제어

스프링의 트랜잭션 추상화가 제공하는 트랜잭션 동기화 기술과 트랜잭션 전파 속성 덕분에 테스트도 트랜잭션으로 묶을 수 있다. 이를 잘 이용하면 DB 작업이 포함되는 테스트를 원하는 대로 제어하면서 효과적인 테스트를 만들 수 있다. 테스트에서 트랜잭션을 시작하거나 조작할 수 있는 기능은 매우 유용하다. 트랜잭션 결과나 상태를 조작하면서 테스트하는 것도 가능하다. 테스트 메소드 안에서 트랜잭션을 여러 번 만들 수도 있고, 트랜잭션 속성에 따라서 여러 메소드를 조합해 사용할 때 어떤 결과가 나오는지도 미리 검증 가능하다.

 

롤백 테스트

롤백 테스트는 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게 하고 테스트가 끝나면 무조건 롤백해버리는 테스트를 말한다. 롤백 테스트는 DB 작업이 포함된 테스트가 수행돼도 DB에 영향을 주지 않기 때문에 장점이 많다. 테스트에 따라서 고유한 테스트 데이터가 필요한 경우 그에 맞게 DB를 초기화하고 테스트를 진행해도 된다. 초기화한 작업까지도 모두 롤백되므로 어떤 변경을 가하든 상관없다. 롤백 테스트는 심지어 여러 개발자가 하나의 공용 테스트용 DB를 사용할 수 있게도 해준다. 적절한 격리수준만 보장해주면 동시에 여러 개의 테스트가 진행돼도 상관없다.

이처럼 테스트에서 트랜잭션을 제어할 수 있기 때문에 얻을 수 있는 가장 큰 유익이 있다면 바로 이 롤백 테스트다.

 

 

@Transactional

테스트에도 @Transactional을 적용할 수 있다. 이를 이용하면 테스트 내에서 진행하는 모든 트랜잭션 관련 작업을 하나로 묶어줄 수 있다. 트랜잭션 매니저와 번거로운 코드를 사용하는 대신 간단한 애노테이션만으로 트랜잭션이 적용된 테스트를 손쉽게 만들 수 있다.

 

@Rollback

테스트용 트랜잭션은 테스트가 끝나면 자동으로 롤백된다. 테스트에 적용된 @Transactional은 기본적으로 트랜잭션을 강제 롤백시키도록 설정되어 있다. 그런데 테스트 메소드 안에서 진행되는 작업을 하나의 트랜잭션으로 묶고 싶기는 하지만 강제 롤백을 원하지 않는 경우 @Rollback이라는 애노테이션을 이용하면 된다.

@Rollback은 롤백 여부를 지정하는 값을 갖고 있다. @Rollback의 기본 값은 true다. 따라서 트랜잭션은 적용되지만 롤백을 원치 않는다면 @Rollback(false)라고 해줘야 한다.

 

@TransactionConfiguration

@Transactional은 테스트 클래스에 넣어서 모든 테스트 메소드에 일괄 적용할 수 있지만 @Rollback 애노테이션은 메소드 레벨에만 적용할 수 있다. 테스트 클래스의 모든 메소드에 트랜잭션을 적용하면서 모든 트랜잭션이 롤백되지 않고 커밋되게 하려면 클래스 레벨에 부여할 수 있는 @TransactionConfiguration 애노테이션을 이용하면 편리하다.

@TransactionConfiguration을 사용하면 롤백에 대한 공통 속성을 지정할 수 있다. 디폴트 롤백 송성은 false로 해두고, 테스트 메소드 중에서 일부만 롤백을 적용할 때 메소드에 @Rollback을 부여해주면 된다.

 

NotTransaction과 Propagation.NEVER

@NotTransaction을 테스트 메소드에 부여하면 클래스 레벨의 @Transactional 설정을 무시하고 트랜잭션을 시작하지 않은 채로 테스트를 진행한다. 물론 테스트 안에서 호출하는 메소드에서 트랜잭션을 사용하는 데는 영향을 주지 않는다.

@NotTransaction 대신 @Transactional의 트랜잭션 전파 속성을 사용하는 방법도 있다. @Transactional을 다음과 같이 NEVER 전파 속성으로 지정해주면 @NotTransactional과 마찬가지로 트랜잭션이 시작되지 않는다.

@Transactional(propagation=Propagation.NEVER)

 

효과적인 DB 테스트

테스트 내에서 트랜잭션을 제어할 수 있는 네 가지 애노테이션을 잘 활용하면 DB가 사용되는 통합 테스트를 만들 때 매우 편리하다.

일반적으로 고립된 상태에서 테스트를 진행하는 단위 테스트와, 외부의 리소스나 여러 계층의 클래스가 참여하는 통합 테스트는 아예 클래스를 구분해서 따로 만드는게 좋다. DB가 사용되는 통합 테스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional을 부여해준다. DB가 사용되는 통합 테스트는 가능한 한 롤백 테스트로 만드는 게 좋다.

테스트는 어떤 경우에도 서로 의존하면 안 된다. 테스트가 진행되는 순서나 앞의 테스트 성공 여부에 따라서 다음 테스트의 결과가 달라지는 테스트를 만들면 안 된다. 코드가 바뀌지 않는 한 어떤 순서로 진행되더라도 테스트는 일정한 결과를 내야 한다. 트랜잭션을 지원하는 롤백 테스트는 매우 유용한 도구가 되어줄 것이다.

 

 

 

 

 

 

 

 

'챕터정리방' 카테고리의 다른 글

[8장] 스프링이란 무엇인가?  (0) 2022.01.25
[7장] 스프링 핵심 기술의 응용  (0) 2022.01.16
[5장] 서비스 추상화  (0) 2021.12.29
[5장] 서비스 추상화  (0) 2021.12.26
[4장] 예외  (0) 2021.12.21

댓글