토비의 스프링 Vol1의 6장 내용입니다.
0. 개요
AOP(Aspect Oriented Programming)는 IoC/DI, 서비스 추상화와 더블어 스프링의 3대 기반기술 중 하나이다. 스프링의 기술 중에서 가장 이해하기 힘든 난해한 용어를 가진 기술로 악명이 높다.
1. 트랜잭션 코드의 분리
public void upgradeLevels() throws Exception {
TransactionStatus status = this.transactionManager
.getTransaction(new DefaultTransactionDefinition()); // 트랜잭션 경계
try { // 비즈니스 로직 시작
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}// 비즈니스 로직 끝
this.transactionManager.commit(status); // 트랜잭션 경계
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는 듯 보이지만, 자세히 살펴보면 뚜렷하게 두 가지 종류의 코드가 구분되어 있음을 알 수 있다.
비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 배치되어 있다. 또한, 비즈니스 로직과 트랜잭션 경계 바깥의 코드들은 서로 주고받는 정보가 "전혀 없다" 는 점이다.
따라서, 이 부분을 분리해서 독립된 메서드로 만들 수 있지 않을까?
public void upgradeLevels() throws Exception {
TransactionStatus status = this.transactionManager
.getTransaction(new DefaultTransactionDefinition());
try {
upgradeLevelsInternal();
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevelsInternal() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
코드를 분리하고 나니 가독성이 좋아졌다. 하지만, 여전히 트랜잭션을 담당하는 기술적인 코드가 버젓이 UserService 안에 자리잡고 있다. 어차피 서로 직접적으로 정보를 주고받는 것이 없다면, 아예 트랜잭션 코드를 안보이게 하는 것이 Service 레이어의 완성도를 높일 것이다. 안보이게 하는 가장 간단한 방법은 트랜잭션 코드를 클래스 밖으로 뽑아내는 것이다.
1.1. DI 적용을 이용한 트랜잭션 분리
지금 UserService는 UserServiceTest가 클라이언트 형태로, 호출해서 사용하고 있다. 지금은 Test가 사용하고 있지만, 실제로는 컨트롤러에서 서비스들을 사용 할 것이다. 그런데 UserService에 트랜잭션 코드가 없다면 UserService 사용자들은 트랜잭션 기능이 없는 채로 사용하게 될 것이다.
직접 사용하는 것이 문제가 된다면 간접적으로 사용하면 된다. 바로 여기서 DI(Dependency Injection)가 사용된다.
이전의 가정에선 UserServiceTest에 UserService 객체를 직접 사용하고 있었다. 이러면 두개가 강하게 결합되기 때문에 수정에 취약하다. 이를 개선하기 위해 UserService 인터페이스를 만들고, 해당 인터페이스를 상속하게끔 하여 직접 결합을 막자.

UserServiceImpl가 비즈니스 로직을 담당하고, UserServiceTx가 트랜잭션을 담당한다. UserServiceTx가 UserServiceImpl를 가지고 있는 그림이 된다.
public interface UserService{
void add(User user);
void upgradeLevels();
}
UserServiceImpl는 기존 UserService 클래스의 내용을 대부분 그대로 유지하면 된다. 단, 트랜잭션과 관련된 코드는 모두 제거한다.
public class UserServiceImpl implements UserService{
UserDao userDao;
MailSender mailSender;
pulbic void upgradeLevels(){
List<User> users = userDat.getAll();
for(User user : users){
if(canUpgraeLevel(user)){
upgradeLevel(user);
}
}
}
}
UserDao 라는 인터페이스를 이용하고, User라는 도메인 정보를 가진 비즈니스 로직에만 충실한 깔끔한 코드이다. 이제 비즈니스 트랜잭션 처리를 담은 UserServiceTx를 만들어보자
1.2. 분리된 트랜잭션 기능
그리고 같은 인터페이스를 구현한 다른 오브젝트에게 고스란히 작업을 위임하게 만들면 된다. 적어도 비즈니스 로직에서는 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;
}
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;
}
}
}
트랜잭션 경계설정 API(Transactionstatus) 까지 설정을 해주면 된다. upgradeLevels() 는 UserService에서 트랜잭션 처리 메소드와 비즈니스 로직 메소드를 분리했을 때 트랜잭션을 담당한 메소드와 거의 한 메소드가 됐다. 추상화된 트랜잭션 구현 오브젝트를 DI 받을 수 있도록 PlatformTransactionManager 타입의 프로퍼티도 추가됐다.
1.3. 트랜잭션 적용을 위한 DI 설정
클라이언트(여기선 Test)가 UserService 라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때, 먼저 트랜잭션을 담당하는 오브젝트(UserServiceTx)가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직(UsetServiceImpl)을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다.
스프링의 DI 설정에 의해 결국 만들어질 빈 오브젝트와 그 의존관계는 그림 6-4와 같이 구성돼야 한다.

