DataBase

트랜잭션 격리 수준, 스프링 @Transactional

ohyujeong 2024. 5. 29. 16:41

1. 트랜잭션 격리 수준

  • 트랜잭션 : 데이터베이스의 일관성과 무결성을 유지하기 위한 논리적인 작업 단위. 원자성, 일관성, 고립성, 지속성을 유지한다.
  • 트랜잭션 격리 수준(isolation level) : 트랜잭션이 얼만큼 고립되어 있는 지 그 정도를 말한다. 동시에 여러 트랜잭션이 진행될 때, 이 격리 수준에 따라서 다른 트랜잭션에서 작업되는 데이터에 얼만큼 접근할 수 있는 지가 제어된다. 

 

격리 수준에 따라 발생할 수 있는 문제점

  • 더티 리드 : 어떤 트랜잭션에서 처리한 작업이 완료(COMMIT)되지 않았음에도, 다른 트랜잭션에서 해당 작업에 의한 데이터를 볼 수 있는 현상이다. 이는 데이터의 일관성을 깨뜨려서 정합성에 큰 문제를 일으킬 수 있다.

ex) A,B 트랜잭션

A -> 선재의 계좌 잔액 10000에서 5000으로 업데이트

B -> 선재 계좌 잔액 5000 읽기

B -> 선재 계좌 잔액(5000)에서 3000 빼기해서 업데이트 -> 2000

A -> 선재 계좌 잔액 업데이트 작업을 ROLLBACK -> 다시 선재 계좌는 10000

B -> 선재 계좌 업데이트 한 내역 COMMIT

 

A에서 선재 계좌를 10000으로 다시 롤백했음에도, B에서 커밋되기 전 선재 계좌 잔액을 읽어와서 작업함으로써 데이터 정합성이 깨지게 됨

 

  • 반복 읽기 불가능 : 같은 트랜잭션 내에서는 항상 같은 결과를 가져와야 한다는 REAETABLE READ 정합성에 어긋나는 현상

하나의 트랜잭션에서 쿼리에 대한 결과값을 정확히 예측할 수 없게 된다. 이로 인해 반환 결과를 기반으로 추가적인 작업을 해야 하는 상황에 문제가 발생할 수 있다.

 

ex) A가 장바구니에서 최종적으로 총액을 확인하고 결제하는 과정은 하나의 트랜잭션으로 이루어져야한다. 

5만원 이상 시 20% 할인이 발생하는 이벤트에서, 사용자 A가 5만원을 맞추고 결제를 하려고 한다. 이 때 사용자 B가 특정 상품의 가격을 변경하고 커밋완료한다. 사용자 A의 장바구니의 총액이 5만원 이하가 되면서(사용자 B가 커밋완료한 데이터를 읽게 됨), 20% 할인을 받지 못하게 된다. 

 

  • 팬텀 리드 : 다른 트랜잭션에 의해 레코드가 추가되거나 삭제될 경우, 데이터가 보였다 안 보였다 하는 현상.

REPEATABLE READ에서는 언두 영역을 통해 일관된 읽기를 보장하지만, 새로운 행의 삽입은 이전 버전이 없기 때문에 언두 영역을 통해 일관성을 유지할 수가 없게 된다. 즉, 기존 행에 대해서는 변경이 돼도 언두 영역을 통해 이전 버전을 확인함으로써 일관된 데이터를 읽어올 수 있지만 새로운 행은 포함될 수가 있어서 팬텀 리드가 발생할 수 있는 것이다.

 

ex) A에서 2만원 이상의 상품 개수 select -> 10개

B에서 2만1천원 상품 추가 후 커밋

A에서 다시 select -> 11개 

 

이처럼 조건을 만족하는 다른 트랜잭션에서 중간에 추가된 행이 읽어짐

 

