[토비의 스프링 3.1 Vol 1] 6장. AOP (~6.3)
6장 AOP
스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다.
6.1 트랜잭션 코드의 분리
6.1.1 메소드 분리
- 비즈니스 로직 코드 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다.
- 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보 없이 독립적인 코드다.
- 비즈니스 로직을 담당하는 코드를 메소드로 추출해서 독립시킨다.
6.1.2 DI를 이용한 클래스의 분리
- 여전히 트랜잭션을 담당하는 기술적인 코드가 UserService 안에 있다.
- 간단하게 트랜잭션 코드를 클래스 밖으로 뽑아내면 된다.
- DI의 기본 아이디어를 이용하면 된다.
- 실제 사용할 오브젝트의 클래스 정체는 감춘 채 인터페이스 통해 간접으로 접근하는 것이다.
- 그 덕분에 구현 클래스는 얼마든지 외부에서 변경할 수 있다.
Vol1-30/Ch6/6.2.4/src/springbook/user/service
package springbook.user.service;
import springbook.user.domain.User;
public interface UserService {
void add(User user);
void upgradeLevels();
}
Vol1-30/Ch6/6.2.4/src/springbook/user/service/UserServiceImpl.java
package springbook.user.service;
import java.util.List;
import org.springframework.mail.MailSender;
import org.springframework.mail.SimpleMailMessage;
import springbook.user.dao.UserDao;
import springbook.user.domain.Level;
import springbook.user.domain.User;
public class UserServiceImpl implements UserService {
public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECCOMEND_FOR_GOLD = 30;
private UserDao userDao;
private MailSender mailSender;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public void setMailSender(MailSender mailSender) {
this.mailSender = mailSender;
}
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
private boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
switch(currentLevel) {
case BASIC: return (user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER);
case SILVER: return (user.getRecommend() >= MIN_RECCOMEND_FOR_GOLD);
case GOLD: return false;
default: throw new IllegalArgumentException("Unknown Level: " + currentLevel);
}
}
protected void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
sendUpgradeEMail(user);
}
private void sendUpgradeEMail(User user) {
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(user.getEmail());
mailMessage.setFrom("useradmin@ksug.org");
mailMessage.setSubject("Upgrade ¾È³»");
mailMessage.setText("»ç¿ëÀÚ´ÔÀÇ µî±ÞÀÌ " + user.getLevel().name());
this.mailSender.send(mailMessage);
}
public void add(User user) {
if (user.getLevel() == null) user.setLevel(Level.BASIC);
userDao.add(user);
}
}
Vol1-30/Ch6/6.2.4/src/springbook/user/service/UserServiceTx.java
package springbook.user.service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import springbook.user.domain.User;
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) {
this.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;
}
}
}
클라이언트 코드 ▶
@Test
public void upgradeAllOrNothing() {
TestUserService testUserService = new TestUserService(users.get(3).getId());
testUserService.setUserDao(userDao);
testUserService.setMailSender(mailSender);
UserServiceTx txUserService = new UserServiceTx();
txUserService.setTransactionManager(transactionManager);
txUserService.setUserService(testUserService);
userDao.deleteAll();
for(User user : users) userDao.add(user);
try {
txUserService.upgradeLevels();
fail("TestUserServiceException expected");
}
catch(TestUserServiceException e) {
}
checkLevelUpgraded(users.get(1), false);
}
트랜잭션 경계설정 코드 분리의 장점
- 비즈니스 로직을 담당하고 있는 UserServieImpl의 코드 작성할때는 트랜잭션과 같은 기술적인 내용은 신경 안써도 된다.
- 비즈니스 로직에 대한 테스트를 손쉽게 만들 수 있다.
6.2 고립된 단위 테스트
6.2.1 복잡한 의존관계 속의 테스트
- 기존 UserSercie의 구현 클래스들이 동작하려면 세 가지 타입의 의존 오브젝트가 필요하다.
- UserDao타입의 오브젝트 통해 DB와 데이터 주고받아야 하고
- MailSender 구현한 오브젝트 이용해 메일 발송해야 하고
- 마지막으로 트랜잭션 처리 위해 PlatformTransactionManager와 커뮤니케이션이 필요하다.
- 따라서 UserService를 테스트하는 것처럼 보이지만 사실은 그 뒤에 존재하는 훨씬 더 많은 오브젝트와 환경, 서비스, 서버, 심지어 네트워크까지 함께 테스트하는 셈이다.
- 이런 경우 테스트 준비하기 힘들고, 환경이 조금이라도 달라지면 동일한 테스트 결과를 내지 못하고, 수행 속도가 느리며 그에 따라 테스트 작성하고 실행하는 빈도가 점차 떨어진다.
6.2.2 테스트 대상 오브젝트 고립시키기
그래서 테스트 대상의 환경이나 외부 서버, 다른 클래스의 코드에 종속되고 영향 받지 않도록 고립시킬 필요가 있다.
테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 MailSender에 적용해봤던 대로 테스트 위한 대역을 사용하는 것
MockUserDao.java
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);
}
public void add(User user) { throw new UnsupportedOperationException(); }
public void deleteAll() { throw new UnsupportedOperationException(); }
public void get(String id) { throw new UnsupportedOperationException(); }
public void int getCount() { throw new UnsupportedOperationException(); }
}
MockUserDao를 사용해서 만든 고립된 테스트
@Test
public void upgradeLevels() throws Exception {
UserServiceImpl userServiceImpl = new UserServiceImpl();
MockUserDao mockUserDao = new MockUserDao(this.users);
userServiceImpl.setUserDao(mockUserDao);
MockMailSender mockMailSender = new MockMailSender();
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
List<User> updated = mockUserDao.getUpdated();
assertThat(updated.size().is(2));
checkUserAndLevel(updated.get(0), "joytouch", Level.SILVER);
checkUserAndLevel(updated.get(0), "madnite1", Level.GOLD);
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 checkUserAndLevel(User updated, String expectedId, Level expectedLevel) {
assertThat(updated.getId(), is(expectedId));
assertThat(updated.getLevel(), is(expectedLevel));
}
6.2.3 단위 테스트와 통합 테스트
- 단위 테스트 단위는 정하기 나름이다.
- 사용자 관리 기능 전체를 하나의 단위로 볼 수도 있고 하나의 클래스나 하나의 메소드 단위로 볼 수도 있다.
- 반면 두 개 이상의, 성격이나 계층이 다른 오브젝트가 연동하도록 마늗ㄹ어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트는 통합 테스트라고 한다.
- 통합 테스트란 두 개 이상의 단위가 결합해서 동작하면서 테스트가 수행되는 것이라고 보면 된다.
단위 테스트와 통합 테스트 가이드라인
- 항상 단위 테스트를 먼저 고려한다.
- 하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개 모아서 외부와의 외존관계 모두 차단하고 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역 이용하도록 테스트 만든다.
- 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향 받지도 않기 때문에 가장 빠른 시간에 효과적인 테스트 작성하기에 유리하다.
- 외부 리소스 사용해야만 가능한 테스트는 통합 테스트로 만든다.
- 단위 테스트로 만들기 어려운 코드도 있다. ex, DAO. DAO는 DB까지 연동하는 테스트로 만드는 편이 효괒거이다. DB를 사용하는 테스트는 DB에 테스트 데이터 준비하고, DB에 직접 확인을 하는 등의 부가적인 작업이 필요하다.
- DAO 테스트는 DB라는 외부 리소스 사용하기 때문에 통합 테스트로 분류된다. 하지만 코드에서 보자면 하나의 기능 단위를 테스트하는 것이기도 하다. DAO를 테스트를 통해 충분히 검증해두면, DAO를 이용하는 코드는 DAO역할 하는 스텁이나 목 오브젝트로 대체해서 테스트할 수 있다.
- 여러 개의 단위가 위존관계를 가지고 동작할 때 위한 통합 테스트는 필요하다. 다만, 단위 테스트 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
- 단위 테스트 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다.
- 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트다. 가능하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하면서 단위 테스트하는게 좋겠지만 스프링의 설정 자체도 테스트 대상이고 스프링을 이용해 좀 더 추상적인 레벨에서 테스트해야 할 경우도 종종 있다. 이럴 땐 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.
6.2.4 목 프레임워크
Mocktio 프레임워크
@Test
public void mockUpgradeLevels() throws Exception {
UserServiceImpl userServiceImpl = new UserServiceImpl();
UserDao mockUserDao = mock(UserDao.class);
when(mockUserDao.getAll()).thenReturn(this.users);
userServiceImpl.setUserDao(mockUserDao);
MailSender mockMailSender = mock(MailSender.class);
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
verify(mockUserDao, times(2)).update(any(User.class));
verify(mockUserDao, times(2)).update(any(User.class));
verify(mockUserDao).update(users.get(1));
assertThat(users.get(1).getLevel(), is(Level.SILVER));
verify(mockUserDao).update(users.get(3));
assertThat(users.get(3).getLevel(), is(Level.GOLD));
ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mockMailSender, times(2)).send(mailMessageArg.capture());
List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();
assertThat(mailMessages.get(0).getTo()[0], is(users.get(1).getEmail())));
assertThat(mailMessages.get(0).getTo()[0], is(users.get(3).getEmail())));
}
6.3 다이내믹 프록시와 팩토리 빈
6.3.1 프록시와 프록시 패턴, 데코레이터 패턴
- 부가기능은 마치 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다.
- 그러기 위해서는 클라이어트가 자신을 거쳐서 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.
- 이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시라고 부른다.
- 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃 또는 실체라고 부른다.
데코레이터 패턴
- 데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴
- 다이내믹하게 기능을 부가한다는 의미는 컴파일 시점, 즉 코드 상에는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.
- 자바 IO 패키지의 InputStream과 OutputStream 구현 클래스는 데코레이터 패턴이 사용된 대표적인 예이다.
- UserService 인터페이스를 구현한 타깃인 UserServiceImpl에 트랜잭션 부가기능을 제공해주는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이라고 볼 수 있다.
- 데코레이터 패턴은 인터페이스 통해 위임하는 방식이기 때문에 어느 데코레이터에서 타깃으로 연결될지 코드 레벨에서는 알 수 없다.
- 데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다.
프록시 패턴
- 일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 있다.
- 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 총칭한다면
- 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.
- 구조적으로 보자면 프록시와 데코레이터는 유사하지만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다.
<참고> 프로시 vs 데코레이터
1. 공통점: 둘 다 ‘대리’ 역할을 하는 객체
둘 다 원래 객체를 감싸서, 그 객체처럼 동작하면서도 부가적인 기능을 추가할 수 있는 구조
2. 프록시 패턴 (Proxy Pattern)
핵심 목적: 접근 제어 또는 대체
• 어떤 객체에 접근을 제어하거나, 원래 객체 대신해서 동일하게 동작하게 하면서 추가 로직을 추가
• 예를 들면, 보안 체크, 캐싱, 트랜잭션 관리 등.
3. 데코레이터 (Decorator)
프록시와 매우 비슷하지만, 프록시는 주로 접근 제어나 기능 캡슐화를 위해 사용되고, 데코레이터는 기능 확장에 초점
• 프록시처럼 대상 객체를 감싸고 메서드를 오버라이딩해서 기능을 추가하지만, 의도가 ‘부가기능 확장’에 더 가까움
6.3.2 다이내믹 프록시
프록시의 구성과 프록시 작성의 문제점
- 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임
- 지정된 요청에 대해서는 부가기능을 수행
프록시를 만들기가 번거로운 이유는 무엇일까?
- 첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다.
- 두번째는 부가기능 코드가 중복될 가능성이 많다는 점이다.
리플렉션
- 첫 번째 문제인 인터페이스 메소드의 구현과 위임 기능 문제는 JDK의 다이내믹 프록시로 해결 가능하다.
- 다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다.
- 리플렉션 API 중에서 메소드에 대한 정의를 담은 Method라는 인터페이스 이용해 메소드를 호출하면 된다.
- String의 length() 메소드에 대해 아래와 같이 호출 가능하다
Method lengthMethod = String.class.getMethod(“length”);
프록시 클래스
- 여기에 다이내믹 프록시를 적용한다.
- invoke() 메소드는 리플렉션의 Method 인터페이스를 파라미터로 받는다. 메소드 호출할 때 전달되는 파라미터도 args로 받는다.
6.3.3 다이내믹 프록시 이용한 트랜잭션 부가기능
- UserServiceTx는 서비스 인터페이스의 메소드를 모두 구현해야 하고 트랜잭션이 필요한 메소드마다 트랜잭션 처리코드가 중복돼서 나타나는 비효율적인 방법으로 만들어져 있다.
- 트랜잭션이 필요한 클래스와 메소드가 증가하면 UserserviceTx 처럼 프록시 클래스를 일일이 구현하는 것은 큰 부담이다.
트랜잭션 InnvocationHandler
- 요청을 위임할 타깃을 DI로 제공받는다.
- 타깃을 저장할 변수는 Object로 선언했다.
- 한가지 달라진 점은 롤백을 적용하기 위한 예외는 RuntimeException 대신 InnvocationTargetException을 잡도록 해야 한다는 점이다.
TransactionHandler와 다이내믹 프록시 이용하는 테스트
6.3.4 다이내믹 프록시 위한 팩토리 빈
- 문제는 다이내믹 프록시 오브젝트 TransactionHandler는 스프링의 빈으로 등록할 방법이 없다.
- 스프링의 빈은 기본적으로 클래스 이름과 프로퍼티로 정의되는데, 문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다는 것이다.
<참고> chatgpt 버전으로 쉽게 풀어서 설명하면?
✅ 1. 일반적인 스프링 빈 등록 방식
스프링 빈은 보통 다음 중 하나로 등록합니다:
• XML에서 <bean class="..." />로 클래스 이름과 생성자/프로퍼티 지정
• Java Config에서 @Bean을 붙인 메서드로 등록
• @Component 등으로 클래스 자체를 빈으로 스캔
이 방식들의 공통점은 “클래스가 명확히 존재해야 한다”는 점입니다.
✅ 2. 그런데 다이내믹 프록시는?
Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[]{SomeInterface.class},
new TransactionHandler(...)
)
• 이 객체는 실제 구현 클래스가 없는 ‘익명 프록시 객체’입니다.
• 즉, 클래스 이름이 없고, 그때그때 코드로 만들어지는 객체죠.
• XML이나 @Component, @Bean으로 정의하려 해도 클래스가 명확하지 않아서 등록할 수 없음.
6.3.5 프록시 팩토리 빈 방식의 장점과 한계
프록시 팩토리 빈의 재사용
- TransactionHandler 이용하는 다이내믹 프록시 생성해주는 TxProxyFactoryBean은 코드 수정 없이도 다양한 클래스에 적용할 수 있다.
- 타깃 오브젝트에 맞는 프로퍼티 정보를 설정해서 빈으로 등록해주기만 하면 된다.
프록시 팩토리 빈 방식의 장점
- 다이내믹 프록시 이용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 안 할 수 있다.
- 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다.
프록시 팩토리 빈의 한계
- 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 불가능하다.
- 하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제가 생긴다.
- TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어진다.
토비 스프링 소스코드 ▶ https://github.com/AcornPublishing/toby-spring3-1/tree/main
토비 스프링 ▶ https://product.kyobobook.co.kr/detail/S000000935358