Coding/개발 서적

[토비의 스프링 3.1 Vol 1] 06장. AOP

kangplay 2025. 4. 16. 14:39

AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3개 기반 기술의 하나이다. AOP의 등장 배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지 알아보도록 하자.

6.1 트랜잭션의 코드 분리

지금까지 서비스 추상화 기법을 적용해 트랜잭션 기술에 독립적으로 만들어줬고, 메일 발송 기술과 환경에도 종속적이지 않은 깔끔한 코드로 다듬어온 UserService이지만, 트랜잭션의 경계가 비즈니스 로직의 전후에 설정되어야함에 따라 비즈니스 코드에 트랜잭션 코드가 더 많은 자리를 차지하고 있다.

이 코드의 특징은 트랜잭션의 경계 설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없다는 점이다. 즉, 두 코드는 완벽하게 독립적인 코드이다. 

6.1.1 DI를 통한 클래스의 분리

이를 해결하기 위해 트랜잭션 코드를 클래스 밖으로 뽑아내보자. 먼저, 기존 UserService는 UserServiceImpl로 변경하고, UserService 인터페이스를 생성한 뒤, 클라이언트가 인터페이스에 의존하도록 하자. 그리고, 클라이언트가 한 번에 두개의 UserService 인터페이스 구현체를 동시에 이용하도록 하면 된다.

UserServiceTx는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션의 경계설정이라는 책임을 맡고 있을 뿐이다. 즉, 또 비즈니스 로직을 담고 있는 UserService 구현 클래스에 실제적인 로직 처리 작업을 위임하는 것이다.

6.2 고립된 단위 테스트

테스트 대상이 다른 오브젝트와 환경에 의존하고 있다면 작은 단위의 테스트가 주는 장점을 얻기 힘들다. 

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

테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트를 위한 대역을 사용하는 것이다. 

하지만, UserServiceImpl의 upgradeLevels() 메소드는 리턴 값이 없는 void 형이다. 따라서 메소드를 실행하고 그 결과를 받아서 검증해야하지만 DB에 데이터가 저장되지 않으므로 다른 방법을 생각해야 한다.

-> UserDao의 update() 메소드를 호출하는 것을 확인할 수 있다면, 결국 DB에 그 결과가 반영될 것이라고 결론을 내릴 수 있다!

userDao.getAll() 기능을 지원하기 위해서는 미리 준비된 사용자 목록을 제공해줘야 한다(스텁 오브젝트). update() 메서드는 리턴 값이 없으므로, update() 메소드를 실행하면서 넘겨준 업그레이드 대상 User 오브젝트를 저장해뒀다가 검증을 위해 돌려주어야 한다(목 오브젝트).

6.2.2 단위 테스트와 통합 테스트

  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
  • DAO는 DB까지 연동하는 테스트로 만드는 편이 효과적이다.
  • 단위 테스트를 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다. 
  • 테스트는 가능한 빨리 작성하도록 해야한다.

6.2.3 목 프레임워크

단위 테스트가 많은 장점이 있고 가장 우선시해야 할 테스트 방법인 건 사실이지만 작성이 번거롭다는 점이 문제다. 특히 목 오브젝트를 만드는 일이 가장 큰 짐이다. 다행히도, 이런 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다.

 

그 중에서도 Mockito라는 프레임워크는 사용하기도 편리하고, 코드도 직관적이라 최근 많은 인기를 끌고 있다. UserDao 인터페이스를 구현한 테스트용 목 오브젝트는 다음과 같이 Mockito의 static 메소드를 한 번 호출해주면 만들어진다.

UserDao mockUserDao = mock(UserDao.class);

이렇게 만들어진 목 오브젝트는 아직 아무런 기능이 없다. 여기에 getAll() 메소드를 불려올 때 사용자 목록을 리턴하도록 스텁 기능을 추가해줘야 한다.

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

