트랜잭션 격리 수준 이해 및 스프링부트 실습

리본24·2025년 1월 27일

Spring

목록 보기
3/7
post-thumbnail

1. 트랜잭션 격리 수준 개념

1.1 ACID 원칙 복습

트랜잭션은 데이터베이스에서 작업 단위를 정의하며, 데이터 정합성을 보장하기 위해 다음 네 가지 ACID 원칙을 준수해야 합니다.

  1. Atomicity(원자성)
    • 트랜잭션 내의 모든 작업이 성공적으로 완료되거나, 전혀 수행되지 않아야 합니다.
    • 예를 들어, 은행 계좌 이체 시, 송금 계좌의 금액 감소와 수신 계좌의 금액 증가가 하나의 작업 단위로 처리되어야 하며, 둘 중 하나라도 실패하면 모든 작업이 롤백되어야 합니다.
  2. Consistency(일관성)
    • 트랜잭션이 완료되면 데이터베이스는 항상 일관된 상태를 유지해야 합니다.
    • 예를 들어, 수학적으로 계산된 데이터 값이 항상 올바르게 유지되거나, 제약 조건이 위배되지 않아야 합니다.
  3. Isolation(격리성)
    • 여러 트랜잭션이 동시에 실행되더라도 각 트랜잭션은 서로 독립적으로 실행되어야 합니다.
    • 예를 들어, 한 트랜잭션이 데이터를 읽는 동안 다른 트랜잭션이 데이터를 수정하지 못하도록 격리해야 합니다.
  4. Durability(지속성)
    • 트랜잭션이 성공적으로 커밋되면, 시스템 오류나 장애가 발생하더라도 데이터는 영구적으로 저장되어야 합니다.
    • 예를 들어, 트랜잭션이 완료된 후 데이터가 디스크에 안전하게 기록되어야 합니다.

1.2 격리 수준이 동시성 문제 해결에 미치는 영향

트랜잭션 격리 수준은 동시에 실행되는 여러 트랜잭션 간의 상호작용을 제어하여 동시성 문제를 방지하는 데 중요한 역할을 합니다. 격리 수준이 높을수록 트랜잭션 간의 간섭을 최소화하여 데이터 정합성을 보장할 수 있지만, 반대로 성능 저하를 초래할 수 있습니다. 따라서 시스템의 요구사항에 따라 적절한 격리 수준을 선택해야 합니다.

1.3 격리 수준의 종류

1. Read Uncommitted

  • 특징
    • 커밋되지 않은 데이터를 읽을 수 있습니다.
    • 데이터 정합성이 보장되지 않으므로 Dirty Read 문제가 발생할 수 있습니다.
  • 장점
    • 가장 낮은 격리 수준으로, 높은 성능을 제공합니다.
  • 단점
    • Dirty Read, Non-Repeatable Read, Phantom Read 문제가 발생할 수 있습니다.

2. Read Committed

  • 특징
    • 커밋된 데이터만 읽을 수 있어 Dirty Read를 방지합니다.
    • Non-Repeatable Read와 Phantom Read는 여전히 발생할 수 있습니다.
  • 장점
    • Dirty Read를 방지하며, 성능과 데이터 정합성 간의 균형을 제공합니다.
  • 단점
    • 반복 조회 시 데이터가 변경될 가능성이 있습니다(Non-Repeatable Read).

3. Repeatable Read

  • 특징
    • 동일 데이터를 반복 조회할 때 항상 같은 값을 읽을 수 있어 Non-Repeatable Read를 방지합니다.
    • 데이터의 추가나 삭제로 인해 Phantom Read가 발생할 가능성은 있습니다.
  • 장점
    • 대부분의 동시성 문제를 방지하며, 데이터 정합성을 보장합니다.
  • 단점
    • Phantom Read를 방지하지 못하며, 성능이 Read Committed보다 낮을 수 있습니다.

4. Serializable

  • 특징
    • 트랜잭션이 순차적으로 실행되는 것처럼 동작하여 Dirty Read, Non-Repeatable Read, Phantom Read를 모두 방지합니다.
  • 장점
    • 데이터 정합성을 가장 확실하게 보장합니다.
  • 단점
    • 동시 실행 트랜잭션의 수가 크게 줄어들어 성능 저하가 발생할 수 있습니다.

2. 격리 수준별 동시성 문제 비교

2.1 Dirty Read