--> MySQL 의 InnoDB 엔진은 갭 락(Gap Lock) 과 넥스트 키 락 (Next-Key Lock) 을 통해 팬텀 리드를 방지한다.

  •  갭 락
    • 두 인덱스 값 사이의 "갭" 에 잠금을 건다. -> 특정 범위 내에 새로운 행이 삽입되지 않도록 잠금
    • 인덱스 값 : 10,20,30
SELECT * FROM products WHERE price BETWEEN 15 AND 25 FOR UPDATE;

 

(10, 20) : 10과 20 사이의 갭

(20, 30)  : 20과 30 사이의 갭에 갭 락이 걸린다.

-> 15,25 범위 삽입을 방지한다, 예를 들어 18 삽입을 시도하면 갭 락에 의해 블록된다.

-> 그러나, 이미 존재하는 인덱스 값(ex : 20) 에 대한 수정은 가능한다. 

-> 즉 갭 락은 범위에 대해서만 영향

 

  • 넥스트 키 락
    • record lock과 gap lock을 함께 사용하는 lock이다.
SELECT * FROM products WHERE price BETWEEN 15 AND 25 FOR UPDATE;

(10, 20) : 인덱스 레코드 10과 그 다음 갭

(20, 30)  : 인덱스 레코드 20과 그 다음 갭에 넥스트 키 락이 걸린다.

 

-> 갭 락과 달리 20 인덱스 값도 수정하거나 삭제할 수 없다. 

-> 즉 넥스트 키 락은 인덱 값 자체와 범위까지 영향

 

FOR UPDATE(쓰기 잠금=배타적 잠금), FOR SHARE(공유 잠금)로 명시적 잠금을 걸 수 있다.

배타 락은 해제될 때(commit)까지 해당 데이터에 접근 X 

공유 락은 읽는 동안에만 잠금이 걸림, 다른 공유 락끼리 접근 가능, 배타 락과 호환 안 됨 -> 즉 다른 트랜잭션에서 update 수행한 레코드에 대해 select 할 수 없음

 

 

격리 수준 설명 발생 가능한 문제점 고립 정도
READ UNCOMMITTED 커밋되지 않은 변경 사항 읽기 가능 더티 리드
반복 읽기 불가능 
팬텀리드
낮음
READ COMMITTED 커밋된 변경 사항만 읽기 가능

커밋 되지 않는 변경 사항을 읽으려 했을 때는 언두 영역에 백업된 데이터를 반환함.
반복 읽기 불가능
팬텀리드
중간
REPEATABLE READ 트랜잭션 시작 시점의 데이터 스냅샷 읽기 가능 -> 즉, 트랜잭션이 완전 종료되기 전까지는 동일 쿼리에 대해 동일한 결과값만 조회됨을 보장함


트랜잭션 번호를 참고하여 자신보다 먼저 실행된 트랜잭션의 데이터만을 참조함. 즉, 자신이 (10)이라면 1...9까지 조회 가능, 이후 트랜잭션에 의한 데이터는 언두 로그를 참조해서 데이터를 조회한다. 
팬텀 리드

BUT, InnoDB 엔진에서는 갭 락, 넥스트 키 락을 통해 팬텀리드가 발생하지 않는다.
-> 하지만 갭 락과 넥스트 키 락은 여러 데드락 문제를 발생시킬 수 있음으로 최대한 사용을 자제하는 게 좋다. 
높음
SERIALIZABLE 트랜잭션이 완전히 순차적으로 실행되는 것 처럼 동작

읽기 작업에도 잠금을 획득해서 실행된다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 접근할 수 없다.
성능 저하, 잠금 경합 매우 높음

 

READ COMMITTED 에서는 트랜잭션 내의 select과 외부의 selcet에 큰 차이가 없다. -> 다른 트랜잭션에서 커밋 완료된 데이터가 select 될 수 있으니까