Mockito를 통해 만들어진 목 오브젝트는  메소드 호출과 관련된 모든 내용을 자동으로 저장해두고, 이를 간단한 메소드로 검증할 수 있게 해준다.

verify(mockUserDao, times(2)).update(any(User.class));
//verify(mockUserDao).update(users.get(1))은 users.get(1)을 파라미터로 update()가 호출된 적이 있는지 확인

각각의 오브젝트가 호출됐는지는 확인했지만, 레벨의 변화는 파라미터의 직접 비교로슨ㄴ 확인이 되지 않는다. 이때, ArgumentCaptor 라는 것을 사용해서 전달된 객체를 꺼내 실제 내부값까지 검증이 가능하다.

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

트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있었다. 이렇게 분리된 부가 기능을 담은 클래스는 중요한 특징이 있다. 부가기능 외의 나머지 모든 기능은 원래 핵심 기능을 가진 클래스로 위임해줘야 한다는 사실이다.

 

문제는, 클라이언트가 핵심 기능을 가진 클래스를 직접 사용해버리면 부가 기능을 적용될 기회가 없다는 점이다. 그래서, 부가 기능은 마치 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐 핵심 기능을 사용하도록 만들어야 한다. 이때, 클라이언트는 인터페이스를 통해서만 핵심 기능을 사용하게 하고, 부가 기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다. 

6.3.1 데코레이터 패턴과 프록시 패턴

이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 프록시라고 부른다. 프록시의 사용 목적에 따라 두 가지로 구분할 수 있다.

  • 데코레이터 패턴: 타깃에 부가적인 기능을 런타임 시에 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴이다.
  • 프록시 패턴: 클라이언트가 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우 사용하는 패턴이다.

6.3.2 다이내믹 프록시 - JDK 동적 프록시

일일이 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성할 수 있다.

🪞 리플렉션이란?
다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은, 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다. 그 중 Method 인터페이스에 정의된 invoke() 메소드를 사용하면, 메소드를 직접 호출하지 않고도, 메소드명을 인자로 넘기면 그 결과를 Object 타입으로 반환해준다. 

 

자바가 제공하는 JDK 동적 프록시를 사용하면, 다이내믹 프록시를 생성할 수 있다. (인터페이스 필수)

이를 위해서는 프록시의 기능을 수행할 InvocationHandler를 생성한다. 이렇게 해서 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다.

  • Object target: 동적 프록시가 호출할 대상
  • method.invoke(target,args): 리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다. args는 메서드 호출 시 넘겨줄 인자이다.

그 다음, 클래스 로더 정보와 인터페이스, 그리고 핸들러 로직을 인자로 갖는 프록시 객체를 생성해야한다. 동적 프록시는 java.lang.reflect.Proxy를 통해서 생성할 수 있다.

그림으로 정리하면 다음과 같다.

스프링 애플리케이션에서는 Config 파일에서 프록시 생성 및 반환을 해주면 된다. 즉, 프록시 객체를 스프링 빈으로 등록하고, 실체 객체는 스프링 빈으로 등록하면 안된다.

6.3.3 다이내믹 프록시 - CGLIB

CGLIB는 바이트 코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리로, 이를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.

동적 프록시에 적용한 공통 로직을 정의한 MethodInterceptor
Enhancer를 이용한 동적 프록시 객체 생성
그림으로 설명

6.4  스프링의 프록시 팩토리 빈

6.4.1 프록시 팩토리 

스프링은 트랜잭션 기술과 메일 발송 기술에 적용했던 서비스 추상화를 프록시 기술에도 동일하게 적용하고 있다. 자바에서는 여러 프록시 기술(JDK 동적 프록시,CGLIB)을 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다.

Advice는 프록시에 적용하는 부가 기능 로직이다. JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor 의 개념과 유사하다. 프록시 팩토리를 사용하면 둘 대신에 Advice를 사용하면 된다.

