티스토리 뷰

SPRING

[토비의 스프링] 6. AOP(1)

혀내 2022. 12. 29. 15:35
반응형

AOP

AOP는 다소 내용이 길어 두 글로 나눈 점 감안해주세요 :)


 

트랜잭션 코드의 분리

5장까지 다뤘던 upgradeLevel() 메소드를 다시 한번 살펴보자.

public void upgradeLevels() throws Exception {
	TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
    	List<User> users = userDao.getAll();
        for (User user : users) {
        	if (canUpgradeLevel(user)) {
            	upgradeLevel(user);
            }
        }
        
        this.transactionManager.commit(status);
    } catch (Exception e) {
    	this.transactionManager.rollback(status);
        throw e;
    }
}

 

 

트랜잭션 경계설정과 비즈니스 로직이 한 메소드에서 공존하고 있다.

비즈니스 로직 담당 코드를 메소드로 추출해 독립시켜보자.

 

 

 

DI를 이용한 클래스의 분리

1. UserService 인터페이스의 구현 클래스인 UserServiceTx에 트랜잭션 코드를 분리한다.

2. UserService의 구현 클래스인 UserServiceImpl에 비즈니스 로직을 작성한다.

 

public interface UserService {
	void add(User user);
	void upgradeLevels();
}

 

public class UserServiceImpl implements UserService {
	UserDao userDao;
	MailSender mailSender;

	public void upgradeLevels() {
		List<User> users = userDao.getAll();
		for (User user: users) {
			if (canUpgradeLevel(user)) {
				upgradeLevel(user);
			}
		}
	}
	...

 

@Setter
public class UserServiceTx implements UserService {
	UserService userService;
	PlatformTransactionManager transactionManager;

	public void add(User user) {
		userService.add(user);
	}

	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;
		}
	}
}

 

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

  1. 비즈니스 로직 코드 작성 시 기술적인 내용에 신경 쓰지 않아도 된다.
  2. 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.

 

 

고립된 단위 테스트

이제 우리가 만든 UserService를 테스트해보자.

UserService는 UserDao, TransactionManager, MailSender 빈과 의존 관계를 맺고 있다.

즉 UserService를 테스트하는 것은 그의 의존 관계들까지 함께 테스트하는 셈이다.

 

 

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

UserService를 고립시키기 위해 의존관계인 빈들은 테스트 대역으로 바꿔 사용하자.

 

UserDao ➡ MockUserDao
MailSender ➡ MockMailSender

 

 

UserServiceTest의 upgradeLevels()에 목 오브젝트 적용하기

@Test
public void upgradeLevels() throws Exception {
	UserServiceImpl userServiceImpl = new UserServiceImpl();

	MockUserDao mockUserDao = new MockUserDao(this.users);
	userService.Impl.setUserDao(mockUserDao);

	MockMailSender mockMailSender = new MockMailSender();
	userServiceImpl.setMailSender(mockMailSender);

	userService.upgradeLevels();

	List<User> updated = mockUserDao.getUpdated();
	assertThat(updated.size(), is(2));
	checkUserAndLevel(updated.get(0), "joytouch", Level.SILVER);
	checkUserAndLevel(updated.get(1), "madnite1", Level.GOLD);

	List<String> request = mockMailSender.getRequests();
	assertThat(request.size(), is(2));
	assertThat(request.get(0), is(users.get(1).getEmail()));
	assertThat(request.get(1), is(users.get(3).getEmail()));
}

