AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술중의 하나다. 스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔끔한 방식으로 바꿔보자.
1. 메소드 분리
스프링이 제공하는 깔끔한 트랜잭션 인터페이스를 썼지만 비즈니스 로직이 주인이어야 할 메소드 안에 이름도 길고 무시무시하게 생긴 트랜잭션 코드가 더 많은 자리를 차지하고 있는 모습이 못마땅하다. 하지만 논리적으로 따져봐도 트랜잭션의 경계는 분명 비즈니스 로직의 전후에 설정돼야 하는 것이 분명하니 UserService의 메소드에 두는 것은 거부할 명분이 없다. 그렇다고 여기서 깔끔한 코드에 대한 열정을 포기할 이유는 없다.
먼저 트랜잭션이 적용된 코드를 다시 한번 보자.
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;
}
}
얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는 것 같지만, 자세히 보면 뚜렷하게 두 가지 종류의 코드가 구분되어 있음을 알 수 있다. 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다.
또, 이 코드의 특징은 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고 받는 정보가 없다는 점이다. 비즈니스 로직 코드에서 직접 DB를 사용하지 않기 때문에 트랜잭션 준비 과정에서 만들어진 DB 커넥션 정보 등을 직접 참조할 필요가 없기 때문이다. 이 메소드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 활용한다.
따라서 이 두 가지 코드는 성격이 다를 뿐 아니라 서로 주고받는 것도 없는, 완벽하게 독립적인 코드다. 다만 이 비즈니스 로직을 담당하는 코드가 트랜잭션의 시작과 종료 작업 사이에서 수행돼야 한다는 사항만 지켜지면 된다. 그렇다면 이 성격이 다른 코드를 두 개의 메소드로 분리할 수 있지 않을까? 아래와 같이 비즈니스 로직을 담당하는 코드를 메소드로 추출해서 독립시켜보자.
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);
}
}
}
코드를 분리하고 나니 보기가 한결 깔끔해졌다. 적어도 순수하게 사용자 레벨 업그레이드를 담당하는 비즈니스 로직 코드만 독립적인 메소드에 담겨 있으니 이해하기도 편하고, 수정하기에도 부담이 없다. 실수로 트랜잭션 코드를 건드릴 염려도 없어졌다.
2. DI 적용을 이용한 트랜잭션 분리
비즈니스 로직을 담당하는 코드는 깔끔하게 분리돼서 보기 좋긴 하다. 그렇지만 여전히 트랜잭션을 담당하는 기술적인 코드가 버젓이 UserService 안에 자리 잡고 있다. 아예 트랜잭션 코드가 존재하지 않는 것처럼 사라지게 할 수는 없을까?
지금 UserService는 UserServiceTest가 클라이언트가 되어서 사용하고 있다. 실전에서는 아마 다른 클래스나 모듈에서 이 UserService를 호출해 사용할 것이다. 그런데 UserService는 현재 클래스로 되어 있으니 다른 코드에서 사용한다면 UserService 클래스를 직접 참조하게 된다. 그렇다면 트랜잭션 코드를 어떻게든 해서 UserService 클래스 밖으로 빼버리면 UserService 클래스를 직접 사용하는 클라이언트 코드에서는 트랜잭션 기능이 빠진 UserService를 사용하게 될 것이다. 구체적인 구현 클래스를 직접 참조하는 경우의 전형적인 단점이다.
직접 사용하는 것이 문제가 된다면 간접적으로 사용하면 된다. DI의 기본 아이디어는 실제 사용할 오브젝트의 클래스 정체는 감춘 채 인터페이스를 통해 간접으로 접근하는 것이다. 그 덕분에 구현 클래스는 얼마든지 외부에서 변경할 수 있다. 바로 이런 개념을 가진 DI가 지금 필요하다.
현재 구조는 아래와 같이 UserService 클래스와 그 사용 클라이언트 간의 관계가 강한 결합도로 고정되어 있다. 이 사이를 비집고 다른 무엇인가를 추가하기는 어렵다.
그래서 다음과 같이 UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들어넣도록 한다. 그러면 클라이언트와 결합이 약해지고, 직접 구현 클래스에 의존하고 있지 않기 때문에 유연한 확장이 가능해진다. 아래 그림은 인터페이스를 이용해 클라이언트와 UserService 구현 클래스의 직접 결합을 막아주고, 유연한 확장이 가능하게 만든 것이다. 이미 여러 차례 반복했던 익숙한 작업이다.
그런데 보통 이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이유는, 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서다. 테스트 때는 필요에 따라 테스트 구현 클래스를, 정식 운영 중에는 정규 구현 클래스를 DI 해주는 방법처럼 한 번에 한 가지 클래스를 선택해서 적용하도록 되어 있다.
하지만 꼭 그래야 한다는 제약은 없다. 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 이용한다면 어떨까? 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것이다. 하지만 클라이언트가 UserService의 기능을 제대로 이용하려면 트랜잭션이 적용돼야 한다. 그래서 아래와 같은 구조를 생각해볼 수 있다.
이 클래스는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션 경계설정이라는 책임을 맡고 있을 뿐이다. 그리고 스스로는 비즈니스 로직을 담고 있지 않기 때문에 또 다른 비즈니스 로직을 담고 있는 UserService의 구현 클래스에 실제적인 로직 처리 작업은 위임하는 것이다. 그 위임을 위한 호출 작업 이전과 이후에 적절한 트랜잭션 경계를 설정해주면, 클라이언트 입장에서 볼 때는 결국 트랜잭션이 적용된 비즈니스 로직의 구현이라는 기대하는 동작이 일어날 것이다.
이제 이 방법을 이용해 트랜잭션 경계설정 코드를 분리해낸 결과를 살펴보자.
3. UserService 인터페이스 도입
먼저 기존의 UserService 클래스를 UserServiceImpl로 이름을 변경한다. 그리고 클라이언트가 사용할 로직을 담은 핵심 메소드만 UserService 인터페이스로 만든 후 UserServiceImpl이 구현하도록 만든다.
클라이언트에 노출할 메소드를 담은 인터페이스는 아래와 같다. 현재 구현한 사용자 관리 로직의 메소드는 add()와 upgradeLevels() 두 개 뿐이다.
public interface UserService {
void add(User user);
void upgradeLevels();
}
UserService 인터페이스의 구현 클래스인 UserServiceImpl은 기존 UserService 클래스의 내용을 대부분 그대로 유지하면 된다. 단, 트랜잭션과 관련된 코드는 독립시키기로 했으니 모두 제거해도 좋다. 따라서 PlatformTransactionManager 인스턴스 변수와 수정자 메소드, 또 upgradeLevels()에 남겨뒀던 트랜잭션 관련 코드도 모두 제거한다. 이렇게 정리한 UserServiceImpl 코드는 아래와 같다.
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);
}
}
}
...
}
이렇게 수정하고 보니 메일 발송 기능을 추가했던 것을 제외하면 트랜잭션을 고려하지 않고 단순하게 로직만을 구현했던 처음 모습으로 돌아왔다.
코드 어디에도 기술이나 서버환경, 심지어는 스프링에 관련된 코드도 보이지 않는다. 이 자체로만 보면 UserDao 라는 인터페이스를 이용하고, User라는 도메인 정보를 가진 비즈니스 로직에만 충실한 깔끔한 코드다.
4. 분리된 트랜잭션 기능
이제 비즈니스 트랜잭션 처리를 담은 UserServiceTx를 만들어보자. UserServiceTx는 기본적으로 UserService를 구현하게 만든다. 그리고 같은 인터페이스를 구현한 다른 오브젝트에게 고스란히 작업을 위임하게 만들면 된다. 적어도 비즈니스 로직에 대해서는 UserServiceTx는 아무런 관여도 하지 않는다.
public class UserServiceTx implements UserService {
UserService userService;
// UserService를 구현한 다른 오브젝트를 DI 받는다.
public void setUserService(UserService userService) {
this.userService = userService;
}
// DI 받은 UserService 오브젝트에 모든 기능을 위임한다.
public void add(User user) {
userService.add(user);
}
public void upgradeLevels() {
userService.upgradeLevels();
}
}
UserServiceTx는 UserService 인터페이스를 구현했으니, 클라이언트에 대해 UserService 타입 오브젝트의 하나로서 행세할 수 있다. UserServiceTx는 사용자 관리라는 비즈니스 로직을 전혀 갖지 않고 고스란히 다른 UserService 구현 오브젝트에 기능을 위임한다. 이를 위해 UserService 오브젝트를 DI 받을 수 있도록 만든다.
이렇게 준비된 UserServiceTx에 트랜잭션의 경계설정이라는 부가적인 작업을 부여해보자. UserService에 스프링의 트랜잭션 경계설정 API를 도입한 것과 동일하다. 그 구체적인 기술은 알지 못하지만 transactionManager 라는 이름의 빈으로 등록된 트랜잭션 매니저를 DI로 받아뒀다가 트랜잭션 안에서 동작하도록 만들어줘야 하는 메소드 호출의 전과 후에 필요한 트랜잭션 경계설정 API를 사용해주면 된다.
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;
}
}
}
upgradeLevels()는 UserService에서 트랜잭션 처리 메소드와 비즈니스 로직 메소드를 분리했을 때 트랜잭션을 담당한 메소드와 거의 한 메소드가 됐다. 추상화된 트랜잭션 구현 오브젝트를 DI 받을 수 있도록 PlatformTransactionManager 타입의 프로퍼티도 추가됐다.
5. 트랜잭션 적용을 위한 DI 설정
이제 남은 것은 설정파일을 수정하는 부분이다. 클라이언트가 UserService라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때 먼저 트랜잭션을 담당하는 오브젝트가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다. 스프링의 DI 설정에 의해 결국 만들어질 빈 오브젝트와 그 의존관계는 아래와 같이 구성돼야 한다.
아래는 수정한 스프링 설정파일 내용이다.
<bean id="userService" class="springbook.user.service.UserServiceTx">
<property name="transactionManager" ref="transactionManager" />
<property name="userService" ref="userServiceImpl" />
</bean>
<bean id="userServiceImpl" class="springbook.user.service.UserServiceImpl">
<property name="userDao" ref="userDao" />
<property name="mailSender" ref="mailSender" />
</bean>
이제 클라이언트는 UserServiceTx 빈을 호출해서 사용하도록 만들어야 한다. 따라서 userService라는 대표적인 빈 아이디는 UserServiceTx 클래스로 정의된 빈에게 부여해준다. userService 빈은 UserServiceImpl 클래스로 정의되는, 아이디가 userServiceImpl인 빈을 DI하게 만든다.
6. 트랜잭션 분리에 따른 테스트 수정
기본적인 분리 작업이 끝났으니 이제 테스트를 돌려봐야 하는데, 그 전에 테스트 코드에 손볼 곳이 제법 있다. 먼저 스프링의 테스트용 컨텍스트에서 가져올 빈들을 생각해보자. 기존에는 UserService 클래스 타입의 빈을 @Autowired로 가져다가 사용했다. UserService는 이제 인터페이스로 바뀌었다. 인터페이스라고 하더라도 @Autowired로 가져오는 데는 아무런 문제가 없다. 하지만 @Autowired는 기본적으로 타입이 일치하는 빈을 찾아주기 때문에 다른 문제가 발생한다.
수정한 스프링의 설정파일에는 UserService라는 인터페이스 타입을 가진 두 개의 빈이 존재하기 때문이다. 같은 타입의 빈이 두 개라면 @Autowired를 적용한 경우 어떤 빈을 가져올까? @Autowired는 기본적으로 타입을 이용해 빈을 찾지만 만약 타입으로 하나의 빈을 결정할 수 없는 경우에는 필드 이름을 이용해 빈을 찾는다. 따라서 UserServiceTest에서 다음과 같은 userService 변수를 설정해두면 아이디가 userService인 빈이 주입될 것이다.
@Autowired UserService userService
UserService는 인터페이스로 변경했으므로 테스트 코드는 이제 구체적인 클래스 정보는 알지 못한 채로 컨테이너가 제공해주는 대표적인 UserService 구현 오브젝트를 사용하게 된다. 그런데 UserServiceTest는 하나의 빈을 더 가져와야 한다. 바로 UserServiceImpl 클래스로 정의된 빈이다. 다음과 같이 UserServiceImpl 클래스 타입의 변수를 선언하고 @Autowired를 지정해서 해당 클래스로 만들어진 빈을 주입받도록 한다. 그래야만 MockMailSender를 설정해주기 위한 수정자 메소드에 접근할 수 있기 때문이다.
@Autowired UserServiceImpl userServiceImpl
다음은 upgradeLevels() 테스트 메소드다. 한 가지만 수정해주면 된다. MailSender의 목 오브젝트를 설정해주는 건 이제 UserService 인터페이스를 통해선 불가능하기 때문에 아래처럼 별도로 가져온 userServiceImpl 빈에 해줘야 한다.
@Test
public void upgradeLevels() throws Exception {
...
MockMailSender mockMailSender = new MockMailSender();
userServiceImpl.setMailSender(mockMailSender);
add() 테스트 메소드는 손댈 것이 없다. 변경하기 전과 동일하게 동작함이 보장되므로 테스트는 그대로 둬도 좋다.
upgradeAllOrNothing() 테스트는 수정할 부분이 있다. 이 테스트는 사실 사용자 관리 로직을 테스트하려는 목적이 아니라, 트랜잭션 기술이 바르게 적용됐는지를 확인하기 위해 만든 일종의 학습 테스트다. 그래서 직접 테스트용 확장 클래스도 만들고 수동 DI도 적용하고 한 만큼, 바뀐 구조를 모두 반영해주는 작업이 필요하다.
아래와 같이 TestUserService 오브젝트를 UserServiceTx 오브젝트에 수동 DI시킨 후에 트랜잭션 기능까지 포함된 UserServiceTx의 메소드를 호출하면서 테스트를 수행하도록 해야 한다.
@Test
public void upgradeAllOrNothing() throws Exception {
TestUserService testUserService = new TestUserService(users.get(3).getId());
testUserService.setUserDao(userDao);
testUserService.setMailSender(mailSender);
// 트랜잭션 기능을 분리한 UserServiceTx는 예외발생용으로 수정할 필요가 없으니 그대로 사용한다.
UserServiceTx txUserService = new UserServiceTx();
txUserService.setTransactionManager(transactionManager);
txUserService.setUserService(testUserService);
userDao.deleteAll();
for(User ser : users) userDao.add(user);
try {
// 트랜잭션 기능을 분리한 오브젝트를 통해 예외 발생용 TestUserService가 호출되게 해야 한다.
txUserService.upgradeLevels();
fail("TestUserServiceException expected");
}
...
트랜잭션 테스트용으로 특별히 정의한 TestUserService 클래스는 이제 UserServiceImpl 클래스를 상속하도록 바꿔주면 된다.
static class TestUserService extends UserServiceImpl
7. 트랜잭션 경계설정 코드 분리의 장점
트랜잭션 경계설정 코드의 분리와 DI를 통한 연결은 지금까지 해왔던 작업 중에서 가장 복잡하고, 큰 개선 작업이었다. 이를 통해 얻은 장점에는 무엇이 있을까?
첫째, 이제 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경 쓰지 않아도 된다. 트랜잭션은 DI를 이용해 UserServiceTx와 같은 트랜잭션 기능을 가진 오브젝트가 먼저 실행되도록 만들기만 하면 된다. 따라서 언제든지 트랜잭션을 도입할 수 있다.
둘째, 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다는 것이다. 이에 대해서는 다음 절에서 좀 더 자세히 알아보자.
'Spring > Toby's Spring' 카테고리의 다른 글
[토비의 스프링 3.1 Vol. 1][6장. AOP] 3절. 다이내믹 프록시와 팩토리 빈 (0) | 2024.12.08 |
---|---|
[토비의 스프링 3.1 Vol. 1][6장. AOP] 2절. 고립된 단위 테스트 (0) | 2024.12.08 |
[토비의 스프링 3.1 Vol. 1][5장. 서비스 추상화] 2절. 트랜잭션 서비스 추상화 (1) | 2024.12.08 |
[토비의 스프링 3.1 Vol. 1][5장. 서비스 추상화] 1절. 사용자 레벨 관리 기능 추가 (0) | 2024.12.08 |
[토비의 스프링 3.1 Vol. 1][4장. 예외] 2절. 예외 전환 (0) | 2024.12.08 |