@Slf4j
public class TimeAdvice implements MethodInterceptor {
	@Override
	public Object invoke(MethodInvocation invocation) throws Throwable {
		log.info("TimeProxy 실행");
		long startTime = System.currentTimeMillis();
		Object result = invocation.proceed();
		long endTime = System.currentTimeMillis();
		long resultTime = endTime - startTime;
		log.info("TimeProxy 종료 resultTime={}ms", resultTime);
		return result;
	}
}
//CGLIB의 `MethodInterceptor` 와 이름이 같으므로 패키지 이름에 주의하자
//기존에 보았던 코드와 다르게 target 클래스 정보가 없다. target 클래스의 정보는 MethodInvocation 안에 모두 포함되어 있다.
//참고로 여기서 사용하는 `org.aopalliance.intercept` 패키지는 스프링 AOP 모듈(`spring-aop` )안에 들어있다.
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFacotry.getProxy();
proxy.save();
  • new ProxyFactory(target): 프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 인스턴스 정보를 기반으로 프록시를 만들어낸다. 만약 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고 인터페이스가 없고 구체 클래스만 있다면 CGLIB 통해서 동적 프록시 생성한다.
  • proxyFactory.addAdvice(new TimeAdvice()): 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 `InvocationHandler` CGLIB 제공하는`MethodInterceptor` 개념과 유사하다.
  • proxyFactory.getProxy() : 프록시 객체를 생성하고 결과를 받는다.

6.4.2 포인트컷, 어드바이스, 어드바이저

  • 포인트컷(Pointcut): 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링 한다.
  • 어드바이스(Advice): 프록시가 호출하는 부가 기능이다.
  • 어드바이저(Advisor): 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이다.

코드로 구현하면 다음과 같다.

포인트 컷 코드는 다음과 같다.

static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save";
	
    @Override
	public boolean matches(Method method, Class<?> targetClass) {
		boolean result = method.getName().equals(matchName);
		log.info("포인트컷 호출 method={} targetClass={}", method.getName(), targetClass);
		log.info("포인트컷 결과 result={}", result);
		return result;
	}
    
	@Override
	public boolean isRuntime() {
		return false;
        //false이면 위에 matches, true이면 아래 matches 메서드 호출
	}
    
	@Override
	public boolean matches(Method method, Class<?> targetClass, Object... args)
	{
		throw new UnsupportedOperationException();
	}
}
  • matches(): 이 메서드에 `method` , `targetClass` 정보가 넘어온다. 이 정보로 어드바이스를 적용할지 적용하지 않을지 판단할 수 있다.
  • isRuntime(),matches(... args): isRuntime()  값이 참이면 matches(... args) 메서드가 대신 호출된다. 동적으로 넘어오는 매개변수를 판단 로직으로 사용할 있다.
    • isRuntime() false  경우 클래스의 정적 정보만 사용하기 때문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만, isRuntime()  true  경우 매개변수가 동적으로 변경된다고 가정하기 문에 캐싱을 하지 않는다.

-> 스프링은 무수히 많은 포인트 컷을 제공한다. 특히, AspectJExpressionPointcut을 주로 사용하는데, 이는 이후 AOP를 설명할 때 자세히 알아보자.

6.4.3 하나의 프록시, 여러 어드바이저

만약 여러 어드바이저를 하나의 target에 적용하려면 어떻게 해야할까? 

스프링 프록시 빈은 여러 프록시를 생성하지 않아도, 하나의 프록시에 여러 어드바이저를 적용할 수 있게 만들어두었다. 

@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
	void multiAdvisorTest2() {
	//proxy -> advisor2 -> advisor1 -> target
	DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE,
	DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2()); new Advice1());
	ServiceInterface target = new ServiceImpl();
	ProxyFactory proxyFactory1 = new ProxyFactory(target);
	proxyFactory1.addAdvisor(advisor2);
	proxyFactory1.addAdvisor(advisor1);
	ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();
	//실행
	proxy.save();
}

-> 스프링의 AOP는 target마다 하나의 프록시만 생성한다! 이를 꼭 기억해두자.

6.4.4 프록시 팩토리의 남은 문제

