본문 바로가기

Spring

JDBC 트랜잭션 동기화

트랜잭션이란 데이터베이스에서 자주 사용되는 용어로, 하나의 논리적인 작업 단위를 의미합니다.

우리는 지금까지 하나의 쿼리를 하나의 작업단위로 사용해왔습니다. 인서트 1번하는 작업 1개, 업데이트 1번 하는 작업 1개..

하지만 어플리케이션의 로직상 쿼리 하나가 작업 하나로 되는 경우만이 있는 것은 아닙니다.

간단하게 계좌이체를 예로 들어보면, A가 B에게 계좌이체를 할 때, 작업은 하나지만 쿼리는 최소 2개가 됩니다.

A의 잔액을 감소시키기 위해 업데이트 하고, B의 잔액을 증가시키기 위해 업데이트를 하게 됩니다.

그런데 만약 A의 잔액을 감소시키고, B의 잔액을 증가시키려는데 갑자기 네트워크 오류와 같은 에러가 발생한다면?

A의 잔액은 감소되었지만, B의 잔액은 증가되지 않은... 공중으로 돈이 날라가는 현상이 발생합니다.


이러한 현상들 때문에, 트랜잭션이라는 것이 필요하게 됩니다.

두 작업을 하나의 논리적인 단위로 묶어서 DB에 전송하게 되고, 그 논리 작업 단위 내의 작업이 하나라도 실패할 경우

내부의 작업을 모두 시작전 상태로 원상복구합니다. 다 되거나, 아님 다 안되거나의 방식이죠.

(옛날에는 프로시저를 많이 사용했다고 합니다..)


트랜잭션 테스트를 위한 데이터와, 코드를 간단히 작성하였습니다.

- account 테이블

> 이름과 잔액만 있는 간단한 계좌 테이블입니다.


- AccountDAO

public class AccountDAO {
    JdbcTemplate jdbcTemplate;
    
    @Autowired
    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
    
    public int getBalance(String name){
        return this.jdbcTemplate.queryForInt("select balance from account where name = ?", name);
    }
    
    public void minus(String name, int money){
        this.jdbcTemplate.update("update account set balance = (select balance from account where name = ?) - ? "
                + "where name = ?", name, money, name);
    }
    
    public void plus(String name, int money){
        this.jdbcTemplate.update("update account set balance = (select balance from account where name = ?) + ? "
                + "where name = ?", name, money, name);
    }
}

> JdbcTemplate를 사용하였고, 조회, 감소, 증가 정도의 기능이 있습니다.

(참고로 위의 setDataSource처럼 파라미터에 있는 오브젝트의 @Autowired도 가능합니다~)


- AccountService

public class AccountService {
    @Autowired
    AccountDAO accountDAO;
    
    public void accountTransfer(String sender, String receiver, int money){
        int balance = accountDAO.getBalance(sender); // 보내는 사람 잔액 체크
        
        if(balance >= money){ // 보내는 돈이 잔액보다 많으면
            accountDAO.minus(sender, money);
            accountDAO.plus(receiver, money);
        } else{
            throw new NoMoneyException();
        }
    }
}

> 보내는 사람의 잔액을 감소시키고, 받는 사람의 잔액을 증가시킵니다.


- AccountTest

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/applicationContext.xml")
public class AccountTest {
    @Autowired
    AccountService accountService;
    
    @Test
    public void accountTransfer(){
        accountService.accountTransfer("A", "B", 100000);
    }
}

> A가 B에게 10만원을 보내줍니다.. 나도 돈받고싶다..


> 문제없이 진행됩니다.



실패시키기

문제가 되는 상황을 만들어서 왜 트랜잭션이 필요한지 한번 살펴보겠습니다.

AccountDAO의 plus 메서드를 아래와 같이 변경해서 의도적으로 예외를 발생시킵니다.

public void plus(String name, int money) throws SQLException{
    if(true){
        throw new SQLException(); // 의도적 예외 발생
    }
    this.jdbcTemplate.update("update account set balance = (select balance from account where name = ?) + ? "
            + "where name = ?", name, money, name);
}

> 데이터베이스 접속 오류 등의 상황을 재현하기 위해 중간에 SQLException을 발생시킵니다.

잔액 증가 기능을 수행하려다 오류가 난것이죠. 아래의 update는 수행되지 않습니다.


그리고 테스트 해보면..

돈을 보냈는데 돈이 안갔습니다.. 맙소사


