토비의 스프링 정리
토비의 스프링 - 5.2 트랜잭션 서비스 추상화
ksb-dev
2022. 10. 4. 21:55
5.2.1 모 아니면 도
- 레벨 업그레이드 도중 문제가 발생하면 이전에 업그레이드 된 것은 초기화 되는가?
- 일부 사용자가 차별성을 느끼기 때문에 업그레이드 도중 실패 시, 초기화 하는게 옳음
- 짧은 업그레이드 시간 중간에 DB 서버를 다운시키거나 네트워크에 장애 발생하는 것은 불가능 하며, 테스트는 자동화되야 하므로 예외가 발생하는 상황을 의도적으로 만들 것임
- 예외를 강제로 발생시킬 때 애플리케이션 코드를 수정하는 것은 좋지 않음
- 기존의 UserService 내부에 UserService를 상속한 테스트용 확장 static 클래스를 만들어 사용
- 현재 두 번째와 네 번째 사용자가 레벨 업그레이드 되므로, 네 번째 사용자 처리하는 중에 예외 발생할 것임
- upgradeLevel() 메소드 오버라이드를 통해 네 번째 id에서 예외 발생
- checkLevelUpgrade()메소드를 통해 업그레이드가 되었는지 확인
public class UserServiceTest {
@Test
public void upgradeAllOrNothing(){
UserService testUserService =
new UserService.TestUserService(users.get(3).getId());
testUserService.setUserDao(this.userDao);
userDao.deleteAll();
for(User user : users) userDao.add(user);
try {
testUserService.upgradeLevels();
fail("TestUserServiceException expected");
} catch (UserService.TestUserServiceException e){
}
checkLevelUpgraded(users.get(1), false);
}
}
public class UserService {
...
protected void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
...
public static class TestUserService extends UserService {
private String id;
public TestUserService(String id){
this.id=id;
}
@Override
protected void upgradeLevel(User user) {
if(user.getId().equals(this.id)) throw new TestUserServiceException();
super.upgradeLevel(user);
}
}
public static class TestUserServiceException extends RuntimeException{}
}
💡 테스트가 실패해야 정상인 것임. 즉, 중간에 에러가 발생해도 에러나기 이전 업그레이드는 DB에 새로 저장된다는 뜻임
5.2.2 테스트 실패 원인
- 트랜잭션 문제임
- 트랜잭션이란, 더이상 나눌 수 업는 작업의 단위로 원자성을 의미한다.
- upgradeLevels() 메소드는 각각의 트랜잭션 단위로 동작을 함
- DB 자체로 완벽한 트랜잭션을 지원하면서, 하나의 SQL 명령을 처리하는 경우 트랜잭션을 보장함
- 별다른 설정을 하지않는 한, 트랜잭션은 SQL 실행 단위로 동작
- 즉, 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 묶는 경계설정 작업 필요
- 트랜잭션 롤백과 커밋
- 트랜잭션 롤백(Transaction Rollback)
- 하나의 트랜잭션 작업 도중 문제가 발생할 경우 앞서 처리해서 성공한 SQL 작업도 초기화
- 트랜잭션 커밋(Transaction Commit)
- 하나의 트랜잭션 작업을 모두 성공했을 때 DB에 알려 작업 확정
- 트랜잭션 롤백(Transaction Rollback)
5.2.3 JDBC 트랜잭션의 트랜잭션 경계설정
- 트랜잭션 경계란, 트랜잭션이 시작되고 종료되는 위치
- JDBC 트랜잭션은 하나의 Connection을 가져와 사용하다 닫는 사이에 발생함
- 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 발생
- JDBC의 기본설정은 DB 작업을 수행한 직후 자동으로 커밋함
- 때문에, setAutoCommit()을 false로 지정해야 함
- 해당 설정을 하면 commit() 또는 rollback()을 만날 때 까지 하나의 트랜잭션으로 묶임
- commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션 경계설정(Transaction Demarcation)이라 함
- 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(Local Transaction)라 함
5.2.4 UserService와 UserDao의 트랜잭션 문제
- 코드 어디에도 트랜잭션을 시작하고 커밋, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않음
- JDBC는 Connection 오브젝트 단위로 트랜잭션이 이뤄지는데, JdbcTemplate를 사용하면서 Connection 오브젝트를 사용하지 않음
- JdbcTemplate는 템플릿 메소드를 실행할 때 마다 자동으로 Connection 오브젝트를 생성하기 때문에 독립적인 트랜잭션으로 실행될 수 밖에 없음
- JDBC는 Connection 오브젝트 단위이므로, 하나의 Connection 오브젝트를 공유하면 트랜잭션을 묶을 수 있음
- 또는, DAO 내부에 updgradeLevels()로직을 담는다면 하나의 트랜잭션으로 동작할 것임
- 하지만, 기껏 DI 작업으로 분리한 비즈니스 로직과 데이터 로직을 다시 묶는 작업이 되므로 매우 바람직하지 않음
5.2.5 비즈니스 로직 내의 트랜잭션 경계설정
- UserService와 UserDao를 그대로 둔 채 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService로 가져와야 함
- 이유는, upgradeLevels() 메소드의 시작과 함께 트랜잭션이 시작해야 하고 메소드를 종료와 함께 트랜잭션이 종료되야 하므로
- 또한, 하나의 Connection 오브젝트로 트랜잭션이 동작하므로 upgradeLevels() 내부에 DB 커넥션도 만들고 종료를 해야함
- 이러한 이유로 upgradesLevels()에서 사용되는 메서드들에 커넥션을 전달해 줘야함
5.2.6 UserService 트랜잭션 경계설정의 문제점
- 네 가지 문제점이 존재함
- JdbcTemplate를 사용하지 못하고 , JDBC API를 사용하는 초기 방식으로 돌아가야 함
- Connection 오브젝트가 계속 메소드에 전달되야 함
- UserService는 싱글톤으로 되어 있으니 UserService의 인스턴스 변수에 다른 메소드에서 사용할 수 없음
- 멀티스레드 환경에서 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하면 덮어씌워지는 경우가 발생하기 때문
- 더 이상 데이터 액세스 기술에 독립적일 수 없음
- Connection이 전달되면 JPA나 하이버네이트로 구현 방식을 변경하려고 하면 Connection 대신 EntityManager나 Session 오브젝트를 UserDao 메소드가 전달받도로 해야되기 때문
- 테스트 코드에 영향을 끼침
5.2.7 트랜잭션 동기화
- 스프링에서 멋진 방법을 제공하고 있음
- upgradeLevels()메소드가 트랜잭션 경계설정을 해야하는 것은 피할 수 없음
- 스프링이 제공하는 트랜잭션 동기화(Transaction Synchronization)을 사용하면 Connection 오브젝트가 전달되는 문제를 해결할 수 있음
- 트랜잭션 동기화는 특별한 장소(TransactionSynchronization)에 Connection 오브젝트를 보관하고, 이후 호출되는 DAO의 메소드에서 저장된 Connection을 가져다 씀
- JdbcTemplate은 이 트랜잭션 동기화 방식을 사용하고 있음
5.2.8 JdbcTemplate 트랜잭션 동기화 순서
- UserService에서 Connection 생성
- Connection을 트랜잭션 동기화 저장소에 저장 및 setAutoCommit(fasle)를 호출
- 첫 번째 dao.update() 실행
- Connection을 생성하기 전에 트랜잭션 동기화 저장소에 Connection이 존재하는지 확인 및 가져옴
- 가져온 Connection을 이용해 PrepareStatement를 만들어 수정 SQL 실행
- Connection을 닫지않고 3번 부터 반복
- ...
- Connection의 commit()을 호출해서 트랜잭션 완료시킴
- 트랜잭션 동가화 저장소에서 Connection 제거
💡 트랜잭션 동기화 저장소은 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 멀티스레드 환경에서 충돌이 발생하지 않음
5.2.9 트랜잭션 동기화 적용
- 스프링은 JdbcTemplate과 더불어 트랜잭션 동기화 기능을 제공하는 간단한 유틸리티 메소드를 제공하고 있음
- UserService에서 DB 커넥션을 위해 DataSource가 필요하므로 DI 설정을 해 줘야함
- 스프링에 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager가 있음
- 해당 클래스의 initSynchronization()를 통해 초기화 요청을 함
- DataSourceUtils에서 제공하는 getConnection()을 통해 DB 커넥션 생성 및 트랜잭션 동기화에 사용되도록 저장소에 바인딩함
- 해당 설정을 하면 JdbcTemplate의 작업에서 동기화 된 DB 커넥션 사용
- JdbcTemplate는 트랜잭션 동기화 저장소에 DB 커넥션이 없을 경우에만 직접 DB 커넥션을 생성함
- 따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출하면 되고, DAO 외부에서 트랜잭션을 만들고 관리가 필요하면 DB 커넥션을 생성하고 동기화를 해 주면 됨
public class UserService {
protected DataSource dataSource;
public void setDataSource(DataSource dataSource){
this.dataSource = dataSource;
}
public void upgradeLevels() throws SQLException {
TransactionSynchronizationManager.initSynchronization();
//여기서 dataSource 필요
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 {
//스프링 유팅리티 메소드를 이용해 DB 커넥션을 안전하게 닫음
DataSourceUtils.releaseConnection(c, dataSource);
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
...
}
public class UserServiceTest {
@Autowired
DataSource dataSource;
@Test
public void upgradeLevels() throws SQLException {
...
}
@Test
public void upgradeAllOrNothing() throws Exception{
UserService testUserService =
new UserService.TestUserService(users.get(3).getId());
testUserService.setUserDao(this.userDao);
testUserService.setDataSource(this.dataSource);
...
}
}
<beans
...>
<bean id="userService" class="com.ksb.spring.UserService">
<property name="userDao" ref="userDao"/>
<property name="dataSource" ref="dataSource" />
</bean>
...
<beans>
5.2.10 기술과 환경에 종속되는 트랜잭션 경계설정 코드
- 새로운 문제가 발생함
- G 회사가 사용자 관리 모듈을 구매해서 사용하고자 하는데, 하나의 트랜잭션 안에서 여러개 DB에 데이터를 넣는 작업을 할 것임
- 하지만, 현재의 코드는 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션 방식임
- 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문에 여러개 DB에 접근할 수 없음
- 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(Global Transaction) 방식 필요
- 자바는 JDBC 외에 글로벌 트랜젝션을 지원하는 트랜잭션 매니저 API 인 JTA(Java Transaction API)를 제공하고 있음
- DB는 JDBC, 메시징 서버는 JMS같은 API를 사용함
- 글로벌 트랜잭션을 위해 JDBC나 JMS API를 직접 제어하지 않고, JTA를 통해 트랜잭션 매니저가 관리하도록 위임함
- 트랜잭션 매니저는 XA 프로토콜을 통해 리소스와 연결됨
- 이를통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 됨
- 즉, G 회사의 요청은 트랜잭션 매니저와 트랜잭션 서비스를 사용할 테니 JDBC API가 아닌 JTA를 사용해 관리하게 해달라는 것임
- JTA를 이용한 트랜잭션 경계설정 구조는 JDBC를 사용할 때와 비슷함
- Connection의 메소드 대신 UserTransaction의 메소드를 사용하는 차이만 존재
💡 즉, 하나 이상의 DB가 참여하는 트랜잭션을 만들기위해 JTA 필요
5.2.11 기존의 UserService의 문제
- UserService의 upgradeLevels()는 JDBC 로컬 트랜잭션을 이용한 코드임
- JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면 UserService를 수정해야 함
- 로컬 트랜잭션을 사용면 충분한 고객은 JDBC를 이용한 트랜잭션 관리 코드를, G 회사 처럼 다중 DB를 위한 글로벌 트랜잭션을 필요하는 곳은 JTA를 이용한 트랜잭션 관리 코드를 적용해야 함
- 즉, UserService는 로직 변경되지 않았음에도 기술적 환경에 따라 코드가 변경되야 함
- 또, Y 회사에서 JDBC가 아닌 하이버네이트를 사용한 UserDao를 직접 구현한다고 요구함
- 기존 DI 구조를 통해 UserService를 변경하지 않아도 XML에서 DB를 변경할 수 있음
- 하지만, 하이버네이트를 이용한 트랜잭션 관리 코드는 JDBC나 JTA의 코드와 또 다름
- 하이버네이트의 경우 Connection을 직접 사용하지 않고, Session을 사용함
- 또한, 독자적인 트랜잭션 관리 API 사용함
- Y 회사의 요구를 들어주기 위해서는 하이버네이트의 Session과 Transaction 오브젝트를 사용하는 트랜잭션 경계설정 코드를 변경할 수 밖에 없음
- 💡 즉, UserService는 로직이 변경되지 않아도 기술에 따라 변경되는 문제 발생
5.2.12 트랜잭션 API의 의존관계 문제와 해결책
- 기존의 UserService는 데이터 액세스 기술이 변경되더라도 UserService의 코드는 영향받지 않았음
- 하지만, UserService에서 경계설정 코드를 추가하면서 특정 데이터 액세스 기술(JDBC)에 종속적인 구조가 되었음
- UserService는 UserDaoJdbc애 간접적 의존하는 코드가 되었음
- 다행히, 트랜잭션의 경계설정 담당하는 코드는 일정한 패턴을 가지는 유사한 구조임
- 여러 트랜잭션 경계설정 담당 코드의 공통점을 추상화 하여 하위 시스템이 변경되더라도 일관된 방법으로 접근할 수 있음
5.2.13 스프링의 트랜잭션 서비스 추상화
- 스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있음
- 해당 기술로 각 기술의 API를 이용하지 않아도, 일관된 방식으로 트랜잭션을 제어하는 경계설정 작업이 가능함
- 스프링이 제공하는 추상 인터페이는 PlatformTransactionManager임
- JDBC의 로컬 트랜잭션을 이용하면 dataSource를 DataSourceTransactionManager의 생성자에 전달하여 오브젝트 생성
- getTransaction()만 해주면 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업 수행 및 트랜젹센 시작
- DefaultTransactionDefinition는 트랜잭션의 속성을 가짐
- 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장됨
- TransactionStatus는 조작이 필요할 때 메소드의 파라미터로 전달하면 됨
public class UserService {
...
public void upgradeLevels() {
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;
}
}
...
}
5.2.14 트랜잭션 기술 설정의 분리
- 트랜잭션 추상화 API를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경
- DataSourceTransactionManager를 JTATransactionManager로 변경하면 됨
PlatformTransactionManager txManager = new JTATransactionManager();
- 는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 가지고 있음
- JTATransactionManager만 사용해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작됨
- 하지만, 어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService가 알고 있는 것은 DI 원칙에 위배됨
- 컨테이너를 통해 DI 받도록 해야함
- 모든 PlatformTransactionManager 구현 클래스는 싱글톤으로 사용 가능하므로 빈으로 등록해도 됨
- 일반적으로 인터페이스 이름, 변수 이름, 수정자 메소드 이름은 모두 같도록 통일하지만, PlatformTransactionManager는 관례적으로 transactionManger라는 이름 사용
- UserService에 추가한 DataSource는 더이상 사용되지 않으므로 제거 및 transactionManger 추가
- JDBC를 사용할 것이기 때문에 XML에 PlatformTransactionManager구현 클래스인 DataSourceTransactionManager로 설정
- JTA를 이용하려면 JtaTransactionManger로 설정
public class UserService {
private PlatformTransactionManager transactionManager;
...
public void setTransactionManager(PlatformTransactionManager
transactionManager){
this.transactionManager = transactionManager;
}
public void upgradeLevels() {
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 class UserServiceTest {
@Autowired
PlatformTransactionManager transactionManager;
...
@Test
public void upgradeAllOrNothing() {
UserService testUserService =
new UserService.TestUserService(users.get(3).getId());
testUserService.setUserDao(this.userDao);
testUserService.setTransactionManager(this.transactionManager);
userDao.deleteAll();
for(User user : users) userDao.add(user);
try {
testUserService.upgradeLevels();
fail("TestUserServiceException expected");
} catch (UserService.TestUserServiceException e){
}
checkLevelUpgraded(users.get(1), false);
}
}
<beans
...>
<bean id="userService" class="com.ksb.spring.UserService">
<property name="userDao" ref="userDao"/>
<property name="transactionManager" ref="transactionManager" />
</bean>
<bean id ="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
...
</beans>