프록시 팩토리를 도입하면서 많은 문제들이 해결되었다. 하지만, 아직 해결되지 않은 문제가 있다.

  1. 너무 많은 설정: 애플리케이션에 스프링 빈이 100개가 있다면, 여기에 부가 기능을 적용하기 위해 100개의 동적 프록시 생성 코드를 만들어야 한다.
  2. 컴포넌트 스캔: 최근에는 스프링 빈을 등록하기 귀찮아서 컴포넌트 스캔까지 사용하는데, 이렇게 직접 등록하는 것도 모자라서, 프록시를 적용하는 코드까지 빈 생성 코드에 넣어야 한다.

6.5 빈 후처리기

6.5.1 빈 후처리기의 장점

스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용하면 된다. 빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.

@Slf4j
static class AToBPostProcessor implements BeanPostProcessor {
	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
		log.info("beanName={} bean={}", beanName, bean);
		 if (bean instanceof A) {
			return new B();
		}
		return bean;
	}
}

이를 통해, 빈 후처리기로 프록시를 스프링 빈으로 등록하는 게 가능해진다.

이제 프록시를 생성하는 코드가 설정 파일에는 필요 없다. 순수한 빈 등록하고, 프록시 관련 과정은 빈 후처리기가 모두 처리해준다.

6.5.2 스프링이 제공하는 빈 후처리기

스프링 부트 자동 설정으로, AnnotaionAwareAspectJAutoProxyCreator 라는 빈 후처리기가 스프링 빈에 자동으로 등록된다. 이 빈 후처리기는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. 즉, 어드바이저에 있는 포인트 컷을 통해 프록시 적용 대상 여부를 체크한다. 

즉, 포인트 컷의 역할은 두 가지이다!

  1. 프록시 적용 여부 판단 - 생성 단계: 자동 프록시 생성기는 포인트컷을 사용해서 해당 빈이 프록시를 생성할 필요가 있는지 체크한다. 클래스 + 메서드 조건을 모두 비교한다. 포인트컷 조건에 하나하나 매칭해보고, 만약 조건에 맞는 메소드가 하나라도 있으면 프록시를 생성한다.
  2. 어드바이스 적용 여부 판단 - 사용 단계: 프록시가 호출되었을 때 부가 기능인 어드바이스를 적용할지 말지 포인트컷을 보고 판단한다.
포인트 컷 역할의 변화
빈 후처리기를 적용하기 전, 수동으로 ProxyFactory에 타켓 객체를 지정했을 때에는 포인트 컷의 클래스 필터링 메소드는 아무 역할을 하지 못했다. 내가 직접 타겟 객체 지정하기 때문이다.
하지만, 빈 후처리기를 적용하면서 스프링 컨테이너가 빈을 만들 때 Advisor(Pointcut + Advice)를 스프링 빈으로 등록해둔 걸 보고 빈 하나하나에 대해 "이 클래스에 어드바이저(Pointcut) 매칭되는 게 있을까?" 검사함으로써, 포인트 컷의 역할이 확장되었다고 볼 수 있다!

즉, 모든 스프링 빈에 프록시를 적용하는 것이 아니라 포인트 컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다.

AspectJExpressionPointcut은 패키지에 메서드 이름까지 함께 지정할 수 있는 매우 정밀한 포인트 컷이다.

스프링 빈이 여러 포인트 컷의 조건을 만족하더라도, 프록시 자동 생성기는 프록시를 하나만 생성한다. 왜냐하면, 프록시 팩토리가 생성하는 프록시는 내부에 여러 advisor들을 포함할 수 있기 때문이다.
- advisor1의 포인트 컷만 만족 -> 프록시 1개 생성, advisor1만 포함
- advisor1, advisor2의 포인트컷을 모두 만족 -> 프록시 1개 생성, advisor1, advisor2 모두 포함
- advisor1, advisor2의 포인트컷을 모두 만족하지 않음 -> 프록시가 생성되지 않음