1.4. 트랜잭션 분리에 따른 테스트 수정
기본적인 분리 작업이 끝났으니 테스트도 수정해야 한다. 기존에는 UserService를 직접 사용하는 테스트 구조였다. 물론, @Autowired를 통해 UserService 구현체를 자동으로 찾아준다. 하지만 지금은 UserService의 구현체가 2개나 된다. 게다가 이 2개의 구현체는 서로 결합되어 있기 때문에, 둘 다 필요하다.
1.5. 트랜잭션 경계설정 코드 분리의 장점
- 이제 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경쓰지 않아도 된다.
- 트랜잭션은 DI를 이용해 UserServiceTx와 같은 트랜잭션 기능을 가진 오브젝트가 먼저 실행되게만 만들면 된다. 따라서 언제든지 트랜잭션을 도입할 수 있다.
- 트랜잭션 같은 기술적인 내용 때문에 잘 만들어놓은 비즈니스 로직 코드에 괜히 손을 대서 엉망으로 만드는 불상사도 없다.
- 비즈니스 로직에 대한 테스트를 손쉽게 만들어 낼 수 있다는 것이다.
2. 고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는것이다. 작은 단위 테스트는 실패했을 때 그 윈인을 찾기가 쉽고, 반대의 경우에는 원인을 찾기 매우 어렵다.

