@Transactional 바르게 알고 사용하기

양일표 Rosie
지디지인천,송도 & 플러터송도
15 min readApr 14, 2023

--

@Transactional 그거 어떻게 동작하는 건가요?

meme

안녕하세요 GDG Incheon Oraganizor 양일표입니다!

최근 리펙토링을 하면서 @Transactional 선언 메서드가 잘못 사용된 경우를 보았습니다. Service Layer에서 습관적으로 사용하고 있는 @Transactional의 동작을 바르게 이해하고 주의할 점들에 대해 알아보겠습니다.

# Hello @Transactional!
# @Transactional은 어떻게 동작할까?
#### @Transactional에서 proxy까지
#### @Transactional 어노테이션의 속성
# 사용할 때 이것만큼은 고려하자!

Hello @Transactional! 🖐

Spring Framework 2.0 이상의 버전에서 지원하는 @Transactional은 선언적 데이터베이스 트랜잭션 관리 방법을 제공합니다. 메서드 레벨 또는 클래스 레벨에서 사용할 수 있으며, 해당 메서드 또는 클래스의 모든 public 메서드에 트랜잭션을 적용합니다.

해당하는 메서드를 실행할 때 스프링은 트랜잭션을 시작하고, 메서드가 정상적으로 종료되면 트랜잭션을 commit하고, 예외가 발생하면 트랜잭션을 rollback합니다. 즉, 비정상적 종료로 인한 rollback이 발생할 경우에는 트랜잭션의 일부 작업만 데이터베이스에 반영되는 것을 방지해 데이터 일관성을 유지할 수 있습니다.

그리고 아래 간단한 예시 코드처럼 기존의 긴 JDBC 트랜잭션을 짧은 코드를 Service 단 메서드 위에 어노테이션 처리한 두 번째 코드처럼 유지보수가 쉽게 관리할 수 있습니다.

import java.sql.Connection;

Connection conn = dataSource.getConnection();
Savepoint savepoint;

try (connection) {
conn.setAutoCommit(false);
Statement stmt = conn.createStatement();
savepoint = conn.setSavepoint("savepoint");
String sql = "Insert into User values (16, 'Rosie', 'rosie@gmail.com')"
stmt.executeUpdate(sql);
conn.commit();
} catch (SQLException e) {
conn.rollback(savepoint);
}
public class UserService{
private final UserRepository userRepository;

@Transactional(readOnly = true)
public List<User> findAll(User user) {
return userRepository.save(user);
}
}

@Transactional은 어떻게 동작할까? 🤖

그렇다면 @Transactional은 Spring에서 어떻게 동작하는 것인지 구체적으로 살펴보겠습니다.

@Transactional에서 proxy까지

Spring boot 애플리케이션을 실행하는 시점에 proxy 를 생성에 필요한TransactionAutoConfiguration 등 클래스들이 자동으로 활성화됩니다. 따라서 클라이언트 request를 보내면 @Transactional이 적용된 메서드에 대한 트랜잭션 처리가 가능해집니다.

그 관계성을 TransactionAutoConfiguration 클래스로 확인해봅시다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(PlatformTransactionManager.class)
@AutoConfigureAfter({ JtaAutoConfiguration.class, HibernateJpaAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, Neo4jDataAutoConfiguration.class })
@EnableConfigurationProperties(TransactionProperties.class)
public class TransactionAutoConfiguration {

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(TransactionManager.class)
@ConditionalOnMissingBean(AbstractTransactionManagementConfiguration.class)
public static class EnableTransactionManagementConfiguration {

@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = false)
@ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "false")
public static class JdkDynamicAutoProxyConfiguration {}

@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
@ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "true",
matchIfMissing = true)
public static class CglibAutoProxyConfiguration {}

}
}

위 코드에서 알 수 있듯이 TransactionAutoConfiguration으로 ProxyConfiguration 구성을 활성화하기 위해서는 @ConditionalOnClass에 따른 선행조건으로 PlatformTransactionManager()가 있어야합니다.

일반적으로 spring-jdbc나 spring-data-jpa 등의 의존성을 포함시키면DataSourceTransactionManage나 JpaTransactionManager 등과 같은 클래스가 PlatformTransactionManager 구현체로 사용됩니다. 이렇게 구성된 TransactionManager는 connection 객체를 생성하고, 트랜잭션의 commit 또는 rollback을 가능하게 합니다.

