코코넛씨(concurrnecy)
주말간 글을 봤다.
https://lion-king.tistory.com/entry/SpringJPATransaction-Write-skew-Phantom-1
JPA를 사용 간 트랜잭션 동시성 이슈가 발생하였다는 글.
일반적인 update가 아니라
select - isRowExist ? update : insert 의 상황이었다.
실무 간 같은 구조의 테이블이 존재하였고 해당 이슈를 주의 깊게 읽어보았다.
첫 시작부터 어려운 말이 나온다.
"객체관계형 매핑 프레임워크를 사용하면 뜻하지않게 데이터베이스가 제공하는 원자적 연산을 사용하는 대신 불안전한 read-modify-write 주기를 실행하는 코드를 작성하기 쉽다."
JPA(hibiernate - ORM)을 사용하면 트랜잭션에 대해서 고려할 부분이 있는데,
그 이유는 아래에 내용대로.
격리수준
일반적으로 주로 사용되는 데이터베이스는 주로 READ COMMITTED에 해당하는 격리수준을 가지는 경우가 많습니다. 하지만 JPA를 사용할 경우 한번 영속 컨텍스트에 적재된 엔터티를 다시 조회할 경우 데이터베이스를 조회하지 않고 영속 컨텍스트에서 엔터티를 가져오므로 REPEATABLE READ 격리수준과 동일하게 동작하게 됩니다.
https://reiphiel.tistory.com/entry/understanding-jpa-lock
그럼 JPA의 isolation level은 어떻게 결정될까?
JPA(hibernate)의 isolation은 Database Vendor에 의해 결정된다고 한다
그리고 이는 Spring의 @Transactional에 의해서도 변경될 수 있다.
하지만 위에서 언급한 영속성 캐싱에 대한 주의는 언제나 필요하다.
*Hibernate의 default isolation level
https://allaroundjava.com/transaction-management-hibernate/
한 예로 'Write Skew(쓰기 왜곡)'이 있다. 어떤 값을 쓰는데 본래와 다른 내용이 써졌다 정도로 이해할 수 있겠다. 자세히 들여다보면 다음과 같다.
영속상태의 객체를 dirty checking 메카니즘에 따라 변화가 있는 값만을 update 진행하게 된다. 그러나 dirty checking 을 통한 update 당시 update의 대상이 아닌 값이 변화되었다면 해당 부분에 대해 인지를 할 수 없는 상황을 가리킨다.ex) 잔액 인출, 포인트 차감
(lost update 이슈로 볼 수 도 있는데 두 차이점은 아래 참고 링크로 대신하였다.)
A beginner’s guide to Read and Write Skew phenomena
https://vladmihalcea.com/a-beginners-guide-to-read-and-write-skew-phenomena/
Optimistic(낙관적) lock
트랜잭션 간 lock이 발생하지 않는다는 관점이라서 낙관적 lock 이라고 한다.
JPA에서 제공하는 @Version을 사용하며 테이블에 version 칼럼을 명시하고 update 간 해당 칼럼을 +1 씩 갱신한다.
트랜잭션 간 update의 대상의 version 칼럼 데이터가 영속객체의 version 데이터와 다르다면 Exception을 발생시킨 후 보상 트랜잭션을 진행하는 등의 방식을 택한다.
PESSIMISTIC(비관적) lock
트랜잭션 간 lock이 발생할것이라는 관점이라서 비관적 lock 이라고 한다.
mysql에서 ~~ for update와 같은 방식의 쿼리로 lock을 요청한다.
JPA 잠금(Lock) 이해하기
https://reiphiel.tistory.com/entry/understanding-jpa-lock
위에서 말했다시피 실무에서도 비슷한 로직이있다.
특정 Id를 select 한 후 연관 테이블에 값이 없으면 insert 있으면 update.
위의 링크를 참조하여 아래와 같이 테스트코드를 작성했다.
@Test
void updateConcurrencyTest() throws InterruptedException
{
Long Id = 100L;
int numberOfThreads = 10;
ExecutorService service = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
boolean result;
for(int i = 0; i < numberOfThreads; i++) {
int finalI = i;
service.execute(() -> {
try {
//테스트될 메소드
service.updateById(Id, UseYn.Y);
System.out.println("Tid : " + finalI);
} catch(Exception e) {
e.printStackTrace();
}
latch.countDown();
});
}
latch.await();
Entity entity = entityRepository.findById(Id)
.orElseThrow(() -> new RuntimeExeception("Not Found"));
Assertions.assertEquals(UseYn.Y, entity.getUseYn());
}
역시 insert 로직에서 같은 에러 발생
insert 시점에 이미 unique 한 값이 대상에 존재하게 되었다.
시도
1. PESSIMISTIC(비관적) lock - PESSIMISTIC_WRITE - 성공
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Entity> findAllByEntityIdIn(@Param("Id") List<Long> Id);
(repository 메소드에 애노테이션을 추가하였다.)
select ..
where Id in (1234) for update
for update 쿼리로 x lock 이 사용되었다.
insert 한 번만 되고 나머진 다 update 되었다.
예제에서는 dead lock 이 걸렸다고 했는데(왜지..?) 나는 다행이 성공하였다.
2. PESSIMISTIC(비관적) lock - PESSIMISTIC_READ - 실패
select ..
where id in (1234) lock in share mode
위와 같이 lock in share mode로 쿼리가 동작하였다.
( 참고: MySQL 8.0 부터는 기존 LOCK IN SHARE MODE 대신 FOR SHARE라고 간략하게 적어줘도 된다. (하위 호환성을 위해 기존 구문도 문제 없이 실행됨)
share lock 으로 진행되서 아래와 같은 Deadlock 이 발생하였다
3. Optimistic(낙관적) lock - OptimisticLockException과 보상 트랜잭션 적용-실패
OptimisticLockException은 말 그대로 낙관적 lock을 사용한 트랜잭션 중 발생한 예외로 트랜잭션 실패 대상 entity를 반환 받을 수 있는 편의성이 존재한다.
가장 쉽게 성공할 것이라고 생각했는데 실패했다.
OptimisticLockException가 발생하기 전에 DeadLock이 발생하여 트랜잭션 실패에 대한 보상 트랜잭션을 실행할 수 없었다.
이유는 내가 위의 duplicate key에 사로잡혀서 '존재하지 않을 경우 insert' 로직에만 집중한 탓. 근본적으로 version 칼럼을 만들어서 row 의 갱신을 알아차리는 것이라하면
insert 의 상황이 아닌 lost update의 방지책으로 쓰이는게 알맞아 보인다.
Hibernate Optimistic Locking for Concurrent Insert-possible?
https://stackoverflow.com/questions/15761661/hibernate-optimistic-locking-for-concurrent-insert-possible
무엇이 best?
위의 테스트를 진행하면서 비관적, 낙관점 lock을 사용하여 해결책을 찾았지
insert와 update를 한 메소드에서 진행하는 특수한 케이스로 미루어보아 insert 와 update 각각 상황에 맞는 locking 기법을 알맞게 사용하는 것도 중요하게 보인다.
각 lock 기법의 특징과 장단점을 올바르게 파악하는 것도 필요하겠다.
동시성 문제 - 비즈니스 애플리케이션
동시성 제어 기법 중 하나인 낙관적 잠금 기법을 이용해 아키텍처를 구성하면 믿을 수 없는 재고값 문제를 해결할 수 있습니다. 하지만 전체 프로세스 중에 Transaction 의 원자성을 보장하지 못하게 하는 “외부 시스템 연동” 같은 과정이 있다면, 낙관적 잠금은 사용하기 어렵습니다. 낙관적 잠금은 전체 프로세스의 실패를 마지막 저장 시도 시점에 알 수 있는데, 원자적으로 Rollback 이 어려운 프로세스라면 전체 시스템의 정합성이 깨지기 때문입니다.
이런 경우, 시스템의 활동성을 조금 포기하더라도 정확성을 높일 수 있는 “비관적 잠금” 을 사용할 수 있습니다.
비관적 잠금은 낙관적 잠금에 비해 활동성은 줄어들어 주문이 몰리는 시점에 고객은 더 느린 주문을 경험하겠지만, 결제만 되고 취소가 되는 등의 부정확한 시스템을 경험하게 될 확률은 그만큼 줄 것입니다.
http://jaynewho.com/post/44
Database Transactions: Difference between 'write skew' and 'lost update'
https://stackoverflow.com/questions/27826714/database-transactions-difference-between-write-skew-and-lost-update
Lock으로 이해하는 Transaction의 Isolation Level
https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/
Mysql innoDB Lock, isolation level과 Lock 경쟁
https://taes-k.github.io/2020/05/17/mysql-transaction-lock/
[JPA] 영속성 컨텍스트와 플러시 이해하기
https://ict-nroo.tistory.com/130
Transaction isolation in Hibernate
https://www.waitingforcode.com/hibernate/transaction-isolation-in-hibernate/read
Optimistic Lock과 Pessimistic Lock
https://effectivesquid.tistory.com/entry/Optimistic-Lock%EA%B3%BC-Pessimistic-Lock
하나 더..
약 5천만건 정도의 배치작업 간 병렬처리 phantom read 현상이 발생하였다.
윗 댓에서 이야기한 isolation level 처리하기엔 batch가 실행해야하는 작업의 무게가 크기 때문에
try - catch 와 unique key 익셉션등을 사용하는게 적합했다.
그러나 JPA를 사용하였을 때 호출부에 따라 rollback이 되는 현상이 발생하기도 한다.
우아한형제들 - 이게 왜 롤백..?
https://woowabros.github.io/experience/2019/01/29/exception-in-transaction.html
따라서 exception을 통한 별도의 retry 로직등을 사용한다 하더라도 예상치 못한 Rollback 을 겪지 않도록 꼼꼼 코딩이 필요하겠다.
팀원 분들과 의견을 공유한 결과
for update를 사용하게 되면 병목현상 등의 부작용이 발생할 수도 있다고 한다.(lock 때문에..)
batch 성 프로그램이 아니라면 트랜잭션에 대한 unchecked exception 으로 롤백가능한 메소드로 구현하도록 하여 차라리 실패케이스를 만드는게 나을수도 있겠다. 요청에 실패하여 사용자가 한 번 더 요청하는게 더 낫겠다는 관점이다.
또한 그게 아니라면 보상 트랜잭션을 사용하여 재시도를 하되 재시도에 대한 timeout 및 retry 가능 횟수등을 제한하는 방법도 있겠다.