private void checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
	assertThat(updated.getId(), is(expectedId);
	assertThat(updated.getLevel(), is(expectedLevel);
}

 

static class MockUserDao implements UserDao {
	private List<User> users;
	private List<User> updated = new ArrayList();

	private MockUserDao(List<User> users) {
		this.users = users;
	}

	public List<User> getUpdated() {
		return this.updated;
	}

	public List<User> getAll() {
		return this.users;
	}

	public void update(User user) {
		updated.add(user);
	}

	// 테스트에 사용되지 않는 메소드
	public void add(User user) { throw new UnsupportedOperationException(); }
	public void deleteAll() { throw new UnsupportedOperationException(); }
	public User get(String id) { throw new UnsupportedOperationException(); }
	public int getCount() { throw new UnsupportedOperationException(); }

 

 

Mockito 목 프레임워크

사실 이렇게 일일이 목 오브젝트를 만드는 일은 테스트에 있어 가장 큰 짐이 된다.

다행히도, 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 프레임워크가 존재한다.

 

그 중에서도 가장 많이 사용하는 Mockito 프레임워크를 적용해 코드를 수정하자. 

 

 

@Test public void mockUpgradeLevels() throws Exception {
	UserServiceImpl userServiceImpl = new UserServiceImpl();

	UserDao mockUserDao = mock(UserDao.class);
	when(mockUserDao.getAll()).thenReturn(this.users);
	userServiceImpl.setUserDao(mockUserDao);

	MailSender mockMailSender = mock(MailSender.class);
	userServiceImpl.setMailSender(mockMailSender);

	userServiceImpl.upgradeLevels();

	
	verify(mockUserDao, times(2)).update(any(User.class));
	verify(mockUserDao, times(2)).update(any(User.class));
	verify(mockUserDao).update(users.get(1));
	assertThat(users.get(1).getLevel(), is(Level.SILVER));
	verify(mockUserDao).update(users.get(3));
	assertThat(users.get(3).getLevel(), is(Level.GOLD));

	ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
	List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();
	assertThat(mailMessages.get(0).getTo()[0], is(users.get(1).getEmail()));
	assertThat(mailMessages.get(1).getTo()[0], is(users.get(3).getEmail()));

 


 

프록시와 프록시 패턴, 데코레이터 패턴

프록시

자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해 대리자, 대리인처럼 클라이언트의 요청을 받아 주는 것

 

 

프록시 사용 목적

1. 클라이언트에 타깃에 접근하는 방법을 제어하기 위해서

2. 타깃에 부가적인 기능을 부여하기 위해서

 

 

실체, 타깃

프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트

 

 

데코레이터 패턴

타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴

 

 

프록시 패턴

  • 프록시는 클라이언트가 타깃에 접근하는 방식을 변경한다.
  • 실제 타깃 오브젝트를 만드는 대신 프록시를 넘겨 오브젝트 생성 시점을 최대한 늦춘다.
  • 특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 사용할 수도 있다.
  • 프록시를 쉽게 만들 수 있도록 지원하는 클래스: java.lang.reflect

 

 

 

그러나 프록시를 사용하기 위해 인터페이스 메소드를 구현하고 위임하는 코드를 일일이 작성하는 것이 번거롭다.

 

→ 이 문제를 JDK의 다이내믹 프록시가 해결한다.

 

 

리플렉션 학습 테스트

리플렉션(java.lang.reflect)

자바의 코드 자체를 추상화해 접근할 수 있도록 만든 다이내믹 프록시의 기능

 

public class ReflectionTest {
	@Test
	public void invokeMethod() throws Exception {
		String name = "Spring";

		assertThat(name.length(), is(6));

		// String.class에서 "length"라는 이름을 갖고 있고, 파라미터는 없는 메소드 정보를 가져옴
		Method lengthMethod = String.class.getMethod("length");
		assertThat((Integer)lengthMethod.invoke(name), is(6));
		...
	}
}

 

 

이제 다시 프록시를 만들어보자.

 

 

Hello 인터페이스와 타깃 클래스 

interface Hello {
	String sayHello(String name);
	String sayHi(String name);
	String sayThankYou(String name);
}

 

public class HelloTarget implements Hello {
	public String sayHello(String name) {
		return "Hello " + name;
	}

	public String sayHi(String name) {
		return "Hi " + name;
	}

	public String sayThankYou(String name) {
		return "Thank you " + name;
	}
}

 

@Test
public void simpleProxy() {
	Hello hello = new HelloTarget();
	assertThat(hello.sayHello("Toby"), is("Hello Toby"));
	...
}

 

 

프록시 클래스

public class HelloUppercase implements Hello {
	Hello hello;

	public HelloUppercase(Hello hello) {
		this.hello = hello;
	}

	public String sayHello(String name) {
		return hello.sayHello(name).toUpperCase();
	}

	public String sayHi(String name) {
			return hello.sayHi(name).toUpperCase();
	}

	...
}

 

프록시 클래스의 문제점

  1. 인터페이스의 모든 메소드를 구현해 위임해야 한다.
  2. 리턴 값을 대문자로 바꾸는 기능이 모든 메소드에 중복된다.

 

 

이번엔 아까 배웠던 다이나믹 프록시를 사용해보자.

 

InvocationHandler: 모든 요청을 타깃에 위임하면서 리턴 값을 대문자로 바꾸는 부가기능을 가짐

@AllArgsConstructor
public class UppercaseHandler implements InvocationHandler {
	Object target;

	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		Object ret = method.invoke(target, args);
		if (ret instanceof String && method.getName().startsWith("say")) {
			return ((String)ret).toUpperCase();
		}
		else {
			return ret;
		}
	}
}

 

프록시 생성

Hello proxiedHello = (Hello)Proxy.newProxyInstance(
	getClass().getClassLoader(),
	new Class[] { Hello.class },
	new UppercaseHandler(new HelloTarget()));

 

복잡한 리플렉션 API를 적용하고, 코드 작성도 까다로워진 것 같다.

과연 다이내믹 프록시의 장점이 있긴 있는 것일까?

 

 

1. 직접 구현한 프록시는 인터페이스의 메소드가 늘어날 때마다 코드를 추가해야 하지만, 다이내믹 프록시를 사용하는 코드는 전혀 손댈 게 없다.

2. InvocationHandler는 타깃의 종류에 상관없이 적용이 가능하다.

 

 

 

다이내믹 프록시를 이용한 트랜잭션 부가기능

이제 UserServiceTx를 다이내믹 프록시 방법으로 변경해보자.

UserServiceTx는 트랜잭션이 필요한 메소드마다 트랜잭션 처리코드가 중복되며, 인터페이스의 메소드를 모두 구현해야 한다는 비효율적인 방법으로 만들어져 있다.

 

 

TransactionInvocationHandler

@Setter
public class TransactionHandler implements InvocationHandler {
	private Object target;
	private PlatformTransactionManager transactionManager;
	private String pattern;

	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();
		}
	}
}

 