이러한 상황이 발생한것은 minus, plus가 각각 독립된 작업 단위이기 때문입니다. 별개란 뜻이죠.

그래서 트랜잭션이라는 것이 필요합니다. 

둘을 논리적인 단위로 묶어주고 단위 내의 작업이 실패할 경우, 그 작업 자체를 원점으로 돌려버려야합니다. 

정말 위와 같이 계좌이체 하는데 중간에 오류나고, 내 잔액만 사라지면 곤란하지 않겠습니까?



JDBC의 트랜잭션 처리

JDBC에서 트랜잭션을 관리하는 객체는 Connection 객체라는 점입니다.

자바에서는 이 Connection 객체를 통해 트랜잭션을 제어할 수 있습니다.

근데 Connection을 통한 트랜잭션 제어 방식은 기술적 한계를 가지고 있습니다. 그것은 아래에서 살펴보겠습니다.


DBMS 상에서, 변경사항을 DB에 반영하는 것을 커밋(commit)이라고 하고, 변경사항을 DB에 반영하지 않고 원상복구

하는 것을 롤백(rollback) 이라고 합니다.

즉, 작업이 다 성공적으로 되었을 경우 commit을 해줘야하고, 실패했을 경우 rollback을 해줘야겠지요.

트랜잭션의 단위는 비즈니스 로직에서 정해지므로, 트랜잭션은 서비스 계층에서 설정해주는 것이 적절한 관심사 분리입니다.

이로 인해 Connection 객체가 서비스 계층에 등장하게 되는데, 이로 인해 발생하는 문제점이 한두개가 아닙니다.


@Autowired
DataSource dataSource;

public void accountTransfer(String sender, String receiver, int money) throws SQLException{
    int balance = accountDAO.getBalance(sender); // 보내는 사람 잔액 체크
    
    if(balance >= money){ // 보내는 돈이 잔액보다 많으면
        Connection conn = dataSource.getConnection();
        conn.setAutoCommit(false); // 트랜잭션 시작

        try {          
            accountDAO.minus(conn, sender, money);
            accountDAO.plus(conn, receiver, money);
            
            conn.commit(); // 성공
        } catch (SQLException e) {
            conn.rollback(); // 실패
        }
    } else{
        throw new NoMoneyException();
    }
}

이런식으로 변하게 됩니다. (Connection 객체를 얻으려면 DataSource가 필요하므로 상단에서 DI받아줍니다)

Conncetion 객체의 AutoCommit 속성을 false로 지정해주면서 트랜잭션이 시작되고,

정상 종료되었을 때 commit, 실패시 rollback 해줍니다.


AutoCommit을 false로 설정해주었으니까 그 객체를 DAO에 전달하고, DAO는 그 Connection을 사용해야겠죠.

그래서 위와 같이 minus와 plus에 Connection을 전달하는 모습이 보여지고 있습니다.


이런식으로 되었을 때... 첫번째 문제점으로 JdbcTemplate를 사용하지 못하게 됩니다.

JdbcTemplate는 독립적으로 Connection 객체를 생성해서 사용하는 템플릿이었습니다.

템플릿이다보니까 따로 분리가 불가능하죠.. 결국 JDBC를 이용해서 DAO 코드를 작성하던 떄로 돌아가게 됩니다.

public void minus(Connection conn, String name, int money) throws SQLException{
    PreparedStatement ps = conn.prepareStatement("update account set balance = (select balance from account where name = ?) - ? "
            + "where name = ?");
    ps.setString(1, name);
    ps.setInt(2, money);
    ps.setString(3, name);
    
    ps.executeUpdate();
}

public void plus(Connection conn, String name, int money) throws SQLException{
    PreparedStatement ps = conn.prepareStatement("update account set balance = (select balance from account where name = ?) + ? "
            + "where name = ?");
    ps.setString(1, name);
    ps.setInt(2, money);
    ps.setString(3, name);
    
    ps.executeUpdate();
}

> AutoCommit false된 Connection을 받아서 사용해야 하므로 파라미터에 Connection이 추가됩니다.


두번쨰 문제점으로는, 방금 막 언급했던 Connection 객체가 전달되고 있는 부분입니다.

지금처럼 바로 DAO를 호출하지 않고 비즈니스 로직을 몇개 더 태워야 한다면, 그 로직마다 전부 Connection을 전달해줘야

하는 불편함이 발생합니다. 그에 따른 인터페이스도 전부 변화해야겠네요.

