본문 바로가기
Programming/Languages (Java, etc)

[Spring] AOP 2편 - Spring의 AOP과 @Transactional

by kghworks 2023. 12. 3.

2023.12.02 - [Programming/JAVA] - [Java] AOP 핵심 기술 - Proxy, Dynamic proxy, Factory bean

 

[Java] AOP 핵심 기술 - Proxy, Dynamic proxy, Factory bean

목차 AOP란 프락시(Proxy)의 정의, 프락시 패턴 다이내믹 프락시 다이내믹 프락시 예제 한계 스프링의 ProxyFactoryBean : 어드바이저 Spring 3대 기술 (DI, PSA, AOP) 중 하나인 AOP는 프락시 기반으로 동작한

kghworks.tistory.com

이전 포스팅에서 이어진다. 이번 포스팅에서는 트랜잭션을 스프링 AOP를 사용해 구현해 본다. 스프링이 제공하는 어노테이션 @Transactional을 사용해 더 직관적이고 간단하게 트랜잭션 AOP를 구현한다. 마지막으로 트랜잭션을 테스트에서 다루는 작은 팁을 공유한다.

 

목차

  • 자동 프락시 생성과 Spring AOP
  • AOP 를 구현하는 방법
  • 스프링의 트랜잭션 설정
  • @Transactional
  • 트랜잭션과 테스트

자동 프록시 생성과 Spring AOP

 앞선 포스팅에서 스프링의 ProxyFactoryBean을 사용하면 새로운 Advisor가 추가될 때마다 ProxyFactoryBean을 추가해줘야하는 번거로움이 있었다. 스프링은 org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator 빈을 통해 이 부분을 자동화한다. DefaultAdvisorAutoProxyCreator은 Advisor를 탐색해 자동으로 프록시를 생성하는 자동 프록시 생성기다. 책 (토비의 스프링)에서는 빈 후처리기라고 한다.  DefaultPointcutAdvisor 빈을 탐색해  포인트컷을 기반으로 프록시 적용 대상인지 확인하여 프록시 적용 대상이면 기존의 빈을 프록시로 교체하여 컨테이너에 반환한다. 

 

DefaultAdvisorAutoProxyCreator 적용

 먼저 DefaultAdvisorAutoProxyCreator가 사용할 포인트컷을 구현한 뒤 빈으로 등록한다. 

import org.springframework.aop.ClassFilter;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.util.PatternMatchUtils;

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName) {
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }

    static class SimpleClassFilter implements ClassFilter {
        private String mappedName;

        public SimpleClassFilter(String mappedName) {
            this.mappedName = mappedName;
        }

        @Override
        public boolean matches(Class<?> clazz) {
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
        }
    }
}

...

@Bean
public NameMatchClassMethodPointcut transactionPointcut() {
    NameMatchClassMethodPointcut nameMatchClassMethodPointcut = new NameMatchClassMethodPointcut();
    nameMatchClassMethodPointcut.setMappedClassName("*ServiceImpl");
    nameMatchClassMethodPointcut.setMappedNames("update*", "delete*");
    return nameMatchClassMethodPointcut;
}

 

그 다음 DefaultAdvisorAutoProxyCreator을 빈으로만 등록해주면 된다.

@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
    return new DefaultAdvisorAutoProxyCreator();
}

 

 이제 스프링이 시작될 때 DefaultAdvisorAutoProxyCreator가 Advisor 구현체를 찾아 포인트컷 빈을 기반으로 프록시를 생성하여 컨테이너에 반환한다. 

 

DefaultAdvisorAutoProxyCreator가 자동으로 생성하는 프록시

 이제 위 그림에서 각 *Service 빈을 명시하는 빈 설정 코드는 모두 지워지고 스프링 DefaultAdvisorAutoProxyCreator빈이 포인트컷을 참고해서 프록시를 생성해 컨테이너에 반환한다.

@Autowired
private UserService userService;

@Test
void autoFactoryTransaction() throws Exception {

    System.out.println("class           : " + userService.getClass());
    System.out.println("super class     : " + userService.getClass().getSuperclass().getName());
    System.out.println("interfaces      : " + userService.getClass().getInterfaces()[0].getName());
}

 

