Spring - (28) : AOP

­이승환·2021년 12월 18일
0

spring

목록 보기
25/26

Overview


토비의 스프링을 보고 정리한 자료입니다.

문제가 있을 시, lshn1007@hanyang.ac.kr 로 메일주시면 삭제하겠습니다.

AOP

  • IoC/DI, PSA(서비스 추상화) 와 더북어 스프링의 3대 기반 기술
  • 대표적인 선언적 트랜젝션 기능에 사용한다.
  • 특정 비지니스 로직 사이에 우리가 원하는 기능들을 추가할 때 사용한다고 생각하면 된다.

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

트랜잭션 경계 설정과 같은 로직과 비즈니스 로직과 같이 성격다른 로직을 분리하면 얻을 수 있는 장점은 아래와 같다.

  • 비즈니스 로직을 작성할 때 트랜잭션과 같은 기술적인 내용은 전혀 신경쓰지 않아도 된다.
  • 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.

테스트 대상 오브젝트 고립시키기

  • 가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 하지만, 실전에서는 그렇지 않은 경우가 많다. 테스트 대상은 간단한 기능을 수행하고 있음에도 테스트 대상이 의존하는 오브젝트들이 문제를 일으키면 테스트에 실패하기 때문에 의존하고 있는 오브젝트들의 기능 테스트까지 같이 수행해야되는 경우가 많다.
  • 테스트의 대상이 외부 환경이나 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트 대역(Test double)을 사용하는 것이다.
  • 테스트 대상 오브젝트가 의존하는 오브젝트들을 테스트 대역으로 만들면, 기존에 의존하고 있던 많은 의존 오브젝트들과의 연결 고리가 없어지게 되므로 테스트 대상에 대한 고립된 테스트가 가능해진다.

Test double은 Mocking 또는 Test stub 으로 구성되어 있다. (이전 포스팅 참조)

테스트 수행 성능의 향상

  • 테스트 대상이 의존하는 오브젝트들을 목 오브젝트로 대체하여 고립된 테스트를 진행하면 테스트 수행 성능이 크게 향상될 수 있다.
  • 테스트 대상이 의존하는 오브젝트들이 문제를 일으키는지 확인하지 않아도 되기 때문에 테스트를 단순하게 수행할 수 있다.

단위 테스트와 통합 테스트

  • 단위 테스트 : 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않고 단독으로 테스트가 수행되는 것
  • 통합 테스트 : 두 개 이상의 단위가 결합해서 동작하면서 테스트가 수행되는 것

단위 테스트와 통합 테스트 중 어떤 방법을 써야하는가?

단위 테스트와 통합 테스트 중 어떤 방법을 쓸지 결정할 때는 아래의 가이드라인을 검토해볼 수 있다.

  • 항상 단위 테스트를 먼저 고려한다.
  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다. (데이터베이스와 같은..)
  • 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 단, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
  • 단위 테스트를 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다. 이 때도 통합 테스트에 참여하는 코드 중에서 가능한 한 많은 부분을 미리 단위 테스트로 검증해두는 게 유리하다.

목 프레임워크

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

  • 이 때, 목 오브젝트를 만드는 일은 큰 짐이 되는데 번거로운 목 오브젝트를 편리하게 작성핟조록 도와주는 목 오브젝트 지원 프레임워크가 있다. (Mockito 프레임워크)

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

  • 인터페이스를 구현할 때 사용하지 않을 메소드라면 UnsupportedOperationException을 던지도록 만드는 편이 좋다. 그냥 빈 채로 두거나 null을 리턴하게 해도 문제는 없으나, 실수로 사용될 위험이 있으므로 UnsupportedOperationException을 던지게 해서 지원하지 않는 기능이라는 예외가 발생하도록 만들어라.

프록시, 타깃, 프록시 패턴, 데코레이터 패턴 ..

클라이언트가 Target(비즈니스로직) 을 실행하기 전에, 우리가 원하는 특정 데코레이터나 프록시를 실행하기 위해 Proxy 오브젝트를 활용할 수 있다.

Proxy

  • 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시(Proxy) 라고 부른다.
  • 특별한 로직을 활용한다기 보다, 요청을 대신 받아주는 대리자 역할을 한다고 생각하자.

Target

  • Proxy를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃(target) 또는 실체(real subject) 라고 부른다.

Proxy의 특징

  • 프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 점과 프록시가 타깃을 제어할 수 있는 위치에 있다는 점이다.

  • 프록시의 사용 목적은 아래와 같이 분류할 수 있다.

    (1) 클라이언트가 타깃에 접근하는 방법을 제어하기 위함
    (2) 타깃에 부가적인 기능을 부여해주기 위함

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

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

Decorator pattern

  • 타깃에 부가적인 기능을 런타임 시에 다이내믹하게 부여하기 위해 프록시를 사용하는 패턴을 말한다. (다이나믹하게 기능을 부가한다는 의미는 컴파일 시점. 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.)
  • 이 패턴에서는 프록시가 한 개로 제한되지 않으며, 프록시가 직접 타깃을 사용하도록 고정시킬 필요도 없다. 이를 위해 데코레이터 패턴에서는 같은 인터페이스를 구현한 타깃과 여러 개의 프록시를 사용할 수 있다.
  • 프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.
  • 런타임 시 다이내믹하게 데코레이터 패턴을 적용하기 위해서는 스프링의 DI를 이용해 데코레이터, 타깃 간 의존 관계를 설정해주면 된다.

Proxy pattern

  • 여기서 말하는 프록시 패턴은 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우에 사용하는 패턴을 말한다. 즉, 프록시 패턴은 타깃의 기능을 확장하거나 추가하지 않으며, 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
  • 프록시 패턴의 적용을 고려할 수 있는 경우는 아래와 같다.

    (1) 클라이언트에서 타깃 오브젝트에 대한 레퍼런스가 미리 필요한 경우 타깃이 아닌 프록시를 먼저 넘겨줘서 클라이언트에서 선개발이 진행할 수 있도록 하고자 할 때
    (2) 원격 오브젝트를 이용하는 경우, 원격 오브젝트에 대한 프록시를 만들어두고 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼 하고자 할 때
    (3) 특별한 상황에서 타깃에 대한 접근 권한을 제어하고자 할 때