6.6 트랜잭션 속성

트랜잭션의 경계는 트랜잭션 매니저에게 트랜잭션을 가져오는 것과 commit(), rollback() 중의 하나를 호출하는 것으로 설정되고 있다. 

6.6.1 트랜잭션 속성 종류

DefaultTransactionDeifinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다. 

 

1. 트랜잭션 전파

트랜잭션 전파란, 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. 

  • PROPAGATION_REQUIRED
    • 가장 많이 사용되는 트랜잭션 전파 속성으로, 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트래잭션에 있으면 이에 참여한다. 
  • PROPAGATION_REQUIRED_NEW
    • 항상 새로운 트랜잭션을 시작한다. 즉, 앞에서 시작된 트랜잭션이 있든 없든 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 한다.
  • PROPAGATION_NOT_SUPPORTED
    • 트랜잭션 없이 동작할 수 있게 하는 속성이다. 기존 트랜잭션(a)을 일시적으로 중단(suspend) 시키고, b 메서드를 트랜잭션 없이(non-transactional) 실행한다. 그리고 b 실행이 끝나면 a 트랜잭션은 다시 재개된다.
    • 특정 메소드의 전파 속성만 해당 전파 속성으로 설정하면, 특정 메소드에만 트랜잭션 적용에서 제외할 수 있다.
진행 중인 트랜잭션에 참여하는 경우는 트랜잭션 경계의 끝에서 트랜잭션을 커밋시키지 않는다. 최초로 트랜잭션을 시작한 경계까지 정상적으로 진행되어야 비로소 커밋될 수 있다.
✴️ 진행 중인 트랜잭션에 참여하는 경우, 트랜잭션 경계의 끝에서 트랜잭션을 롤백시키는 경우, 상위 트랜잭션도 롤백된다. 단, 상위 트랜잭션의 커넥션이 종료되어야, DB에 롤백이 반영된다. (그 전에는 단일 트랜잭션 일관 조회 특성으로 인해 롤백 전 데이터를 읽게 된다.)

 

2. 트랜잭션 격리수준

서버환경에서는 여러 개의 트랜잭션이 동시에 진행될 수 있으므로, 적절하게 격리 수준을 조정해서 가능한 한 많은 트랜잭션을 동시에 진행시키면서도, 문제가 발생하지 않게 하는 제어가 필요하다.

 

3. 제한 시간

트랜잭션을 수행하는 제한시간을 설정할 수 있다. DefaultTransactionDefinition의 기본 설정은 제한시간이 없는 것이다.

 

4. 읽기 전용

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

JPA는 영속성 컨텍스트에서 더티 감지를 생략하고 쓰기 지연 저장소를 생략해준다. JPA 내부의 최적화는 잘 동작하지만, DB 레벨에서도 변경을 금지하려면 별도 설정이 필요하다. MySQL는 기본적으로 읽기 전용 힌트를 받아들이는 설정이 활성화되어 있고,  H2 Database는  기본 설정에서는 이 힌트를 무시하고, 변경 쿼리를 허용한다. 

6.6.2 트랜잭션 속성 변경 

트랜잭션 속성을 변경하려면 어떻게 해야할까? TransactionAdvice에 다른 TransactionDefinition 오브젝트를 DI 받아서 사용하게 하면 된다. 하지만 이 방법은 TransactionAdvice를 사용하는 모든 트랜잭션의 속성이 한꺼번에 바뀐다는 문제가 있다. 원하는 메소드만 선택해서 독자적인 트랜잭션 정의를 적용할 수 있는 방법은 없을까?

 

스프링이 편리하게 트랜잭션 경계 설정 어드바이스로 사용할 수 있도록 만든 TransactionInterceptor를 사용하면 메서드마다 트랜잭션 정의를 다르게 지정할 수 있는 방법을 제공해준다. 또한, @Transactional의 속성 중 rollbackFor, noRollbackFor 등을 통해 어떤 예외가 발생했을 때 트랜잭션을 롤백할지 정밀하게 설정할 수 있다.

