쇼핑몰 재고관리 - 동시성 문제 Named Query로 해결하기 (Lock 구간을 최소화하기)

조성현·2023년 8월 12일
0

시작하는 글

이번 글의 초점은 DeadlockLost Update가 발생했던 원인을 DB Lock 최소화관점에서 해결해보는데에 있습니다.
혹여 이 글을 통해 제 동시성 시리즈를 접하시는 분들은 앞의 두 글을 참고하셔서 본인 프로젝트의 상황에 맞는 해결방안을 강구하시길 응원합니다.
Redis를 통해 해결하는 방안(분산락, Redis를 통한 재고관리 등)은 다음 글에 이어질 예정입니다.

원활한 이해를 위해 파트 1은 이전 글의 내용을 가지고 왔습니다.


1. 동시성 문제의 원인 및 해결계획

이전 글에서 분석한 데드락 발생 이유를 기억해보자
1. Product.Id를 FK로 가지는 OrderProduct Insert 쿼리가 커밋되면서 해당 Product 레코드에 공유락을 획득한다.
2. Product Update를 위해 배타락(Exclusive Lock)을 요청한다.
3. 그러나 다른 트랜잭션들이 공유락을 걸어둔 상태이므로, 배타락을 획득하지 못하고 데드락에 걸린다.

1-1. 배타락을 먼저 획득한다면 데드락을 피할 수 있다.

  • 공유락은 다른 트랜잭션이 보유하고 있더라도 획득할 수 있다.
    -> 그렇기에 여러 트랜잭션들이 공유락을 보유하게 되고, 배타락 획득 요청이 다른 트랜잭션의 공유락으로 인해 거절당하면서 데드락이 발생하게 된다.

  • 공유락보다 배타락이 먼저 획득되도록 한다면?
    -> 배타락을 다른 트랜잭션에서 보유하고 있다면 대기상태에 들어가게 된다.
    -> 당연히 이후 로직들이 실행되지 않기에 Product 레코드의 공유락 획득도 밀리게 되면서
    -> 배타락을 획득한 트랜잭션은 무사히 모든 작업을 수행하게 된다.

  • 비관적 락에 비해 상대적으로 배타락 점유시간이 짧다.
    -> 비관적 락은 Product Select 시점부터 배타락을 점유하지만, 이 방법은 Product Update 시점부터 배타락을 점유하는 것이므로 더 짧게 보유한다.

1-2. Dirty Checking이 아닌 DB 레코드의 값을 기준으로 재고를 차감하여 갱신손실(Lost Update)을 방지한다.

  • 갱신손실은 조회 시점과 update 시점의 데이터 정합성이 일치하지 않기에 발생한다.
  • Update 쿼리를 직접 작성하여 DB 레코드의 값을 기준으로 재고차감을 진행한다면 갱신손실을 막을 수 있다.

1-3. 재고부족으로 인한 예외처리는 어떻게 하는가?

  • Update 쿼리에 where 조건을 붙여서 재고가 없음에도 차감하는 것을 예방하고, 쿼리를 통해 수정된 레코드 수를 int로 받아 예외처리를 진행한다.
    .

2. 데드락 해결

2-1. 현재의 구조

Product.reduceStock()을 통해 재고차감 및 예외발생로직을 실행하고 있다.
영속성 컨텍스트에 의해 관리되는 Product이기에 트랜잭션 커밋 직전에 더티체킹을 통해
Product Update 쿼리가 작성되고 커밋되게 된다.

2-2 Named Query로 Update쿼리를 작성하자

더티체킹 대신 직접 네임드쿼리를 작성하여 커밋해주면 된다.
-> 단, 부수적으로 발생하는 파급효과를 인지하고 고려하여 사용하자.

위와 같이 @Query, @Modfying 어노테이션을 통해 직접 Named Query를 작성해주면
쓰기지연저장소 저장 -> 트랜잭션 커밋 의 사이클과 별도로 쿼리를 커밋할 수 있다.
-> 데드락의 발생 원인이었던 OrderProduct Insert -> Product Update 커밋 순서에서
Product Update가 앞으로 오게 되면서
공유락으로 인해 배타락을 획득하지 못하던 데드락의 원인을 해결할 수 있게 되었다!

2-3 Named Query 주의사항

[ 관련 Ref ]

.


3. 갱신손실(Lost Update) 해결

3-1. 현재의 구조

Select 해온 Product의 재고를 기준으로 주문수량만큼 차감하고
차감한 후의 Product의 값들로 Update 쿼리를 커밋한다.

  • Select 시점과 Update 시점 사이에 발생한 변경사항(재고차감)과 무관하게
    현재 트랜잭션의 Product 값들로 덮어쓰기를 해버린다.

  • 타 트랜잭션들의 Update 내역들이 덮어쓰기된다. -> 갱신내역들이 손실된다.(Lost Update)

3-2 DB Record 값을 기준으로 재고차감을 진행하자.

위 쿼리를 살펴보면, DB Record값을 기준으로 재고를 차감하고 있으며,
재고보다 주문수량이 많을 경우에는 업데이트를 하지 않도록 Where절에 조건을 주고 있다.
-> Lost Update 해결 :)

.


4. 재고부족으로 인한 예외처리

기존에는 Prduct.reduceStock()에서 재고부족 예외처리를 해주고 있었다.

4-1. Update Query의 응답값으로 int를 받을 수 있다.


위에서 작성한 네임드쿼리를 보면, 응답값으로 int를 받고 있었다.
이는 해당 쿼리로 인해 변경된 레코드 수를 return해주는 것이다.
-> 변경된 레코드 수가 0이라는 것은 해당 id를 가진 Product가 없거나, 재고가 부족했다라는 얘기인데
-> Product를 Select해오면서 시작되는 로직이기에 전자의 경우를 배제하고
int 값을 기준으로 재고부족 예외발생 로직을 작성해 주었다.

--> 전자의 경우가 발생하려면 그 사이에 Product가 삭제됐다는 얘기인데, 그 때에도 예외가 발생하는 편이 낫다. 확실한 예외를 던지고 싶다면 if문을 중첩해서 existById()로 검증하고 예외를 던지면 될...듯 하다


5. 이 해결방안의 단점

5-1. OOP를 약간 포기해야한다.

  • 기존 도메인 내부의 메서드로 재고를 차감하고 더티체킹을 통해 커밋하던 방식을 도메인 외부에서 처리하게 된다.

5-2 '도메인은 벌크업, 서비스는 다이어트' 원칙에 어긋난다.

  • 우테코 수료생 분들의 글에서 꼭 언급하는 도메인은 벌크업, 서비스는 다이어트 원칙을 지키고자 노력하지만..!
  • 이 방법은 재고차감 로직이 튀어나오면서 서비스로직이 벌크업을 하게 된다.

5-3. 네임드쿼리 자체의 위험성도 고려하며 코드를 작성해야한다.

  • 영속성 컨텍스트와 DB의 정합성이 어긋난다는 점, 네임드쿼리 실행 전 flush()가 발생한다는 점의 파급효과를 고려해야 한다.

    네임드쿼리 실행 전 flush()가 발생한다는 점
    -> 이부분은 아직 명확하게 실험해보진 못하여 정확하게 이렇다! 라고 설명드리진 못합니다.
    (저는 재고차감 이전에 발생하는 쿼리가 없어 고려하지 않았습니다.)

profile
맛있는 음식과 여행을 좋아하는 당당한 뚱땡이

0개의 댓글