AOP
학습목표
- AOP의 등장배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지 설명 할 수 있다.
- AOP의 효과적인 사용을 할 수 있다.
- AOP 선언적 트랜잭션 기능과 그 장점을 통해 스프링이 AOP를 도입해야하는 이유에 대해 설명 할 수 있다.
트랜잭션 코드의 분리
지금까지 서비스 추상화를 통해 트랜잭션을 기술에 독립적으로 만들었다. 하지만 비즈니스 로직이 주인이어야 할 메소드에 이름도 길고 무시무시하게 생긴 트랜잭션 코드가 더 많은 자리를 차지하는 모습이 보기 좋지 않다. 하지만 논리적으로 따져봐도 트랜잭션의 경계는 비즈니스 로직 전후로 설정돼야 하는 것이 분명하니 제거할 명분이 없다.
메소드 분리
일단 간단하게 메소드 분리를 통해 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 구분되게 만든다. 비즈니스 로직 코드를 사이에 두고 트랜잭션 경계설정 코드가 앞뒤에 위치한다. 특히, 트랜잭션 경계설정 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없다는 것을 알아둔다. 완벽하게 독립적이다.
public void upgradeLevels() {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
upgradeLevelsInternal();
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
public void upgradeLevelsInternal() {
List<User> users = userDao.getAll();
for(User user : users) {
if(userLevelUpgradePolicy.canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
DI를 이용한 클래스의 분리
좀 더 나아가 UserService 안에 트랜잭션을 담당하는 기술적인 코드를 분리해보자. 트랜잭션 코드를 클래스 밖으로 뽑아낼 것이다.
DI 적용을 이용한 트랜잭션 분리
클라이언트 코드에서 트랜잭션을 뽑아낸 UserService의 구체적인 구현 클래스를 직접 참조하면 필요한 트랜잭션이 없는 UserService를 사용하게 된다. 따라서 직접적인 사용이 문제라면 간접적으로 사용하면 된다. DI의 기본 아이디어는 실제 사용할 오브젝트의 클래스 정체는 감춘 채 인터페이스를 통해 간접으로 접근하는 것이다.
이번에는 DI를 통해 한 번에 한 가지 구현 클래스를 선택해서 적용하지 않겠다. 꼭 그래야 한다는 제약은 없다. 한 번에 두 개의 인터페이스 구현 클래스를 동시에 이용하겠다.
UserService 인터페이스 도입
UserService 인터페이스의 비즈니스 로직을 담은 UserServiceImpl 클래스와 트랜잭션의 경계설정을 담은 UserServiceTx 클래스를 구현하겠다.
public interface UserService {
void add(User user);
void upgradeLevels();
}
분리된 트랜잭션
같은 인터페이스를 구현한 다른 오브젝트에게 비즈니스 로직의 구현을 위임한다.
// Tx : Transaction
public class UserServiceTx implements UserService {
// UserService를 구현한 다른 오브젝트를 DI 받는다.
UserService userService;
public void setUserService(UserService userService) {
this.userService = userService;
}
@Override
public void add(User user) {
this.userService.add(user);
}
@Override
public void upgradeLevels() {
this.userService.upgradeLevels();
}
}
트랜잭션 경계설정 코드 분리의 장점
첫째, 비즈니스 로직을 담당하는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경 쓰지 않아도 된다.
두 번째, 비즈니스 로직에 대한 테스트를 손쉽게 만들어 낼 수 있다.
고립된 단위 테스트
좋은 테스트는 가능한 작은 단위로 쪼개서 테스트하는 것이다. 왜냐하면 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문이다. 테스트 단위가 작아야 테스트의 의도나 내용이 분명해지고, 만들기도 쉬워진다. 하지만 작은 단위로 테스트하고 싶어도 테스트 대상이 다른 오브젝트와 환경에 의존하고 있다면 작은 단위의 테스트가 주는 장점을 얻기 힘들다.
복잡한 의존관계 속의 테스트
UserService는 간단한 기능만을 가지고 있지만, UserDao, MailSender, PlatformTransactionManager 세 가지 타입의 의존 오브젝트가 필요하다.
하지만 UserServiceTest가 테스트하고자 하는 대상은 UserService의 사용자 정보를 관리하는 비즈니스 로직의 구현 코드다. 따라서 UserService의 테스트의 단위는 UserService의 코드에 따라 성공 혹은 실패하는 테스트 즉, UserService 클래스에 한정되어야 한다.
테스트 대상 오브젝트 고립시키기
테스트 대상의 의존관계를 따라 등장하는 오브젝트와 서비스, 환경 등이 모두 합쳐진 테스트하지 않도록 테스트 대상을 고립시킬 필요가 있다. 테스트를 의존 대상으로부터 분리 시키기 위한 방법으로 테스트를 위한 대역을 사용하는 것이다.
테스트를 위한 UserServiceImpl 고립
UserServiceImpl은 트랜잭션 코드에서 독립시켰기 때문에 MockUserDao, MockMailSender 두 개의 목 오브젝트를 필요로 하는 테스트 대상이 된다.
UserDao는 테스트 대상의 코드가 정상적으로 수행되도록 도와주기만 하는 스텁이 아니라, 부가적인 검증 기능까지 가진 목 오브젝트로 만들어야 한다. 왜냐하면 고립된 환경에서 동작하는 upgradeLevels()의 테스트 결과를 검증할 방법이 필요하다. upgradeLevels()는 리턴 값이 없기 때문에 메소드를 실행하고 그 결과를 받아서 검증하는 것이 불가능하기 때문이다.
의존 오브젝트나 외부 서비스에 의존하지 않는 고립된 테스트 방식으로 만들면 그 수행의 결과가 DB 등을 통해서 남지 않으니, 기존처럼 DB값을 확인하는 방법으로는 결과를 검증하기 어렵다. 그래서 협력 목 오브젝트를 통해 어떤 요청을 했는지를 확인하는 작업이 필요하다. 호출된 협력 목 오브젝트와 테스트 대상 사이에서 주고받은 정보를 저장해 두어 테스트의 검증에 사용하면 된다.
고립된 단위 테스트 활용
테스트 대상이 의존하는 메소드를 찾아 테스트의 목 오브젝트에는 필요한 메소드만 구현한다.
@Override
public void upgradeLevels() {
List<User> users = **userDao.getAll();**
for(User user : users) {
if(userLevelUpgradePolicy.canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
protected void upgradeLevel(User user) {
userLevelUpgradePolicy.upgradeLevel(user);
**userDao.update(user);**
sendUpgradeEMail(user);
}
MockUserDao
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;
}
@Override
public List<User> getAll() {
return this.users;
}
@Override
public void update(User user) {
this.updated.add(user);
}
// 테스트에 필요 없는 인터페이스 메소드들
@Override
public void add(User user) {
throw new UnsupportedOperationException();
}
//....
}
UserServiceTest::upgradeLevels()
public void upgradeUsersLevels() throws Exception {
// 고립된 테스트에서는 테스트 대상 오브젝트를 직접 생성한다.
UserServiceImpl userServiceImpl = new UserServiceImpl();
userServiceImpl.setUserLevelUpgradePolicy(this.userLevelUpgradePolicy);
// 목 오브젝트로 만든 UserDao를 직접 DI 해준다.
MockUserDao mockUserDao = new MockUserDao(this.users);
userServiceImpl.setUserDao(mockUserDao);
MockMailSender mockMailSender = new MockMailSender();
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
List<User> updated = mockUserDao.getUpdated();
assertEquals(2, updated.size());
checkUserAndLevel(updated.get(0), "joytouch", Level.SILVER);
checkUserAndLevel(updated.get(1), "madnite1", Level.GOLD);
List<String> requests = mockMailSender.getRequests();
assertEquals(2, requests.size());
assertEquals(users.get(1).getEmail(), requests.get(0));
assertEquals(users.get(3).getEmail(), requests.get(1));
}
// 헬퍼 메소드
private void checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
assertEquals(expectedId, updated.getId());
assertEquals(expectedLevel, updated.getLevel());
}
단위 테스트와 통합 테스트
단위 테스트의 단위는 정하기 나름이다. 사용자 관리 기능 전체를 하나로 볼 수 있고 하나의 클래스나 하나의 메소드를 단위로 볼 수 있다. 중요한 점은 하나의 단위에 초점을 맞춘 테스트라는 점이다. 단위 테스트라는 용어를 사용할 때는 그 의미를 명확히 할 필요가 있다.
보통 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트하는 것을 단위 테스트라고 한다.
반면에 두 개 이상의, 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트는 통합 테스트라고 한다.
p424 : 단위 통합 테스트 가이드 라인
목 프레임워크
단위 테스트를 만들기 위해서 스텁이나 목 오브젝트의 사용이 필수적이다. 단위 테스트가 많은 장점이 있고 가장 우선시 할 테스트 방법이지만 작성이 번거롭다. 특히 목 오브젝트를 만드는 일이 가장 큰 짐이다. 이런 번거로운 목 오브젝트의 작성을 편리하게 도와주는 목 오브젝트 지원 프레임워크를 알아보자.
Mockito 프레임워크
목 프레임워크의 특징은 목 클래스를 일일이 준비해둘 필요가 없다는 점이다. 간단한 메소드 호출로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.
목 오브젝트는 다음의 네 단계를 거쳐서 사용하면 된다.
- 인터페이스를 이용해 목 오브젝트를 만든다.
- 목 오브젝트가 리턴할 값이 있으면 이를 지정해준다. 메소드가 호출되면 예외를 강제로 던지게 만들 수 있다.
- 테스트 대상 오브젝트에 DI 해서 목 오브젝트가 테스트 중에 사용되도록 만든다.
- 테스트 대상 오브젝트를 사용한 후에 목 오브젝트의 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지를 검증한다.
목 오브젝트는 테스트 중 호출되면 자동으로 호출 기록과 파라미터를 기록한다. 미리 설정된 리턴값이 있는 경우에는 그 값을 그래로 리턴해주기도 한다.
호출 횟수 검사는 다음과 같다. (times()는 메소드 호출 횟수를 검증해준다. any() 메소드는 파라미터의 내용은 무시하고 호출 횟수만 확인할 수 있다.)
verify(mockUserDao, times(2)).update(any(User.class));
호출 횟수 검사가 끝나면 목 오브젝트가 호출됐을 때의 파라미터를 하나씩 점검한다. 다음 코드는 users.get(1)을 파라미터로 update가 호출된 적 있는지를 확인한다. update()가 호출된 적 없거나 파라미터가 users.get(1)이 아니면 테스트는 실패한다.
verify(mockUserDao).update(users.get(1));
오브젝트는 확인했지만 레벨의 변화는 파라미터의 직접 비교로는 확인되지 않는다. 따라서 getAll()을 통해 전달했던 this.users의 내용 변화를 확인해야 한다.
MailSender의 경우 ArgumentCaptor 클래스를 활용해 실제 MailSender 목 오브젝트에 전달된 파라미터를 가져와 내용을 검증하는 방법을 사용했다. 위의 update메소드를 검증 할 때 처럼 파라미터를 직접 비교하기보다는 파라미터의 내부 정보를 확인해야하는 경우 유용하다.
ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mockMailSender, times(2)).send(mailMessageArg.capture());
List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();
assertEquals(users.get(1).getEmail(), Objects.requireNonNull(mailMessages.get(0).getTo())[0]);
assertEquals(users.get(3).getEmail(), Objects.requireNonNull(mailMessages.get(1).getTo())[0]);
다이내믹 프록시와 팩토리 빈
프록시와 프록시 패턴, 데코레이터 패턴
지금까지 부가기능 구현 코드(트랜잭션)와 핵심기능 구현 코드(비즈니스 로직)를 UserService인터페이스를 구현한 두 개의 클래스(UserServiceTx, UserSerivceImpl)로 분리했다. 여기서 UserServiceTx는 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들었다. 클라이언트는 인터페이스만 보고 사용하기 때문에 핵심기능을 가진 클래스를 사용할 것이라고 기대하지만 사실 부가기능을 통해 핵심기능을 이용하게 된다. 부가기능 코드에서는 핵심기능으로 요청을 위임해주는 과정에서 자신이 가진 부가적인 기능을 적용해 줄 수 있다.
- 자신이 클라이언트가 사용하려고 하는 실재 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시
- 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃 또는 실체(real subject)
- 프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치
데코레이터 패턴
타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하게 기능을 부여한다는 의미는 컴파일 시점, 즉 코드상에는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.
데코레이터 패턴에서는 프록시가 한 개로 제한되지 않는다. 프록시가 직접 타깃을 사용하도록 고정시킬 필요도 없다. 이 때문에 데코레이터 패턴에서는 같은 인터페이스를 구현한 티깃과 여러 개의 프록시를 사용할 수 있다. 프록시의 순서를 정해서 단계적으로 위임하는 구조로 만들면 된다.
인터페이스를 통한 데코레이터의 정의와 런타임 시의 다이내믹한 구성 방법은 스프링의 DI를 이용하면 편리하다. 스프링 설정에서 데코레이터 빈의 프로퍼티로 같은 인터페이스를 구현한 다른 데코레이터 또는 타깃 빈을 설정하면 된다.
프록시 패턴
일반적인 프록시라는 용어는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 총칭한다. 하지만 디자인 패턴에서 말하는 프록시 패턴은 프록시 사용 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.
프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다. 클라이언트에게 타깃에 대한 레퍼런스를 넘기는 대신 프록시를 넘길 수 있다. 예를 들어 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않은 경우에는 타깃 오브젝트를 꼭 필요한 시점까지 생성하지 않고 프록시 패턴으로 늦춤으로써 얻는 이득을 취할 수 있다.
원격 오브젝트를 이용하는 경우에도 프록시를 사용하면 편리하다. RMI, EJB 등에서 원격 오브젝트에 대한 프록시를 만들고 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼 프록시를 사용할 수 있다. 프록시는 클라이언트의 요청을 받으면 네트워크를 통해 원격 오브젝트를 실행하고 결과를 클라이언트에 넘기면 된다. 클라이언트로 하여금 원격 오브젝트에 대한 접근 방법을 제공해주는 프록시 패턴의 예라고 볼 수 있다.
특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 프록시 패턴을 사용할 수 있다. 수정 가능한오브젝트가 특정 레이어로 넘어가서 읽기전용으로 변한다던가 하는 경우에 프록시의 특정 수정 메소드를 호출하게 되면 접근 불가능 예외를 전달하도록 하면 된다.
프록시와 데코레이터는 구조적으로 유사하다. 하지만 프록시는 데코레이터와 달리 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다.
프록시 패턴이더라도 인터페이스를 통해 위임하도록 만들 수 있다. 인터페이스를 통해 다음 호출 대상으로 접근하면 그 사이에 다른 프록시나 데코레이터가 계속 추가될 수 있기 때문이다.
앞으로는 타깃과 동일한 인터페이스를 구현하고 클라이언트와 타깃 사이에서 존재하면서 기능의 부가 또는 접근 제어를 담당하는 오브젝트를 모두 프록시라고 부른다. 하지만 그때마다 프록시의 사용 목적에 따라 구분하면 데코레이터와 프록시가 구분이 가능하다.
다이내믹 프록시
프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다. 하지만 매번 새로운 클래스를 정의하고 인터페이스의 모든 메소드를 일일히 구현하는 수고는 불편하다. 목이나 스텁을 일일이 클래스로 정의하고 모의 메소드를 구현하는 것과 비슷하다.
Mockito처럼 java.lang.reflect를 이용하면 손쉽게 만들 수 있다. 일일이 프록시 클래스를 정의하지 않고도 몇가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성할 수 있다.
프록시의 구성과 프록시 작성의 문제점
프록시의 구성
- 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
- 지정된 요청에 대해서는 부가기능을 수행한다.
프록시를 만들기 번거로운 이유
- 첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다. 부가기능이 필요없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다. 복잡하지는 않지만 인터페이스의 메소드가 많아지고 다양해지면 부담스러운 작업이 된다. 인터페이스의 변경이 생이면 함께 변경되는 부담도 있다.
- 두 번째는 부가기능 코드가 중복될 가능성이 많다는 점이다. 트랜잭션은 DB를 사용하는 대부분의 비즈니스 로직에 적용될 필요가 있다. 메소드가 많아지고 트랜잭션 적용의 비율이 높아지면 트랜잭션 기능을 제공하는 유사한 코드가 여러 메소드에 중복돼서 나타날 것이다.
인터페이스 메소드의 구현과 위임 기능 문제는 JDK의 다이내믹 프록시를 이용해 해결할 수 있다.
리플렉션
다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어 준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.
자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 갖고 있다. ‘클래스 이름.class’라고 하거나 오브젝트의 getClass() 메소드를 호출하면 클래스 정보를 담은 Class 타입의 오브젝트를 가져올 수 있다. 클래스 오브젝트를 이용하면 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다. 예를 들어 클래스의 이름, 상속한 클래스, 구현한 인터페이스, 필드와 그 타입, 정의한 메소드, 메소드 파라미터 및 리턴타입을 알 수 있다.
다이내믹 프록시 적용
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.
다이내믹 프록시가 인터페이스를 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 작성해야 한다. 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler 인터페이스는 다음과 같은 메소드 한 개만 가진 간단한 인터페이스다.
public Objct invoke(Object proxy, Method method, Object[] args)
invoke() 메소드는 리플렉션의 Method 인터페이스를 파라미터로 받는다. 메소드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.
남은 것은 각 메소드 요청을 어떻게 처리할지 결정하는 일이다. 리플렉션으로 메소드와 파라미터 정보를 모두 갖고 있으므로 타깃 오브젝트의 메소드를 호출하게 할 수도 있다. InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플렉션을 이용해 간단히 위임 코드를 만들어 낼 수 있다.
인터페이스를 제공하면서 프록시 팩토리에게 다이내믹 프록시를 만들어달라고 요청하면 인터페이스의 모든 메소드를 구현한 오브젝트를 생성해준다. 프록시 팩토리에게 InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이내믹 프록시가 받는 모든 요청을 InvocationHandler의 invoke() 메소드로 보내준다. 인터페이스의 메소드가 아무리 많더라도 invoke() 메소드 하나로 처리할 수 있다.
다이내믹 프록시의 사용
ExampleInterface examWithProxy =
(ExampleInterface)Proxy.newProxyInstance(
getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스 로더
new Class[] {ExampleInterface.class}, // 구현할 인터페이스
new ExampleProxyHandler(new ExampleTarget())); // 부가기능(Handler)과 위임코드
다이내믹 프록시의 확장
Method를 이용한 타깃 오브젝트의 메소드를 호출 후 리턴 타입을 확인해야 한다. 추가적으로 InvocationHandler 방식은 타깃의 종류에 상관없이도 적용이 가능하다. 따라서 어떤 종류의 인터페이스를 구현한 타깃이든 상관없이 재사용할 수 있다.
리턴타입이 String인 경우 대문자로 바꿔주는 Handler
public class UppercaseHandler implements InvocationHandler {
Object target;
public UppercaseHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args);
if(ret instanceof String) {
return ((String)ret).toUpperCase();
} else {
return ret;
}
}
}
메소드를 선별해서 부가기능을 적용하기
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args);
if(ret instanceof String && method.getName().startWith("say")) {
return ((String)ret).toUpperCase();
} else {
return ret;
}
}
다이내믹 프록시를 이용한 트랜잭션 부가기능
트랜잭션 부가기능을 제공하는 다이내믹 프록시를 만들자
트랜잭션 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().equals(pattern)) {
return invokeTransaction(method, args);
} else {
return method.invoke(target, args);
}
}
private Object invokeTransaction(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();
}
}
}
요청을 위임할 target을 DI 받아 사용한다. target을 Object형으로 지정함으로써 트랜잭션이 필요한 어떤 타깃 오브젝트에도 적용할 수 있다.
지정된 pattern으로 시작하는 함수만 트랜잭션을 적용한다.
롤백을 적용하기 위한 예외를 RuntimeException 대신 InvocationTargetException을 잡도록 해야 함에 주의하자. 왜냐하면 타깃 메소드를 호출하는 리플렉션 메소드인 Method.invoke()는 타깃 오브젝트의 메소드에서 발생하는 예외가 InvocationTargetException으로 한 번 포장돼서 전달된다. 따라서 타깃 오브젝트의 예외를 가져오려면 InvocationTargetException으로 받은 후 getTargetException() 메소드로 중첩되어 있는 예외를 가져와야 한다.
다이내믹 프록시를 이용한 트랜잭션 테스트
TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(testUserService);
txHandler.setTransactionManager(this.transactionManager);
txHandler.setPattern("upgradeLevels");
UserService txUserService = (UserService) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{UserService.class}, txHandler);
다이내믹 프록시를 위한 팩토리 빈
스프링은 내부적으로 리플랙션 API(Class의 newInstance())를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성한다. 하지만 다이내믹 프록시 오브젝트는 스프링의 방식으로 오브젝트를 생성되지 않는다. 다이내믹 프록시 오브젝트의 클래스가 어떤 것인지 알 수도 없다. 클래스 자체도 내부적으로 다이내믹하게 새로 정의해서 사용하기 때문이다. 따라서 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없다. 또한 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.
팩토리 빈
스프링은 클래스 정보를 가지고 디폴트 생성자를 통해 오브젝트를 만드는 방법 외에도 빈을 만드는 여러 가지 방법을 제공한다. 대표적인 생성 방법 중 팩토리 빈을 활용하는 방법이 있다. 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다. 팩토리 빈은 스프링의 FactoryBean
인터페이스를 구현하는 것이다.
package org.springframework.beans.factory;
public interface FactoryBean<T> {
T getObject() throw Exception; // 빈 오브젝트를 생성해서 돌려준다.
Class<? extends T> getObjectType(); // 생성되는 오브젝트의 타입을 알려준다.
boolean isSingleton(); // getObject()가 돌려주는 오브젝트가 항상 싱글톤 오브젝트 여부
}
FactoryBean
인터페이스를 구현한 클래스를 스프링의 빈으로 등록하면 팩토리 빈으로 동작한다.
스프링은 private 생성자를 가진 클래스도 빈으로 등록해주면 리플렉션을 이용해 오브젝트를 만들어준다. 리플렉션은 private으로 선언된 접근 규약도 위반할 수 있다. 하지만 private으로 만들었다는 것은 스태틱 메소드를 통해 오브젝트가 만들어져야 하는 중요한 이유가 있기 때문이므로 위반하지 않도록 한다.
팩토리 빈은 전형적인 팩토리 메소드를 가진 오브젝트다. 스프링은 FactoryBean 인터페이스를 구현한 클래스가 빈의 클래스로 지정되면, 팩토리 빈 클래스의 오브젝트의 getObject() 메소드를 이용해 오브젝트를 가져오고, 이를 빈 오브젝트로 사용한다. 빈의 클래스로 등록된 팩토리 빈은 빈 오브젝트를 생성하는 과정에서만 사용될 뿐이다.
팩토리 빈의 설정 방법
<bean id="message" class="springbook.learningtest.spring.factorybean.MessageFactoryBean">
<property name="text" value="Factory Bean" />
</bean>
여타 빈 설정과 다르게 message 빈 오브젝트의 타입이 class 애트리뷰트에 정의된 MessageFactoryBean이 아니라 Message 타입이라는 것이다. Message 빈의 타입은 팩토리 빈의 getObjectType() 메소드가 돌려주는 타입으로 결정된다. 또, getObject() 메소드가 생성해주는 오브젝트가 message 빈의 오브젝트가 된다.
다이내믹 프록시를 만들어주는 팩토리 빈
Proxy의 newProxyInstance() 메소드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트는 팩토리 빈을 이용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들 수 있다. 팩토리 빈의 getObject() 메소드에 다이내믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되기 때문이다.
팩토리 빈은 다이내믹 프록시가 위임할 타깃 오브젝트에 대한 레퍼런스를 프로퍼티를 통해 DI 받아야 한다. 다이내믹 프록시와 함께 생성할 핸들러에게 타깃 오브젝트를 전달해줘야 하기 때문이다. 그 외에도 프록시 생성에 필요한 클래스 정보나 패턴 정보를 프로퍼티로 설정해뒀다가 전달해야 한다.
트랜잭션 프록시 팩토리 빈
public class TxProxyFactoryBean implements FactoryBean<Object> {
Object target;
PlatformTransactionManager transactionManager;
String pattern;
Class<?> serviceInterface; // unbounded wildcard type ?
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<?> serviceInterface) {
this.serviceInterface = serviceInterface;
}
@Override
public Object getObject() throws Exception {
TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(this.target);
txHandler.setTransactionManager(this.transactionManager);
txHandler.setPattern(this.pattern);
return Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{this.serviceInterface}, txHandler);
}
@Override
public Class<?> getObjectType() {
return this.serviceInterface;
}
@Override
public boolean isSingleton() {
return false;
}
}
트랜잭션 프록시 팩토리 빈 테스트
트랜잭션 테스트를 위한 특별히 만든 타깃 테스트 클래스(TestUserSevrice)를 프록시 팩토리 빈을 수정하여 넣을 수 있다. id 앞에 &를 붙여 팩토리 빈을 가져와 수정할 수 있다.
TxProxyFactoryBean txProxyFactoryBean = context.getBean("&userService", TxProxyFactoryBean.class);
txProxyFactoryBean.setTarget(testUserService);
UserService txUserService = (UserService)txProxyFactoryBean.getObject();
프록시 팩토리 빈 방식의 장점과 한계
한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있다.
프록시 팩토리 빈 방식의 장점
다이나믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있다.
하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다.
다이내믹 프록시에 팩토리 빈을 이용한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다. DI 설정만으로 다양한 타깃 오브젝트에 적용도 가능하다.
프록시 팩토리 빈의 한계
프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위로 일어나는 일이다. 하나의 클래스 안에 존재하는 여러 개의 메소드에 부가기능을 제공하는 것은 가능하다. 하지만 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 불가능하다. 트랜잭션과 같이 비즈니스 로직을 담는 많은 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 중복되는 것을 막을 수 없다.
하나의 타깃에 여러 개의 부가기능을 적용하는 것도 문제다. 서비스 빈 설정이 부가기능의 개수만큼 따라 붙어야 한다. 적용 대상의 클래스가 많아지면 부가기능도 클래스의 갯수 곱하기 빈 설정 줄 수 만큼 xml 설정 파일의 길이가 늘어날 수 있다. 따라서 설정파일이 복잡해지는 부작용이 나올 수 있다.
또 한 가지 문제점은 TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어진다는 점이다. TransactionHandler는 타깃 오브젝트를 프로퍼티로 갖고 있다. 따라서 트랜잭션의 부가기능을 제공하는 동일한 코드임에도 불구하고 타깃 오브젝트가 달라지면 새로운 TransactionHandler 오브젝트를 만들어야 한다.
스프링의 프록시 팩토리 빈
ProxyFactoryBean
스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다. 생성된 프록시는 스프링 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공한다. 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
ProxyFactoryBean이 생성할 프록시에서 사용할 부가기능은 MethodInterceptor인터페이스를 구현해서 만든다. InvocationHandler와 비슷하지만 스프링 MethodInterceptor의 invoke() 메소드는 타깃 오브젝트에 대한 정보가 제공된다는 점이 다르다. 그 덕분에 MethodInterceptor는 타깃 오브젝트에 독립적으로 만들 수 있다. 따라서 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록이 가능하다.
어드바이스: 타깃이 필요 없는 순수한 부가기능
InvocationHandler와 달리 MethodInterceptor를 구현할 때는 타깃 오브젝트가 등장하지 않는다. MethodInterceptor의 invoke 메소드는 타깃 오브젝트의 정보 및 메소드를 실행할 수 있는 MethodInvocation 파라미터가 주어지기 때문이다. 따라서 MethodInterceptor는 부가기능을 제공하는 데만 집중할 수 있다.
MethodInvocation은 일종의 콜백 오브젝트로, proceed()메소드를 실행하면타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것이다. 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다. MethodInterceptor를 설정할 때는 addAdvice()를 이용한다. ProxyFactoryBean에는 여러 개의 MethodInterceptor(부가기능)를 추가할 수 있다. 새로운 부가기능을 추가할 때마다 프록시와 프록시 팩토리 빈도 추가해줘야 한다는 문제를 해결할 수 있다.
MethodInterceptor를 추가하는 이름은 addAdvice다. MethodInterceptor는 Advice인터페이스를 상속하고 있는 서브인터페이스이기 때문이다. 스프링에서 단순히 메소드 실행을 가로채는 방식 외에도 부가기능을 추가하는 여러 가지 방법을 제공하고 있다. 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스(advice)라고 부른다.
ProxyFactoryBean에는 인터페이스 자동검출 기능을 이용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸다. 그리고 알아낸 인터페이스를 동일하게 구현하는 프록시를 만들어준다. 타깃 오브젝트가 구현하는 인터페이스 중에서 일부만 프록시를 적용하기 원한다면 인터페이스 정보를 직접 제공해줘도 된다.
어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트
포인트컷: 부가기능 적용 대상 메소드 선정 방법
MethodInterceptor는 InvocationHandler와 다르게 프록시가 클라이언트로부터 받는 요청을 일일이 전달받을 필요는 없다. MethodInterceptor에는 재사용 가능한 순수한 부가기능 제공 코드만 남겨주는 것이다. 대신 프록시에 부가기능 적용 메소드를 선택하는 기능을 넣는다. 프록시의 핵심 가치는 타깃을 대신해서 클라이언트의 요청을 받아 처리하는 오브젝트로서의 존재 자체이므로 메소드를 선별하는 기능은 프록시로부터 다시 분리하는 것이 좋다. 메소드를 선정하는 일도 일종의 교환 가능한 알고리즘이므로 전략 패턴을 적용할 수 있기 때문이다.
스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 싱글톤 빈으로 등록이 가능하다.
프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지 확인해달라고 요청한다. 포인트컷은 Pointcut인터페이스를 구현해서 만든다. 부가기능 대상 메소드임이 확인되면 MethodInterceptor 타입의 어드바이스를 호출한다. 어드바이스는 JDK InvocationHandler와 달리 직접 타깃 오브젝트를 호출하지 않고 Invocation 콜백을 통해 호출한다. 이를 통해 자신은 타깃에 의존하지 않고 공유될 수 있다. 어드바이스는 일종의 템플릿, 타깃을 호출하는 MethodInvocation은 콜백이 되는 템플릿 콜백의 구조가 된다.
프록시로부터 어드바이스와 포인트컷을 독립시키고 DI를 사용하게 한 것은 전략 패턴 구조다.
포인트컷이 필요 없을 때는 ProxyFactoryBean의 addAdvice() 메소드를 호출해서 어드바이스만 등록하면 된다. 하지만 포인트컷과 함께 등록할 때는 어드바이스와 포인트컷을 Advisor 타입으로 묶어서 addAdvisor() 메소드를 호출해야 한다. 왜냐하면 ProxyFactoryBean에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있기 떄문이다. 따로 등록하면 어떤 어드바이스가 어떤 포인트컷을 선택하는 지 알 수가 없다. 어드바이스와 포인트컷을 묶은 오브젝트를 인터페이스 이름을 따서 어드바이저라고 부른다.
ProxyFactoryBean 적용
TransactionAdvice
public class TransactionAdvice implements MethodInterceptor {
PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
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;
}
}
}
XML 수정
<bean id="userService" class="org.springframework.aop.framework.ProxyFactoryBean">
<property name="target" ref="userServiceImpl"/>
<property name="interceptorNames">
<list>
<value>transactionAdvisor</value>
</list>
</property>
</bean>
<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>
UserServiceTest::upgradeAllOrNothing()
ProxyFactoryBean proxyFactoryBean //spring의 프록시팩토리빈 가져오기
= context.getBean("&userService", ProxyFactoryBean.class);
proxyFactoryBean.setTarget(testUserService);
UserService txUserService = (UserService) proxyFactoryBean.getObject();
userDao.deleteAll();
for(User user : users) userDao.add(user);
assertThrows(TestUserServiceException.class, txUserService::upgradeLevels);
스프링 AOP
투명성
분리해낸 트랜잭션 코드는 투명한 부가기능 형태로 제공돼야 한다. 투명하다는 건 부가기능을 적용한 후에도 기존 설계와 코드에는 영향을 주지 않는다는 뜻이다. 유리를 사이에 둔 것처럼 다른 코드에서는 그 존재가 보이지 않지만, 메소드가 호출되는 과정에서 다이내믹하게 참여해서 부가적인 기능을 제공해주도록 만드는 것이다. 투명하기 때문에 언제든지 자유롭게 추가하거나 삭제할 수 있고, 기존 코드는 항상 원래의 상태를 유지할 수 있다.
자동 프록시 생성
부가기능이 타깃 오브젝트마다 새로 만들어지는 프록시 문제(프록시 타깃 종속문제)는 스프링 ProxyFactoryBean의 어드바이스를 통해 해결됐다. 다음 문제인 부가기능의 적용이 필요한 새로운 타깃 오브젝트마다 거의 비슷한 내용의 ProxyFactoryBean 빈 설정정보를 추가해야하는 부분을 수정해야 한다.
중복 문제의 접근 방법
한 번에 여러 개의 빈에 프록시를 적용할 만한 방법이 없다.
일정한 타깃 빈의 목록을 제공하면 자동으로 각 타깃 빈에 대한 프록시를 만들어주는 방법이 있다면 ProxyFactoryBean 타입 빈 설정을 매번 추가해서 프록시를 만들어내는 수고를 덜 수 있다.
빈 후처리기를 이용한 자동 프록시 생성기
OCP(유연한 확장)를 충실하게 따르는 스프링은 컨테이너로서 제공하는 기능 중 변하지 않는 핵심적인 부분외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다. 그 중 스프링 빈 오브젝트가 만들어진 후 다시 빈을 가공하는 BeanPostProcessor 인터페이스를 구현해 만드는 빈 후처리기가 있다.
빈 후처리기 자체를 빈으로 등록하면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티의 강제 수정, 별도 초기화를 할 수 있다. 빈 오브젝트를 바꿔치기까지 할 수도 있다.
DefaultAdvisorAutoProxyCreator는 어드바이저를 활용하는 자동 프록시 생성기다. 스프링이 생성한 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수 있다.
빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에 빈을 보낸다. 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 적용 대상인지 확인한다. 적용 대상이면 내장된 프록시 생성기에서 현재 빈에 대한 프록시를 만들고, 프록시에 어드바이저를 연결해준다. 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다. 컨테이너에는 최종적으로 빈 후처리가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
확장된 포인트컷
포인트컷은 빈 오브젝트를 선택하는 기능은 없다. 포인트 컷은 두가지 기능을 갖고 있다. 포인트컷은 클래스 필터와 메소드 매처 두가지를 돌려주는 메소드를 갖고 있다.
public interface Pointcut {
ClassFilter getClassFilter();
MethodMatcher getMethodMatcher();
}
MethodMatcher는 메소드를 선별하는 기능, ClassFilter는 클래스를 선별하는 기능을 갖는다. 두 가지 조건이 모두 충족되는 타깃의 메소드에 어드바이스가 적용되는 것이다.
ProxyFactoryBean에서는 굳이 클래스 레벨의 필터는 필요 없지만, 모든 빈에 대한 프록시 적용 대상을 선별하는 DefaultAdvisorAutoProxyCreator는 클래스와 메소들 선정 알고리즘을 갖는 포인트컷이 필요하다.
DefaultAdvisorAutoProxyCreator의 적용
클래스 필터를 적용한 포인트컷 작성
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {
public void setMappedClassName(String mappedClassName) {
this.setClassFilter(new SimpleClassFilter(mappedClassName));
}
static class SimpleClassFilter implements ClassFilter {
String mappedName;
private SimpleClassFilter(String mappedName) {
this.mappedName = mappedName;
}
public boolean matches(Class<?> clazz) {
return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
}
}
}
어드바이저를 이용하는 자동 프록시 생성기 등록
자동 프록시 생성기인 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 것을 모두 찾는다. 그리고 생성되는 모든 빈에 대해 어드바이저의 포인터컷을 적용해보면서 프록시 적용 대상을 선정한다. 빈 클래스가 프록시 선정 대상이라면 프록시를 만들어 원래 빈 오브젝트와 바꿔치기 한다. 원래 빈 오브젝트는 프록시 뒤에 연결돼서 프록시를 통해서만 접근 가능하게 바뀐다. 타깃 빈에 의존한다고 정의한 다른 빈들은 프록시 오브젝트를 대신 DI 받게 될 것이다.
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator">
이 빈은 특이하게도 id 애트리뷰트는 없고 class뿐이다. 다른 빈에게 참조되거나 코드에서 빈 이름으로 조회될 필요가 없는 빈이라면 아이디를 등록하지 않아도 된다.
자동 프록시 생성기를 사용하는 테스트
<bean id="userService" class="springbook.user.service.UserServiceImpl">
<property name="userDao" ref="userDao"/>
<property name="mailSender" ref="mailSender" />
</bean>
<bean id="testUserService" class="springbook.user.service.UserServiceTest$TestUserServiceImpl" parent="userService"/>
클래스 이름에 $ 기호를 사용하면 스태틱 멤버 클래스를 지정할 때 사용한다. 특정 테스트 클래스에서만 사용되는 클래스는 스태틱 멤버 클래스로 정의하는 것이 편리하다.
parent 애트리뷰트를 사용하면 다른 빈 설정의 내용을 상속받을 수 있다. UserServiceImpl을 상속받아서 TestUserServiceImpl을 만들었다. 기능을 그대로 가져오면서 일부 기능만 테스트에 맞게 수정해서 사용할 수 있다. 마찬가지로 스프링에서는 빈의 설정도 상속받을 수 있다. parent로 지정된 빈의 모든 설정을 그대로 가져와서 사용하겠다는 의미이다. 클래스 및 프로퍼티 설정도 상속받는다.
자동생성 프록시 확인
자동생성기를 빈 후처리기 메커니즘을 통해 적용한 오브젝트의 체크요소.
- 첫째는 트랜잭션이 필요한 빈에 트랜잭션 부가기능이 적용됐는가? upgradeAllOrNothing() 테스트를 통해 확인 가능하다.
- 둘째는 아무 빈에나 트랜잭션 부가기능이 적용된 것은 아닌지 확인해야 한다.
DefaultAdvisorAutoProxyCreator에 의해 userService 빈이 프록시로 바꿔치기 됐다면 getBean(“userService”)로 가져온 오브젝트는 TestUserServie 타입이 아니라 JDK의 Proxy 타입이어야 한다. JDK 다이내믹 프록시 방식으로 만들어지는 프록시는 Proxy 클래스의 서브클래스이기 때문이다.
포인트컷 표현식을 이용한 포인트컷
스프링은 간단하고 효과적인 방법으로 포이트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 이것은 포인트컷 표현식(pointcut expression)이라고 부른다.
포인트컷 표현식
포인트컷 표현식은 AspectJExpressionPointcut 클래스를 사용해 적용할 수 있다. Pointcut 인터페이스를 구현해야하는 스프링의 포인트컷은 클래스 필터와 매소드 매처를 제공해야 했다. 하지만 AspectJExpressionPointcut은 클래스와 메소드의 선정 알고리즘을 표현식을 통해 한 번에 지정할 수 있다. 정규식처럼 간단한 문자열로 복잡한 선정조건을 쉽게 만들어 낼 수 있다. AspectJ 포인트컷 표현식이라고 부르기도 한다.
포인트컷 표현식 문법
AspectJ 포인트컷 표현식은 포인트컷 지시자를 이용해 작성한다. 지시자 중 대표적으로 사용되는 것은 execution()이다. [ ]는 생략 가능한 옵션이고, | 는 OR 조건이다.
문법
`execution([접근제한자 패턴] 리턴 타입패턴 [패키지와 클래스 타입패턴.]
메소드 이름패턴 (파리미터 타입패턴 | ". .", . . .) [throws 예외패턴])`
예시1) 메소드의 풀 시그니처 출력을 통해 표현식 문법을 알아보자
실행 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 예외 이름에 대한 타입 패턴이다. 생략 가능하다.
Method 오브젝트의 메소드 시그니처와 동일한 구조를 갖는다.
포인트컷 표현식을 이용하는 포인트컷 적용
스프링에서 사용될 때 빈의 이름으로 비교하는 bean()이 있다. bean(*Service)라고 쓰면 id가 Service로 끝나는 모든 빈을 선택한다. 클래스와 메소드라는 기준을 넘어서는 유용한 선정 방식이다.
특정 애노테이션이 타입, 메소드, 파라미터에 적용되어 있는 것을 보고 메소드를 선정하게 하는 포인트컷도 만들 수 있다. 애노테이션만 부여해놓고, 포인트컷을 통해 자동으로 선정해서, 부가기능을 제공하는 편리한 방법이다.
예시) @Transactional
애노테이션이 적용된 메소드 선정하기
@annotation(org.springframework.transaction.annotation.Transactional)
타입 패턴과 클래스 이름 패턴
NameMatchMethodPointcut
를 사용할 때 클래스 이름의 패턴을 이용해 타깃 빈을 선정하는 포인트컷을 사용했었다. 하지만 포인트컷 표현식의 클래스 이름에 적용되는 패턴은 클래스 이름 패턴이 아니라 타입패턴이다. 타입패턴은 빈의 슈퍼클래스, 구현 인터페이스까지 타입 패턴의 조건을 고려한다. 포인트컷 표현식에서는 빈의 클래스 이름 뿐만 아니라 슈퍼클래스, 인터페이스까지 따지는 타입패턴임을 기억하자.
AOP란 무엇인가?
핵심기능(비즈니스 로직)에 부여되는 부가기능을 효과적으로 모듈화하는 방법을 적용하고 원시적인 모듈의 형태인 어드바이저(어드바이스 + 포인트컷)를 만들었다.
AOP: 애스펙트지향 프로그래밍 504p
부가기능 모듈화 작업은 기존의 객체지향 설계 패러다임과는 구분되는 새로운 특성이 있다고 생각했다. 이러한 부가기능 모듈을 애스펙트(Aspect)라고 부르기 시작했다. 애스펙트는 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.
애스펙트는 부가기능이 정의된 어드바이스와, 어드바이스를 적용할 곳을 결정하는 포인트컷을 함께 갖고 있다. 어드바이저는 아주 단순한 형태의 애스펙트라고 볼 수 있다.
애스펙트는 그 단어의 의미대로 애플리케이션을 구성하는 한 가지 측면이라고 생각할 수 있다. 애스펙트가 도입되기 전 부가기능이 핵심기능의 모듈에 침투해 들어가면서 설계와 코드가 지저분해졌다. 이러한 부가기능 코드는 여기저기 메스드에 마구 흩어져서 나타나고 코드는 중복된다. 그래서 핵심기능 코드에 침투한 부가기능을 독립적인 측면에 존재하는 애스펙트로 분리한다. 그렇게 하여 핵심기능은 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 되었다.
애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 에스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스펙트 지향 프로그래밍(Aspect Oriented Programming) 또는 AOP라고 부른다.
AOP는 애플리케이션을 다양한 측면에서 독립적으로 모델링하고, 설계하고, 개발할 수 있도록 만들어 준다. 예를 들어 애플리케이션을 사용자 관리라는 핵심 로직 대신 트랜잭션 경계설정이라는 관점에서 바라보고 그 부분에 집중해서 설계하고 개발할 수 있게 된다는 뜻이다. 애플리케이션을 특별한 관점을 기준으로 바라볼 수 있게 해준다는 의미에서 AOP를 관점 지향 프로그래밍이라고도 한다.
AOP 적용기술
프록시를 이용한 AOP
프록시를 만들어 DI로 연결된 빈 사이에 적용해 타깃의 메소드 호출 과정에 참여해서 부가기능을 제공해주도록 만들었다. 따라서 스프링 AOP는 자바의 기본 JDK와 스프링 컨테이너 외에는 특별한 환경이나 JVM 설정 등을 요구하지 않는다.
독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는 게 프록시이다. 그래서 스프링 AOP는 프록시 방식의 AOP라고 할 수 있다.
바이트코드 생성과 조작을 통한 AOP
AspectJ는 다이내믹 프록시 방식을 사용하지 않고 타깃 오브젝트를 뜯어고쳐서 부가 기능을 직접 넣어주는 직접적인 방법을 사용한다. 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점에 가로채서 바이트코드를 조작한다.
AspectJ가 프록시 대신 바이트코드 조작을 하는 이유
- 첫째 바이트코드를 조작해서 타깃 오브젝트를 직접 수정해버리면 스프링과 같은 DI 컨테이너의 자동 프록시 생성과 같은 도움을 받지 않아도 AOP를 적용할 수 있기 때문이다.
- 둘째 프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능하기 때문이다. 프록시를 AOP의 핵심 메커니즘으로 사용하면 부가기능의 적용 대상은 클라이언트 호출 메소드로 제한된다. 하지만 바이트코드를 조작해 AOP를 적용하면 오브젝트 생성, 필드 값 조회 및 조작, 스태틱 초기화 등 다양한 부가기능을 부여할 수 있다. 타깃 오브젝트의 생성되는 순간 부가기능을 부여할 수도 있다. 프록시 적용이 불가능한 private 메소드 호출, 스태틱 메소드 호출 및 초기화, 필드 입출력 등에 부가기능을 부여하기 위해서는 바이트코드를 직접 수정해 타깃 오브젝트나 호출 클라이언트의 내용을 수정하는 것밖에는 방법이 없다.
하지만 대부분은 프록시 방식의 AOP로 충분하다. 프록시를 넘어서는 기능이 필요하면 그때 AspectJ를 사용하면 된다.
AOP의 용어
- 타깃 타깃은 부가기능을 부여할 대상이다. 핵심기능을 담은 클래스 또는 경우에 따라 프록시 오브젝트일 수 있다.
- 어드바이스 어드바이스는 타깃에게 제공할 부가기능을 담은 모듈이다. 어드바이스는 오브젝트로 정의하기도 하지만 메소드 레벨에서 정의할 수 있다. 메소드 호출 과정 전반, 예외가 발생한 경우 등 일부 혹은 전체에서 동작하는 다양한 어드바이스종류가 있다.
- 조인 포인트 조인 포인트(join point) 어드바이스가 적용될 수 있는 위치를 말한다. 스프링 AOP의 조인 포인트는 메소드 실행 단계뿐이다. 타깃 오브젝트가 구현한 인터페이스의 모든 메소드는 조인 포인트가 된다.
- 포이트컷 포인트컷이란 어드바이스를 적용한 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링 AOP의 조인 포인트는 메소드의 실행이므로 스프링의 포인트컷을 메소드 선정하는 기능을 갖는다. 그래서 포인트컷 표현식은 메소드 실행의 의미인 execution으로 시작하며 메소드의 시그니처를 비교한다. 메소드는 클래스 안에 존재하기 때문에 메소드 선정이란 결국 클래스를 선정하고 그 안의 메소드를 선장하게 된다.
- 프록시 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트다. DI를 통해 타깃 대신 클라이언트에게 주입되며, 클라이언트의 호출을 대신 받아서 타깃에 위임하며, 그 과정에서 부가기능을 부여한다.
- 어드바이저 어드바이저는 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트다. 어드바이저는 어떤 부가기능을 어디에 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다. 스프링의 자동프록시 생성기가 어드바이저를 AOP 작업의 정보로 활용한다. 어드바이저는 스프링AOP에서만 사용되는 특별한 용어다.
- 애스펙트 OOP의 클래스와 마찬가지로 애스팩트는 AOP의 기본 모듈이다. 한 개 또는 그 이상의 어드바이스와 포인트컷의 조합으로 만들어지며 보통 싱글톤 형태의 오브젝트로 존재한다. 따라서 클래스와 같은 모듈 정의와 오브젝트와 같은 실체(인스턴스)의 구분이 특별히 없다. 두 가지 모두 애스팩트라 부른다. 스프링 어드바이저는 단순한 애스팩트라 볼 수 있다.
AOP 네임스페이스
스프링 프록시 AOP 사용을 위한 최소한 컨테이너에 등록해야하는 네 가지 빈이 있다. 이들은 컨테이너에서 자동으로 인식된다.
- 자동 프록시 생성기 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록한다. 다른 빈을 DI하지도 않고 자신도 DI 되지 않는 독립적인 존재다. 따라서 id도 필요없다. 애플리케이션 컨텍스트가 빈 오브젝트를 생성하는 과정에 빈 후처리기로 참여한다. 어드바이저 빈을 이용해 프록시를 자동생성한다.
- 어드바이스 부가기능을 구현한 클래스 빈. AOP 관련 빈 중에서 유일하게 직접 구현한 클래스를 이용한다.
- 포인트컷 AspectJExpressionPointcut을 빈으로 등록하고 expression 프로퍼티에 포인트컷 표현식을 넣는다.
- 어드바이저 DefaultPointcutAdvisor 클래스를 빈으로 등록해서 사용한다. 자동 프록시 생성기에 의해 자동 검색되어 사용된다.
AOP 네임스페이스 등록
스프링 AOP를 위한 빈들을 간편하게 등록할 수 있다. AOP 관련 태그를 정의한 aop스키마를 제공한다. aop스키마의 별도의 네임스페이스를 정의해 bean태그와 구분해서 사용할 수 있다.
AOP 네임스페이스 선언
<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"
xsi:schemaLocation=
"http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
**http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd">
</beans>
AOP 네임스페이스를 적용한 AOP 설정 빈
<aop:config>
<aop:pointcut id="transactionPointcut" expression="execution(* *..*ServiceImpl.upgrade*(..))"/>
<aop:advisor advice-ref="transactionAdvice" pointcut-ref="transactionPointcut"/>
</aop:config>
<aop:config>, <aop:pointcut>, <aop:advisor> 세 가지 태그를 정의해두면 그에 따라 세 개의 빈이 자동으로 등록된다.
<aop:config>
AOP 설정을 담는 부모 태그다. 필요에 따라 AspectJAdvisorAutoProxyCreator를 빈으로 등록해준다.
<aop:poitncut>
expression의 표현식을 프로퍼티로 가진 AspectJExpressionPointCut을 빈으로 등록해준다.
<aop:advisor>
advice의 pointcut의 ref를 프로퍼티로 갖는 DefaultBeanFactoryPointcutAdvisor를 등록해준다.
어드바이저 내장 포인트컷
<aop:advisor advice-ref="transactionAdvice" pointcut="execution(* *..*ServiceImpl.upgrade*(..))" />
aop 스키마 전용 태그를 사용하여 포인트컷을 독립적인 태그로 두고 어드바이저 태그에서 참조하는 대신 어드바이저 태그와 결합하는 방법도 가능하다.
트랜잭션 속성
트랜잭션 매니저에서 트랜잭션을 가져올 때 사용한 DefaultTransactionDefinition 오브젝트의 용도가 무엇인지 알아보자.
트랜잭션 정의
트랜잭션이라고 모두 같은 방식으로 동작하는 것은 아니다. 트랜잭션의 개념에 따라 더 이상 쪼갤 수 없는 최소 단위의 작업이라는 개념을 항상 유효하다. 트랜잭션 경계 안에서 진행된 작업은 commit()을 통해 모두 성공하거나 rollback(()을 통해 모두 취소돼야 한다.
DefaultTransactionDefinition이 구현하고 있는 TransactinoDefinition 인터페이스는 트랜잭션의 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다.
트랜잭션 전파
트랜잭션 전파(transaction propagation)란 트랜잭션의 경계에서 이미 진행중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작을 전파할 것인가를 결정하는 방식을 말한다.
독자적인 트랜잭션 경계를 가진 코드에 대해 이미 진행 중인 트랜잭션이 어떻게 영향을 미칠 수 있는가를 정의하는 것이 트랜잭션 전파 속성이다.
-
PROPAGATION_REQUIRED
가장 많이 사용되는 트랜잭션 전파 속성이다. 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다. PROPAGATION_REQUIRED 트랜잭션 전파 속성을 갖는 코드는 다양한 방식으로 결합해서 하나의 트랜잭션으로 구성하기 쉽다. A와 B가 모두 이 속성을 갖는 다면 A, B, A→B, B→A 네 가지의 조합된 트랜잭션이 모두 가능하다. DefaultTransactionDefinition은 이 전파 속성을 사용한다.
-
PROPAGATION_REQUIRES_NEW
항상 새로운 트랜잭션을 시작한다. 즉 앞에서 시작된 트랜잭션이 있든 없든 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 된다. 독립적인 트랜잭션이 보장돼야 하는 코드에 적용할 수 있다.
-
PROPAGATION_NOT_SUPPORTED
이 속성을 사용하면 트랜잭션 없이 동작하도록 만들 수도 있다. 진행 중인 트랜잭션이 있어도 무시한다. 트랜잭션 없이 동작하게 할 거라면 뭐하러 트랜잭션 경계를 설정하는 것일까? 트랜잭션 경계설정은 보통 AOP를 이용해 한 번에 많은 메소드에 동시에 적용하는 방법을 사용한다. 그런데 그 중에서 특별한 메소드만 트랜잭션의 적용에서 제외하려면 어떻게 해야 할까? 물론 포인트컷을 잘 만들어서 특정 메소드가 AOP 적용 대상이 되지 않게 하는 방법도 있겠지만 포인트컷이 상당히 복잡해질 수 있다. 그래서 차라리 모든 메소드에 트랜잭션 AOP가 적용되게 하고, 특정 메소드의 트랜잭션만 PROPAGATION_NOT_SUPPORTED로 설정해서 트랜잭션 없이 동작하게 만드는 편이 낫다.
트랜잭션 전파 속성은 이 외에도 더 있지만 나머지는 찾아보도록 하자. 트랜잭션 매니저를 통해 트랜잭션을 시작하려고 할 때 getTransaction()이라는 메소드를 사용하는 이유는 바로 이 트랜잭션 전파 속성이 있기 때문이다. 트랜잭션 매니저는 getTransaction()을 통해 트랜잭션 전파 속성에 따라 동작을 취한다. 현재 진행 중인 트랜잭션의 존재 여부에 따라 새로운 트랜잭션을 시작할 수도 있고, 이미 진행 중인 트랜잭션에 참여할 수도 있다. 진행 중인 트랜잭션에 참여하는 경우는 트랜잭션 경계의 끝에서 트랜잭션을 커밋시키지도 않는다. 최초로 시작한 경계까지 정상적으로 진행돼야 비로소 커밋될 수 있다.
격리수준
모든 DB 트랜잭션은 격리수준을 갖고 있어야 한다.
서버환경에서는 여러개의 트랜잭션이 동시에 진행될 수 있다. 가능하다면 모든 트랜잭션이 순차적으로 진행돼서 다른 트랜잭션의 작업에 독립적인 것이 좋겠지만, 성능이 크게 떨어질 수 밖에 없다. 따라서 적절하게 격리수준을 조정해서 가능한 많은 트랜잭션을 동시에 진행시켜서 문제가 발생하지 않게 제어가 필요하다.
격리수준은 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서 재설정할 수 있고, 필요하다면 트랜잭션 단위로 격리수준을 조정할 수 있다.
DefaultTransactionDefinition에 설정된 격리 수준은 ISOLATION_DEFAULT다. 이는 DataSource에 설정되어 있는 디폴트 격리수준을 그대로 따른다는 뜻이다. 기본적으로는 DB나 DataSource에 설정된 디폴트 격리수준을 따르지만 특별한 작업을 수행하는 메소드의 경우는 독자적인 격리수준을 지정할 필요가 있다.
제한시간
트랜재견을 수행하는 제한시간(timeout)을 설정할 수 있다. DefaultTransactionDefinition의 기본 설정은 제한시간이 없는 것이다. 제한시간은 트랜잭션을 직접 시작할 수 있는 PROPAGATION_REQUIRED나 PROPAGATION_REQUIRED_NEW와 함께 사용해야하만 의미가 있다.
읽기 전용
읽기 전용(read only)으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막을 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다.
TransactionDefinition 타입 오브젝트를 사용하면 네 가지 속성을 이용해 트랜잭션의 동작방식을 제어할 수 있다.
TransactionDefinition 오브젝트를 생성하고 사용하는 코드는 트랜잭션 경계설정 기능을 가진 TransactionAdvice다.
트랜잭션 정의를 바꾸고 싶다면 외부에서 정의된 TransactionDefinition 오브젝트를 DI 받아서 사용하도록 만들면 된다. TransactionDefinition 타입의 빈을 정의해두면 프로퍼티를 통해 원하는 속성을 지정해줄 수 있다. 하지만 이 방식으로 트랜잭션 속성을 변경하면 TransactinoAdvice를 사용하는 모든 트랜잭션의 속성이 한꺼번에 바뀐다는 문제가 있다. 원하는 메소드만 선택해서 독자적인 트랜잭션을 정의를 적용할 방법을 찾아보자.
트랜잭션 인터셉터와 트랜잭션 속성
매소드별로 다른 트랜잭션 정의를 적용하려면 어드바이스의 기능을 확장해야 한다. 메소드 이름 패턴에 따라 다른 트랜잭션 정의가 적용되도록 만드는 것이다.
TransactionInterceptor
이를 위해 기존의 TransactionAdvice를 다시 설계할 필요는 없다. 이미 스프링에는 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 만들어진 TransactionInterceptor가 존재하기 때문이다.
TransactionInterceptor 어드바이스는 PlatformTransactionManager와 Propertiees 타입의 두 가지 프로퍼티를 갖고 있다. Properties 타입은 이름이 transactionAttributes로 트랜잭션 속성을 정의한 프로퍼티다. 이 트랜잭션 속성은 TransactionDefinition의 네 가지 기본 항목에 rollbackOn()이라는 메소드를 하나 더 갖고 있는 TransactionAttribute 인터페이스로 정의된다. rollbackOn() 메소드는 어떤 예외가 발생하면 롤백을 할 것인가를 결정한다. 이를 활용하면 트랜잭션 부가기능의 동작방식을 모두 제어할 수 있다.
메소드 이름 패턴을 이용한 트랜잭션 속성 지정
Properties 타입의 transactionAttributes 프로퍼티는 메소드 패턴과 트랜잭션 속성을 키와 값으로 갖는 컬렉션이다. 트랜잭션 속성은 다음과 같은 문자열로 정의할 수 있다.
PROPAGATION_NAME, ISOLATION_NAME, readOnly, timeout_NNMM, -Exception1, +Exception2
- PROPAGATION_NAME : 트랜잭션 전파 방식. 필수항목
- ISOLATION_NAME : 격리수준. 생략 가능하다.
- readOnly : 읽기전용 항목. 생략 가능하다. 디폴트는 읽기전용이 아니다.
- timeout_NNMM : 제한시간, 초 단위 시간을 뒤에 붙인다. 생략가능.
- -Exception1 : 체크 예외 중에서 롤백 대상으로 추가할 것을 넣는다. 한 개 이상을 등록할 수 있다.
- +Exception2 : 런타임 예외지만 롤백시키지 않을 예외들을 넣는다. 한 개 이상 등록할 수 있다.
<bean id="transactionAdvice" class="org.springframework.transaction.interceptor.TransactionInterceptor">
<property name="transactionManager" ref="transactionManager" />
<property name="transactionAttributes">
<props>
<prop key="get*">PROPAGATION_REQUIRED,readOnly,timeout_30</prop>
<prop key="upgrade*">PROPAGATION_REQUIRES_NEW,ISOLATION_SERIALIZABLE</prop>
<prop key="*">PROPAGATION_REQUIRED</prop>
</props>
</property>
</bean>
tx 네임스페이스를 이용한 설정 방법
TransactionInterceptor 타입의 어드바이스 빈과 TransactionAttribute 타입의 속성 정보도 tx스키마의 전용 태그를 이용해 정의할 수 있다.
<tx:advice id="transactionAdvice" transaction-manager="transactionManager">
<tx:attributes>
<tx:method name="get*" propagation="REQUIRED" read-only="true" timeout="30"/>
<tx:method name="upgrade*" propagation="REQUIRES_NEW" isolation="SERIALIZABLE"/>
<tx:method name="*" propagation="REQUIRED"/>
</tx:attributes>
</tx:advice>
트랜잭션 속성 적용
트랜잭션 경계설정의 일원화
트랜잭션 경계설정의 부가기능은 특정 계층의 경계와 일치시키는 것이 바람직하다. 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 적절한 대상이다.
서비스 계층을 트랜잭션이 시작되고 종료되는 경계로 정하면, 특별한 이유 없이 다른 계층과 모듈에서 DAO에 직접 접근하는 것을 차단해야 한다. 트랜잭션의 서비스 계층의 메소드 조합을 통해 만들어지기 때문에 DAO가 제공하는 기능은 서비스 계층에 위임 메소드를 만들어둘 필요가 있다.
애노테이션 트랜잭션 속성과 포인트컷
클래스나 메소드에 따라 제각각 속성이 다른, 세밀하게 튜닝된 트랜잭션 속성을 적용해야 하는 경우 메소드 이름 패턴을 이용해서 일괄적으로 트랜잭션 속성을 부여하는 방식은 적합하지 않다. 직접 타깃에 트랜잭션 속성정보를 가진 애노테이션을 지정하는 방법을 알아보자.
트랜잭션 애노테이션
애노테이션의 정의를 읽고 그 내용과 특징을 이해할 수 있도록 애노테이션의 정의에 사용되는 주요 메타애노테이션을 알고 있어야 한다.
@Transactional
package org.springframework.transaction.annotation;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
...
@Target
: 애노테이션을 사용할 대상을 지정한다. 여기에 사용된 ‘메소드와 타입(클래스, 인터페이스)’ 처럼 한 개 이상의 대상을 지정할 수 있다.
@Retention
: 애노테이션 정보가 언제가지 유지되는지 지정한다. 이렇게 RUNTIME까지 지정하면 런타임 때도 애노테이션 정보를 리플렉션을 통해 얻을 수 있다.
@Inherited
: 상속을 통해서도 애노테이션 정보를 얻을 수 있게 한다.
public @interface Transactional {...}
: 트랜잭션 속성의 모든 항목을 엘리먼트로 지정할 수 있다. 디폴트 값이 설정되어 있으므로 모두 생략 가능하다.
@Transactional의 타깃은 메소드와 타입이다. 따라서 메소드, 클래스, 인터페이스에 사용할 수 있다. @Transactional을 트랜잭션 속성정보로 사용하도록 지정하면 이 애노테이션이 부여된 모든 오브젝트를 자동으로 타깃 오브젝트로 인식한다. 포인트컷은 TransactionAttributeSourcePointcut
을 이용해 지정한다. 스스로 표현식과 같은 선정기준을 가지지 않지만 @Transactional이 타입 레벨이나 메소드 레벨에 상관없이 부여된 빈 오브젝트를 모두 찾아서 포인트컷의 선정결과로 돌려준다.
@Transactional은 기본적으로 트랜잭션 속성을 정의하는 것이지만, 동시에 포인트컷의 자동등록에도 사용된다.
트랜잭션 속성을 이용하는 포인트컷
TransactionInterceptor는 메소드 이름 패턴을 통해 부여되는 일관적인 트랜잭션 속성정보 대신 @Transactional 애노테이션의 앨리먼트에서 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource를 사용한다.
포인트컷도 @Transactional을 통한 트랜잭션 속성정보를 참조하도록 만든다. @Transactional로 트랜잭션 속성이 부여된 오브젝트라면 포인트컷의 선정 대상이기도 하기 때문이다.
이 방식을 이용하면 포인트컷과 트랜잭션 속성을 애노테이션 하나로 지정할 수 있다. 트랜잭션 속성은 타입 레벨에 일괄적으로 부여할 수도 있고, 메소드 단위로 세분화하여 부여할 수 있어 세밀한 트랜잭션 속성 제어가 가능해진다.
트랜잭션 부가기능 적용 단위는 메소드다. 따라서 메소드마다 애노테이션을 적용하면 유연한 속성 제어가 가능하지만 코드는 지저분해지고, 동일한 속성 정보를 가진 애노테이션을 반복적으로 메소드에 부여해 주는 부작용이 생길 수 있다.
대체 정책
스프링은 @Transactional을 적용할 때 4단계 대체(fallback) 정책을 이용하게 해준다. 메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스, 인터페이스)의 순서에 따라서 @Transactional이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성정보를 사용하게 하는 방법이다.
자세한 설명은 535p
트랜잭션 애노테이션 사용을 위한 설정
스프링은 모든 설정을 하나의 태그에 담아 두었다. 이 태그 하나로 트랜잭션 애노테이션을 이용하는 데 필요한 어드바이저, 어드바이스, 포인트컷, 애노테이션을 이용하는 트랜잭션 속성정보가 등록된다.
<tx:annotation-driven/>
트랜잭션 지원 테스트
선언전 트랜잭션과 트랜잭션 전파 속성
AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법을 선언적 트랜잭션(declarative transaction)이라고 한다. 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 프로그램에 의한 트랜잭션(programmatic transaction)이라고 한다. 특별한 경우가 아니면 선언적 방식의 트랜잭션을 이용하는 것이 바람직하다.
트랜잭션 동기화와 테스트
트랜잭션의 자유로운 전파와 그로 인한 유연한 개발이 가능할 수 있었던 기술적인 배경에는 AOP가 있다. AOP 덕분에 프록시를 이용한 트랜잭션 부가기능을 적용할 수 있었다. 스프링의 트랜잭션 추상화 덕분에 AOP를 통한 선언적 트랜잭션이나 트랜잭션 전파가 가능했다.
트랜잭션 매니저와 트랜잭션 동기화
PlatformTransactionManager 인터페이스를 구현한 트랜잭션 매니저를 통해 구체적인 트랜잭션 기술의 종류와 관계없이 일관된 트랜잭션의 제어가 가능했다. 트랜잭션 동기화 기술이 있었기에 시작된 트랜잭션 정보를 저장소에 보관해뒀다가 DAO에서 공유할 수 있었다.
효과적인 DB 테스트
일반적으로 의존, 협력 오브젝트를 사용하지 않고 고립된 상태에서 테스트를 진행하는 단위 테스트와, DB 같은 외부의 리소스나 여러 계층의 클래스가 참여하는 통합 테스트는 아예 클래스를 구분해서 따로 만드는 게 좋다. 단위 테스트와 통합 테스트, 학습 테스트는 깔끔하게 분리하는 것이 바람직하다. DB가 사용되는 통합 테스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional을 부여해준다. DB가 사용되는 통합 테스트는 가능한 한 롤백 테스트로 만드는게 좋다.
Truncate Table
truncate table 을 이용해 테이블 데이터를 지우면 롤백으로 되돌릴 수 없다.
출처
토비의 스프링(책)
댓글남기기