두 가지 방법을 보기 전에, TransactionAttributeSource와 TransactionAtrribute에 대해 알아보자.
public interface TransactionAttributeSource {
    @Nullable
    TransactionAttribute getTransactionAttribute(Method method, @Nullable Class<?> targetClass);
}​

TransactionAtrributeSource는 TransactionAtrribute를 반환하는 인터페이스로, 이름 기반 구현체와 애노테이션 기반 구현체가 있다. 이때, 반환되는 TransactionAttribute는 트랜잭션의 세부 설정 정보를 담는 인터페이스이다. 

 

1. 이름 패턴 기반

TransactionInterceptor는 TransactionAtrribute를 맵 타입 오브젝트(Map<String, TransactionAttribute>)로 전달받아, 메소드 패턴에 따라 각기 다른 트랜잭션 속성을 부여할 수 있도록 한다.

<tx:advice id="txAdvice" transaction-manager="txManager">
    <tx:attributes>
        <tx:method name="save*" propagation="REQUIRED"/>
        <tx:method name="get*" read-only="true"/>
        <tx:method name="*" propagation="SUPPORTS"/>
    </tx:attributes>
</tx:advice>

-> 스프링이 내부적으로 NameMatchTransactionAttributeSource 객체를 생성한다.

 

2. 애노테이션 기반

직접 타깃에 트랜잭션 속성 정보를 가진 애노테이션을 지정하는 방법으로, @Transactional 을 사용한다. 이 애노테이션을 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다. 

스프링은 @Transactional 대체 정책을 이용한다. 인터페이스 < 클래스 < 메서드, 즉 가장 구체적인 수준의 설정을 우선 적용한다.

⚠️ AOP 적용 시 주의할 점
프록시 방식의 AOP에서는 프록시를 통한 부가기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 즉, 타깃 오브젝트가 자기 자신의 메소드를 호출할 때에는 프록시를 통한 부가기능의 적용이 일어나지 않는다. 
@Service
public class UserService {
	@Transactional
	public void delete () {
		System.out.println("== delete(): 트랜잭션 시작됨");
	}
	@Transactional
	public void update() {
		System.out.println("== update(): 트랜잭션 시작됨");
		delete(); //
	}
}​

 

위 코드에서, delete()는 별도의 트랜잭션 없이 단순히 update()의 트랜잭션 범위 내에서 실행된다. 따라서 트랜잭션은 update() 메서드에만 적용되고, delete()는 트랜잭션이 적용된 것처럼 보이지만 실제로는 AOP 프록시를 거치지 않았기 때문에 트랜잭션 없이 실행된다.
이 문제를 해결하려면 delete() 메서드를 다른 클래스로 분리하고, 해당 클래스를 스프링 빈으로 등록한 뒤 프록시를 통해 호출해야 트랜잭션이 정상 적용된다.

6.6.3 트랜잭션 속성 적용

비즈니스 로직을 담고 있는 서비스 게층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다. 트랜잭션은 보통 서비스 계층의 메소드 조합을 통해 만들어지기 때문에 DAO가 제공하는 주요 기능 서비스 계층에 위임 메소드르 만들어둘 필요가 있다.

 

즉, 가능하면 다른 모듈의 DAO에 접근할 때는 서비스 계층을 거치도록 하는 게 바람직하다. 그래야만 부가 로직을 적용할 수도 있고, 트랜잭션 속성도 제어할 수 있기 때문이다. 

물론 순수한 조회나 간단한 수정이라면 직접 DAO에 접근해도 괜찮지만, 등록이나 수정, 삭제가 포함된 작업이라면 다른 모듈의 DAO를 직접 이용할 때 신중을 기해야 한다. 

6.7 트랜잭션 지원 테스트

스프링의 테스트 컨텍스트를 이용한 테스트에서는 @Autowired를 이용해 애플리케이션 컨텍스트에 등록된 빈을 가져와 테스트 목적으로 활용할 수 있었다. 그렇다면 당연히 트랜잭션 매니저 빈도 가져올 수 있다.