Decorator pattern vs Proxy pattern

두 패턴은 구조적으로 유사하나, 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. (생성을 지연하는 프록시의 경우 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다.) 물론 프록시 패턴이라고 하더라도 인터페이스를 통해 위임하도록 만들 수 있다.

Dynamic Proxy

  • 프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다.

  • 하지만 프록시는 일반적으로 만들기가 번거롭다는 단점이 있다.

    (1) 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다.
    (2)부가 기능을 수행하는 코드가 중복될 가능성이 많다.

  • 이런 문제를 해결하기 위해 JDK에서는 DynamicProxy라는 기능을 제공한다.

  • 다이내믹 프록시는 리플렉션 기능을 이용해 프록시를 만들어주는데 Reflection 은 자바의 코드 자체를 추상화해서 접근하도록 만든 것으로 일일이 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성할 수 있다.

Dynamic Proxy의 동작 방식

(1) 런타임 시 클라이언트가 프록시 생성을 요청
(2) 프록시 팩토리에 의해 프록시 객체를 생성
(3) 클라이언트가 프록시를 통해 메소드를 호출
(4) 다이나믹 프록시 오브젝트는 클라이언트의 요청을 리플렉션으로 변환해서 InvocationHandler 구현체의 invoke() 메소드로 전달
(5) InvocationHandler 구현체는 로직을 수행하고, 타깃에 위임
(6) 결과를 리턴

결론적으로 InvocationHandler가 하나의 프록시 또는 데코레이션 프록시 라고 보면 된다.
굳이 이렇게 하는 이유는 언제나 그랬듯이 중복을 최소화 하기 위함임.

Dynamic Proxy 사용 방법

  • Dynamic proxy가 인터페이스의 오브젝트는 만들어주지만 프록시로서 필요한 부가 기능을 제공하는 코드(Target)는 직접 작성해야 한다. InvocationHandler 의 invoke() 메소드에 부가 기능을 작성하면 된다.
  • Method.invoke() 를 이용해 타깃 오브젝트의 메소드를 호출할 때는 타깃 오브젝트에서 발생하는 예외가 * InvocationTargetException으로 한 번 포장되서 전달된다. 따라서 일단 InvocationTargetException으로 받은 후 getTargetException() 메소드로 중첩되어 있는 예외를 가져와야 한다.

이전 테스트 포스팅에서 언급한 예외를 상위레벨로 던질때 치환할 수 있다는 것을 기억하자

@Setter
public class TransactionHandler implements InvocationHandler {
    private Object target;  // 부가 기능을 제공할 타깃 오브젝트. 어떤 타입의 오브젝트에도 적용할 수 있다.
    private PlatformTransactionManager transactionManager;  // 트랜잭션 기능을 제공할 때 필요한 트랜잭션 매니저
    private String pattern;     // 트랜잭션을 적용할 메소드 이름 패턴

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

    private 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);
            throw e.getTargetException();
        }
    }
}
  • 참고로 위 예제에서 this.pattern 은 transaction과 관련한 메소드 네이밍으로 선언해야 의도한 대로 동작한다.

  • Dynamic proxy 객체는 Proxy 클래스의 newProxyInstance() static factory 메소드를 이용해 생성하면 된다.

Hello proxiedHello = (Hello) Proxy.newProxyInstance(
        getClass().getClassLoader(),                // 동적으로 사용되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더(=다이내믹 프록시가 정의되는 클래스 로더 지정)
        new Class[] { Hello.class },                // 구현할 인터페이스
        new UppercaseHandler(new HelloTarget()));   // 부가 기능과 위임 코드를 담은 InvocationHandler
  • Dynamic Proxy를 적용하면 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되므로 중복되는 기능을 효과적으로 제공할 수 있다.

