Skip to content

Dev

트랜잭션 격리레벨

Transaction2 min read

데이터베이스는 여러 쓰레드(사용자)들이 사용하게 된다. 때로는 동일한 데이터에 동시에 여러 쓰레드들이 접근하여 데이터를 조작 할 때 동시성 문제로 인하여 데이터의 정합성이 깨질 수도 있다. 이러한 동시성 문제를 해결할 때 쓰레드간의 트랜잭션의 고립수준을 정하여 트랜잭션간 영향도 범위를 정하기 위하여 격리레벨을 사용한다.

이번 포스팅에서는 트랜잭션 격리레벨에 대한 개념정리와 spring의 @Transactional 어노테이션을 사용하여 격리레벨이 소스코드상에서 어떻게 진행되는지 확인하는 방식으로 내용을 정리하였다.

트랜잭션 격리레벨은 총 4개로 구성되어 있다. 고립수준이 높아질 수록 동시성에 대한 데이터의 고립성 혹은 안전성은 높지만 동시성에 대한 처리는 낮아지게 된다.

Isolation LevelDirty ReadNonrepeatable ReadPhantom Read
Read Uncommittedooo
Read Committedxoo
Repeatable Readxxo
Serializablexxx

위의 표는 트랜잭션 격리레벨에 따라 어떤 동시성 이슈가 발생하는지 정리된 표이다. 위 표에 대한 자세한 내용은 아래의 각각의 격리레벨에 대한 설명에서 자세히 다루겠다.

  1. Read Uncommitted
    일반적으로 거의 사용하지 않는 레벨이다. Read UnCommitted는 특정 트랜잭션이 commit, rollback 여부와 상관없이 다른 트랜잭션에서 보여진다. Commit 혹은 Rollback의 결과 이전에 다른 트랜잭션에서 보여지기 때문에 Dirty ReadDirty Wirte 현상이 발생할 수 있다. (Dirty Read/Wirte는 동시에 트랜잭션이 발생되었을 때, 커밋되지 않는 사항에 대해서 읽거나 수정을 할때를 일컫는다)

    1현재 DB에는 product 테이블에 사과상품이 500원으로 되어있다
    2
    3 +-------+ +---+ +------+
    4 |트랜잭션A| | DB | |트랜잭션B|
    5 +-------+ +---+ +------+
    6 | | |
    7 | update product | |
    8 | set price = 1000 | |
    9 | where name = '사과' | select price |
    10 | -------------------+ | from product |
    11 | | | where name ='사과' |
    12 | | | +-------------------- |
    13 | | | | (Dirty Read 발생) |
    14 | <----- A 커밋 ------+ | +------------------> |
    15 | | |
    16 | | |
  • 트랜잭션A가 사과 상품의 가격을 500원에서 1000원으로 변경한다.
  • 트랜잭션B가 사과 상품의 가격을 조회하면, 아직 커밋되지 않는 1000원의 가격을 조회한다.
  • 트랜잭션 A에 대해 커밋완료한다.
    만약 트랜잭션 A가 롤백이 된다면 업데이트한 사과의 가격 1000원은 취소되고 다시 500원이 되지만, 커밋되지 않는 데이터를 트랜잭션B는 읽었기 때문에(Dirty Read) 1000원의 가격을 조회한 결과가 나오게 되어 데이터의 정합성이 깨진다. 이러한 부분때문에 Read Uncommitted 격리레벨은 거의 사용하지 않는다.
  1. Read Committed
    커밋된 데이터만 읽고, 쓸수 있는 레벨이기 때문에 Read Uncommited에서 발생되었던 Dirty Read 혹은 Dirty Write 현상이 발생하지 않는다. 커밋된 데이터와 트랜잭션 진행중인 데이터를 별도의 공간으로 보관하기 때문에 커밋된 데이터만 읽을 수 있도록 보장한다. 또한, 데이터 쓰기는 행단위 잠금을 사용하는데, 동일한 데이터에 동시에 여러 트랜잭션이 같은 데이터를 수정한다면 먼저 수정에 들어간 트랜잭션이 끝날때까지 대기상태에 있는다.

    1현재 DB에 저장되어있는 데이터
    2 +-------------------+
    3 | ID | name | price |
    4 +-------------------+
    5 | 1 | 사과 | 500 |
    6 +-------------------+
    7
    8 +-------+ +---+ +------+
    9 |트랜잭션A| | DB | |트랜잭션B|
    10 +-------+ +---+ +------+
    11 | | |
    12 | update product | |
    13 | set price = 1000 | |
    14 | where name = '사과' | select price |
    15 | -------------------+ | from product |
    16 | | | where name ='사과' |
    17 | | | +------------------ |
    18 | | | | |
    19 | | | +------------------> |
    20 | | | 커밋되어있는 500원 리턴 |
    21 | <----- A 커밋 -----+ | |
    22 | | |
    23 | | |

    커밋되어있는 데이터만 읽거나 쓰기 때문에 Dirty Read/Write 현상이 발생되지 않는다. 하지만, 하나의 트랜잭션에서 동일한 쿼리를 실행할 때 읽는 시점에 따라 데이터가 달라지는 현상이 발생할 수 있다.

    1현재 DB에 저장되어있는 데이터
    2+-------------------+
    3| ID | name | price |
    4+-------------------+
    5| 1 | 사과 | 500 |
    6+-------------------+
    7
    8+-------+ +---+ +------+
    9|트랜잭션A| | DB | |트랜잭션B|
    10+-------+ +---+ +------+
    11 | | |
    12 | update product | |
    13 | set price = 1000 | |
    14 | where name = '사과' | select price |
    15 | -------------------+ | from product |
    16 | | | where name ='사과' |
    17 | | | +------------------ |
    18 | | | | |
    19 | | | +--- 500원 리턴 -----> |
    20 | | | |
    21 | <----- A 커밋 -----+ | |
    22 | | |
    23 | | select price |
    24 | | from product |
    25 | | where name = '사과' |
    26 | | +------------------- |
    27 | | | |
    28 | | +--- 1000원 리턴 ---> |
    29 | | |
  • 트랜잭션A가 사과 상품가격을 1000원으로 변경한다.

  • 트랜잭션B가 사과 상품가격을 조회한다. 이때 이미 커밋된 내용인 500원을 리턴받는다.

  • 트랜잭션A의 상품가격 1000원 변경건이 커밋된다.

  • 트랜잭션B가 사과 상품가격을 조회한다. 이때 커밋된 내용인 1000원을 리턴받는다.

    트랜잭션 B는 하나의 트랜잭션 내에서 동일한 조회 쿼리를 수행하였지만 읽는 시점에 따라 다른 결과를 받아 데이터가 다르게 나오는 문제가 발생한다. 이를 Nonrepeatable Read 현상이라 한다.

    v. ReadCommitted의 Nonrepeatable Read 현상 테스트

    1public interface FruitRepository extends JpaRepository<Fruit, Long> {
    2 @Transactional(isolation = Isolation.READ_COMMITTED)
    3 Fruit findFruitById(Long id);
    4}

    데이터를 조회할 때 격리레벨 READ_COMMITED로 지정하였다.