문제 정의

Dirty Read는 하나의 트랜잭션이 커밋되지 않은 데이터를 읽는 상황을 말합니다. 커밋되지 않은 데이터는 롤백될 가능성이 있기 때문에, 이 데이터를 읽어 작업을 수행하면 잘못된 결과를 초래할 수 있습니다.

발생 시나리오

  1. 트랜잭션 A가 데이터를 수정한 후 커밋하지 않고 대기합니다.
  2. 트랜잭션 B가 수정 중인 데이터를 읽습니다.
  3. 이후 트랜잭션 A가 롤백하면, 트랜잭션 B는 유효하지 않은 데이터를 기반으로 작업을 수행하게 됩니다.

발생 가능 격리 수준

  • Read Uncommitted: 커밋되지 않은 데이터를 읽을 수 있으므로 Dirty Read가 발생합니다.

방지 격리 수준

  • Read Committed: 커밋된 데이터만 읽을 수 있으므로 Dirty Read가 방지됩니다.
  • Repeatable ReadSerializable: Dirty Read를 완벽히 방지합니다.

2.2 Non-Repeatable Read

문제 정의

Non-Repeatable Read는 동일 데이터를 반복 조회할 때, 다른 트랜잭션의 변경으로 인해 값이 달라지는 상황을 말합니다. 이는 데이터의 일관성을 보장하지 못하는 문제를 초래합니다.

발생 시나리오

  1. 트랜잭션 A가 특정 데이터를 조회합니다.
  2. 트랜잭션 B가 해당 데이터를 수정하고 커밋합니다.
  3. 트랜잭션 A가 동일 데이터를 다시 조회하면, 값이 변경된 것을 확인할 수 있습니다.

발생 가능 격리 수준

  • Read Uncommitted: 커밋되지 않은 변경도 읽을 수 있으므로 Non-Repeatable Read가 발생합니다.
  • Read Committed: 커밋된 데이터만 읽을 수 있지만, 반복 조회 시 데이터가 변경될 수 있어 Non-Repeatable Read가 발생합니다.

방지 격리 수준

  • Repeatable Read: 동일 데이터를 반복 조회할 때 항상 같은 값을 읽으므로 Non-Repeatable Read가 방지됩니다.
  • Serializable: 트랜잭션이 순차적으로 실행되므로 Non-Repeatable Read를 완벽히 방지합니다.

2.3 Phantom Read

문제 정의

Phantom Read는 트랜잭션 도중 다른 트랜잭션에 의해 조건에 맞는 데이터가 추가되거나 삭제되어, 같은 조건으로 데이터를 조회했을 때 결과가 달라지는 상황을 말합니다. 이는 주로 삽입(INSERT)이나 삭제(DELETE)가 포함된 트랜잭션에서 발생합니다.

발생 시나리오

  1. 트랜잭션 A가 WHERE 조건을 사용해 특정 데이터를 조회합니다.
  2. 트랜잭션 B가 조건에 맞는 데이터를 새로 삽입하거나 삭제하고 커밋합니다.
  3. 트랜잭션 A가 동일한 조건으로 데이터를 다시 조회하면, 결과가 달라집니다.

발생 가능 격리 수준

  • Read Uncommitted: 트랜잭션 도중 조건에 맞는 데이터가 추가/삭제되어 Phantom Read가 발생합니다.
  • Read Committed: 커밋된 데이터만 읽을 수 있지만, 추가/삭제된 데이터는 영향을 미칠 수 있어 Phantom Read가 발생합니다.
  • Repeatable Read: 데이터의 추가/삭제는 통제하지 못하므로 Phantom Read가 발생할 수 있습니다.

방지 격리 수준

  • Serializable: 트랜잭션이 순차적으로 실행되므로 Phantom Read를 완벽히 방지합니다.
동시성 문제문제 정의발생 가능 격리 수준방지 격리 수준
Dirty Read커밋되지 않은 데이터를 읽음Read UncommittedRead Committed 이상
Non-Repeatable Read동일 데이터를 반복 조회 시 값이 달라짐Read Uncommitted, Read CommittedRepeatable Read 이상
Phantom Read조건에 맞는 데이터의 추가/삭제로 인해 조회 결과가 달라짐Read Uncommitted, Read Committed, Repeatable ReadSerializable