이제 다이내믹 프록시를 이용해 트랜잭션 테스트를 실행하자.

@Test
public void upgradeAllOrNothing() throws Exception {
	...
	TransactionHandler txHandler = new TransactionHandler();
	
	// 트랜잭션 핸들러가 필요한 정보, 오브젝트를 DI한다.
	txHandler.setTarget(testUserService);
	txHandler.setTransactionManager(trnasactionManager);
	txHandler.setPattern("upgradeLevels");
    
	// UserService 인터페이스 타입의 다이내믹 프록시를 생성한다.
	UserService txUserService = (UserService)Proxy.newProxyInstance(
		getClass().getClassLoader(), new Class[] { UserService.class }, txHandler);
	...
}

 

 

다이내믹 프록시를 위한 팩토리 빈

다이내믹 프록시를 스프링 빈으로 등록하는 방법

 

1. 팩토리 빈: 스프링의 FactoryBean 인터페이스를 구현하자.

 

@Setter
public class TxProxyFactoryBean implements FactoryBean<Object> {
	Object target;
	PlatformTransactionManager transactionManager;
	String pattern;
	Class<?> serviceInterface;

	public Object getObject() throws Exception {
		TransactionHandler txHandler = new TransactionHandler();
		txHandler.setTarget(target);
		txHandler.setTransactionManager(trnasactionManager);
		txHandler.setPattern(pattern);
		return Proxy.newProxyInstance(
			getClass().getClassLoader(), new Class[] { serviceInterface }, txHandler);
	}