포인트컷 표현식 : 포인트컷 선정을 더 유연하게

 메서드나 클래스의 이름을 기준으로 하지않고 더 복잡하고 유연한 기준으로 포인트컷을 지정할 수 있다. 앞에서는 org.springframework.aop.ClassFilter 구현체를 사용해 포인트컷을 만들었다. 매우 번거로운데 이 모든 것은 org.springframework.aop.support.AspectJEpxressionPointcut으로 대체할 수 있다.  아래처럼 포인트컷을 지정해보자.

@Bean
public AspectJExpressionPointcut transactionPointcut() {
    AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
    aspectJExpressionPointcut.setExpression(
            "execution(* *..*ServiceImpl.update*(..)) || execution(* *..*ServiceImpl.delete*(..))"
    );
    return aspectJExpressionPointcut;
}

AspectJEpxressionPointcut로 포인트컷 간단히 작성하기

 

특정 @어노테이션, bean 이름 등으로 지정할 수 있다. 더 풍부한 표현식 사용법은 아래 링크를 참고하자

https://www.baeldung.com/spring-aop-pointcut-tutorial


AOP 를 구현하는 방법

 AOP를 구현하는 방법은 크게 2가지가 있다. AOP의 주 목적은 타깃 오브젝트에 일괄적으로 부가기능을 더하는 것이다. Spring은 앞선 포스팅에서 보았던 프락시 방법으로 AOP를 구현한다.

 

프락시를 이용해 AOP 구현

 지금까지 봐온 방법이다. 개발자가 작성한 빈 대신 프락시로 컨테이너에 교체해서 반환해둔다. 코드에서 타겟을 호출하면 프락시가 적용된다. 개발자는 프락시(타겟, 빈)을 스프링 ProxyFactoryBean으로부터 빈으로 생성시켜 편하게 DI를 적용해 사용한다.

 

바이트 코드 생성, 조작으로 AOP 구현

 대표적인 AOP 프레임워크 AspectJ가 해당된다. 타겟 오브젝트의 바이트 코드를 직접 조작한다. 혹슨 컴파일된 타깃 클래스를 수정하기도 한다. 스프링 컨테이너, DI의 도움 없어도 된다는 장점이 있다. 또한 아주 강력하고 유연한데, Spring AOP는 클래스-메서드 레벨까지만 타겟을 지정할 수 있다면 AspectJ는 필드, 생성자, 파라미터, 지역변수 등 더 다양한 레벨로 유연하게 타겟을 지정할 수 있다. 

 

일반적인 프로젝트에서는 프락시 기반의 AOP면 충분하고 아주 드물게 필요에 따라 AspectJ를 사용할 수 있다.


스프링의 트랜잭션 설정

스프링의 트랜잭션 설정은 org.springframework.transaction.TransactionDefinition 에 명세된다. org.springframework.transaction.support.DefaultTransactionDefinition을 기본 구현체로 사용한다. 

 

트랜잭션 전파 (Transaction propagation)

 DefaultTransactionDefinition에는 아래처럼 트랜잭션 전파 속성을 지정할 수 있다. 

DefaultTransactionDefinition의 propagationConstants 필드
setPropagationBehaviorName 메서드

총 7가지의 전파 속성이 있지만 아래 3가지만 살펴보자

 

  • PROPAGATION_REQUIRED : 이미 진행 중인 트랜잭션이 있으면 참여하고 없으면 새로운 트랜잭션을 시작
  • PROPAGATION_REQUIRED_NEW : 항상 새로운 트랜잭션을 시작
  • PROPAGATION_NOT_SUPPORTED : 이미 진행 중인 트랜잭션이 있으면 일시정지하고 트랜잭션 없이 진행

기본값은 PROPAGATION_REQUIRED이다. 

 

트랜잭션 고립 레벨 (Isolation level)

 트랜잭션의 고립레벨은 기본적으로 DB에 지정되어있다. 필요에 따라 JDBC Driver, DataSource, 트랜잭션 단위 별로 설정할 수 있다. 

DefaultTransactionDefinition의 기본 레벨은 ISOLATION_DEFAULT이다.

DefaultTransactionDefinition의&nbsp;isolationConstants 필드
setIsolationLevelName 메서드

 

트랜잭션 고립레벨에 대한 설명은 아래 링크에 포스팅해두었다.

https://kghworks.tistory.com/84

 

[RDBMS] Transaction Isolation Level (트랜잭션 고립 수준)

