본 포스팅은 [인프런] 스프링 DB 2편 - 데이터 접근 활용 기술 강의를 바탕으로 공부하고 정리한 글입니다.
스프링 트랜잭션
스프링 트랜잭션 사용 방식
1. 선언적 트랜잭션 관리
- @Transactional 애노테이션을 선언해 트랜잭션을 적용
- 해당 로직에 트랜잭션을 적용하겠다고 선언하기만 하면 트랜잭션이 적용되는 방식
2. 프로그래밍 방식 트랜잭션 관리
- 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해 트랜잭션 관련 코드를 직접 작성하는 방식
프로그래밍 방식의 트랜잭션 관리를 사용하면, 애플리케이션 코드가 트랜잭션이라는 기술 코드와 강하게 결합되기 때문에
실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.
스프링이 제공하는 트랜잭션 AOP
스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
따라서 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙이면 된다.
스프링의 트랜잭션 AOP는 이 애노테이션을 인식해 트랜잭션을 처리하는 프록시를 적용해준다.
👉🏻 프록시 도입 전
public class Service {
public void logic() {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 비즈니스 로직
bizLogin(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);
}
}
트랜잭션을 처리하기 위한 프록시를 적용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
예시 코드를 보면 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져가 트랜잭션을 시작한 후에 실제 서비스를 대신 호출하는 것을 확인 할 수 있다.
👉🏻 프록시 도입 후 전체 과정
- 트랜잭션은 커넥션에 con.setAutocommit(false)를 지정하면서 시작한다.
- 같은 트랜잭션을 유지하려면 같은 데이터베이스 커넥션을 사용해야 한다.
- 이를 위해 스프링 내부에서는 트랜잭션 동기화 매니저가 사용된다.
- JdbcTemplate을 포함한 대부분의 데이터 접근 기술들은 트랜잭션을 유지하기 위해 내부에서 트랜잭션 동기화 매니저를 통해 리소스(커넥션)를 동기화 한다.
트랜잭션 적용
public 메서드만 트랜잭션 적용
스프링의 트랜잭션 AOP 기능은 기본적으로 public 메서드에만 트랜잭션을 적용한다.
public이 아닌 곳은 트랜잭션 적용을 무시한다.
클래스 레벨에 트랜잭션을 적용하는 경우가 있는데, 그러면 의도하지 않는 곳까지 과도하게 트랜잭션이 적용될 수있다.
트랜잭션은 주로 비즈니스 로직의 시작점에 걸기 때문에 외부에 열어준 곳을 시작점으로 사용한다. 이러한 이유로 스프링은 public 메서드에만 트랜잭션을 적용하도록 막아두었다.
@Transactional
public class TransactionalTest {
public method1(); // 트랜잭션 적용 o
method2(); // 트랜잭션 적용 x
protectied method3(); // 트랜잭션 적용 x
privatemethod4(); // 트랜잭션 적용 x
}
트랜잭션 적용 확인하기
트랜잭션은 AOP를 기반으로 동작하기 때문에 실제로 트랜잭션이 적용되고 있는지 아닌지 확인하기가 어렵다.
스프링 트랜잭션이 실제 적용되고 있는지 확인하는 방법에 대해 알아보자.
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired
BasicService basicService;
@Test
void proxyCheck() {
log.info("aop class = {}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@Test
void txCheck() {
basicService.tx();
basicService.nonTx();
}
@TestConfiguration
static class TxApplyBasicConfig {
@Bean
BasicService basicService() {
return new BasicService();
}
}
static class BasicService {
@Transactional
public void tx() {
log.info("call tx");
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);
}
}
}
AopUtils.isAopProxy()
- @Transactional을 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 실제 객체(basicService)를 대상으로 프록시 객체(basicService...$$CGLIB)를 만들어 스프링 빈에 등록한다.
- 주입을 받을 때도 실제 객체 대신에 프록시 객체가 주입된다. (프록시는 실제 객체를 상속해 만들어지기 때문에 다형성 활용 가능)
실제 주입받은 basicService의 클래스 이름을 출력해보면 프록시 클래스의 이름이 출력되는 것을 확인할 수 있다.
TransactionSynchronizationManager.isActualTransactionActive()
- 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다.
- 결과가 ture면 트랜잭션이 적용되어 있는 것으로, 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다.
트랜잭션 로그 추가
해당 로그를 추가하면 트랜잭션 프록시가 호출하는 트랜잭션의 시작과 종료를 명확하게 로그로 확인할 수 있다.
📁 application.propterties
logging.level.org.springframework.transaction.interceptor=TRACE
- Getting transaction → "트랜잭션을 시작했다"
- Completing transaction → "트랜잭션을 완료했다"
트랜잭션 적용 위치에 따른 우선순위
@Transactional의 적용 위치에 따라 우선순위는 어떻게 달라질까?
스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다는 것만 기억하면 된다.
@Transactional도 마찬가지로 예를 들어 메서드와 클래스에 애노테이션을 붙일 수 있다면, 더 구체적인 메서드가 더 높은 우선순위를 가진다.
@SpringBootTest
public class TxLevelTest {
@Autowired
LevelService service;
@Test
void orderTest() {
service.write();
service.read();
}
@TestConfiguration
static class TxLevelTestConfig {
@Bean
LevelService levelService() {
return new LevelService();
}
}
@Slf4j
@Transactional(readOnly = true) // 읽기 전용 트랜잭션
static class LevelService {
@Transactional(readOnly = false)
public void write() {
log.info("call write");
printTxInfo();
}
public void read() {
log.info("call read");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("tx readOnly = {}", readOnly);
}
}
}
트랜잭션 AOP 주의사항
프록시 내부 호출 문제
@Transactinal을 사용하는 트랜잭션 AOP는 프록시를 사용한다.
이러한 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다는 문제가 발생한다.
@Slf4j
@SpringBootTest
public class InternalCallTest {
@Autowired
CallService callService;
@Test
void externalCall() {
callService.external();
}
@TestConfiguration
static class InternalCallTestConfig {
@Bean
CallService callService() {
return new CallService();
}
}
@Slf4j
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);
}
}
}
이런 경우 트랜잭션을 사용하는 메서드를 별도의 클래스로 분리해 문제를 해결할 수 있다.
@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();
}
}
@Slf4j
@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);
}
}
}
초기화 시점 문제
초기화 코드에 @PostConstruct와 @Transactional을 함께 사용하면 트랜잭션이 적용되지 않는 문제가 발생한다.
이러한 문제가 발생하는 이유는 초기화 코드가 먼저 호출되고, 그 이후에 트랜잭션 AOP가 적용되기 때문에 초기화 시점에는 트랜잭션을 획득할 수가 없는 것이다.
@SpringBootTest
public class InitTxTest {
@Autowired Hello hello;
@Test
void go() {
// 초기화 코드는 스프링이 초기화 시점에 호출
}
@TestConfiguration
static class InitTxTestConfig {
@Bean
Hello hello() {
return new Hello();
}
}
@Slf4j
static class Hello {
@PostConstruct
@Transactional
public void init() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active = {}", isActive);
}
}
Hello init @PostConstruct tx active = false
이런 경우 @EventListener(ApplicationReadyEvent.class)를 사용해 문제를 해결할 수 있다.
ApplicationReadyEvent를 사용하면 트랜잭션 AOP를 포함한 스프링이 컨테이너가 완전히 생성된 이후에 이벤트가 붙은 메서드를 호출하기 때문에 트랜잭션이 적용된다.
@Slf4j
static class Hello {
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void init() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active = {}", isActive);
}
}
Getting transaction for [hello.springtx.apply.InitTxTest$Hello.init]
Hello init ApplicationReadyEvent tx active = true
Completing transaction for [hello.springtx.apply.InitTxTest$Hello.init]
트랜잭션 옵션
value (트랜잭션 매니저 지정)
사용할 트랜잭션 매니저를 지정하려면 value 또는 transactionManager 옵션을 사용해 트랜잭션 매니저의 스프링 빈 이름을 적어주면 된다.
이 값을 생략할 경우 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략하지만, 사용하는 트랜잭션 매니저가 둘 이상이라면 해당 옵션으로 트랜잭션 매니저의 이름을 지정해 구분해줘야 한다.
만약 애노테이션의 속성이 하나일 경우 다음과 같이 value는 생략이 가능하다.
public class TxService {
@Transactional(value="aTxManager")
public void a() {...}
@Transactional("bTxManager")
public void b() {...}
}
rollbackFor (예외 롤백 지정)
예외 발생 시 스프링 트랜잭션의 기본 정책은 다음과 같다.
- 언체크 예외(RuntimeException, Error) 발생 : 롤백
- 체크 예외(Exception) 발생 : 커밋
rollbackFor 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정할 수 있다.
// 체크 예외인 Exception이 발생해도 롤백 진행
@Transactional(rollbackFor = Exception.class)
반대로 noRollbackFor 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백하면 안되는지 지정할 수 있다.
// 언체크 예외인 RuntimeException 발생해도 롤백하지 않음
@Transactional(noRollbackFor = RuntimeException.class)
체크 예외 vs 언체크(런타임) 예외 더 알아보기
Isolation (격리 수준 지정)
Isolation 옵션을 사용해 트랜잭션 격리 수준을 지정할 수 있다.
기본 값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 DEFAULT이며, 대부분은 이 경우를 사용한다.
(애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드뭄)
격리 수준 | 설명 |
DEFAULT | 데이터베이스 설정에 따름 |
READ_UNCOMMITTED | 커밋되지 않은 읽기 |
READ_COMMITTED | 커밋된 읽기 |
REPEATABLE_READ | 반복 가능한 읽기 |
SERIALIZABLE | 직렬화 가능 |
timeout
트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정할 수 있다.
기본 값은 트랜잭션 시스템의 타임아웃을 사용하며, 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 반드시 확인 후 사용해야 한다.
timeoutString을 사용해 숫자 대신 문자 값으로 지정하는 것도 가능하다.
readOnly
트랜잭션은 기본적으로 읽기와 쓰기가 모두 가능한 트랜잭션이 생성되는데, readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다.
이 경우 등록, 수정, 삭제가 안되고 오직 읽기 기능만 동작하며 읽기에서 다양한 성능 최적화가 발생할 수 있다.
readOnly 옵션은 크게 3곳에 적용된다.
- 프레임워크
- JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던짐
- JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 플러시를 호출하지 않음. 추가로 변경이 필요 없기 때문에 변경 감지를 위한 스냅샷 객체도 생성하지 않음. → 다양한 최적화 발생
- JDBC 드라이버
- DB와 드라이버 버전에 따라 다르게 동작하기 때문에 사전에 확인 필요
- 읽기 전용 트랜잭션에서 변경 쿼리 발생 시 예외를 던짐
- 읽기, 쓰기 베이터베이스를 구분하여 요청 (읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득하여 사용)
- 데이터베이스
- 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되기 때문에 내부에서 성능 최적화 발생
propagation (트랜잭션 전파)
'🌱 Spring > DB 접근 기술' 카테고리의 다른 글
트랜잭션 전파 (0) | 2023.06.07 |
---|---|
데이터 접근 기술 (SQL Mapper 기술 - JdbcTemplate, MyBatis) (0) | 2023.03.10 |
커넥션 풀(Connection Pool) (0) | 2023.03.02 |