	public Class<?> getObjectType() {
		return serviceInterface;
	}

	public boolean isSingleton() {
		return false;
	}
}

 

이제 UserServiceTx 빈 설정을 대신해 TxProxyFactoryBean을 userService라는 이름으로 등록하면 된다.

<bean id="userService" class="springbook.user.service.TxProxyFactoryBean">
    <property name="target" ref="userServiceImpl" />
    <property name="transactionManager" ref="transactionManager" />
    <property name="pattern" value="upgradeLevels" />
    <property name="serviceInterface" value="springbook.user.service.UserService" />
</bean>

 

마지막으로 테스트를 살펴보자.

// 팩토리 빈을 가져오기 위함
@Autowired ApplicationContext context;

@Test
@DirtiesContext
public void upgradeAllOrNothing() throws Exception {
	TestUserService testUserService = new TestUserService(users.get(3).getId());
	testUserService.setUserDao(userDao);
	testUserService.setMailSender(mailSender);

	TxProxyFactoryBean txProxyFactoryBean = context.getBean("&userService", TxProxyFactoryBean.class);
	txProxyFactoryBean.setTarget(testUserService);
    
	// 다이내믹 프록시 오브젝트 생성
	UserService txUserService = (UserService) txProxyFactoryBean.getObject();

	...
}

 

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

장점

1. 프록시 팩토리 빈을 재사용할 수 있다.

2. 타깃 인터페이스의 구현 클래스를 일일이 만들 필요가 없다.

3. 부가기능 코드의 중복 문제도 해결한다.

4. …

 

 

한계

1. 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 것은 불가능하다.

2. 한 타깃에 여러 개의 부가기능을 적용하기 힘들다.

3. TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어진다.

 


 

스프링의 프록시 팩토리 빈

ProxyFactoryBean

스프링에서 제공하는 빈으로 위의 한계를 모두 해결하면서 프록시를 생성해 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.

MethodInterceptor 인터페이스를 구현해 InvocationHandler 역할을 수행하도록 한다.

 

  • Advice: 타깃이 필요없는 순수한 부가기능의 오브젝트
  • Pointcut: 부가기능 적용 대상 메소드를 선정하는 오브젝트
  • Advisor = Advice + Pointcut

 

 

ProxyFactoryBean 적용

스프링이 제공하는 ProxyFactoryBean을 이용하도록 수정해보자.

 

TransactionAdvice

public class TransactionAdvice implements MethodInterceptor {
	PlatformTransactionManager transactionManager;

	public void setTransactionManager(PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}

	public Object invoke(MethodInvocation invocation) throws Throwable {
		TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
		try {
			Object ret = invocation.proceed();
			this.transactionManager.commit(status);
			return ret;
		} catch (RuntimeException e) {
			this.transactionManager.rollback(status);
			throw e;
		}
	}
}

 

<bean id="transactionAdvice" class="springbook.user.service.TransactionAdvice">
	<property name="transactionManager" ref="transactionManager"/>
</bean>

// 포인트컷 빈 설정
<bean id="transactionPointcut"
	class="org.springframework.aop.support.NameMatchMethodPointcut">
    <property name="mappedName" value="upgrade*" />
</bean>

// 어드바이저 빈 설정
<bean id="transactionAdvisor"
	class="org.springframework.aop.support.DefaultPointcutAdvisor">
    <property name="advice" ref="transactionAdvice" />
    <property name="pointcut" ref="transactionPointcut" />
</bean>

<bean id="userService" class="org.springframework.aop.framework.ProxyFactoryBean">
	<property name="target" ref="userServiceImpl" />
    <property name="interceptorNames">
    	<list>
        	<value>transactionAdvisor</value>
        </list>
    </property>
</bean>
반응형