트랜잭션은 데이터베이스에서 작업 단위를 정의하며, 데이터 정합성을 보장하기 위해 다음 네 가지 ACID 원칙을 준수해야 합니다.
트랜잭션 격리 수준은 동시에 실행되는 여러 트랜잭션 간의 상호작용을 제어하여 동시성 문제를 방지하는 데 중요한 역할을 합니다. 격리 수준이 높을수록 트랜잭션 간의 간섭을 최소화하여 데이터 정합성을 보장할 수 있지만, 반대로 성능 저하를 초래할 수 있습니다. 따라서 시스템의 요구사항에 따라 적절한 격리 수준을 선택해야 합니다.
1. Read Uncommitted
2. Read Committed
3. Repeatable Read
4. Serializable
문제 정의
Dirty Read는 하나의 트랜잭션이 커밋되지 않은 데이터를 읽는 상황을 말합니다. 커밋되지 않은 데이터는 롤백될 가능성이 있기 때문에, 이 데이터를 읽어 작업을 수행하면 잘못된 결과를 초래할 수 있습니다.
발생 시나리오
발생 가능 격리 수준
Read Uncommitted: 커밋되지 않은 데이터를 읽을 수 있으므로 Dirty Read가 발생합니다.방지 격리 수준
Read Committed: 커밋된 데이터만 읽을 수 있으므로 Dirty Read가 방지됩니다.Repeatable Read 및 Serializable: Dirty Read를 완벽히 방지합니다.문제 정의
Non-Repeatable Read는 동일 데이터를 반복 조회할 때, 다른 트랜잭션의 변경으로 인해 값이 달라지는 상황을 말합니다. 이는 데이터의 일관성을 보장하지 못하는 문제를 초래합니다.
발생 시나리오
발생 가능 격리 수준
Read Uncommitted: 커밋되지 않은 변경도 읽을 수 있으므로 Non-Repeatable Read가 발생합니다.Read Committed: 커밋된 데이터만 읽을 수 있지만, 반복 조회 시 데이터가 변경될 수 있어 Non-Repeatable Read가 발생합니다.방지 격리 수준
Repeatable Read: 동일 데이터를 반복 조회할 때 항상 같은 값을 읽으므로 Non-Repeatable Read가 방지됩니다.Serializable: 트랜잭션이 순차적으로 실행되므로 Non-Repeatable Read를 완벽히 방지합니다.문제 정의
Phantom Read는 트랜잭션 도중 다른 트랜잭션에 의해 조건에 맞는 데이터가 추가되거나 삭제되어, 같은 조건으로 데이터를 조회했을 때 결과가 달라지는 상황을 말합니다. 이는 주로 삽입(INSERT)이나 삭제(DELETE)가 포함된 트랜잭션에서 발생합니다.
발생 시나리오
WHERE 조건을 사용해 특정 데이터를 조회합니다.발생 가능 격리 수준
Read Uncommitted: 트랜잭션 도중 조건에 맞는 데이터가 추가/삭제되어 Phantom Read가 발생합니다.Read Committed: 커밋된 데이터만 읽을 수 있지만, 추가/삭제된 데이터는 영향을 미칠 수 있어 Phantom Read가 발생합니다.Repeatable Read: 데이터의 추가/삭제는 통제하지 못하므로 Phantom Read가 발생할 수 있습니다.방지 격리 수준
Serializable: 트랜잭션이 순차적으로 실행되므로 Phantom Read를 완벽히 방지합니다.| 동시성 문제 | 문제 정의 | 발생 가능 격리 수준 | 방지 격리 수준 |
|---|---|---|---|
| Dirty Read | 커밋되지 않은 데이터를 읽음 | Read Uncommitted | Read Committed 이상 |
| Non-Repeatable Read | 동일 데이터를 반복 조회 시 값이 달라짐 | Read Uncommitted, Read Committed | Repeatable Read 이상 |
| Phantom Read | 조건에 맞는 데이터의 추가/삭제로 인해 조회 결과가 달라짐 | Read Uncommitted, Read Committed, Repeatable Read | Serializable |
격리 수준에 따라 각 동시성 문제를 방지할 수 있는 범위가 다르므로, 애플리케이션의 요구사항에 맞게 적절한 격리 수준을 설정해야 합니다.
MySQL에서 트랜잭션 격리 수준을 설정하여 트랜잭션 간의 동작 방식을 제어할 수 있습니다. 격리 수준은 데이터베이스 전체(Global) 또는 특정 세션(Session)에 대해 설정 가능합니다.
데이터베이스 전체 격리 수준 변경
데이터베이스의 모든 세션에 대해 기본 격리 수준을 변경합니다.
SET GLOBAL TRANSACTION ISOLATION LEVEL <격리 수준>;
예: Read Committed로 변경
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
세션 격리 수준 변경
특정 세션에 대해서만 격리 수준을 설정합니다.
SET SESSION TRANSACTION ISOLATION LEVEL <격리 수준>;
예: Repeatable Read로 설정
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
현재 격리 수준 확인
현재 세션의 격리 수준을 확인합니다.
SELECT @@transaction_isolation;
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 방지
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 방지)
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); -- 대기
Read Committed 이상으로 방지할 수 있습니다.Repeatable Read 이상으로 방지됩니다.Serializable 수준에서만 방지할 수 있습니다.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 방지로 원래 값이 유지되어야 함");
}
updateStockWithoutCommit 메서드를 호출하여 재고를 10으로 업데이트하고, 커밋 없이 3초 동안 대기합니다.getStockWithReadCommitted 메서드를 호출하여 Dirty Read를 방지한 데이터를 읽습니다.stock 값은 기존 값으로 유지됩니다.결과 출력 예시
Read Committed: Stock = 20
결론
READ COMMITTED 이상으로 설정하여 해결 가능.@Transactional의 isolation 속성을 사용해 트랜잭션 격리 수준을 제어할 수 있습니다.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의 변경이 최종적으로 반영되어야 함");
}
stock = 10.stock = 10 (REPEATABLE READ로 변경 사항 반영 안 됨).stock = 5.결과 출력 예시
First Read (Transaction A): Stock = 10
Transaction B: Updated Stock to 5
Second Read (Transaction A): Stock = 10
결론
REPEATABLE READ를 설정하여 해결 가능.@Transactional에서 isolation 속성을 활용해 트랜잭션 격리 수준을 변경할 수 있음.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로 차단되어야 함");
}
stock > 5 조건에 맞는 2개의 상품.결과 출력 예시
First Read (Transaction A): 2 products
Transaction B: Insertion blocked due to SERIALIZABLE isolation level
Second Read (Transaction A): 2 products
결론
SERIALIZABLE 격리 수준으로 삽입이나 삭제 차단 가능.@Transactional에서 isolation 속성을 활용하여 트랜잭션 격리 수준을 설정할 수 있습니다.