격리 수준에 따라 각 동시성 문제를 방지할 수 있는 범위가 다르므로, 애플리케이션의 요구사항에 맞게 적절한 격리 수준을 설정해야 합니다.


3. 트랜젝션 격리 수준 실습

3.1 MySQL 격리 수준 변경

MySQL에서 트랜잭션 격리 수준을 설정하여 트랜잭션 간의 동작 방식을 제어할 수 있습니다. 격리 수준은 데이터베이스 전체(Global) 또는 특정 세션(Session)에 대해 설정 가능합니다.

  1. 데이터베이스 전체 격리 수준 변경

    데이터베이스의 모든 세션에 대해 기본 격리 수준을 변경합니다.

    SET GLOBAL TRANSACTION ISOLATION LEVEL <격리 수준>;

    예: Read Committed로 변경

    SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
  2. 세션 격리 수준 변경

    특정 세션에 대해서만 격리 수준을 설정합니다.

    SET SESSION TRANSACTION ISOLATION LEVEL <격리 수준>;

    예: Repeatable Read로 설정

    SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  3. 현재 격리 수준 확인

    현재 세션의 격리 수준을 확인합니다.

    SELECT @@transaction_isolation;

3.2 Dirty Read 문제 재현 및 해결

1. Dirty Read 문제 재현

격리 수준을 Read Uncommitted로 설정하여 커밋되지 않은 데이터를 읽는 Dirty Read 문제를 재현합니다.

-- 트랜잭션 A: 데이터 수정
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE products SET stock = stock - 10 WHERE id = 1;
-- 커밋하지 않고 대기

-- 트랜잭션 B: 수정 중인 데이터 읽기
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- Dirty Read 발생

2. Read Committed로 해결

격리 수준을 Read Committed로 설정하여 커밋되지 않은 데이터를 읽는 문제를 방지합니다.

-- 트랜잭션 A: 데이터 수정
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE products SET stock = stock - 10 WHERE id = 1;
-- 커밋하지 않고 대기

-- 트랜잭션 B: 데이터 읽기
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- Dirty Read 방지

3.3 Non-Repeatable Read 문제 재현 및 해결

1. Non-Repeatable Read 문제 재현

격리 수준을 Read Committed로 설정하여 동일 데이터를 반복 조회할 때 값이 변경되는 문제를 재현합니다.

-- 트랜잭션 A: 첫 번째 조회
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- 결과: 10

-- 트랜잭션 B: 데이터 수정
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE products SET stock = stock - 5 WHERE id = 1;
COMMIT;

-- 트랜잭션 A: 두 번째 조회
SELECT stock FROM products WHERE id = 1; -- 결과: 5 (Non-Repeatable Read 발생)

2. Repeatable Read로 해결

격리 수준을 Repeatable Read로 설정하여 동일 데이터를 반복 조회할 때 항상 같은 값을 읽도록 설정합니다.

-- 트랜잭션 A: 첫 번째 조회
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- 결과: 10

-- 트랜잭션 B: 데이터 수정
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE products SET stock = stock - 5 WHERE id = 1;
COMMIT;

-- 트랜잭션 A: 두 번째 조회
SELECT stock FROM products WHERE id = 1; -- 결과: 10 (Non-Repeatable Read 방지)

3.4 Phantom Read 문제 재현 및 해결

1. Phantom Read 문제 재현

격리 수준을 Repeatable Read로 설정한 상태에서 데이터 추가로 인해 조회 결과가 달라지는 Phantom Read 문제를 재현합니다.

-- 트랜잭션 A: 첫 번째 조회
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM products WHERE stock > 10; -- 결과: 2개

-- 트랜잭션 B: 새로운 데이터 추가
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
INSERT INTO products (id, name, stock) VALUES (3, 'New Product', 15);
COMMIT;

-- 트랜잭션 A: 두 번째 조회
SELECT * FROM products WHERE stock > 10; -- 결과: 3개 (Phantom Read 발생)

2. Serializable로 해결

격리 수준을 Serializable로 설정하여 Phantom Read를 방지합니다.

-- 트랜잭션 A: 첫 번째 조회
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM products WHERE stock > 10; -- 결과: 2개