FactoryBean

  • Dynamic proxy는 Proxy 클래스의 newProxyInstance() 라는 static factory 메소드를 통해서만 만들 수 있고, 내부적으로 다이내믹하게 생성되는 구조이기 때문에 일반적인 스프링의 빈으로는 등록할 방법이 없다.
  • 스프링은 클래스 정보를 가지고 디폴트 생성자를 통해 오브젝트를 만드는 방법 외에도 빈을 만들 수 있는 여러가지 방법을 제공한다. 대표적으로 FactoryBean 을 이용한 빈 생성 방법을 들 수 있다. 팩토리 빈이란 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.
  • 팩토리 빈을 만드는 방법에는 여러 가지가 있는데, 가장 간단한 방법은 스프링의 FactoryBean 이라는 인터페이스를 구현하는 것이다.
  • FactoryBean 인터페이스를 구현한 클래스를 스프링의 빈으로 등록하면 팩토리 빈으로 동작한다. Spring은 FactoryBean 인터페이스를 구현한 클래스가 빈의 클래스로 지정되면 팩토리 빈 클래스의 오브젝트의 getObject() 메소드를 이용해 오브젝트를 가져오고 이를 빈 오브젝트로 사용한다. 빈의 클래스로 등록된 팩토리 빈은 빈 오브젝트를 생성하는 과정에서만 사용할 뿐이다. → XML Configuration 방식으로만 가능하며, Java Based Configuration은 링크와 같은 방식으로 사용해야 한다. (https://www.baeldung.com/spring-factorybean)
package org.springframework.beans.factory;
...

public interface FactoryBean<T> {
    String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";

    @Nullable
    T getObject() throws Exception;     // 빈 오브젝트를 생성해서 돌려준다.

    @Nullable
    Class<?> getObjectType();           // 생성되는 오브젝트의 타입을 알려준다.

    default boolean isSingleton() {     // getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
        return true;
    }   
}

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

  • 다이내믹 프록시를 생성해주는 팩토리 빈을 사용하는 방법은 여러가지 장점이 있다.
  • 대표적인 장점은 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있다는 점이다.

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

  • 비즈니스 로직을 담은 다수의 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 중복되는 것을 막을 수 없다.
  • 하나의 타깃에 여러 개의 부가 기능을 적용하려고 할 때도 문제다. 여러 개의 프록시가 필요하고 프록시를 적용하고자 하는 대상 클래스에 전부 빈 설정을 해줘야되므로 설정이 복잡해질 수 있다.
  • 또한 InvocationHandler의 오브젝트가 프록시 팩토리의 빈 개수만큼 만들어진다는 점이다. (동일 코드임에도 타깃 오브젝트가 달라지면 새로운 InvocationHandler 구현체의 객체를 만들어줘야 한다.)

Spring의 프록시 팩토리 빈(ProxyFactoryBean)

  • 스프링은 일관된 방법으로 프록시를 만들 수 있도록 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈인 ProxyFactoryBean 을 제공해준다. 생성된 프록시는 스프링의 빈으로 등록되어야 한다.
  • 기존에 만들었던 FactoryBean 의 구현체와는 다르게 ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고, 프록시를 통해 제공해줄 부가 기능은 별도의 빈에 둘 수 있다.
  • ProxyFactoryBean 이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptorInvocationHandler와 비슷하지만 한 가지 다른 점이 있다. InvocationHandlerinvoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서 타깃은 InvocationHandler 를 구현한 클래스가 직접 알고 있어야 한다. 반면에 MethodInterceptorinvoke() 메소드는 ProxyFactoryBean 으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다. 그 차이 덕분에 MethodInterceptor 는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다. 따라서 MethodInterceptor 오브젝트는 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록할 수 있다.
  • MethodInterceptor 로는 메소드 정보와 함께 타깃 오브젝트가 담긴 MethodInvocation 오브젝트가 전달된다. MethodInvocation 은 타깃 오브젝트의 메소드를 실행할 수 있는 기능(proceed()) 이 있기 때문에 MethodInterceptor 는 부가 기능을 제공하는데만 집중할 수 있다.

Advice, Pointcut, Advisor

  • 스프링에서는 타깃 오브젝트에 부가 기능을 제공하는 오브젝트를 어드바이스 라고 부른다.
  • 부가 기능(Advice)을 적용할 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷 이라고 부른다.
  • 어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)

Advisor를 사용하는 이유

  • ProxyFactoryBean 에 어드바이스 와 포인트컷 을 함께 등록할 때는 Advisor 타입으로 묶어서 addAdvisor() 메소드를 호출해야 한다.
  • 이유는 ProxyFactoryBean 에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있는데 포인트컷과 어드바이스를 따로 등록하면 어떤 포인트컷(메소드 선정)에 대해 어떤 어드바이스(부가기능)를 적용할지 알 수 없기 때문이다.

빈 후처리기(BeanPostProcessor)

  • 빈 후처리기는 스프링 빈 오브젝트로 만들어지고 난 후에 빈 오브젝트를 다시 가공할 수 있게 해준다.
  • 빈 후처리기를 Bean으로 등록하면 Bean이 등록될 때마다 후처리기에 요청을 보내서 후처리 작업이 수행된다.
  • 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수 있고, 별도의 초기화 작업도 가능하다. 또한 만들어진 빈 오브젝트 자체를 다른 오브젝트로 대체해 빈으로 등록할 수도 있다.
  • 이를 잘 이용하면 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다
  • DefaultAdvisorAutoProxyCreator : 어드바이저를 이용한 자동 프록시 생성기(모든 빈에 대해 프록시 자동 적용 대상을 선별하고, 적용 대상의 경우 어드바이스를 연결해준다.)

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

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

포인트컷

  • getClassFilter()getMethodMatcher() 순으로 Proxy, Advisor 적용 대상 클래스를 판별한다.
  • Method명이 일치하더라도 ClassFilter에서 필터링되면 Advice가 적용되지 않는다.
package org.springframework.aop;

public interface Pointcut {
    ClassFilter getClassFilter();        // 프록시를 적용할 대상 클래스인지 확인 
    MethodMatcher getMethodMatcher();    // 어드바이스를 적용할 메소드인지 확인
}
  • NameMatchMethodPointcutgetClassFilter()에서 모든 클래스를 통과시키기 때문에 아래와 같이 재정의해서 사용할 수 있다.
NameMatchMethodPointcut classMethodPointcut = new NameMatchMethodPointcut() {
    @Override
    public ClassFilter getClassFilter() {
        return new ClassFilter() {
            @Override
            public boolean matches(Class<?> clazz) {
                return clazz.getSimpleName().startsWith("HelloT");  // 'HelloT'로 시작하는 클래스에만 프록시를 적용한다.
            }
        };
    }
};

포인트컷 표현식을 이용한 포인트컷

  • 지금까지는 클래스나 메소드의 이름을 통해 포인트컷 적용 대상을 선정할 수 있었다. 그렇다면 이보다 더 복잡하고 세밀한 기준을 이용해 클래스나 메소드를 선정하게 하려면 Reflection API를 이용할 수 있다. Reflection을 이용하면 다양한 정보를 얻어낼 수 있으므로 더 세밀하게 적용 대상을 선정할 수 있다. 하지만, 리플렉션 API를 이용해 메타 정보를 비교하는 방법은 조건이 달라질 때마다 포인트컷 구현 코드를 수정해야 하는 번거로움이 따른다.
  • 따라서 스프링에서는 간단하면서도 효율적으로 포인트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 정규식이나 JSP의 EL과 비슷한 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 이를 포인트컷 표현식(Pointcut expression) 이라고 한다.

