✅ 메소드 분리
트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀있는 것처럼 보이지만, 코드 간의 서로 주고 받는 정보가 없이 뚜렷하게 구분되어짐이 보입니다. 성격이 다른 두 코드를 메소드 추출 기법으로 분리해보겠습니다.
- UserService
//TransactionManger을 외부에서 주입받음 public void setTransactionManager(PlatformTransactionManager transactionManager) { this.transactionManager = transactionManager; } //== 트랜잭션 동기화 ==// //로그인과 추천수에 따라 전체 사용자의 레벨을 업그레이드하는 비즈니스 코드 public void upgradeLevels() throws Exception { //트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화 TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { upgradeLevelsInternal(); transactionManager.commit(status); //-> 정상적으로 작업을 마칠 경우 커밋 } catch (Exception e) { transactionManager.rollback(status); //-> 정상적으로 작업을 마치지 않을 경우 롤백 throw e; } } // 사용자 레벨 업그레이드 코드 분리 private void upgradeLevelsInternal() { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } }
코드를 리팩토링하면서 트랜잭션과 비즈니스 로직이 깔끔하게 분리되었습니다. 하지만 여전히 Service 안에 트랜잭션 코드가 들어가 있기 때문에 DI를 통한 트랜잭션 분리가 이루어져야 합니다.
✅ DI 적용, 트랜잭션 분리
현재는 위와 같이 Client인 UserServiceTest와 UserService 간에 강하게 결합되어있습니다. 이는 UserServiceTest가 상속을 통해 확되었기 때문입니다. 그래서 UserServiceTest는 UserService의 비즈니스 로직과 트랜잭션 로직에 강하게 결합되어 있는 것입니다.
따라서 지금까지 해왔던 것처럼 기존의 UserService를 인터페이스로 만들고 이를 구현한 UserServiceImpl 클래스를 생성합니다. 이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이뉴는, 구현 클래스를 바꿔가면서 쓰기 위함입니다.
그렇다면 위와 같이 비즈니스 로직과 트랜잭션 코드를 분리한 구현체가 만들어질 수도 있습니다. 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것입니다. 하지만 Client가 UserService의 기능을 제대로 사용하려면 트랜잭션이 적용되어야 합니다.
UserServiceTx는 UserServiceImpl을 대체하기 위한 구현 클래스가 아니라 트랜잭션 경계설정이라는 책임을 분리하기 위해 만들어진 클래스입니다. 스스로 비즈니스 로직을 가지고 있지 않기 때문에 UserService의 구현 클래스에게 실제적인 로직 처리 작업을 위임합니다.
- UserService
package com.jhcode.spring.ch6.user.service; import com.jhcode.spring.ch6.user.domain.User; public interface UserService { // 트랜잭션과 비즈니스 로직을 분리하기 위한 인터페이스 void add(User user); void upgradeLevels(); }
- UserServiceImpl
package com.jhcode.spring.ch6.user.service; import java.util.List; import org.springframework.mail.MailSender; import org.springframework.mail.SimpleMailMessage; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; import com.jhcode.spring.ch6.user.dao.UserDao; import com.jhcode.spring.ch6.user.domain.Level; import com.jhcode.spring.ch6.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; //UserDao 주입 public void setUserDao(UserDao userDao) { this.userDao = userDao; } // MailSender 주입 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); } //== 처음 사용자에게 BASIC Level 부여 ==// public void add(User user) { if (user.getLevel() == null) user.setLevel(Level.BASIC); userDao.add(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); } }
- UserServiceTx
package com.jhcode.spring.ch6.user.service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; import com.jhcode.spring.ch6.user.domain.User; public class UserServiceTx implements UserService { UserService userService; PlatformTransactionManager transactionManager; public void setUserService(UserService userService) { this.userService = userService; } public void setTransactionManager(PlatformTransactionManager transactionManager) { this.transactionManager = transactionManager; } //== DI 받은 UserService에게 비즈니스 로직 위임 ==// @Override public void add(User user) { userService.add(user); } @Override 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; } } }
- TestServiceFactory
package com.jhcode.spring.ch6.user.service; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.SimpleDriverDataSource; import com.jhcode.spring.ch6.user.dao.UserDao; import com.jhcode.spring.ch6.user.dao.UserDaoJdbc; @Configuration public class TestServiceFactory { @Bean public UserDao userDao() { UserDaoJdbc userDao = new UserDaoJdbc(); userDao.setDataSource(dataSource()); return userDao; } @Bean public DataSource dataSource() { SimpleDriverDataSource dataSource = new SimpleDriverDataSource(); String url = "jdbc:mariadb://localhost:3306/toby_study?characterEncoding=UTF-8"; String username = "root"; String password = "1234"; dataSource.setDriverClass(org.mariadb.jdbc.Driver.class); dataSource.setUrl(url); dataSource.setUsername(username); dataSource.setPassword(password); return dataSource; } @Bean public UserService userService() { UserServiceTx userServiceTx = new UserServiceTx(); userServiceTx.setTransactionManager(transactionManager()); userServiceTx.setUserService(userServiceImpl()); return userServiceTx; } @Bean public UserServiceImpl userServiceImpl() { UserServiceImpl userServiceImpl = new UserServiceImpl(); userServiceImpl.setUserDao(userDao()); userServiceImpl.setMailSender(mailSender()); return userServiceImpl; } @Bean public DummyMailSender mailSender() { DummyMailSender dummyMailSender = new DummyMailSender(); return dummyMailSender; } @Bean public DataSourceTransactionManager transactionManager() { DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager(); dataSourceTransactionManager.setDataSource(dataSource()); return dataSourceTransactionManager; } }
xml 기반이 아닌 java-based-configuration 설정을 사용했습니다. 적용된 의존관계는 아래와 같습니다. UserServiceTx는 트랜잭션 기능만 담당하며, 비즈니스 로직은 DI받은 UserServiceImpl에게 위임합니다.
✅ 테스트 수정
- 동일한 타입의 객체는 어떻게 DI 하는가?
기본적인 분리 작업을 마치고 테스트를 수정해 보겠습니다. 현재 UserServiceTest에서는 UserService인터페이스 타입의 객체를 @Autowired로 주입받고 있습니다. 그런데 testUserService에서 설정한 UserService 타입의 Bean 객체가 두 개입니다. @Autowired는 기본적으로 타입을 이요해 빈을 찾지만 만약 타입으로 하나의 빈을 결정할 수 없는 경우에는 필드 이름을 이용해 빈을 주입합니다. 따라서 이때 주입되는 Bean은 UserServiceTx 객체라고 할 수 있습니다.
@Autowired private UserService userService;
- 목 오브젝트 테스트를 위한 구체적인 클래스 DI
목 오브젝트를 통해서 MailSender 객체를 주입하고 테스트할 때에는 UserServiceImpl에 대한 구체적인 객체가 필요합니다. MailSender 객체를 DI할 구체적인 클래스가 어떤 것인지 알고 있어야 목 오브제트를 통해 테스트가 가능하기 때문입니다. 따라서 MockMailSender 객체를 주입하여 테스트하기 위해 UserServiceImpl 객체도 주입 받습니다.
@Autowired private UserServiceImpl userServiceImpl;
- upgradeLevels()
@Test @DirtiesContext public void upgradeLevels() throws Exception { userDao.deleteAll(); for(User user : users) { userDao.add(user); } MockMailSender mockMailSender = new MockMailSender(); userServiceImpl.setMailSender(mockMailSender); //DB의 모든 User을 가지고와서 Level 등급을 조정함 userService.upgradeLevels();
Mock 객체를 통해서 테스트하고 있기 때문에 해당 객체를 구체적인 클래스인 UserServiceImpl 객체에 직접 주입해야 합니다. 그런데 어떻게 Bean Factory에서 UserServiceImpl 객체를 주입했는데 위에서 다시 Mock 객체를 주입한 것이 반영될까요? 이는 실제로 DI 받는 것은 실제 객체 아닌 메모리의 참조 주소를 받기 때문입니다. 따라서 해당 메모리 참조 주소의 UserServiceImpl에 대한 정보는 변경되었지만 UserService 객체가 참조하는 것은 여전히 동일한 객체이므로 이를 사용할 수 있는 것입니다.
- upgradeAllOrNothing
@Test public void upgradeAllorNothing() throws Exception { UserServiceImpl 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) { testUserService.add(user); } try { txUserService.upgradeLevels();
public class TestUserService extends UserServiceImpl
upgradeAllOrNothing() 메소드는 트랜잭션 기술이 바르게 적용됐는지 확인하기 위한 일종의 학습 테스트입니다. 이제 TestUserService는 트랜잭션 기술이 빠진 UserServiceImpl 클래스를 상속 받아서 확장해야 합니다. 트랜잭션 롤백의 확인을 위해 강제로 예외를 발생시킬 위치가 UserServiceImpl에 있기 때문입니다.
📖 토비 스프링 3.1 -p401~413
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T