다음은 토비의 스프링 6장 AOP 를 공부하고 정리하였습니다.
AOP를 이해하기 전에 먼저 아래 개념이 선행되어야 한다.
- 빈 생명주기 (그냥 훑어봐도 이해하기 쉽다. 아래 빈 후처리~ 로직을 이해하려면 필연적으로 알아야 함 !)
- DI
- Transaction (DataBase 관련 개념)
- 프록시 개념
아래 여러 혼란스러운 개념들이 나오니 단어들을 헷갈리지 않고 정리하면서 보는 과정이 필요하다..
AOP를 한 마디로 말하면, 공통된 기능을 재사용하는 기법이다. spring에서 볼 수 있는 AOP의 적용 대상은 바로 @Transactional 기능이다.
우선 Transactional 의 기본 코드는 아래와 같다는 사실을 알아두자.
public void upgradeLevels() {
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
TransactionStatus status = 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;
}
}
Transaction은 전체적으로 ‘트랜잭션 진행 시작, 종료, 커밋’ 등의 공통되는 모듈들이 존재한다.
본래 변하지 않는 부분, 변하는 부분을 모듈화해서 조립화해서 사용하는 디자인 패턴들에는 '템플릿 메서드 패턴', '전략 패턴', '템플릿 콜백 패턴'이 있었다.
하지만 위에서 볼 수 있듯 중간중간 트랜잭션 로직들이 난잡하게 섞여있어 비즈니스 로직과 떼어내기는 쉽지 않다. 이러한 한계를 극복하기 위해 ‘프록시’ 개념을 사용한 것이 AOP이다.
AOP(Aspect Oriented Programming)는 기능을 핵심 비즈니스 로직과 공통모듈(보안인증, 로깅 등)로 구분하고,
핵심 로직에 영향을 미치지 않고 사이사이에 공통(부가)모듈을 효과적으로 잘 활용하는 방법이다.
객체지향 프로그래밍과 달리, '관점' 지향 프로그래밍이다.
AOP가 사용되는 경우는 아래와 같다. (아래의 것들은 모두 비즈니스 코드와 독립적이고, 부가적인 코드이면서, 난잡하게 비즈니스 코드에 속해있다는 특징이 있다.)
- 간단한 메소드 성능 검사(ex DB에 다량의 데이터를 넣고 빼는 등의 배치 작업에 대한 시간 측정)
- Transaction 처리
- 예외 반환
- 아키텍처 검증
- 로깅
- 보안
"트랜잭션"이라는 부가기능 떼어놓기
위의 Transaction 예시 코드는 class UserService 속 코드의 일부였다. 즉, UserService 안에 upgradeLevels라는 메소드 안에 Transaction 로직이 있었다.
위의 UserService는 class로 되어 있어 다른 코드들은 UserService 클래스를 직접 참조한다. 즉 client와의 응집도가 강하다. 아래와 같이 interface로 만들어 클라이언트가 interface에만 의존하도록 하고 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);
}
}
}
}
이 때 UserService(interface)가 Target라는 걸 기억하자.
이번에는 트랜잭션이 적용된 UserServiceTx을 구현한다. UserServicelmpl은 굳이 Tx을 신경쓰지 않고 자신의 비즈니스 로직을 담고 있는 코드만 가지고 있다.
엥? 그럼 적용이 어떻게 되는 거지
UserServiceTx는 기본적으로 UserService의 핵심로직을 구현하게 만들고 그 속에 Transaction 코드 또한 추가한다. 아래와 같이 우선 transactionManager를 DI 받는다. 이후 아래와 같이 UserService 속 함수 upgradeLevels()에 transactionManager의 함수를 사용해 transaction 시작, 커밋, 롤백 등을 구현한다.
public class UserServiceTx {
private UserService userService;
private PlatformTransactionManager transactionManager;
public void setUserService(UserService userService) {
this.userService = userService;
}
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = 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;
}
}
}
userServiceTx에는 UserService를 생성자 주입하였다.
따라서 의존관계는 이렇게 될 것이다.
그런데 굳이 이 transactionManager 코드도 보이기 싫다. 트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있다. 이것이 프록시 방법이다 !!
프록시 클래스 코드 생성
이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시라고 부른다.
프록시의 특징은 타깃(UserService)과 동일한 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다.
이런 프록시는 사용 목적에 따라 두 가지로 구분한다.
1. 새로운 기능 추가 →데코레이터
2. 패턴접근 제어 → 프록시 패턴
일단 우리는 1번째 목적으로 프록시를 사용한다. 하지만 프록시의 가장 큰 문제점은 타깃의 인터페이스를 구현하고 위임하는 코드를 일일히 작성해주어야 한다는 것이다. 아래 예시를 보자.
책에서는 HelloTarget이라는 class를 만들고, Hello라는 interface를 만들어 위임한 후, HelloUppercase라는 프록시 클래스를 이용하여 타깃인 HelloTarget에 ‘리턴하는 문장을 대문자로’라는 부가기능을 추가한다.
가장 중요한 HelloUppercase라는 프록시 클래스 코드를 보자.
public class HelloUppercase implements Hello {
private final Hello delegate;
public HelloUppercase(Hello delegate) {
this.delegate = delegate;
}
@Override
public String sayHello(String name) {
return delegate.sayHello(name).toUpperCase();
}
@Override
public String sayHi(String name) {
return delegate.sayHi(name).toUpperCase();
}
@Override
public String sayThankYou(String name) {
return delegate.sayThankYou(name).toUpperCase();
}
}
잘 보면 이런 문제점들이 보인다.
- 인터페이스의 모든 메소드를 구현해 위임하도록 코드를 죄다 만들었다.
- 부가기능인 리턴 값을 대문자로 바꾸는 기능이 모든 메소드에 중복돼서 나타난다.
이렇게 일일히 짜지 않고 부가적인 기능을 런타임 시간에 Dynamic하게 (동적으로!) 부여하도록 만든 것이 바로 Dynamic Proxy이다.
Dynamic proxy
우선 로직을 다시 한 번 훑어보자. Transaction이 들어갈 곳을 찾으려면 아래의 단계를 밟아야 한다!
⭐
1. 요청을 처리하는 프로그램에서는 비지니스 로직(UserServiceImpl 속 upgradeLevels)만을 작성한다.
2. 공통적인 부가코드(Transaction 코드)는 따로 작성을 해 둔다.
3. 코드 사이사이 어디에 끼워 넣어서 실행할지 판단을 해주면 된다. (try except문 사이사이)
4. 코드에 intercept한다.
이 로직은 아래와 같이 시행된다.
⭐
1. 요청을 처리하는 프로그램에서는 비지니스 로직(UserServiceImpl 속 upgradeLevels)만을 작성한다.
-> UserServiceImpl에 작성했다.
2. 공통적인 부가코드(Transaction 코드)는 따로 작성을 해 둔다.
-> handler로 코드를 구현하고, Advice로 해당 코드(부가기능 프록시 로직)를 등록해두겠다.
3. 코드 사이사이 어디에 끼워 넣어서 실행할지 판단을 해주면 된다.
-> 끼워넣을 곳을 조인포인터라고 한다. 그 조인포인터 들을 포인트컷에 모아둘거다.
4. 코드에 intercept한다.
그림으로 나타내면 아래와 같다.
위의 intercept 과정을 더 자세히 보자.
1. Proxy Factory Bean으로 Bean 등록
이렇게 Dynamic하게 끼워넣어 실행할 곳들을 판단하여 적용하였다. 하지만 이들은 bean으로 등록되어야 DI할 수 있다. 하지만 target 멤버 변수는 dynamic하게 일어나기 때문에 bean 등록이 기존 방식(빈 생명주기/FactoryBean 개념 참고)으로는 불가능하다. 대신 프록시 팩토리 빈으로 등록 가능하다.
2. 중복 메서드 한 번에 처리
위에 HelloUppercase에서는 부가기능인 리턴 값을 대문자로 바꾸는 기능이 모든 메소드에 중복돼서 나타났다. 이 중복된 메소드도 한 번에 처리하고 싶다면, InvocationHandler 속 invoke 메소드 하나로 해결할 수 있다. 이 메서드는 프록시 객체가 메서드 호출을 처리하고 실제 객체에 대한 호출을 위임하기 위해 사용된다.
target을 설정해준 후, target에 있는 메소드들을 invoke가 호출해주어 여러 target들에 부가기능을 추가할 수 있다. 즉, class마다 메소드를 일일히 생성시키지 않아도 여러 class에 공통적으로 적용해준다.
- InvocationHandler 인터페이스는 아래 메소드 하나만 가진 간단한 인터페이스이다.
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
따라서 아래와 같이 로직이 마무리된다.
자동 프록시 생성
여기까지 아주 행복하다. 하지만 조금 더 발전시킬 여지가 있었다. 위에는 트랜잭션 적용 대상이 되는 빈마다 일일이 프록시 팩토리 빈을 설정해줘야 한다는 부담이 남아있다.
따라서 스프링 컨테이너의 빈 생성 후처리 기법을 활용해 컨테이너 초기화 시점에서 자동으로 프록시를 만들어주는 방법을 도입했다. 빈 후처리기(BeanPostProcessor)을 사용하면 빈 저장소에 등록 직전에 조작할 수 있다 !
우선 어드바이저에 어드바이스와 포인트컷을 한데 묶어 다룬다. 이 속에서 포인트컷은 AdviceFilter 역할을 한다. 쉽게 말해 부가 기능을 해당 클래스/메소드에 적용할지 안할지 판단하는 것이다!
이후 pointcut, Advisor 없이도 빈 후처리기를 이용해 자동 프록시 생성을 할 수 있다 DefaultAdvisorAutoProxyCreator을 적용하여 로직을 더 간단히 한다.
1. 우선 빈 객체들은 생성된다.
2. 이후 빈을 초기화하는 단계에서 먼저 등록된 빈들 중 Advisor 인터페이스를 구현한 것은 모두 찾는다.
3. 생성되는 모든 빈에 대해 포인트컷을 적용해보면서(부가기능 적용할지 안할지?) 프록시 적용 대상을 선정한다.
4. 선정 대상이라면 원래 빈 오브젝트와 바꿔치기 한다.
따라서 AOP 란??
왼쪽은 Aspect(관점)으로 부가기능을 분리하기 전의 상태이다. 오른쪽은 핵심기능 코드 사이에 침투한 부가기능을 독립적인 모듈인 관점으로 구분해낸 것이다.
이를 통해 DI와 Aspect를 이용해 @Transactional 어노테이션이 깔끔하게 탄생했음을 이해할 수 있다 !
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[WAS/WS|Docker] Springboot war 파일 외장 tomcat 배포 (2) | 2024.01.10 |
---|---|
[SPRING] 서비스 추상화 (0) | 2023.10.19 |
[SPRING] enum 타입, 역할과 책임의 분리 (0) | 2023.10.02 |
[SPRING] DI, 템플릿과 콜백 (0) | 2023.09.22 |
[SPRING] DI와 XML을 이용한 설정 (0) | 2023.09.05 |