ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring) @Transactional 과 Database 의 Lock
    Spring 2025. 1. 14. 22:55

    개요


    ??? : 잘되면 그만이야~

     

    Spring Boot 개발을 시작할 때, 많은 개발자들이 특정 어노테이션을 별다른 생각 없이 사용하는 경향이 있습니다. @Controller, @Service, @Component, @Data 같은 어노테이션들은 마치 마법처럼 애플리케이션의 다양한 부분을 자동으로 연결해 주기 때문에, 초기에는 그 내부 작동 원리를 깊이 이해하지 않고도 원하는 기능을 구현할 수 있습니다.

    이러한 편리함은 곧 @Configuration, @NoArgsConstructor 등 더 다양한 어노테이션을 실험하게 만듭니다. 이들을 사용하면서 "그냥 갖다 쓰면 잘 되네?"라는 생각을 하게 되는 경우가 많습니다.

    특히, 데이터베이스 트랜잭션 관리에서는 @Transactional 어노테이션이 그러한 경우입니다. 많은 개발자들이 이 어노테이션을 통해 복잡한 트랜잭션 관리를 손쉽게 처리할 수 있지만, 그 기능과 필요성에 대한 이해 없이 사용되는 경우가 많습니다. 이 글에서는 @Transactional 어노테이션과 데이터베이스 락에 대한 깊이 있는 이해를 돕기 위해, 그 원리와 적용 방법을 자세히 탐구하고자 합니다.

     

    ACID


    ACID는 데이터베이스 트랜잭션의 신뢰성과 일관성을 보장하기 위한 네 가지 핵심 특성을 나타냅니다.

    특성 설명
    원자성 트랜잭션은 모두 실행되거나 전혀 실행되지 않음.
    일관성 트랜잭션 전후에 데이터베이스는 항상 일관성을 유지함.
    격리성 트랜잭션은 서로 독립적으로 실행되어야 함.
    내구성 트랜잭션이 성공적으로 커밋된 후에는 변경 사항이 영구적으로 저장됨.

     

    실제 데이터베이스에서 ACID 보장

    • 관계형 데이터베이스: MySQL, PostgreSQL, Oracle, SQL Server 등은 ACID 특성을 충족하기 위해 설계되었습니다.
    • NoSQL 데이터베이스: MongoDB, Cassandra 같은 일부 NoSQL 데이터베이스는 성능과 확장성을 위해 ACID의 일부 특성을 완화하거나 선택적으로 적용합니다.
      • 예를 들어, CAP 이론에 따라 일관성(Consistency) 대신 가용성(Availability)을 우선시할 수 있습니다.

     

    @Transactional


    @Transactional 어노테이션은 Spring Framework에서 트랜잭션 경계를 선언적으로 관리할 수 있도록 제공되는 기능입니다. 이 어노테이션을 사용하면 개발자가 코드 내에서 명시적으로 트랜잭션을 시작하고, 커밋하고, 롤백하는 복잡한 작업을 처리할 필요 없이, 프레임워크가 자동으로 이러한 트랜잭션의 생명 주기를 관리해줍니다. 이 섹션에서는 @Transactional의 주요 특징과 작동 원리에 대해 자세히 설명하겠습니다.

    @Transactional(rollbackFor = BapException.class, isolation = Isolation.DEFAULT)
    public void createBrand(BrandDto brandDto) throws BapException {
        //Brand Name 중복 검사
        Brand brandByBrandName = brandRepository.findByBrandName(brandDto.getBrandName());
        if(brandByBrandName != null){
            throw new BapException(APIResponseCode.B_ALREADY_BRAND_NAME);
        }
    
        Brand brand = Brand.builder()
                .brandName(brandDto.getBrandName())
                .build();
        try {
            brandRepository.save(brand);
        } catch (DataIntegrityViolationException e) {
            // 중복 이름으로 인한 예외 처리
            log.error("brandRepository save error , brand : {}, fail Message: {}", brandDto, e.getMessage());
            throw new BapException(APIResponseCode.B_ALREADY_BRAND_NAME);
        }
    }

     

    트랜잭션의 관리


    @Transactional을 사용할 때, Spring은 AOP(Aspect-Oriented Programming)를 활용하여 선언된 메소드나 클래스에 트랜잭션 관리 기능을 자동으로 적용합니다. AOP는 특정 작업을 메소드의 전후나 예외 발생 시에 자동으로 실행하는 프로그래밍 패러다임을 말합니다. 이를 통해, @Transactional이 적용된 메소드를 실행할 때 자동으로 다음과 같은 트랜잭션 관리 작업이 수행됩니다

    1. 트랜잭션 시작: 메소드 실행 전에 트랜잭션을 시작합니다.
    2. 트랜잭션의 격리: 지정된 격리 수준에 따라 트랜잭션을 격리하여 데이터베이스의 일관성과 무결성을 보장합니다.
    3. 예외 처리와 롤백: 메소드 실행 중에 예외가 발생하면, 롤백 옵션에 따라 자동으로 트랜잭션을 롤백합니다.
    4. 트랜잭션 커밋: 메소드 실행이 성공적으로 완료되면 트랜잭션을 커밋합니다.

    자세한 원리는 블로그의 작성글인  AOP 와 프록시 패턴(Proxy Pattern) (https://will-of-rough.tistory.com/58) 를 참고하시기 바랍니다.

     

    격리성으로 인해 나타날 수 있는 문제점


    Dirty read

     

    Dirty Read'는 한 트랜잭션이 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽을 수 있게 될 때 발생합니다. 예를 들어, 트랜잭션 A가 데이터를 수정하고 아직 커밋하지 않은 상태에서 트랜잭션 B가 해당 데이터를 조회하고 사용하면, 만약 A가 나중에 롤백을 하게 되면 B는 무효가 된 데이터를 사용하게 됩니다. 이러한 문제는 데이터의 일관성을 해칠 수 있습니다.

     

    Phantom read

    'Non-Repeatable Read'는 한 트랜잭션 내에서 같은 데이터를 두 번 조회했을 때, 첫 번째 조회와 두 번째 조회의 결과가 다르게 나타나는 현상입니다. 예를 들어, 트랜잭션 A가 데이터 X를 조회한 후 다른 트랜잭션 B가 데이터 X를 수정하고 커밋을 완료한다면, A가 다시 데이터 X를 조회했을 때 수정된 데이터를 보게 됩니다. 이는 트랜잭션 도중 데이터의 일관성이 유지되지 않음을 의미합니다.

    Non-Repeatable Read

    'Phantom Read'는 한 트랜잭션 내에서 일관된 조회를 보장하지 못하는 경우 발생합니다. 예를 들어, 트랜잭션 A가 특정 조건에 맞는 데이터를 조회하고, 그 사이에 트랜잭션 B가 같은 조건을 만족하는 새로운 데이터를 삽입하고 커밋한다면, A가 같은 조건으로 데이터를 다시 조회했을 때 처음에는 없던 데이터가 조회되는 현상입니다.

     

     

    그림 출처 https://jennyttt.medium.com/dirty-read-non-repeatable-read-and-phantom-read-bd75dd69d03a ,

    https://www.mydbops.com/blog/back-to-basics-isolation-levels-in-mysql

     

     

    이러한 문제들은 트랜잭션의 격리 수준(Isolation Level)을 조정하여 완화시킬 수 있습니다. 격리 수준을 높이면 데이터의 일관성과 정확성은 증가하지만, 동시성은 감소하여 시스템의 성능에 영향을 줄 수 있습니다. 각 격리 수준은 다른 종류의 읽기 문제를 방지하는 데 효과적입니다.

     

    주요 속성


    @Transactional 어노테이션은 여러 속성을 제공하여 트랜잭션의 동작을 세밀하게 제어할 수 있습니다. 다음은 가장 중요한 몇 가지 속성입니다

    Propagation (트랜잭션 전파 행위)

    트랜잭션 전파 행위는 현재 트랜잭션이 다른 트랜잭션과 어떻게 상호 작용하는지를 결정합니다. Spring은 다음과 같은 전파 옵션을 제공합니다.

    1. REQUIRED (기본값): 현재 진행 중인 트랜잭션이 있으면 참여하고, 없다면 새로운 트랜잭션을 시작합니다.
    2. SUPPORTS: 현재 진행 중인 트랜잭션이 있으면 그 트랜잭션에 참여하고, 없다면 트랜잭션 없이 실행합니다.
    3. MANDATORY: 현재 진행 중인 트랜잭션이 있어야 하며, 없을 경우 예외를 발생시킵니다.
    4. REQUIRES_NEW: 항상 새로운 트랜잭션을 시작하고, 이미 진행 중인 트랜잭션이 있으면 잠시 보류합니다.
    5. NOT_SUPPORTED: 트랜잭션을 사용하지 않고 실행되며, 진행 중인 트랜잭션이 있으면 보류합니다.
    6. NEVER: 트랜잭션을 사용하지 않고 실행되며, 진행 중인 트랜잭션이 있을 경우 예외를 발생시킵니다.
    7. NESTED: 현재 트랜잭션 내에서 중첩된 독립적 트랜잭션을 시작합니다.
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.beans.factory.annotation.Autowired;
    
    @Service
    public class TransactionalService {
    
        @Autowired
        private AnotherService anotherService;
    
        // REQUIRED: 기본 전파 모드, 현재 트랜잭션이 있으면 참여, 없으면 새로 시작
        @Transactional(propagation = Propagation.REQUIRED)
        public void updateDataRequired() {
            // 비즈니스 로직
        }
    
        // REQUIRES_NEW: 항상 새로운 트랜잭션을 시작, 현재 트랜잭션이 있으면 일시 중단
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void updateDataRequiresNew() {
            // 비즈니스 로직
        }
    
        // NESTED: 현재 트랜잭션 내에서 중첩된 트랜잭션을 시작
        @Transactional(propagation = Propagation.NESTED)
        public void updateDataNested() {
            // 비즈니스 로직
        }
    
        // SUPPORTS: 현재 진행 중인 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 진행
        @Transactional(propagation = Propagation.SUPPORTS)
        public void readDataSupports() {
            // 비즈니스 로직
        }
    
        // NOT_SUPPORTED: 트랜잭션을 사용하지 않고 실행, 현재 트랜잭션이 있으면 일시 중단
        @Transactional(propagation = Propagation.NOT_SUPPORTED)
        public void performNonTransactionalOperation() {
            // 비즈니스 로직
        }
    
        // MANDATORY: 현재 진행 중인 트랜잭션이 반드시 있어야 하며, 없으면 예외 발생
        @Transactional(propagation = Propagation.MANDATORY)
        public void updateDataMandatory() {
            // 비즈니스 로직
        }
    
        // NEVER: 트랜잭션을 사용하지 않고 실행, 현재 트랜잭션이 있으면 예외 발생
        @Transactional(propagation = Propagation.NEVER)
        public void updateDataNever() {
            // 비즈니스 로직
        }
    }

    Isolation (트랜잭션의 격리 수준)

    트랜잭션 격리 수준은 트랜잭션 간의 간섭을 얼마나 허용할지 결정합니다. 다음은 격리 수준 옵션입니다:

    1. DEFAULT: 데이터베이스의 기본 격리 수준을 사용합니다.
    2. READ_UNCOMMITTED: 다른 트랜잭션에서 커밋되지 않은 변경 내용을 읽을 수 있습니다 (더티 리드).
    3. READ_COMMITTED: 커밋된 데이터만 읽을 수 있어 더티 리드는 방지하지만, 다른 문제는 발생할 수 있습니다.
    4. REPEATABLE_READ: 같은 필드의 다중 읽기가 동일한 결과를 보장하며, 팬텀 리드는 방지하지 않습니다.
    5. SERIALIZABLE: 가장 엄격한 격리 수준으로, 트랜잭션들이 순차적으로 실행되는 것처럼 보장합니다.
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.transaction.annotation.Isolation;
    
    @Service
    public class IsolationLevelExampleService {
    
        // READ UNCOMMITTED 격리 수준 (다른 트랜잭션에서 커밋되지 않은 변경 사항을 볼 수 있음)
        @Transactional(isolation = Isolation.READ_UNCOMMITTED)
        public void readUncommittedData() {
            // 비즈니스 로직, 다른 트랜잭션의 더티 데이터를 읽을 수 있음
        }
    
        // READ COMMITTED 격리 수준 (커밋된 데이터만 읽기)
        @Transactional(isolation = Isolation.READ_COMMITTED)
        public void readCommittedData() {
            // 비즈니스 로직, 커밋된 데이터만 읽을 수 있음
        }
    
        // REPEATABLE READ 격리 수준 (트랜잭션 내에서 반복된 읽기가 일관된 결과를 보장)
        @Transactional(isolation = Isolation.REPEATABLE_READ)
        public void repeatableReadData() {
            // 비즈니스 로직, 한 트랜잭션 내에서 일관된 읽기 결과를 보장
        }
    
        // SERIALIZABLE 격리 수준 (가장 엄격한 격리 수준, 트랜잭션이 순차적으로 처리됨)
        @Transactional(isolation = Isolation.SERIALIZABLE)
        public void serializableData() {
            // 비즈니스 로직, 트랜잭션이 완전히 순차적으로 실행되어 격리 수준이 가장 높음
        }
    }


    ReadOnly

    이 속성을 true로 설정하면, 트랜잭션이 데이터를 수정하지 않고 읽기만 할 때 성능 최적화를 할 수 있습니다. 일부 데이터베이스 및 ORM에서는 이 설정으로 인해 리소스 사용을 최적화할 수 있습니다.

    Timeout

    트랜잭션이 허용된 시간을 초과하면 시간 초과 예외가 발생합니다. 이는 트랜잭션이 너무 오래 실행되는 것을 방지하며, 시간은 초 단위로 설정할 수 있습니다.

    RollbackFor

    이 속성을 사용하여 특정 예외가 발생했을 때 트랜잭션을 자동으로 롤백하도록 설정할 수 있습니다. 클래스 배열을 통해 하나 이상의 예외를 지정할 수 있으며, 해당 예외가 발생하면 트랜잭션은 롤백됩니다.

    이러한 옵션들을 적절히 조합하고 사용함으로써, 트랜잭션의 정확성과 성능을 효과적으로 관리할 수 있습니다.

     

    트랜잭션 관리와 데이터베이스 동작


    Spring의 @Transactional은 트랜잭션을 시작한 후, 해당 메서드의 모든 작업이 같은 트랜잭션 컨텍스트에서 실행되도록 합니다. 데이터베이스 트랜잭션 내에서 발생한 변경 사항은 커밋되기 전이라도 동일 트랜잭션 내에서는 가시적입니다.

     

    INSERT, UPDATE, DELETE가 실행되면 데이터는 트랜잭션의 임시 작업 공간에 저장됩니다.
    SELECT를 실행하면 데이터베이스는 동일 트랜잭션의 작업 공간에서 변경된 데이터를 포함한 결과를 반환합니다.

     

    JPA와 MyBatis의 차이

    1. JPA의 동작 방식
    JPA/Hibernate는 내부적으로 **1차 캐시 (Persistence Context)**를 사용합니다. 트랜잭션 내에서 발생한 변경 사항은 데이터베이스에 즉시 반영되지 않고, 1차 캐시에 저장된 후 필요 시 플러시(flush)됩니다.

    동일 트랜잭션 내에서 SELECT를 실행하면, JPA는 먼저 1차 캐시를 조회하고 변경된 데이터를 반환합니다.
    1차 캐시에 없는 경우에만 데이터베이스에서 데이터를 조회합니다.


    2. MyBatis의 동작 방식
    MyBatis는 JPA와 달리 1차 캐시나 Persistence Context를 제공하지 않습니다. 모든 쿼리는 데이터베이스로 직접 실행됩니다. 하지만, 동일 트랜잭션 내에서 실행된 변경 사항은 데이터베이스 트랜잭션에 의해 관리되므로, 트랜잭션 범위 내에서 SELECT를 통해 커밋되지 않은 데이터도 조회할 수 있습니다.

     

    특징 JPA/Hibernate MyBatis
    캐시 사용 1차 캐시 (Persistence Context)를 사용 별도의 캐시가 없음
    데이터 조회 위치 1차 캐시 → 데이터베이스 직접 데이터베이스에서 조회
    변경 사항 반영 변경 사항은 1차 캐시에 저장되고, flush 시 DB에 반영됨 변경된 데이터는 DB에 바로 전달됨
    동작 주체 JPA가 상태 관리 및 캐시 관리 데이터베이스 트랜잭션에 의존

     

    @Service
    public class TransactionalService {
    
        @Autowired
        private SomeEntityRepository repository;
    
        @Transactional
        public SomeEntity updateAndFetch(Long id, String newValue) {
            // 데이터 업데이트
            SomeEntity entity = repository.findById(id).orElseThrow();
            entity.setSomeField(newValue);
            repository.save(entity);
    
            // 동일 트랜잭션 내에서 변경된 데이터 조회
            return repository.findById(id).orElseThrow();
        }
    }

     

    마치며


    @Transactional 어노테이션과 데이터베이스의 배타적 락은 복잡한 현대 애플리케이션에서 데이터 무결성과 일관성을 유지하는 데 필수적인 역할을 합니다. 이들은 애플리케이션과 데이터베이스 간의 상호작용을 관리하면서, 트랜잭션 처리의 복잡성을 추상화하고, 동시에 여러 사용자의 데이터 접근을 안정적으로 조율합니다.

    이 두 기능의 효과적인 조합과 적절한 사용은 데이터 중심 애플리케이션의 성능과 신뢰성을 결정짓는 중요한 요소입니다. 트랜잭션 관리와 락의 적절한 사용은 복잡한 데이터베이스 작업을 간소화하고, 시스템의 성능을 최적화하는 동시에, 데이터 무결성을 확보하는 데 중추적인 역할을 합니다. 따라서, 애플리케이션의 요구사항과 환경을 면밀히 분석하여, 최적의 트랜잭션 관리와 락 전략을 수립하는 것이 중요합니다. 이를 통해 개발자는 더 효율적이고 안정적인 애플리케이션을 구축할 수 있습니다.

     

     

     

    추가) 트랜젝션시 사용하는 락의 종류와 설명

    배타적 락(Exclusive Lock)


    배타적 락(Exclusive Lock)은 데이터베이스 관리 시스템에서 사용하는 중요한 락 유형 중 하나입니다. 이 락은 특정 데이터 항목에 대한 독점적인 접근 권한을 한 트랜잭션이 갖도록 하여, 해당 트랜잭션이 데이터를 읽고 수정할 수 있도록 합니다. 배타적 락이 걸린 데이터는 해당 락을 보유한 트랜잭션이 커밋하거나 롤백을 완료할 때까지 다른 트랜잭션에서는 읽거나 쓸 수 없습니다.

    배타적 락의 주요 특징

    독점적 접근: 배타적 락이 걸린 데이터는 락을 보유한 트랜잭션만이 접근할 수 있습니다. 다른 어떤 트랜잭션도 해당 데이터를 읽거나 수정할 수 없습니다.

    데이터 무결성 보장: 배타적 락은 데이터에 대한 동시 수정을 방지함으로써 데이터의 무결성을 보장합니다. 이는 트랜잭션이 데이터를 변경하는 동안 다른 트랜잭션이 동일한 데이터를 변경하거나 읽지 못하게 하여 일관성 있는 데이터 상태를 유지합니다.

    데드락의 가능성: 배타적 락은 여러 트랜잭션 간에 복잡한 락 경쟁을 유발할 수 있으며, 이는 때때로 데드락이라는 상태를 초래할 수 있습니다. 데드락은 두 개 이상의 트랜잭션이 서로의 락 해제를 무한히 기다리는 상태를 말합니다.

    배타적 락의 사용 예
    배타적 락은 주로 데이터를 수정하는 연산(예: UPDATE, DELETE, INSERT)에 사용됩니다. 예를 들어, 은행 시스템에서 계좌 이체를 처리할 때, 이체에 관련된 계좌의 잔액을 수정해야 합니다. 이 과정에서 배타적 락을 사용하여 이체 작업 중인 계좌의 잔액 레코드에 락을 걸어 다른 트랜잭션이 동시에 같은 계좌의 잔액을 변경하지 못하도록 합니다. 이를 통해 계좌 잔액의 정확성을 보장할 수 있습니다.

    주요 문제 및 해결책


    락 경쟁(Lock Contention)

    배타적 락은 레코드에 대한 독점적 접근을 보장합니다. 첫 번째 트랜잭션이 레코드에 락을 걸면, 다른 모든 트랜잭션은 해당 레코드가 해제될 때까지 대기해야 합니다. 이 과정에서 높은 락 경쟁이 발생하면 시스템의 전체적인 응답 시간이 증가할 수 있습니다.

    해결책: 트랜잭션이 필요한 데이터에 대해서만 락을 걸고, 가능한 한 빨리 락을 해제하여 대기 시간을 최소화하는 것이 중요합니다. 또한, 레코드 레벨 락 대신 더 세밀한 락 전략을 사용하여 락의 범위를 제한할 수 있습니다.

    대기 큐(Wait Queue)

    데이터베이스 시스템은 대기 중인 트랜잭션을 관리하기 위해 대기 큐를 사용합니다. 레코드에 락이 해제되면, 대기 큐에 있는 트랜잭션 중 하나가 락을 획득하고 작업을 계속합니다. 그러나 대기 큐가 길어지면 트랜잭션 처리 속도가 느려질 수 있습니다.

    해결책: 데이터 접근 패턴을 분석하여 핫스팟을 식별하고, 로드를 분산시켜 대기 큐의 길이를 관리할 수 있습니다. 분산 데이터베이스 또는 복제를 통해 읽기 부하를 줄일 수도 있습니다.

    성능 문제

    동시에 많은 트랜잭션이 하나의 레코드를 수정하려고 할 때, 높은 락 경쟁은 시스템 성능에 심각한 영향을 미칠 수 있습니다.

    해결책: 로드 밸런싱과 캐싱 전략을 도입하여 부하를 줄이고 데이터 접근 패턴을 최적화합니다. 이를 통해 필요한 락의 수를 감소시키고, 전체적인 시스템 성능을 개선할 수 있습니다.

    데드락(Deadlock)

    두 개 이상의 트랜잭션이 서로의 락 해제를 무한히 기다리는 데드락 상태는 시스템 작동을 완전히 멈출 수 있습니다.

    해결책: 현대의 데이터베이스 관리 시스템은 데드락 감지 알고리즘을 내장하고 있어서 데드락을 자동으로 감지하고 해결할 수 있습니다. 트랜잭션을 설계할 때는 데드락 가능성을 최소화하도록 노력해야 합니다. 또한, 트랜잭션의 크기를 작게 유지하고, 필요한 락을 미리 예측하여 순서대로 획득하는 전략을 사용할 수 있습니다.

    댓글

Designed by Tistory.