JDBC로 DAO를 구현한 Springboot 프로젝트에서 트랜잭션이 어떻게 동작하는지 알아보고 적용해보려고 한다.
클래스나 메소드 위에 @Transactional
을 붙여 트랜잭션을 사용할 수 있다. Spring framework의 선언형 트랜잭션은 AOP 프록시 기반으로 동작한다. 실제 메소드가 호출되기 전 프록시가 먼저 실행되어 트랜잭션을 시작하고, 실제 메소드가 실행되고, 프록시에서 커밋 혹은 롤백한다. Reactor context를 제외하고 트랜잭션은 일반적으로 PlatformTransactionManager
에서 관리하며 thread-bound이다. 즉 현재 실행되는 thread가 종료되거나 트랜잭션이 종료될 때까지 트랜잭션이 해당 thread에 유지된다. 다만 메소드 안에서 새롭게 시작된 thread에는 전파되지 않는다.
ACID라는 데이터 안정성 원칙을 지키는 데에 사용된다. 트랜잭션이라는 원자 단위(Atomic)에서 모든 작업이 커밋되거나 롤백된다. 이 때 데이터베이스는 일관된(Consistent) 상태를 유지하고 서로 다른 트랜잭션은 격리(Isolate)된다. 격리는 잠금(locking)을 통해 이루어진다. 성능과 동시성의 트레이드 오프를 조율하며 격리 수준(Isolation level)이 결정된다. 커밋된 트랜잭션 동작은 영구적으로 저장된다. (Durable)
다시 Spring framework로 돌아와서, @Transaction
에 커스텀 롤백 규칙이 설정되지 않았다면 트랜잭션은 Unchecked Exception(RuntimeException)에서 롤백한다. 아래와 같이 예외가 발생한다면 db에 변경사항(save
결과)이 반영되지 않는다.
// MemberService.java
@Transactional
public void create(Member member) {
memberRepository.save(member);
throw new ViolationException("트랜잭션 안에서 예외가 발생했습니다.");
}
// MemberServiceTest.java
@Test
void rollback() {
try {
memberService.create(USER_MIA());
} catch (ViolationException e) {
}
assertThat(memberService.findAll()).hasSize(0);
}
@Transaction
의 rollbackFor()
, noRollbackFor()
등으로 롤백 규칙을 설정할 수 있다. Spring에서는 구체적인 패턴, 고유한 이름을 사용할 것을 권장한다.
위 예시에서는 그저 예외를 던졌지만 의도한 예외 혹은 예기치 못한 서버 문제로 발생한 예외가 있다면 create
메소드의 작업은 롤백될 것이다.
Propagation.REQUIRED
@Transaction
의 propagation
옵션의 기본 값은 Propagation.REQUIRED
이다. 처음 적용된 메소드부터 스레드 내의 call stack에 있는 모든 메소드들에 하나의 물리적 트랜잭션이 전파된다. 하지만 각 메소드들은 논리적 트랜잭션을 가져 롤백을 할 수 있다. 내부 트랜잭션에서 롤백되면 외부 트랜잭션 역시 롤백이다.
Propagation.REQUIRES_NEW
Propagation.REQUIRED
와 반대로 모든 메소드에 대해 독립적인 물리적 트랜잭션을 가진다.
Propagation.NESTED
하나의 물리적 트랜잭션이 여러 세이브 포인트를 가져 각 포인트까지만 롤백한다. 이 설정은 JDBC 세이브 포인트에 매핑되기 때문에 JDBC 리소스 트랜잭션에서만 동작한다.
readOnly
옵션은 기본 설정이 false여서 'Read write' 트랜잭션이 생성된다. true로 설정한다면 Read only
트랜잭션으로 생성된다. 항상 데이터베이스에 읽기 전용 트랜잭션을 강제하지 않는다. 그저 힌트로 작용할 뿐이고 읽기 전용 힌트를 해석하지 못하는 트랜잭션 매니저는 이를 무시한다. 예를 들어 h2 db를 사용한다면 org.h2.jdbc.JdbcConnection
에서 read only 옵션을 무시한다. 그렇지 않은 데이터베이스들은 각 설정에 맞게 읽기 전용 트랜잭션을 진행한다. DataSourceTransactionManager
에서 읽기 전용 트랜잭션을 사용하는 구문을 확인할 수 있다. 데이터베이스 단에서 읽기 전용 트랜잭션을 통해 최적화를 진행하는데, MySQL의 innoDB engine의 최적화는 이전 게시글에 간단히 정리해두었다.
다 적진 못했지만 격리 수준이나 타임아웃 설정도 할 수 있다.
보통 서비스 메소드에서 여러 레포지토리(혹은 DAO) 메소드들을 호출하면서 여러 sql statement를 실행한다. 트랜잭션 애너테이션을 붙이기 딱 좋은 위치인 것만 같다. 하지만 DB connection은 한정된 자원이다. 서비스 메소드에 오랜 시간이 걸리는 로직이 존재하고 하나의 트랜잭션으로 묶는다면 어떻게 될까?
트랜잭션의 개념을 공부하다 보니 트랜잭션 범위 설정을 어떻게 해야 할 지 고민을 잠깐 했고, 결론은 나지 않았지만 트랜잭션 범위를 똑바로 생각하지 않고 남용할 경우 발생할 수 있는 문제 상황 하나를 생각해보았다.
우선 JDBC connection pool을 만들어보자.
// application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/roomescape?createDatabaseIfNotExist=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username:
password:
hikari:
maximum-pool-size: 15
minimum-idle: 10
connection-timeout: 5000
서비스 메소드에서 트랜잭션을 시작하고, 오랜 시간이 걸리는 특별한 로직을 구현하기 귀찮으므로 thread sleep을 걸었다.
// MemberService.java
@Transactional(readOnly = true)
public Member findById(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new NotFoundException("해당 Id의 사용자가 없습니다."));
sleepAndPrintConnection();
return member;
}
private void sleepAndPrintConnection() {
int sleepDuration = 5000;
int interval = 1000;
int elapsedTime = 0;
while (elapsedTime < sleepDuration) {
try {
Thread.sleep(interval);
elapsedTime += interval;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
int activeConnections = dataSource.getHikariPoolMXBean().getActiveConnections();
int idleConnections = dataSource.getHikariPoolMXBean().getIdleConnections();
int totalConnections = dataSource.getHikariPoolMXBean().getTotalConnections();
System.out.printf("activeConnections:%d, IdleConnections:%d, TotalConnections:%d%n",
activeConnections, idleConnections, totalConnections);
}
}
실행 결과는 아래와 같다. 당연하게도 트랜잭션 내내 connection을 붙들고 있으므로 서비스의 sleepAndPrintConnection
에서 activeConnections가 1이다. 저 긴 시간을 잡아먹는 로직이 트랜잭션이 필요없다면? 레포지토리 메소드(혹은 저 로직을 제외한 서비스 메소드)에서 트랜잭션을 시작해보자.
// MemberRepository.java
@Transactional(readOnly = true)
Optional<Member> findById(Long id);
findById
가 끝나면 트랜잭션이 종료되기 때문에 나머지 로직이 도는 동안 트랜잭션이 필요 없고, activeConnections가 0인걸 확인할 수 있다. 실제로 오랜 시간이 걸리는 로직에 트랜잭션이 필요하지 않다면 범위를 다시 생각해야 할 필요가 있다. 외부 api와의 소통이라던가 (이메일 전송 등)
JDBC 단독으로 Springboot 프로젝트를 처음 구성해보니 영속성 컨텍스트 개념을 제외한 Spring의 트랜잭션을 제대로 공부해본 적 없다는 걸 깨달았다. 겸사겸사 DB 트랜잭션도 복습할 수 있어서 좋았다. 고민도 좀 하면서 재밌는 공부를 한 경험이었다.
[1]
[2]
[3]