UserService를 테스트한다고 가정해보자. UserService에서 DB에 유저 정보를 저장하고 확인하는 테스트를 진행한다면, 생각해야 하는 변수가 수십가지는 된다. DB 서버, 네트워크, 연관된 클래스 구조(User 객체 등), 기타 환경 등에서 하나만 오류가 나도 실패한다. 이 테스트 결과를 보고 내 코드에 문제가 생겼다고 확신할 수 있을까? 아닐 것이다.
2.1. 테스트 대상 오브젝트 고립시키기
그래서 테스트 대상의 환경이나 외부 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트 대역(@Mock)을 사용하는 것이다.
@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<String> 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());
...
}
이렇게 수정해봤지만, 여전히 DB및 네트워크 등 기타 설정에도 테스트 결과값이 바뀔 수 있다. 따라서, DB와의 의존 관계를 완전히 제거한 테스트를 만들어야 한다.
따라서 UserDao를 구현한 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;
}
public List<User> getAll(){
return this.users;
}
public void update(User user){
updated.add(user);
}
}
이 구현체는 순전히 test에서만 사용될 것 이기 때문에, 내부 구현 또한 test용으로 맞춤 제작되어 있다. 내부에 2개의 리스트들 또한 getUpdated(), update() 메서드를 통해 DB내용이 바뀌는 것을 단순히 모사하기 위함이다. 이 구현체를 테스트 코드의 UserService에 D.I를 통해 추가해주면 더욱 단순한/고립된 테스트가 된다.
2.2. 테스트 수행 성능의 향상
위 과정을 통해 성공적으로 단순화시켜서 테스트를 진행해보면, 테스트 목적도 명료해지지만 부가적으로 굉장히 속도가 빨라짐을 알 수 있다. 책에서는 무려 500배나 테스트 속도가 빨라졌다고 말한다. 어떻게 이렇게 빨라졌을까? 가장 큰 이유는 DB의존성 제거에 있다.
고립된 테스트를 하면 테스트가 다른 의존 대상에 영향을 받을 경우를 대비해, 복잡하게 준비할 필요가 없을 뿐만 아니라 테스트 수행 성능도 크게 향상된다.
2.3. 단위 테스트와 통합 테스트
책에서 내린 단위 테스트의 정의는 다음과 같다.
테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트하는 것
반면, 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하는 것들을 통합 테스트라고 부른다. 통합 테스트 = 두 개 이상의 단위가 결합해서 동작하며 테스트하는 것 이라고 볼 수 있다.
단위 테스트와 통합 테스트 중에서 어떤 방법을 쓸지는 어떻게 결정할 것인가? 그에 대한 가이드라인은 아래와 같다.
- 항상 단위 테스트를 먼저 고려한다.
- 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
- 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
2.4. Mock Framework(Mockito)
단위 테스트를 만들기 위해서는 스텁이나 Mock 객체 사용이 필수적이다. 의존관계가 없는 단순한 클래스나 세부 로직을 검증하기 위해메서드 단위로 테스트할 때가 아니면, 대부분 의존 오브젝트를 필요로 하는 코드를 테스트하게 되기 때문이다.
단위 테스트가 많은 장점이 있고, 가장 우선시해야 할 테스트 방법인 건 사실이지만 작성이 번거롭다는 점이 문제다. 특히, Mock 객체를 만드는 일이 귀찮은 일이다.
이런 번거로운 Mock 객체를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있고, 그중 가장 유명한 것은 Mockito 프레임워크이다.
Mockito와 같은 목 프레임워크의 특징언 목 클래스를 일일히 준비해둘 필요가 없다는 점이다.
간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 Mock 객체를 만들 수 있다.
// 목 오브젝트 생성, 아무 기능 없음
UserDao mockUserDao = mock(UserDao.class);
// getAll 메서드가 불릴 때, 사용자 목록을 리턴하도록 스텁 기능 추가
when(mockUserDao.getAll().thenReturn(this.users));
// User타입 오브젝트를 파라미터로 받으며, update()메서드가 두 번 호출됐는지 확인
verify(mockUserDao, times(2)).update(any(User.class));
UserDao 인터페이스를 구현한 클래스를 만들 필요도 없고, 리턴 값을 생성자에 넣어둘 필요도 없다. 특정 메서드의 호출이 있었는지 등등.. 에 관한 기능들을 일일히 만들어 줄 필요가 없다.
3. 다이내믹 프록시와 팩토리 빈
3.1. 프록시
위 예시의 UserServiceTx와 UserServiceImpl를 보면 다음과 같은 그림이 된다.

UserServiceTx가 부가 기능을 맡고, UserServiceImpl가 핵심 기능을 처리하게 된다. 핵심 기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서, 부가기능이 핵심기능을 사용하는 구조가 되는 것이다.
문제는 이렇게 구성했더라도 클라이언트가 핵심 기능을 가진 클래스를 직접 사용해버리면 부가기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심 기능을 사용하도록 만들어야 한다. 그러기 위해선 UserService를 인터페이스화 하여, 부가/핵심 기능 전부 같은 메서드를 사용하도록 해야 한다.

그러면 클라이언트는 인터페이스만 보고 사용을 하기 때문에 자신은 핵심기능을 가진 클래스를 사용할 것이라고 기대하게 되지만, 사실은 위 그림처럼 부가기능을 통해 핵심기능을 사용하게 되는 것이다. 물론 클라이언트는 이 내용을 모른다.
이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것 처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시라고 부른다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃/실체 라고 부른다.

3.2. 데코레이터 패턴
타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해 주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하게 기능을 부가한다는 의미는 컴파일 시점, 즉 코드상에는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.
이 패턴에서는 여러 프록시를 활용해 부가적 효과를 줄 수 있다.

