2022.11.04 - [Backend & Spring (스프링)] - Spring Data JPA와 Hibernate. 그리고 Persistence
Spring을 이용해 RDB를 다루면 JPA, Transactional과 DB 격리 수준에 대해서 많이 들어보았을 것이다.
다만, 각각에 대해서는 잘 들어봤더라도
서로 정확히 어떻게 동작하는지, 어떤 경우에 에러가 발생하는지에 대한 정보는 부족하다.
이 글에서는 특히 Transactional과 Database Isolation Level에 대한 관계를 실제 코드를 이용해서 실험해보고자 한다.
격리 수준에 대한 자세한 내용은 다음 링크 참고 - 2024.06.21 - [Data] - 트랜잭션 격리 수준 / Transaction Isolation Level
현재 DB의 격리 수준은 아래 명령어를 통해 확인할 수 있다.
(5.7 이하 버전에서는 transaction_isolation 대신 tx_isolation 사용)
SELECT @@GLOBAL.transaction_isolation;
SELECT @@SESSION.transaction_isolation;
참고로 MySQL의 기본 값은 REPEATABLE READ이다.
각 코드는 아래 github에서 확인할 수 있다.
https://github.com/jinwookkk/transactional
1. Transactional
간단한 @Transactional 예제를 보자.
private final SimpleRepository simpleRepository;
public void exceptionAfterSave(SimpleDto simpleDto) {
SimpleDto savedSimpleDto = simpleRepository.save(simpleDto);
throw new IllegalArgumentException();
}
@Transactional
public void exceptionAfterSaveWithTransactional(SimpleDto simpleDto) {
SimpleDto savedSimpleDto = simpleRepository.save(simpleDto);
throw new IllegalArgumentException();
}
// Basic Code without Transactional
try {
SimpleDto simpleDto = SimpleDto.builder().simpleData(1).build();
transactionalService.exceptionAfterSave(simpleDto);
} catch (Exception e) {
}
// exceptionAfterSave with Transactional
try {
SimpleDto simpleDto = SimpleDto.builder().simpleData(10).build();
transactionalService.exceptionAfterSaveWithTransactional(simpleDto);
} catch (Exception e) {
}
위 코드를 실행한 경우 어떻게 될까?
Transactional에 의해 10인 값은 저장되지 않고, 1인 값만 저장되게 된다.
2. READ UNCOMMITTED
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
우선 위 Query를 통해 격리 수준을 변경해준다.
프로덕션에서 사용되지는 않는 격리 수준이지만
해당 수준에서 궁금한 것은 @Transactional에서 값이 삭제되는 경우에도 다른 Transaction에서 진짜로 읽을 수 있을까?
(DIRTY READ)
코드는 다음과 같다.
실제로 Exception 발생 전까지 값이 잠시 읽어진다..!
log로도 확인할 수 있다.
READ UNCOMMITTED의 무서움을 실제로 확인하였다..
3. READ COMMITTED
READ COMMITTED로 변경하여 READ UNCOMMITTED의 테스트 내용을 다시 한번 확인해보겠다.
다행히(당연히) 위에서 처럼 값이 읽어지는 시간은 없었다.
READ COMMITTED에서 문제가 되는 상황은 무엇일까?
NON-REPETABLE READ의 Consistency 이다.
한 Transaction 내에서 Update를 하지 않았다면 READ는 항상 같은 결과를 가져와야한다.
하지만 다른 Transaction에서 Update가 일어나고 이것이 COMMIT 되었다면, 해당 문제가 발생한다.
코드는 위 링크 중 readCommitted() method이다.
이를 실행해 보았...... 오잉? NON-REPETABLE READ 문제가 발생하지 않고 값이 100으로 똑같다.
id로 조회해서 그런가?
simpleData 값으로 조회하도록 변경하였다.
readCommittedFindByValue() method이다.
예상한 대로 transacional 내부에서 조회되는 size가 변경되어
NON-REPETABLE READ 정합성(Consistey) 문제가 발생하였다.
findById는 왜 이 문제가 발생하지 않는 걸까?
@Transactional
public Void getTwiceByIdUsingEm(Integer id, Integer seconds) {
System.out.println("simpleData before update on transaction 1: " + findByIdUsingEm(id));
try {
Thread.sleep(1000 * seconds);
} catch (Exception e) {
}
System.out.println("simpleData after update on transaction 1: " + findByIdUsingEm(id));
return null;
}
public SimpleDto findByIdUsingEm(Integer id) {
return entityManager.createQuery("SELECT s FROM SimpleDto s WHERE s.id = :id", SimpleDto.class)
.setParameter("id", id)
.getSingleResult();
}
혹시 JPA의 caching 문제일까 entity manager를 직접이용하여 해봤으나
문제가 발생하지 않았다.
@Transactional
public Void getTwiceByIdUsingEm(Integer id, Integer seconds) {
System.out.println("simpleData before update on transaction 1: " + findByIdUsingEm(id));
try {
Thread.sleep(1000 * seconds);
} catch (Exception e) {
}
entityManager.clear(); // Clear 추가 !!
System.out.println("simpleData after update on transaction 1: " + findByIdUsingEm(id));
return null;
}
public SimpleDto findByIdUsingEm(Integer id) {
return entityManager.createQuery("SELECT s FROM SimpleDto s WHERE s.id = :id", SimpleDto.class)
.setParameter("id", id)
.getSingleResult();
}
두 번쨰 쿼리 직전에 entity manager의 clear() 까지 추가하고 나서야
우리가 원하던(?) 문제를 얻을 수 있었다.
JPA에서 자체적으로 영속성 컨텍스트 내에서 관리하는게 있는 것으로 추측된다.
이 부분에 대해서는 나중에 포스트 해보도록 하겠다.
4. REPEATABLE READ
우선 READ COMMITED의 코드를 그대로 실행해보았다.
예상한대로 NON-REPETABLE READ 정합성 문제가 발생하지 않았다.
REPEATABLE READ에는 2가지 문제가 존재한다. 각각에 대해서 테스트해보도록 하자
4-1. UPDATE 부정합
한 Transaction 내에서 READ로 읽은 데이터가 UPDATE 시에는 변경되지 않는 경우
방법은 간단하다.
한 Transaction 내의 READ와 UPDATE 사이에 다른 Transaction에서 Update 해주는 것이다.
final Integer simpleData = 100;
SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
simpleDto = transactionalService.save(simpleDto);
int id = simpleDto.getId();
// Transaction 1
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> transactionalService.getAndUpdateSimpleData(id, 999));
// Transaction 2
transactionalService.updateSimpleData(simpleData, 777);
Thread.sleep(1000 * 5);
// Result
transactionalService.findById(id);
System.out.println("updated simpleData: " +
transactionalService.findById(id).getSimpleData());
결과는 예상대로 Transaction 1에 의한 Update가 발생하지 않았다.
4-2. Phantom READ
한 Transaction 내에서 여러 번 READ를 한 경우 없던 Data가 나타나는 경우. (DELETE는 영향 없음) 다른 Transaction의 Insert에만 영향을 받음.
한 Transaction 내의 READ와 UPDATE 사이에 다른 Transaction에서 Save 해준다.
final Integer simpleData = 66;
SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
simpleDto = transactionalService.save(simpleDto);
System.out.println("inserted id:" + simpleDto.getId());
// Transaction 1
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> transactionalService.getSimpleDtoTwiceBySimpleData(simpleData));
Thread.sleep(1000);
// Transaction 2
SimpleDto simpleDto2 = SimpleDto.builder().simpleData(simpleData).build();
System.out.println("inserted id:" + transactionalService.save(simpleDto2).getId());
결과는..
당연하게도 문제가 발생하지않는다. 왜냐하면 자신보다 낮은 Transaction의 결과만 조회하기 때문에 이후의 Transacition에서 추가된 Entity는 조회하지 않는다.
4-2-1. Phantom READ - Update
{
final Integer simpleData = 689;
// Transaction 1
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> transactionalService.getAllAndGetAllAndUpdateAndGetAllBySimpleData(simpleData, simpleData + 1));
Thread.sleep(1000);
// Transaction 2
SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
simpleDto = transactionalService.save(simpleDto);
System.out.println("inserted id:" + simpleDto.getId());
}
@Transactional
public Void getAllAndGetAllAndUpdateAndGetAllBySimpleData(Integer originSimpleData, Integer newSimpleData) throws Exception {
System.out.println("Size of first query: " + simpleRepository.findAll().size());
Thread.sleep(1000 * 3);
System.out.println("Size of second query: " + simpleRepository.findAll().size());
Thread.sleep(1000 * 5);
System.out.println("Updated size: " + simpleRepository.updateBySimpleData(originSimpleData, newSimpleData));
System.out.println("Size of fourth query: " + simpleRepository.findAll().size());
return null;
}
코드를 위처럼 변경한다.
예상하는 결과로는 나중에 생긴 Transaction에 의해 inserted됐으므로 update와 마지막 쿼리 모두 0건이어야 한다.
실제 결과를 보면 update와 select 모두 성공했음을 알 수 있다.
즉, read와 write에 대해서만 잠금이 적용되는 것 같다.
wikipedia를 확인해보면 "range-locks"이 관리되지 않아 Phantom Read가 발생할 수 있다고 한다.
entity는 0건인데 업데이트는 된다니.... 유령이다!!!
4-2-2. Range
Range를 이용한 예제이다.
BEGIN;
SELECT simple_data FROM simple_table WHERE simple_data > 17;
-- retrieve no result
BEGIN;
INSERT INTO simple_table VALUES (1, 20);
COMMIT;
SELECT simple_data FROM simple_table WHERE simple_data > 17;
-- retrieve 20
COMMIT;
MySQL Query 시에 Phantom Read발생한다.
final Integer simpleData = 666;
SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
simpleDto = transactionalService.save(simpleDto);
System.out.println("inserted id:" + transactionalService.save(simpleDto).getId());
// Transaction 1
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> transactionalService.getSimpleDtoTwiceBySimpleDataGreaterThan(0));
Thread.sleep(500);
// Transaction 2
SimpleDto simpleDto2 = SimpleDto.builder().simpleData(simpleData + 1).build();
System.out.println("inserted id:" + transactionalService.save(simpleDto2).getId());
하지만 위와 같이 Spring JPA 코드를 사용하면 Phantom Read가 발생하지 않는다.
왜 그럴까.. JPA에서 또 다른 제어를 해주는걸까? 이것도 다음 기회에 포스팅해보겠다.
SERIALIZABLE
가장 엄격한 공유잠금이다.
Phantom Read 코드를 실행해보면 어떻게 될까?
insert는 0.01초 이하에 수행될 수 있음에도
10초가 걸리는 Transaction 1이 모두 수행된 뒤에 수행된다.
이런게 수십개만 쌓이게된다고 생각해도..
끔찍하다.
Transaction을 가장 완벽하게 고립할 수 있음에도 쓰지 않는 데는 이유가 있는 것 같다.
'Data' 카테고리의 다른 글
Airflow 파헤치기 - 아키텍쳐 구조 (0) | 2024.11.13 |
---|---|
트랜잭션 격리 수준 / Transaction Isolation Level (0) | 2024.07.07 |
데이터베이스 정규화 - 제1, 제2, 제3정규형과 BCNF (RDB, Database Normalization) (0) | 2022.11.03 |
Airflow 정리 execution_date, data_interval_start, logical_date, start_date (0) | 2022.11.03 |