이 포스트링으로 동시성은 잡았으나, 데드락이 발생했습니다.
데드락, 동시성 모두 확보할 수 있는 방법을 포스팅해두었기에 다음 포스팅을 참고바랍니다.
공계란씨 (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번은 원래 데이터베이스 정합성이 목적이라 제대로 된 해결법은 아니라고 생각하는데,
문제 해결해보려고 이것저것 해보다가 썰 풀게 생겨서 가져와봄.
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);
}
위와 같이 정상적으로 문제를 해결한 것을 볼 수 있다.
동시성 문제를 해결하기 위해서 블로그며 벨덩이며 공식문서까지 이것저것 많이 들어가봤는데,
정작 나와 같은 문제를 겪은 사람은 별로 없는것 같다.
분명히 있었을텐데 왜 포스팅을 안 해놨는지 모르겠다.
다른 개발자들은 나처럼 머리털 쥐어뜯지말고 이 포스팅 보고 광명찾길.