트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해준다. 예를 들어 A의 5000원을 B에게 계좌이체한다고 가정해보자. 이 거래는 다음 2가지 작업으로 이루어져 있다.
A의 잔고를 5000원 감소
B의 잔고를 5000원 증가
계좌이체라는 거래는 이렇게 2가지 작업이 합쳐져서 하나의 작업처럼 동작해야 한다. 데이터베이스가 제공하는 트랜잭션 기능을 사용하면 1, 2 둘 다 함께 성공해야 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있다. 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋이라 하고, 작업 중 하나라도 실패해서 거래 전으로 되돌리는 것을 롤백이라 한다. 데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋 명령어인 commit
을 호출하고, 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback
을 호출하면 된다. 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다. 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않는다.
사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다. 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이때 데이터베이스 서버는 내부에 세션을 만든다. 그리고 앞으로 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
세션은 트랜잭션을 시작하고, (커밋 또는 롤백을 통해) 종료한다.
SQL을 전달하면 커넥션에 연결된 세션이 SQL을 실행한다.
사용자가 커넥션을 닫거나, DB관리자가 세션을 강제로 종료하면 세션은 종료된다. 만약 커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 생성된다.
commit
, rollback
을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 수동 커밋을 사용해야 한다.set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
commit
또는 rollback
을 호출해야 한다.set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋
만약 하나의 세션에서 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋이나 롤백을 하지 않았는데, 다른 세션에서 동시에 같은 데이터를 수정하면 문제가 발생한다. 데이터베이스는 이런 문제를 해결하기 위해 락(Lock)이라는 개념을 제공한다.
세션1이 트랜잭션을 시작하고 데이터 변경을 시도하려면, 우선 해당 로우의 락을 획득해야 한다. 락이 남아 있는 경우 세션1은 락을 획득하고 update 쿼리를 실행한다.
세션1이 아직 커밋이나 롤백을 하지 않은 상태에서, 세션2도 해당 데이터를 변경하려고 시도하는 경우, 아직 락이 없기 때문에 락이 돌아올 때까지 대기한다. 이때 설정한 락 대기 시간만큼 대기하고, 락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다.
세션1이 커밋이나 롤백을 하면 락은 다시 반납된다. 이때 대기하던 세션2는 락을 획득하고 update 쿼리를 실행할 수 있다.
데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않고도 데이터를 조회할 수 있다. 예를 들어, 세션1이 락을 획득하고 데이터를 변경하고 있어도, 세션2에서는 락을 획득하지 않도록 데이터를 조회할 수 있다.
그런데 데이터를 조회할 때도 락을 획득하고 싶을 때가 있다. 조회 시점에 락을 획득하고 싶다면 select for update
구문을 사용하면 된다. 이렇게 하면 세션1이 조회 시점에 락을 획득하고, 그동안 세션2는 락을 획득할 수 없으므로 해당 데이터 변경이 불가하다.
그렇다면 조회 시점에 락이 필요한 경우는 언제일까?
예를 들어서 애플리케이션 로직에서 어떤 회원의 주문 금액을 '조회'한 후 이 금액 정보로 어떤 계산을 수행하는 경우, 계산을 완료할 때까지 이 회원의 주문 금액을 다른 세션에서 변경하면 안된다. 이런 상황에서 select for update를 사용하려 조회 시점에 락을 획득하여, 다른 세션에서의 데이터 변경을 막을 수 있다.
각각의 데이터 접근 기술들은 트랜잭션을 처리하는 방식에 차이가 있다. 예를 들어 JDBC와 JPA는 트랜잭션을 사용하는 코드 자체가 다르다.
JDBC: con.setAutoCommit(false)
JPA : transaction.begin()
따라서 JDBC를 사용하다가 JPA로 변경할 경우, 트랜잭션을 사용하는 코드도 변경해야 하는 문제가 발생한다. 스프링은 이런 문제를 해결하기 위해 트랜잭션 추상화를 제공한다. 따라서 트랜잭션 추상화를 통해 어떤 데이터 접근 기술을 사용하든지 동일한 방식으로 트랜잭션을 수행할 수 있다.
스프링은 PlatformTransactionManager라는 인터페이스를 통해 트랜잭션을 추상화한다. 뿐만 아니라 스프링은 데이터 접근 기술에 따른 PlatformTransactionManager 인터페이스의 구현체도 제공한다. 스프링이 제공하는 '추상화된 트랜잭션(PlatformTransactionManager)'과 그 '구현체'를 트랜잭션 매니저라 한다.
트랜잭션 추상화 덕분에 개발자는 데이터 접근 기술에 따른 트랜잭션 관련 코드의 차이를 신경쓰지 않아도 된다. 필요한 구현체를 스프링 빈으로 등록하고 주입받아서, 어떤 데이터 접근 기술이든 간에 동일한 방식으로 트랜잭션을 적용할 수 있다. 그런데 스프링부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식하여 적절한 트랜잭션 매니저(구현체)를 스프링 빈으로 등록해준다. 따라서 개발자는 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다. 예를 들어 스프링부트는 JdbcTemplate, MyBatis를 사용하면 DataSourceTransactionManager(JdbcTransactionManager)를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링 빈으로 등록한다.
트랜잭션 추상화 도입 전에는, 데이터 접근 기술을 변경하면 서비스 계층의 코드도 수정해야 한다.
트랜잭션 추상화를 도입하면, 서비스는 특정 트랜잭션 기술에 직접 의존하는 것이 아니라, 추상회된 인터페이스에 의존한다. 그리고 원하는 구현체를 DI를 통해 주입하면 된다.(스프링 부트가 적절한 구현체를 자동으로 주입해준다.) 따라서 서비스 계층의 코드를 수정하지 않고도 트랜잭션 기술을 변경할 수 있다.
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스이다. 트랜잭션은 트랜잭션 시작, 커밋, 롤백으로 단순하게 추상화할 수 있다.
getTransaction()
: 트랜잭션을 시작한다. 이미 진행중인 트랜잭션이 있는 경우에 해당 트랜잭션에 참여할 수도 있다.
commit()
: 트랜잭션을 커밋한다.
rollback()
: 트랜잭션을 롤백한다.
같은 트랜잭션을 유지하려면 같은 데이터베이스 커넥션을 사용해야 한다. 이를 위해 트랜잭션 매니저는 내부에서 트랜잭션 동기화 매니저를 사용한다. 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용해서 커넥션을 동기화한다. 즉 트랜잭션을 처리하는 동안 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하여 동일한 커넥션을 유지하고 결과적으로 같은 트랜잭션을 유지할 수 있다.
동작 방식은 다음과 같다.
트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
리포지토리는 데이터소스를 통해 커넥션을 획득하는 것이 아닌, 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
데이터소스에서 직접 커넥션을 획득하는 방식(dataSource.getConnection()
)
: 해당 코드가 실행될 때마다 새로운 커넥션을 획득하게 되어, 커넥션이 동기화 되지 않는 문제가 발생한다.
트랜잭션 매니저를 통해 커넥션을 획득하는 방식
: 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환하고, 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면 새로운 커넥션을 생성해서 반환한다. 따라서 커넥션 동기화 문제를 해결할 수 있다.
트랜잭션이 종료되면, 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.
PlatformTransactionManager를 사용하는 방식으로 다음 두가지가 있다.
프로그래밍 방식 트랜잭션 관리
: 트랜잭션 관련 코드를 직접 작성하는 방식이다. 이때 트랜잭션 템플릿 등을 사용할 수 있다.
선언적 트랜잭션 관리
: @Transactional 어노테이션 하나만 선언해서 편리하게 트랜잭션을 적용하는 방식이다.
@Transactional 어노테이션을 사용하지 않고, 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다. 이때 트랜잭션 템플릿 등을 사용할 수 있다.
프로그래밍 방식으로 트랜잭션을 사용하는 코드를 살펴보면, 다음과 같이 같은 패턴이 반복되는 것을 볼 수 있다.
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
트랜잭션을 시작하고, 성공하면 커밋, 예외가 발생하면 롤백한다. 트랜잭션이 필요한 서비스 계층마다 이런 패턴이 반복되고, 달라지는 부분은 비즈니스 로직 뿐이다. 이런 경우 템플릿 콜백 패턴을 활용하면 반복 문제를 해결할 수 있다.
템플릿 콜백 패턴을 적용하기 위해, 스프링은 TransactionTemplate이라는 템플릿 클래스를 제공한다.
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..} //응답 값이 있을 때 사용
void executeWithoutResult(Consumer<TransactionStatus> action){..} //응답 값이 없을 때 사용
}
트랜잭션 템플릿을 사용하면 다음과 같이 반복되는 문제를 해결할 수 있다.
public class MemberService {
private final TransactionTemplate txTemplate; //트랜잭션 템플릿
private final MemberRepositoryV3 memberRepository;
public MemberService(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(fromId, toId, money); //비즈니스 로직
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
}
TransactionTemplate을 사용하기 위해 생성자에서 transactionManager를 주입 받는다.
트랜잭션 템플릿 덕분에 트랜잭션을 시작하고 커밋, 롤백하는 코드가 제거되었다.
트랜잭션 템플릿은 비즈니스 로직이 정상 수행되면 커밋한다. 반면 언체크 예외가 발생하면 롤백한다. (*주의: 체크 예외가 발생하면 커밋)
만약 비즈니스 로직에서 체크 예외가 발생한 경우, 언체크 예외로 바꾸어 예외를 던진다.
트랜잭션 템플릿 덕분에 트랜잭션을 사용할 때 반복되는 코드를 제거할 수 있었다. 하지만 여전히 서비스 계층에 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 있는 문제가 있다. 서비스 계층에는 가급적 핵심 비즈니스 로직만 있어야 한다. 하지만 트랜잭션을 사용하려면 어쩔 수 없이 트랜잭션 관련 코드가 나오게 되는데, 이 문제를 트랜잭션 AOP를 통해 해결할 수 있다.
트랜잭션 템플릿 -> 트랜잭션과 관련된 반복되는 코드 제거
트랜잭션 AOP -> 트랜잭션 코드와 서비스 계층의 비즈니스 로직 코드의 분리
프록시 도입 전에는 서비스 계층에서 트랜잭션을 직접 시작하고 종료한다. 따라서 서비스 계층에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.
프록시 도입 후에는 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 프록시 덕분에 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있으며, 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있다.
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
public class TransactionProxy {
private MemberService target;
public void logic() {
TransactionStatus status = transactionManager.getTransaction(...); //트랜잭션 시작
try {
target.logic(); //서비스 로직 호출
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
스프링은 트랜잭션 AOP를 처리하기 위한 @Transactional 어노테이션을 제공한다. 그리고 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들(ex, 어드바이저, 포인트컷, 어드바이스)도 자동 등록해준다. 따라서 트랜잭션 처리가 필요한 곳에 @Transactional 어노테이션만 붙여주면 된다. 스프링은 이 어노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
이처럼 @Transactional 어노테이션 하나만 사용하여 트랜잭션을 편리하게 적용하는 방식을 선언적 트랜잭션 관리라 한다.
@Slf4j
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
프록시 도입 후 흐름은 다음과 같다.
클라이언트(테스트 케이스, 컨트롤러 등)가 프록시를 호출한다.
스프링 컨테이너를 통해 트랜잭션 매니저를 획득하고, 이 트랜잭션 매니저가 트랜잭션을 시작(transactionManager.getTransaction()
)한다.
트랜잭션 매니저는 데이터소스를 사용해서 커넥션을 생성한다.
커넥션을 수동 커밋 모드로 변경(con.setAutoCommit(false)
)해서 트랜잭션을 시작한다.
커넥션을 트랜잭션 동기화 매니저에 보관한다.
서비스는 비즈니스 로직을 실행하면서 리포지토리의 메소드들을 호출한다.
리포지토리 메소드들은 트랜잭션이 시작된 커넥션(동기화 된 커넥션)이 필요한데, 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 즉 같은 커넥션을 사용하기 때문에 트랜잭션을 유지할 수 있게 된다.
@Transactional 어노테이션이 특정 클래스나 메소드 레벨에 하나라도 있으면, 해당 객체는 트랜잭션 AOP의 대상이 되고, 실제 객체가 아닌 프록시 객체가 스프링 빈으로 등록되고, 주입받을 때도 이 프록시 객체가 주입된다. (* 프록시 객체는 실제 객체를 상속받아서 만들어지기 때문에, BasicService
형 참조변수에 BasicService$$SpringCGLIB
를 주입할 수 있다.)
참고: 로그 추가
application.properties
에
logging.level.org.springframework.transaction.interceptor=TRACE
를 추가하면 트랜잭션 프록시가 호출하는 트랜잭션의 시작과 종료를 로그로 확인할 수 있다.
@Slf4j
@SpringBootTest
public class TxTest {
@Autowired
BasicService basicService;
@Test
void proxyCheck() {
log.info("aop class = {}", basicService.getClass());
Assertions.assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@Test
void txTest() {
basicService.tx();
basicService.nonTx();
}
@TestConfiguration
static class TxTestConfig {
@Bean
BasicService basicService() {
return new BasicService();
}
}
static class BasicService {
@Transactional
public void tx() {
log.info("call tx()");
//현재 쓰레드에 트랜잭션이 적용되어 있는지 확인 (트랜잭션이 적용되어 있으면 true)
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
public void nonTx() {
log.info("call nonTx()");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
}
}
BasicService$$SpringCGLIB...
이다. 즉 실제 객체가 아닌 프록시 객체가 스프링 빈으로 등록되고 주입되었다.basicService.tx()
호출 흐름
클라이언트가 basicService.tx()를 호출하면 프록시의 tx()가 호출된다. 이때 프록시는 tx() 메소드가 트랜잭션 적용 대상인지 확인한다. tx() 메소드는 @Transactional 어노테이션이 붙어있으므로 트랜잭션 적용 대상이다.
따라서 프록시는 트랜잭션을 시작하고, 실제 basicService.tx()를 호출한다.
실제 basicService.tx()의 호출이 끝나서 프록시로 제어(리턴)가 돌아오면, 프록시는 커밋하거나 롤백해서 트랜잭션을 종료한다.
basicService.nonTx()
호출 흐름
클라이언트가 basicService.nonTx()를 호출하면 프록시의 nonTx()가 호출된다. 이때 프록시는 nonTx() 메소드가 트랜잭션 적용 대상인지 확인한다. nonTx()는 @Transactional이 없으므로 트랜잭션 적용 대상이 아니다.
따라서 프록시는 트랜잭션을 시작하지 않고, 실제 basicService.nonTx()를 호출한다.
@Transactional를 클래스에 적용하면 클래스 내의 모든 메소드에 자동 적용된다.
그리고 트랜잭션을 사용할 때 다양한 옵션을 사용할 수 있는데, 더 구체적인 것이 우선순위가 높다.
예를 들어 클래스 레벨에 @Transactional(readOnly = true)
를 붙이면 모든 메소드에 해당 옵션이 적용된다. 그런데 특정 메소드에만 메소드 레벨에 @Transactional(readOnly = false)
를 붙이면, 해당 메소드에 대해서만 false로 옵션이 적용된다. 참고로 readOnly = false는 기본 옵션이기 때문에 @Transactional(readOnly = false)
대신 @Transactional
로 적어도 된다.
인터페이스에도 @Transactional을 적용할 수 있는데, 이 경우 다음 순서로 적용된다. 즉 구체적인 것이 더 높은 우선순위를 갖는다.
프록시는 클래스의 메소드를 우선으로 @Transactional 어노테이션을 찾고, 없으면 클래스 타입에서 찾는다. 그래도 없으면 인터페이스의 메소드, 인터페이스 타입 순으로 @Transactional 어노테이션을 찾는다.
@Transactional을 적용하면 프록시 객체가 생성되어 스프링 빈으로 등록되고, 클라이언트 요청 시 프록시 객체가 먼저 요청을 받아서 트랜잭션을 처리하고 실제 객체를 호출해준다. 즉 트랜잭션을 적용하려면 항상 프록시를 통해서 실제 객체를 호출해야 한다. 만약 프록시를 거치지 않고 실제 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.
@Transactional을 적용하면 스프링은 실제 객체 대신에 프록시 객체를 스프링 빈으로 등록하고, 의존관계 주입시 항상 실제 객체가 아닌 프록시 객체를 주입한다. 따라서 실제 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 실제 객체의 내부에서 메소드 호출이 발생하면, 프록시를 거치지 않고 실제 객체의 메소드를 호출하기 때문에 @Transactional
이 있어도 트랜잭션이 적용되지 않는 문제가 발생한다.
@Slf4j
@SpringBootTest
public class InternalCallTest {
@Autowired
CallService callService;
@Test
void internalCall() {
callService.internal();
}
@Test
void externalCall() {
callService.external();
}
@TestConfiguration
static class InternalCallTestConfig {
@Bean
CallService callService() {
return new CallService();
}
}
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
internal()에는 트랜잭션을 적용하고, external()에는 트랜잭션을 적용하지 않는다.
@Transactional이 클래스나 메소드에 하나라도 있으면, 해당 객체를 대상으로 프록시 객체가 생성된다.
따라서 callService에는 프록시 객체가 주입된다.
클라이언트(테스트 코드)는 callService.internal()을 호출한다.
callService의 프록시 객체가 호출된다.
internal()에 @Transactional이 적용되어 있으므로, 프록시는 트랜잭션을 시작하고 실제 객체의 internal()을 호출한다.
실제 객체가 처리를 완료하면, 응답이 트랜잭션 프록시로 돌아오고 트랜잭션 프록시는 트랜잭션을 커밋 혹은 롤백하여 종료한다.
클라이언트(테스트 코드)는 callService.external()을 호출한다.
callService의 프록시 객체가 호출된다.
external()에 @Transactional이 없으므로, 트랜잭션 프록시는 트랜잭션을 시작하지 않고 실제 객체의 external()을 호출한다.
extenal()은 내부에서 internal()을 호출한다. 이러한 내부 호출은 트랜잭션 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않는다. 즉 internal() 메소드이 @Transactional이 붙어있어도 트랜잭션이 적용되지 않는 문제가 발생한다.
@Slf4j
@SpringBootTest
public class InternalCallTest {
@Autowired
CallService callService;
@Test
void externalCall() {
callService.external();
}
@TestConfiguration
static class InternalCallTestConfig {
@Bean
CallService callService() {
return new CallService(internalService());
}
@Bean
InternalService internalService() {
return new InternalService();
}
}
@RequiredArgsConstructor
static class CallService {
private final InternalService internalService;
public void external() {
log.info("call external");
printTxInfo();
internalService.internal();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
internal() 메소드를 별도의 클래스로 분리하여, 메소드 내부 호출을 외부 호출로 변경했다.
CallService는 트랜잭션 프록시가 적용되지 않는다.
InternalService는 트랜잭션 프록시가 적용된다.
클라이언트(테스트 코드)는 callService.external()을 호출한다.
실제 객체인 callService의 external() 메소드가 호출된다.
callService는 의존관계를 주입 받은 internalService의 internal() 메소드를 호출한다. (이때 internalService는 프록시 객체이다.)
internalService는 internal() 메소드에 @Transactional이 적용되어 있으므로, 트랜잭션을 시작하고, 실제 객체의 internal() 메소드를 호출한다.
스프링은 public 메소드에만 트랜잭션이 적용되도록 설정해두었다. 그래서 protected, private, package-visible에는 @Transactional을 붙여도 트랜잭션이 적용되지 않는다. 이때 예외는 발생하지 않고 트랜잭션 적용만 무시된다.
그렇다면 스프링이 public 메소드에만 트랜잭션이 적용되도록 한 이유는 무엇일까? 예를 들어, 클래스 레벨에 @Transactional 어노테이션을 붙이면 클래스 내의 모든 메소드에 트랜잭션이 적용될 수 있다. 그러면 트랜잭션을 의도하지 않은 곳까지 트랜잭션이 과도하게 적용된다. 주로 트랜잭션은 비즈니스 로직의 시작점에 걸기 때문에 대부분 외부에 열어준 곳을 시작점으로 사용한다. 이런 이유로 public 메소드에만 트랜잭션이 적용되도록 설정되어 있다.
초기화 코드(@PostConstruct)가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용된다. 따라서 메소드에 @Postconstruct와 @Transactional을 함께 붙이면, 해당 메소드는 트랜잭션이 적용되지 않는다.
@EventListener(value = ApplicationReadyEvent.class)
를 사용하면, 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성된 후에 이벤트가 붙은 메소드가 호출된다.
@SpringBootTest
@Slf4j
public class InitTxTest {
@Autowired
Hello hello;
@Test
void go() {
//초기화 코드는 스프링 초기화 시점에 자동 호출된다.
}
@TestConfiguration
static class InitTxTestConfig {
@Bean
Hello hello() {
return new Hello();
}
}
static class Hello {
@PostConstruct
@Transactional
public void initV1() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init PostConstruct tx active={}", isActive);
}
@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void initV2() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}", isActive);
}
}
}
초기화 코드인 @PostConstruct가 붙은 initV1() 메소드가 호출된다. 이때 initV1() 메소드에는 트랜잭션이 적용되지 않는다.
스프링 AOP를 포함한 스프링 컨테이너가 완전히 생성된 후에 initV2() 메소드가 호출된다. 이 경우에는 initV2() 메소드에 트랜잭션이 적용된다.
트랜잭션은 트랜잭션 매니저를 통해서 이루어진다. 프로그래밍 방식의 트랜잭션 관리의 경우, 다음과 같은 코드를 통해 트랜잭션 매니저를 주입받아서 사용한다.
@RequiredArgsConstructor
public class BasicService {
private final PlatformTransactionManager transactionManager;
public void serviceLogic() {
TransactionStatus status = transactionManager.getTransaction();
try {
bizLogic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
@Transactional을 사용할 때도 트랜잭션 프록시가 사용할 트랜잭션 매니저를 지정해주어야 한다. 어떤 트랜잭션 매니저를 사용할지 value, transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다.
이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 상황에 맞는 적절한 트랜잭션 매니저를 주입받아서 사용해야 한다.
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
예외 발생시 스프링 트랜잭션의 기본 정책은 다음과 같다.
언체크 예외인 RuntimeException, Error와 그 하위 예외가 발생하면 롤백
체크 예외인 Exception과 그 하위 예외는 커밋
rollbackFor 옵션을 사용하면 어떤 예외가 발생했을 때 롤백할지 지정할 수 있다. 예를 들어 다음과 같이 지정하면 체크 예외인 Exception이 발생해도 롤백한다.
@Transactional(rollbasckFor = Exception.class)
rollbackFor은 예외 클래스를 직접 지정하면 되고, 이와 유사한 rollbackForClassName이 있는데, 이는 예외 이름을 문자로 넣으면 된다.
@Transactional(rollbackForClassName = "Exception")
rollbackFor과 반대로, 어떤 예외가 발생했을 때 롤백하면 안되는지 지정할 수 있다. 예외 클래스가 아닌 예외 이름을 문자로 넣을 수 있는 noRollbackForClassName도 있다.
트랜잭션 격리 수준을 지정할 수 있다. 기본 값은 데이터베이스에서 설정한 기준을 따르는 DEFAULT이다. 대부분 데이터베이스에서 설정한 기준을 따르며 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다.
DEFAULT : 데이터베이스에서 설정한 격리 수준을 따른다.
READ_UNCOMMITTED : 커밋되지 않은 읽기
READ_COMMITTED : 커밋된 읽기
REPEATABLE_READ : 반복 가능한 읽기
SERIALIZABLE : 직렬화 가능
트랜잭션은 기본적으로 읽기, 쓰기가 모두 가능한 트랜잭션이 생성된다. readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제는 안되고 읽기만 가능하다.
그리고 readOnly 옵션을 사용하면 다양한 성능 최정화가 발생할 수 있다. 예를 들어 JPA의 경우, 읽기 전용 트랜잭션의 경우 읽기만 수행하기 때문에 변경에 사용되는 플러시를 호출할 필요가 없다. 따라서 커밋 시점에 플러시를 호출하지 않는다. 그리고 변경 감지를 위한 스냅샷 객체도 생성하지 않는다.