@SpringBootTest
public class UserServiceTest {
	@Autowired
    PlatformTransactionManager transactionManager;
}

만약 다음의 테스트 메소드가 실행되는 동안 몇 개의 트랜잭션이 만들어졌을까?

@Test
public void transactionSync() {
    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));
}

UserService의 모든 메소드에 트랜잭션을 적용했으니 당연히 3개이다. 각 메소드는 모두 독립적인 트랜잭션 안에서 실행된다.

그렇다면 이 테스트 메소드에서 만들어지는 세 개의 트랜잭션을 하나로 통합할 수는 없을까?

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

물론, UserService에 새로운 메소드를 만들고 그 안에서 deleteAll()과 add()를 호출한다면 물론 가능하다. (전파 속성이 모두 REQUIRED이니)

그런데 메소드를 추가하지 않고도 테스트 코드만으로 세 메소드의 트랜잭션을 통합하는 방법이 있다. 테스트 메소드에서 UserService의 메소드를 호출하기 전에 트랜잭션을 미리 시작하고, 트랜잭션 매니저를 통해 이를 동기화시켜주면 된다. 테스트도 트랜잭션 동기화에 참여하는 것이다. 

@Test
public void transactionSync() {
    DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
    //트랜잭션 동기화 검증을 위하여 ReadOnly로 설정
    //트랜잭션 속성 중에서 읽기 전용과 제한시간 등은 처음 트랜잭션이 시작할 때만 적용되고 그 이후에 참여하는 메소드의 속성은 무시된다.
    txDefinition.setReadOnly(true);
    TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);
    
    //여기서 예외 발생
    userService.deleteAll();
    userService.add(users.get(0)); //add 메서드는 롤백을 이용하면 동기화 검증을 알 수 있다. 
    userService.add(users.get(1));
    
    transactionManager.commit(txStatus);
}

 

테스트 코드로 트랜잭션을 제어해서 적용할 수 있는 테스트 기법이 있다. 바로 롤백 테스트이다. 롤백 테스트는 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게 하고, 테스트가 끝나면 무조건 롤백해버리는 테스트를 말한다.

롤백 테스트는 DB 작업이 포함된 테스트가 수행돼도 DB에 영향을 주지 않기 때문에 장점이 많다. 물론 테스트에 따라서 고유한 테스트 데이터가 필요한 경우가 있다. 이때는 테스트 앞 부분에서 그에 맞게 DB를 초기화하고 테스트를 진행하면 된다.

6.7.2 트랜잭션 애노테이션 이용한 테스트용 트랜잭션 제어

@Transactional 애노테이션을 타깃 클래스 또는 인터페이스에 부여하는 것만으로 트랜잭션을 적용해주는 건 매우 편리한 기술이다. 그런데 이 편리한 방법을 테스트 클래스와 메소드에도 적용할 수 있다.

 

테스트 클래스 또는 메소드에 @Transactional 애노테이션을 부여해주면 마치 타깃 클래스나 인터페이스에 적용된 것처럼 테스트 메소드에 트랜잭션 경계가 자동으로 설정된다.

@Test
@Transactional
public void transactionSync() {
    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));
}

 

테스트 메소드나 클래스에 사용하는 @Transactiona은 애플리케이션의 클래스에 적용할 때와 디폴트 속성은 동일하다. 하지만 중요한 차이점이 있는데, 테스트용 트랜잭션은 테스트가 끝나면 자동으로 롤백된다는 것이다.

@Rollback(false)이라는 애노테이션을 이용하면 롤백 설정을 강제적으로 해제할 수 있다. 단, @Rollback 애노테이션은 메소드 레벨에만 적용할 수 있다. 롤백에 대한 공통 속성을 지정하고자 하면, @TransactionConfiguration 애노테이션을 클래스 레벨에 부여하면 된다.