REPETABLE READ은 한 트랜잭션 범위 내에서의 selcet과 외부 select에 차이가 있음. -> 외부 select은 다른 트랜잭션에서 커밋해서 변경된 데이터 읽어오지만, 트랜잭션 내부에서는 아직 트랜잭션이 끝나지 않았다면 아무리 다른 트랜잭션에서 데이터를 commit 해서 변경해도, 동일 데이터만 select됨

 

 

2. 스프링에서 @Transactional 어노테이션을 통한 트랜잭션 관리

 

스프링에서는 @Transactional 어노테이션을 통해 트랜잭션 경계를 선언적으로 정의해서 사용한다. 명시적으로 트랜잭션 관리 코드를 비즈니스 로직에 포함시키지 않고 트랜잭션을 실행할 수 있다. 

 

클래스 수준에서의 @Transactional 사용

해당 클래스의 모든 메서드에 트랜잭션이 적용된다.

메서드 수준에서의 @Transactional 사용

특정 메서드에만 트랜잭션이 적용된다. 트랜잭션이 필요한 메서드와 불필요한 메서드를 구분한다.

 

트랜잭션 전파 설정

트랜잭션 전파(propagation): 현재 트랜잭션이 존재하는 경우, 새로운 트랜잭션을 생성할 지 기존 트랜잭션을 사용할 지를 결정한다.

  • REQUIRED: 기본 설정, 현재 트랜잭션이 존재하면 이를 사용하고, 존재하지 않으면 새로운 트랜잭션을 생성
  • REQUIRES_NEW: 항상 새로운 트랜잭션을 생성. 기존 트랜잭션은 보류
  • NESTED: 현재 트랜잭션 내에서 중첩된 트랜잭션을 생성
  • MANDATORY: 현재 트랜잭션이 존재하지 않으면 예외 발생
  • SUPPORTS: 현재 트랜잭션이 존재하면 이를 사용, 존재하지 않으면 트랜잭션 없이 실행.
  • NOT_SUPPORTED: 현재 트랜잭션이 존재하면 이를 보류, 트랜잭션 없이 실행.
  • NEVER: 현재 트랜잭션이 존재하면 예외 발생

예시) 

  • OrderService: 주문 생성
  • PaymentService: 결제 처리
@Service
public class OrderService {

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private InventoryService inventoryService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(Order order) {
        // 주문 생성 로직
        createOrder(order);

        // 결제 처리
        paymentService.processPayment(order.getPayment());
    }

    private void createOrder(Order order) {
        // 주문 저장 로직
    }
}

 

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPayment(Payment payment) {
        // 결제 처리 로직
    }
}
  1. REQUIRED: OrderService의 placeOrder 메서드는 REQUIRED 전파 수준을 사용합니다. 이 메서드가 호출되면 새로운 트랜잭션이 시작됩니다(존재하지 않는 경우).
  2. REQUIRES_NEW: PaymentService의 processPayment 메서드는 REQUIRES_NEW 전파 수준을 사용 -> 이 메서드가 호출되면 항상 새로운 트랜잭션이 생성 ->이를 통해 결제 처리 중에 문제가 발생해도 주문 생성은 영향을 받지 않는다. 

트랜잭션 속성 설정

트랜잭션 동작 방식을 제어함

  • readOnly: 기본 값, 트랜잭션을 읽기 전용으로 설정하여, 데이터베이스에 대한 변경이 없음을 보장.
  • timeout: 트랜잭션이 롤백되기 전까지 대기할 최대 시간을 설정
  • rollbackFor: 특정 예외가 발생할 때 트랜잭션을 롤백하도록 설정
  • noRollbackFor: 특정 예외가 발생해도 트랜잭션을 롤백하지 않도록 설정

트랜잭션 예외 처리

트랜잭션 내에서 예외가 발생할 시, 롤백으로 처리한다. 여러 커스텀 예외 상황이 될 수 있다. 

'DataBase' 카테고리의 다른 글

인덱스는 언제, 왜, 어떻게 사용해야 좋을까?  (0) 2024.07.31