본문 바로가기
개발/Spring FW

스프링 트랜잭션

by Devsong26 2023. 11. 26.

스프링 트랜잭션은 스프링 프레임워크에서 데이터베이스 작업을 관리하기 위한 메커니즘입니다. 트랜잭션은 일련의 데이터베이스 작업들이 하나의 논리적 단위로 묶여서 수행되도록 보장하는 것으로, 모든 작업이 성공적으로 완료되거나, 하나라도 실패할 경우 이전 상태로 롤백되어 데이터의 일관성을 유지하는 것을 목표로 합니다. 

 


스프링 트랜잭션의 정의를 좀 더 자세히 살펴보면 다음과 같습니다:

    • ACID 속성 준수
      • 스프링 트랜잭션은 데이터베이스 트랜잭션의 기본 원칙인 ACID(Atomicity, Consistency, Isolation, Durability)를 준수합니다. 이는 각각 원자성, 일관성, 격리성, 지속성을 의미하며, 트랜잭션이 안전하고 신뢰할 수 있는 방식으로 처리되도록 합니다.
      • 원자성(Atomicity)
        트랜잭션 내의 모든 연산들은 하나의 단위로 처리되어야 합니다. 즉, 트랜잭션이 모두 성공적으로 실행되거나, 하나라도 실패할 경우 전체 트랜잭션이 취소되어야 합니다. 이는 "모두 또는 전혀 없음(All or Nothing)" 원칙으로, 트랜잭션 내의 연산들이 부분적으로만 적용되는 것을 방지합니다.
      • 일관성(Consistency)
        트랜잭션은 데이터베이스의 일관된 상태에서 시작하여, 완료 후에도 일관된 상태를 유지해야 합니다. 이는 데이터베이스의 무결성 제약 조건이 트랜잭션 전후로 계속 유지되어야 함을 의미합니다. 예를 들어, 데이터베이스 규칙에 따라 계좌 이체는 잔액이 음수가 되지 않도록 해야 합니다.
      • 격리성(Isolation)
        동시에 실행되는 트랜잭션들은 서로 영향을 주지 않아야 합니다. 즉, 한 트랜잭션이 다른 트랜잭션의 중간 결과를 볼 수 없습니다. 격리성은 다른 트랜잭션의 중간 상태가 노출되는 것을 방지함으로써, 데이터베이스의 일관성을 유지하는 데 도움을 줍니다. 다만, 완전한 격리는 성능 저하를 초래할 수 있기 때문에, 데이터베이스 시스템들은 다양한 격리 수준을 제공합니다.
      • 지속성(Durability)
        트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 데이터베이스에 반영되어야 합니다. 시스템 오류, 충돌, 전원 손실 등이 발생해도 이 결과는 유지되어야 합니다. 이는 데이터의 신뢰성을 보장하는 중요한 요소입니다.
    • 선언적 트랜잭션 관리
      • 스프링은 `@Transactional` 어노테이션을 사용하여 트랜잭션을 관리하는 선언적 방식을 제공합니다. 이 방법을 사용하면 개발자는 복잡한 트랜잭션 관리 코드를 작성할 필요 없이, 비즈니스 로직에 집중할 수 있습니다.
    • 프로그래밍 방식의 트랜잭션 관리
      • 필요한 경우, 스프링은 `TransactionTemplate`을 사용한 프로그래밍 방식의 트랜잭션 관리도 지원합니다. 이 방식은 더 많은 제어가 필요할 때 사용됩니다.
    • 트랜잭션 추상화
      • 스프링은 JDBC, JPA, Hibernate 등 다양한 데이터 액세스 기술에 대한 일관된 트랜잭션 관리 인터페이스를 제공합니다. 이는 다양한 데이터 액세스 기술을 사용하는 애플리케이션에서도 동일한 트랜잭션 관리 방법을 사용할 수 있게 해줍니다.

 

스프링 트랜잭션의 이러한 특징들은 애플리케이션의 데이터 일관성과 신뢰성을 보장하는 데 중요한 역할을 하며, 개발자가 데이터베이스 작업을 보다 쉽고 효율적으로 관리할 수 있도록 돕습니다.

 

 

 

