트랜잭션은 시작지점과 종료지점이 존재한다. 시작하는 방법은 한 가지이지만, 종료되는 방법은 두 가지이다.
종료 방법
트랜잭션은 하나의 Connection을 가져와서 사용하다가 닫는 사이에 일어난다. 트랜잭션의 시작과 종료는 Connection 객체를 통하여 이뤄지기 때문이다.
스프링 사용시에는 내부적으로 커넥션을 갖고 있는 트랜잭션 매니저를 이용한다. 이 트랜잭션 매니저는 추상화 되어있는 녀석이다. 이 땐 다음과 같이 트랜잭션을 시작하게 되고 자동 커밋 옵션을 변경하는 등의 작업은 트랜잭션 매니저 내부에서 진행된다.
public void executeQuery() throws SQLException {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// 트랜잭션 시작
...
}
스프링에서는 transactionManager
의 commit()
혹은 rollback()
함수가 호출되면서 트랜잭션이 종료된다.
흔하게 우리는 선언적 트랜잭션 관리를 하기 때문에 @Transactional
어노테이션이 붙은 메서드의 끝에서 commit
이 된다고 생각하면 편하다.
rollback
은 언제될까? 바로 예외가 발생했을 때 일어난다.
Java에서의 예외는 크게 두 개로 구분된다. 이 두 개는 각각 체크 예외, 언체크 예외라고 불러진다.
각 예외가 어떤 건지 한 번 알아보자.
체크 예외가 발생할 시에는 진행 중인 트랜잭션은 일단 커밋된다. 체크 예외 발생 시에서도 이를 롤백하고 싶다면 @Transactional
의 프로퍼티인 rollbackFor =
옵션을 설정해주면 된다.
언체크 예외가 발생할 땐 무조건 롤백된다.
서비스 로직 개발시 입력 필드에 대해 오류가 발생한다거나 그럴 경우 체크 예외를 사용하면 되는 것이다.
반면 네트워크 오류 등 개발자 손을 벗어난 오류는 언체크 예외를 던진다고 보면 된다.
개발을 하다보면 트랜잭션을 진행시키고 있을 때 다른 트랜잭션을 또 진행해야하는 경우가 생길 수 있다. 나의 경우는 하나의 컨텐츠를 만들기 위해 여러 도메인이 협력해야하는 경우가 그랬다. 예를 들면 레시피 컨텐츠를 만들기 위해서는 일단 게시글에 대한 트랜잭션과, 여러 재료들에 대한 트랜잭션이 필요했다. 이는 재료들이 해당 재료가 쓰인 다른 레시피들을 찾아야할 때 필요했기 때문에 정규화 되었기 때문이었다. 얘기가 길어졌는데, 아무튼 이렇게 다른 트랜잭션을 진행해야하는 경우가 생길 수 있다는 사실을 기억하고 들어가자.
위 이미지에서 보이는 것처럼 최초의 트랜잭션을 외부 트랜잭션, 거기서 추가로 진행되면서 생기는 트랜잭션을 내부 트랜잭션이라고 한다.
트랜잭션은 데이터베이스에서 제공하는 기술이므로 커넥션 객체를 통해 처리된다. 즉, 1개의 트랜잭션을 사용한다는 것은 하나의 커넥션 객체를 사용한다는 것이고, 실제 데이터베이스의 트랜잭션을 사용한다는 점에서 물리 트랜잭션이라고도 한다.
트랜잭션 전파 속성에 따라서 외부 트랜잭션과 내부 트랜잭션이 동일한 트랜잭션을 사용할 수도 있다. 하지만 스프링의 입장에서는 트랜잭션 매니저를 통해 트랜잭션을 처리하는 곳이 2군데이지 않나?
때문에 실제 DB 트랜잭션과 스프링이 처리하는 트랜잭션 영역을 구분하기 위해 스프링은 논리 트랜잭션이라는 개념을 추가하였다. 위의 그림은 외부 트랜잭션과 내부 트랜잭션이 1개의 물리 트랜잭션(커넥션)을 사용하는 경우이다.
스프링의 트랜잭션 전파 속성은 두 가지다.
REQUIRED
REQUIRED_NEW
스프링이 제공하는 디폴트 전파 속성. 논리 트랜잭션이 하나라도 잘못되면 물리 트랜잭션이 롤백된다. 위의 이미지의 형태로 외부 트랜잭션도, 내부 트랜잭션도 하나의 물리 트랜잭션에 join하기 때문에 그렇다.
여기서 join, 참여한다는 말은 외부 트랜잭션을 그대로 이어간다는 말이며, 외부 트랜잭션의 범위가 내부까지 확장되는 것이다. 그러므로 내부 트랜잭션은 새로운 물리 트랜잭션을 사용하지 않는다.
하지만 트랜잭션 매니저에 의해 관리되는 논리 트랜잭션이 존재하니까 커밋은 내부 1회, 외부 1회해서 총 2회 실행된다. 물론 각 트랜잭션은 논리 트랜잭션이므로 커밋이 호출되어도 즉시 커밋되진 않고, 외부 트랜잭션이 최종적으로 끝날 때 실제 커밋이 된다.
롤백도 비슷하다. 내부 트랜잭션에서 롤백을 해도 즉시 롤백되지는 않고, 물리 트랜잭션이 롤백으로 끝날 때 실제 롤백이 처리된다. 이 때 위에서 이야기했듯 논리 트랜잭션들 중 하나라도 롤백된 게 있으면 롤백된다.
REQUIRED_NEW 옵션은 외부 트랜잭션과 내부 트랜잭션을 완전히 분리하는 속성이다.
각 논리 트랜잭션 별로 물리 트랜잭션이 사용되며, 각각 커밋과 롤백이 수행된다. 따라서 하나의 논리 트랜잭션에서의 롤백이 전체에 전파되지는 않는다. 왜? 서로 다른 물리 트랜잭션을 쓰니까.
그러나 이는 각기 다른 커넥션을 쓴다는 의미이기도 하다. 한 개의 http 요청에 두 개 이상의 커넥션이 사용되는 것이다. 그렇기에 db 커넥션을 고갈시킬 수 있으므로 조심해서 사용해야한다.
내부 트랜잭션이 처리되는 중에서는 외부 트랜잭션은 대기한다. 이후 내부 트랜잭션이 처리완료되면 외부 트랜잭션도 처리 된다.
그러므로 조심해서 사용해야 하며, 만약 REQURES_NEW 없이 해결 가능하다면 대안책(별도의 클래스를 두기 등)을 사용하는 것이 좋다고한다.
스프링은 총 7가지 전파 속성을 제공한다.
REQUIRED는 디폴트 속성으로써 모든 트랜잭션 매니저가 지원하는 속성이다. 별도의 설정이 없다면 REQUIRED로 트랜잭션이 진행된다.
NESTED는 진행중인 트랜잭션에 중첩(자식) 트랜잭션을 만드는 것으로, 독립적인 트랜잭션을 만드는 REQUIRES_NEW와 다르다. NESTED에 의한 중첩 트랜잭션은 부모 트랜잭션의 영향(커밋과 롤백)을 받지만, 중첩 트랜잭션이 외부에 영향을 주지는 않는다.
즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋이 가능하지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션은 함께 롤백되는 것이다. NESTED는 JDBC의 savepoint 기능을 사용하는데, DB 드라이버가 이를 지원하는지 확인이 필요하며 JPA에서 사용이 불가능하다.