목차 트랜잭션 고립 수준 종류 참고 트랜잭션 고립 수준 동일한 트랜잭션 시나리오 (입력, 작업)에도 불구하고 고립 수준에 따라 그 결과가 다를 수 있습니다. ANSI / ISO SQL 표준에서는 이와 같은

kghworks.tistory.com

 

제한시간 (timeout)

DefaultTransactionDefinition의 TIMOUT_DEFAULT 필드

 

트랜잭션 수행시간에 제한을 두는 것으로 제한시간이 넘어가면 롤백한다.

 

읽기전용 (read only)

 트랜잭션을 읽기 전용으로 실행한다. DML을 막는다. 데이터 엑세스 기술에 따라 read-only 속성은 성능을 향상 시킬수 있다. (e.g. Hibernate) 기본값은 false이다. 

DefaultTransactionDefinition의 setReadOnly 메서드

@Test
public void txSyncException() {
    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setReadOnly(true);

    transactionManager.getTransaction(def);

    assertThrows(TransientDataAccessException.class, () ->
            userService.deleteAll()
    );
}

 

readonly인 트랜잭션에서 DML을 일으키면 위처럼 TransientDataAccessException이 발생한다.

 

TransactionInterceptor를 어드바이스로 사용하기

기존의 transactionAdvice 구조

 

 org.springframework.transaction.interceptor.TransactionInterceptor은 트랜잭션 속성을 정의하고 어드바이스로 사용할 수 있는 클래스다. 기본적으로 예외를 롤백하고, 체크 예외는 커밋한다.

@Bean
public TransactionInterceptor transactionAdvice() {
    TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
    transactionInterceptor.setTransactionManager(transactionManager());
    Properties properties = new Properties();
    properties.setProperty("get*", "readOnly");
    properties.setProperty("*", "");

    transactionInterceptor.setTransactionAttributes(properties);
    return transactionInterceptor;
}

@Bean
public TransactionInterceptor transactionBatchadvice() {
    TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
    transactionInterceptor.setTransactionManager(transactionManager());
    Properties properties = new Properties();
    // Batch에 따른 특별한 처리
    properties.setProperty("get*", "readOnly");
    properties.setProperty("*", "PROPAGATION_REQUIRED");

    transactionInterceptor.setTransactionAttributes(properties);
    return transactionInterceptor;
}

TransactionInterceptor가 적용된&nbsp;transactionAdvice

트랜잭션 속성 적용하기

public interface UserService {

    public void add(User user);

    public User get(String name);

    List<User> getAll();

    void deleteAll();

    public void updateNameGroupUpper();
}

@AllArgsConstructor
public class UserServiceImpl implements UserService {
    private UserDao userDao;


    @Override
    public void updateNameGroupUpper() {

        List<User> userList = userDao.findAll();

        userList.stream().forEach(member -> {
            member.setGroupName(member.getGroupName().toUpperCase());
            userDao.updateGroupName(member);
        });
    }

    @Override
    public User get(String name) {
        return userDao.findByName(name);
    }

    @Override
    public List<User> getAll() {
        return userDao.findAll();
    }

    @Override
    public void deleteAll() {
        userDao.deleteAll();
    }

    @Override
    public void add(User user) {
        userDao.add(user);
    }

}

 

@Bean
public AspectJExpressionPointcut transactionPointcut() {
  AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
  aspectJExpressionPointcut.setExpression(
          "bean(*Service)"
  );
  return aspectJExpressionPointcut;
}

@Bean
public TransactionInterceptor transactionAdvice() {
    TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
    transactionInterceptor.setTransactionManager(transactionManager());
    Properties properties = new Properties();
    properties.setProperty("get*", "PROPAGATION_REQUIRED, readOnly");
    properties.setProperty("*", "PROPAGATION_REQUIRED");

    transactionInterceptor.setTransactionAttributes(properties);
    return transactionInterceptor;
}
@Test
public void txTest() {
    User karina1 = new User("Karina", 23, "aespa");
    userService.add(karina1);

    User karina2 = new User("Karina", 23, "aespa");

    assertThrows(DuplicateKeyException.class, () -> {
        userService.add(karina2);
    });

    User karina = userService.get("Karina");
    assertThat(karina, is(notNullValue()));
}

 