트랜잭션의 격리 수준

 

동시에 실행되는 여러 트랜잭션 간의 상호 작용을 얼마나 허용할지 결정하는 중요한 요소입니다. 격리 수준을 적절히 설정하는 것은 성능과 일관성 사이의 균형을 맞추는 데 필요합니다.

 

일반적으로 사용되는 격리 수준은 다음과 같습니다:

  • Read Uncommitted (읽기 비커밋)
    • 가장 낮은 격리 수준입니다.
    • 다른 트랜잭션이 아직 커밋되지 않은 데이터를 읽을 수 있습니다.
    • 이로 인해 "더티 리드(Dirty Read)"가 발생할 수 있습니다. 더티 리드는 한 트랜잭션이 다른 트랜잭션에 의해 수정되었지만 아직 커밋되지 않은 데이터를 읽는 현상입니다.
  • Read Committed(읽기 커밋)
    • 스프링 트랜잭션의 디폴트 값입니다.
    • 대부분의 데이터베이스 시스템에서 기본으로 설정된 격리 수준입니다.
    • 커밋된 데이터만 읽을 수 있습니다.
    • 더티 리드는 방지되지만, "논리피트(Non-repeatable Read)"는 발생할 수 있습니다. 논리피트는 한 트랜잭션 내에서 동일한 쿼리를 두 번 실행했을 때 다른 결과가 나오는 현상을 말합니다.
  • Repeatable Read (반복 가능 읽기)
    • 한 트랜잭션이 읽은 데이터는 그 트랜잭션이 종료될 때까지 다른 트랜잭션에 의해 수정될 수 없습니다.
    • 논리피트는 방지됩니다.
    • 하지만 "팬텀 리드(Phantom Read)"는 발생할 수 있습니다. 팬텀 리드는 한 트랜잭션 내에서 수행된 두 쿼리 사이에 다른 트랜잭션이 새로운 데이터를 삽입하거나 삭제할 때 발생하는 현상입니다.
  • Serializable (직렬화 가능)
    • 가장 높은 격리 수준입니다.
    • 트랜잭션이 완전히 독립적으로 실행되는 것처럼 보장합니다.
    • 더티 리드, 논리피트, 팬텀 리드를 모두 방지합니다.
    • 하지만 이 수준의 격리는 동시성 처리 능력이 크게 감소할 수 있으며, 성능 저하의 원인이 될 수 있습니다.


이러한 격리 수준들은 각기 다른 상황과 요구 사항에 따라 선택되어야 하며, 일반적으로는 데이터 일관성과 시스템 성능 사이에서 균형을 맞추는 데 중점을 둡니다. 높은 격리 수준은 더 많은 일관성을 제공하지만 동시성과 성능에 더 큰 영향을 줄 수 있습니다.

 

 

 

트랜잭션 처리 중 발생할 수 있는 읽기 문제

  • 더티 리드(Dirty Read)
    • 더티 리드는 한 트랜잭션이 다른 트랜잭션에 의해 변경되었지만 아직 커밋되지 않은 데이터를 읽는 현상입니다.
    • 예를 들어, 트랜잭션 A가 데이터를 수정하고 있지만 아직 커밋하지 않았는데, 트랜잭션 B가 그 데이터를 조회하여 사용하는 경우입니다.
    • 만약 트랜잭션 A가 나중에 롤백되면, 트랜잭션 B는 유효하지 않거나 존재하지 않는 데이터를 사용하게 됩니다.
  • 논리피트 리드(Non-Repeatable Read)
    • 한 트랜잭션에서 같은 데이터를 두 번 읽었을 때, 두 번의 읽기 사이에 다른 트랜잭션이 해당 데이터를 수정하고 커밋하여, 두 번의 읽기 결과가 서로 다른 경우입니다.
    • 예를 들어, 트랜잭션 A가 특정 데이터를 읽고 나중에 다시 읽었을 때, 트랜잭션 B가 그 사이에 해당 데이터를 수정하고 커밋했기 때문에 A가 처음 읽었던 데이터와 다른 결과를 얻게 되는 상황입니다.
  • 팬텀 리드(Phantom Read)
    • 한 트랜잭션이 특정 조건에 해당하는 데이터 집합을 읽었을 때, 다른 트랜잭션이 그 사이에 해당 조건을 만족하는 새로운 데이터를 삽입하거나 삭제하면, 처음 트랜잭션에서 동일한 쿼리를 다시 수행했을 때 다른 결과 집합을 얻는 현상입니다.
    • 예를 들어, 트랜잭션 A가 어떤 범위의 데이터를 조회하고, 그 사이에 트랜잭션 B가 해당 범위 내에 새로운 데이터를 삽입하거나 삭제하면, A가 같은 쿼리를 다시 실행했을 때 처음과 다른 데이터 집합을 얻게 됩니다.

 

