5.2장은 다음과 같은 질문으로부터 시작된다.
유저의 레벨을 업그레이드하는 와중에
장애가 발생하여 작업을 완료할 수 없게 되었을 때
이미 바꿔버린 레벨까지 전부 기존 상태로 돌려놓아야 하나?
그리고 DB에는 이미 바꾼 데이터까지 rollback()해준다고 치더라도,
애플리케이션 코드 레벨에서도 알아서 이전 상태로 돌려놓나?
아니면 변경된 그 상태 그대로 있나?
5.2.1. 모 아니면 도
질문에 답하기 위해서는 직접 테스트를 돌려보는 방법밖에 없다.
UserService.upgradeLevels() 실행 도중에 인위적으로 예외를 일으키고
사용자 오브젝트들을 들춰서 결과를 확인해야 한다.
테스트용 UserService 대역
기존의 UserService는 StandardUpgradePolicy를 주입받아
정상적으로 작동하고 있었다.
예외를 일으키는 부분은 엄밀하게 따지면 upgradeLevel()에 둬야 하므로
새로운 인터페이스 구현 클래스를 생성해서 주입해도록 하자.
public class TransitionUpgradePolicy implements UserLevelUpgradePolicy {
public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_COUNT_FOR_GOLD = 30;
public boolean canUpgradeLevel(User user) {
switch (user.getLevel()) {
case BASIC : if (user.getLogin() >= MIN_LOGIN_COUNT_FOR_SILVER) return true;
case SILVER: if (user.getRecommend() >= MIN_RECOMMEND_COUNT_FOR_GOLD) return true;
case GOLD : return false;
default : throw new AssertionError("Unknown value : " + user.getLevel());
}
}
public void upgradeLevel(User user, UserDao_Interface userDao) {
if (user.getId().equals("Deftones")) throw new TestUserServiceException();
user.upgradeLevel();
userDao.update(user);
}
}
>>> 다른 부분은 StandardUpgradePolicy와 동일하다.
upgradeLevel()에서 파라미터로 받은 user의 Id가 "Deftones"인 경우에만 예외를 일으킨다는 점만 다르다.
그리고 특별한 기능을 하는 예외가 아니면서 이유를 명시하기 위해
RuntimeException을 상속하는 임시 예외를 하나 만들어 사용한다.
public class TestUserServiceException extends RuntimeException{
}
UpgradePolicy -> UserService 간 DI 관계 변경
<bean id="userService" class="springbook.user.dao.UserService">
<property name="userDao" ref="userDao"/>
<property name="upgradePolicy" ref="upgradePolicy"/>
</bean>
<bean id="upgradePolicy" class="springbook.user.dao.TransitionUpgradePolicy"/>
>>> 기존에 StandardUpgradePolicy였던 것을
새로 만든 TransitionUpgradePolicy로 변경했다.
테스트 오브젝트 Set
@Before
public void setUp() {
users = Arrays.asList(
new User("i1", "n1", "p1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER - 1, 0),
new User("i2", "n2", "p2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0),
new User("i3", "n3", "p3", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD - 1),
new User("Deftones", "n4", "p4", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD + 1),
new User("i5", "n5", "p5", Level.GOLD, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD + 3)
);
}
>>> 총 다섯 명의 사용자들 중에서
두번째와 네번째만 업그레이드되도록 세팅했다.
그리고 네번째 사용자의 Id가 "Deftones"기 때문에 이 때 예외가 던져질 것이다.
upgradeAllOrNothing() 테스트
@Test
public void upgradeAllOrNothing() {
userDao.deleteAll();
for (User user : users) userDao.add(user);
try {
userService.upgradeLevels();
fail("TestUserServiceException expected");
} catch (TestUserServiceException exception) {
}
checkUpgraded(users.get(1), false);
}
>>> upgradeLevels()를 실행하여 예외를 일으킨다.
catch로 강제종료를 막고
checkUpgraded()로 이미 레벨이 승급됐을 유저가
DB 상에 반영되었는지를 따져본다.
테스트 실패...
책에서는 테스트가 실패해야 한다고 한다.
즉 두번째 사용자의 업그레이드 결과가 DB에 들어감으로써,
다섯 명 사용자를 업그레이드하는 과정이 하나의 트랜잭션 안에 들어있지 않다는 걸 증명해야 한다.
그런데 놀랍게도 내 테스트 결과는 깔끔하게 통과다...
두번째 사용자의 트랜잭션이 일어나지 않아버렸다!
new User("i2", "n2", "p2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0)
>>> 로그인 값도 실버 기준 최솟값으로 업그레이드에 문제가 없고,,,
public void upgradeLevel(User user, UserDao_Interface userDao) {
if (user.getId().equals("Deftones"))
{
System.out.println("wow!");
throw new TestUserServiceException();
}
user.upgradeLevel();
userDao.update(user);
}
>>> 혹시 예외가 발생하지 않는 건 아닌지
프린트 문 하나 두고 실행해 보니
>>> 와우!라고 예외도 잘 던져지고 있다고 알려준다.
하찮은 결론...
지금 상황에서는 책에서와 다른 스프링 버전을 사용하기 때문에
트랜잭션 경계가 다르게 설정되어 있다고 생각하는 수밖에 없는 것 같다.
이 부분에 대해서는 공부를 좀 더 해보면서 해결해야 할 것 같다!
그러면 우선은 보류~~~
5.2.2. 트랜잭션 경계설정
Transaction Commit
: 여러 쿼리가 모두 정상적으로 실행됨
Transaction Rollback
: 여러 쿼리 중에서 중간에 오류가 나서 이미 실행된 것까지 다시 원래 상태로 복구함
JDBC 트랜잭션의 트랜잭션 경계설정
모든 트랜잭션은 시작하는 지점과 끝나는 지점을 가지며,
이를 트랜잭션의 경계라고 한다.
트랜잭션이 끝나는 방법은 commit 혹은 rollback뿐이다.
Test
public void transactionEx1() throws SQLException {
Connection c = userDao.getDataSource().getConnection();
c.setAutoCommit(false);
try {
PreparedStatement st1 = c.prepareStatement("update users set name = 'konu123' where id = 'i1'");
st1.executeUpdate();
PreparedStatement st2 = c.prepareStatement("update users set name = 'konu123' where id = 'i2'");
st2.executeUpdate();
c.commit();
} catch (Exception e) {
c.rollback();
}
c.close();
}
>>> setAutoCommit(false)부터 트랜잭션의 시작 경계다.
try에서 쿼리를 만들어 실행해보고, 혹시 예외가 던져졌을 경우 catch문에서 rollback()된다.
반대로 예외가 던져지지 않으면 commit()된다.
이처럼 setAutoCommit()으로 시작해서 commit()이나 rollback()으로 마쳐주는 것을
트랜잭션의 경계설정이라고 한다.
참고로, 위 코드처럼 하나의 DB 커넥션 안에서
만들어지는 트랜잭션을 로컬 트랜잭션이라고 한다.
UserService와 UserDao의 트랜잭션 문제
위처럼 commit()하고 rollback()하는 로직을 우리는 여태 본 적이 없다.
지금 데이터 액세스 로직을 맡고 있는 쪽이 누구더라...
public class UserDao_JDBC implements UserDao_Interface {
private JdbcTemplate jdbcTemplate;
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
jdbcTemplate = new JdbcTemplate(dataSource);
}
>>> UserDao_JDBC에서 DataSource를 JdbcTemplate으로 넘겨주고 있으니까
DB 접근은 JdbcTemplate에 넘겨준 상태다.
public int update(final String sql) throws DataAccessException {
Assert.notNull(sql, "SQL must not be null");
if (this.logger.isDebugEnabled()) {
this.logger.debug("Executing SQL update [" + sql + "]");
}
class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
UpdateStatementCallback() {
}
public Integer doInStatement(Statement stmt) throws SQLException {
int rows = stmt.executeUpdate(sql);
if (JdbcTemplate.this.logger.isTraceEnabled()) {
JdbcTemplate.this.logger.trace("SQL update affected " + rows + " rows");
}
return rows;
}
public String getSql() {
return sql;
}
}
return updateCount((Integer)this.execute((StatementCallback)(new UpdateStatementCallback())));
}
>>> JdbcTemplate의 update() 코드.
아마 execute()가 쿼리를 DB로 넘겨주는 메서드인 것 같다.
public static void doCloseConnection(Connection con, @Nullable DataSource dataSource) throws SQLException {
if (!(dataSource instanceof SmartDataSource) || ((SmartDataSource)dataSource).shouldClose(con)) {
con.close();
}
}
>>> execute()에서 타고 타고 넘어가다 보니 doCloseConnection()까지 왔고,
그 끝엔 Connection.close()가 있다.
그러니까 우리는 JdbcTemplate.update()을 한 번 호출할 때마다
DB 커넥션을 열었다 닫고 있었던 것이다.
그리고 열었다 닫는 것이 정확하게 트랜잭션의 경계를 포함하고 있다.
따라서 현재 UserDao_JDBC 메서드들의 로직은
트랜잭션 안에 여러 쿼리를 넣을 수가 없는 구조다.
비즈니스 로직 내의 트랜잭션 경계설정
그렇다면 upgradeLevels()를 통해 한꺼번에 여러 사용자를 수정할 때
하나의 트랜잭션 안에 집어넣기 위해서는 어떻게 해야할까?
가장 단순하게는 upgradeLevels()가 DB 커넥션의 통제권을 가지도록 해야할 것이다.
upgradeLevels() 안에서 커넥션을 받아서 쿼리를 쌓고
commit() 혹은 rollback()하여 close()까지 해줘야 한다.
public void upgradeLevel(User user, UserDao_Interface userDao) {
user.upgradeLevel();
userDao.update(user);
}
>>> 그런데 사실 DB에 쿼리를 보내는 로직은 userDao.update()가 관리하고 있다.
public void update(User user) {
this.jdbcTemplate.update("update users set name = ?, password = ?, " +
"level = ?, login = ?, recommend = ? where id = ?",
user.getName(), user.getPassword(),
user.getLevel().getValue(), user.getLogin(), user.getRecommend(), user.getId());
}
>>> 그리고 userDao.update()는 지금 JdbcTemplate을 쓰고 있다.
방금 언급했듯, JdbcTemplate은 저런 update() 메서드 한 번 호출에
커넥션을 열었다 닫고 있기 때문에 JdbcTemplate을 써서는 여러 쿼리를 쌓을 수가 없다.
public void update(Connection connection, User user) {
PreparedStatement st1 = connection.prepareStatement("update users set name = 'konu123' where id = 'i1'");
st1.executeUpdate();
>>> 그러면 결국 Connection 오브젝트를 파라미터로 전달해서
prepareStatement()로 쿼리 써서 executeUpdate()로 모아야 한다.
public void upgradeLevels() {
List<User> users = userDao.getAll();
Connection connection = userDao.getDatasource();
connection.setAutoCommit(false);
try {
for (User user : users)
if (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao, connection);
connection.commit();
} catch (Exception e) { connection.rollback(); }
connection.close();
>>> upgradeLevels() 안에서 Connection 오브젝트를 생성하고 있다.
트랜잭션 시작해서 try 안에서 upgradeLevel()을 하되,
거기서 UserDao의 메서드를 사용하기 위해 Connection 오브젝트를 파라미터로 전달해야 한다.
UserService 트랜잭션 경계설정의 문제점
위처럼 하면 로직에는 문제가 전혀 없지만 다른 문제가 많다.
1. JdbcTemplate의 깔끔한 메서드들과 데이터 액세스 기능들을 사용할 수 없게 된다.
2. Connection, 즉 DB 액세스와 관련한 로직을 들여와서 기능이 혼재된다.
3. UserDao가 Connection 오브젝트를 파라미터로 받아서 다른 데이터 액세스 기술들과 독립적으로 존재하지 못한다.
4. 이전의 upgradeLevels()를 호출하던 테스트들도 Connection 오브젝트를 생성하도록 전부 변경해야 한다.
5.2.3. 트랜잭션 동기화
Connection 파라미터 제거
upgradeLevels()에서 트랜잭션 경계를 관리해야 한다는 사실은 변함이 없다.
upgradeLevels()의 실행이 곧 트랜잭션의 단위이기 때문이다.
그렇지만 적어도 UserDao의 add(), update() 등의 메서드를 사용할 때
Connection 파라미터를 넘겨주는 일은 피할 수 있다.
( JdbcTemplate에 그 기능이 사실 내장되어 있었다 )
그러면 어떻게?
UserDao(JdbcTemplate)와 upgradeLevels() 사이에 트랜잭션 보관소를 둠으로써.
upgradeLevels()에서는 Connection을 한 번 만들고 JdbcTemplate의 메서드를 호출하면
JdbcTemplate이 보관소를 뒤져서 Connection을 참조하는 식이다.
모든 작업이 완료되면 동일하게 commit()하여 DB에 여러 쿼리를 한꺼번에 보내든지
중간에 예외가 발생해서 한꺼번에 rollback()된다.
이후에 트랜잭션 저장소에서 Connection을 지우고 닫아버리면 트랜잭션이 종료된다.
트랜잭션 동기화 적용
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 (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao);
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource);
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
>>> initSynchronization()으로 동기화를 시작한다.
Connection을 가져온다. 그런데 dataSource.getConnection()이 아니다.
DataSouceUtils의 getConnection()은 동기화 저장소와 연결시켜주기 때문이다.
이제 autoCommit()으로 트랜잭션을 시작하고 메서드를 호출한 이후 commit() 혹은 rollback()한다.
마지막으로 releaseConnection()으로 커넥션을 종료하고,
unbindResource()로 dataSouce로의 매핑을 종료한다.
clearSynchronization()으로 동기화 저장소를 비운다.
트랜잭션 테스트를 위한 xml 설정
public class UserService {
UserDao_Interface userDao;
public void setUserDao(UserDao_Interface userDao) {
this.userDao = userDao;
}
UserLevelUpgradePolicy upgradePolicy;
public void setUpgradePolicy(UserLevelUpgradePolicy upgradePolicy) {
this.upgradePolicy = upgradePolicy;
}
DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
>>> upgradeLevels()에서 DataSouce 인스턴스를 주입받아야 하기 때문에
DataSource 변수를 하나 뒀다.
그리고 스프링한테 주입을 위임하기 위해서는
<bean id="userService" class="springbook.user.dao.UserService">
<property name="userDao" ref="userDao"/>
<property name="upgradePolicy" ref="upgradePolicy"/>
<property name="dataSource" ref="dataSource"/>
</bean>
>>> xml 파일에 다음과 같이 DataSource 참조를 명시해줘야 한다.
JdbcTemplate과 트랜잭션 동기화
JdbcTemplate은 트랜잭션의 개념을 들어가기 이전부터 이미 트랜잭션을 고려하고 있었다.
다만 우리가 동기화 저장소를 초기화하지 않았기 때문에
계속해서 커넥션을 스스로 생성하고 닫아왔던 것이었다.
따라서 JdbcTemplate은
1. try/catch/finally 지원
2. SQLExcpetion 전환
3. 트랜잭션 동기화
라는 세 가지 유용한 기능을 한다.
5.2.4. 트랜잭션 서비스 추상화
기술과 환경에 종속되는 트랜잭션 경계설정 코드
지금까지는 로컬 트랜잭션,
즉 하나의 트랜잭션 내 쿼리가 하나의 DB로만 몰빵되는 Connection을 사용해왔다.
그런데 만약 하나의 트랜잭션 내용을 여러 DB로 분산해야 하는
글로벌 트랜잭션 환경으로 변경된다면 어떻게 해야할까?
다행히도 자바에서는 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한
API인 JTA( Java Transaction API )를 제공하고 있다.
이는 기존의 DB 액세스 기술인 JDBC와 호환되기 때문에 트랜잭션만 신경쓸 수 있게 해준다.
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);
tx.begin();
Connection c = dataSource.getConnection();
try {
List<User> users = userDao.getAll();
for (User user : users)
if (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao);
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
}
>>> 대강 UserService.upgradeLevels()의 로직이 이렇게 바뀐다곤 하는데
코드만 봐서는 아직 정확히 알기 어렵다...
눈대중으로는 InitialContext로 글로벌 트랜잭션을 시작하고,
UserTransaction이라는 것이 이미 JNDI라는 파일?에 명시되어 있으며
그 파일을 참조하는 InitialContext에서 tx 인스턴스를 만들어주는 것 같다.
( 그래서 User에 관한 트랜잭션만 따로 특정 DB와 연결되는 식인 것 같다 )
여튼 문제는 그게 아니다!!
더 큰 문제는 지금 우리가 은연중에 UserService.upgradeLevels()의 코드를 맘대로 바꿨다는 점이다.
혹여나 로컬 트랜잭션도 함께 제공해야 한다면?
뿐만 아니라 하이버네이트와 같이 독립된 트랜잭션 API를 지원하는 데이터 액세스 기술이 개입된다면?
그런 걱정이 들 때
우리가 이 부분을 분리해줘야 한다는 것을 깨닫는다...
트랜잭션 API의 의존관계 문제와 해결책
트랜잭션 API의 의존관계라는 게 누구와 누구 간의 의존관계인 것일까?
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 (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao);
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource);
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
public void add(User user) {
if (user.getLevel() == null) {
user.setLevel(Level.BASIC);
userDao.update(user);
}
}
>>> UserService.upgradeLevels()에 답이 있다.
Connection이 UserService와 UserDao_JDBC 간의 의존관계를 만든다.
UserService에서 JDBCTemplate을 이용하기 위해
Connection을 사용하면서 자연스럽게 UserDao_JDBC에 의존한다.
그리고 다른 트랜잭션 설정,
예를 들면 글로벌 트랜잭션이나 하이버네이트 등과 같은 환경에서는
Connection이 아닌 다른 오브젝트를 이용하기 때문에
확장이 막히게 된 것이다.
그리고 확장성이라는 것은 추상화에서 온다.
추상화는 하위 시스템들의 공통점을 뽑아서 구현한다.
지금 Connection을 비롯한 여타 오브젝트들의 공통점은
결국 트랜잭션 개념을 포함한다는 점이다.
스프링의 트랜잭션 서비스 추상화
다행히도 스프링은 다양한 트랜잭션 서비스가 추상화된 인터페이스인
PlatformTransactionManager를 제공한다.
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 (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
>>> 인터페이스 변수인 transactionManager에
JDBC의 DataSourceTransactionManager 구현 클래스 인스턴스를 집어넣는다.
getTransaction()에는 트랜잭션을 시작한다는 의미도 있지만
DB 커넥션을 생성( getConnection() )한다는 의미도 담겨있다.
파라미터인 DefaultTransactionDefinition은 트랜잭션의 속성을 가지고 있다.
생성된 트랜잭션은 TransactionStatus 타입 변수에 저장된다.
트랜잭션 상태 변수는 commit(), rollback() 등의 경계설정에 파라미터로 사용된다.
트랜잭션 기술 설정의 분리
위 코드에서 DataSourceTransactionManager 인스턴스를 생성한다는 것은
결국 PlatformTransactionManager를 사용하지 않는 것과 같다...
확장성을 위해 추상화를 하는 것인데, 결국 다른 트랜잭션 로직을 사용하려면
upgradeLevels()를 수정해야 하지 않은가?
그러면 결국 DI를 사용한다는 것이고,
DI는 이제 기계처럼 뚝딱할 수 있을 것 같다.
public class UserService {
PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
>>> UserService에 변수와 setter를 깔아둔다.
public void upgradeLevels() throws Exception {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users)
if (upgradePolicy.canUpgradeLevel(user))
upgradePolicy.upgradeLevel(user, userDao);
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
>>> 덕분에 transactionManager 변수를 초기화하는 코드가 날라갔다.
그리고 다른 코드는 전부 그대로다.
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
>>> xml 파일에 TransactionManager를 빈으로 등록하고,
이제 DB 커넥션도 여기서 관리하기 때문에 DataSource를 알아서 받도록 property로 설정해준다.
( 따라서 UserService에서 DataSource에 의존할 필요가 없어진다 )
이제 트랜잭션 매니저가 무엇인지에 아무런 상관이 없어졌다.
JTA를 쓰든, 하이버네이트를 쓰든 UserService가 동일한 기능을 한다는 가정 하에
xml 파일에서 class 경로 부분만 그에 맞춰 설정해주면 된다.
'스프링 (인프런) > 토비의 스프링' 카테고리의 다른 글
5.4. 메일 서비스 추상화 (0) | 2023.02.06 |
---|---|
5.3. 서비스 추상화와 단일 책임 원칙 (0) | 2023.02.05 |
5.1. 사용자 레벨 관리 기능 추가 (0) | 2023.02.04 |
4.2. 예외 전환 (0) | 2023.02.02 |
4.1. 사라진 SQLException (0) | 2023.02.01 |