@Transactional 과 비관적 락

텐저린티·2024년 7월 31일
0

이 포스트링으로 동시성은 잡았으나, 데드락이 발생했습니다.
데드락, 동시성 모두 확보할 수 있는 방법을 포스팅해두었기에 다음 포스팅을 참고바랍니다.

🎯 사건의 발단

공계란씨 (Concurrency)

나도 한 번 잡아보려고 이빨 바득바득 갈았는데, 이참에 한 번 잡아봤다.

문제 상황은 다음과 같다.

서평픽 (서평 좋아요) 등록 시 픽커(서평픽한 사람)리시버(서평픽 받은 사람) 모두에게 포인트를 지급한다.

픽커는 상관없지만, 만약에 동시에 같은 리시버에게 서평픽을 준다면??

딱 봐도 Lost Update 각이다.

이 문제를 해결해보고자 한다.

🩻 문제 살펴보기

개발 환경

  • Spring Boot
  • MySQL
  • JPA

문제 상황을 재현해봤다.

10건의 서평픽을 등록했다.
해당 서평에는 pick_counts 가 10 으로 설정되어있다. (맨 왼쪽 reviews 테이블)
서평픽도 10건이 등록되어있는것을 확인할 수 있다. (중간 review_picks 테이블)

문제는 맨 오른쪽 members 테이블이다.

리시버인 1번 멤버가 50점이 아니라 20점이다.
나는 서평픽을 받을때마다 5 포인트를 지급하기로 했는데 말이다.

로그를 보면 이렇다.

74번 요청에서 보면 이전에 이미 10으로 증가된 값을 읽어오는것이 아니라,
갱신되기 이전 값인 5를 가져와서 다시 5 포인트를 증가해서 저장한다.

Lost Update 가 발생한 것이다.

Lost Update 를 처리하기 위해서 아래 두 가지 방법을 사용해봤다.

  1. 데이터베이스 격리 수준을 변경하기
  2. 락으로 해결하기

1번은 원래 데이터베이스 정합성이 목적이라 제대로 된 해결법은 아니라고 생각하는데,
문제 해결해보려고 이것저것 해보다가 썰 풀게 생겨서 가져와봄.

🔍 톺아보기

MySQL 의 기본 격리 수준은 Repeatable Read 다.
한 트랜잭션에서 같은 쿼리는 항상 같은 결과를 내온다는 거다.
이게 가능한 이유는 MVCC 덕분.

커밋되지 않은 진행상황을 Undo 영역에 두기 때문에 다른 트랜잭션에서는 본인의 작업을 할 때 기존 레코드의 스니펫을 읽어서 사용한다.
그래서 이전 트랜잭션이 롤백을 하던 커밋을 하던 아무런 상관없이 자기 할일을 할 수 있다.

이게 Lost Update 의 원인이었다.

그래서 떠올린게 락과 격리수준.

📺 진행과정

태초 코드

일단 태초의 코드를 보고 오자.

// 서평픽 등록 로직
@Transactional  
public ReviewPick register(long loginId, long reviewId) {  
  Member picker = memberRepository.read(loginId);  
  Review review = reviewRepository.readWithLock(reviewId);  
  
  pointService.creditPointForReviewPick(picker.memberId(), review.member().memberId());  
  reviewRepository.update(review.increasePickCount());  
  
  return reviewPickRepository.create(new ReviewPick(null, picker, review));  
}
// 서평픽 등록 시 포인트 지급 로직
@Override  
@Transactional  
public void creditPointForReviewPick(long pickerMemberId, long receiverMemberId) {  
  if (dailyPointLimitRepository.isCreditable(pickerMemberId)) {  
    dailyPointLimitRepository.increaseCreditCount(pickerMemberId);  
    pointRepository.creditPoints(pickerMemberId, REVIEW_PICK_PICKER_POINT);  
    pointRepository.creditPoints(receiverMemberId, REVIEW_PICK_RECEIVER_POINT);  
  }  
}
// 포인트 지급 로직
@Override  
@Transactional  
public void creditPoints(long memberId, long creditPoint) {  
  validatePointValue(creditPoint);  

  getMember(memberId).addPoints(creditPoint);  
}

private MemberEntity getMember(long memberId) {  
  return repository  
      .findById(memberId)  
      .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 멤버입니다."));  
}

이 상태로 실행하면 당연히 동시성 문제가 발생한다.

비관적 락을 걸었으나 실패함

뭐로 해결해볼까 하다가,

  • 충돌 빈번
  • 데이터베이스 레벨에서의 동시성 제어
  • 재시도 로직 작성하기 싫음

이러한 이유로 비관적 락으로 해결하기로 했다.

다음은 변경한 코드

// 포인트 지급 로직
@Override  
@Transactional  
public void creditPointForReviewPick(long pickerMemberId, long receiverMemberId) {  
  if (dailyPointLimitRepository.isCreditable(pickerMemberId)) {  
    dailyPointLimitRepository.increaseCreditCount(pickerMemberId);  
    pointRepository.creditPoints(pickerMemberId, REVIEW_PICK_PICKER_POINT);  
    pointRepository.creditPointsWithLock(receiverMemberId, REVIEW_PICK_RECEIVER_POINT);  
  }  
}
@Override  
@Transactional
public void creditPointsWithLock(long memberId, long creditPoint) {  
  validatePointValue(creditPoint);  
  
  repository  
      .findByMemberId(memberId)  
      .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 멤버입니다."))  
      .addPoints(creditPoint);  
}
// 비관적 락을 걸어두었다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)  
Optional<MemberEntity> findByMemberId(long memberId);

비관적 락을 걸어두었으나 놀랍게도 문제는 해결되지 않았다!
이전 코드와 비교해서 조금도 바뀌지 않았다.
비관적 락이 적용되지 않았다는 거다.

이유는 이러하다.

포인트 로직이 @Transactional 로 설정한 메소드 내부에 있다는것.
@Transactional 의 기본 전파 방식인 REQUIRED 는 상위 트랜잭션을 전파받아서 그대로 사용한다.
따라서 내부에 있는 비관적 락이 동작하지 않은것.

포인트 로직(내부 @Transactional) 의 전파레벨을 REQUIRES_NEW 로 설정하면, 새로운 트랜잭션이 시작되어, 정상적으로 비관적 락이 적용된다.

최종 코드

나머지 코드는 변경 없다.

@Override  
@Transactional(propagation = Propagation.REQUIRES_NEW)  
public void creditPointsWithLock(long memberId, long creditPoint) {  
  validatePointValue(creditPoint);  
  
  repository  
      .findByMemberId(memberId)  
      .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 멤버입니다."))  
      .addPoints(creditPoint);  
}

위와 같이 정상적으로 문제를 해결한 것을 볼 수 있다.

🔑 결론

동시성 문제를 해결하기 위해서 블로그며 벨덩이며 공식문서까지 이것저것 많이 들어가봤는데,
정작 나와 같은 문제를 겪은 사람은 별로 없는것 같다.

분명히 있었을텐데 왜 포스팅을 안 해놨는지 모르겠다.

다른 개발자들은 나처럼 머리털 쥐어뜯지말고 이 포스팅 보고 광명찾길.

profile
개발하고 말테야

0개의 댓글