DI를 이용한 클래스의 분리
[1] 비즈니스 로직과 트랜잭션 경계설정의 분리
public void upgradeLevels() throws SQLException {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
upgradeLevelsInternal();
transactionManager.commit(status);
} catch (RuntimeException e) {
transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevelsInternal() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
- UserService 객체안에 비즈니스 로직 이외의 트랜잭션 관련 코드가 자리 잡고 있다.
[2] DI를 이용한 클래스의 분리
public interface UserService {
void add(User user);
void upgradeLevels();
}
public class UserServiceImpl implements UserService {
...
@Override
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
...
}
public class UserServiceTx implements UserService {
private UserService userService;
private PlatformTransactionManager transactionManager;
public void setUserService(UserService userService) {
this.userService = userService;
}
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public void add(User user) {
userService.add(user);
}
@Override
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;
}
}
}
- UserService를 클래스 -> 인터페이스로 변경한다.
- 일반적으로 DI를 적용하는 이유는 구현 클래스를 바꿔가며 사용하기 위함이지만
다음과 같이 한 번에 두 개의 UserSerivce 구현 클래스를 동시에 이용하게 할 수 있다.
- [장점1] 비즈니스 로직(UserServiceImpl)과 트랜잭션 로직(UserServiceTx)를 분리.
- [장점2] 비즈니스 로직 테스트를 보다 손쉽게 할 수 있다.
고립된 단위 테스트
기존의 UserService
- UserService는 여러 의존관계를 갖고 있다.
- UserService의 로직만 테스트 하는것이 목적이지만
그 뒤에 존재하는 많은 오브젝트와 환경, 서비스, 서버, 네트워크까지 함께 테스트하게 된다.
테스트를 위한 UserSerivceImpl 고립
- 트랜잭션 코드를 독립시킨 후의 구조이다.
- UserServiceImpl은 TransactionManager를 신경쓰지 않아도 된다.
- 또한 Mock 객체를 이용해 DB에 독립되지 않은 상태로 바꿀 수 있다.
단위 테스트와 통합 테스트
단위 테스트
- 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜 테스트.
통합 테스트
- 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하는 테스트.
- 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트.
- 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트(Spring DI).
테스트 가이드라인
- 항상 단위 테스트를 먼저 고려한다.
- 외부 리소스를 반드시 사용해야만 하는 경우 통합 테스트로 만든다.
- DAO와 같이 단위 테스트로 만들기 어려운 코드도 있다. SQL을 통해 DB와 연동되기 때문이다.
DAO의 경우 DB까지 연동하는 테스트로 만드는 편이 효과적이다. - DAO를 충분히 검증해두면 DAO를 이용하는 코드는 이를 목 오브젝트로 대체하여 테스트하면 된다.
다이내믹 프록시와 팩토리 빈
UserServiceTx과 UserServiceImpl의 관계
- 핵심기능 인터페이스는 UserService를 의미한다.
- 부가기능은 UserServiceTx를 의미한다.
- 핵심기능은 UserServiceImpl을 의미한다.
- 부가기능 코드에서는 핵심기능으로 요청을 위임하는 과정에서
자신의 부가적인 기능을 적용시킨다. - 프록시 : 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받는 것.
- 타깃 : 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 객체.
데코레이터 패턴
- 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴.
- 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 주입받는다.
- ex) UserServiceTx와 UserServiceImpl.
프록시 패턴
- 프록시 패턴에서 프록시는 타킷의 기능을 확장하거나 추가하지 않는다.
- 대신 클라이언트가 타킷에 접근하는 방식을 변경해준다.
Collections.unmodifiableCollection()
다이내믹 프록시
프록시의 문제점
public class UserServiceTx implements UserService {
private UserService userService;
...
public void add(User user) {
userService.add(user);//[1] 메소드 구현과 위임
}
public void upgradeLevels() {
//[2] 부가기능 수행
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
userService.upgradeLevels();//위임
this.transactionManager.commit(status);
} catch (RuntimeException e) {
this.transactionManager.rollback(status);
throw e;
}
}
}
- [1] 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기 번거롭다. 부가기능이 필요없는 메소드도 모두 구현해야하기 때문이다.
- [2] 부가기능 코드가 중복될 수도 있다. 물론 메소드 분리 등으로 해결할 수는 있다.
리플렉션
- 프록시의 문제는 JDK의 다이내믹 프록시를 이용하여 해결할 수 있다.
- 다이내믹 프록시는 리플렉션을 이용해서 프록시를 만든다.
간단한 예제를 이용한 다이내믹 프록시 적용
public interface Hello {
String sayHello(String name);
String sayHi(String name);
String sayThankYou(String name);
}
public class HelloTarget implements Hello {
@Override
public String sayHello(String name) {
return "Hello " + name;
}
@Override
public String sayHi(String name) {
return "Hi " + name;
}
@Override
public String sayThankYou(String name) {
return "Thank You " + name;
}
}
public class UppercaseHandler implements InvocationHandler {
//Hello 대신 Obejct로 선언해 어떤 종류의 인터페이스를 구현한 타깃에도 적용 가능
private Object target;
public UppercaseHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object returnValue = method.invoke(target, args);
//부가기능 수행
if (returnValue instanceof String && method.getName().startsWith("say")) {
return ((String) returnValue).toUpperCase();
}
return returnValue;
}
}
public void dynamicProxyTest() throws Exception {
//클래스 로더, 구현할 인터페이스, InvocationHandler
Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{Hello.class}, new UppercaseHandler(new HelloTarget()));
assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
assertThat(proxiedHello.sayThankYou("Toby"), is("THANK YOU TOBY"));
}
- [Before] 인터페이스의 메소드가 추가될때마다 프록시 클래스에서 구현해줘야했다.
프록시 클래스 역시 해당 인터페이스를 구현한 클래스였기 때문이다. - [After] 인터페이스의 메소드가 추가되도 별도의 작업이 필요없다.
다이내믹 프록시를 이용한 트랜잭션 부가기능
- 위의 Hello 예제를 바탕으로 트랜잭션 적용 기능을 다이내믹 프록시로 만든다.
- UserService 인터페이스 메소드가 추가되어도 변경할 필요가 없으며
UserSerivceImpl 이외에도 트랜잭션 적용이 필요한 어떠한 타깃 오브젝트에도 적용 가능하다.
public class TransactionHandler implements InvocationHandler {
private Object target;
private PlatformTransactionManager transactionManager;
private String pattern;
public void setTarget(Object target) {
this.target = target;
}
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().startsWith(pattern)) {
return invokeInTransaction(method, args);
}
return method.invoke(target, args);
}
private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
Object returnValue = method.invoke(target, args);
this.transactionManager.commit(status);
return returnValue;
} catch (InvocationTargetException e) {
this.transactionManager.rollback(status);
throw e.getTargetException();
}
}
}
//Test
public void upgradeAllOrNothing() throws Exception {
...
TransactionHandler transactionHandler = new TransactionHandler();
transactionHandler.setTarget(testUserService);
transactionHandler.setTransactionManager(transactionManager);
transactionHandler.setPattern("upgradeLevels");
UserService userServiceTx = (UserService) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{UserService.class}, transactionHandler);
...
}
다이내믹 프록시를 위한 팩토리 빈
현재 다이내믹 프록시의 문제점
- 다이내믹 프록시 오브젝트(TransactionHandler)는 일반적인 스프링의 빈으로 등록할 수 없다.
따라서 스프링 DI 작업을 할 수 없다. - 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 메소드를 통해서만 만들 수 있다.
팩토리 빈
- 스프링에서는 빈을 만드는 여러 방법이 있는데 그중 팩토리 빈이 있다.
- 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈이다.
- FactoryBean 인터페이스를 구현한 클래스를 만들어 스프링 빈으로 만들어두면
getObject()라는 메소드가 생성해주는 오브젝트가 실제 빈의 오브젝트로 대치된다. - 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어준다.
팩토리 빈의 getObejct()에 다이내믹 프록시 오브젝트 생성코드 기입.
프록시 팩토리 빈의 장점과 한계
- [장점] 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면
타깃의 타입에 상관없이 재사용 할 수 있다.
- [한계1] 여러 클래스에 공통적인 부가기능을 여러 개 정의하게되면 매번 빈 설정을 해야하므로 설정이 중복된다.
- [한계2] TransactionHandler 오브젝트가 여러개 만들어진다.
스프링의 프록시 팩토리 빈
스프링의 ProxyFactoryBean
- 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.
- ProxyFactoryBean은 순수하게 프록시를 만드는 작업만 담당하고
프록시를 통해 제공해줄 부가기능은 별도의 빈에 둔다.
public class DynamicProxyTest {
@Test
public void proxyFactoryBean() {
ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
proxyFactoryBean.setTarget(new HelloTarget());//타깃 설정
proxyFactoryBean.addAdvice(new UppercaseAdvice());//부가기능이 담긴 Advice 설정
Hello proxiedHello = (Hello) proxyFactoryBean.getObject();
assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
assertEquals(proxiedHello.sayThankYou("Toby"), "THANK YOU TOBY");
}
static class UppercaseAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
String ret = (String) methodInvocation.proceed();
return ret.toUpperCase();
}
}
}
어드바이스
- 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트.
- MethodInterceptor를 구현해서 만든다.
- MethodInterceptor는 InvocationHandler와 비슷하지만 다른점이 있다.
MethodInterceptor의 invoke() 메소드는 타깃 오브젝트에 대한 정보까지 함께 제공받는다. - 따라서 MethodInterceptor는 타깃이 다른 여러 프록시에 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.
포인트컷
- 부가기능을 적용할 대상 메소드를 선정하는 방법이다.
@Test
void proxyTest() {
ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
proxyFactoryBean.setTarget(new HelloTarget());
//포인트컷 적용
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedName("sayH*");
//프록시 빈에 어드바이저 추가
proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));
Hello proxiedHello = (Hello) proxyFactoryBean.getObject();
assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
assertEquals(proxiedHello.sayThankYou("Toby"), "Thank You Toby");
}
어드바이저
- 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)
기존 JDK 다이내믹 프록시 -> 스프링 ProxyFactoryBean 이용
스프링 AOP
빈 후처리기를 이용한 자동 프록시 생성기
DefaultAdvisorAutoProxyCreator
- 어드바이저를 이용한 자동 프록시 생성기
- 빈 후처리기 자체를 빈으로 등록하면 적용된다.
- 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트를 생성할 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다.
- 포인트컷은 클래스와 메소드 선정 알고리즘을 갖고있다.
- DefaultAdvisorAutoProxyCreator는 등록된 빈중에서 Advisor 인터페이스를 구현한 것을 찾은 후 프록시를 적용할 객체인지 찾는다.
트랜잭션 속성
- TransactionDefinition 인터페이스가 정의하고 있는 트랜잭션의 네가지 속성을 알아본다.
트랜잭션 전파
- 트랜잭션 경게에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때
어떻게 동작할 것인가를 결정하는 방식.
PROPAGATION_REQUIRED
- 가장 많이 사용하는 전파 속성.
- 진행중인 트랜잭션이 없으면 새로 시작하고
이미 시작된 트랜잭션이 있으면 이에 참여.
PROPAGATION_REQUIRED_NEW
- 항상 새로운 트랜잭션을 시작한다.
- 따라서 독자적으로 동작한다.
PROPAGATION_NOT_SUPPORTED
- 진행 중인 트랜잭션이 있어도 무시한다.
- 트랜잭션이 적용되지 않는것이다.
격리수준
- 모든 DB 트랜잭션은 격리수준을 갖고있다.
- 격리수준은 기본적으로 DataSource의 설정을 따른다.
- 필요에 따라 별도로 지정한다.
제한시간(timeout)
- 트랜잭션의 수행시간.
- Default는 제한시간이 없다.
읽기전용(readOnly)
- 트랜잭션 내에서 데이터를 조작하는 시도를 막아준다.
- 트랜잭션 속성 중 readOnly나 timeout은 트랜잭션이 처음 시작될 때 아니면 적용되지 않는다.
즉, 다른 이미 시작된 다른 트랜잭션에 참여하지 않는다.
Reference
- 토비의 스프링
- https://vvshinevv.tistory.com/72?category=839949