[Spring] 트랜잭션
Controller, Service, Repository으로 분리해서 개발할 때 Service 계층에는 비즈니스 로직만 남겨두고 특정 기술에 종속
된 코드를 작성하지 않는게 좋다. 그래야 추후에 다른 기술을 사용하게 됐을 때 Service 계층에 있는 코드는 변경없이 그대로 남겨
둘 수 있기 때문이다. 하지만 기술적인 이유로 처음부터 Service 계층에 비즈니스 로직만 남기도록 설계할 수는 없었고, 이후 여러
가지 방법을 적용하면서 비즈니스 로직만 남길 수 있도록 점차 발전하면서 지금의 스프링이 있게 됐다. 처음부터 그렇게 설계할 수 없
었던 이유는 트랜잭션 처리을 처리하는 코드가 포함됐기 때문이다.
트랜잭션(Transaction)
트랜잭션은 DB 상태를 변화시키는 하나의 단위이다. 일을 하나하나 처리하면 각각의 결과가 따로 DB에 처리되는데 때에 따라서는
문제가 될 수 있다. 은행에서 계좌이체를 하면 사람A는 사람B에게 송금함으로써 두 사람의 계좌에서 입출금이 동시에 이뤄져야 한다.
입금과 출금이 따로 처리(=다른 트랜잭션에서 처리)된다면 한 트랜잭션에서 장애가 났을 때 계좌이체라는 서비스를 신뢰할 수 없게 된
다. 그러므로 이러한 서비스는 한 트랜잭션 내에서 처리돼야 하며, 문제가 생겼을 경우 처리하기 이전의 상태로 돌아갈 수 있게하는
로직이 필요하다. 이걸 롤백(Rollback)이라 한다.
SQLException
accountTransfer()
는 Service 계층에 있는 함수로, 내부만 봤을 땐 특정 기술에 종속된 코드가 없고 비즈니스 로직만 있어
깔끔한 상태다. 하지만 SQLException
을 던지고 있기 때문에 JDBC 기술에 의존한다는 점을 확인할 수 있다. JDBC 기술에 의존
한다는 건 다른 기술로 바뀌었을 때 코드도 수정해야 한다는 점을 의미한다. 그리고 update()
를 2번 호출하는데 같은 트랜잭션
내에서 처리된다는걸 보장해주지 않고 있어 한쪽만 수행되거나 아예 수행이 안될 수 있다는 문제를 갖고 있다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
SQLException
을 처리하는 로직은 다음글에서 소개
JDBC 트랜잭션 코드
비즈니스 로직을 bizLogic()
으로 따로 정의했다. 이전 코드에 비해서 트랜잭션 처리를 위한 코드가 많아지면서 지저분해졌고,
트랜잭션 처리를 위해 DataSource
, Connection
같은 JDBC 기술에 의존해야 한다. 드디어 한 트랜잭션 내에서 처리됨을
보장할 수 있지만 유지보수가 어렵다는 단점이 있다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); //트랜잭션 시작
bizLogic(con, toId, fromId, money); //비즈니스 로직
con.commit(); //성공 -> 커밋
} catch (Exception e) {
con.rollback(); //실패 -> 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
private void bizLogic(Connection con, String toId, String fromId, int money) throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
트랜잭션 추상화
특정 기술에 의존한다는 건 다른 기술로 교체할 때 코드도 수정해야 한다고 언급했었다. 그런데 이 기술들이 같은 인터페이스를 써서
사용하는 메소드를 강제한다면 코드를 수정하지 않고 구현체만 바꿔줘도 된다는 장점이 있다. 그래서 스프링은 여러 트랜잭션 클래스들
이 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;
}
리소스 동기화
같은 트랜잭션 내에서 처리되는 모든 로직은 같은 커넥션을 사용해야 한다. 그래서 JDBC 트랜잭션 코드
에서는 con
을 파라미터로
넘겨주고 있는걸 확인할 수 있다. 하지만 con
이 필요하지 않은 로직은 파라미터를 추가하지 않아도 된다는 복잡성이 있고 일단 파라
미터로 추가한다는 것부터 코드가 지저분해진다.
그래서 스프링은 트랜잭션 동기화 매니저를 사용한다. 트랜잭션을 관리하는 트랜잭션 매니저
는 트랜잭션 동기화 매니저
를 통해
같은 트랜잭션이 같은 커넥션을 사용하도록 보장한다. 정확히는 트랜잭션 매니저
가 DataSource를 통해 만든 커넥션을 트랜잭션
동기화 매니저
에 저장하고 repository는 커넥션이 필요할 때마다 트랜잭션 동기화 매니저
에서 저장했던 커넥션을 꺼내서 사용한
다. 이렇게 처리함으로써 이전처럼 파라미터로 커넥션을 넘겨주지 않아도 된다. 트랜잭션을 종료하고 싶으면 트랜잭션 매니저
가
트랜잭션 동기화 매니저
에 저장된 커넥션을 불러와 트랜잭션 종료 후 커넥션을 닫는다.
private Connection getConnection() throws SQLException {
//트랜잭션 동기화
Connection con = DataSourceUtils.getConnection(dataSource);
return con;
}
DataSourceUtils.getConnection(DataSource)
:트랜잭션 동기화 매니저
에서 커넥션을 가져와 반환- 파라미터로 커넥션을 전달받지 않고
getConnection()
으로 커넥션을 얻음
커넥션 종료
DataSourceUtils.releaseConnection()
를 호출해서 커넥션을 닫게 되는데, 호출했다고 바로 커넥션을 닫지 않는다. 트랜잭
션 안에서 해당 커넥션을 바로 닫아 종료했을 때 뒤에 커넥션이 유지돼야 하는 작업이 있다면 문제가 생기기 때문이다. 즉, 트랜잭션
이 종료될 때까지 커넥션은 유지돼야 하므로 트랜잭션을 사용하기 위해 동기화된 커넥션이면 바로 닫지않고 유지해주고, 트랜잭션 동
기화 매니저
가 관리하는 커넥션이 없다면 해당 커넥션을 닫는다.
트랜잭션 템플릿(TransactionTemplate)
첫 번째 코드는 비즈니스 로직을 수행 후 성공적으로 완료했다면 commit을, 그렇지 않으면 rollback을 해주고 있다. 항상 DB에
반영하는 작업인 경우 트랜잭션을 거쳐야 하기 때문에 항상 commit과 rollback 함수를 반복적으로 작성해야 한다. 이런 수고로움
을 덜기위해 트랜잭션 템플릿을 사용한다. 두 번째 코드를 보면 commit, rollback에 대한 코드를 작성하지 않는다.
//트랜잭션 템플릿 미적용
try {
bizLogic(toId, fromId, money);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
//트랜잭션 템플릿 적용
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(toId, fromId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
- 하지만 위 코드는 Service 로직에 해당되며 트랜잭션을 처리 로직이 포함돼 있음
- 비즈니스 로직과 트랜잭션 처리 로직이 같이 있으면 유지보수가 어려움
트랜잭션 AOP
Service 로직은 순수한 비즈니스 로직만 있어야 하는데 아직 목표를 달성하지 못했다. 이러한 문제점을 스프링 AOP를 통해 프록시
를 도입하면 해결할 수 있다! 프록시는 비즈니스 로직을 수행하기 위해(=Service 로직을 수행하기 위해) 위에 있는 트랜잭션 처리
로직을 알아서 수행한다. 즉, 트랜잭션 처리 로직을 작성해 비즈니스 로직과 같이 적용할 필요가 없기 때문에 순수한 비즈니스 로직만
남겨둘 수 있고, 유지보수성이 증가한다는 장점이 있다!
//트랜잭션 AOP 미적용
public void logic() {
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(toId, fromId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
//트랜잭션 AOP 적용
@Transactional
public void logic() {
bizLogic(toId, fromId, money);
}
@Transactional
: 트랜잭션 처리가 필요한 곳에 붙여주면 됨 (스프링 AOP)
스프링 부트의 자동 리소스 등록
스프링 부트는 application.properties
에 적은 내용을 참고해 DataSource
와 트랜잭션 매니저
를 알아서 등록해준다.
다음은 H2 데이터베이스를 사용할 때 예시이다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
댓글남기기