이러한 현상들은 트랜잭션의 격리 수준에 따라 방지될 수 있습니다. 예를 들어, "Read Committed" 격리 수준은 더티 리드를 방지하지만, 논리피트 리드와 팬텀 리드는 여전히 발생할 수 있습니다. 반면, "Serializable" 격리 수준은 이 세 가지 문제를 모두 방지할 수 있지만, 동시성 처리 능력이 감소할 수 있습니다. 따라서 적절한 격리 수준을 선택하는 것은 애플리케이션의 요구 사항과 성능 사이의 균형을 고려해야 합니다.

 

 

 

트랜잭션의 범위

 

데이터베이스 트랜잭션의 범위를 설정하는 것은 어플리케이션의 데이터 무결성과 성능에 매우 중요합니다. 

 

트랜잭션의 범위와 관련된 베스트 프랙티스는 다음과 같습니다:

  • 최소한의 범위 설정
    • 트랜잭션의 범위는 가능한 한 작게 유지해야 합니다. 필요한 최소한의 작업만을 포함시켜 트랜잭션을 관리함으로써, 불필요한 자원 소모를 줄이고 데이터베이스의 락(lock) 경쟁을 최소화할 수 있습니다.
  • 비즈니스 로직과의 일치
    • 트랜잭션의 범위는 비즈니스 로직과 밀접하게 연관되어야 합니다. 한 트랜잭션 내에서 수행되는 모든 작업들은 단일 비즈니스 목적을 가지고 있어야 하며, 이는 트랜잭션의 원자성을 보장하는 데 도움이 됩니다.
  • 명확한 시작과 종료 지점
    • 트랜잭션의 시작과 종료 지점은 코드 내에서 명확하게 정의되어야 합니다. 이를 통해 트랜잭션의 관리가 용이해지며, 예기치 않은 오류로 인한 데이터 무결성 문제를 방지할 수 있습니다.
  • 예외 처리와 롤백
    • 트랜잭션 중에 발생할 수 있는 예외를 적절히 처리하고, 필요한 경우 자동으로 롤백되도록 해야 합니다. 이는 트랜잭션 중 발생한 문제로 인해 데이터의 일관성이 깨지는 것을 방지합니다.
  • 동시성 고려
    • 다수의 사용자나 프로세스가 동일한 데이터에 접근할 수 있는 애플리케이션에서는 트랜잭션의 동시성을 고려해야 합니다. 격리 수준을 적절히 설정하여 더티 리드, 논리피트 리드, 팬텀 리드와 같은 문제를 방지할 수 있습니다.
  • 성능과의 균형
    • 트랜잭션은 데이터베이스 성능에 영향을 미칩니다. 너무 많은 작업을 하나의 트랜잭션으로 묶는 것은 성능 저하를 초래할 수 있습니다. 반면, 너무 많은 트랜잭션은 관리 오버헤드를 증가시킬 수 있습니다. 따라서, 트랜잭션의 크기와 수를 적절히 조절하는 것이 중요합니다.
  • 스프링의 @Transactional 사용
    • 스프링 프레임워크를 사용하는 경우, `@Transactional` 어노테이션을 통해 선언적 트랜잭션 관리를 적용할 수 있습니다. 이는 코드의 가독성을 높이고 트랜잭션 관리의 복잡성을 줄일 수 있습니다.
  • 트랜잭션 전파 고려
    • 스프링에서는 다양한 트랜잭션 전파 옵션을 제공합니다. 예를 들어, 이미 진행 중인 트랜잭션에 참여할지, 새로운 트랜잭션을 시작할지 결정할 수 있습니다. 이를 통해 트랜잭션 간의 상호 작용을 적절히 관리할 수 있습니다.

 