자바 IO 패키지의 InputStream과 OutputStream 구현 클래스는 데코레이터 패턴이 사용된 대표적인 예다.
InputStream = new BufferedInputStream(new FileInputStream("a.txt"));
데코레이터 패턴은 인터페이스를 통해 위임하는 방식이기 때문에, 어느 데코레이터에서 타깃으로 연결될지 코드 레벨에선 미리 알 수 없다. 구성하기에 따라서 여러 데코레이터까지 적용할 수도 있다.
3.3. 프록시 패턴
일반적으로 사용하는 프록시와, 디자인 패턴에서의 프록시 패턴은 살짝 다르다.
전자는 클라이언트와 사용 대상 사이에서 대리 역할을 맡은 오브젝트를 두는 방법을 총칭한다면, 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가리킨다.
프록시 패턴의 프록시는 타깃 기능을 추가하거나 확장하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다. 타깃 오브젝트를 생성하기 복잡하거나 당장 필요하지 않은 경우에는 꼭 필요한 시점까지 오브젝트를 생성하지 않는 편이 여러모로 좋다.
그럼 언제 필요하다는거냐? 바로 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있다. 이럴 때 필요하다는 것이다.
실제로 타깃 오브젝트를 생성하는 대신 프록시를 넘겨주는 것이다. 그리고 프록시의 메소드를 통해 타깃을 만들려고 시도한다면, 그때 타깃 오브젝트를 생성한 뒤 위임해주는 것이다.
또는 원격 오브젝트를 이용하는 경우에도 프록시를 사용하면 편리하다. RMI나 EJB 등으로 다른 서버에 존재하는 오브젝트를 사용해야 한다면, 원격 오브젝트에 대한 프록시를 만들어두고, 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼 프록시를 사용하게 할 수 있다.
RMI은 자바에서 다른 호스트의 객체 메서드를 호출하는 기술이고, EJB는 RMI를 기반으로 하는 자바 Enterprise Edition (Java EE) 기술로, 분산 애플리케이션을 개발하기 위한 서버 측 컴포넌트
이 두 패턴을 조합해서 많이 사용한다. 두 패턴 모두 인터페이스를 활용한다.

3.4. 다이내믹 프록시
프록시는 충분히 유용하지만, 많은 개발자는 귀찮아서 그냥 타깃 코드를 수정하려 한다.
이를 자동화해주는 클래스들이 java.lang.reflect 패키지 내에 존재한다. 기본적인 아이디어는 Mock 프레임워크와 비슷하다.
다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만들어진 것이다.
자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class타입의 오브젝트를 하나씩 갖고 있다. 클래스이름.class 혹은 객체.getClass() 메서드를 호출하면 클래스 정보를 담은 Class 타입의 객체를 가져올 수 있다.
Method lengthMethod = String.class.getMethod("length");
위와 같이, String클래스의 length라는 이름을 가지고 있고, 파라미터는 없는 메서드를 가져올수도 있다. 또한 invoke() 를 통해 해당 메서드를 실행하는 기능까지 가지고 있다.
String name = "chabin37";
int length = lengthMethod.invoke(name);
// int length = name.length();

다이내믹 프록시 객체는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면, 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.
다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로써 필요한 부가기능 제공 코드는 직접 제공해줘야 한다.
부가기능은 프로시 오브젝트와 독립적으로 InvocationHandler 를 구현한 오브젝트에 담는다. InvocationHandler 인터페이스는 다음과 같은 메서드 한 개만 가진 간단한 인터페이스이다.
public Object invoke(Object proxy, Method method, Object[] args)
invoke()메서드는 리플렉션의 Method 인터페이스를 파라미터로 받는다. 메서드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다.
정리하자면, 테스트 과정에서 mockUserDao.getAll().thenReturn(this.users)
남은 것은 각 메서드 요청을 어떻게 처리지 결정하는 일이다. 리플렉션으로 메서드와 파라미터 정보를 모두 갖고 있으므로 타깃 오브젝트의 메서드를 호출하게 할 수도 있다.
Hello 인터페이스를 제공하면서 프록시 팩토리에게 다이내믹 프록시를 만들어달라고 요청하면 Hello 인터페이스의 모든 메소드를 구현한 객체를 생성해준다. InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면, 다이내믹 프록시가 받는 모든 요청을 InvocationHandler의 invoke() 메서드로 보내준다. Hello 인터페이스의 메서드가 아무리 많더라도, invoke() 메서드 하나로 처리할 수 있다.

