ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JPA, MySQL] 여러명이 동시에 좋아요를 누르면 데드락과 동시성 문제가..??
    백엔드/Spring 2023. 8. 15. 14:39
    반응형

    [리뷰 좋아요 트러블 슈팅]

    - 1편 : 여러명이 동시에 좋아요를 누르면 데드락과 동시성 문제가..??

    - 2편 : 같은 유저가 동시 접속해서 좋아요를 누르면 동시성 문제가..??

     


    팀프로젝트에서 리뷰 좋아요 기능에 데드락과 동시성 문제가 발생했습니다.

    어디서 데드락이 발생한지는 빠르게 파악했으나.. 근본적인 원인을 찾는데 오랜 시간이 걸렸습니다.

    동시성 문제의 경우에는 500명이 리뷰 좋아요를 동시에 눌렀을 때, 좋아요 수가 500개가 아닌 50여개만 나오는 상황입니다.

     

     

    [발생 환경]

    MySQL 8.0.32 InnoDB

     

    [격리 수준]

    REPEATABLE_READ

     

    [데드락 발생 상황]

    여러명의 유저가 좋아요 버튼을 전혀 눌러보지 않은 리뷰에서 동시에 좋아요를 누를 경우 발생

    좋아요를 이미 눌렀던 리뷰라면 데드락이 발생하지 않음 (대신 동시성 문제가... ㅠ)

     

    [최근 데드락 로그 - 락 요청은 모두 같은 레코드]

    LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
    MySQL thread id 2022, OS thread handle 6127988736, query id 307226 localhost 127.0.0.1 root updating
    update review set content='review_content', created_at='2023-08-15 02:25:39', favorite_count=41, image='review_image', member_id=1, product_id=1, rating=4, re_buy=1 where id=1

     

    [Transaction 17283]

    - RECORD LOCKS space id 531 page no 4 n bits 72 index PRIMARY of table `funeat_test`.`review` trx id 17283 lock mode S locks rec but not gap

    - RECORD LOCKS space id 531 page no 4 n bits 72 index PRIMARY of table `funeat_test`.`review` trx id 17283 lock_mode X locks rec but not gap waiting

     

    [Transaction 17291]

    - RECORD LOCKS space id 531 page no 4 n bits 72 index PRIMARY of table `funeat_test`.`review` trx id 17291 lock mode S locks rec but not gap

    - RECORD LOCKS space id 531 page no 4 n bits 72 index PRIMARY of table `funeat_test`.`review` trx id 17291 lock_mode X locks rec but not gap waiting

     

    정리하면 UPDATE문에서 데드락이 발생한다고 합니다. 트랜잭션1이 X lock 획득을 시도하려는데 트랜잭션2가 S lock을 가지고 있어서 대기상태이고, 트랜잭션2가 X lock 획득을 시도하려는데, 트랜잭션1이 S lock을 가지고 있어서 서로 대기상태가 되어 데드락이 발생한다는 내용이네요.


    1. 문제가 되는 서비스 코드


    [코드 흐름 설명]

    1. 사용자가 존재하는지, 리뷰가 존재하는지 확인합니다. (findMember, findReview)

    2. 해당 리뷰에 좋아요 기록이 있는지 확인합니다. 없으면 새롭게 생성합니다. (savedReviewFavorite)

    3. 좋아요 개수를 업데이트합니다. (updateChecked)

     

    [이슈 발생]

    updateChecked 안에서 Review에 대한 UPDATE문이 실행될 때, 데드락이 발생합니다.


     

     

     

     

    2. 테스트 코드로 데드락, 동시성 상황 구현하기


    데드락 상황을 구현하는 테스트를 만들기는 쉬웠습니다.

    테스트 환경을 H2에서 MySQL로 바꾸고, 데이터베이스 안에 유저 데이터, 리뷰 데이터를 넣어주면 됩니다.

    신규 유저라면 데이터베이스에 등록을 하게 되는데, 유저가 오자마자 0.00x초만에 리뷰에 좋아요를 할 수는 없을테니까요

     

    그러면 이제 테스트 코드만 작성하면 됩니다.

    멀티스레드를 활용해서 테스트 코드를 작성해주면 되는데, 동시성 테스트가 2가지 방법이 있어서 지금 1편에는 Thread를 직접 사용하고, 2편에는 ExecutorService를 사용했습니다.

    여기서 주의할 점은 ExecutorService 대신 thread를 직접 사용하므로 thread.start()를 호출하는 반복문과 thread.join()을 호출하는 반복문을 따로 돌려야합니다.


    1. 데드락 발생

    500명 모두가 처음 보는 리뷰에 좋아요 버튼을 동시에 누를 때

     

    2. 동시성 문제

    500명 모두가 좋아요를 눌렀다가 취소를 한 상황에서 동시에 좋아요 버튼을 누를 때
    (미리 ReviewFavorite 데이터를 추가했을 때)


     

     

     

     

    3. 데드락 발생 원인


    리뷰 좋아요 서비스 로직에는 5개의 데이터베이스 연산이 실행됩니다.

    1. MemberRepository - findById (SELECT)

    2. ReviewRepository - findById (SELECT)

    3. ReviewFavorite - findByReviewAndMember (SELECT)

    4. ReviewFavorite - save (INSERT)

    5. Review - favoriteCount +1, -1 (UPDATE)

     

    MySQL InnoDB에서 REPEATABLE READ 격리 레벨이면 S-LOCK이 걸리는 경우는 SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE입니다.

    그러면 1번, 2번, 3번은 문제가 없고, 4번의 INSERT문과 5번의 UPDATE문에서 문제가 발생한다는 것을 파악할 수 있습니다.

     

    처음에는 Review의 UPDATE문에서 데드락이 발생하니 두 트랜잭션이 UPDATE문에서 데드락이 발생하므로 Review의 UPDATE문만 문제인줄 알았습니다.

    그러나... MySQL 공식문서를 살펴보면 외래키(FK)에 대한 락 이야기가 있습니다.

    외래키로 인해 데드락이 걸리는 줄은 전혀 몰랐어서 며칠동안 삽질을 엄청 했네요.

    For foreign key checks, a shared read-only lock (LOCK TABLES READ) is taken on related tables. For cascading updates, a shared-nothing write lock (LOCK TABLES WRITE) is taken on related tables that are involved in the operation.

     

    외래키 제약조건을 검사할 때, S Lock이 걸린다고 합니다.

    Review의 UPDATE문이 실행될 때, S Lock으로 인해 데드락이 발생했으니, 공범은 ReviewFavorite의 INSERT문이었습니다.

    데드락 발생 상황을 표로 그려보면 아래와 같습니다.

     

    Transaction 1 Transaction 2 Lock 획득 현황
    INSERT REVIEW_FAVORITE   Review S-Lock 획득
      INSERT REVIEW_FAVORITE Review S-Lock 획득
    UPDATE REVIEW   Transaction 2가 S-Lock 사용
    Review X-Lock 대기
      UPDATE REVIEW Transaction 1이 S-Lock 사용
    Review X-Lock 대기
    💥  💥  데드락 발생

     

    Trx 1은 X Lock을 얻기 위해서 Trx 2의 S Lock이 해제되기를 기다려야하고, Trx 2도 X Lock을 얻기 위해서 Trx 1의 S Lock이 해제되기를 기다리므로 둘이 영원히 기다리게 되어 데드락이 발생합니다.

     

    검색하다가 알게 됐는데, Real MySQL 3장에는 다음과 같은 내용이 적혀있다고 합니다.

    외래키는 부모테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고, 그로인해 데드락이 발생할 수 있다. 그래서 실무에서는 잘 사용하지 않는다.

     

     

     

     

    4. 데드락 해결 방법 - 비관적 락 적용


     

    동시성 이슈가 발생하면 해결하는 대중적인 방법은 낙관적 락과 비관적 락입니다.

    여기서 비관적 락은 데드락을 해결할 수 있는 방법이 되기 때문에, 저는 이중에서 낙관적 락이 아닌 비관적 락을 적용하였습니다.

    비관적 락을 이야기하기 전에 낙관적 락을 사용하는 상황부터 이야기를 하겠습니다.

     

    [낙관적 락]

    1. 대부분의 트랜잭션이 서로 충돌이 나지 않을 것이라는 상황 

    2. 데이터베이스에 락을 걸지 않고, 애플리케이션 단에서 엔티티 버전 관리를 통해 동시성을 제어함

     

    낙관적 락은 엔티티에 @Version과 버전 필드를 만들어주고, Repository에 @Lock(OPTIMISTIC)을 설정해주면 됩니다.

     

    하지만 현재 상황은 낙관적 락을 사용하는데 적합하지 않습니다.

    1. 대부분이 트랜잭션이 서로 충돌이 나지 않는다 → 리뷰 좋아요는 많은 사람들이 누르므로 데드락이 꽤 일어날 수 있는 상황

    2. 데이터베이스에 락을 걸지 않고, 버전 관리를 통해 제어함 → 현재 락으로 인해 문제가 발생하므로 문제가 해결되지 않음

     

    데드락은 현재 S Lock → X Lock으로 인해 발생하는 상황인데, 락을 걸지 않는 낙관적 락을 적용해봤자 기존 락이 사라지는 것이 아니고, S Lock  X Lock 상황은 똑같으니까요.

     

    그래서 비관적 락과 다르게 낙관적 락은 동시성 문제에서 처리할 때 사용하게 됩니다.


    [비관적 락]

    1. 대부분의 트랜잭션이 서로 충돌이 날 것이라는 상황

    2. 데이터베이스에 락을 걸어서 동시성을 제어함

     

    리뷰 좋아요는 많은 유저들이 사용하는 기능이고, 현재 프로젝트에서 핵심적인 기능이기 때문에 트랜잭션이 서로 충돌이 일어날 수 밖에 없습니다.

    거기다가 findById에 X Lock을 걸게 된다면 X Lock X Lock이므로 데드락이 발생할 수 없게 됩니다.

     

    비관적 락은 종류가 3가지가 있는데, 저는 이중에서 X Lock을 거는 대중적인 방법인 @Lock(PESSMISTIC_WRITE)를 적용했습니다.

    Repository에 findByIdForUpdate를 적용하고, Service 로직에 적용해주면 됩니다.

    저는 X Lock의 범위를 작게 잡는 것이 좋으므로 likeReview 안에 있는 saveReviewFavorite에 X Lock을 걸었습니다.

    다른 사람이 보기엔 왜 saveReviewFavorite 메서드에 뜬금없이 findByIdForUpdate()가 있는지 모르겠지만, 그래도 성능적으로는 좋아졌습니다. 이렇게 코드를 작성한 이유는 바로 아래에 동시성 문제도 보여주고 해결하기 위함입니다.

     

    이제 다시 테스트 코드를 실행해보면...

    여러명의 유저가 좋아요 버튼을 전혀 눌러보지 않은 리뷰에서 동시에 좋아요를 누를 경우, 더이상 데드락은 발생하지 않지만, 동시성 문제만 남게 됩니다.


     

     

     

     

    5. 동시성 문제 해결하기


    좋아요 -1 ..??

    동시성 이슈의 원인도 파악해봅시다.

    이건 좋아요가 5개 있었다면 좋아요 취소가 동시에 6개가 들어온 상황에서 5 - 6 = -1이 되는 상황이 발생합니다.

    5개의 좋아요가 있는데, 6개가 인정되는 이유는 SELECT문을 했을 떄, 유저가 좋아요가 눌러있는 상태로 인식이 되어 -1이 여러번 발생하게 됩니다.

     

    좋아요 수가 1개일 때, 좋아요를 누른 유저가 좋아요 취소를 동시에 2번 눌렀을 때

    Transaction 1 Transaction 2
    리뷰 확인 (id = 1)  
      리뷰 확인 (id = 1)
    리뷰에 대한 좋아요 상태 확인  
      리뷰에 대한 좋아요 상태 확인
    좋아요가 눌러져 있음  
      좋아요가 눌러져 있음
    좋아요 취소  
      좋아요 취소
    [좋아요 수] 1 → 0  
      [좋아요 수] 0 → -1

     

     

    동시성 이슈를 해결하는 방법은 데드락과 마찬가지로 낙관적 락, 비관적 락을 고려할 수 있습니다.


    [1] 낙관적 락 적용

    다시 낙관적 락 특징을 살펴보겠습니다.

    1. 대부분의 트랜잭션이 서로 충돌이 나지 않을 것이라는 상황

    2. 데이터베이스에 락을 걸지 않고, 애플리케이션 단에서 엔티티 버전 관리를 통해 동시성을 제어함

     

    사실 트랜잭션이 서로 충돌이 매우 일어나기 떄문에 낙관적 락을 사용하기엔 적합하지 않지만, 그래도 학습용으로 사용해보겠습니다.

     

    낙관적 락을 사용할 경우 다음과 같은 트랜잭션이 진행됩니다.

     

    Transaction 1 Transaction 2
    리뷰 확인 (id = 1, version = 0)  
      리뷰 확인 (id = 1, version = 0)
    리뷰에 대한 좋아요 상태 확인  
      리뷰에 대한 좋아요 상태 확인
    좋아요가 눌러져 있음  
      좋아요가 눌러져 있음
    좋아요 취소  
      좋아요 취소
    [좋아요 수] 1 → 0
    [버전] 0 → 1
     
      [좋아요 수] 0 → -1
    [버전] 현재 버전이 1이므로 버전 에러 발생 💥 
      낙관적 락 에러 발생

     

    이제 코드를 통해 확인해봅시다.

     

    먼저 Review 엔티티에 @Version과 Version 필드를 추가합니다.

    필요한 부분만 캡쳐하기 위해 맨 위에 추가

    그리고 Repository에는 @Lock(OPTIMISTIC)을 설정합니다.

    마지막으로 낙관적 락은 충돌할 경우, 애플리케이션 단에서 예외처리를 해주어야합니다.

    어차피 삭제할 코드니 서비스 전체 로직에...

    테스트 코드도 예외처리를 해준 뒤에 확인해보면 아래와 같이 동시성 문제는 그대로 나오게 됩니다.

    하지만 예외처리를 애플리케이션에서 처리할 수 있으니 다른 방향으로 해결책을 제시할 수 있게 됩니다.

     

    사실 낙관적 락을 사용해도 유저가 다른 유저들도 동시에 좋아요를 누를 수 있다는 것을 인지할 수 없으므로 괜찮다고 생각할 수 있으나 다른 문제가 존재하게 됩니다.

    만약 낙관적 락의 예외처리를 해서 에러 메시지를 클라이언트에게 보여지게 되는 경우, 많은 사용자들이 리뷰에 좋아요를 누를 때마다 `다시 시도해주세요`와 같은 메시지를 보여줘야하는 상황이 발생하게 됩니다.

     

    거기다가 기술적으로 사용하지 않은 아유는 다음과 같습니다.

    1. 리뷰 좋아요 트랜잭션이 서로 충돌하는 상황이 매우 많음

    2. 좋아요 기능에 낙관적 락 + 비관적 락을 동시에 사용하는 경우는 들어본적 없음

    3. X Lock 범위를 좁히기 위해 저장 메서드 내부에서 비관적 락을 걸어버리는데, 다른 사람이 보기엔 전혀 이해하지 못할 코드임

    4. 500명이 동시에 눌러서 약 60개만 인식되는 것은 정확도가 매우 떨어짐


    [2] 비관적 락 범위 늘리기

    이전에 데드락을 해결하기 위해 saveReviewFavorite 메서드에 비관적 락을 사용했습니다.

    하지만 해당 코드의 문제는 다른 개발자가 뜬금없는 repository.findByIdForUpdate() 코드를 보면 `어떤 의도로 저 코드를 남겨둔거지.. 코드 지우는걸 잊어버리셨나??`하고 고민에 빠지게 됩니다.

    거기다가 동시성 문제도 발생하니 차라리 Review에 적용한 X Lock의 범위를 늘리는 것도 좋은 선택인 것 같습니다.

     

    이렇게 비관적 락을 걸어두고, 다시 테스트를 해봅시다.

     

    1. 500명 모두가 좋아요 취소를 한 상태에서 좋아요를 누를 때 (500명의 reviewFavorite 데이터가 존재)

     

    2. 500명 모두가 처음 보는 리뷰에 좋아요를 눌렀을 때 (reviewFavorite 데이터 존재 X)

     

    이렇게 동시성 문제도 성공적으로 해결할 수 있게 되었습니다.

     

    하지만 비관적 락의 단점은 X Lock이 걸리기 때문에 처음 좋아요를 누르는 사람들이 굉장히 많아지면 오랜 시간이 걸린다는 것입니다.

    (해결책 같은걸 찾아는데, 구현이 성공되면 글 추가할 예정)

     

    아무튼 지금은 문제를 해결했으니 만족해야겠습니다.


     

     

     

     

    6. 하지만..


     

    여러 아이디가 좋아요를 눌렀을 때, 데드락과 동시성 문제는 해결했지만, 아직 좋아요에 모든 동시성 이슈를 해결한건 아니었습니다.

    이대로 테스트 코드를 버리기엔 아깝기도 하고, 혹시 몰라서 같은 아이디로 500개의 전자기기에 동시 접속을 하는 상황을 상상하며 테스트를 돌려보는데.. NonUniqueResultException이 발생하게 됩니다.

     

    2편에 계속..

     

    https://hello70825.tistory.com/576

     

    [JPA, MySQL] 같은 유저가 동시 접속을 해서 좋아요를 누르면 동시성 문제가..??

    [리뷰 좋아요 트러블 슈팅] - 1편 : 여러명이 동시에 좋아요를 누르면 데드락과 동시성 문제가..?? - 2편 : 같은 유저가 동시 접속해서 좋아요를 누르면 동시성 문제가..?? 여러명이 동시에 좋아요를

    hello70825.tistory.com

     

    반응형

    댓글

Designed by Tistory.