토비의 스프링 Vol.1 - 6장 AOP
AOP
AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술의 하나다.
트랜잭션 코드의 분리
스프링이 제공하는 깔끔한 트랜잭션 인터페이스를 썼음에도 비즈니스 로직이 주인이어야 할 메소드 안에 이름도 길고 무시무시하게 생긴 트랜잭션 코드가 더 많은 자리를 차지하고 있다.
메소드 분리
비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다. 이를 리팩토링하자.
DI를 이용한 클래스의 분리
여전히 트랜잭션을 담당하는 기술적인 코드가 UserService 안에 자리 잡고 있으니 이를 클래스 밖으로 뽑아내자.
UserService는 현재 클래스로 되어 있으니 다른 코드에서 사용한다면 UserService 클래스를 직접 참조하게 된다. 그렇다면 트랜잭션 코드를 어떻게든 해서 UserService 밖으로 빼버리면 UserService를 사용하게 될 것이다. 구체적인 구현 클래스를 직접 참조하는 경우의 전형적인 단점이다.
직접 사용하는 것이 문제가 된다면 간접적으로 사용하면 된다. DI의 기본 아이디어는 실제 사용할 오브젝트의 클래스 정체는 감춘 채 인터페이스를 통해 간접으로 접근하는 것이다. 그 덕분에 구현 클래스는 얼마든지 외부에서 변경할 수 있다.
UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들어넣으면 클라이언트와 결합이 약해지고, 직접 구현 클래스에 의존하고 있지 않기 때문에 유연한 확장이 가능해진다. 인터페이스를 이용해 클라이언트와 UserService 구현 클래스의 직접 결합을 막아주고, 유연한 확장이 가능하게 만든 것이다.
그런데 보통 이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이유는, 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서다. 테스트 때는 필요에 따라 테스트 구현 클래스를, 정식 운영중에는 정규 구현 클래스를 DI 해주는 방법처럼 한 번에 한 가지 클래스를 선택해서 적용하도록 되어 있다.
하지만 꼭 그래야 한다는 제약은 없다. 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 이용한다면 어떨까? 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것이다. 하지만 클라이언트가 UserService의 기능을 제대로 이용하려면 트랜잭션이 적용돼야 한다.
UserService를 구현한 또 다른 구현 클래스를 만든다. 이 클래스는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션의 경계설정이라는 책임을 맡고 있을 뿐이다. 그리고 스스로는 비즈니스 로직을 담고 있지 않기 때문에 또 다른 비즈니스 로직을 담고 있는 UserService의 구현 클래스에 실제적인 로직 처리 작업은 위임하는 것이다. 그 위임을 위한 호출 작업 이전과 이후에 적절한 트랜잭션 경계를 설정해주면, 클라이언트 입장에서 볼 때는 결국 트랜잭션이 적용된 비즈니스 로직의 구현이라는 기대하는 동작이 일어날 것이다.
- UserService 인터페이스
public interface UserService { void add(User user); void upgradeLevels(); }
- 트랜잭션 코드를 제거한 UserService 구현 클래스
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); } } } }
- 분리된 트랜잭션 기능 - 위임 기능을 가진 UserServiceTx 클래스
public class UserServiceTx implements UserService { // UserService를 구현한 다른 오브젝트를 DI 받는다. UserService userService; public void setUserService(UserService userService) { this.userService = userService; } // DI 받은 UserService 오브젝트에 모든 기능을 위임한다. public void add(User user) { userService.add(user); } // DI 받은 UserService 오브젝트에 모든 기능을 위임한다. public void upgradeLevels() { userService.upgradeLevels(); } }
UserServiceTx는 UserService 인터페이스를 구현했으니, 클라이언트에 대해 UserService 타입 오브젝트의 하나로서 행세할 수 있다. UserServiceTx는 사용자 관리라는 비즈니스 로직을 전혀 갖지 않고 고스란히 다른 UserService 구현 오브젝트에 기능을 위임한다. 이를 위해 UserService 오브젝트를 DI 받을 수 있도록 만든다.
UserServiceTx에 트랜잭션의 경계설정이라는 부가적인 작업을 부여해보자.
public class UserServiceTx implements UserService {
UserService userService;
PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void setUserService(UserService userService) {
this.userService = userService;
}
// DI 받은 UserService 오브젝트에 모든 기능을 위임한다.
public void add(User user) {
userService.add(user);
}
// DI 받은 UserService 오브젝트에 모든 기능을 위임한다.
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;
}
}
}
트랜잭션 적용을 위한 DI 설정
클라이언트가 UserService라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때 먼저 트랜잭션을 담당하는 오브젝트가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다.
- 트랜잭션 기능의 오브젝트가 적용된 의존관계
Client(UserServiceTest) -> UserServiceTx -> UserServiceImpl
이제 클라이언트는 UserServiceTx 빈을 호출해서 사용하도록 만들어야 한다. 따라서 userService라는 대표적인 빈 아이디는 UserServiceTx 클래스로 정의된 빈에게 부여해준다. userService 빈은 UserServiceImpl 클래스로 정의되는, 아이디가 userServiceImpl인 빈을 DI 하게 만든다.
트랜잭션 경계설정 코드 분리의 장점
- 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경 쓰지 않아도 된다.
- 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.
고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 작은 단위의 테스트가 좋은 이유는 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문이다. 하지만 작은 단위로 테스트하고 싶어도 그럴 수 없는 경우가 많다. 테스트 대상이 다른 오브젝트와 환경에 의존하고 있다면 작은 단위의 테스트가 주는 장점을 얻기 힘들다.
복잡한 의존관계 속의 테스트
UserService는 엔터프라이즈 시스템의 복잡한 모듈과는 비교할 수 없을 만큼 간단한 기능만을 갖고 있다. 그럼에도 UserService의 구현 클래스들이 동작하려면 세 가지 타입의 의존 오브젝트가 필요하다. UserDao타입의 오브젝트를 통해 DB와 데이터를 주고받아야 하고, MailSender를 구현한 오브젝트를 이용해 메일을 발송해야 한다. 마지막으로 트랜잭션 처리를 위해 PlatformTransactionManager와 커뮤니케이션이 필요하다. 이런 경우의 테스트는 준비하기 힘들고, 환경이 조금이라도 달라지면 동일한 테스트 결과를 내지 못할 수도 있으며, 수행 속도는 느리고 그에 따라 테스트를 작성하고 실행하는 빈도가 점차로 떨어질 것이 분명하다.
테스트 대상 오브젝트 고립시키기
그래서 테스트의 대상이 환경이나, 외부 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다.
테스트를 위한 UserServiceImpl 고립
-
upgradeLevels() 테스트 ``` @Test public void upgradeLevels() throws Exception { // DB 테스트 데이터 준비 userDao.deleteAll(); for(User user : users) userDao.add(user);
// 메일 발송 여부 확인을 위해 목 오브젝트 DI MockMailSender mockMailSender = new MockMailSender(); userServiceImpl.setMailSender(mockMailSender);
userService.upgradeLevels(); // 테스트 대상 실행
// DB에 저장된 결과 확인 checkLevelUpgraded(users.get(0), false); checkLevelUpgraded(users.get(1), true); checkLevelUpgraded(users.get(2), false); checkLevelUpgraded(users.get(3), true); checkLevelUpgraded(users.get(4), false);
// 목 오브젝트를 이용한 결과 확인 List
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 checkLevelUpgraded(User user, boolean upgraded) { User userUpdate = userDao.get(user.getId()); … }
이 테스트는 다섯 단계의 작업으로 구성된다.
1. 테스트 실행 중에 UserDao를 통해 가져올 테스트용 정보를 DB에 넣는다. UserDao는 결국 DB를 이용해 정보를 가져오기 때문에 최후의 의존 대상인 DB에 직접 정보를 넣어줘야 한다.
2. 메일 발송 여부를 확인하기 위해 MailSender 목 오브젝트를 DI 해준다.
3. 실제 테스트 대상인 userService의 메소드를 실행한다.
4. 결과가 DB에 반영됐는지 확인하기 위해서 UserDao를 이용해 DB에서 데이터를 가져와 결과를 확인한다.
5. 목 오브젝트를 통해 UserService에 의한 메일 발송이 있었는지를 확인하면 된다.
> 테스트 작업을 분류해보면 처음 두 가지는 UserService의 upgradeLevels() 메소드가 실행되는 동안에 사용하는 의존 오브젝트가 테스트의 목적에 맞게 동작하도록 준비하는 과정이다.<br/>
> * 첫 번째 작업은 의존관계를 따라 마지막에 등장하는 DB를 준비하는 것인 반면에, 두 번째는 테스트를 의존 오브젝트와 서버 등에서 고립시키도록 테스트만을 위한 목 오브젝트를 준비한다는 점이 다르다.<br/>
> 네 번째와 다섯 번째는 테스트 대상 코드를 실행한 후에 결과를 확인하는 작업이다.<br/>
> * 네 번째는 의존관계를 따라 결국 최종 결과가 반영된 DB의 내용을 확인하는 방법인 반면, <br/>
> * 다섯 번째는 메일 서버까지 갈 필요 없이 목 오브젝트를 통해 upgradeLevels() 메소드가 실행되는 중에 메일 발송 요청이 나간 적이 있는지만 확인하도록 되어 있다.
###### 테스트 수행 성능의 향상
DB를 이용하는 테스트와 목 오브젝트만을 이용하는 테스트를 비교해보면 어마어마한 차이가 난다. 목 오브젝트의 외에는 사용자 관리 로직을 검증하는 데 직접적으로 필요하지 않은 의존 오브젝트와 서비스를 모두 제거한 덕분이다.
> 고립된 테스트를 만들려면 목 오브젝트 작성과 같은 약간의 수고가 더 필요할지 모르겠지만, 그 보상은 충분히 기대할 만하다.
#### 단위 테스트와 통합 테스트
* **단위 테스트** - 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트파는 것<br/>
* **통합 테스트** - 두 개 이상의, 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트
단위 테스트와 통합 테스트 선택 가이드
* 항상 단위 테스트를 먼저 고려한다.
* 하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개를 모아서 외부와의 의존관계를 모두 차단하고 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역을 이용하도록 테스트를 만든다. 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향을 받지도 않기 때문에 가장 빠른 시간에 효과적인 테스트를 작성하기에 유리하다.
* 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
* 단위 테스트로 만들기가 어려운 코드도 있다. 대표적인 게 DAO다. DAO는 그 자체로 로직을 담고 있기보다는 DB를 통해 로직을 수행하는 인터페이스와 같은 역할을 한다. SQL을 JDBC를 통해 실행하는 코드만으로는 고립된 테스트를 작성하기가 힘들다. 작성한다고 해도 가치가 없는 경우가 대부분이다. 따라서 DAO는 DB까지 연동하는 테스트로 만드는 편이 효과적이다. DB를 사용하는 테스트는 DB에 데이터를 준비하고, DB에 직접 확인을 하는 등의 부가적인 작업이 필요하다.
* DAO 테스트는 DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류된다. 하지만 코드에서 보자면 하나의 기능 단위를 테스트하는 것이기도 하다. DAO를 테스트를 통해 충분히 검증해두면, DAO를 이용하는 코드는 DAO 역할을 스텁이나 목 오브젝트로 대체해서 테스트할 수 있다. 이후에 실제 DAO와 연동했을 때도 바르게 동작하리라고 확신할 수 있다. 물론 각각의 단위 테스트가 성공했더라도 여러 개의 단위를 연결해서 테스트하면 오류가 발생할 수도 있다. 하지만 충분한 단위 테스트를 거친다면 통합 테스트에서 오류가 발생할 확률도 줄어들고 발생한다고 하더라도 쉽게 처리할 수 있다.
* 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
* 단위 테스트를 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다. 이때도 통합 테스트에 참여하는 코드 중에서 가능한 한 많은 부분을 미리 단위 테스트로 검증해두는 게 유리하다.
* 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트다. 가능하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하면서 단위 테스트를 하는 게 좋겠지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트해야 할 경우도 종종 있다. 이럴 땐 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.
#### 목 프레임워크
번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다.
###### Mockito 프레임워크
간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.
UserDao = mockUserDao = mock(UserDao.class);
* getAll() 메소드가 불려올 때 사용자 목록을 리턴하도록 스텁 기능을 추가해줘야 한다.
when(mockUserDao.getAll()).thenReturn(this.users);
> mockUserDao.getAll()이 호출됐을 때(when), users 리스트를 리턴해주라(thenReturn)는 선언이다.
* update() 메소드가 두 번 호출됐는지 확인
verify(mockUserDao, times(2)).update(any(User.class));
> User 타입의 오브젝트를 파라미터로 받으며 update() 메소드가 두 번 호출됐는지(times(2)) 확인하라(verify)는 것이다.
Mockito 목 오브젝트 사용 단계(2, 4번째는 각각 필요할 경우에만 사용할 수 있다.)
1. 인터페이스를 이용해 목 오브젝트를 만든다.
2. 목 오브젝트가 리턴할 값이 있으면 이를 지정해준다. 메소드가 호출되면 예외를 강제로 던지게 만들 수도 있다.
3. 테스트 대상 오브젝트에 DI 해서 목 오브젝트가 테스트 중에 사용되도록 만든다.
4. 테스트 대상 오브젝트를 사용한 후에 목 오브젝트의 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지를 검증한다.
<hr/>
### 다이내믹 프록시와 팩토리 빈
트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있다. 이 방법을 이용해 UserServiceTx를 만들었고, UserServiceImpl에는 트랜잭션 관련 코드가 하나도 남지 않게 됐다.
이렇게 분리된 부가기능을 담은 클래스는 부가기능 외의 나머지 모든 기능은 원래 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것이다.
문제는 이렇게 구성했더라도 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가 기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸면서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다. 그러기 위해서는 클라이언트는 인터페이스를 통해서만 핵심긴능을 사용하게 하고, 부가 기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다. 그러면 클라이언트는 인터페이스만 보고 사용을 하기 때문에 자신은 핵심기능을 가진 클래스를 사용할 것이라고 기대하지만, 사실은 부가기능을 통해 핵심기능을 이용하게 되는 것이다.
이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 **프록시**라고 부른다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 **타깃** 또는 **실체**라고 부른다.
프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다.
프록시 사용 목적
1. 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서
2. 타깃에 부가적인 기능을 부여해주기 위해서<br/>
> 두 가지 모두 대리 오브젝트라는 개념의 프록시를 두고 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서는 다른 패턴으로 구분한다.
###### 데코레이터 패턴
데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하게 기능을 부가한다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다. 따라서 데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다.
프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.
###### 프록시 패턴
일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 없다. 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 총칭한다면, 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.
프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다. 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않은 경우네는 꼭 필요한 시점까지 오브젝트를 생성하지 않는 편이 좋다. 그런데 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있다. 이럴 때 프록시 패턴을 적용하면 된다. 클라이언트에게 타깃에 대한 레퍼런스를 넘겨야 하는데, 실제 타깃 오브젝트는 만드는 대신 프록시를 넘겨주는 것이다. 그리고 프록시의 메소드를 통해 타깃을 사용하려고 시도하면, 그때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해주는 식이다. 만약 레퍼런스는 갖고 있지만 끝까지 상하지 않거나, 많은 작업이 진행된 후에 사용되는 경우라면, 이렇게 프록시를 통해 생성을 최대한 늦춤으로써 얻는 장점이 많다.
#### 다이내믹 프록시
프록시를 만드는 일이 상당히 번거롭지만 자바에는 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들수 있도록 지원해주는 클래스들이 있다. 일일이 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성하는 것이다.
###### 프록시 구성과 프록시 작성의 문제점
프록시의 기능
* 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
* 지정된 요청에 대해서는 부가기능을 수행한다.
프록시를 만들기가 번거로운 이유
1. 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다. 부가기능이 필요 없는 메소드도 구현해서 위임하는 코드를 일일이 만들어줘야 한다. 또, 타깃 인터페이스의 메소드가 추가되거나 변경될 때마다 함께 수정해줘야 한다.
2. 부가기능 코드가 중복될 가능성이 많다.
###### 리플렉션
다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체르르 추상화해서 접근하도록 만든 것이다.
자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 갖고 있다. 클래스 오브젝트를 이용하면 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다. 예를 들어 클래스의 이름이 무엇이고, 어떤 클래스를 상속하고, 어떤 인터페이스를 구현했는지, 어떤 필드를 갖고 있고, 각각의 타입은 무엇인지, 메소드는 어떤 것을 정의했고, 메소드의 파라미터와 리턴 타입은 무엇인지 알아낼 수 있다. 더 나아가서 오브젝트 필드의 값을 읽고 수정할 수도 있고, 원하는 파라미터 값을 이용해 메소드를 호출할 수도 있다.
###### 다이내믹 프록시 적용
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.
#### 다이내믹 프록시를 이용한 트랜잭션 부가기능
UserServiceTx는 서비스 인터페이스의 메소드를 모두 구현해야 하고 트랜잭션이 필요한 메소드마다 트랜잭션 처리코드가 중복돼서 나타나는 비효율적인 방법으로 만들어져 있다. 트랜잭션이 필요한 클래스와 메소드가 증가하면 UserServiceTx처럼 프록시 클래스를 일일이 구현하는 것은 큰 부담이다. 따라서 다이내믹 프록시와 연동해서 트랜잭션 기능을 부가해주는 InvocationHandler는 한 개만 정의해도 충분하다.
###### 트랜잭션 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; }
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();
} } } ``` 요청을 위임할 타깃을 DI로 제공받도록 한다. 타깃을 저장할 변수는 Object로 선언했다. 따라서 UserServiceImpl 외에 트랜잭셔 적용이 필요한 어떤 타깃 오브젝트에도 적용할 수 있다.
롤백을 적용하기 위한 예외는 RuntimeException 대신에 InvocationTargetException을 잡도록 해야한다. 리플렉션 메소드인 Method.invoke()를 이용해 타깃 오브젝트의 메소드를 호출할 때는 타깃 오브젝트에서 발생하는 예외가 InvocationTargetException으로 한 번 포장돼서 전달된다. 따라서 일단 InvocationTargetException으로 받은 후 getTargetException() 메소드로 중첩되어 있는 예외를 가져와야 한다.
다이내믹 프로시를 위한 팩토리 빈
사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.
팩토리 빈
팩토리 빈이란 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.
팩토리 빈을 만드는 방법에는 여러 가지가 있는데, 가장 간단한 방법은 스프링의 FactoryBean이라는 인터페이스를 구현하는 것이다.
public interface FactoryBean<T> {
T getObject() throws Exception; // 빈 오브젝트를 생성해서 돌려준다.
Class<? extends T> getObjectType(); // 생성되는 오브젝트의 타입을 알려준다.
boolean isSingleton(); // getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
}
FactioryBean 인터페이스를 구현한 클래스를 스프링의 빈으로 등록하면 팩토리 빈으로 동작한다.
다이내믹 프록시를 만들어주는 팩토리 빈
Proxy의 newProxyInstance() 메소드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트는 일반적인 방법으로는 스프링의 빈으로 등록할 수 없다. 대신 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어줄 수가 있다. 팩토리 빈의 getObject() 메소드에 다이내믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되기 때문이다.
트랜잭션 프록시 팩토리 빈
public class TxProxyFactoryBean implements FactoryBean<Object> {
Object target; // 부가기능을 제공할 타깃 오브젝트. 어떤 타입의 오브젝트에도 적용 가능.
PlatformTransactionManager transactionManager; // 트랜잭션 기능을 제공하는 데 필요한 트랜잭션 매니저
String pattern; // 트랜잭션을 적용할 메소드 이름 패턴
Class<?> serviceInterface; // 다이내믹 프록시를 생성할 때 필요하다. UserService 외의 인터페이스를
가진 타깃에도 적용할 수 있다.
public void setTarget(Object target) {
this.target = target;
}
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public void setServiceInterface(Class<?> service Interface) {
this.serviceInterface = serviceInterface;
}
// FactoryBean 인터페이스 구현 메소드
public Object getObject() throws Exception {
// DI 받은 정보를 이용해서 TransactionHandler를 사용하는 다이내믹 프록시를 생성한다.
TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(target);
txHandler.setTransactionManager(transactionManager);
txHandler.setPattern(pattern);
return Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {serviceInterface}, txHandler);
}
public Class<?> getObjectType() {
return serviceInterface; // 팩토리 빈이 생성하는 오브젝트의 타입은 DI 받은 인터페이스 타입에 따라 달라진다.
따라서 다양한 타입의 프록시 오브젝트 생성에 재사용할 수 있다.
}
public boolean isSingleton() {
return false; // 싱글톤 빈이 아니라는 뜻이 아니라 getObject()가 매번 같은 오브젝트를 리턴하지 않는다는 의미.
}
}
프록시 팩토리 빈 방식의 장점과 한계
프록시 팩토리 빈의 재사용
TransactionHandler를 이용하는 다이내믹 프록시를 생성해주는 TxProxyFactoryBean은 코드의 수정 없이도 다양한 클래스에 적용할 수 있다. 타깃 오브젝트에 맞는 프로퍼티 정보를 설정해서 빈으로 등록해주기만 하면 된다. 하나 이상의 TxProxyFactoryBean을 동시에 빈으로 등록해도 상관없다. 팩토리 빈이기 때문에 각 빈의 타입은 타깃 인터페이스와 일치한다.
프록시 팩토리 빈 방식의 장점
- 다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있다.
- 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다.
- 다이내믹 프록시에 팩토리 빈을 잉요한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다.
프록시 팩토리 빈의 한계
프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위로 일어나는 일이다. 하나의 클래스 안에 존재하는 여러 개의 메소드에 부가기능을 한 번에 제공하는 건 어렵지 않게 가능했다. 하지만 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 불가능하다. 하나의 타깃 오브젝트에만 부여되는 부가기능이라면 상관없겠지만, 트랜잭션과 같이 비즈니스 로직을 담은 많은 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 중복되는 것을 막을 수 없다.
하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다. 설정파일이 급격히 복잡해진다. 텍스트로 된 빈 설정 작성은 실수하기 쉽고 점점 다루기 힘들어진다.
또 TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어진다. TransactionHandler는 타깃 오브젝트를 프로퍼티로 갖고 있다. 따라서 트랜잭션 부가기능을 제공하는 동일한 코드임에도 불구하고 타깃 오브젝트가 달라지면 새로운 TransactionHandler 오브젝트를 만들어야 한다.
스프링의 프록시 팩토리 빈
ProxyFactoryBean
스프링은 서비스 추상화를 프록시 기술에도 동일하게 적용하고 있다. 자바에는 JDK에서 제공하는 다이내믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 따라서 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다. 생성된 프록시는 스프링의 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다.
스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor인터페이스를 구현해서 만든다. MethodInterceptor는 InvocationHandler와 비슷하지만 한 가지 다른 점이 있다. InvocationHandler의 invoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서 타깃은 InvocationHandler를 구현한 클래스가 직접 알고 있어야 한다. 반면에 MethodInterceptor의 invoke() 메소드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다. 그 차이 덕분에 MethodInterceptor는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다. 따라서 MethodInterceptor 오브젝트는 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.
어드바이스: 타깃이 필요 없는 순수한 부가기능
ProxyFactoryBean을 적용한 코드를 보면 InvocationHandler를 구현했을 때와 달리 MethodInterceptor를 구현한 클래스에는 타깃 오브젝트가 등장하지 않는다. MethodInterceptor로는 메소드 정보와 함께 타깃 오브젝트가 담긴 MethodInvocation 오브젝트가 전달된다. MethodInvocation은 타깃 오브젝트의 메소드를 실행할 수 있는 기능이 있기 때문이다. MethodInterceptor는 부가기능을 제공하는 데만 집중할 수 있다.
MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것이다. ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다.
ProxyFactoryBean에 이 MethodInterceptor를 설정해줄 때는 일반적인 DI 경우 처럼 수정자 메소드를 사용하는 대신 addAdvice()라는 메소드를 사용한다는 점도 눈여겨봐야 한다. add라는 이름에서 알 수 있듯이 ProxyFactoryBean에는 여러 개의 MethodInterceptor를 추가할 수 있다. ProxyFactoryBean 하나만으로 여러 개의 부가 기능을 제공해주는 프록시를 만들 수 있다. 아무리 많은 부가기능을 적용하더라도 ProxyFactoryBean 하나로 충분하다.
MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 부른다. 어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트다.
포인트컷: 부가기능 적용 대상 메소드 선정 방법
메소드의 이름을 가지고 부가기능을 적용할 대상 메소드를 선정해야 하는데 여러 프록시가 공유하는 MethodInterceptor에 특정 프록시에만 적용되는 패턴을 넣으면 문제가 된다.
MethodInterceptor에는 재사용 가능한 순수한 부가기능을 넣으면 된다. 그리고 메소드를 선별하는 기능은 프록시로부터 다시 분리한다. 메소드를 선정하는 일도 일종의 교환 가능한 알고리즘이므로 전략 패턴을 적용할 수 있다.
스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.
포인트컷이 필요 없을 때는 ProxyFactoryBean의 addAdvice() 메소드를 호출해서 어드바이스만 등록하면 됐다. 그런데 포인트컷을 함께 등록할 때는 어드바이스와 포인트컷을 Advisor 타입으로 묶어서 addAdvisor() 메소드를 호출해야 한다. ProxyFactoryBean에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있기 때문이다. 포인트컷과 어드바이스를 따로 등록하면 어떤 어드바이스(부가기능)에 대해 어떤 포인트컷(메소드 선정)을 적용할지 애매해지기 때문이다. 이렇게 어드바이스와 포인트컷을 묶은 오브젝트를 인터페이스 이름을 따서 어드바이저라고 부른다.
어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)
어드바이스와 포인트컷의 재사용
ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용된 것이다. 그 덕분에 독립적이며, 여러 프록시가 공유할 수 있는 어드바이스와 포인트컷으로 확장 기능을 분리할 수 있었다. 새로운 비즈니스 로직을 담은 클래스가 만들어져도 이미 만들어둔 어드바이스를 그대로 재사용할 수 있으며, 메소드 선정 방식이 달라지는 경우만 포인트컷의 설정을 따로 등록하고 어드바이저로 조합해서 적용해주면 된다.
스프링 AOP
빈 후처리기를 이용한 자동 프록시 생성기
스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다. 그중에서 관심을 가질 만한 확장 포인트는 바로 BeanPostProcessor 인터페이스를 구현해서 만드는 빈 후처리기다. 빈 후처리기는 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.
DefaultAdvisorAutoProxyCreator는 어드바이저를 이용한 자동 프록시 생성기다. 빈 후처리기를 스프링에 적용하는 방법은 간단하다. 빈 후처리기 자체를 빈으로 등록하는 것이다. 스프링은 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수도 있고 별도의 초기화 작업을 수행할 수도 있다. 심지어는 만들어진 빈 오브젝트를 자체를 바꿔치기할 수도 있다. 따라서 스프링이 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다.
DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다. DefaultAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다. 프록시 적용 대상이면 그때는 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다. 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
확장된 포인트컷
포인트컷은 클래스 필터와 메소드 매처 두 가지를 돌려주는 메소드를 갖고 있다. 만약 Pointcut 선정 기능을 모두 적용한다면 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상 클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는 식으로 동작한다.
포인트컷 표현식을 이용한 포인트컷
스프링은 아주 간단하고 효과적인 방법으로 포인트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 정규식이나 JSP의 EL과 비슷한 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 그래서 이것을 포인트컷 표현식이라고 부른다.
포인트컷 표현식
포인트컷 표현식을 지원하는 포인트컷을 적용하려면 AspectJExpressionPointcut 클래스를 사용하면 된다. AspectJExpressionPointcut은 클래스와 메소드의 선정 알고리즘을 포인트컷 표현식을 이용해 한 번에 지정할 수 있게 해준다.
execution([접근제한자 패턴] 타입패턴 [타입패턴.]이름패턴 (타입패턴 | "..", ...)
- 리플렉션의 Method 오브젝트가 제공하는 Target.minus() 메소드의 풀 시그니처 보면 이해하기 쉽다.
System.out.println(Target.class.getMethod("minus", int.class, int.class));
- 결과
public int springbook.learningtest.spring.pointcut.Target.minus(int, int) throws java.lang.RuntimeException
- public
- 접근 제한자다. public, protected, private 등이 올 수 있다. 포인트컷 표현식에서는 생략할 수 있다. 생략이 가능하다는 건 이 항목에 대해서는 조건을 부여하지 않는다는 의미다.
- int
- 리턴 값의 타입을 나타내는 패턴이다. 포인트컷의 표현식에서 리턴 값의 타입 패턴은 필수항목이다. 따라서 반드시 하나의 타입을 지정해야 한다. 또는 에스터리스크를 써서 모든 타입을 다 선택하겠다고 해도 된다. 생략은 불가능하다.
- springbook.learningtest.spring.pointcut.Target
- 여기까지가 패키지와 티입 이름을 포함한 클래스의 타입 패턴이다. 역시 생략 가능하다. 생략하면 모든 타입을 다 허용하겠다는 뜻이다. 뒤에 이어나오는 메소드 이름패턴과 ‘.’으로 연결되기 때문에 작성할 때 잘 구분해야 한다. 여기까지가 타입 패턴이다. 패키지 이름과 클래스 또는 인터페이스 이름에 에스터리스크를 사용할 수 있다. 또 ‘..’를 사용하면 한 번에 여러 개의 패키지를 선택할 수 있다.
- minus
- 메소드 이름 패턴이다. 필수항목이기 때문에 반드시 적어야 한다. 모든 메소드를 다 선택하겠다면 에스터리스크를 넣으면 된다.
- (int, int)
- 메소드 파라미터의 타입 패턴이다. 메소드 파라미터의 타입을 ‘,’로 구분하면서 순서대로 적으면 된다. 파라미터가 없는 메소드를 지정하고 싶다면 () 로 적는다. 파라미터의 타입과 개수에 상관없이 모두 다 허용하는 패턴으로 만들려면 ‘..’을 넣으면 된다. ‘…‘을 이용해서 뒷부분의 파라미터 조건만 생략할 수도 있다. 필수항목이므로 반드시 넣어야 한다.
- throws java.lang.RuntimeException
- 예외 이름에 대한 타입 패턴이다. 생략 가능하다.
옵션 항목을 생략하면 다음과 같다
execution(int minus(int,int))
단, 생략한 부분은 모든 경우를 다 허용하도록 되어 있기 때문에 이 포인트컷 표현식은 어떤 접근제한자를 가졌든, 어떤 클래스에 정의됐든, 어떤 예외를 던지든 상관없이 정수 값을 리턴하고 두 개의 정수형 파라미터를 갖는 minus라는 이름의 모든 메소드를 선정하는 좀 더 느슨한 포인트컷이 됐다는 점에 주의하자.
포인트컷 표현식에서 타입 패턴이라고 명시된 부분은 이름 패턴이 아닌 타입 패턴임을 기억해두자.
AOP란 무엇인가?
AOP: 애스펙트 지향 프로그래밍
부가기능 모듈을 객체지향 기술에서 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르는데 이것이 바로 애스펙트다. 애스펙트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.
애스펙트는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다. 어드바이저는 아주 단순한 형태의 애스펙트라고 볼 수 있다.
애플리케이션의 여러 다른 측면에 존재하는 부가기능은 결국 핵심기능과 함께 어우러져서 동작하게 되어 있다. 하나 이상의 부가기능이 핵심기능과 함께 동시에 동작할 수도 있다. 결국 런타임 시에는 각 부가기능 애스펙트는 자기가 필요한 위치에 다이내믹하게 참여하게 될 것이다. 하지만 설계와 개발은 다른 특성을 띤 애스펙트들을 독립적인 관점으로 작성하게 할 수 있다.
이렇게 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스펙트 지향 프로그래밍 또는 약자로 AOP라고 부른다.
AOP는 OOP를 돕는 보조적인 기술이지 OOP를 완전히 대체하는 새로운 개념은 아니다. 부가기능이 핵심기능 안으로 침투해서 들어가 버리면, 핵심기능 설계에 객체지향 기술의 가치를 온전히 부여하기가 힘들어진다. 부가된 코드로 인해 객체지향적인 설계가 주는 장점을 잃어버리기 십상이다. AOP는 애스펙트를 분리함으로써 핵심기능을 설걔하고 구현할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 것이다.
AOP는 결국 애플리케이션을 다양한 측면에서 독립적으로 모델링하고, 설계하고, 개발할 수 있도록 만들어주는 것이다. 그래서 애플리케이션을 다양한 관점에서 바라보며 개발할 수 있게 도와준다.
AOP 적용 기술
프록시를 이용한 AOP
스프링은 IoC/DI 컨테이너와 다이내믹 프록시, 데코레이터 패턴, 프록시 패턴, 자동 프록시 생성 기법, 빈 오브젝트의 후처리 조작 기법 등의 다양한 기술을 조합해 AOP를 지원하고 있다. 그중 가장 핵심은 프록시를 이용했다는 것이다. 프록시로 만들어서 DI로 연결된 빈 사이에 적용해 타깃의 메소드 호출 과정에 참여해서 부가기능을 제공해주도록 만들었다.
스프링 AOP의 부가기능을 담은 어드바이스가 적용되는 대상은 오브젝트의 메소드다. 프록시 방식을 사용했기 때문에 메소드 호출 과정에 참여해서 부가기능을 제공해주게 되어 있다.
독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는 게 바로 프록시다. 그래서 스프링 AOP는 프록시 방식의 AOP라고 할 수 있다.
바이트코드 생성과 조작을 통한 AOP
AOP 기술의 원조이자, 가장 강력한 AOP 프레임워크로 꼽히는 AspectJ는 프록시를 사용하지 않는 대표적인 AOP 기술이다.
AspectJ는 프록시처럼 간접적인 방법이 아니라, 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 사용한다. 부가기능을 넣는다고 타깃 오브젝트의 소스코드를 수정할 수는 없으니, 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다.
- 이유
- 바이트코드를 조작해서 타깃 오브젝트를 직접 수정해버리면 스프링과 같은 DI 컨테이너의 도움을 받아서 자동 프록시 생성 방식을 사용하지 않아도 AOP를 적용할 수 있다.
- 프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능하다.
일반적인 AOP를 적용하는 데는 프록시 방식의 스프링 AOP로도 충분하다.
AOP의 용어
- 타깃
- 타깃은 부가기능을 부여할 대상이다. 핵심기능을 담은 클래스일 수도 있지만 경우에 따라서는 다른 부가기능을 제공하는 프록시 오브젝트일 수도 있다.
- 어드바이스
- 어드바이스는 타깃에게 제공할 부가기능을 담은 모듈이다. 어드바이스는 오브젝트로 정의하기도 하지만 메소드 레벨에서 정의할 수도 있다.
- 조인 포인트
- 조인 포인트란 어드바이스가 적용될 수 있는 위치를 말한다. 스프링의 프록시 AOP에서 조인 포인트는 메소드의 실행 단계뿐이다. 타깃 오브젝트가 구현한 인터페이스의 모든 메소드는 조인 포인트가 된다.
- 포인트컷
- 포인트컷이란 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링 AOP의 조인 포인트는 메소드의 실행이므로 스프링의 포인트컷은 메소드를 선정하는 기능을 갖고 있다. 그래서 포인트컷 표현식은 메소드의 실행이라는 의미인 execution으로 시작하고, 메소드의 시그니처를 비교하는 방법을 주로 이용한다. 메소드는 클래스 안에 존재하는 것이기 때문에 메소드 선정이란 결국 클래스를 선정하고 그 안의 메소드를 선정하는 과정을 거치게 된다.
- 프록시
- 프록시는 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트다. DI를 통해 타깃 대신 클라이언트에게 주입되며, 클라이언트의 메소드 호출을 대신 받아서 티깃에 위임해주면서, 그 과정에서 부가기능을 부여한다. 스프링은 프록시를 이용해 AOP를 지원한다.
- 어드바이저
- 어드바이저는 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트다. 어드바이저는 어떤 부가기능(어드바이스)을 어디에(포인트컷) 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다. 스프링은 자동 프록시 생성기가 어드바이저를 AOP 작업의 정보로 활용한다. 어드바이저는 스프링 AOP에서만 사용되는 특별한 용어이고, 일반적인 AOP에서는 사용되지 않는다.
- 애스펙트
- OOP의 클래스와 마찬가지로 애스펙트는 AOP의 기본 모듈이다. 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 만들어지며 보통 싱글톤 형태의 오브젝트로 존재한다. 따라서 클래스와 같은 모듈 정의와 오브젝트와 같은 실체(인스턴스)의 구분이 특별히 없다. 두 가지 모두 애스펙트라고 불린다. 스프링의 어드바이저는 아주 단순한 애스펙트라고 볼 수도 있다.
AOP 네임스페이스
스프링의 프록시 방식 AOP를 적용하려면 최소한 네 가지 빈을 등록해야 한다.
- OOP의 클래스와 마찬가지로 애스펙트는 AOP의 기본 모듈이다. 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 만들어지며 보통 싱글톤 형태의 오브젝트로 존재한다. 따라서 클래스와 같은 모듈 정의와 오브젝트와 같은 실체(인스턴스)의 구분이 특별히 없다. 두 가지 모두 애스펙트라고 불린다. 스프링의 어드바이저는 아주 단순한 애스펙트라고 볼 수도 있다.
- 자동 프록시 생성기
- 스프링의 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록한다. 다른 빈을 DI 하지도 않고 자신도 DI 되지 않으며 독립적으로 존재한다. 따라서 id도 굳이 필요하지 않다. 애플리케이션 컨텍스트가 빈 오브젝트를 생성하는 과정에 빈 후처리기로 참여한다. 빈으로 등록된 어드바이저를 이용해서 프록시를 자동으로 생성하는 기능을 담당한다.
- 어드바이스
- 부가기능을 구현한 클래스를 빈으로 등록한다.
- 포인트컷
- 스프링의 AspectJExpressionPointcut을 빈으로 등록하고 expression 프로퍼티에 포인트컷 표현식을 넣어주면 된다.
- 어드바이저
- 스프링의 DefaultPointcutAdvisor 클래스를 빈으로 등록해서 사용한다. 어드바이스와 포인트컷을 프로퍼티로 참조하는 것 외에는 기능은 없다. 자동 프록시 생성기에 의해 자동 검색되어 사용된다.
AOP 네임스페이스
스프링에서는 이렇게 AOP를 위해 기계적으로 적용하는 빈들을 간편한 방법으로 등록할 수 있다. 스프링은 AOP와 관련된 태그를 정의해둔 aop 스키마를 제공한다. aop 스키마에 정의된 태그는 별도의 네임스페이스를 지정해서 디폴트 네임스페이스의
태그와 그분해서 사용할 수 있다.
- 스프링의 DefaultPointcutAdvisor 클래스를 빈으로 등록해서 사용한다. 어드바이스와 포인트컷을 프로퍼티로 참조하는 것 외에는 기능은 없다. 자동 프록시 생성기에 의해 자동 검색되어 사용된다.
aop 스키마에 정의된 태그를 사용하려면 설정파일에 aop 네임스페이스 선언을 설정파일에 추가해줘야 한다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" // beans 스키마는 디폴트 메임스페이스이므로 접두어 없이 사용할 수 있다.
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop" // aop 스키마는 aop 네임스페이스를 가지므로 aop 접두어를 사용한다.
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/apring-aop-3.0.xsd">
...
</beans>
- aop 네임스페이스를 적용한 AOP 설정 빈 ```
<aop:config>, <aop:pointcut>, <aop:advisor> 세 가지 태그를 정의해두면 그에 따라 세 개의 빈이 자동으로 등록된다. 이 세 개의 태그에 의해 등록되는 빈들은 지금까지 사용해온 것들과 동일하거나, 기능이 좀 더 확장된 것들이다.
포인트컷이나 어드바이저, 자동 포인트컷 생성기 같은 특별한 기능을 가진 빈들은 별도의 스키마에 정의된 전용 태그를 사용해 정의해주면 편리하다. 애플리케이션을 구성하는 컴포넌트 빈과 컨테이너에 의해 사용되는 기반 기능을 지원하는 빈은 구분이 되는 것이 좋다.
###### 어드바이저 내장 포인트컷
AspectJ 포인트컷 표현식을 활용하는 포인트컷은 스트링으로 된 표현식을 담은 expression 프로퍼티 하나만 설정해주면 사용할 수 있다. 또, 포인트컷은 어드바이저에 참조돼야만 사용된다. 그래서 aop 스키마의 전용 태그를 사용하는 경우에는 굳이 포인트컷을 독립적인 태그로 두고 어드바이저 태그에서 참조하는 대신 어드바이저 태그와 결합하는 방법도 가능하다.
태그가 하나 줄었으니 포인트컷을 독립적으로 정의하는 것보다 간결해서 보기 좋다. 하지만 하나의 포인트컷을 여러 개의 어드바이저에서 공유하려고 하는 경우에는 포인트컷을 독립적인 <aop:pointcut> 태그로 등록해야 한다.
포인트컷을 내장하는 경우에는 <aop:advosir> 태그 하나로 두 개의 빈이 등록된다. 전용 스키마를 갖는 태그는 한 번에 하나 이상의 빈을 등록할 수 있다. <aop:advisor>처럼 애트리뷰트 설정에 따라 등록되는 빈의 개수와 종류가 달라질 수도 있다. 또한 서버환경이나 클래스패스에 존재하는 라이브러리에 따라서 등록되는 빈이 달라지는 경우도 있다.
<hr/>
### 트랜잭션 속성
#### 트랜잭션 정의
트랜잭션이라고 모두 같은 방식으로 동작하는 것은 아니다. 물론 트랜잭션의 기본 개념인 **더 이상 쪼갤 수 없는 최소 단위의 작업**이라는 개념은 항상 유효하다.
DefaultTransactionDefinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션의 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다.
1. 트랜잭션 전파
**트랜잭션 전파**란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다.
* PROPAGATION_REQUIRED
* 가장 많이 사용되는 트랜잭션 전파 속성이다. 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다.
* PROPAGATION_REQUIRES_NEW
* 항상 새로운 트랜잭션을 시작한다. 즉 앞에서 시작된 트랜잭션이 있든 없든 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 한다. 독립적인 트랜잭션이 보장ㅇ돼야 하는 코드에 적용할 수 있다.
* PROPAGATION_NOT_SUPPORTED
* 이 속성을 사용하면 트랜잭션 없이 동작하도록 만들 수도 있다. 진행 중인 트랜잭션이 있어도 무시한다.
* 포인트컷의 복잡해질 수 있어 모든 메소드에 트랜잭션 AOP가 적용되게 하고, 특정 메소드의 트랜잭션 전파 속석만 PROPAGATION_NOT_SUPPORTED로 설정해서 트랜잭션 없이 동작하게 만들 때
2. 격리수준
* 모든 DB 트랜잭션은 격리수준(isolation level)을 갖고 있어야 한다. 서버환경에서는 여러 개의 트랜잭션이 동시에 진행될 수 있따. 가능하다면 모든 트랜잭션이 순차적으로 진행돼서 다른 트랜잭션의 작업에 독립적인 것이 좋겠지만, 그러자면 성능이 크게 떨어질 수 밖에 없다. 따라서 적절하게 격리수준을 조정해서 가능한 한 많은 트랜잭션을 동시에 진행시키면서도 문제가 발생하지 않게 하는 제어가 필요하다. 격리수준은 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서 재설정할 수 있고, 필요하다면 트랜잭션 단위로 격리수준을 조정할 수 있다.
3. 제한시간
* 트랜잭션을 수행하는 제한시간을 설정할 수 있다. 제한시간은 트랜잭션을 직접 시작할 수 있는 PROGATION_REQUIRED나 PROGATION_REQUIRES_NEW와 함께 사용해야만 의미가 있다.
4. 읽기전용
* 읽기전용으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다.
#### 트랜잭션 인터셉터와 트랜잭션 속성
###### TransactionInterceptor
스프링에는 편리하게 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 만들어진 TransactionInterceptor가 존재한다. TransactionInterceptor는 트랜잭션 정의를 메소드 이름 패턴을 이용해서 다르게 지정할 수 있는 방법을 추가로 제공해준다. TransactionInterceptor는 PlatformTransactionManager와 Properties 타입의 두 가지 프로퍼티를 갖고 있다.
Properties 타입인 두 번째 프로퍼티 이름은 transactionAttributes로, 트랜잭션 속성을 정의한 프로퍼티다. 트랜잭션 속성은 TransactionDefinition의 네 가지 기본 항목에 rollbackOn()이라는 메소드를 하나 더 갖고 있는 TransactionAttribute 인터페이스로 정의된다. rollbackOn() 메소드는 어떤 예외가 발생하면 롤백을 할 것인가를 결정하는 메소드다. 이 TransactionAttribute를 이용하면 트랜잭션 부가기능의 동작방식을 모두 제어할 수 있다.
스프링이 제공하는 TransactionInterceptor에는 기본적으로 두 가지 종류의 예외 처리 방식이 있다.
1. 런타임 예외가 발생하면 트랜잭션은 롤백된다.
2. 반면에 타깃 메소드가 런타임 예외가 아닌 체크 예외를 던지는 경우에는 이것을 예외상황으로 해석하지 않고 일종의 비즈니스 로직에 따른, 의미가 있는 리턴 방식의 한 가지로 인색해서 트랜잭션을 커밋해버린다.<br/>
> 스프링의 기본적인 예외처리 원칙에 따라 비즈니스적인 의미가 있는 예외상황에만 체크 예외를 사용하고, 그 외의 모든 복구 불가능한 순수한 예외의 경우는 런타임 예외로 포장돼서 전달하는 방식을 따른다고 가정하기 때문이다.
그런데 TransactionInterceptor의 이러한 예외처리 기본 원칙을 따르지 않는 경우가 있을 수 있다. 그래서 TransactionAttribute는 rollbackOn()이라는 속성을 둬서 기본 원칙과 다른 예외처리가 가능하게 해준다. 이를 활용하면 특정 체크 예외의 경우는 트랜잭션을 롤백시키고, 특정 런타임 예외에 대해서는 트랜잭션을 커밋시킬 수도 있다.
* 트랜잭션 속성 정의 예
###### tx 네임스페이스를 이용한 설정 방법
* tx 스키마의 전용 태그
<?xml version=”1.0” encoding=”UTF-8”?> <beans xmlns=”http://www.springframework.org/schema/beans” xmlns:xsi=”http://www.w3.org/2001/XMLSchema-instance” xmlns:aop=”http://www.springframework.org/schema/aop” xmlns:tx=”http://www.springframework.org/schema/tx” // tx 네임스페이스 선언 xsi:schemaLocation=”http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/apring-aop-3.0.xsd http://www.springframework.org/schema/tx // tx 스키마 위치 지정 http://www.springframework.org/schema/tx/spring-tx-2.5.xsd”> // tx 스키마 위치 지정 …
> 트랜잭션 속성이 개별 애트리뷰트를 통해 지정될 수 있으므로 설정 내용을 읽기가 좀 더 쉽고, XML 에디터의 자동완성 기능을 통해 편하게 작성할 수 있다. 문자열로 입력할 때 자주 발생하는 오타 문제도 XML 스키마에 미리 등록해둔 값을 통해 검증할 수 있어서 편리하다. bean 태그로 등록하는 경우에 비해 장점이 많으므로 tx 스키마의 태그를 사용해 어드바이스를 등록하도록 권장한다.
#### 포인트컷과 트랜잭션 속성의 적용 전략
###### 트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다
일반적으로 트랜잭션을 적용할 타깃 클래스의 메소드는 모두 트랜잭션 적용 후보가 되는 것이 바람직하다.
쓰기 작업이 없는 단순한 조회 작업만 하는 메소드에는 모두 트랜잭션을 적용하는게 좋다. 조회의 경우에는 읽기전용으로 트랜잭션 속성을 설정해두면 그만큼 성능의 향상을 가져올 수 있다. 또, 복잡한 조회의 경우는 제한시간을 지정해줄 수도 있고, 격리수준에 따라 조회도 반드시 트랜잭션 안에서 진행해야 할 필요가 발생하기도 한다.
따라서 트랜잭션용 포인트컷 표현식에는 메소드나 파라미터, 예외에 대한 패턴을 정의하지 않는 게 바람직하다. 트랜잭션의 경계로 삼을 클래스들이 선정됐다면, 그 클래스들이 모여 있는 패키지를 통째로 선택하거나 클래스 이름에서 일정한 패턴을 찾아서 표현식으로 만들면 된다. 가능하면 클래스보다는 인터페이스 타입을 기준으로 타입 패턴을 적용하는 것이 좋다. 인터페이스는 클래스에 비해 변경 빈도가 적고 일정한 패턴을 유지하기 쉽기 때문이다.
###### 공통된 메소드 이름 규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다
실제로 하나의 애플리케이션에서 사용할 트랜잭션 속성의 종류는 그다지 다양하지 않다. 너무 다양하게 트랜잭션 속성을 부여하면 관리만 힘들어질 뿐이다. 따라서 기준이 되는 몇 가지 트랜잭션 속성을 정의하고 그에 따라 적절한 메소드 명명 규칙을 만들어 두면 하나의 어드바이스만으로 애플리케이션의 모든 서비스 빈에 트랜잭션 속성을 지정할 수 있다.
###### 프록시 방식 AOP는 같은 타깃 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다
프록시 방식의 AOP에서는 프록시를 통한 부가 기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 여기서 클라이언트는 인터페이스를 통해 타깃 오브젝트를 사용하는 다른 모든 오브젝트를 말한다. 반대로 타깃 오브젝트가 자신의 메소드를 호출할 때는 프록시를 통한 부가기능의 적용이 일어나지 않는다.
* 타깃 안에서의 호출에는 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가 있다.
1. 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제한다. 하지만 복잡한 과정을 거쳐서 순수한 비즈니스 로직만을 남겨두려고 노력했는데, 거기에 스프링 API와 프록시 호출 코드가 등장하는 건 그다지 바람직하지 않다.
2. AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP 기술을 적용한다. 하지만 그만큼 다른 불편도 뒤따르기 때문에 꼭 필요한 경우에만 사용해야 한다.
<hr/>
### 애노테이션 트랜잭션 속성과 포인트컷
클래스나 메소드에 따라 제각각 속성이 다른, 세밀하게 튜닝된 트랜잭션 속성을 적용해야 하는 경우라면 메소드 이름 패턴을 이용해서 일괄적으로 트랜잭션 속성을 부여하는 방식은 적합하지 않다. 기본 속성과 다른 경우가 있을 때마다 일일이 포인트컷과 어드바이스를 새로 추가해줘야 하기 때문이다. 포인트컷 자체가 지저분해지고 설정파일도 복잡해지기 쉽다.
이런 세밀한 트랜잭션 속성의 제어가 필요한 경우를 위해 스프링이 제공하는 방법은 설정파일에서 패턴으로 분류 가능한 그룹을 만들어서 일괄적으로 속성을 부여하는 대신에 직접 타깃에 트랜잭션 속성정보를 가진 애노테이션을 지정하는 방법이다.
#### 트랜잭션 애노테이션
###### @Transactional
@Transactional 애노테이션의 타깃은 메소드와 타입이다. 따라서 메소드, 클래스, 인터페이스에 사용할 수 있다. @Transactional 애노테이션을 트랜잭션 속성정보로 사용하도록 지정하면 스프링은 @Transactional이 부여된 모든 오브젝트를 자동으로 타깃 오브젝트로 인식한다. @Transactional은 기본적으로 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다.
###### 트랜잭션 속성을 이용하는 포인트컷
TransactionInterceptor는 메소드 이름 패턴을 통해 부여되는 일괄적인 트랜잭션 속성정보 대신 @Transactional 애노테이션의 엘리먼트에서 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource를 사용한다. @Transactional은 메소드마다 다르게 설정할 수도 있으므로 매우 유연한 트랜잭션 속성 설정이 가능해진다.
동시에 포인트컷도 @Transactional을 통한 트랜잭션 속성정보를 참조하도록 만든다. @Transactional로 트랜잭션 속성이 부여된 오브젝트라면 포인트컷의 선정 대상이기도 하기 때문이다.
이 방식을 이용하면 포인트컷과 트랜잭션 속성을 애노테이션 하나로 지정할 수 있다. 트랜잭션 속성은 타입 레벨에 일괄적으로 부여할 수도 있지만, 메소드 단위로 세분화해서 트랜잭션 속성을 다르게 지정할 수도 있기 때문에 매우 세밀한 트랜잭션 속성 제어가 가능해진다.
트랜잭션 부가기능 적용 단위는 메소드다. 따라서 메소드마다 @Transactional을 부여하고 속성을 지정할 수 있다. 이렇게 하면 유연한 속성 제어는 가능하겠지만 코드는 지저분해지고, 동일한 속성 정보를 가진 애노테이션을 반복적으로 메소드마다 부여해주어야 한다.
###### 대체 정책
스프링은 @Transactional을 적용할 때 4단계의 대체 정책을 이용하게 해준다. 메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스, 인터페이스)의 순서에 따라서 @Transactional이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성정보를 사용하게 한다.
기본적으로 @Transactional 적용 대상은 클라이언트가 사용하는 인터페이스가 정의한 메소드이므로 @Transactional 적용 대상은 클라이언트가 사용하는 인터페이스가 정의한 메소드이므로 @Transactional도 타깃 클래스보다는 인터페이스에 두는 게 바람직하다. 하지만 인터페이스를 사용하는 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 정의한 @Transactional은 무시되기 때문에 안전하게 타깃 클래스에 @Transactional을 두는 방법을 권장한다.
###### 트랜잭션 애노테이션 사용을 위한 설정
> 이 태그 하나로 트랜잭션 애노테이션을 이용하는 데 필요한 어드바이저, 어드바이스, 포인트컷, 애노테이션을 이용하는 트랜잭션 속성정보가 등록된다.
> 애노테이션을 이용한 트랜잭션 속성 지정은 tx 스키마를 사용할 때와 마찬가지로 IDE의 자동완성 기능을 활용할 수 있고 속성을 잘못 지정한 경우 컴파일 에러가 발생해서 손쉽게 확인할 수 있따는 장점이 있다.
<hr/>
### 트랜잭션 지원 테스트
#### 선언적 트랜잭션과 트랜잭션 전파 속성
AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법을 **선언적 트랜잭션**이라고 한다. 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 **프로그램에 의한 트랜잭션**이라고 한다. 스프링은 이 두 가지 방법을 모두 지원하고 있지만, 특별한 경우가 아니라면 선언적 방식의 트랜잭션을 사용하는 것이 바람직하다.
##### 롤백 테스트
롤백 테스트는 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게 하고 테스트가 끝나면 무조건 롤백해버리는 테스트를 말한다.
@Test public void transactionSync() { DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition(); TransactionStatus txStatus = transactionManager.getTransaction(txDefinition); // 테스트 안의 모든 작업을 하나의 트랜잭션으로 통합한다.
try { userService.deleteAll(); userService.add(users.get(0)); userService.add(users.get(1)); } finall { transactionManager.rollback(txStatux); // 테스트 결과가 어떻든 상관없이 테스트가 끝나면 무조건 롤백한다. } }
#### 테스트를 위한 트랜잭션 애노테이션
##### @Transactional
테스트에도 @Transactional을 적용할 수 있다. 테스트 클래스 또는 메소드에 @Transactional 애노테이션을 부여해주면 마치 타깃 클래스나 인터페이스에 적용된 것처럼 테스트 메소드에 트랜잭션 경계가 자동으로 설정된다. 이를 이용하면 테스트 내에서 진행하는 모든 트랜잭션 관련 작업을 하나로 묶어줄 수 있다. @Transacional에는 모든 종류의 트랜잭션 속성을 지정할 수 있기도 하다. 트랜잭션 매니저와 번거로운 코드를 사용하는 대신 간단한 애노테이션만으로 트랜잭션이 적용된 테스틀 손쉽게 만들 수 있다.
##### @Rollback
테스트에 적용된 @Transactional은 기본적으로 트랜잭션을 강제 롤백시키도록 설정되어 있다. @Transactional을 지정해주면 롤백 테스트가 되는 것이다. 그런데 강제 롤백을 원하지 않을 때 @Rollback을 사용할 수 있다. @Rollback은 롤백 여부를 지정하는 값을 갖고 있다. 기본 값은 true이기 때문에 롤백을 원치 않는다면 @Rollback(false)라고 해주면 된다.
##### @TransactionConfiguration
@Transactional은 테스트 클래스에 넣어서 모든 테스트 메소드에 일괄 적용할 수 있지만 @Rollback 애노테이션은 메소드 레벨에만 적용할 수 있다.
테스트 클래스의 모든 메소드에 트랜잭션을 적용하면서 모든 트랜잭션이 롤백되지 않고 커밋되게 하려면 모든 메소드에 @Rollback(false)를 이용하는 것보다 클래스 레벨에 @TransactionConfiguration 애노테이션을 이용하면 된다. @TransactionConfiguration을 사용하면 롤백에 대한 공통 속성을 지정할 수 있다. 디폴트 롤백 속성은 false로 해두고(@TransactionConfiguration(defaultRollback=false)), 테스트 메소드 중에서 일부만 롤백을 적용하고 싶으면 메소드에 @Rollback을 부여해주면 된다.
##### NotTransactional과 Propagation.NEVER
@NotTransactional을 테스트 메소드에 부여하면 클래스 레벨의 @Transactional설정을 무시하고 트랜잭션을 시작하지 않은 채로 테스트를 진행한다. 물론 테스트 안에서 호출하는 메소드에서 트랜잭션을 사용하는 데는 영향을 주지 않는다. 그런데 @NotTransactional은 스프링 3.0에서 제거 대상이 됐기 때문에 사용하기가 조금 꺼림칙하다. 스프링의 개발자들은 트랜잭션 테스트와 비 트랜잭션 테스트를 아예 클래스를 구분해서 만들도록 권장한다.
@NotTransactional 대신 @Transaction의 트랜잭션 전파 속성을 사용하는 방법도 있다. @Transactional을 다음과 같이 NEVER 전파 속성으로 지정해주면 @NotTransactional과 마찬가지로 트랜잭션이 시작되지 않는다.
@Transactional(propagation=Propagation.NEVER) ```
효과적인 DB 테스트
일반적으로 의존, 협력 오브젝트를 사용하지 않고 고립된 상태에서 테스트를 진행하는 단위 테스트와, DB 같은 외부의 리소스나 여러 계층의 클래스가 참여하는 통합 테스트는 아예 클래스를 구분해서 따로 만드는 게 좋다.
DB가 사용되는 통합 테스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional을 부여해준다. DB가 사용되는 통합 테스트는 가능한 한 롤백 테스트로 만드는 게 좋다.
테스트는 어떤 경우에도 서로 의존하면 안 된다. 테스트가 진행되는 순서나 앞의 테스트의 성공 여부에 따라서 다음 테스트의 결과가 달라지는 테스트를 만들면 안 된다. 코드가 바뀌지 않는 한 어떤 순서로 진행되더라도 테스트는 일정한 결과를 내야 한다.
정리
- 트랜잭션 경계설정 코드를 분리해서 별도의 클래스로 만들고 비즈니스 로직 클래스와 동일한 인터페이스를 구현하면 DI의 확장 기능을 이용해 클라이언트의 변경 없이도 깔끔하게 분리된 트랜잭션 부가기능을 만들 수 있다.
- 트랜잭션처럼 환경과 외부 리소스에 영향을 받는 코드를 분리하면 비즈니스 로직에만 충실한 테스트를 만들 수 있다.
- 목 오브젝트를 활용하면 의존관계 속에 있는 오브젝트도 손쉽게 고립된 테스트로 만들 수 있다.
- DI를 이용한 트랜잭션의 분리는 데코레이터 패턴과 프록시 패턴으로 이해될 수 있다.
- 번거로운 프록시 클래스 작성은 JDK의 다이내믹 프록시를 사용하면 간단하게 만들 수 있다.
- 다이내믹 프록시는 스태틱 팩토리 메소드를 사용하기 때문에 빈으로 등록하기 번거롭다. 따라서 팩토리 빈으로 만들어야 한다. 스프링은 자동 프록시 생성 기술에 대한 추상화 서비스를 제공하는 프록시 팩토리 빈을 제공한다.
- 프록시 팩토리 빈의 설정이 반복되는 문제를 해결하기 위해 자동 프록시 생성기와 포인트컷을 활용할 수 있다. 자동 프록시 생성기는 부가기능이 담긴 어드바이스를 제공하는 프록시를 스프링 컨테이너 초기화 시점에 자동으로 만들어준다.
- 포인트컷은 AspectJ 포인트컷 표현식을 사용해서 작성하면 편리하다.
- AOP는 OOP만으로는 모듈화하기 힘든 부가기능을 효과적으로 모듈화하도록 도와주는 기술이다.
- 스프링은 자주 사용되는 AOP 설정과 트랜잭션 속성을 지정하는 데 사용할 수 있는 전용 태그를 제공한다.
- AOP를 이용해 트랜잭션 속성을 지정하는 방법에는 포인트컷 표현식과 메소드 이름 패턴을 이용하는 방법과 타깃에 직접 부여하는 @Transactional 애노테이션을 사용하는 방법이 있다.
- @Transactional을 이용한 트랜잭션 속성을 테스트에 적용하면 손쉽게 DB를 사용하는 코드의 테스트를 만들 수 있다.
출처 : https://github.com/Masssidev/toby-vol1