1@Transactional(readOnly = true)
2 public void readCommitted(Long id) {
3 Fruit fruit1 = fruitRepository.findFruitById(id);
4 log.info("before : {}", fruit1.getPrice());
5
6 try {
7 Thread.sleep(5000);
8 } catch (InterruptedException e) {
9 e.printStackTrace();
10 }
11
12 entityManager.detach(fruit1); //영속성 캐싱으로 인하여 비영속 상태로 처리
13 Fruit fruit2 = fruitRepository.findFruitById(id);
14 log.info("after : {}", fruit2.getPrice());
15
16 if(fruit1.getPrice() != fruit2.getPrice()) {
17 throw new RuntimeException("다르다");
18 }
19 }
20
21 @Transactional(isolation = Isolation.READ_COMMITTED)
22 public void updatePrice_READCOMMITED(Long id, int updatePrice) {
23 Fruit fruit = fruitRepository.findFruitById(id);
24
25 fruit.updatePrice(updatePrice);
26 }

조회 부분은 과일이라는 객체에 대해 두 번의 동일한 조회요청이 이루어진다. 조회 후 과일의 가격 값이 다르면 RuntimeException이 발생되도록 만들었다. 그리고 과일 객체의 상태값을 업데이트 할 수 있는 서비스 기능도 제공하여 두 개의 트랜잭션이 발생했을 때 Nonrepeatable현상이 발생하는지 확인 할 수 있다.

1@Test
2 public void readCommitted() throws Exception {
3 int threadCount = 50;
4 ExecutorService selectorExecutor = Executors.newFixedThreadPool(10);
5 ExecutorService updatorExecutor = Executors.newFixedThreadPool(10);
6
7 CountDownLatch countDownLatch = new CountDownLatch(threadCount);
8 CountDownLatch countDownLatch1 = new CountDownLatch(threadCount);
9
10 AtomicInteger success = new AtomicInteger();
11 AtomicInteger fail = new AtomicInteger();
12
13 for (int i = 0; i < threadCount; i++) {
14 updatorExecutor.execute(() -> {
15 service.updatePrice_READCOMMITED(fruit.getId(), 300);
16 countDownLatch1.countDown();
17 });
18 selectorExecutor.execute(() -> {
19 try {
20 service.readCommitted(fruit.getId());
21 success.incrementAndGet();
22 }catch (Exception e) {
23 fail.incrementAndGet();
24 }
25 countDownLatch.countDown();
26 });
27 }
28 countDownLatch.await();
29 countDownLatch1.await();
30
31 assertThat(success.get()).isNotEqualTo(threadCount);
32 }

