모든 사용자에 대해 업그레이드 작업을 진행하다가 중간에 예외가 발생해서 작업이 중단된다면 어떻게 될까요? 이미 변경된 사용자의 레벨은 작업 이전 상태로 돌아갈까요? 아니면 바뀐 채로 남아있을까요? 이를 확인해보기 위해 1초도 안 걸리는 짧은 사용자 레벨 업그레이드 과정에서 네트워크가 끊긴다거나 DB 서버가 다운되거나 하는 상황을 직접 구현하는 것은 어렵습니다. 그렇기 때문에 해당 상황이 발생했을 때의 오류 중 일부를 임의로 던져서 해당 로직을 멈추는 방법으로 재현해보겠습니다.
UserService의 기능을 그대로 사용해야 하고, 테스트에 필요한 로직도 더 필요한 상황입니다. UserService의 코드를 복붙하여 새로운 클래스를 만들 수도 있지만 코드 중복도 발생하고, 사용하기도 번거롭기 때문에 UserService를 상속 받고, 테스트용 메소드를 가진 클래스를 생성하여 테스트해보겠습니다.
- TestUserService
package com.jhcode.spring.ch5.user.service; import com.jhcode.spring.ch5.user.domain.User; public class TestUserService extends UserService { private String id; //예외를 내부 클래스로 선언하여 사용한다 static class TestUserServiceException extends RuntimeException{}; public TestUserService(String id) { this.id = id; } @Override protected void upgradeLevel(User user) { //테스트에서 생성자로 주입한 ID와 User 객체의 아이디가 같으면 오류를 발생시킨다. if (user.getId().equals(this.id)) throw new TestUserServiceException(); super.upgradeLevel(user); } }
- UserService
public class UserService { protected UserLevelUpgradePolicy userLevleUpgrade; protected UserDao userDao; //...생략 //== 업그레이드가 가능할 때 실제로 값을 변경하는 코드 ==// protected void upgradeLevel(User user) { userLevleUpgrade.upgradeLevel(user, userDao); }
위 필드들을 protected로 선언하여 UserService를 상속받은 TestUserService에서도 사용할 수 있도록 했습니다.
- UserServiceTest
@Test public void upgradeAllorNothing() { UserService testUserService = new TestUserService(users.get(3).getId()); UserLevelUpgradePolicy policy = new UserLevelUpgradeImpl(); testUserService.setUserDao(userDao); testUserService.setUserLevelUpgradePolicy(policy); userDao.deleteAll(); for (User user : users) { testUserService.add(user); } try { testUserService.upgradeLevels(); //테스트가 제대로 동작하게 하기 위한 안전장치, 로직을 잘못짜서 upgradeLevels() 메소드가 통과되도 무조건 실패함. //fail("TestUserServiceException expected"); } catch (TestUserServiceException e) { System.out.println("TestUserServiceException 예외 발생함"); e.getStackTrace(); } finally { checkLevel(users.get(1), false); } }
testUserService에는 DAO와 Policy를 각각 주입해주어서 사용할 수 있도록 해야합니다. 또한 더 이상 DAO의 add() 메소드를 직접 호출하는 것이 아니라 UserService를 통해서 add() 메소드를 수행하면 Level의 값이 없는 경우 자동으로 BASIC 레벨이 설정됩니다.
testUserService의 upgradeLevels() 메소드는 DB에서 모든 사용자 정보를 꺼내고 UserSerivce의 메소드를 오버라이드한 upgradeLevel() 메소드를 통해서 Level을 업그레이드할 때, 지정된 id 값이 같으면 오류가 발생하도록 설정했습니다.
checkLevel(users.get(1), false); 메소드는 오류가 발생하고 나서 User 객체의 값이 변경되었는지 확인하는 메소드입니다. false 조건을 주었기 때문에, DB에서 가져온 User 객체의 Level 값과 테스트를 위해 생성한 User 객체의 Level 값이 다를 때 테스트가 성공한 것입니다.
👉 위 테스트는 실패를 하는게 정상입니다. checkLevel(users.get(1), false); 는 업그레이드가 되지 않았음을 체크합니다. 따라서 업그레이드가 되지 않았다면 테스트가 통과해야 하고, 업그레이드가 되었다면 테스트가 실패해야 합니다. 위 코드는 upgradeLevels() 메소드가 하나의 트랜잭션 단위로 작업하는지 확인하기 위한 코드였습니다. 만약 하나의 트랜잭션 단위로 작업한다면 중간에 오류가 발생할 시 모든 값이 롤백되어야 하고, 여러 개의 트랜잭션 단위로 작업한다면 중간에 오류가 발생할 시 기존의 값이 그대로 있을 것입니다.
따라서 위 테스트가 실패했기 때문에 우리는 upgradeLevles() 메소드가 여러 개의 트랜잭션 단위로 동작한다는 것을 알 수 있습니다.
‼️ 위 오류가 처음에 성공했던 이유는 DAO에서 getAll() 메소드로 DB의 모든 정보를 가지고 올 때 id의 내림차순으로 가져왔기 때문에 4번 사용자 유저에서 오류가 발생해 2번 사용자 유저까지 업그레이드 로직이 수행되지 않았기 때문에 DB의 유저의 값이 변경되지 않았다며 테스트가 성공한 것입니다. UserDaoJdbc의 getAll() 메소드 쿼리문을 아래와 같이 수정합니다.
//== 사용자 전체 조회 ==// public List<User> getAll() { String sql = "SELECT * FROM users"; //Order By Id DESC 삭제 return this.jdbcTemplate.query(sql, this.userMapper); }
비로서, upgradeLevels() 메소드는 하나의 트랜잭션 작업 단위로 실시되지 않고, 여러 트랜잭션 단위로 실행됩니다.
✅ JDBC 트랜잭션 경계 설정
- 트랜잭션 롤백(Rollback)
트랜잭션 도중에 오류가 발생하거나 사용자의 명시적인 요청에 따라 트랜잭션을 취소하고 이전 상태로 되돌리는 작업입니다. 롤백은 트랜잭션의 모든 변경 내용을 취소하고 데이터베이스를 이전 상태로 되돌리는 역할을 합니다. 이를 통해 데이터 일관성을 유지하고 오류 발생 시 안정성을 보장합니다.
- 트랜잭션 커밋(Commit)
트랜잭션의 모든 작업이 성공적으로 완료되어 데이터베이스에 영구적으로 반영되는 작업입니다. 트랜잭션 내의 모든 변경 사항은 커밋되어 데이터베이스에 영구 저장됩니다. 커밋이 수행되면 트랜잭션의 변경 내용은 다른 사용자들에게도 보이게 되며, 데이터베이스는 변경된 상태를 계속 유지합니다.
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있고, 시작은 한 가지이지만 끝은 롤백과 커밋으로 두 가지로 나눌 수 있습니다. 애늘리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부릅니다.
🔹 UserService와 UserDao의 트랜잭션 문제
UserService의 upgradeLevels()에 트랙잭션이 적용되지 않았던 이유는 코드 어디에도 트랜잭션을 시작하고, 커밋하고, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않았기 때문입니다. 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧기 때문에 JdbcTemplate의 메소드를 사용하는 UserDao에는 각 메소드마다 하나씩의 독립적인 트랜잭션으로 실행될 수밖에 없습니다.
위 그림은 UserService와 UserDao를 통해 트랜잭션이 일어나느 과정을 나타낸 것입니다. UserDao는 JdbcTemplate을 통해 매번 새로운 DB 커넥션과 트랜잭션을 만들어 사용합니다. 첫 번째 update() 메소드를 호출할 때 작업이 성공했다면 트랜잭션이 종료되고 커밋되어, 두 번째 update() 메소드에서 오류가 발생하더라도 롤백되지 않습니다.
✅ 트랜잭션 동기화
UserSerivce의 upgradeLevels() 메소드가 트랜잭션 경계 설정을 해야 한다는 것은 변함이 없습니다. 따라서 해당 메소드 안에서 Connection을 생성하고 트랜잭션 시작과 종료를 관리하게 해야 합니다. 하지만 이때 스프링이 제안하는 트랜잭션 동기화(Transaction synchronization)을 사용하여 DAO가 호출될 때 해당 Connection을 사용하는 것을 방지할 수 있습니다.
-
UserService
가Connection
을 생성한다.
- 생성한
Connection
을 트랜잭션 동기화 저장소에 저장한다. 이후에Connection
의setAutoCommit(false)
를 호출해 트랜잭션을 시작시킨다.
- 첫 번째
update()
메소드를 호출한다.
update()
메소드 내부에서 이용하는JdbcTemplate
은 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진Connection
오브젝트가 존재하는지 확인한다. ((2)
단계에서 만든Connection
오브젝트를 발견할 것이다.)
- 발견한
Connection
을 이용해PreparedStatement
를 만들어 SQL을 실행한다. 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는JdbcTemplate
은Connection
을 닫지 않은채로 작업을 마친다. 이렇게 첫번째 DB 작업을 마쳤고, 트랜잭션은 아직 닫히지 않았다. 여전히Connection
은 트랜잭션 동기화 저장소에 저장되어 있다.
- 동일하게
userDao.update()
를 호출한다.
- 트랜잭션 동기화 저장소를 확인하고
Connection
을 가져온다.
- 발견된
Connection
으로 SQL을 실행한다.
userDao.update()
를 호출한다.
- 트랜잭션 동기화 저장소를 확인하고
Connection
을 가져온다.
- 가져온
Connection
으로 SQL을 실행한다.
Connection
의commit()
을 호출해서 트랜잭션을 완료시킨다.
Connection
을 제거한다.
✅ 트랜잭션 동기화 적용
- UserService
//== 트랜잭션 동기화 ==// private DataSource dataSource; public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; } //로그인과 추천수에 따라 전체 사용자의 레벨을 업그레이드하는 비즈니스 코드 public void upgradeLevels() throws Exception { //트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화 TransactionSynchronizationManager.initSynchronization(); Connection c = DataSourceUtils.getConnection(dataSource); c.setAutoCommit(false); try { List<User> users = userDao.getAll(); for(User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } c.commit(); //-> 정상적으로 작업을 마칠 경우 커밋 } catch (Exception e) { c.rollback(); //-> 정상적으로 작업을 마치지 않을 경우 롤백 throw e; } finally { //Connection 닫기 DataSourceUtils.releaseConnection(c, dataSource); //동기화 작업 종료 및 정리 TransactionSynchronizationManager.unbindResource(this.dataSource); TransactionSynchronizationManager.clearSynchronization(); } }
UserService에서 DB 커넥션을 직접 다룰 때에는 DataSource 객체가 필요하기 때문에 DI 해주었습니다. DataSourceUtils 클래스를 사용하여 Connection 객체를 반환 받아 사용하는 이유는 Connection 객체를 트랜잭션 동기화에 사용할 수 있도록 저장소에 바인딩 해주기 때문입니다.
- UserServiceTest
@ExtendWith(SpringExtension.class) @ContextConfiguration(classes = {TestServiceFactory.class}) public class UserServiceTest { @Autowired private UserService userService; @Autowired DataSource dataSource; @Autowired private UserDao userDao; List<User> users; @BeforeEach public void setUp() { //배열을 리스트로 만들어주는 메소드 users = Arrays.asList( new User("user1", "user1", "p1", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER -1, 0), new User("user2", "user2", "p2", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER, 0), new User("user3", "user3", "p3", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD -1), new User("user4", "user4", "p4", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD), new User("user5", "user5", "p5", Level.GOLD, 100, 100) ); } //... 생략
DataSource를 주입 받아 upgradeAllorNothing() 메소드에서 TestUserService 객체의 주입될 수 있도록 만들었습니다. DataSorce 프로퍼티 설정은 xml 설정 방식이 아닌 Java-based-configuration 설정으로 클래스에 @Configuration, @Bean 어노테이션을 통해서 작성했습니다. 이는 스프링 IoC 컨테이너가 관리할 객체가 무엇인지 알려주는 클래스로, 스프링이 관리할 Bean 객체에 대한 정보를 전달합니다. 코드는 아래와 같습니다.
package com.jhcode.spring.ch5.user.service; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.SimpleDriverDataSource; import com.jhcode.spring.ch5.user.dao.UserDaoJdbc; @Configuration public class TestServiceFactory { @Bean public UserDaoJdbc 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() { UserService userService = new UserService(); UserLevelUpgradePolicy userLevelUpgradePolicy = new UserLevelUpgradeImpl(); userService.setUserDao(userDao()); userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy); return userService; } }
✅ 트랜잭션 서비스 추상화
만약 하나의 DB를 사용하는 것이 아니라 여러 개의 DB를 사용할 경우 트랜잭션 경계 설정을 어떻게 해야 할까요? 지금까지 JDBC의 Connection을 이용하여 만든 트랜잭션 방식은 로컬 트랜잭션 방식으로 단일 DB에 종속되는 방식입니다. 즉, 여러 개의 DB를 가지고 있다면 로컬 트랜잭션 방식이 아닌 별도의 트랜잭션 관리자를 통한 글로벌 트랜잭션(global transaction) 방식을 사용해야 합니다. Java에서는 이는 JTA(Java Transaction API)를 제공하고 있습니다.
- JTA를 통한 글로벌 /분산 트랙잭션 관리
이를 코드로 보면 아래와 같습니다.
JTA를 이용한 방법은 JDBC를 사용한 방법과 구조가 비슷합니다. Connection의 메소드 대신에 UserTransaction의 메소드를 사용할 뿐입니다. 다만 JTA를 이용하는 글로벌 트랜잭션으로 바꿀 때 UserService의 코드를 수정해야 합니다. JTA, JDBC 등 UserService의 기능에 대한 로직은 변경되지 않았지만, 기술환경에 대해서 코드가 변경되어야 하는 요소가 생기기 때문에 기술에 종속적인 코드가 되어 버립니다. 이는 아래와 같이 트랜잭션 경계를 설정할 때 특정 기술에 종속되는 코드를 작성했기 때문입니다.
✅ 스프링의 트랜잭션 서비스 추상화
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있습니다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해집니다.
- 트랜잭션 추상화 계층 구조
- UserService
//== 트랜잭션 동기화 ==// private DataSource dataSource; public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; } //로그인과 추천수에 따라 전체 사용자의 레벨을 업그레이드하는 비즈니스 코드 public void upgradeLevels() throws Exception { //트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화 PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { List<User> users = userDao.getAll(); for(User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } transactionManager.commit(status); //-> 정상적으로 작업을 마칠 경우 커밋 } catch (Exception e) { transactionManager.rollback(status); //-> 정상적으로 작업을 마치지 않을 경우 롤백 throw e; } }
DataSourceTransactionManager 클래스는 PlatformTransactionManager 인터페이스를 구현한 구현체입니다. JDBC 트랜잭션 추상화를 위한 오브젝트입니다.
트랜잭션 매니저에게 dataSource 객체를 주입 하고, 트랜잭션 매니저를 통해서 트랜잭션 경계 설정을 할 수 있도록 변경하였습니다. 트랜잭션 동기화에 필요한 dataSource 객체가 필요하기 때문에 이를 스프링 IoC 컨테이너가 관리하는 Bean 객체로 만들고, UserService 객체를 Bean으로 등록할 때 설정하도록 했습니다.
- TestServiceFactory, userService()
@Bean public UserService userService() { UserService userService = new UserService(); UserLevelUpgradePolicy userLevelUpgradePolicy = new UserLevelUpgradeImpl(); userService.setUserDao(userDao()); userService.setDataSource(dataSource()); userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy); return userService; }
- TestServiceFactory, userService()
✅ 트랜잭션 기술 설정의 분리
트랜잭션 추상화 API를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면 어떻게 해야 할까요? JTATrancationManager 클래스를 사용하면 됩니다. 하이버네이트로 구현했다면 HibernateTransactionManager 클래스를 사용하면 됩니다.
- JTATransactionManager 사용하기
PlatformTransactionManager jtaTransactionManager = new JtaTransactionManager();
그런데 UserService 클래스가 어떤 트랜잭션 매니저의 구현 클래스를 사용할지 알고 있어야 하는 부분에서 DI의 원칙에 위배된다고 할 수 있습니다. 그래서 DI 방식으로 코드를 변경해 보겠습니다. DI 방식을 사용하기 위해 스프링 빈 객체로 등록할 때는 멀티 스레드 환경에서 안전하게 사용할 수 있도록 싱글톤으로 만들어져도 괜찮은 클래스인가? 를 살펴보는게 좋습니다. PlatformTransactionManager의 구현 클래스는 싱글톤으로 사용이 가능하기 때문에 DI 방식으로 적용하는 것이 좋습니다.
- UserService
public class UserService { protected UserLevelUpgradePolicy userLevleUpgrade; protected UserDao userDao; private PlatformTransactionManager transactionManager; //UserDao 주입 public void setUserDao(UserDao userDao) { this.userDao = userDao; } //UserLevelUpgradePolicy 주입 public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy) { this.userLevleUpgrade = userLevelUpgradePolicy; } //TransactionManger을 외부에서 주입 public void setTrancationManager(PlatformTransactionManager transactionManager) { this.transactionManager = transactionManager; }
기존의 DataSource를 주입 받던 필드와 setter 메소드는 더 이상 직접적으로 필요하지 않기 때문에 삭제하였습니다.
- TestServiceFactory
@Bean public DataSourceTransactionManager transactionManager() { DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(); transactionManager.setDataSource(dataSource()); return transactionManager; } @Bean public UserService userService() { UserService userService = new UserService(); UserLevelUpgradePolicy userLevelUpgradePolicy = new UserLevelUpgradeImpl(); userService.setUserDao(userDao()); userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy); userService.setTranscationManager(transactionManager()); return userService; }
DataSourceTransactionManager 타입으로 객체를 생성하여 반환하여 Bean 객체로 등록하는 transactionManager() 메소드를 만들었습니다.
userService() 메소드에서 DataSource 객체를 주입하는 부분을 삭제하였습니다. setTransactionManager() 메소드를 통해서 transactionManager() 객체를 주입받도록 하였습니다.
- UserServiceTest
@ExtendWith(SpringExtension.class) @ContextConfiguration(classes = {TestServiceFactory.class}) public class UserServiceTest { @Autowired private UserService userService; @Autowired private PlatformTransactionManager transactionManager; //...생략 @Test public void upgradeAllorNothing() throws Exception { UserService testUserService = new TestUserService(users.get(3).getId()); UserLevelUpgradePolicy policy = new UserLevelUpgradeImpl(); testUserService.setUserDao(userDao); testUserService.setTranscationManager(transactionManager); testUserService.setUserLevelUpgradePolicy(policy);
스프링 컨테이너에 등록된 transactionManager 구현체를 DI 받습니다. 그리고 이를 testUserService 객체에 setter 메소드로 다시 주입해주면 됩니다. TestUserService 객체는 스프링 컨테이너가 관리하는 객체가 아니기 때문에, setter 메소드를 사용해서 의존 관계를 위와 같이 직접 설정해주어야 합니다. 하지만 UserService 객체는 Bean으로 등록되어 스프링 컨테이너가 관리하는 객체이며, Bean 설정에서 setter 메소드로 관계 설정을 전부했기 때문에 테스트 클래스에서 또 다시 관계 설정을 해줄 필요는 없습니다.
📖 토비 스프링 3.1 -p349~375
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T