본문 바로가기
Computer Engineering

Isolation level 차이에 따른 이슈 공유

by ybs 2024. 3. 3.
반응형

 

이전 글 에서 db transaction 과 isolation level 에 대해서 정리했다. 하지만 이것만으론 실제 개발할 때 뭘 어떻게 신경써야 하는지 바로 이해하기 어렵다.

 

그래서 isolation level 에 따라 동시성 문제가 어떻게 발생하는지, 다시말해 isolation level 에 따라 lock 결과가 어떻게 달라지는지 예를 들어 설명해 보겠다.

 

아래 주문서는 오직 하나의 상품만 주문 및 결제가 가능하다. 현실에선 말이 안되지만 문제를 단순화 시키기 위해 그렇게 만들었다. 그리고 브라우저에서 주문서 창을 여러개 띄워서 여러번 결제가 가능한 상황이다. 여기서 우리는 '하나의 주문서로 단 한번만 결제 가능'하도록 만들고 싶다.

 

상품 table 은 다음과 같다. pk 는 상품 id 이다. 주문서로 결제를 하면 상품 table 에 insert 된다. 아래 row 들을 보면 하나의 주문서로 책 상품을 여러번 결제 했다는것을 알 수 있다(주문서 id 가 모두 같음).

 

이제 하나의 주문서로 한번만 결제 가능하게 막아보자. app 에서 아래와 같이 방어로직을 추가하면 될 거 같다. 상품을 insert 하기전 이미  insert 된 주문서 id 가 있는지 조회하고, 있다면 저장 안되게 막았다.

boolean isExist = repository.existOrderSheetId(orderSheetId);

if (isExist) {
  throw Exception;
}

insert(product);

 

그런데 위 코드에는 문제가 있다. 주문서 id 필드는 pk 도 아니고 unique 속성도 갖고 있지 않다. 그래서 동시 요청이 오면 경합이 발생할 수 있다(race condition). 첫 요청에서 isExist 결과는 false 니까 insert 를 진행하고 있는 중간에 같은 주문서id 로 두번째 요청이 오면, isExist 결과는 여전히 false 라서 중복 insert 를 막을 수 없다.

 

해결 방법은 lock 을 잡는 거다. 즉 exist 를 확인하기 전에 lock 을 잡아야 한다.

// lock
select * from product_table where orderSheetId = ? for update // 방법1
update product_table set orderSheetId = orderSheetId where orderSheetId = ? // 방법2
boolean isExist = repository.existOrderSheetId(orderSheetId);

if (isExist) {
  throw Exception;
}

insert(product);

 

그런데 lock 잡는 로직을 추가하더라도, isolation level 에 따라서 결과가 달라진다.

만약 isolation level 이 "read commiited" 라면 lock 이 안잡히고 "repeatable read" 라면 gap lock 이 잡힌다.

cf) gap lock 은 mysql inno db 의 specific 한 lock 인데 이름만 다를뿐 range lock 이다. 이 글에선 gap lock 에 대한 자세히 설명은 생략하겠다.

 

isolation level 이 "read commiited" 여서 lock 이 안잡히는 이유는, 아래와 같이 새로운 상품 추가 요청이 왔을 때

 

lock 잡는 쿼리 결과가 없기 때문이다. 최초 insert 일 때 lock 잡을 orderSheetId 가 없다. lock 이 안잡히니 이걸론 동시성 문제를 해결할 수 없다.

select * from product_table where orderSheetId = ? for update // 방법1
update product_table set orderSheetId = orderSheetId where orderSheetId = ? // 방법2

 

 

isolation level 이 "repeatable read" 이면 db 는 임의의 끝값을 잡고 gap lock 을 잡음으로써 중복 insert 를 막는다. gap lock 은 target record 가 아닌 인접한 record 사이의 간격에 lock 을 잡는다(필요에 따라 target record 도 lock 잡기도 함). 그래서 동시성 문제를 해결 할 수 있다.

 

그런데 우리는 isolation level 을 "read commiited" 로 쓰고 있다. 예전에는 "repeatable reaad" 를 썼는데 gap lock 으로 인해  dead lock 문제를 경험해서 "read commiited" 로 바꿨다. lock 잡는 범위가 넓어지니 그에 따른 문제가 생긴것이다.

cf) 물론 "read commiited" 로 바꾸면 전에 없던 팬텀 문제가 생길 수 있다. 하지만 app 단에서도 대응이 가능하고, 정책을 바꾸는 방법도 있다.

 

그렇다면 어떻게 해결해야 할까? 한가지 방법은 lock 전용 table 을 만드는 것이다. 상품 id 와 주문서 id 를 묶어서 pk 로 만들고, 상품 table 에 insert 하기 전 여기 테이블에 lock 잡고 조회해서 확인한다면 동시성 문제를 해결 할 수 있다.

 

하지만 모든 문제를 lock 으로 해결 하려면 코드가 더 복잡해질 수 있다. 애당초 동시에 같은 주문서 id 로 insert 요청 오는게 문제였다. 이걸 동시에 오지 못하게 막을 수만 있다면 lock 로직은 굳이 필요없다.

 

기존에는 주문서 id 가 아닌 다른 id 기준으로 partition 과 consumer 가 맺어져 있었기 때문에 같은 주문서 id 를 가진 요청이 여러 consumer 에서 동시에 처리 될 수 있었고 이것 때문에 문제가 발생했다.

 

이제 partition key 를 주문서 id 로 바꿨다. 그러면 같은 주문서 id 는 항상 같은 consumer 에서만 처리되므로 동시 요청이 올 수 없다. 

 

 

 

반응형