@Test
@DisplayName("딘일 스레드 환경에서 좋아요 요청 테스트")
void single_thread_like() {
// when
for (Member member : members) {
postLikeService.like(member.getId(), new CreatePostLikeRequest(post.getId()));
}
// then
int likeCount = postRepository.getById(post.getId()).getLikeCount();
Assertions.assertEquals(EXPECTED_LIKE_SIZE, likeCount);
}
@Test
@DisplayName("멀티 스레드 환경에서 좋아요 요청 테스트")
void multi_thread_like() throws InterruptedException {
// given
ExecutorService executorService = Executors.newFixedThreadPool(30);
CountDownLatch latch = new CountDownLatch(EXPECTED_LIKE_SIZE);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (Member member : members) {
executorService.submit(
() -> {
try {
postLikeService.like(member.getId(), request);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
// then
int likeCount = postRepository.getById(post.getId()).getLikeCount();
Assertions.assertEquals(EXPECTED_LIKE_SIZE, likeCount);
}
멀티스레드 환경에서 별도의 처리를 하지 않았을 때는 테스트 결과가 실패하였다.
심지어 갱신 손실 문제가 발생할 것이라는 것은 예상할 수 있는 문제였지만
deadlock이 발생하는 신기한 현상이 일어났다.
둘 이상의 프로세스들이 자원을 점유(Lock을 획득)한 상태에서 서로 다른 프로세스가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황
그럼 LOCK이 사용되었다는 건데
내가 별도로 LOCK을 사용하지 않았으니깐 내가 사용한 쿼리에서 언제 LOCK을 사용하고 있는지 파악해보자
mysql 명령을 통해 deadlock history를 확인하였다.
s-lock, x-lock이라는 것을 획득하고 있는 것을 확인할 수 있다.
MySQL은 Row-Lovel Locking의 특성을 가지고 있다.
즉, 특정 행에 대해서 잠금을 걸지 않고 행 전체에 대해서 잠금을 건다.
public void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator); // post에 대해 x-lock
postLikeRepository.save(postLike); // post에 대해 s-lock
}
post_like - post의 관계는 1:1 연관관계를 가지고 있다.
즉, post_like에서는 post의 id를 fk로 사용하고 있다.
따라서!!!
즉 서로 S-Lock을 얻고 X-Lock을 얻기 위해 서로를 대기하는 상황이 발생한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :postId")
Post findByIdWithLock(@Param("postId") Long postId);
현 트랜젝션이 완료될 때까지 다른 트랜젝션에서 해당 행을 수정하지 못 하도록 행을 잠그는 sql이 나간 것을 확인할 수 있다.
우선 FK가 존재하기 때문에 s-lock은 발생할 수 밖에 없다.
따라서 낙관적 락을 사용한다고 해도 데드락을 피할 수는 없다.
똑같이 s-lock을 획득한 상태에서 x-lock을 획득하기 위해서 다른 transaction이 s-lock를 해제시키는 것을 무한정으로 기다리게 된다.
각 스레드가 공유 자원을 동시에 접근해서 생긴 일이므로
공유 자원에 두 개 이상의 스레드가 접근할 수 없도록 synchronized를 적용하자
synchronized를 적용했지만 테스트가 실패했다.
public synchronized void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator);
postLikeRepository.save(postLike);
}
그 이유는 transactional은 Spring AOP 방식으로 동작한다.
따라서 like 메서드를 호출하게 되면 해당 메서드에 대한 proxy 객체를 생성하고
proxy 객체에서 transaction을 start 및 commit한다.
따라서 like 메서드에만 한 트랜젝션이 접근할 수 있다라는 제약사항이 있으므로
proxy객체에서 commit을 하기 전에 다른 트랜젝션에서 transaction을 시작할 수 있다.
@Transactional
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 1000,
backoff = @Backoff(100))
public synchronized void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator);
postLikeRepository.save(postLike);
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
@SuperBuilder(toBuilder = true)
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private String color;
@OneToOne private Member member;
@Embedded private Coordinate coordinate;
private int likeCount = 0;
@Version private Long version;
public void clickLike() {
this.likeCount++;
}
public void cancelLike() {
if (likeCount == 0) {
throw new PostLikeCountNegativeException();
}
this.likeCount--;
}
}
그러나 단일 서버인 경우에만 synchronized가 유효하다라는 문제점이 있다.
단일 DB 환경 또는 단일 서버일 경우에만 해결이 된다.
또한 좋아요 같은 경우는 순차적으로 처리되지 않아도 되는데 비관적 락을 적용했을 때는 마치 선착순 처리처럼 순차적으로 처리가 된다.