AspectJ Pointcut expression

  • Pointcut 인터페이스를 구현한 클래스는 클래스 선정 알고리즘과 메소드 선정 알고리즘을 각각 구현해야 했는데, 스프링에서 제공하는 AspectJExpressionPointcut 은 정규표현식과 유사한 형태의 AspectJ Pointcut expression을 이용해 한번에 지정할 수 있게 해준다.
  • 스프링에서 제공하는 포인트컷 표현식은 AspectJ 프레임워크에서 제공하는 것을 가져와 일부 문법을 확장해서 사용하는 것이다.
  • AspectJ 포인트컷 표현식은 포인트컷 지시자를 이용해 작성한다. 그 중 대표적으로 사용되는 것은 execution() 이다.
// AspectJ 포인트컷 표현식 형식
execution([접근제한자 패턴] 반환형_타입패턴 [패키지를 포함한 클래스의 타입패턴.]메소드이름패턴 (파라미터 타입패턴 | "..", ...) [throws 예외패턴]

(1) 접근제한자 패턴(생략가능)

  • public, protected, private 등이 올 수 있다. 생략을 하면 이 항목에 대해 제한을 두지 않는다는 것을 의미한다.
  • ex. public

(2) 반환형 타입 패턴(필수)

  • 반드시 하나의 타입을 지정해야 한다. 또는 와일드카드(*)를 써서 모든 타입을 다 선택하겠다고 할 수 있다.
  • ex. int

(3) 패키지를 포함한 클래스의 타입 패턴(생략가능)

  • 패키지와 타입 이름을 포함한 클래스의 타입 패턴이다.
  • 클래스 및 인터페이스 이름에 와일드카드를 사용할 수 있다. 또는 '..' 를 사용하면 한 번에 여러 개의 패키지를 선택할 수 있다.
  • ex.springbook.learningtest.spring.pointcut.Target

(4) 메소드 이름 패턴(필수)

  • 와일드카드 사용이 가능하다.
  • ex. minus()

(5) 메소드 파라미터 타입 패턴(필수)

  • 쉼표로 메소드 파라미터의 타입을 구분하면서 순서대로 적으면 된다.
  • 파라미터가 없는 메소드를 지정하고 싶다면 () 로 적는다. 파라미터의 타입과 개수에 상관없이 모두 다 허용하는 패턴으로 만들려면 '...' 를 넣으면 된다.
  • '...' 를 이용해서 뒷부분의 파라미터 조건만 생략할 수도 있다.
  • ex. (int, int)

(6) 예외 이름에 대한 타입 패턴(생략가능)

  • ex. throws java.lang.RuntimeException

AspectJExpressionPointcut 사용 방법

  • AspectJExpressionPointcut 클래스의 오브젝트를 만들고 포인트컷 표현식을 expression property에 넣어주면 포인트컷을 사용할 준비가 된다. 포인트컷 표현식은 메소드 시그니처를 execution() 안에 넣어서 작성한다. execution() 은 메소드 실행에 대한 포인트컷이라는 의미이다.
@Test
void methodSignaturePointcut() throws NoSuchMethodException {

    // method full signature
    System.out.println(Target.class.getMethod("minus", int.class, int.class));

    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    pointcut.setExpression("execution(public int com.corgi.example.learningtest" +
            ".target.Target.minus(int,int) throws java.lang.RuntimeException)");

    /**
     * Target.minus()
     * ClassFilter와 MethodMatcher를 각각 가져와 비교한다.
     */
    assertEquals(true, pointcut.getClassFilter().matches(Target.class)
            && pointcut.getMethodMatcher().matches(Target.class.getMethod("minus", int.class, int.class), null));

    /**
     * Target.plus()
     */
    assertEquals(false, pointcut.getClassFilter().matches(Target.class)
            && pointcut.getMethodMatcher().matches(Target.class.getMethod("plus", int.class, int.class), null));
}
  • 아래와 같이 표현식을 사용시, 인터페이스를 구현한 메소드에만 포인트컷이 적용된다.
execution(* *..TargetInterface.*(..))

포인트컷 표현식을 이용하는 포인트컷 적용

  • execution() 외에도 스프링에서 사용될 떄 빈의 이름으로 비교하는 bean() 이 있다.
  • bean(*Service) 라고 쓰면 아이디가 Service로 끝나는 모든 빈을 선택한다.
  • 또한 타입, 메소드, 파라미터에 적용된 특정 어노테이션을 이용해 메소드를 선정하게 하는 포인트컷도 만들 수 있다. 아래와 같이 쓰면 @Transactional 어노테이션이 적용된 메소드를 선정하게 해준다.
@annotation(org.springframework.transacion.annotation.Transactional)
  • 포인트컷 표현식을 사용하면 클래스나 코드를 추가할 필요 없이 짧은 문자열을 통해 포인트컷 선정 알고리즘을 지정할 수 있어 편리하다. 하지만 문자열로 된 표현식이기 때문에 런타임 시점까지 문법의 검증이나 기능 확인이 되지 않는다는 단점도 있다.

AOP(Aspect Oriented Programming)란 무엇인가?

  • Aspect 란 그 자체로 애플리케이션의 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소로, 핵심 기능에 부가 기능을 부여해주는 특별한 모듈을 말한다.
  • Aspect 는 부가 기능을 정의한 코드인 Advice 와 Advice 를 어디에 적용할지를 결정하는 Pointcut 을 함께 갖고 있다. 지금까지 사용한 Advisor 는 아주 단순한 형태의 Aspect 라고 볼 수 있다.
  • 독립된 모듈 형태로 존재하는 Aspect 로 분리한 덕에 핵심 기능은 순수하게 그 기능을 담은 코드로만 존재할 수 있었다.
  • 이렇게 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 Aspect 라는 독립적인 모듈로 만들어서 설계하고 개발하는 방법을 AOP(Aspect Oriented Programming) = 관점 지향 프로그래밍 라고 부른다. * * 여기서 AOP가 OOP와 같은 패러다임으로 느껴지지만, AOP는 OOP를 돕는 보조적인 기술이지 OOP를 대체하는 새로운 개념은 아니다.

AOP 적용 기술 정리

  • 스프링 AOP는 독립적으로 개발한 부가 기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이나믹하게 적용해주기 위해 프록시를 이용하므로 스프링 AOP는 프록시 방식의 AOP라고 할 수 있다.

  • 프록시 방식의 AOP 말고도 다른 방식의 AOP가 존재하는데, 대표적으로 AspectJ는 프록시를 사용하지 않는 AOP 기술이다. AspectJ는 스프링처럼 다이나믹 프록시 방식을 사용하지 않는다.

  • AspectJ는 프록시처럼 간접적인 방식이 아니라 타깃 오브젝트를 뜯어고쳐서 부가 기능을 직접 넣어주는 방법을 사용한다. 여기서 부가 기능을 넣는다고 타깃 오브젝트의 소스코드를 수정할 수는 없으니, 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다.

  • 즉, 예제에서 UserService 클래스에 비즈니스 로직과 함께 있었을 때처럼 만들어버리는 것이다.
    프록시 방법이 아닌 컴파일된 클래스 파일의 수정이나 바이트 코드 조작과 같은 방법을 사용하는 이유는 두 가지를 생각해볼 수 있다.

    (1) 바이트코드를 조작하면 DI 컨테이너의 도움을 받아 자동 프록시 생성 방식을 사용하지 않아도 AOP 적용 가능(즉, 스프링과 같은 컨테이너가 사용되지 않는 환경에서도 AOP 적용 가능)
    (2) 프록시 방식보다 훨씬 강력하고 유연한 AOP 가능(프록시를 이용하면 AOP 적용 대상은 클라이언트가 호출하는 메소드로 제한된다. 하지만, 프록시를 이용하지 않고 직접적인 조작을 통해 AOP를 적용하면 private 메소드의 호출, 스태틱 메소드 호출이나 초기화, 심지어 필드 입출력 등에도 부가 기능 적용이 가능하다.)

  • 물론 대부분의 부가기능은 프록시 방식을 사용해 메소드의 호출 시점에 부여하는 것만으로도 충분하다. 게다가 AspectJ 같은 고급 AOP 기술은 바이트코드 조작을 위해 JVM의 실행 옵션을 변경하거나, 별도의 바이트코드 컴파일러를 사용하거나, 특별한 클래스 로더를 사용하게 하는 등의 번거로운 작업이 필요하다. 따라서 일반적인 AOP 적용에는 프록시를 사용하는 스프링 AOP로도 충분하다.

AOP 용어

  • 타깃

    • 부가기능을 부여할 대상이다. 핵심 기능을 담은 클래스일 수도 있지만 경우에 따라서 다른 부가 기능을 제공하는 프록시 오브젝트일 수도 있다.
  • 어드바이스

    • 타깃에게 제공할 부가기능을 담은 모듈이다. 어드바이스는 오브젝트로 정의하기도 하지만 메소드 레벨에서 정의할 수도 있다.
    • 어드바이스는 여러 종류가 있는데 메소드 호출 과정에 전반적으로 참여하는 것도 있지만, 예외가 발생했을 때만 동작하는 어드바이스처럼 메소드 호출 과정의 일부에서만 동작하는 어드바이스도 있다
  • 조인 포인트

    • 어드바이스가 적용될 수 있는 위치를 말한다. 스프링의 프록시 AOP에서 조인 포인트는 메소드의 실행 단계뿐이다. 타깃 오브젝트가 구현한 인터페이스의 모든 메소드는 조인 포인트가 된다.
  • 포인트컷

    • 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다.
    • 스프링 AOP의 조인 포인트는 메소드의 실행이므로 스프링의 포인트컷은 클래스와 메소드를 선정하는 기능을 갖고 있다.
  • 프록시

    • 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트다.
    • DI를 통해 타깃 대신 클라이언트에 주입되며, 클라이언트의 메소드 호출을 대신 받아 타깃에 위임해주면서 그 과정에서 부가 기능을 부여해준다.
  • 어드바이저

    • 포인트컷(어드바이스 적용 대상을 선정하는 알고리즘)과 어드바이스(부가 기능)를 하나씩 갖고 있는 오브젝트다.
    • 스프링은 자동 프록시 생성기가 어드바이저를 AOP 작업의 정보로 활용한다.
    • 스프링 AOP에서만 사용되는 특별한 용어로, 일반적인 AOP에서는 사용되지 않는다.
  • Aspect

    • 어드바이저와 마찬가지로 핵심 기능에 부가 기능을 부여해주는 오브젝트, 독립적인 모듈을 말한다. 일반적인 AOP에서 사용하는 용어로, 어드바이저를 포함하는 포괄적인 개념을 의미한다. 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 만들어지며 보통 싱글톤 오브젝트로 존재한다.
    • 스프링의 어드바이저는 아주 단순한 애스팩트라고 볼 수 있다.

스프링의 프록시 방식 AOP를 사용하기 위한 절차

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

자동 프록시 생성기

  • 스프링의 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록한다.
  • 다른 빈을 DI 하지도 않고, 자신도 DI 되지 않으며 독립적으로 존재한다.
  • 애플리케이션 컨텍스트가 빈 오브젝트를 생성하는 과정에서 빈 후처리기로 참여한다.
  • 빈으로 등록된 어드바이저를 이용해 프록시를 자동으로 생성하는 기능을 담당한다.

어드바이스

  • 부가 기능을 구현한 클래스

포인트컷

  • AspectJExpressionPointcut 을 빈으로 등록하고, expression 프로퍼티에 포인트컷 표현식을 넣어준다.

어드바이저

  • 스프링의 DefaultPointcutAdvisor 클래스를 빈으로 등록해서 사용한다.
  • 어드바이스와 포인트컷을 참조하는 것 외에는 기능이 없다.
  • 자동 프록시 생성기에 의해 자동 검색되어 사용된다.

트랜잭션 속성

DefaultTransactionDefinition 이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션의 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다. 네 가지 속성을 이용하면 트랜잭션의 동작 방식을 제어할 수 있다. 트랜잭션 정의를 수정하고 싶다면 TransactionDefinition 타입의 빈을 원하는 속성을 지정해 정의해두고, 트랜잭션을 적용할 빈에 DI 받아 사용하면 된다.

전파 속성(Transaction Propagation)

  • 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. 즉, 이미 진행 중인 트랜잭션에 참여할지, 아니면 독립적으로 트랜잭션을 시작할지를 결정하는 방식을 말한다.
  • 예를 들어, 각각의 트랜잭션 경계를 가진 A와 B라는 코드가 있을 때, A의 작업이 끝나지 않은 상태에서 B가 호출되면 B의 코드는 어떤 트랜잭션 안에서 동작해야 될지를 결정하는 것이다. 만약 두 코드가 하나의 트랜잭션으로 묶여있다면 둘 중 하나가 실패했을 때 전체가 롤백되어야 하고, 독립적인 트랜잭션으로 구성되어 있다면 하나가 실패했을 때 다른 트랜잭션이 적용되어 있는 코드는 영향을 받지 않아야 한다.
  • 대표적인 속성 3가지는 아래와 같다.

PROPAGATION_REQUIRED

  • 가장 많이 사용되는 속성으로 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있다면 이에 참여한다.
  • DefaultTransactionDefinition 의 트랜잭션 전파 속성은 이 속성으로 지정되어 있다.

PROPAGATION_REQUIRES_NEW

  • 항상 새로운 트랜젝션 새로 시작

PROPAGATION_NOT_SUPPORTED

  • 트랜잭션 없이 동작하도록 만든다. 진행 중인 트랜잭션이 있어도 무시한다.
  • 트랜잭션의 경계 설정은 보통 AOP를 이용해 한 번에 많은 메소드에 동시에 적용하는 방법을 사용하는데 그 중에서 특별한 메소드만 트랜잭션 적용에서 제외하기 위해 사용한다.

격리 수준(Isolation level)

  • 트랜잭션에서 일관성이 없는 데이터를 허용하는 수준을 말하며, 모든 DB 트랜잭션은 격리 수준을 갖고 있어야 한다. (예를 들어 하나의 트랜잭션이 끝나고, 데이터가 수정되었을 때 동일 데이터에 동시간대에 접근하는 다른 트랜잭션은 변경된 데이터를 읽어야하는지, 변경 이전의 데이터를 읽어야 하는지 등을 결정하는 옵션이다.)
  • 서버 환경에서는 여러 개의 트랜잭션이 동시에 진행될 수 있는데, 각각의 트랜잭션이 동시에 진행되면서도 문제가 발생하지 않도록 설정하는 것이 격리 수준이다.

제한 시간(timeout)

  • 트랜잭션을 수행하는 제한 시간을 설정할 수 있다.
  • DefaultTransactionDefinition 의 기본 설정은 제한 시간이 없으며, 제한 시간은 트랜잭션을 직접 시작하 수 있는 PROPAGATION_REQUIRED 나 PROPAGATION_REQUIRES_NEW 와 함께 사용해야만 의미가 있다.

읽기 전용 여부(read only)

  • 읽기 전용으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. (데이터 조작 시 예외 발생)
  • 또한 데이터 액세스 기술에 따라 성능이 향상될 수도 있다

TransactionInterceptor advice

  • 스프링에서는 편리하게 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 TransactionInterceptor advice를 제공한다.
  • 기존에 만들었던 TransactionAdivce 의 동작 방식과 다르지 않다. 다만 트랜잭션 정의를 메소드 이름 패턴을 이용해서 다르게 지정할 수 있는 방법을 추가로 제공해준다.
  • TransactionInterceptor 는 PlatformTransactionManager 와 Properties 타입의 두 가지 프로퍼티를 가지고 있는데, Properties 타입의 프로퍼티에는 트랜잭션 부가 기능의 동작 방식을 모두 제어할 수 있다. (ex. 어떤 예외가 발생했을 때 트랜잭션을 롤백할 것인지 지정)
  • 스프링에서는 기본적으로 런타임 예외가 발생하면 트랜잭션을 롤백시키며, 체크 예외가 발생할 경우 이것을 예외 상황으로 해석하지 않고 일종의 비즈니스 로직에 따라 의미가 있는 리턴 방식의 한 가지로 인식해서 트랜잭션을 커밋해버린다. 하지만, TransactionInterceptor 의 TransactionAttribute.rollbackOn() 이라는 속성에 체크 예외를 지정해주면, 체크 예외에 대해서도 롤백이 가능하다.
  • TransactionInterceptor advice에서 메소드 이름이 하나 이상의 패턴과 일치하는 경우가 있다면 메소드 이름 패턴 중에서 가장 정확히 일치하는 한가지에만 적용된다.

포인트컷과 트랜잭션 속성의 적용 전략

트랜잭션 포인트컷 표현식 적용 시 유의사항

  • 일반적으로 트랜잭션을 적용할 타깃 클래스의 메소드는 모두 트랜잭션 적용 후보가 되는 것이 바람직하다. 지금까지는 세부적으로 특정 메소드에만 적용해왔지만 비즈니스 로직을 담고 있는 클래스라면 메소드 단위까지 세밀하게 포인트컷을 지정해줄 필요는 없다.
  • 하나의 DB 작업을 수행하는 메소드도 트랜잭션이 적용되어야 한다. 트랜잭션 전파 방식을 생각해보면, 이 메소드도 요구 사항 변동이나 기능 구현에 따라 다른 트랜잭션에 참여할 가능성이 높다.
  • 쓰기 작업이 없는 단순 조회 작업을 수행하는 메소드에도 트랜잭션이 적용되어야 한다. 읽기 전용 옵션을 통해 성능 향상을 꾀하거나, 복잡한 조회의 경우 제한 시간을 적용할 수도 있고, 격리 수준에 따라 조회도 반드시 트랜잭션 안에서 진행되어야 할 필요가 발생하기도 한다.

공통된 메소드 이름 규칙을 사용하여 최소한의 어드바이스와 속성 사용

  • 공통된 메소드 이름 규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의하여 사용하는 것이 좋다. 하나의 어플리케이션 안에서 사용할 트랜잭션 속성의 종류는 그다지 다양하지 않다. 너무 많은 속성을 부여하면 관리만 힘들어질 뿐이다.

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

  • 프록시 방식의 AOP에서는 프록시를 통한 부가 기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 여기서 클라이언트는 인터페이스를 통해 타깃 오브젝트를 사용하는 오브젝트를 말한다. 반대로 타깃 오브젝트가 자기 자신의 메소드를 호출할 때는 프록시를 통한 부가 기능이 적용되지 않으므로 주의해야 한다.

트랜잭션 경계설정의 일원화

  • 트랜잭션 경계설정의 부가 기능을 여러 계층에서 중구난방으로 적용하는 건 좋지 않다. 일반적으로는 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하며, 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다.
  • 서비스 계층을 트랜잭션이 시작되고 종료되는 경계로 정했다면, 테스트와 같은 특별한 이유가 아니고는 다른 계층이나 모듈에서 DAO에 직접 접근하는 것은 차단해야 한다. 트랜잭션은 보통 서비스 계층의 메소드 조합을 통해 만들어지기 때문에 DAO가 제공하는 주요 기능은 서비스 계층에 위임 메소드를 만들어둘 필요가 있다.
  • 가능하면 다른 모듈의 DAO에 접근할 때는 서비스 계층을 거치도록 하는 게 바람직하다. 그래야 UserService의 add() 처럼 부가 로직을 적용할 수도 있고, 트랜잭션 속성도 제어할 수 있기 때문이다. 예를 들어 UserService가 아니라면 UserDao에 직접 접근하지 않고 UserService의 메소드를 이용하는 편이 좋다.

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

  • 클래스나 메소드에 따라 세밀하게 튜닝된 트랜잭션 속성을 적용해야 하는 경우에 메소드 이름 패턴을 이용해 일괄적으로 트랜잭션을 적용하는 방법은 적합하지 않다.
  • 대신에 스프링이 제공하는 다른 방법을 이용할 수 있는데 설정파일에서 패턴으로 분류 가능한 그룹을 만들어서 일괄적으로 속성을 부여하는 대신에 직접 타깃에 트랜잭션 속성정보를 가진 애노테이션을 지정하는 방법이다.

@Transactional

  • 애노테이션을 사용할 대상을 지정한다. 여기서는 메소드와 타입(클래스, 인터페이스)처럼 한 개 이상의 대상을 지정할 수 있다.
@Target({ElementType.TYPE, ElementType.METHOD})
  • 애노테이션 정보가 언제까지 유지되는지를 지정한다. 이렇게 설정하면 런타임 때도 애노테이션 정보를 리플렉션을 통해 얻을 수 있다.
@Retention(RetentionPolicy.RUNTIME)
  • 상속을 통해서도 애노테이션 정보를 얻을 수 있게 한다.
@Inherited
  • 트랜잭션 속성의 모든 항목을 애노테이션의 엘리먼트로 지정할 수 있다. 디폴트 값이 설정되어 있으므로 모두 생략 가능하다.
public @interface Transactional {
    @AliasFor("transactionManager")
    String value() default "";
    @AliasFor("value")
    String transactionManager() default "";
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    int timeout() default -1;
    boolean readOnly() default false;
    Class<? extends Throwable>[] rollbackFor() default {};
    String[] rollbackForClassName() default {};
    Class<? extends Throwable>[] noRollbackFor() default {};
    String[] noRollbackForClassName() default {};
}
  • @Transactional 애노테이션을 트랜잭션 속성정보로 사용하도록 지정하면 스프링은 @Transactional 이 부여된 모든 오브젝트를 자동으로 타깃 오브젝트로 인식한다.
  • 이때 포인트컷은 TransactionAttributeSourcePointcut 이 사용되는데 @Transactional 애노테이션이 부여된 모든 빈 오브젝트를 찾아서 포인트컷의 선정 결과로 돌려준다. @Transactional 은 기본적으로 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다.

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

  • TransactionInterceptor 는 메소드 이름 패턴을 통해 부여되는 일괄적인 트랜잭션 속성 정보 대신에 @Transactional 애노테이션 엘리먼트의 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource 를 사용한다.
  • @Transactional 은 메소드마다 다르게 설정할 수도 있으므로 매우 유연한 트랜잭션 속성 설정이 가능하다.

대체(fallback) 정책

  • 스프링은 @Transactional 을 적용할 때 4단계의 대체(fallback) 정책을 이용해 트랜잭션을 적용 한다. 타깃 메소드 - 타깃 클래스 - 선언 메소드 - 선언 타입(클래스, 인터페이스)의 순서로 @Transactional 이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성정보를 사용한다.
  • 가장 먼저 타깃 메소드에 @Transactional 이 있는지 확인하고, 있다면 이를 속성으로 사용한다. 그리고 없으면 다음 대체 후보인 타깃 클래스에 부여된 @Transactional 을 확인한다. 이런 식으로 메소드가 선언된 타입까지 단계적으로 확인해서 발견되면 적용하고, 발견되지 않으면 해당 메소드는 트랜잭션 적용 대상이 아니라고 판단한다.
/**
 * @Transactional 대체 정책의 예
 * [1] ~ [4] 순으로 @Transactional 애노테이션을 확인한다.
 */
[4]
public interface Service {
    [3]
    void method1();
}

[2]
public class ServiceImpl implements Service {
    [1]
    public void method1() { }
}
  • @Transactional 을 사용하면 대체 정책을 잘 활용해서 애노테이션 자체는 최소한으로 사용하면서도 세밀한 제어가 가능하다.
  • 기본적으로는 @Transactional 을 타입 레벨에 정의하고, 공통 속성을 따르지 않는 메소드에 대해서만 메소드 레벨에 다시 @Transactional 을 사용하는 식으로 적용하는 것이 좋다.
  • 하지만 인터페이스를 사용하는 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 정의한 @Transactional 은 무시되기 때문에 안전하게 타깃 클래스에 사용하는 것이 좋다.
/**
 * 예제 - @Transactional 애노테이션을 이용한 속성 부여
 */
@Transactional
public interface UserService {
    // @Transactional이 없으므로 대체 정책에 따라 타입 레벨에 부여된 디폴트 속성이 적용된다. 
    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();
}

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

  • AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법을 선언적 트랜잭션(declarative transaction) 이라고 한다.
  • 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 프로그램에 의한 트랜잭션(Programmatic transaction) 이라고 한다.
  • 스프링은 위 두 가지 방식을 모두 지원하며, 특별한 경우가 아니라면 선언적 트랜잭션 방식을 사용하는 것이 바람직하다.

트랜잭션 테스트

@Configuration
public class DatabaseConfig {
    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}
...

@Autowired
private PlatformTransactionManager transactionManager;

...

/**
 * 트랜잭션 동기화 테스트(앞서 시작한 트랜잭션에 해당 메소드가 참여하여 돌아가는지 확인)
 */
@Test
void transactionSync() {
    DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
    txDefinition.setReadOnly(true);
    TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);

    userDao.deleteAll();
}

