참고자료
스프링 트랜잭션 추상화
각각의 데이터 접근 기술들은 트랜잭션을 처리하는 방식에 차이가 있다. 예를 들어 JDBC 기술과 JPA 기술은 트랜잭션을 사용하는 코드 자체가 다르다.
JDBC 트랜잭션 코드 예시
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); //트랜잭션 시작 //비즈니스 로직
bizLogic(con, fromId, toId, money);
con.commit(); //성공시 커밋
} catch (Exception e) {
con.rollback(); //실패시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
JPA 트랜잭션 코드 예시
public static void main(String[] args) {
//엔티티 매니저 팩토리 생성
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");
EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성
EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득
try {
tx.begin(); //트랜잭션 시작 logic(em); //비즈니스 로직 tx.commit();//트랜잭션 커밋
} catch (Exception e) {
tx.rollback(); //트랜잭션 롤백
} finally {
em.close(); //엔티티 매니저 종료
}
emf.close(); //엔티티 매니저 팩토리 종료
}
JDBC 기술을 사용하다가 JPA 기술로 변경하게 되면 트랜잭션을 사용하는 코드도 모두 함께 변경해야 한다. 스프링은 이런 문제를 해결하기 위해 트랜잭션 추상화를 제공한다. 트랜잭션을 사용하는 입장에서는 스프링 트랜잭션 추상화를 통해 둘을 동일한 방식으로 사용할 수 있게 되는 것이다. 스프링은 PlatformTransactionManager라는 인터페이스를 통해 트랜잭션을 추상화한다.
- 스프링은 트랜잭션을 추상화해서 제공할 뿐만 아니라, 실무에서 주로 사용하는 데이터 접근 기술에 대한 트랜잭션 매니저의 구현체도 제공한다. 우리는 필요한 구현체를 스프링 빈으로 등록하고 주입 받아서 사용하기만 하면 된다.
- 그런데, 그럴 필요도 없다. 스프링 부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록해주기 때문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다. 예를 들어서 JdbcTemplate, Mybatis를 사용하면 DataSourceTransactionManager를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링 빈으로 등록해준다.
스프링 트랜잭션 사용 방식
PlatformTransactionManager를 사용하는 방법은 크게 2가지가 있다.
- 선언적 트랜잭션 관리
- @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
- 프로그래밍 방식의 트랜잭션 관리
- 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
- 이 방식의 문제는 애플리케이션 코드가 트랜잭션이라는 기술 코드와 강하게 결합된다는 점이다. 쉽게 말해 서비스 코드 안에 트랜잭션 처리 코드가 같이 있다는 말이다.
선언적 트랜잭션과 AOP
@Transactional을 통한 선언적 트랜잭션 관리 방식을 사용하게 되면 기본적으로 프록시 방식의 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 void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
- 위의 프로그래밍 방식의 트랜잭션 관리랑 비교해보면 정말 순수하고 깔끔한 코드이다.
- 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간 상태이다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출해준다.
프록시 도입 후 전체 과정
- 트랜잭션은 커넥션에 con.setAutoCommit(false)를 지정하면서 시작한다.
- 같은 트랜잭션을 유지하려면 같은 데이터베이스 커넥션을 사용해야 한다. 이것을 위해 스프링 내부에서는 트랜잭션 동기화 매니저가 사용된다.
- JdbcTemplate을 포함한 대부분의 데이터 접근 기술들은 트랜잭션을 유지하기 위해 내부에서 트랜잭션 동기화 매니저를 통해 리소스(커넥션)를 동기화한다.
- 스프링의 트랜잭션은 매우 중요한 기능이고, 전세계 누구나 사용하는 기능이다. 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
- 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션을 처리하는 프록시를 적용해준다.
트랜잭션 적용 확인
실제로 저렇게 @Transactional 애노테이션만 붙여주면 정말 트랜잭션이 적용되는 걸까? 눈으로 봐야만 확신할 수 있는 우리는 직접 확인해봐야겠다. 그러기 위해 다음과 같은 테스트 코드를 준비해보자.
package cwchoiit.tx.apply;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired
BasicService basicService;
@Test
void proxyCheck() {
log.info("aop class = {}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@Test
void txTest() {
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");
log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
}
public void nonTx() {
log.info("call Non Tx");
log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
}
}
}
proxyCheck() 실행
- AopUtils.isAopProxy()는 선언적 트랜잭션 방식에서 스프링 트랜잭션은 AOP를 기반으로 동작한다. @Transactional을 메서드나 클래스에 붙이면 해당 객체는 트랜잭션 AOP 적용 대상이 되고, 결과적으로 실제 객체 대신에 트랜잭션을 처리해주는 프록시 객체가 스프링 빈에 등록된다. 그리고 주입을 받을 때도 실제 객체 대신에 프록시 객체가 주입된다.
- 클래스 이름을 출력해보면, 아래와 같은 이름의 프록시 클래스가 출력된다.
aop class = class cwchoiit.tx.apply.TxBasicTest$BasicService$$SpringCGLIB$$0
- 근데 어떻게 프록시가 주입될까?
스프링 컨테이너에 트랜잭션 프록시 등록
- @Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 그리고 실제 basicService 객체 대신에 프록시인 BasicService$$SpringCGLIB를 스프링 빈에 등록한다. 그리고 프록시는 내부에 실제 BasicService를 참조하게 된다. 여기서 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다.
- 클라이언트인 txBasicTest는 스프링 컨테이너에 BasicService로 의존관계 주입을 요청하는데 스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입한다. 타입은 BasicService이고 인스턴스가 프록시일 뿐이다.
- 왜냐하면 프록시는 BasicService를 상속해서 만들어지기 때문에 다형성을 활용할 수 있기 때문이다. 따라서 BasicService 대신에 프록시인 BasicService$$SpringCGLIB를 주입받을 수 있다.
txTest() 실행
TransactionSynchronizationManager.isActualTransactionActive()는 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 true면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다.
2024-12-08T18:22:49.688+09:00 INFO 18734 --- [ Test worker] cwchoiit.tx.apply.TxBasicTest : call Tx
2024-12-08T18:22:49.688+09:00 INFO 18734 --- [ Test worker] cwchoiit.tx.apply.TxBasicTest : tx active = true
2024-12-08T18:22:49.690+09:00 INFO 18734 --- [ Test worker] cwchoiit.tx.apply.TxBasicTest : call Non Tx
2024-12-08T18:22:49.690+09:00 INFO 18734 --- [ Test worker] cwchoiit.tx.apply.TxBasicTest : tx active = false
트랜잭션 적용 위치
이번에는 @Transactional 애노테이션의 적용 위치에 따른 우선순위를 확인해보자. 스프링에서 우선순위는 항상 더 디테일하고 자세한 것이 높은 우선순위를 가진다. 이것만 기억하면 스프링에서 발생하는 대부분의 우선순위를 쉽게 기억할 수 있다. 그리고 더 구체적인 것이 더 높은 우선순위를 가지는 것은 상식적으로 자연스럽다. 예를 들어, 메서드와 클래스에 애노테이션을 붙일 수 있다면, 더 구체적인 메서드가 더 높은 우선순위를 가진다. 인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다.
package cwchoiit.tx.apply;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Slf4j
@SpringBootTest
public class TxLevelTest {
@Autowired LevelService levelService;
@Test
void orderTest() {
levelService.write();
levelService.read();
}
@TestConfiguration
static class TestConfig {
@Bean
public LevelService levelService() {
return new LevelService();
}
}
@Transactional(readOnly = true)
static class LevelService {
@Transactional
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);
}
}
}
- 스프링의 @Transactional 애노테이션은 다음 두 가지 규칙이 있다.
- 우선순위 규칙
- 클래스에 적용하면 메서드는 자동 적용
우선순위
트랜잭션을 사용할 때는 다양한 옵션을 사용할 수 있다. 그런데 어떤 경우에는 옵션을 주고, 어떤 경우에는 옵션을 주지 않으면 어떤 것이 선택될까? 예를 들어 읽기 전용 트랜잭션 옵션을 사용하는 경우와 아닌 경우를 비교해보자. 위 코드의 경우 클래스 레벨에는 읽기 전용 옵션`@Transactional(readOnly = true)`이 붙어 있다. 그런데 write() 메서드에는 `@Transactional`만 붙어있다. 이러면 당연히 메서드가 더 구체적이기 때문에 메서드에 있는 쓰기도 가능한 트랜잭션이 적용된다.
클래스에 적용하면 메서드는 자동 적용
위 코드에서 read()의 경우 메서드에 @Transactional 애노테이션이 붙지 않았다. 이런 경우 더 상위 개념인 클래스를 확인한다. 클래스에 @Transactional(readOnly = true)가 붙어 있다. 따라서 트랜잭션이 적용되고 읽기 전용 모드로 진행된다. 참고로 기본 옵션이 readOnly는 false이기 때문에 위 코드처럼 생략한다.
인터페이스에 @Transactional 적용
인터페이스에도 @Transactional 애노테이션을 적용할 수는 있다. 이 경우 다음 순서로 적용된다. 구체적인 것이 더 높은 우선순위를 가진다고 생각하면 바로 이해가 될 것이다.
- 클래스의 메서드 (우선순위가 가장 높다)
- 클래스의 타입
- 인터페이스의 메서드
- 인터페이스의 타입 (우선순위가 가장 낮다)
클래스의 메서드를 찾고, 만약 없으면 클래스의 타입을 찾고, 만약 없으면 인터페이스의 메서드를 찾고, 그래도 없으면 인터페이스의 타입을 찾는다. 그런데 인터페이스에 @Transactional 애노테이션을 사용하는 것은 스프링 공식 매뉴얼에도 권장하지 않는 방법이다. 그러니까 이렇게 코드는 작성 하지 않았으면 한다. 가급적 구체 클래스에 @Transactional을 사용하자. 왜 권장하지 않냐면, 스프링이 프록시를 만들어내는 방식 중 하나인 CGLIB는 구체 클래스를 상속받아 만들어낸다. 그래서 인터페이스까지 올라가지 않고 구체클래스를 상속받은 프록시를 만들기 때문에 AOP가 적용되지 않는 사례가 있기 때문이다. 지금은 인식문제가 없다. 과거, 그러니까 스프링 5.0 이전에는 이런 인식 문제가 발생했는데 지금은 그 부분을 많이 개선했지만 그래도 불구하고 구체 클래스에 @Transactional을 사용하자!
@Transactional은 private 메서드에는 적용되지 않는다.
이건 CGLIB를 통해 프록시를 만들어내는 스프링의 방식에서 비롯된건데 CGLIB로 프록시를 만들땐 실제 객체를 상속받아 프록시를 만든다. 상속받아 프록시를 만들고 그 프록시 안에 실제 객체를 참조하기까지 한다. 참조하고 있어야 실제 객체의 메서드를 호출할테니. 그런데 상속받아 만들어 기존 객체의 메서드를 재정의하여 만약 @Transactional 애노테이션이 붙은 메서드라면 트랜잭션 기능을 적용한 재정의 메서드가 만들어진다. `private` 접근 제어자는 상속받는다고 해도 재정의할 수 없다. 아예 자식 객체에서 호출조차 불가능하다. 이러한 이유 때문에 private 메서드는 @Transactional 애노테이션이 적용되지 않는 것이다. 이 말은, 스프링이 지원하는 애노테이션을 붙였을때 프록시로 만들어주는 모든 애노테이션이 다 동일하게 이렇게 동작한다는 말이다. 여기까지는 기술적인 이유이다.
그럼, 논리적인 이유는 뭘까? `private` 접근 제어자는 트랜잭션이 필요할까? `private` 이라는 키워드는 나 자신만이 이 메서드를 사용하고 싶다, 외부로 노출하고 싶지 않다는 강력한 의지 표현이다. 그럼 외부에 노출하고 싶지 않은데 트랜잭션과 상호작용할 필요가 있을까? 사실 논리적으로 생각해도 이건 말이 맞지 않기도 하다.
결론적으로, @Transactional 애노테이션은 `private`에는 적용되지 않는다.
참고로, 스프링 부트 3.0 이전에는 protected, default 접근 제어자 역시도 @Transactional 애노테이션을 달아도 트랜잭션이 적용되지 않았다. 오로지 `public`만이 가능했는데 3.0 이후부터는 `private`을 제외하고 다 가능하다.
트랜잭션 AOP 주의 사항 - 초기화 시점
스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다. 타이밍 이슈 때문인데 코드를 보자.
package cwchoiit.tx.apply;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Slf4j
@SpringBootTest
public class InitTxTest {
@Test
void go() {
}
@TestConfiguration
static class InitTxTestConfiguration {
@Bean
public Hello hello() {
return new Hello();
}
}
static class Hello {
@PostConstruct
@Transactional
public void initV1() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("initV1 isActive: {}", isActive);
}
}
}
- 여기서 주의깊게 볼 부분은 다음 부분이다.
@PostConstruct
@Transactional
public void initV1() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("initV1 isActive: {}", isActive);
}
- @PostConstruct 애노테이션은 해당 클래스가 스프링 컨테이너에 빈으로 등록된 직후에 이 애노테이션이 달려있으면 해당 메서드를 호출해준다.
- 그런데 이 메서드에 @Transactional 애노테이션이 붙어있다. 이 애노테이션이 붙어있으면, 해당 객체를 프록시로 만들어 빈으로 등록한다. 벌써부터 그럼 뭐가 먼저지? 이런 고민거리가 생긴다. 이렇게 애매모호한 경우에는 뭐든간에 사용하면 안되는게 맞다.
- 결론부터 말하면 스프링 컨테이너에 빈으로 등록해서 @PostConstruct 애노테이션이 먼저 사용되고 그 다음 AOP로 프록시로 만들어 빈을 갈아끼운다. 따라서 @PostConstruct는 트랜잭션이 적용되지가 않는다.
- 순서가 @PostConstruct → @Transactional 이렇기 때문에 트랜잭션이 적용되지가 않는다.
실행결과
그래서, 이 경우 아래와 같은 해결책이 있다.
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("initV2 isActive: {}", isActive);
}
- @EventListener(ApplicationReadyEvent.class) 애노테이션은 이 스프링이 완전히 준비가 다 끝난 후 호출되는 이벤트고 그 이벤트를 캐치하는 리스너 애노테이션이다. 따라서 AOP가 모두 적용된 상태 이후에 호출되므로 정상적으로 트랜잭션이 적용된다.
정리를 하자면
모든 데이터베이스와의 연동은 트랜잭션안에서 이루어져야 한다. 그래서 JDBC를 직접 사용하든 JPA, Mybatis 등 어떤 기술을 사용하더라도 트랜잭션이 사용된다. 그런데 문제는 각 기술마다 트랜잭션을 사용하는 방법이 다르다는 것이다. 그래서 스프링은 이 트랜잭션을 사용하는 방법을 추상화했다. 그게 PlatformTransactionManager이다. 이 표준을 각 기술별로 구현한 구현체를 가져다가 사용만 하면 되는데 이 구현체 역시도 스프링이 만들어놨기 때문에 아주 편리하게 사용할 기술만 라이브러리로 내려받으면 된다.
그런데, 여기서 한 발 더 나아가서 스프링이 이 PlatformTransactionManager를 주입받아 사용하는 것조차 귀찮고, 트랜잭션 적용 코드와 비즈니스 로직이 들어있는 서비스 코드가 합쳐져 순수한 서비스 코드를 망치게 하니 스프링은 @Transactional 애노테이션을 만들어 두었다. 이 애노테이션 하나만 있으면 AOP, 프록시 기술을 사용하여 순수한 서비스 코드를 작성할 수 있다.
지금까지 이 @Transactional 애노테이션의 기본 사용 방법과 주의할 사항들을 알아보았다. 그럼 다음 포스팅에서는 이 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보고 거기서 중요한 부분이 굉장히 많으니 그 부분에 대해서 심도있게 알아보자!
'Spring + Database' 카테고리의 다른 글
[Renewal] @Transactional 옵션들 (0) | 2024.12.11 |
---|---|
[Renewal] @Transactional 내부 호출 주의 (2) | 2024.12.11 |
[Renewal] 데이터 접근 기술에 대한 고민 두가지 (2) | 2024.12.08 |
[Renewal] Spring Data JPA (4) | 2024.12.07 |
[Renewal] MyBatis (4) | 2024.12.06 |