https://www.baeldung.com/java-transactions

이렇게 TransactionManager가 선정되면 @EnableTransactionManagement가 선언된 ProxyConfiguration 클래스를 통해 트랜잭션 관리 기능을 활성화합니다. ProxyConfiguration 클래스로 두 가지를 제시하고 있는데 바로 JDK Dynamic ProxyCGLIB 방식입니다. JDK Dynamic Proxy는 인터페이스를 기반으로 프록시 객체를 생성하며, CGLIB은 바이트 코드 조작을 통해 원본 객체의 서브클래스를 생성한다는 차이점이 있습니다.

1.4. Declarative Transaction Management

Spring은 AOP 프레임워크를 이용하여 프록시를 생성하고, 특정 메소드 호출을 가로채서 추가 동작을 수행합니다. 여기서 가로챈다’는 것은 Spring AOP의 어드바이스가 수행하는 것으로 각 Jointpoint에서 동작하는 횡단 관심의 공통 기능을 수행합니다.

@Transactional 어노테이션의 속성

@Transactional 어노테이션에는 여러 가지 속성이 있습니다. 그 중에서도 대표적인 속성인 propagation, isolation에 대해 알아보겠습니다.

propagation
propagation은 여러 트랜잭션이 관련된 경우 트랜잭션 전파 방식을 결정합니다. 총 7가지 유형이 있으며,

  1. REQUIRED
    트랜잭션이 있는 경우 참여하고 없으면 새 트랜잭션을 생성하며 propagation 설정이 없는 경우의 기본값입니다.
  2. REQUIRES_NEW
    항상 새 트랜잭션을 만들고 트랜잭션이 있다면 끝날 때까지 일시중지합니다.
  3. NESTED
    기존 트랜잭션과 중첩된 트랜잭션을 생성하고, 없다면 새로 트랜잭션을 생성합니다.
  4. SUPPORTS
    존재하는 트랜잭션이 있다면 지원하고, 없으면 트랜잭션 없이 메서드만 실행합니다.
  5. MANDATORY
    반드시 트랜잭션이 존재해야 하는 유형으로 없으면 예외(ThrowIllegalTransactionStateException)가 발생합니다.
  6. NOT_SUPPORTED
    트랜잭션이 있어도 중단되며, 트랜잭션을 지원하지 않습니다.
  7. NEVER
    트랜잭션이 존재하면 예외(ThrowIllegalTransactionStateException)가 발생합니다.
@Transactional(propagation = Propagation.REQUIRED)       // Default
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional(propagation = Propagation.NESTED)
@Transactional(propagation = Propagation.SUPPORTS)
@Transactional(propagation = Propagation.MANDATORY)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Transactional(propagation = Propagation.NEVER)

isolation
트랜잭션 격리 수준을 설정하며, 속성을 설정하지 않은 경우에는 기본값으로 연결된 DB의 격리수준을 따릅니다.

  1. READ_UNCOMMITTED
    가장 낮은 격리수준으로 다른 트랜잭션에서 commit되지 않은 상태의 데이터까지 읽어옵니다. (Dirty read)
  2. READ_COMMITTED
    UNCOMMITTED와 반대로 commit된 내용만 읽어옵니다. 하지만 트랜잭션들이 동시에 수행되고 있다면 commit 이후의 데이터가 다른 동시성 문제가 발생할 수 있습니다. (Nonrepeatable read)
  3. REPEATABLE_READ
    하나의 트랜잭션에 하나의 스냅샷을 이용하기 때문에 READ_COMMITTED와 같은 문제가 발생하지 않지만 다시 데이터를 조회하는 과정에서 새로 추가되거나 제거된 값을 가져올 수 있습니다. (Phantom read)
  4. SERIALIZABLE
    가장 높은 격리수준으로 Read시에 DML 작업이 불가능하기 때문에 동시성이 낮습니다.
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
@Transactional(isolation = Isolation.READ_COMMITTED)
@Transactional(isolation = Isolation.REPEATABLE_READ)
@Transactional(isolation = Isolation.SERIALIZABLE)

