-
AOP(Aspect-Oriented Programming)란? - 스프링 AOP와 프록시스프링 2022. 9. 7. 15:30
안녕하세요! 이번 포스트에서는 AOP의 개념과 스프링의 AOP 방식에 대해서 작성해보겠습니다.
AOP란?
AOP는 흔히 스프링의 3대 프로그래밍 모델(DI, AOP, PSA) 중 하나로 알려져있으며 Aspect-Oriented Programming 약자입니다. 이걸 그대로 번역하면 관점 지향 프로그래밍입니다. 그럼 관점(Ascpect)란 무엇일까요?!
어플리케이션 코드에서는 로깅, 보안, 트랜잭션 등 비즈니스 로직과는 상관없이 반복적으로 등장하는 부가적은 코드들이 존재합니다. 객체지향적으로 잘 설계된 코드들도 이런 부가적인 코드들을 완벽히 독립시키기에는 부족한 부분이 있었습니다.
이런 부가기능들을 어떻게 모듈화할 것인지 생각해온 사람들은 기존의 전통적인 객체지향 설계 패러다임으로는 한계가 있다고 생각했었고 객체지향 기술에서 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르기 시작했습니다.
그것이 바로 관점(Aspect) 입니다.
애스펙트(Aspect)란 그 자체로 핵심기능을 담고있진 않지만 어플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킵니다.
애스펙트(Aspect)에 대해 정의를 내렸으니 AOP를 다시 한 번 설명해보면 어플리케이션의 핵심적인 기능에서 부가적인 기능을 분리한 뒤 애스펙트라는 독특한 모듈로 만들고 개발하는 방법을 관점 지향 프로그래밍(Aspect-Oriented Programming), AOP라고 부른다고 이해하면 될 것 같습니다.
스프링 AOP 방식 - 프록시, 다이나믹 프록시
스프링에서 제공하는 AOP 기술 외에도 다양한 AOP 프레임워크가 존재하고 그 중 가장 유명한 프레임워크는 AspectJ 입니다. 스프링 AOP와 AspectJ AOP는 작동방식이 많이 다릅니다.
스프링 AOP의 방식은 프록시 기반입니다. 자바 JDK에서 지원하는 다이나믹 프록시 기술을 이용해
AOP 기술을 제공합니다. 프록시 패턴을 이용해 AOP를 적용하는 과정을 예제코드를 이용해 설명해드리겠습니다.
프록시
프록시가 적용되지 않은 지저분한 코드를 프록시를 적용하여 개선시켜보겠습니다.
프록시 적용 전)
public class UserService { ... public void upgradeLevels() throws SQLException { TransactionStatus status = this.transactionManager .getTransaction(new DefaultTransactionDefinition()); try { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } transactionManager.commit(status); } catch (RuntimeException e) { transactionManager.rollback(status); throw e; } } }
위 코드는 트랜잭션이라는 부가기능이 핵심 로직에 섞여있는 코드입니다. 핵심 로직보다 부가 기능 코드가 더 많은 상황입니다. 심지어 트랜잭션 경계설정의 코드와 비즈니스 로직은 서로 주고 받는 정보도 없습니다. 이 두가지 코드는 성격이 다를 뿐 아니라 서로 주고받는 정보도 없으니 완벽히 독립적인 코드입니다.
위 코드를 프록시 기술을 이용하여 부가 기능을 분리해보겠습니다.
프록시 적용 후)
public interface UserService { void add(User user); void upgradeLevels(); }
public class UserServiceImple implements UserService { ... @Override public void upgradeLevels() throws SQLException { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } } }
기존 UserService 메서드를 추출하여 인터페이스를 하나 생성합니다. 그리고 기존 UserService는 UserServiceImpl로 이름을 변경하고 UserService 인터페이스를 구현하도록 합니다.
그 후 기존 로직에서 트랜잭션 경계설정 로직을 모두 제거하고 핵심 로직만 남깁니다.
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 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 인터페이스를 구현하도록 만들어야 합니다. 그리고 같은 인터페이스를 구현한 다른 오브젝트(UserServiceImpl)에게 고스란히 작업을 위임하게 됩니다.
이 프록시는 비즈니스 로직은 전혀 갖고있지 않고 클라이언트 요청이 들어오면 UserService 구현 오브젝트에게 기능을 위임합니다.
프록시 사용
private UserService userService; public UserServiceContrller(UserService userService) { this.userService = userService; } public void upgradeUserLevels() { userService.upgradeLevels(); }
DI를 통해 UserServiceTx(프록시)를 컨트롤러에게 주입해준다고 가정해보겠습니다.
컨트롤러 입장에서는 인터페이스만 알기때문에 구현체가 프록시인지 알 수가 없습니다. UserServiceImpl을 사용한다고 생각하지만 실제론 프록시 객체를 사용하는 것입니다.
현재 클라이언트와 UserService의 의존관계를 나타내는 그림입니다. 위 그림처럼 클라이언트는 프록시 객체를 사용하고 부가기능이 핵심기능을 사용하는 구조입니다. 부가기능은 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야합니다.
다이나믹 프록시
프록시의 구성과 프록시 작성의 문제점
- 인터페이스의 모든 메서드를 구현해 위임하도록 코드를 만들어야합니다.
- 부가기능을 수행하는 로직이 모든 메서드에 중복돼서 나타납니다.
위 두 가지 문제점을 해결하기 위해 다아나믹 프록시를 적용해보겠습니다.
다이나믹 프록시란?
다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트입니다. 다이나믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어집니다. 이 다이나믹 프록시는 인터페이스 정보만 제공해주면 알아서 구현체를 자동으로 만들어주기 때문에 일일이 모든 메서드를 구현해야할 수고를 덜 수 있습니다.
하지만! 프록시로서 필요한 부가기능은 개발자가 직접 코드로 작성해줘야합니다. 귀찮은 작업은 다이나믹 프록시가 알아서 해주니까 이정도는 개발자가 해도 될 것 같습니다.
부가기능은 InvocationHandler 인터페이스를 구현한 클래스에 작성합니다. InvocationHandler 인터페이스는 invoke() 메서드 한 개만 가진 간단한 인터페이스입니다.
public interface InvocationHandler { public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; }
아래는 문자열을 모두 대문자로 바꿔주는 부가기능이 있는 InvocationHandler 구현체입니다.
private Target target; //다이나믹 프록시로부터 전달받은 요청을 다시 타깃 오브젝트에 위임해야 하기 때문에 //타깃 오브젝트를 주입받아둡니다. public UppercaseHandler(Target target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object returnValue = method.invoke(target, args); //타깃으로 위임, 인터페이스의 메서드 //호출에 모두 적용됩니다. return ((String) returnValue).toUpperCase(); }
invoke() 메서드는 다이나믹 프록시로부터 메소드 호출 정보를 받아서 부가기능 로직을 처리해줍니다.
다이나믹 프록시가 어떤 메서드를 호출했는지 어떻게 알 수 있을까요? invoke 메서드의 파리미터 중
Method 객체가 있습니다. invoke 메서드는 리플렉션의 Method 인터페이스를 파라미터로 받고 있기 때문에 다이나믹 프록시가 호출한 메서드를 알 수 있습니다.
다이나믹 프록시는 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것입니다.
InvocationHandler 인터페이스 구현)
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(); } } }
위에서 설명드렸던 트랜잭션 부가기능을 InvocationHandler를 통해 구현하였습니다.
다이나믹 프록시 생성)
UserService userService = (UserService) Proxy.newProxyInstance( getClass().getClassLoader(), new Class[]{UserService.class}, new UppercaseHandler(new TransactionHandler())); userService.upgradeLevels();
Proxy 클래스의 스태틱 팩토리 메서드 newProxyInstance를 이용해 프록시를 생성합니다.
첫 번째 파라미터는 클래스 로더를 넣어줍니다.
두 번째 파라미터에는 프록시가 구현할 인터페이스를 지정해줍니다. 다이나믹 프록시는 하나 이상의 인터페이스를 구현할 수 있기 때문에 배열 형태로 넣어줍니다.
세 번째 파라미터에는 InvocationHandler를 넣어줍니다.
이렇게 다이나믹 프록시를 이용한다면 기존 프록시를 직접 구현했던 것에 비해 코드수는 많이 줄어들 수 있습니다. 인터페이스를 직접 구현할 필요가 없고 부가기능만 따로 InvocationHanlder에 작성하면 되기 때문입니다. 또한 매번 프록시를 구현할 때 마다 중복되어 나타나던 부가기능도 InvocationHandler 한 곳에서만 관리하니 훨씬 변화에 유연해졌습니다.
이렇게해서 스프링 AOP 작동방식인 다이나믹 프록시에 대해 알아봤습니다. 물론 스프링 AOP가 다이나믹 프록시로만 동작하지 않습니다. 단순히 다이나믹 프록시로 생성된 오브젝트는 빈으로 등록할 수가 없기 때문입니다. 생성되는 프록시 자체가 내부적으로 다이나믹하게 새로 정의되기 때문입니다. 스프링 입장에서 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 빈에 정의할 방법이 없습니다.
그렇기 때문에 스프링은 부가기능 로직의 적용 대상을 정의하는 포인트컷과 빈 후처리기를 사용해서 프록시를 생성해냅니다. 스프링 컨테이너에 빈 후처리기가 등록되면 빈을 생성할 때 마다 후처리기로 보냅니다. 후처리기는 컨테이너에 등록된 어드바이저 내에 포인트컷을 참조하여 해당 빈이 프록시 적용 대상인지 판별하고 만약 적용 대상이면 프록시를 생성하여 원래 오브젝트를 건네줬던 컨테이너에게 프록시를 대신 건네줍니다. 그리고 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용합니다.
자세한 내용은 아래 포스트를 참조해주시기 바랍니다!!
https://backtony.github.io/spring/2021-12-28-spring-aop-1/
AOP 용어 정리
PointCut Aspect를 적용할 메서드를 선정하는 오브젝트(지시자, 알고리즘 등 다양하게 불린다) JoinPoint Aspect 적용이 가능한 모든 지점, 즉 PointCut은 JoinPoint의 부분 집합이라 볼 수 있다. Advice 타깃 메서드에 적용할 부가 기능 Aspect 여러 개의 Advice와 여러 개의 PointCut의 조합, Aspect = Advice들 + PointCut들 Advisor 한 개의 Advice와 한 개의 PointCut의 조합, Advisor = Advice + PointCut 스프링 AOP 간단 예제
Advice와 PointCut 정의
@Component @Aspect public class AspectExample { @Before("execution(public void com.example.*.*(..))") public void before(JoinPoint joinPoint) { System.out.println("집밖으로 나간다."); } @After("execution(public void com.example.*.*(..))") public void after(JoinPoint joinPoint) { System.out.println("집으로 들어온다"); } }
@Aspect 이 클래스를 이제 AOP에서 사용하겠다는 의미 @Before 대상 메서드 실행 전에 이 메서드를 실행하겠다는 의미 @After 대상 메서드 실행 후에 이 메서드를 실행하겠다는 의미 타겟 오브젝트 생성
package com.example; import org.springframework.stereotype.Component; @Component public class Dog implements Animal { public void walk() { System.out.println("산책을 한다."); } }
결과
@Autowired private Dog dog; @Test void test() { dog.walk(); }
집밖으로 나간다. 산책을 한다. 집으로 들어온다
execution(public void com.example.*.*(..)) 포인트컷에 의해 Dog클래스에 walk() 메소드가 AOP 적용 대상이 되었습니다. 그렇기에 walk() 메소드를 실행하면 미리 정의된 Advice들이 실행됩니다.
스프링 Advice 애노테이션
Advice는 애노테이션을 이용해 적용할 수 있습니다.@AspectJ에서는 다섯 가지 종류의 어드바이스를 사용할 수 있습니다.
@Before, @After, @AfterReturning, @AfterThrowing, @Around
@Before 메서드 시작 전 @After 메서드 시작 후 @AfterReturning 메서드 정상 종료 후 @AfterThrowing 메서드에서 예외가 발생하면서 종료된 후 @Around 메서드 실행 전후 '스프링' 카테고리의 다른 글
[Spring Security] UsernamePasswordAuthenticationFilter의 내부 동작 과정을 알아보자 (0) 2022.08.03 [Spring Security] Security Oauth2 설정에서 custom OAuth2UserService가 실행하지 않는 이슈 (0) 2022.07.23 스프링 디스패처 서블릿(DispatcherServlet)의 개념과 동작흐름 (0) 2022.07.05 [Spring] Spring Config Server와 Git Private Repository 연동 방법 (0) 2022.06.18