백엔드 개발자로서 굉장히 신경썼던 부분 중 하나 일 것이다.
바로 transaction이다.
모든 작업은 원자성을 가져야하며, 만약 작업 중 문제가 발생했다면 해당 작업의 내용을 모두 rollback 해야 한다는 것이다.
모 아니면 도
해당 코드를 봐보자.
public void upgradeLevels() {
for(User user : userDao.getAll())
{
if(user.isUpgradeLevel())
upgradeLevel(user);
}
}
@BeforeEach
public void setUp() {
this.userService = applicationContext.getBean("userService", UserService.class);
this.userDao = applicationContext.getBean("userDao", UserDao.class);
users = Arrays.asList(
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
);
}
현재 해당 유저 중 2번, 4번의 유저가 업그레이드 대상이다.
하지만 만약 3번째 유저의 작업 수행 중 에러가 발생한다면 어떻게 될까?
이렇게 의도적으로 에러를 발생시키고, 데이터베이스를 확인해보았다.
2번 유저까지는 정상적으로 업그레이드가 되었지만, 업그레이드가 되어야 하는 4번 유저는 업그레이드가 되지 못한 것을 볼 수 있다.
모두 업그레이드가 진행되거나, 모두 업그레이드가 되지 않았어야 했지만 일부만 업그레이드가 된 것이다.
아마 바로 이유를 알 수 있을 것이다.
1~5번의 업그레이드가 같은 트랜잭션 내에서 이루어지지 않았기 때문이다.
트랜잭션 경계설정
가장 쉽게 생각할 수 있는 건, 이런 작업을 하나의 SQL로 작성하는 것이다.
하나의 SQL 명령을 사용하는 경우 DB가 트랜잭션을 보장해주기 때문이다.
하지만 때때로 다른 SQL에서 동작하더라도 트랜잭션으로 묶어야 하는 경우가 있다.
이런 경우에는 만약 도중에 에러가 발생하면, 실행했던 모든 작업들을 rollback하는 작업을 수행해야 한다.
그리고 모든 SQL이 성공한 경우에만, DB에게 알려줘 트랜잭션 커밋을 진행해야 한다.
트랜잭션을 적용하는 코드를 대충 만들어보자면, 다음과 같을 것이다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false);
try{
PreparedStatement st1 = c.preparedStatement("update users ...");
st1.executeUpdate();
PreparedStatement st2 = c.preparedStatement("update users ...");
st2.executeUpdate();
c.commit();
}
catch(Exception e){
c.rollback();
}
c.close();
commit까지 try문 안에서 실행하고, 에러가 발생했다면 롤백하는 것이다.
그리고 어디서부터 트랜잭션을 시작하고, 끝낼지도 굉장히 중요한 포인트이다.
setAutoCommit(false)를 하는 이유는 JDBC에서 자동으로 커밋을 하기 때문에, st1만 실행되고 커밋이 될 수도 있다.
그것을 막기 위해 자동 커밋을 해제하고 트랜잭션을 시작하는 것이다.
이렇게 만들어야 여러개의 트랜잭션을 묶을 수 있다.
이제 다음 할 일은 당연하다.
upgradeLevels()에 전체적으로 트랜잭션을 설정하는 것이다.
보면 알 수 있겠지만, 트랜잭션은 connection 영역을 벗어나지 못한다.
그렇기 때문에 첫번째 유저부터 마지막 유저까지 데이터베이스에 저장되는 동안 하나의 connection이 유지되어야 한다.
그럼 일단 바로 드는 생각은 jdbcTemplate을 userService에서 가져와서 전체적으로 트랜잭션을 설정하는 것이다.
하지만 당연히 좋은 방법이 아니다.
userService는 비즈니스 로직인데, jdbcTemplate을 가져오는 순간 데이터 로직이 섞여 오염되기 때문이다.
이거를 그대로 두면서 upgradeLevels()에 트랜잭션을 설정하려면 Connection을 UserService가 시작해야 한다.
그리고 UserDao에 Connection을 계속 넘겨줘야 한다.
그리고 UserDao의 사용이 끝나면 Connection을 닫아주면 된다.
update에 오버로딩으로 다음 메서드를 추가한다.
public void update(Connection c, User user) {
try
{
PreparedStatement ps1 = c.prepareStatement(
"update users set name = ?, password = ?, level = ?, login = ?, recommend = ? " +
"where id = ?"
);
ps1.setString(1, user.getName());
ps1.setString(2, user.getPassword());
ps1.setInt(3, user.getLevel().getValue());
ps1.setInt(4, user.getLogin());
ps1.setInt(5, user.getRecommend());
ps1.setString(6, user.getId());
ps1.executeUpdate();
}
catch (SQLException e)
{
throw new RuntimeException(e);
}
}
autoCommit과 commit, rollback은 dao가 아닌 service에서 수행할 예정이다.
그리고 service도 다음과 같이 수정한다.
public void upgradeLevels() {
Connection c = null;
try
{
c = dataSource.getConnection();
c.setAutoCommit(false);
for(User user : userDao.getAll())
{
if(user.isUpgradeLevel())
upgradeLevel(c, user);
}
c.commit();
}
catch (SQLException e) {
if(c != null)
try{
c.rollback();
}
catch (SQLException ex) {
throw new RuntimeException(ex);
}
}
finally {
if(c != null)
try
{
c.close();
}
catch (SQLException ignored)
{
}
}
}
private void upgradeLevel(Connection c, User user) {
user.upgradeLevel();
userDao.update(c, user);
}
이렇게 각 update보다 더 윗단계인 upgradeLevels에서 트랜잭션을 관리해야, 모든 트랜잭션을 하나로 관리 할 수 있다.
하지만 이런 방식은 문제가 있다, 리소스를 깔끔하게 관리해주던 jdbcTemplate을 더 이상 사용할 수 없다.
그렇기에 try/catch/finally를 또 직접 관리해야 한다.
그리고 가장 큰 문제는 비즈니스 로직만 관리해야 하는 UserService에 데이터베이스의 연결 정보인 DataSource가 등장한다는 것이다.
그리고 이런 식으로 Connection이 변수로 사용된다면, 멀티 쓰레드 환경에서 문제가 발생 할 수도 있다.
트랜잭션 동기화
이렇게 Connection을 주고 받는 방법이 아닌, 다른 방법으로 해결해보자.
일단 이 트랜잭션을 update()가 아닌, upgradeLevels()가 관리해야 하는 것은 이제 알 수 있다.
이럴 때 스프링에서 사용하는 방법은 트랜잭션 동기화라고 한다.
Dao가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 한다는 것이다. 그리고 모든 트랜잭션이 종료되면, 그 때 동기화를 마치면 된다.
1. UserService가 Connection을 생성한다.
2. 이를 트랜잭션 동기화 저장소에 저장하고, auto commit을 해제하며, 트랜잭션을 시작한다.
3. jdbcTemplate에서는 먼저 트랜잭션 동기화 저장소(4.)에서 시작된 트랜잭션을 가진 connection이 존재하는지 확인한다. 만약 connection을 찾으면, 이를 사용한다.
5. connection을 이용해 PreparedStatement를 만들고, SQL을 실행한다. (이를 11까지 반복한다)
12. 트랜잭션이 모두 완료되었다면 commit을 호출해 트랜잭션을 완료시킨다.
13. 저장된 connection을 저장소에서 삭제한다.
만약 이 작업 중 에러가 발생하면 connection의 rollback을 호출해 트랜잭션을 복구하고 종료한다.
이 트랜잭션 동기화 저장소는 스레드마다 독립적으로 Connection 오브젝트를 저장한다.
그렇기에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 나지는 않는다.
이렇게 만들면 connection을 전달할 필요가 없고, 트랜잭션의 시작과 종료를 관리하는 upgradeLevels()에서 connection을 관리하고, dao에서 jdbcTemplate과 동기화만 한다면 끝난다.
스프링은 멀티 스레드 환경에서 트랜잭션 동기화 기능을 제공하는 메서드를 가지고 있다.
public void upgradeLevels() {
TransactionSynchronizationManager.initSynchronization();
Connection c = DataSourceUtils.getConnection(dataSource);
try
{
c.setAutoCommit(false);
List<User> users = userDao.getAll();
for(User user: users)
{
if(user.isUpgradeLevel()) upgradeLevel(user);
}
c.commit();
}
catch (SQLException e)
{
try
{
c.rollback();
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}
finally
{
//Connection을 안정적으로 종료
DataSourceUtils.releaseConnection(c, dataSource);
//현재 스레드의 트랜잭션 컨텍스트에서 DataSource 리소스를 제거
TransactionSynchronizationManager.unbindResource(this.dataSource);
//스레드의 트랜잭션 동기화 작업 종료 및 정리
TransactionSynchronizationManager.clearSynchronization();
}
}
스프링에서 제공하는 트랜잭션 동기화 manager 클래스는 TransactionSynchronizationManager이다.
이 클래스를 이용해서 먼저 스레드에서 트랜잭션 동기화 작업을 초기화하도록 요청한다.
//사용 X
dataSource.getConnection();
//사용 O
Connection c = DataSourceUtils.getConnection(dataSource);
또한 기존과는 다르게 DataSourceUtils.getConnection(dataSource)를 사용한 것을 볼 수 있는데, 이 메서드는 Connection 오브젝트를 생성해주는 것 뿐만 아니라 트랜잭션 동기화를 사용하도록 저장소에 바인딩해준다.
public static Connection doGetConnection(DataSource dataSource) throws SQLException {
Assert.notNull(dataSource, "No DataSource specified");
ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(dataSource);
if (conHolder == null || !conHolder.hasConnection() && !conHolder.isSynchronizedWithTransaction()) {
logger.debug("Fetching JDBC Connection from DataSource");
Connection con = fetchConnection(dataSource);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
try {
ConnectionHolder holderToUse = conHolder;
if (conHolder == null) {
holderToUse = new ConnectionHolder(con);
} else {
conHolder.setConnection(con);
}
holderToUse.requested();
TransactionSynchronizationManager.registerSynchronization(new ConnectionSynchronization(holderToUse, dataSource));
holderToUse.setSynchronizedWithTransaction(true);
if (holderToUse != conHolder) {
TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
}
} catch (RuntimeException ex) {
releaseConnection(con, dataSource);
throw ex;
}
}
return con;
} else {
conHolder.requested();
if (!conHolder.hasConnection()) {
logger.debug("Fetching resumed JDBC Connection from DataSource");
conHolder.setConnection(fetchConnection(dataSource));
}
return conHolder.getConnection();
}
}
내부적으로는 해당 메서드를 사용하는데, TransactionSynchronizationManager를 사용해 초기화 상태를 확인하고, register 하는 것을 볼 수 있다.
이렇게 바인딩 해두면, JdbcTemplate이 작업하는 경우 내부적으로
@Nullable
private <T> T execute(PreparedStatementCreator psc, PreparedStatementCallback<T> action, boolean closeResources) throws DataAccessException {
Assert.notNull(psc, "PreparedStatementCreator must not be null");
Assert.notNull(action, "Callback object must not be null");
if (this.logger.isDebugEnabled()) {
String sql = getSql(psc);
String var10001 = sql != null ? " [" + sql + "]" : "";
this.logger.debug("Executing prepared SQL statement" + var10001);
}
Connection con = DataSourceUtils.getConnection(this.obtainDataSource());
PreparedStatement ps = null;
Object var17;
try {
ps = psc.createPreparedStatement(con);
this.applyStatementSettings(ps);
T result = (T)action.doInPreparedStatement(ps);
this.handleWarnings((Statement)ps);
var17 = result;
} catch (SQLException var13) {
if (psc instanceof ParameterDisposer parameterDisposer) {
parameterDisposer.cleanupParameters();
}
if (ps != null) {
this.handleWarnings(ps, var13);
}
String sql = getSql(psc);
psc = null;
JdbcUtils.closeStatement(ps);
ps = null;
DataSourceUtils.releaseConnection(con, this.getDataSource());
con = null;
throw this.translateException("PreparedStatementCallback", sql, var13);
} finally {
if (closeResources) {
if (psc instanceof ParameterDisposer) {
ParameterDisposer parameterDisposer = (ParameterDisposer)psc;
parameterDisposer.cleanupParameters();
}
JdbcUtils.closeStatement(ps);
DataSourceUtils.releaseConnection(con, this.getDataSource());
}
}
return (T)var17;
}
이렇게 DataSourceUtils.getConnection()으로 트랜잭션을 가져오며 작업하게 된다.
만약 트랜잭션이 존재하지 않는다면, 새롭게 커넥션을 생성해서 작업한다고 한다.
이렇게 코드를 작성한다면, upgradeLevels()의 모든 작업을 한 트랜잭션 내에서 수행 할 수 있다.
그렇게 해당 테스트를 수행해보았다.
@BeforeEach
public void setUp() {
this.userService = applicationContext.getBean("userService", UserService.class);
this.userDao = applicationContext.getBean("userDao", UserDao.class);
users = Arrays.asList(
User.builder().id("1").name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
User.builder().id("2").name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
User.builder().id("3").name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
User.builder().id("4").name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
User.builder().id("5").name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
);
}
@Test
public void upgradeLevels() {
userDao.deleteAll();
for(User user : users) userDao.add(user);
try{
userService.upgradeLevels();
}
catch (Exception ignored){
}
Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(0).getId()).getLevel());
Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(1).getId()).getLevel());
//3번 유저의 작업에서 에러가 발생
Assertions.assertEquals(Level.SILVER, userDao.get(users.get(2).getId()).getLevel());
Assertions.assertEquals(Level.SILVER, userDao.get(users.get(3).getId()).getLevel());
Assertions.assertEquals(Level.GOLD, userDao.get(users.get(4).getId()).getLevel());
}
이렇게 정상적으로 테스트가 통과되는 것을 볼 수 있었다.
이렇게 외부에서 트랜잭션을 관리하더라도 UserDao를 수정할 필요가 없는 이유는 JdbcTemplate이 지원하는 기능 때문이다.
트랜잭션 동기화 저장소에 등록된 DB 커넥션이 없는 경우에는 직접 트랜잭션을 시작하고, 트랜잭션 동기화 작업이 시작되었다면 그때부터 실행되는 JdbcTemplate의 메서드는 트랜잭션 동기화 저장소에서 DB 커넥션을 가져와서 사용한다.
이렇게 JdbcTemplate은 try/catch/finally 작업 흐름 지원, SQLException의 예외 변환과 함께 트랜잭션 관리의 3가지 유용한 기능을 제공한다.
트랜잭션 서비스 추상화
거의 생길 경우가 없지만, 만약 한 프로젝트에서 여러개의 DB를 사용한다면?
지금은 데이터베이스의 커넥션 내에서 작업을 하기 때문에, 지금 상황으로는 여러개의 DB를 사용하면 같은 트랜잭션으로 묶을 수 없다.
이런 경우에는 별도로 트랜잭션을 관리하는 글로벌 트랜잭션을 사용해야 한다고 한다.
이 때 사용하는 트랜잭션이 JTA(Java Transaction API)라고 한다.
트랜잭션을 JDBC에게 맡기는 게 아니라, JTA를 통해 트랜잭션을 관리하도록 위임해야 한다.
이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.
JTA를 사용하는 구조는 다음과 같다.
InitialContext initialContext = new InitialContext();
UserTransaction userTransaction = (UserTransaction)ctx.lookup("java:comp/UserTransaction");
userTransaction.begin();
Connection c = dataSource.getConnection();
try
{
//코드
userTransaction.commit();
}
catch(Exception e)
{
userTransaction.rollback();
throw e;
}
finally
{
c.close();
}
어쨋든 트랜잭션이 데이터 접근 밖에 위치해야 하는거는 같다.
그냥 사용하는 것을 Connection이 아니라, UserTransaction을 사용하는 것 뿐이다.
그럼 이제 모든 UserService의 코드를 변경해야 할까?
현재 UserService는 UserDaoImpl이 아닌, UserDao에 의존하고 있기에 OCP를 지키며 구현이 바뀌어도 UserService는 영향을 받지 않았다.
당연히 UserService도 그렇게 만들어야 한다.
사실 이러한 부분도 공통적으로 등장하는 코드일 것이다.
유사한 구조가 반복되는 구조이기에 추상화를 고려해 볼 수 있다.
스프링에서는 이미 트랜잭션 기술들의 추상화가 존재한다.
PlatformTransactionManager 인터페이스이다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
해당 인터페이스는 이렇게 존재한다.
이 중에서 우리는 지금 JTA를 사용하고 있기에, JtaTransactionManager()를 사용했다.
public void upgradeLevels() {
PlatformTransactionManager transactionManager = new JtaTransactionManager();
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
List<User> users = userDao.getAll();
for (User user : users) {
if (user.isUpgradeLevel()) upgradeLevel(user);
}
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
여기서 getTransaction()을 호출하면, 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 수행해준다.
이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다.
트랜잭션의 모든 작업이 끝나면 돌려받은 TransactionStatus 오브젝트를 파라미터로 PlatformTransactionManager의 commit을 호출한다.
getTransaction()의 내부적으로
protected void prepareSynchronization(DefaultTransactionStatus status, TransactionDefinition definition) {
if (status.isNewSynchronization()) {
TransactionSynchronizationManager.setActualTransactionActive(status.hasTransaction());
TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(definition.getIsolationLevel() != -1 ? definition.getIsolationLevel() : null);
TransactionSynchronizationManager.setCurrentTransactionReadOnly(definition.isReadOnly());
TransactionSynchronizationManager.setCurrentTransactionName(definition.getName());
TransactionSynchronizationManager.initSynchronization();
}
}
트랜잭션을 TransactionSynchronizationManager에 등록하는 작업을 수행하기에 JdbcTemplate이 여기서 가져다가 쓰는 것이다.
이렇게 새로 생성하던 JtaTransactionManager도 생성자를 통해 주입받도록 하자.
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserDao userDao;
private final PlatformTransactionManager transactionManager;
}
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<bean id="userService" class="seungkyu.UserServiceImpl">
<constructor-arg ref="userDao" />
<constructor-arg ref="transactionManager"/>
</bean>
이렇게해서 어떤 식으로 transaction이 적용되는지 알 수 있었다.
굉장히 중요한 부분이라고 생각되어, 내부적인 동작까지 살펴보았다.
앞으로는 내부까지 살펴볼 일은 없겠지만, 아마 @Transactional도 비슷한 동작을 프록시로 처리한 것이라고 생각한다.
'Spring > 토비의 스프링' 카테고리의 다른 글
[토비의 스프링] 5.4 메일 서비스 추상화 (0) | 2025.06.11 |
---|---|
[토비의 스프링] 5.3 서비스 추상화와 단일 책임 원칙 (0) | 2025.06.09 |
[토비의 스프링] 5.1 사용자 레벨 관리 기능 추가 (0) | 2025.06.07 |
[토비의 스프링] 4.2 예외 전환 (1) | 2025.06.04 |
[토비의 스프링] 4.1 사라진 SQLException (1) | 2025.06.03 |