조회요청을 하는 쓰레드와 내용을 업데이트 쓰레드를 실행시켜 fruit의 가격이 다를 경우 RuntimeException이 발생되어 AtomicInteger의 fail값이 증가되고, 성공할 경우 success값이 증가하는 방식으로 테스트 환경을 만들었다.

Nonrepeatable 현상이 발생하여 조회요청을 하는 쓰레드 갯수와 성공갯수가 달라야 테스트가 성공한다. 두 트랜잭션이 커밋과 업데이트 시점이 상호 영향이 안가는 범위내에서 운이 좋게 Nonrepeatable 현상이 발생이 안 될 수도 있기에 조회 요청 Thread Count값을 되도록이면 크게 잡는 것이 좋다.

readCommitted 테스트 성공 (테스트 통과)

  1. Repeatable Read
    하나의 트랜잭션이 진행되는 동안은 같은 데이터를 읽게 해줄 수 있도록 보장한다. 트랜잭션을 버젼관리를 함으로써 읽는 시점에 특정 버전에 해당되는 레코드를 읽게 된다. MVCC(Multi Version Concurrency Content)라고도 하며, 하나의 레코드에 대해서 여러 버전을 관리한다.

    1version1(현재 저장되어있는 데이터) version2 (트랜잭션B에서 업데이트 후 저장되는 데이터)
    2 +---------------------+ +---------------------+
    3 | ID | name | price | | ID | name | price |
    4 +---------------------+ +---------------------+
    5 | 1 | apple | 500 | | 1 | apple | 500 |
    6 +---------------------+ +---------------------+
    7 | 2 | banana | 1000 | | 2 | banana | 2000 |
    8 +---------------------+ +---------------------+
    9
    10 +-------+ +---+ +------+
    11 |트랜잭션A| | DB | |트랜잭션B|
    12 +-------+ +---+ +------+
    13 | | |
    14 | select price | |
    15 | from product | |
    16 | where name = banana | update product |
    17 | ---- verson1 ----+ | set price = 2000 |
    18 | | | where name = banana |
    19 | <----- 1000------+ | +------------------ |
    20 | | | |
    21 | | +--commit version2-> |
    22 | | |
    23 | | |
    24 | | |
    25 | select price | select price |
    26 | from product | from product |
    27 | where name = banana | where name = banana |
    28 | ----- verson1 ----+ | +---- verison2 ----- |
    29 | | | | |
    30 | <------ 1000 -----+ | +----- 2000 -------> |
    31 | | |

    버전관리를 함으로써 동일한 쿼리에 동일한 데이터를 읽을 수 있도록 보장한다. 하지만, 동일한 레코드를 여러 트랜잭션에서 수정할 때 문제가 발생할 수 있다.

    1현재 저장되어있는 DB
    2 +-------------------------+
    3 | name | totalInvestor |
    4 +-------------------------+
    5 | A | 1 |
    6 +-------------------------+
    7
    8 +-------+ +---+ +------+
    9 |트랜잭션A| | DB | |트랜잭션B|
    10 +-------+ +---+ +------+
    11 | | |
    12 | select totalInvestor | |
    13 | from product | |
    14 | where name = A | select totalInvestor |
    15 | -----------------+ | from product |
    16 | | | where name = A |
    17 | <----- 1 ------+ | +------------------ |
    18 | | | |
    19 | | +------ 1 -------> |
    20 | | |
    21 | update product | |
    22 | set totalInvestor= 2 | |
    23 | where name = A | |
    24 | ------------------+ | update product |
    25 | | | set totalInvestor= 2 |
    26 | | | where name = A |
    27 | | | +------------------- |
    28 | | | | |
    29 | <------ commit ----+ | | |
    30 | | | |
    31 | | | |
    32 | | +----- commit ------>|
    33 | | |

    트랜잭션B에서 기대값은 3이지만 2가 되는 문제가 발생할 수 있다.

  2. Serializable
    트랜잭션이 서로 완전히 격리되는 가장 높은 단계이다. 다른 트랜잭션에서 수정 중인 데이터를 읽거나 수정할 수 없으며, 다른 트랜잭션B에서 조회중인 데이터조차도 읽거나 수정할 수 없다.

    해당 격리수준은 특정 트랜잭션이 종료될때까지 잠금을 보유한다.

[Refference]