주의사항, 팁 

 트랜잭션의 포인트컷 표현식은 타입 패턴이나 빈 이름으로 포괄적으로 지정한다. 특정 메서드, 예외 패턴 등으로 세부적으로 지정하는 것은 바람직하지 않다. execution(* *..*ServiceImpl.*(..))이나 bean(*Service)처럼 클래스에 전체적으로 적용하는 것이 바람직하다. 그렇지 않으면 트랜잭션 설정이 중구난방 관리가 어려워진다. 즉 트랜잭션이 적용되는 메서드의 모든 메서드에 트랜잭션을 적용하는 것이 일반적으로 맞다.

 트랜잭션 적용 대상 포인트컷 표현식은 간결하게 하고, 하나의 어드바이스로 애플리케이션 전체에 트랜잭션 속성을 적용하도록 한다. 위 소스처럼 배치작업과 같이 특수한 트랜잭션 속성이 필요한 클래스의 경우 어드바이스를 하나 더 추가하여 정의한다.

 쓰기 작업이 없는 단순 조회 메서드도 트랜잭션을 적용한다. (e.g. getAll()) 엑세스 기술에 따라 성능이 향상된다. (Hibernate) 또한 트랜잭션 안에서 읽기 작업이 일어날 수도 있다. 

 프록시 방식 AOP는 같은 타깃 오브젝트 내의 메서드 호출 시 프락시 (부가기능)가 적용되지 않는다. 당연하다 프락시는 클래스 레벨로 타겟 오브젝트 바깥에 있는 객체다. 타겟오브젝트 내에서 자기 자신을 호출해봣자 부가기능은 적용되지 않는다. 예를들어 userService 타겟의 getAll()에서 delleteAll()을 호출해봤자 새로운 트랜잭션이 열리지 않는다. 


@Transactional

 클래스나 메서드 레벨에서 세밀하게 트랜잭션 속성을 지정하고 싶을 때가 있다. 이 때 간편하게 지정할 수 있도록 도와주는 어노테이션

org.springframework.transaction.annotation.Transactional이 있다. 이 방법이 앞의 Advisor 빈을 선언하는 방법보다 직관적이라고 보는 개발자가 많아서 요즘은 더 많이 쓰이는 추세이다. 

package springbook.learningtest.spring.transaction;

import java.lang.annotation.Documented;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE}) // 애노테이션 적용 대상
@Retention(RetentionPolicy.RUNTIME) // 애노테이션 정보 유지 기간
@Inherited // 상속 가능
@Documented
public @interface Transactional {
    Propagation propagation() default Propagation.REQUIRED;

    Isolation isolation() default Isolation.DEFAULT;

    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;

    Class<? extends Throwable>[] rollbackFor() default {};

    String[] rollbackForClassName() default {};

    Class<? extends Throwable>[] noRollbackFor() default {};

    String[] noRollbackForClassName() default {};
}

 

 메서드, 클래스, 인터페이스에 적용이 가능하다. @Transactional이 붙은 오브젝트는 자동으로 타깃 오브젝트로 인식한다. 포인트컷으로는 org.springframework.transaction.interceptor.TransactionAttributeSourcePointcut을 사용한다.  @Transactional의 속성 정보를 TransactionInterceptor가 transactionAttributeSource에 관리한다. 

 

@Transactional 프록시 생성

 

@Transactional 우선순위

// [1]
public interface Service {
    // [2]
    void method();
}

// [3]
public class ServiceImpl implements Service {
    // [4]
    public void method() {
    }
}

 

 숫자가 높을 수록 우선순위가 높다. 만약 ServiceImpl의 [4]에 어노테이션이 있다면 1~3에 어노테이션의 유무에 상관없이 [4]를 mehtod()에 적용한다. 만약 2~4에 없는데, [1]에 있다면 Service구현체는 모든 메서드에 @Transactional을 적용한 것과 같다. 

 

적용예시

import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Transactional
public interface UserService {

    public void add(User user);

    @Transactional(readOnly = true)
    public User get(String name);

    @Transactional(readOnly = true)
    List<User> getAll();

    void deleteAll();

    public void updateNameGroupUpper();
}