/**
 * 트랜잭션 롤백 테스트
 */
@Test
void transactionRollback() {
    userDao.deleteAll();
    assertEquals(0, userDao.getCount());

    // 트랜잭션 정의는 기본 값을 사용한다.
    DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();

    // 트랜잭션 매니저에게 트랜잭션을 요청한다. 기존에 시작된 트랜잭션이 없으니 새로운 트랜잭션을 시작시키고 트랜잭션 정보를 돌려준다.
    // 동시에 만들어진 트랜잭션을 다른 곳에서도 사용할 수 있도록 동기화한다.
    TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);

    userService.add(users.get(0));
    userService.add(users.get(1));
    assertEquals(2, userDao.getCount());

    // 강제로 롤백한다 트랜잭션 시작 전 상태로 돌아가야 한다.
    transactionManager.rollback(txStatus);

    // add()의 작업이 취소되고 트랜잭션 시작 이전의 상태임을 확인할 수 있다.
    assertEquals(0, userDao.getCount());
}
@Test
void transactionSync() {
    DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
  	  TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);

    try {
	// 테스트 코드 상의 모든 작업을 하나의 트랜잭션으로 통합한다.
        userDao.deleteAll();
        userDao.add(users.get(0));
        userDao.add(users.get(1));
    } finally {
	// 테스트 결과가 어떻든 상관없이 테스트가 끝나면 무조건 롤백한다. 
        // 테스트 중에 발생했던 DB의 변경 사항은 모두 이전 상태로 복구한다.
        transactionManager.rollback(txStatus);
    }
}

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