DAO만 해도 Connection 파라미터 때문에 인터페이스가 변경되게 되는데요..

지금은 JDBC의 트랜잭션 동기화를 설명하고 있어서 당장 문제되진 않지만, 좀 크게 봤을 경우

인터페이스에 Connection 파라미터를 가진 메서드가 있다는건 JDBC에 의존적인 DAO가 되는 것입니다.


게다가 저 파라미터 때문에 트랜잭션을 사용하지 않는 데이터 처리에서도

모든 서비스 메서드마다 Connection을 전달하는 코드를 넣어주거나, DAO 메서드를 2개씩 만드는 번거로움이 발생합니다.



스프링의 JDBC 트랜잭션 처리

스프링에서는 위의 단점을 다 엎을 수 있는 기술을 제공합니다. 바로 트랜잭션 동기화 라는 기술입니다.

트랜잭션 동기화는 저장소라는 개념이 등장하는데, 여기에 Connection 객체를 넣어둘 수 있습니다.

이후에 호출되는 DAO는 필요할 때마다 저장소에서 Connection 객체를 가져와서 사용할 수 있게 됩니다.

매번 Connection 객체를 만드는 방식이 아니라, 저장소를 체크해서 Connection이 있을 경우 이를 가져와 사용합니다.

이로 인해 매번 Connection을 전달할 필요가 없어지고, 저장소를 통해 Connection이 공유되므로 언제든 트랜잭션 형성이 가능해집니다.

게다가 각 스레드마다 독립적으로 Connection 오브젝트를 저장 및 관리하기 때문에 서로 충돌날 일도 없습니다.


public class AccountService {
    @Autowired
    AccountDAO accountDAO;
    
    @Autowired
    DataSource dataSource;
    
    public void accountTransfer(String sender, String receiver, int money) throws SQLException{
        int balance = accountDAO.getBalance(sender); // 보내는 사람 잔액 체크
        
        if(balance >= money){ // 보내는 돈이 잔액보다 많으면
            
            TransactionSynchronizationManager.initSynchronization(); // 트랜잭션 동기화 작업 초기화
            
            // Connection 오브젝트 생성, 저장소 바인딩, 참조변수 값 리턴
            Connection conn = DataSourceUtils.getConnection(dataSource); 
            
            conn.setAutoCommit(false); // 트랜잭션 시작
            
            try {
                
                // Connection 전달 안함
                accountDAO.minus(sender, money);
                accountDAO.plus(receiver, money);
                
                conn.commit(); // 성공
            } catch (SQLException e) {
                conn.rollback(); // 실패
            } finally{
                DataSourceUtils.releaseConnection(conn, dataSource); // 커넥션을 닫음
                
                // 동기화 작업을 종료하고 저장소를 비운다
                TransactionSynchronizationManager.unbindResource(this.dataSource);
                TransactionSynchronizationManager.clearSynchronization();
            }
        } else{
            throw new NoMoneyException();
        }
    }
}

TransactionSynchronizationManager는 스프링에서 제공하는 트랜잭션 동기화 작업용 클래스입니다.

initSynchronization 메서드를 이용해 작업을 초기화합니다.

이후 DataSourceUtils 클래스를 사용하여 Connection을 얻어옵니다. 

DataSourceUtils의 getConnection 메서드는 Connection 생성과 동시에 트랜잭션 저장소에 바인딩합니다.

얻어온 Connection 객체를 통해 작업 완료시 commit, 실패시 rollback 하는 모습을 볼 수 있으며

마지막 finally 부분에서 커넥션 닫고, 트랜잭션 동기화 종료 및 저장소를 비우는 모습도 보실 수 있습니다.


이제 DAO에는 트랜잭션 저장소에서 Connection을 체크하고, 얻어오는 부분을 구현해야 합니다.

복잡네.. 싶으실 테지만 그러실 필요가 없는게, JdbcTemplate에 이미 해당 기능의 구현이 다 되어있으므로

DAO는 그냥 JdbcTemplate를 사용하는 방식으로 돌려주시기만 하면 됩니다.

Connection을 생성하기 전에 트랜잭션 저장소를 체크하는 로직까지 다 들어간 템플릿인 거죠 ㅎㅎ

try/catch/finally 작업지원, SQLException 예외변환과 더불어 제공하는 유용한 기능 중 하나입니다.


이로써 JDBC로 트랜잭션을 처리하는 유용한 방법인 트랜잭션 동기화에 대해 알아보았습니다.

감사합니다.