다이내믹 프록시를 만들어보자. 먼저 InvocationHandler 를 만들어야 한다.
interface Hello{
String sayHello(String name);
String sayHi(String name);
String sayThankYou(String name);
}
public class HelloTarget implements Hello {
public String sayHello(String name) {
return "Hello " + name;
}
public String sayHi(String name) {
return "Hi " + name;
}
public String sayThankYou(String name) {
return "Thank You " + name;
}
}
@Test
public void simpleProxy() {
Hello hello = new HelloTarget(); // 타깃은 인터페이스를 통해 접근하는 습관을 들이자.
assertThat(hello.sayHello("Toby"), is("Hello Toby"));
assertThat(hello.sayHi("Toby"), is("Hi Toby"));
assertThat(hello.sayThankYou("Toby"), is("Thank You Toby"));
}
public class UppercaseHandler implements InvocationHandler {
// 다이내믹 프록시로부터 전달받은 요청을 다시 타깃 오브젝트에 위임해야 하기 때문에, 타깃 오브젝트를 주입받아 둔다.
Hello target;
public UppercaseHandler(Hello target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
String ret = (String)method.invoke(target, args);
// 타깃으로 위임. 인터페이스의 메소드 호출에 모두 적용된다.
return ret.toUpperCase(); // 부가기능 제공
}
}
Hello 인터페이스의 메서드들은 반환 타입이 전부 String이므로, method.invoke의 반환 결과를 String으로 고정하였다. ret.toUpperCase라는 부가기능도 추가해놨다.
하지만 String으로 강제하는 것은 문제가 있다. 리턴 타입이 다양해질 수도 있기 때문이다. 이러한 점들을 고려한 확장된 UppsercaseHandler를 구현한다.
public class UppercaseHandler implements InvocationHandler {
Object target;
private UppercaseHandler(Object target) {
this.target = target;
}
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;
}
}
}
InvocationHandler는 단일 메서드에서 모든 요청을 처리하기 때문에, 어떤 메서드에 어떤 기능을 적용할지를 선택하는 과정이 필요할 수도 있다. 호출하는 메서드의 이름, 파라미터의 개수와 타입, 리턴 타입 등의 정보를 가지고 부가적인 기능을 적용할 메서드를 선택할 수 있다.
리턴 타입뿐 아니라 메서드의 이름을 조건으로 걸 수 있다. 메서드 이름이 say로 시작하는 경우에만 대문자로 바꾸는 기능을 적용하고 싶다면, 다음과 같이 적용하면 된다.
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;
}
}
3.5. 다이내믹 프록시를 위한 팩토리 빈
스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성한다. 문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다는 점이다. 사실 다이내믹 프록시 오브젝트의 클래스가 어떤 것인지 알 수도 없다. 따라서 사전에 프록시 오브젝트 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 메서드를 통해서만 만들 수 있다.
팩토리 빈이란, 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 Bean을 말한다. 간단한 방법은 스프링의 FactoryBean라는 인터페이스를 구현하는 것이다.
public interface FactoryBean<T>{
T getObject throws Exception;
Class<? extends T> getObjectType();
boolean isSingleton();
}
FactoryBean을 구현한 클래스를 스프링 빈 으로 등록하면 팩토리 빈으로 동작한다.
아래 예시를 통해, 어떤 방식으로 FactoryBean이 동작하는지 알아보자.
public class Message {
String text;
private Message(String text) {
this.text = text;
}
public String getText() {
return text;
}
public static Message newMessage(String text) {
return new Message(text);
}
}
Message 클래스는 생성자가 Private인, 외부에서 생성이 불가능한 클래스이다. 물론 reflection에는 private에 대한 접근을 가능하게 하는 강력한 기능이 있지만, 베스트 프랙티스냐고 하면 전혀 아니다. 따라서 아래와 같이 FactoryBean을 등록해줘야 한다.
public class MessageFactoryBean implements FactoryBean<Message> {
String text;
public void setText(String text) {
this.text = text;
}
public Message getObject() throws Exception {
return Message.newMessage(this.text);
}
public Class<? extends Message> getObjectType() {
return Message.class;
}
public boolean isSingleton() {
return false;
}
}
다이내믹 프록시를 만들어주는 팩토리 빈
Proxy의 newProxyInstance() 메서드를 통해서만 생성이 가능한 다이나믹 프록시 오브젝트는, 일반적인 방법으로는 스프링의 빈으로 등록할 수 없다. 대신 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 빈으로 만들 수 가 있다. 팩토리 빈의 getObject() 메서드에 다이나믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되기 때문이다.

