토비의 스프링 - AOP - 2
by Gunju Ko
이 글은 “토비의 스프링” 책 내용을 정리한 글입니다.
만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.
토비의 스프링 - AOP 2
1. 트랜잭션 속성
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
Object result = invocation.proceed();
this.transactionManager.commit(status);
return result;
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
- 트랜잭션의 경계는 트랜잭션 매니저에게 트랜잭션을 가져오는 것과 commit(), rollback() 중의 하나를 호출하는 것으로 설정된다.
1.1 트랜잭션 정의
- DefaultTransactionDefinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션의 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다.
- 트랜잭션 전파 : 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식
- PROPAGATION_REQUIRED : 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다. DefaultTransactionDefinition의 트랜잭션 전파 속성이 PROPAGATION_REQUIRED 이다.
- PROPAGATION_REQUIRES_NEW : 항상 새로운 트랜잭션을 시작한다. 즉 앞에서 시작된 트랜잭션이 있든 없든 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 한다.
- PROPAGATION_NOT_SUPPORTED : 이 속성을 사용하면 트랜잭션없이 동작하도록 만들 수도 있다. 진행 중인 트랜잭션이 있어도 무시한다.
- getTransaction( ) 메소드는 항상 트랜잭션을 새로 시작하는 것이 아니다.트랜잭션 전파 속성과 현재 진행 중인 트랜잭션이 존재하는지 여부에 따라서 새로운 트랜잭션을 시작할 수도 있고, 이미 진행중인 트랜잭션에 참여하기만 할 수도 있다. 진행 중인 트랜잭션에 참여하는 경우는 트랜잭션 경계의 끝에서 트랜잭션을 커밋시키지도 않는다. 최초로 트랜잭션을 시작한 경계까지 정상적으로 진행돼야 비로소 커밋될 수 있다.
- 격리수준 : 모든 DB 트랜잭션은 격리 수준을 갖고 있어야한다. 적절하게 격리수준을 조정해서 가능한 한 많은 트랜잭션을 동시에 진행시키면서도 문제가 발생하지 않게 하는 제어가 필요하다.격리수준은 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서 재설정할 수 있고, 필요하다면 트랜잭션 단위로 격리수준을 조정할 수 있다. DefaultTransactionDefinition에 설정된 격리수준은 ISOLATION_DEFAULT다. 이는 DataSource에 설정되어 있는 디폴트 격리수준을 그대로 따른다는 뜻이다.
- 제한시간 : 트랜잭션을 수행하는 제한시간을 설정할 수 있다. DefaultTransactionDefinition의 기본 설정은 제한시간이 없는 것이다. 제한시간은 트랜잭션을 직접 시작할 수 있는 PROPAGATION_REQUIRED, PROPAGATION_REQUIRES_NEW와 함께 사용해야만 의미가 있다.
- 읽기전용 : 읽기 전용으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다.
1.2 트랜잭션 인터셉터와 트랜잭션 속성
TransactionInterceptor
- 스프링에서 제공하는 클래스로 편리하게 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 만들어졌다.
- 트랜잭션 정의를 메소드 이름 패턴을 이용해서 다르게 지정할 수 있는 방법을 추가로 제공
- PlatformTransactionManager와 Properties 타입의 두 가지 프로퍼티를 갖고 있다.
- Properties는 트랜잭션 속성을 정의한 프로퍼티다. 트랜잭션 속성은 TransactionDefinition의 네 가지 기본 항목에 rollbackOn( )이라는 메소드를 하나 더 갖고 있는 TransactionAttribute 인터페이스로 정의된다.
- rollbackOn() 메소드는 예외가 발생하면 롤백을 할 것인가를 결정하는 메소드다.
- 스프링이 제공하는 TransactionInterceptor에는 기본적으로 두 가지 종류의 예외 처리 방식이 있다.
- 런타임 예외 : 트랜잭션은 롤백된다.
- 체크 예외 : 예외상황으로 해석하지 않고 일종의 비즈니스 로직에 따른, 의미가 있는 리턴 방식의 한 가지로 인식해서 트랜잭션을 커밋한다.
- 스프링의 기본적인 예외처리 원칙에 따라 비즈니스적인 의미가 있는 예외상황만 체크 예외를 사용하고, 그 외의 모든 복구 불가능한 순수한 예외의 경우는 런타임 예외로 포장돼서 전달하는 방식을 따른다고 가정하기 때문이다.
- TransactionInterceptor의 이러한 예외처리 기본 원칙을 따르지 않는 경우가 있을 수 있다. 그래서 TransactionAttribute는 rollbackOn( )이라는 속성을 둬서 기본원칙과 다른 예외처리가 가능하게 해준다.
- TransactionInterceptor는 이런 TransactionAttribute를 Properties라는 일종의 맵 타입 오브젝트로 전달받는다. 컬렉션을 사용하는 이유는 메소드 패턴에 따라서 각기 다른 트랜잭션 속성을 부여할 수 있게 하기 위해서다.
메소드 이름 패턴을 이용한 트랜잭션 속성 지정
- TransactionInterceptor의 Properties 타입 프로퍼티는 메소드 패턴과 트랜잭션 속성을 키와 값으로 갖는 컬렉션이다.
- 트랜잭션 속성은 아래와 같은 문자열로 정의할 수 있다.
- PROPAGATION_NAME, ISOLATION_NAME, readOnly, timeout_NNNN, -Exception1, +Exception2
- 트랜잭션 전파 항목만 필수이고 나머지는 전부 생략 가능하다. 생략하면 디폴트 속성이 부여된다.
- 순서는 바뀌어도 상관없다.
- readOnly : 읽기 전용 항목. 생략 가능하다.
- -Exception1 : 체크 예외 중에서 롤백 대상으로 추가할 것을 넣는다. 한 개 이상 등록 가능하다.
- +Exception1 : 런타임 예외지만 롤백시키지 않을 예외들을 넣는다. 한 개 이상 등록 가능하다.
- 트랜잭션 속성 중 readOnly나 timeout 등은 트랜잭션이 처음 시작될 때가 아니라면 적용되지 않는다.
- 메소드 이름이 하나 이상의 패턴과 일치하는 경우에는 가장 정확히 일치하는 것이 적용된다.
- TransactionInterceptor를 사용해서 트랜잭션 어드바이스를 정의하면 메소드 이름 패턴에 따라 서로 다른 트랜잭션 속성을 사용하도록 할 수 있다.
3. 포인트 컷과 트랜잭션 속성의 적용 전략
트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다.
- 트랜잭션용 포인트컷 표현식에는 메소드나 파라미터, 예외에 대한 패턴을 정의하지 않는게 바람직하다.
- 트랜잭션의 경계로 삼을 클래스들이 선정됐다면, 그 클래스들이 모여 있는 패키지를 통째로 선택하거나 클래스 이름에서 일정한 패턴을 찾아서 표현식으로 만들면 된다.
- 가능하면 클래스보다는 인터페이스 타입을 기준으로 타입 패턴을 적용하는 것이 좋다.
- 스프링의 빈 이름을 이용하는 bean() 표현식을 사용하는 방법도 좋다.
공통된 메소드 이름 규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다
- 트랜잭션 적용 대상 클래스의 메소드는 일정한 명명 규칙을 따르게 해야 한다.
- 기준이 되는 몇 가지 트랜잭션 속성을 정의하고 그에 따라 적절한 메소드 명명 규칙을 만들어두면 하나의 어드바이스만으로 애플리케이션의 모든 서비스 빈에 트랜잭션 속성을 지정할수있다.
프록시 방식 AOP는 같은 타킷 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다.
- 프록시 방식의 AOP에서는 프록시를 통한 부가기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다.
- 자기 자신의 메소드를 호출할 때는 프록시를 통한 부가기능의 적용이 일어나지 않는다.
- 위의 그림은 트랜잭션 프록시가 타깃에 적용되어 있는 경우의 메소드 호출 과정을 보여준다.
- 1, 3 : 트랜잭션 경계설정 부가기능이 부여된다.
- 2 : 프록시를 거치지 않고 직접 메소드를 호출하기 때문에, 트랜잭션 경계설정 부가기능이 부여되지 않는다.
- 타깃 안에서의 호출에는 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가있다.
- 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제하는 방법
- AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP 기술을 적용
1.4 트랜잭션 속성 적용
트랜잭션 경계설정의 일원화
- 일반적으로 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하다
- 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다.
- 가능하면 다른 모듈의 DAO에 접근할 때는 서비스 계층을 거치도록 하는 게 바람직하다.
- 서비스 계층에서 다른 모듈의 DAO를 직접 이용할 때 신중을 기해야 한다. 안전하게 사용하려면 다른 모듈의 서비스 계층을 통해 접근하는 방법이 좋다.
트랜잭션 속성 테스트
- 읽기 전용 트랜잭션에서 데이터를 조작하는 작업을 시도하는 경우 TransientDataAccessResourceException이 발생한다.
2 애노테이션 트랜잭션 속성과 포인트컷
2.1 트랜잭션 애노테이션
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
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 TransactionDefinition.TIMEOUT_DEFAULT;
boolean readOnly() default false;
Class<? extends Throwable>[] rollbackFor() default {};
String[] rollbackForClassName() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
String[] noRollbackForClassName() default {};
}
- @Transactional
- 트랜잭션 속성의 모든 항목을 엘리먼트로 지정할 수 있다. 디폴트 값이 설정되어 있으므로 모두 생략이 가능하다.
- @Inherited : 상속을 통해서도 애노테이션 정보를 얻을 수 있게 한다.
- @Transactional은 기본적으로 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다.
- 이 때 사용되는 포인트컷은 TransactionAttributeSourcePointcut이다.
트랜잭션 속성을 이용하는 포인트컷
- Transactionlnterceptor는 메소드 이름 패턴을 통해 부여되는 일괄적인 트랜잭션 속성 정보 대신 @Transactional 애노테이션의 엘리먼트에서 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource를 사용한다.
- 아래 그림은 Transactional 애노태이션을 사용했을 때 어드바이저의 동작 방식을 보여준다.
- @Transactional 방식을 이용하면 포인트컷과 트랜잭션 속성을 애노테이션 하나로 지정할 수 있다. 트랜잭션 속성은 타입 레벨에 일괄적으로 부여할 수도 있지만 메소드 단위로 세분화해서 트랜잭션 속성을 다르게 지정할 수도 있기 때문에 매우 세밀한 트랜잭션 속성 제어가 가능해진다.
대체 정책
-
메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스, 인터메이스)의 순서에 따라서 @Transactional이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성 정보를 사용한다.
-
@Transactional을 사용하면 대체 정책을 잘 활용해서 애노태이션 자체는 최소한으로 사용하면서도 세밀한 제어가 가능하다.
[1]
public interface Service {
[2]
void method1();
[3]
void method2();
}
[4]
public class Servicelmpl implements Service {
[5]
public void method1() (
[6]
public void method2() {
}
- [5], [6] : 스프링은 트랜잭션 기능이 부여될 위치인 타깃 오브젝트의 메소드부터 시작해서 @Transactional 애노테이션이 존재하는지 확인한다. 따라서 [5], [6]번이 @Transactional이 위치할 수 있는 첫번째 후보이다.
- [4] : 메소드에서 @Transactional을 발견하지 못하면, 다음은 타깃 클래스를 확인한다.
- [2, 3] : 스프링은 메소드가 선언된 인터페이스로 넘어간다. 인터페이스에서도 먼저 메소드를 확인한다.
-
[1] : 인터페이스 타입 [1]의 위치에 애노태이션이 있는지 확인한다.
- @Transactional도 타깃 클래스보다는 인터페이스에 두는 게 바람직하다. 하지만 인터페이스를 사용하는 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 정의한 @Transactional은 무시되기 때문에 안전하게 타깃 클래스에 @Transactional을 두는 방법을 권장한다.
테스트를 해보니 CGLIB 방식의 AOP를 사용하는 경우, 인터페이스에 @Transactonal을 붙이는 경우 트랜잭션이 적용되는 것을 확인했다.
3. 트랜잭션 지원 테스트
3.1 선언적 트랜잭션과 트랜잭션 전파 속성
- add() 메소드에 REQUIRED 방식의 트랜잭션 전파 속성을 지정했을 때 트랜잭션이 시작되고 종료되는 경계를 보여준다. add() 메소드도 스스로 트랜잭션 경계를 설정할 수 있지만, 때로는 다른 메소드에서 만들어진 트랜잭션의 경계 안에 포함된다.
- 트랜잭션을 부여하는 두가지 방법
- 선언적 트랜잭션 : AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법
- 프로그램에 의한 트랜잭션 : TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법
3.2 트랜잭션 동기화와 테스트
트랜잭션 매니저와 트랜잭션 동기화
- 트랜잭션 추상화 기술의 핵심은 트랜잭션 매니저와 트랜잭션 동기화다.
- 트랜잭션 매니저 : PlatformTransactionManager 인터페이스를 구현한 트랜잭션 매니저를 통해 구체적인 트랜잭션 기술의 종류에 상관없이 일관된 트랜잭션 제어가 가능했다.
- 트랜잭션 동기화 : 트랜잭션 동기화 기술이 있었기에 시작된 트랜잭션 정보를 저장소에 보관해뒀다가 DAO에서 공유 할 수 있다.
- 트랜잭션 동기화 기술은 트랜잭션 전파를 위해서도 중요한 역할을 한다. 진행 중인 트랜잭션이 있는지 확인하고 트랜잭션 전파 속성에 따라서 이에 참여할 수 있도록 만들어주는 것도 트랜잭션 동기화 기술 덕분이다.
@Test
public void transactionSync() {
userService.deleteAll();
userService.add(users.get(0));
userService.add(users.get(1));
}
- transactionSync() 테스트 메소드가 실행되는 동안에 몇 개의 트랜잭션이 만들어졌을까? UserService의 모든 메소드에는 트랜잭션을 적용했으니 당연히 3개다. 각 메소드가 모두 독립적인 트랜잭션 안에서 실행된다.
트랜잭션 매니저를 이용한 테스트용 트랜잭션 제어
- 그렇다면 이 테스트 메소드에서 만들어지는 세 개의 트랜잭션을 하나로 통합할 수는 없을까?
- 세 개의 메소드 모두 트랜잭션 전파 속성이 REQUIRED이니 이 메소드들이 호출되기전에 트랜잭션이 시작되게만 한다면 가능하다.
- 테스트 메소드에서 UserService의 메소드를 호출하기 전에 트랜잭션을 미리 시작해주면 된다.
- 트랜잭션의 전파는 트랜잭션 매니저를 통해 트랜잭션 동기화 방식이 적용되기 때문에 가능하다고 했다. 그렇다면 테스트에서 트랜잭션 매니저를 이용해 트랜잭션을 시작시키고 이를 동기화해주면 된다.
@Test
public void transactionSync() {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(definition);
userService.deleteAll();
userService.add(users.get(0));
userService.add(users.get(1));
transactionManager.commit(status);
}
- 테스트 코드에서 트랜잭션 매니저를 이용해서 트랜잭션을 만들고 그 후에 실행되는 UserService의 메소드들이 같은 트랜잭션에 참여하게 만들 수 있다. 세 개의 메소드 모두 속성이 REQUIRED이므로 이미 시작된 트랜잭션이 있으면 참여하고 새로운 트랜잭션을 만들지 않는다.
트랜잭션 동기화 검증
- 트랜잭션 속성 중에서 읽기전용과 제한시간 등은 처음 트랜잭션이 시작할 때만 적용되고 그 이후에 참여하는 메소드의 속성은 무시된다.
@Test
public void transactionSync() {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(definition);
definition.setReadOnly(true);
userService.deleteAll();
userService.add(users.get(0));
userService.add(users.get(1));
transactionManager.commit(status);
}
-
위의 테스트를 실행하면 TransientDataAccessResourceException이 발생한다. 읽기 전용 트랜잭션에서 쓰기를 했기 때문이다.
- 스프링의 트랜잭션 추상화가 제공하는 트랜잭션 동기화 기술과 트랜잭션 전파 속성 덕분에 테스트도 트랙잭션으로 묶을 수 있다.
- JdbcTemplate과 같이 스프링이 제공하는 데이터 액세스 추상화를 적용한 DAO에도 동일한 영향을 미친다. JdbcTemplate은 트랜잭션이 시작된 것이 있으면 그 트랜잭션에 자동으로 참여하고, 없으면 트랜잭션 없이 자동커밋 모드로 JDBC 작업을 수행한다. 개념은 조금 다르지만 JdbcTemplate의 메소드 단위로 마치 트랜잭션 전파 속성이 REQUIRED인것 처럼 동작 한다고 볼 수 있다.
롤백 테스트
- 롤백 테스트는 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게하고 테스트가 끝나면 무조건 롤백해버리는 테스트를 말한다.
@Test
public void transactionSync() throws InterruptedException {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(definition);
try {
userService.deleteAll();
userService.add(users.get(0));
userService.add(users.get(1));
} finally {
transactionManager.rollback(status);
}
}
- 롤백 테스트는 DB 작업이 포함된 테스트가 수행돼도 DB에 영향을 주지 않기 때문에 장점이 많다.
- 테스트용 데이터를 DB에 잘 준비해놓더라도 앞에서 실행된 테스트에서 DB의 데이터를 바꿔버리면 이후에 실행되는 테스트에 영향을 미칠 수 있다.
- 이런 이유 때문에 롤백 테스트는 매우 유용하다. 롤백 테스트는 테스트를 진행하는 동안에 조작한 데이터를 모두 롤백하고 테스트를 시작하기 전 상태로 만들어주기 때문이다.
- 테스트에서 트랜잭션을 제어할 수 있기 때문에 얻을 수 있는 가장 큰 유익이 있다면 바로 롤백 테스트다.
3.3 테스트를 위한 트랜잭션 애노테이션
스프링의 컨텍스트 테스트 프레임워크는 애노테이션을 이용해 테스트를 편리하게 만들 수 있는 여러 가지 기능을 추가하게 해준다.
@Transactional
- 테스트에도 @Transactional을 적용할 수 있다. 테스트 클래스 또는 메소드에 @Transactional 애노태이션을 부여해주면 마치 타깃 클래스나 인터페이스에 적용된 것처럼 테스트 메소드에 트랜잭션 경계가 자동으로 설정된다.
- 테스트에서 사용하는 @Transactional은 AOP를 위한 것은 아니다. 단지 컨텍스트 테스트 프레임워크에 의해 트랜잭션을 부여해주는 용도로 쓰일 뿐이다.
@Rollback
- 테스트 메소드나 클래스에 사용하는 @Transactional은 애플리케이션의 클래스에 적용할 때와 디폴트 속성은 동일하다. 하지만 중요한 차이점이 있는데, 테스트용 트랜잭션은 테스트가 끝나면 자동으로 롤백된다는 것이다. 테스트에 적용된 @Transactional은 기본적으로 트랜잭션을 강제 롤백시키도록 설정되어 있다.
- 테스트 메소드 안에서 진행되는 작업을 하나의 트랜잭션으로 묶고 싶기는 하지만 강제 롤백을 원하지 않을 수도 있다. 이때는 @Rollback이라는 애노테이션을 이용하면 된다. @Rollback은 롤백 여부를 지정하는 값을 갖고 있다. @Rollback의 기본 값은 true다. 따라서 트랜잭션은 적용되지만 롤백을 원치 않는다면 @Rollback(false)라고 해줘야 한다.
@TransactionConfiguration
- @Rollback 애노테이션은 메소드 레벨에만 적용할 수 있다.
- 테스트 클래스의 모든 테스트 메소드에 트랜잭션을 적용하면서 롤백이 되지 않도록 하고 싶다면, 클래스 레벨에 부여할 수 있는 @TransactionConfiguration 애노테이션을 이용하면 편리하다.
- @TransactionConfiguration을 사용하면 롤백에 대한 공통 속성을 지정할 수 있다. 디폴트 롤백 속성은 false로 해두고, 테스트 메소드 중에서 일부만 롤백을 적용하고 싶으면 메소드에 @Rollback을 부여해주면 된다
Propagation.NEVER
- @Transactional(propagation = Propagation.NEVER)을 테스트 메소드에 부여하면 트랜잭션을 시작하지 않은 채로 테스트를 진행한다. 물론 테스트 안에서 호출하는 메소드에서 트랜잭션을 사용하는데는 영향을 주지 않는다.
효과적인 DB 테스트
- DB가 사용되는 통합 테스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional을 부여해준다. DB가 사용되는 통합 테스트는 가능한 한 롤백 테스트로 만드는 게 좋다.
- 테스트가 기본적으로 롤백 테스트로 되어 있다면 테스트 사이에 서로 영향을 주지 않으므로 독립적이고 자동화된 테스트로 만들기가 매우 편하다.
4. 정리
- AOP를 이용해 트랜잭션 속성을 지정하는 방법에는 포인트컷 표현식과 메소드 이름 패턴을 이용하는 방법과 타깃에 직접 부여하는 @Transactional 애노테이션을 사용하는 방법이 있다.
- @Transactional을 이용한 트랜잭션 속성을 테스트에 적용하면 손쉽게 DB를 사용하는 코드의 테스트를 만들 수 있다.