이러한 베스트 프랙티스를 따름으로써, 데이터 무결성을 유지하고 애플리케이션의 성능을 최적화하는 데 도움이 됩니다.

 


실습

 

기본적인 트랜잭션 사용 방법

 

아래 코드와 같이 메서드에 @Transactional을 지정하면 PostServiceImpl 클래스는 프록시 빈으로 스프링에서 관리가 됩니다. 클라이언트 코드에서 해당 메서드를 호출하게 되면 프록시에 의해 메서드를 호출하게 되고 예외가 발생하면 롤백하고, 정상 처리되면 커밋합니다.

@Service("postService")
public class PostServiceImpl implements PostService {

    private final PostRepository postRepository;

    public PostServiceImpl(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @Transactional
    @Override
    public void insert(Post post){
        postRepository.save(post);
    }

}

 

 

예외 처리 및 롤백

 

@Transactional을 지정한 메서드는 예외가 발생할 경우 롤백으로 데이터의 일관성을 유지할 수 있습니다. 그러나 예외의 레벨에 따라 롤백이 되지 않을 수도 있습니다. @Transactional에는 rollbackFor 속성이 있습니다.

디폴트 값으로 RuntimeException과 Error를 사용합니다. 하지만 체크 예외가 발생할 경우 롤백을 시켜야 하는 경우가 있습니다. 이때는 다음과 같이 rollbackFor를 지정하면 해결됩니다.

 

@Transactional(rollbackFor = Exception.class)

 

 

 

CQRS 라우팅 방법

 

운영 환경에서 읽기 전용 데이터베이스를 가지고 있는 경우 트랜잭션의 readOnly 속성을 이용하여 읽기와 쓰기 데이터베이스를 선택할 수 있습니다. 이로 인해 용도에 맞는 데이터베이스가 활용되도록 사용할 수 있으며, 주로 읽기 트랜잭션이 많이 발생하기에 대용량 시스템에서는 읽기 저장소를 쓰기 저장소에 비해 많이 구축합니다. 

 

스프링에서는 AbstractRoutingDataSource 클래스를 제공합니다.

AbstractRoutingDataSource를 상속받아 사용자 정의 데이터 소스 라우터를 구현합니다. 이 클래스는 determineCurrentLookupKey() 메서드를 오버라이드하여, 현재 트랜잭션의 특성(읽기 전용 여부 등)에 따라 적절한 데이터 소스 키를 반환하도록 합니다.

 

아래는 예시 코드입니다.

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master";
    }
}

 

그리고 설정 파일은 다음과 같습니다.

@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource masterDataSource() {
        // 마스터 데이터 소스 구성
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://master-db-url:3306/yourdatabase");
        dataSource.setUsername("username");
        dataSource.setPassword("password");
        return dataSource;
    }

    @Bean
    public DataSource slaveDataSource() {
        // 슬레이브 데이터 소스 구성
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://slave-db-url:3306/yourdatabase");
        dataSource.setUsername("username");
        dataSource.setPassword("password");
        return dataSource;
    }

    @Bean
    public DataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("master", masterDataSource());
        dataSourceMap.put("slave", slaveDataSource());

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(masterDataSource());

        return routingDataSource;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(routingDataSource());
    }
}

 

이 방식의 단점은 복제 지연이 발생한다는 것입니다. 만약 CUD 연산 후 READ를 해야 한다면 readOnly = false로 마스터 데이터베이스에 트랜잭션을 처리하는 것이 NPE 등의 예외를 발생시키지 않습니다.

'개발 > Spring FW' 카테고리의 다른 글

[Spring FW] Interceptor  (0) 2023.12.09
Spring에서 ApplicationEvent 처리하기  (0) 2023.11.29
Feign Client  (2) 2023.11.25
JPA N+1 문제  (0) 2023.11.17
@RestControllerAdvice  (0) 2023.11.16