@Transactional

  • @Transactional 애노테이션을 테스트 클래스와 메소드에도 적용할 수 있다. (앞서 사용했던 트랜잭션 매니저를 사용한 코드들을 애노테이션으로 대체할 수 있음)
  • 테스트 클래스, 메소드 레벨에 @Transactional 애노테이션을 사용하면 테스트 실행 전에 새로운 트랜잭션 하나를 만들어주고, 예외가 발생하지 않고 테스트가 종료되면, 트랜잭션을 커밋해준다.

@Rollback

  • 테스트에 적용된 @Transactional 은 기본적으로 트랜잭션을 강제 롤백시키도록 설정되어 있다.
  • 테스트 코드 종료 이후에 강제 롤백을 원하지 않는다면 @Rollback 을 사용하면 된다. 이 애노테이션은 롤백 여부를 지정하는 값을 가지고 있는데, default가 true이므로 강제 롤백을 원하지 않는다면 @Rollback(false) 와 같이 사용하면 된다.
  • 테스트 메소드 레벨에만 적용 가능하다.

@TransactionCOnfiguration

  • @Rollback 은 테스트 메소드 레벨에만 적용 가능하므로 테스트 클래스 전체에 걸쳐 모든 트랜잭션이 롤백되지 않고 커밋되게 하려면 클래스 레벨에 적용할 수 있는 @TransactionConfiguration 을 사용하면 된다.

@NotTransactional && Propagation.NEVER

  • @NotTransactional 을 테스트 메소드 레벨에 부여하면 클래스 레벨의 @Transactional 설정을 무시하고, 트랜잭션을 시작하지 않은 채로 테스트를 진행한다. (Spring 3.0부터 deprecated 설정되었으므로 사용하지 말 것)
  • 이 대신에 @Transactional 의 트랜잭션 전파 속성 중 Propagation.NEVER 를 사용하면 @NotTransactional 과 마찬가지로 트랜잭션이 시작되지 않는다.

효과적인 DB 테스트

  • 고립된 상태에서 테스트를 진행하는 단위 테스트와 외부 리소스, 여러 계층의 클래스가 참여하는 통합 테스트는 아예 클래스를 구분해서 따로 만드는 게 좋다.
  • DB가 사용되는 통합 테스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional 을 부여해 롤백 테스트로 만드는게 좋다. 테스트 사이에 서로 영향을 주지 않아 각각의 테스트를 독립된 테스트로 만들기가 편해지기 때문이다.
profile
Mechanical & Computer Science

0개의 댓글