스프링 빈에는 팩토리 빈과 UserServiceImpl만 빈으로 등록한다. 팩토리 빈은 다이내믹 프록시가 위임할 타깃 오브젝트인 UserServiceImpl에 대한 레퍼런스를 DI 받아둬야만 한다.
트랜잭션 프록시 팩토리 빈
public class TxProxyFactoryBean implements FactoryBean<Object> {
Object target;
PlatformTransactionManager transactionManager;
String pattern;
Class<?> serviceInterface;
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;
}
// FactoryBean 인터페이스 구현 메소드
public Object getObject() throws Exception {
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;
}
public boolean isSingleton() {
return false;
}
}
3.6. 프록시 팩토리 빈 방식의 장단점
데코레이터 패턴의 단점(프록시 클래스 일일히 만들어야 함, 부가적인 기능이 여러 메소드에 반복적으로 나타나게 됨)을 해결해준다. 프록시에 팩토리 빈을 이용한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다.
그러나 프록시를 통해 타깃에 부가기능을 제공하는 것은 메서드 단위로 일어나는 일이다. 현재까지 하나의 클래스 안에 존재하는 여러 개의 메서드에 부가기능을 한 번에 제공하는 것은 어렵지 않게 가능했다. 하지만 한 번에 여러 클래스에 공통적인 부가기능을 제공하는 것은 불가능하다. 이를 위해서 같은 설정(코드)를 계속 반복해야 한다.
4. AOP란 무엇인가?
트랜잭션 경계설정과 같은 부가적인 부분들은 전통적인 객체지향 기술의 설계 방법으로는 독립적인 모듈화가 불가능하다. 이러한 부가적인 부분들을 어떻게 모듈화할 것인가를 연구해온 사람들은, 이 부가기능 모듈화 작업은 기존의 객체지향 설계 패러다임과는 구분되는 새로운 특성이 있다고 생각했다. 그래서 이런 부가기능 모듈을 객체지향 기술에서 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르기 시작했다. 그것이 바로 Aspect이다. Aspect란 그 자체로 어플리케이션 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.
Aspect는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다.
Aspect는 그 단어 의미 그대로 애플리케이션을 구성하는 한 가지 측면 이라고 생각할 수 있다.
트랜잭션 경계설정 코드를 처음 사용자 관리 서비스 클래스에 추가했을 때를 생각해보자. 핵심기능을 담은 코드는 부가적인 트랜잭션 코드와 함께 섞여 있어서 핵심기능인 사용자 관리 로직을 파악하고, 수정하고, 테스트하기 매우 불편했다.

오른쪽 그림은 핵심기능 코드 사이에 침투한 부가기능을 독립적인 모듈인 Aspect로 구분해낸 것이다. 2차원적인 평면 구조에서는 어떤 설계 기법을 동원해도 해결할 수 없었던 것을, 3차원의 다면체 구조로 가져가면서 각각 성격이 다른 부가기능은 다른 면에 존재하도록 만들었다.
이렇게 독립된 측면에서 존재하는 Aspect로 분리한 덕에 핵심기능은 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 구분된 면에 존재하게 된 것이다.
애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 Aspect Oriented Programming - AOP 라고 부른다. AOP는 OOP를 돕는 보조적인 기술이지 OOP를 대체하는 기술은 아니다.
요즘은 이런 AOP용
@Before,@After같은 저수준 어노테이션을 활용하기보단,@Transactional,@Cacheable,@Async,@Retryable같은 어노테이션들을 사용한다.
5. 트랜잭션 속성
저~기 위에서 썼던, 트랜잭션 경계설정 코드에서 사용했던 DefaultTransactionDefinition에 대해 이야기해보자. 그때 코드를 얼핏 떠올려보면, 오류가 없다면 commit(), 오류를 catch하면 rollback() 진행한다.
5.1. 트랜잭션 전파
트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다.