트랜잭션과 테스트

 앞에서 본 AOP를 통한 트랜잭션 제어는 선언적 트랜잭션 (declarative transaction)이라 한다. AOP를 통해 트랜잭션 제어 기능을 외부 AOP 모듈 (Aspect)로부터 주입하는 것이다. 반대로 코드에서 직접 트랜잭션을 제어하고 싶을 수 있다. 이를테면 애플리케이션 전체에 적용된 AOP 트랜잭션을 무시하고 특정 메서드에서는 커밋, 롤백을 명시적으로 하고싶다던가... 이를 프로그램에 의한 트랜잭션 (programmatic transasction)이라고 한다. 코드 내부에서 직접 트랜잭션을 제어하는 방법이다.

 

@Autowired
private PlatformTransactionManager transactionManager;

@Test
public void txSync() {
    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    // 트랜잭션 매니저에게 트랜잭션 요청
    TransactionStatus txStatus = transactionManager.getTransaction(def);

    userService.deleteAll();
    userService.add(new User("Karina", 23, "aespa"));

    // 트랜잭션 커밋
    transactionManager.commit(txStatus);
}

 

 위 deleteAll()과 add()는 각각 @Transactional이 있음에도 하나의 트랜잭션에서 실행된다. txSync()에서 이미 트랜잭션을 시작하고 deleteAll()을 시작하기 때문에 txSync()에 열어둔 트랜잭션을 REQUIRED 속성에 의해 계속 사용하기 때문이다. 진짜일까? 내가 프로그램으로 트랜잭션을 연 설정이 진짜 적용되는지 아래처럼 확인할 수 있다.

@Test
public void txSyncException() {
    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setReadOnly(true);

    transactionManager.getTransaction(def);

    assertThrows(TransientDataAccessException.class, () ->
            userService.deleteAll()
    );
}

 

롤백 테스트

DB와 연관된 테스트가 수행된 뒤 모든 디비 작업을 롤백하는 것을 말한다. 

@Test
public void txSyncRollback() {
    userService.deleteAll();

    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    TransactionStatus txStatus = transactionManager.getTransaction(def);

    try {
        userService.add(new User("Karina", 23, "aespa"));
        userService.add(new User("Giselle", 23, "aespa"));
        userService.add(new User("Winter", 20, "aespa"));

    } finally {
        transactionManager.rollback(txStatus);
    }

    assertThat(userService.getAll().size(), is(0));
}

 

 아래처럼 @Transactional을 사용해 롤백할 수 있다. 테스트 클래스에 적용된 @Transactional은 테스트가 끝나면 기본적으로 롤백하기 때문이다. 회피하고 싶으면 @Rollback(false) 를 사용하면 된다.

@Test
@Transactional(readOnly = true)
public void txSyncExceptionAnno() {
    assertThrows(TransientDataAccessException.class, () ->
            userService.deleteAll()
    );
}

 

DB 트랜잭션 테스트 팁

 일반적으로 단위 테스트와 통합 테스트는 테스트 클래스가 분리되어있다. DB가 사용되는 테스트는 클래스 래밸에 @Transactional을 선언해 롤백 테스트로 만들자. 테스트는 어떤 경우에도 테스트 간의 의존하며 안되고, 디비와도 의존되면 안된다. 롤백 테스트는 대부분의 경우 유용하다. 


참고

https://product.kyobobook.co.kr/detail/S000000935360

 

토비의 스프링 3.1 세트 | 이일민 - 교보문고

토비의 스프링 3.1 세트 | 애플리케이션 아키텍처 설계부터 프레임워크 제작까지 다룬 스프링 가이드북!『토비의 스프링 3.1 세트』는 스프링을 처음 접하거나 스프링을 경험했지만 스프링이 어

product.kyobobook.co.kr

https://docs.spring.io/spring-framework/reference/data-access/jdbc.html 

 

Data Access with JDBC :: Spring Framework

The value provided by the Spring Framework JDBC abstraction is perhaps best shown by the sequence of actions outlined in the following table below. The table shows which actions Spring takes care of and which actions are your responsibility.

docs.spring.io

 

https://www.baeldung.com/spring-jdbc-jdbctemplate

https://www.baeldung.com/spring-aop-pointcut-tutorial 

https://kghworks.tistory.com/84

 

[RDBMS] Transaction Isolation Level (트랜잭션 고립 수준)

목차 트랜잭션 고립 수준 종류 참고 트랜잭션 고립 수준 동일한 트랜잭션 시나리오 (입력, 작업)에도 불구하고 고립 수준에 따라 그 결과가 다를 수 있습니다. ANSI / ISO SQL 표준에서는 이와 같은

kghworks.tistory.com

 

댓글