-- 트랜잭션 B: 새로운 데이터 추가 시도
START TRANSACTION;
INSERT INTO products (id, name, stock) VALUES (3, 'New Product', 15); -- 대기
  • Dirty Read: 커밋되지 않은 데이터를 읽는 문제는 Read Committed 이상으로 방지할 수 있습니다.
  • Non-Repeatable Read: 동일 데이터를 반복 조회 시 값이 달라지는 문제는 Repeatable Read 이상으로 방지됩니다.
  • Phantom Read: 조건에 맞는 데이터의 추가/삭제로 조회 결과가 달라지는 문제는 Serializable 수준에서만 방지할 수 있습니다.

4. Spring boot 트랜젝션 격리 수준 실습

4.1 Dirty Read

Dirty Read 서비스 코드

  @Transactional
  public void updateStockWithoutCommit(Long productId, int newStock) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.setStock(newStock);
    productRepository.flush();

    try {
      // Dirty Read가 실행될 때까지 대기
      Thread.sleep(3000);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }

    // 롤백 강제
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  }

  @Transactional(isolation = Isolation.READ_COMMITTED) // Dirty Read 방지
  public int getStockWithReadCommitted(Long productId) {
    Product product = productRepository.findById(productId).orElseThrow();
    return product.getStock();
  }

