-
6장 AOP (2)24년 11월 이전/책장 속 먼지 털기 - 토비의 스프링 2021. 1. 25. 08:22반응형
책장속 먼지털기 스터디 11차 스터디 날짜 : 2021.01.25 작성 날짜 : 2021.01.24 - 2021.01.25 페이지 : 475 - 512
개요
이전 장을 통해서 우리는
ProxyFactoryBean
과Advise
,Pointcut
을 적용하여, 핵심 기능에서 분리하여 부가 기능을 따로 깔끔하게 적용할 수 있도록 만들었다. 이번 장에서는 우리가 여태까지 해온 작업들을 돌아보고Spring AOP
를 이용해서 조금 더 "투명한 부가기능 형태"로 제공해보자. 여기서 투명하다라는 뜻은, 부가 기능을 적용한 후에도 기존, 설계와 코드에 영향을 주지 않는다는 뜻이다.프록시 팩토리 빈의 한계와 극복 방법
지난 장에서 언급했던 프록시 팩토리 빈의 두 가지 한계를 다시 한 번 톺아보자. (여기서 프록시 팩토리 빈은 스프링의
ProxyFactoryBean
을 말하는 것이 아니다.)- 부가 기능이 다깃 오브젝트마다 새로 만들어진다.
- 한 번에 여러 개의 클래스에 공통 부가 기능을 제공할 수 없다.
첫 번째 문제는 스프링에서 제공하는
ProxyFactoryBean
의 어드바이스를 해결할 수 있었다. 하지만 여전히, 공통적으로 부가 기능이 필요한 객체마다ProxyFactoryBean
의 설정을 추가해주어야 한다는 문제는 여전히 남아 있다. 이를 어떻게 극복할 수 있을까?이렇게 코드가 중복되었을 때 우리는 어떤 방식으로 해결했었는지 잠깐 살펴보자.
- 전략 패턴과 DI를 이용하여 템플릿과 콜백, 클라이언트를 분리 (ex) UserDao )
Dynamic Proxy
와Dynamic Proxy
생성 팩토리를 DI (ex) 이전 장에서 한 작업들)
위 방법들 중 한 번에 여러 개의 빈에 프록시를 적용할 수 때 설정 코드의 중복을 제거할 수 있는 방법은 아쉽게도 없다. 어떻게 해결할 수 있을까?
정답부터 말하자면, 스프링 컨테이너는 다양하게 확장할 수 있는 방법이 존재한다. 이 중
BeanPostProcessor
를 이용해서 빈 객체가 만들어진 후에 그 객체를 개발자 입맛에 맞게 다시 가공할 수 있다. 이BeanPostProcessor
와 더불어서,DefaultAdvisorAutoProxyCreator
를 이용해서, 빈 객체 생성 후에, 어드바이저 적용 여부에 따라 프록시를 자동으로 설정해주면 위의 문제를 해결할 수 있다.Pointcut 더, 자세히!
여기서 한 가지 짚고 가자. 사실
Pointcut
은 크게 2가지 기능이 있다. 다음은 실제Pointcut
인터페이스의 코드이다.public interface Pointcut { ClassFilter getClassFilter(); MethodMatcher getMethodMatcher(); // ... }
이전 장에서 우리가 사용했던 것은
getMethodMatcher
이다. 메소드 이름 별로 어드바이스를 적용할 지 여부를 판단하는 메소드이다.getClassFilter
는 클래스 이름으로 어드바이스를 적용할 지 여부를 판단한다.DefaultAdvisorAutoProxyCreator
와 결합되어 사용될 때, 클래스와 메소드 선정 알고리즘을 모두 갖게끔 만들어서 이전 절에서 얘기했던 문제를 해결할 수 있는 것이다.이제
Pointcut
에 대하여 학습 테스트를 작성해보자.PointCutStudyTest.java
public class PointcutStudyTest { @Test @DisplayName("포인트 컷 확장 테스트") public void test_point_cut() { NameMatchMethodPointcut classMethodPointcut = new NameMatchMethodPointcut(){ @Override public ClassFilter getClassFilter() { return clazz -> isStartedWithHelloT(clazz); } }; classMethodPointcut.setMappedName("sayH*"); checkAdvised(new HelloTarget(), classMethodPointcut, HelloTarget.class); class HelloWorld extends HelloTarget {} checkAdvised(new HelloWorld(), classMethodPointcut, HelloWorld.class); class HelloToby extends HelloTarget {} checkAdvised(new HelloToby(), classMethodPointcut, HelloToby.class); } private boolean isStartedWithHelloT (Class<?> clazz) { return clazz.getSimpleName().startsWith("HelloT"); } private void checkAdvised(Object target, Pointcut pointcut, Class<?> clazz) { ProxyFactoryBean pfBean = new ProxyFactoryBean(); pfBean.setTarget(target); pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UpperCaseAdvise())); Hello proxy = (Hello) pfBean.getObject(); if (isStartedWithHelloT(clazz)) { assertEquals(("Hello Toby").toUpperCase(), proxy.sayHello("Toby")); assertEquals(("Hi Toby").toUpperCase(), proxy.sayHi("Toby")); assertEquals("Thank you Toby", proxy.sayThankU("Toby")); } else { assertEquals("Hello Toby", proxy.sayHello("Toby")); assertEquals("Hi Toby", proxy.sayHi("Toby")); assertEquals("Thank you Toby", proxy.sayThankU("Toby")); } } }
테스트를 돌려보면 무사히 통과한다. 어떤 테스트냐면,
NameMatchMethodPointcut
을 생성하는데,getClassFilter
를 커스터마이징해서 만든다. 클래스 이름이 "HelloT"로 시작해야 어드바이스를 적용한다. 그 후 적용 여부에 따라 적정 결과 값을 내놓는지checkAdvised
를 호출하여 확인한다. 이제Pointcut
을 이용해서 클래스, 메소드 이름 별로 어드바이스를 적용할 수 있다는 확신이 생겼다. 다음 절에서 프록시 팩토리 빈의 한계를 완벽하게 극복해보자.DefaultAdvisorAutoProxyCreator 적용과 테스트
먼저
DefaultAdvisorAutoProxyCreator
를 빈으로 등록한다.TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { @Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { return new DefaultAdvisorAutoProxyCreator(); } // ... }
그 후,
DefaultAdvisorAutoProxyCreator
에서 생성한 빈에 대해서 프록시 적용 여부를 결정하는Pointcut
을 만들어야 한다. 다음과 같이 작성한다.NameMatchClassMethodPointcut.java
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut { public void setMappedClassName(String className) { this.setClassFilter(new SimpleClassFilter(className)); } @RequiredArgsConstructor static class SimpleClassFilter implements ClassFilter { private final String className; @Override public boolean matches(Class<?> clazz) { return PatternMatchUtils.simpleMatch(className, clazz.getSimpleName()); } } }
이제 이
Pointcut
을 빈으로 등록해야 한다. 이전testTransactionPointCut
을 앞서 만든 포인트 컷 기반으로 빈을 만들 수 있도록 바꿔준다.TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // ... @Bean public Pointcut testTransactionPointCut() { NameMatchClassMethodPointcut pointcut = new NameMatchClassMethodPointcut(); pointcut.setMappedClassName("*Service"); pointcut.setMappedName("upgrade*"); return pointcut; } // ... }
뒤에 "Service"가 붙은 클래스에 앞에 "upgrade"가 붙은 메소드가 호출되면, 빈마다 프록시를 적용하게 된다. 기존 어드바이스와 어드바이저는 변경할 필요는 없다. 그 후, 기존
ProxyFactoryBean
타입으로 만들었던testUserService
빈을 다시UserService
타입으로 변환시킨다.TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // ... @Bean public UserService testUserService() { UserService userService = new UserServiceImpl(testMailSender(), testUserDao()); return userService; } // ... }
이제 이에 대한 테스트를 만들어보자.
UserServiceTest
를 다음과 같이 변경한다.UserServiceTest.java
@SpringBootTest @DirtiesContext @Import(TestBeanFactory.class) class UserServiceTest { // ... @Autowired private UserService testUserService; // ... @Test @DisplayName("컨테스트 로드 테스트") public void bean() { assertNotNull(testUserService); } private void checkLevel(User user, boolean isUpgrade) { User update = testUserDao.get(user.getId()); if (isUpgrade) { assertEquals(user.getLevel().getNext(), update.getLevel()); } else { assertEquals(user.getLevel(), update.getLevel()); } } @Test @DisplayName("레벨 업 테스트") public void test_level_upgrade() { DummyMailSender dummyMailSender = (DummyMailSender) testMailSender; List<String> requests = dummyMailSender.getRequests(); requests.clear(); testUserService.upgradeLevels(); checkLevel(users.get(0), false); checkLevel(users.get(1), true); checkLevel(users.get(2), false); checkLevel(users.get(3), true); checkLevel(users.get(4), false); assertEquals(2, requests.size()); assertEquals(users.get(1).getEmail(), requests.get(0)); assertEquals(users.get(3).getEmail(), requests.get(1)); } @Test @DisplayName("생성 테스트") public void test_add() { User existLevelUser = User.builder() .id("test6") .name("test6") .password("test6") .email("test6@test.com") .level(Level.GOLD) .login(60) .recommend(31) .build(); testUserService.add(existLevelUser); User saved = testUserDao.get(existLevelUser.getId()); assertEquals(existLevelUser.getLevel(), saved.getLevel()); User notExistLevelUser = User.builder() .id("test7") .name("test7") .password("test7") .email("test7@test.com") .build(); testUserService.add(notExistLevelUser); saved = testUserDao.get(notExistLevelUser.getId()); assertEquals(Level.BASIC, saved.getLevel()); } // test_cancel_when_exception은 주석 처리한다. }
정상 동작한다. 그러나 아직 해결할게 남았다. 강제로 예외를 발생시키는 테스트를 사용했을 때를 테스트하는 것이다. 조금 바꿔야할 것이 많다. 이제 예외 발생 시, 트랜잭션 롤백이 일어나는 것에 대해서 테스트하기 위해서는
TestUserService
를 빈으로 등록해야만 한다. 다음과 같이 만든다.TestUserService.java
@Service public class TestUserService extends UserServiceImpl implements UserService { private String id; public TestUserService(MailSender mailSender, UserDao userDao) { super(mailSender, userDao); } public void setId(String id) { this.id = id; } public void upgradeLevel(User user) { if (id.equals(user.getId())) { throw new TestUserServiceException(); } super.upgradeLevel(user); } }
그리고 빈으로 설정해준다.
TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // ... @Bean public UserService testTestUserService() { TestUserService userService = new TestUserService(testMailSender(), testUserDao()); userService.setId("test2"); return userService; } // ... }
그리고
UserServiceTest
를 다음과 같이 변경한다.UserServiceTest.java
@SpringBootTest @DirtiesContext @Import(TestBeanFactory.class) class UserServiceTest { // ... @Autowired private UserService testTestUserService; // ... @Test @DisplayName("예외 발생 시 작업 취소 여부 테스트") public void test_cancel_when_exception() { assertThrows(Exception.class, () -> testTestUserService.upgradeLevels()); checkLevel(users.get(0), false); checkLevel(users.get(1), false); checkLevel(users.get(2), false); checkLevel(users.get(3), false); checkLevel(users.get(4), false); } }
테스트를 돌려보면 아쉽게도 실패한다. 이는 스프링 5.1 이상 버전부터 빈 설정에 대해서 제한 기준(재정의에 대한)이 엄격하게 바뀌었기 때문이다. 이를 해결하기 위해서는 일단
application.properties
에 다음과 같이 스프링 설정을 해주어야 한다.application.properties
spring.main.allow-bean-definition-overriding=true
그 후 빈 설정 파일을 다음과 같이 변경한다.
TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // ... @Bean("testUserService") public UserService testUserService() { UserService userService = new UserServiceImpl(testMailSender(), testUserDao()); return userService; } @Bean("testTestUserService") public UserService testTestUserService() { TestUserService userService = new TestUserService(testMailSender(), testUserDao()); userService.setId("test2"); return userService; } // ... }
이제 테스트를 돌려보면 무사히 통과하는 것을 확인할 수 있다.
참고!
책에서는 자동 프록시에 대한 테스트가 있었으나 현재는 트랜잭션이 걸릴지라도, 이에 대한 테스트를 작성할 수 없기 때문에 일단은 스킵합니다. "com.sun.proxy.$Proxy"가 나오는데 이유를 알 수 없군요..
Pointcut 표현식과 테스트
이번에는 조금 더 편리한 포인트컷 작성 방법에 대해 알아본다. 포인트컷은 정규식이나, 스프링 표현식 같이 포인트컷만의 "표현식"이 따로 존재한다. 이 기능을 사용하려면
AspectJExpressionPointcut
을 사용하면 된다. 먼저 이를 학습하기 위해 학습 테스트를 만들어보자. 먼저 다음 클래스들을 생성한다.TargetInterface.java
public interface TargetInterface { void hello(); void hello(String a); int minus(int a, int b) throws RuntimeException; int plus(int a, int b); void method(); }
Target.java
public class Target implements TargetInterface { @Override public void hello() { } @Override public void hello(String a) { } @Override public int minus(int a, int b) throws RuntimeException { return 0; } @Override public int plus(int a, int b) { return 0; } @Override public void method() { } }
Bean.java
public class Bean { public void method() throws RuntimeException { } }
그 후 테스트 클래스를 생성한 후 다음을 테스트를 작성한다.
TargetTest.java
public class TargetTest { @Test @DisplayName("Pointcut Expression Test") public void test_pointcut_expression() throws Exception { isMatchPointcut(Target.class, "minus", int.class, int.class); isMatchPointcut(Target.class, "plus", int.class, int.class); isMatchPointcut(Target.class, "hello"); isMatchPointcut(Target.class, "hello", String.class); isMatchPointcut(Target.class, "method"); isMatchPointcut(Bean.class, "method"); } private boolean isMatchPointcut(Class<?> clazz, String methodName, Class<?>... args) throws NoSuchMethodException { AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); pointcut.setExpression("execution(* *(..))"); return pointcut.getClassFilter().matches(clazz) && pointcut.getMethodMatcher().matches(clazz.getMethod(methodName, args), null); } }
이 때 이 테스트는 빌드되지 않는다. 다른 의존성이 필요하기 때문이다.
build.gradle
에 다음을 추가한다.build.gradle
// ... dependencies { // .. implementation 'org.springframework.boot:spring-boot-starter-aop' // .. } // ...
그 후 테스트를 돌려보면 무사히 통과하는 것을 알 수 있다. 간단히 설명하자면,
AspectJExpressionPointcut
은setExpression
메소드를 통해서 표현식을 전달할 수 있다. 이때 표현식은 다음과 같은 형태로 전달한다.execution([접근 제한자 패턴] 리턴 타입 패턴 [패키지 패턴.].메소드 이름 패턴 (파라미터 타입 패턴 | ..) [예외 이름 패턴])
조금 어려울 수 있다. 한 번 리플렉션 API를 이용해서
Target.minus
의 정보를 출력해보자.System.out.println(Target.class.getMethod("minus", int.class, int.class));
그럼 결과는 다음과 같다.
public int com.gurumee.chonangam.study.Target.minus(int,int) throws java.lang.RuntimeException
하나하나 살펴보자. 첫 번째는 접근 제한자이다. 두 번째는 리턴 타입, 세 번째는 클래스의 패키지(com.gurumee.chonangam.study.Target.)를 나타낸다. 그리고 네 번째는 메소드 이름 패턴이다. 다섯 번째는 파라미터 타입 목록을 나타낸다. 그리고 여섯 번째는 예외 이름에 대한 타입 패턴이다.
AspectJExpressionPointcut.setExpression
에 전달할 표현식과 일치하는 것을 확인할 수 있다. 이제 이를 이용해서, 빈으로 작성했던 포인트컷을 조금 더 업그레이드 한다.TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // ... @Bean public Pointcut testTransactionPointCut() { AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); pointcut.setExpression("execution(* *..*Service*.upgrade*(..))"); return pointcut; } //... }
포인트컷 표현식으로, 클래스 이름에
Service
가 있고 메소드 명이upgrade
로 시작하는 메소드가 호출되면 프록시가 생성된다. 그 외 코드는 건드리지 않는다. 역시 테스트를 돌려보면 통과하는 것을 확인할 수 있다. 조금 더 깔끔한 포인트 컷이 되었다.AOP 재 정리
이제 복습 차원에서 6장에서 했던 내용들을 재 정리해보자. 현재 우리는 트랜잭션이란 부가 기능을 추가하기 위해서 다음과 같은 작업을 진행하였다.
- 트랜잭션 서비스 추상화
- 프록시와 데코레이터 패턴
- 다이나믹 프록시와 프록시 팩토리 빈
- 자동 프록시 생성 방법과 포인트 컷
이러한 일련의 과정을 거친 덕분에
TransactionAdvice
라는 이름으로 트랜잭션 경계를 만드는 부가 기능을 핵심 기능과 분리해낼 수 있었다. 이를 "부가 기능의 모듈화"라고 한다. 이러한 부가적인 기능을 모듈로 만들어서 설계하고 개발하는 방법을 AOP(Aspect Oriented Programming)이라고 한다.AOP는 크게 2가지 방식으로 나눌 수 있다.
- 프록시를 이용한 AOP
- 바이트코드 생성과 조작을 통한 AOP
우리가 여지껏 해왔던 방법이 1번 AOP이다. 스프링 기준에서 보면 IoC 컨테이너와 다이나믹 프록시, 데코레이터 패턴, 프록시 패턴, 자동 프록시 생성 기법, 빈 오브젝트의 후처리 조작 기법 등 다양한 기술을 조합해서 AOP를 구현한다. 2번의 경우 대표적인 라이브러리는
AspectJ
이다. 바이트 코드를 이용하는 것은 DI 같은 기법 없이도 AOP를 적용할 수 있으며 강력하고 유연하며 깔끔한 코드 작업을 할 수 있게 도와준다. 다만 설정하는데 꽤 난이도가 있으므로 여태까지 정도의 부가 기능 모듈화라면, 1번으로도 충분하다. 다음은 용어 정리이다.- Target : 부가 기능을 부여할 대상이다.
- Advice : 부가 기능이다.
- Join Point : 어드바이스가 적용될 위치를 나타낸다.
- Pointcut : 어드바아스를 적용할 조인 포인트를 선별하는 작업 혹은 그 기능을 정의한 모듈을 뜻한다.
- Proxy : 클라이언트와 타깃 사이에 투명하게 존재하면서 부가 기능을 제공하는 오브젝트를 뜻한다.
- Advisor : 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트이다. 어떤 부가 기능(어드바이스)을 어떻게 설정(포인트 컷)할지 알고 있다.
- Aspect : OOP의 클래스처럼 에스펙트는 AOP의 기본 모듈이다. 한 개 이상의 포인트컷과 어드바이스의 조합으로 만들어지며 보통 실글톤 형태의 오브젝트로 존재한다.
AOP 네임스페이스
이제 조금 더 깔끔하게 AOP를 설정해보자. xml 기반으론 AOP 네임스페이스라는 것이 존재하는데, 자바 기반 설정으로 한 번 AOP 설정을 분리해보자. 먼저
AppConfig
를 다음과 같이 생성한다.AppConfig.java
@Configuration @EnableAspectJAutoProxy public class AppConfig { }
눈에 띄는 설정이 있다.
@EnableAspectJAutoProxy
라는 애노테이션이 붙어있는 것을 볼 수 있는데 이렇게 설정해두면 자동으로 빈 생성 후DefaultAdvisorAutoProxyCreator
를 이용하 듯 애플리케이션에 설정된 어드바이스에 따라 프록시를 설정하게 된다. 이제 따로 에스펙트를 만들어주어야 한다.TransactionAspect
를 다음과 같이 생성한다.TransactionAspect.java
@Aspect @Component @RequiredArgsConstructor public class TransactionAspect { private final PlatformTransactionManager transactionManager; @Around("execution(* *..*Service*.upgrade*(..))") public Object invoke(ProceedingJoinPoint jp) throws Throwable { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { Object ret = jp.proceed(); transactionManager.commit(status); return ret; } catch (InvocationTargetException e) { transactionManager.rollback(status); throw e; } } }
여기서 중요한 것은
@Aspect
이다. 이는 이 빈이(@Component가 붙었으니까..), 에스펙트임을 알려준다. 따라서 애플리케이션은 빈 생성 후, 이 에스펙트에 정의된 포인트컷에 맞는 빈이 있으면 프록시를 만들어준다.@Around("execution(* *..*Service*.upgrade*(..))")
는 이 에스펙트에서 정의된 어드바이스이다. 이 때 인수로 포인트컷 표현식을 받는데 해당 표현식은 위에 설명했으니 넘어간다.@Around
애노테이션은 메소드 전반적으로 에스펙트가 가지고 있는 어드바이스를 적용한다. 따라서, 트랜잭션에 걸맞는 어드바이스라고 볼 수 있다. 이외에도, After, Before 등 여러 어드바이스 애노테이션이 존재한다.이제 빈 설정 파일에서 여지껏 작성했던 어드바이스, 어드바이저, 포인트컷 등을 제거한다.
TestBeanFactory.java(BeanFactory.java도 같은 요령으로 업데이트)
@TestConfiguration public class TestBeanFactory { // @Bean // public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { // return new DefaultAdvisorAutoProxyCreator(); // } // @Bean // public TransactionAdvice testTransactionAdvice() { // return new TransactionAdvice(testTransactionManager()); // } // // @Bean // public Pointcut testTransactionPointCut() { // AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); // pointcut.setExpression("execution(* *..*Service*.upgrade*(..))"); // return pointcut; // } // // @Bean // public DefaultPointcutAdvisor testTransactionAdvisor() { // return new DefaultPointcutAdvisor(testTransactionPointCut(), testTransactionAdvice()); // } //... }
그 후 테스트를 돌려보면 무사히 이전처럼 트랜잭션까지 적용되는 것을 확인할 수 있다.
728x90'레거시 > 책장 속 먼지 털기 - 토비의 스프링' 카테고리의 다른 글
책장 속 먼지 털기 - 토비의 스프링 링크 (0) 2021.02.06 6장 AOP (1) (0) 2021.01.18 5장 서비스 추상화 (2) (0) 2021.01.04 5장 서비스 추상화 (1) (0) 2020.12.21 4장 예외 (0) 2020.12.20