그림과 같이 각각 독립적인 트랜잭션 경계를 가진 두 개의 코드가 있다고 하자. 그런데 A의 트랜잭션이 시작되고 아직 끝나지 않은 시점에서 B를 호출했다면 B의 코드는 어떤 트랜잭션 안에서 동작해야 할까?
- A에서 시작된 트랜잭션에 B가 참여하는 경우
- 예외가 발생하면 A와 B의 모든 DB 작업이 취소된다
- B의 트랜잭션을 독립적인 트랜잭션으로 만드는 경우
- B와 A는 완전히 독립적으로 결과가 나타남
- 5.2. 트랜잭션 전파 속성
PROPAGATION_REQUIRED
- 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 참여한다
- 가장 많이 사용되는 트랜잭션 전파 속성이다
PROPAGATION_REQUIRES_NEW
- 항상 새로운 트랜잭션을 시작한다.
PROPAGATION_NOT_SUPPORTED
- 트랜잭션 없이 동작한다!
트랜잭션 경계설정은 보통 AOP를 이용해 한번에 많은 메서드에 동시에 적용하는 방식을 사용하는데, 특별한 메서드만 제외하고 싶을 수 있다. 이때 특별한 메서드의 트랜잭션 전파 속성만 이 값으로 바꿔 사용한다.
5.3. 격리 수준
모든 DB 트랜잭션은 격리 수준을 갖고 있어야 한다. 서버 환경에서는 여러 개의 트랜잭션이 동시에 진행될 수 있다. 가능하다면 모든 트랜잭션이 순차적으로 진행돼서 다른 트랜잭션의 작업에 독립적인 것이 좋겠지만, 그러자면 성능이 크게 떨어질 수 밖에 없다. 따라서 적절하게 격리수준을 조정해서 가능한 한 많은 트랜잭션을 동시에 진행시키면서도 문제가 발생하지 않게 하는 제어가 필요하다.
격리수준은 기본적으로 DB에 설정되어 있지만 JDBC드라이버나 DataSoure 등에서 재설정 할 수 있고, 필요 하다면 트랜잭션 단위로 격리수준을 조정할 수 있다.
제한시간
트랜잭션을 수행하는 제한시간을 설정할 수 있다.
읽기전용
트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 엑세스 기술에 따라서 성능이 향상될 수도 있다.
트랜잭션 정의를 수정하면 모든 트랜잭션 속성이 한번에 바뀐다는 문제가 있다. 원하는 메서드만 선택해서 독자적인 트랜잭션 정의를 적용할 수 있는 방법은 없을까?
5.4. TransactionInterceptor
메서드 이름 패턴에 따라 다른 트랜잭션 정의가 적용되도록 할 수 있는 기능을 제공한다. 기본적으로 두 가지 예외(언체크/체크)에 대한 처리 방식이 존재한다. 1. 런타임 예외가 발생하면 트랜잭션은 롤백된다. 2. 런타임 예외가 아닌, 체크 예외를 발생시키는 경우 이것을 예외상황으로 해석하지 않는다. 스프링의 기본적인 예외처리 원칙에 따라 비즈니스적인 의미가 있는 상황에서만 체크 예외를 사용하고, 그 외의 모든 복구 불가능한 순수 예외의 경우 런타임 예외로 포장해서 전달하는 방식을 따른다고 가정하기 때문이다.
하지만 이러한 예외처리 기본 원칙을 따르지 않는 경우가 있을 수 있따. 그래서 TransactionAttribute는 rollbackOn() 이라는 속성을 둬서 기본원칙과 다른 예외처리가 가능하게 해준다. 이를 활용하면 특정 체크 예외의 경우에는 트랜잭션을 롤백시키고, 특정 런타임 예외에 대해서는 트랜잭션을 커밋시킬 수도 있다.
'Backend > Spring' 카테고리의 다른 글
| 8. 스프링이란 무엇인가? (0) | 2025.11.02 |
|---|---|
| 11. 데이터 액세스 기술 (0) | 2025.10.02 |
| Spring에서의 테스트 (0) | 2025.09.16 |
| 자바와 스프링의 예외처리 (1) | 2025.09.10 |
| [Security]Spring 소셜로그인 및 JWT 토큰 (0) | 2025.04.27 |