Dirty Read 테스트 코드

  @Test
  void testDirtyReadPrevented() throws InterruptedException {
    // Given
    Product firstProduct = productRepository.findAll().get(0);
    Long productId = firstProduct.getId();

    Thread threadA = new Thread(() -> {
      // 트랜잭션 A: stock을 업데이트하고 커밋하지 않음
      productIsolationService.updateStockWithoutCommit(productId, 10);
      try {
        Thread.sleep(3000); // 커밋 없이 대기
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    });

    Thread threadB = new Thread(() -> {
      try {
        Thread.sleep(1000); // 트랜잭션 A 이후 실행
        // 트랜잭션 B: Read Committed로 데이터를 읽음
        int stock = productIsolationService.getStockWithReadCommitted(productId);
        System.out.println("Read Committed: Stock = " + stock);
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    });

    // When
    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    // Then
    Product product = productRepository.findById(productId).orElseThrow();
    Assertions.assertEquals(firstProduct.getStock(), product.getStock(),
        "Dirty Read 방지로 원래 값이 유지되어야 함");
  }
  • Thread A (트랜잭션 A):
    • updateStockWithoutCommit 메서드를 호출하여 재고를 10으로 업데이트하고, 커밋 없이 3초 동안 대기합니다.
  • Thread B (트랜잭션 B):
    • getStockWithReadCommitted 메서드를 호출하여 Dirty Read를 방지한 데이터를 읽습니다.
    • 트랜잭션 A가 커밋되지 않았기 때문에 stock 값은 기존 값으로 유지됩니다.
  • 검증:
    • 트랜잭션 A가 롤백되어도 데이터 일관성이 유지됩니다.

결과 출력 예시

Read Committed: Stock = 20

결론

  • Dirty Read 방지: READ COMMITTED 이상으로 설정하여 해결 가능.
  • Spring Boot에서 @Transactionalisolation 속성을 사용해 트랜잭션 격리 수준을 제어할 수 있습니다.

3.2 Non-Repeatable Read

Non-Repeatable Read 서비스 코드

  @Transactional(isolation = Isolation.REPEATABLE_READ) // Non-Repeatable Read 허용
  public int getStockWithRepeatableRead(Long productId) {
    Product product = productRepository.findById(productId).orElseThrow();
    System.out.println("First Read (Transaction A): Stock = " + product.getStock());

    try {
      Thread.sleep(4000);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
    Product result = productRepository.findById(productId).orElseThrow();
    return result.getStock();
  }

  @Transactional
  public void updateStock(Long productId, int newStock) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.setStock(newStock);
  }

Non-Repeatable Read 테스트 코드

  @Test
  void testNonRepeatableReadPrevented() throws InterruptedException {
    // Given
    Product firstProduct = productRepository.findAll().get(0);
    Long productId = firstProduct.getId();

    Thread threadA = new Thread(() -> {
      // 트랜잭션 A: 데이터 조회
      int stock = productIsolationService.getStockWithRepeatableRead(productId);
      System.out.println("Second Read (Transaction A): Stock = " + stock);
    });

    Thread threadB = new Thread(() -> {
      try {
        Thread.sleep(1000); // 트랜잭션 A의 첫 번째 읽기 이후 실행
        // 트랜잭션 B: 데이터 수정 및 커밋
        productIsolationService.updateStock(productId, 5);
        System.out.println("Transaction B: Updated Stock to 5");
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    });

    // When
    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    // Then
    Product product = productRepository.findById(productId).orElseThrow();
    Assertions.assertEquals(5, product.getStock(),
        "트랜잭션 B의 변경이 최종적으로 반영되어야 함");
  }
  • Thread A (트랜잭션 A):
    • 첫 번째 데이터 읽기: stock = 10.
    • 3초 대기 후 동일 데이터를 다시 읽음: stock = 10 (REPEATABLE READ로 변경 사항 반영 안 됨).
  • Thread B (트랜잭션 B):
    • 데이터 수정: stock = 5.
    • 변경 사항을 커밋함.
  • 검증:
    • 트랜잭션 A는 REPEATABLE READ로 인해 처음 읽은 데이터를 유지함.

결과 출력 예시

First Read (Transaction A): Stock = 10
Transaction B: Updated Stock to 5
Second Read (Transaction A): Stock = 10

결론

  • Non-Repeatable Read 방지: REPEATABLE READ를 설정하여 해결 가능.
  • Spring Boot의 @Transactional에서 isolation 속성을 활용해 트랜잭션 격리 수준을 변경할 수 있음.

3.3 Phantom Read

Phantom Read 서비스 코드

  @Transactional(isolation = Isolation.SERIALIZABLE) // Phantom Read 허용
  public List<Product> getProductsWithSerializable() {
    List<Product> products = productRepository.findAllByStockGreaterThan(5);
    System.out.println("First Read (Transaction A): " + products.size() + " products");

    try {
      Thread.sleep(5000);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }

    return productRepository.findAllByStockGreaterThan(5);
  }

  @Transactional
  public void insertNewProduct(String name, int stock) {
    Category category = categoryRepository.findById(1L)
        .orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_USER));

    Product product = Product.builder()
        .name(name)
        .price(BigDecimal.ONE)
        .stock(stock)
        .category(category)
        .build();
    productRepository.save(product);
  }

Phantom Read 테스트 코드

  @Test
  void testPhantomReadPrevented() throws InterruptedException {
    // Given
    List<Product> firstProducts = productRepository.findAllByStockGreaterThan(5);

    Thread threadA = new Thread(() -> {
      // 트랜잭션 A: 조건에 맞는 데이터 조회
      List<Product> products = productIsolationService.getProductsWithSerializable();
      System.out.println("Second Read (Transaction A): " + products.size() + " products");
    });

    Thread threadB = new Thread(() -> {
      try {
        Thread.sleep(1000); // 트랜잭션 A의 첫 번째 읽기 이후 실행
        // 트랜잭션 B: 새로운 데이터 삽입 시도
        productIsolationService.insertNewProduct("New Product", 10);
        System.out.println("Transaction B: Inserted New Product");
      } catch (Exception e) {
        Thread.currentThread().interrupt();
      }
    });

    // When
    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    // Then
    List<Product> finalProducts = productRepository.findAllByStockGreaterThan(5);
    System.out.println("final products : " + finalProducts.size() + " products");
    Assertions.assertEquals(firstProducts.size() + 1, finalProducts.size(),
        "트랜잭션 B의 삽입이 SERIALIZABLE로 차단되어야 함");
  }
  • Thread A (트랜잭션 A):
    • 첫 번째 데이터 조회: stock > 5 조건에 맞는 2개의 상품.
    • 3초 대기 후 동일 조건으로 데이터를 다시 조회: 결과는 2개로 유지 (Phantom Read 방지).
  • Thread B (트랜잭션 B):
    • 트랜잭션 A가 완료되기 전까지 삽입이 차단됨.
  • 검증:
    • 트랜잭션 B의 삽입이 차단되어 데이터의 일관성 유지.

결과 출력 예시

First Read (Transaction A): 2 products
Transaction B: Insertion blocked due to SERIALIZABLE isolation level
Second Read (Transaction A): 2 products

결론

  • Phantom Read 방지: SERIALIZABLE 격리 수준으로 삽입이나 삭제 차단 가능.
  • Spring Boot의 @Transactional에서 isolation 속성을 활용하여 트랜잭션 격리 수준을 설정할 수 있습니다.
profile
기록하고 소화해보자! 소화가 안되거나 까먹으면 다시 꺼내서 보자! 오늘의 나는 어제의 나보다 강하다!

0개의 댓글