그 외에도 읽기전용 여부를 표시하는 readOnly, 트랜잭션 타임아웃 시간을 설정하는 timeout과 같은 속성들이 존재합니다.

사용할 때 이것만큼은 고려하자! 🐱

마지막으로 이 글의 핵심 내용입니다 🙌
@Transactional 어노테이션을 사용할 때 어떤 점을 주의해야 할까요?

생각해내라 닝겐

1. 트랜잭션을 적용하려는 메서드는 반드시 public으로 선언되어야 합니다.

프록시 객체로 외부에서 접근 가능한 인터페이스를 제공해야 하기 때문입니다. 만약 해당 메서드가 private이나 protected로 선언되어 있다면, 프록시 객체가 생성될 때 해당 메서드에 접근할 수 없으므로 @Transactional 어노테이션을 사용한 트랜잭션 관리가 불가능합니다.

2. 다른 AOP 기능과의 충돌을 고려해야 합니다.

예를 들어 @Secured를 통해 권한이 있는 사용자 여부를 확인하는데 @Transactional이 먼저 수행된다면 권한 검사가 무의미해집니다.
이를 방지하기 위해서는 @Order를 이용해 적용 순서를 정하거나 적용범위를 조정해서 해결할 수 있습니다. 이외에도 @Transactional proxyTargetClass 속성을 true로 설정하여 강제로 클래스를 대상으로 프록시를 사용하도록 지정할 수도 있습니다. 하지만 성능저하가 발생할 수 있다는 단점이 있습니다.

3. Service 계층에서 사용하자.

Service 계층에서 @Transactional을 사용하므로써 여러 데이터베이스 작업들을 원자적으로 처리할 수 있습니다. 그리고 Spring에서 단일 책임원칙에 따라 Database 계층에서 비즈니스 로직과 관련 없는 역할을 담당해 코드의 유지보수와 확장성이 높이기 위함입니다.

👀 JPA 왜 SimpleJpaRepository에 @Transactional을 사용할까요?

Spring Data JPA에서 제공하는 SimpleJpaRepository는 일반적인 레포지토리 구현체와는 조금 다릅니다. SimpleJpaRepository는 JPA에서 제공하는 기능들을 활용하여, 일부 비즈니스 로직을 처리합니다.
예를 들면, save() 메소드를 사용하여 엔티티를 저장할 때 새로운 것인지 아니면 이미 저장된 것인지를 판단하여 동작을 수행합니다.
그리고 Mixed Transaction이 발생해도 일반적으로 Service 계층의 @Transactional이 우선순위를 갖습니다.

4. Exception을 고려하자.

트랜잭션은 RuntimeException과 Error에서는 롤백되지만, Checked exceptions에서는 롤백되지 않습니다. Checked exceptions는 예측가능한 에러를 말하는데, 아래와 같이 @Transactional에 rollbackFor 속성을 두어 롤백처리가 되도록 할 수도 있습니다.

@Transactional(rollbackFor={Exception.class})

👀 트랜잭션 Checked exception이 발생했을 때 Java와 Kotlin

Java에서는 롤백되지 않고 Checked exception을 try-catch, throw 방식으로 처리하고 있지만 Kotlin에서는 트랜잭션이 롤백되는 것을 볼 수 있습니다. 하지만 Java에서처럼 롤백이 되지 않도록 @Throws를 사용해서 처리할 수도 있습니다.
따라서 개발자는 어떤 언어를 스프링과 사용하는지 그리고 Custom Exception 설정을 어떻게 하는지에 따라 다양한 Exception 처리방법을 고려해야 합니다.

5. 트랜잭션과 DeadLock

실제로 DeadLock 이슈가 발생해서 리펙토링을 하면서 가장 유심히 본 부분 중 하나입니다. Service 계층에서 설정한 메서드들이 데이터베이스에서 어떤 방향으로 리소스를 점유하는지는 매우 중요합니다.

MSSQL DeadLock Issue Graph

실제 복잡한 서비스에 트랜잭션 순서를 일치시키거나 최적화시키는 것은 쉬운 일이 아닙니다. 하지만 잘못된 트랜잭션으로 DeadLock이 발생하면 위 사진의 spid 76번처럼 희생양 프로세스가 생기거나 반복적인 DeadLock 이슈로 서버 성능에 문제가 발생할 수도 있습니다.

--

--