[13주차] Deadlock 발생 해결하기

Mando·2024년 6월 28일
0

정글

목록 보기
2/3

정글 13주차 나만무 준비

1. Single Thread

	@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);
	}

image

2. Multi Thread

	@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);
	}

멀티스레드 환경에서 별도의 처리를 하지 않았을 때는 테스트 결과가 실패하였다.

image

심지어 갱신 손실 문제가 발생할 것이라는 것은 예상할 수 있는 문제였지만

deadlock이 발생하는 신기한 현상이 일어났다.

image

교착 상태가 왜 발생하더라?

둘 이상의 프로세스들이 자원을 점유(Lock을 획득)한 상태에서 서로 다른 프로세스가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황

그럼 LOCK이 사용되었다는 건데

내가 별도로 LOCK을 사용하지 않았으니깐 내가 사용한 쿼리에서 언제 LOCK을 사용하고 있는지 파악해보자

image

mysql 명령을 통해 deadlock history를 확인하였다.
s-lock, x-lock이라는 것을 획득하고 있는 것을 확인할 수 있다.

S-LOCK, X-LOCK은 무엇인가

S-Lock(Shared Lock)

  • 데이터를 읽을 때 사용하는 Lock으로 데이터를 읽는 동안 수정이 발생하지 않음을 보장한다.
  • 한 리소스에 두 개 이상의 S-Lock을 동시에 설정할 수 있다.
  • X-Lock을 소유하고 있는 리소스는 읽지 못 한다.

X-Lock(Exclusive Lock)

  • 데이터를 변경할 때 사용하는 Lock으로 쓰는 동안 수정이 발생하지 않음을 보장한다.
  • 한 리소스에 하나의 X-Lock만 설정가능하다.
  • 이때 MySQL의 Row-lovel Locking 특성으로 인해 행 전체
⭐ MySQL InnoDB는 SELECT시에 S-Lock을 걸지 않고 조회한다.

그렇다면 언제 S-LOCK, X-LOCK이 나간걸까?

S-Lock이 사용되는 상황

💡 fk가 있는 테이블에서, fk를 포함한 데이터를 insert, update, delete 하는 쿼리는 제약조건을 확인하기 위해 shared lock(s-lock)을 설정한다

X-Lock이 사용되는 상황

💡 update 쿼리에 사용되는 모든 레코드에 exclusive lock(x-lock)이 설정된다.

MySQL은 Row-Lovel Locking의 특성을 가지고 있다.

즉, 특정 행에 대해서 잠금을 걸지 않고 행 전체에 대해서 잠금을 건다.

코드에서 Lock이 걸리는 원인 확인

	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
	}

image

post_like - post의 관계는 1:1 연관관계를 가지고 있다.

즉, post_like에서는 post의 id를 fk로 사용하고 있다.

따라서!!!

  • post의 like_count를 update하는 시점에 post에 대해서 x-lock이 걸리게 된다.
  • post_like가 insert되는 시점에 post에 대해서 s-lock이 걸리게 된다.

데드락 발생원인

image

  • transaction1은 x-lock을 얻기 위해서 transaction2가 s-lock을 해제시키기를 기다린다.
  • transaction2는 x-lock을 얻기 위해서 transaction1이 s-lock을 해제시키는 것을 기다린다.

즉 서로 S-Lock을 얻고 X-Lock을 얻기 위해 서로를 대기하는 상황이 발생한다.

해결책 1. 점유 대기를 하는 상황을 없애버리자(with 낙관적 락)

image

	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query("SELECT p FROM Post p WHERE p.id = :postId")
	Post findByIdWithLock(@Param("postId") Long postId);

image

현 트랜젝션이 완료될 때까지 다른 트랜젝션에서 해당 행을 수정하지 못 하도록 행을 잠그는 sql이 나간 것을 확인할 수 있다.

생각 : 낙관적 락으로도 해결할 수 있을까?

우선 FK가 존재하기 때문에 s-lock은 발생할 수 밖에 없다.

따라서 낙관적 락을 사용한다고 해도 데드락을 피할 수는 없다.

실패 1. 낙관적 락만 적용

똑같이 s-lock을 획득한 상태에서 x-lock을 획득하기 위해서 다른 transaction이 s-lock를 해제시키는 것을 무한정으로 기다리게 된다.

image

실패 2. synchronized만 적용

각 스레드가 공유 자원을 동시에 접근해서 생긴 일이므로

공유 자원에 두 개 이상의 스레드가 접근할 수 없도록 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);
	}

image

그 이유는 transactional은 Spring AOP 방식으로 동작한다.

따라서 like 메서드를 호출하게 되면 해당 메서드에 대한 proxy 객체를 생성하고

proxy 객체에서 transaction을 start 및 commit한다.

따라서 like 메서드에만 한 트랜젝션이 접근할 수 있다라는 제약사항이 있으므로

proxy객체에서 commit을 하기 전에 다른 트랜젝션에서 transaction을 시작할 수 있다.

해결책 2. 낙관적 락 + synchronized를 적용하자

	@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--;
	}
}

image

그러나 단일 서버인 경우에만 synchronized가 유효하다라는 문제점이 있다.

문제점

단일 DB 환경 또는 단일 서버일 경우에만 해결이 된다.

또한 좋아요 같은 경우는 순차적으로 처리되지 않아도 되는데 비관적 락을 적용했을 때는 마치 선착순 처리처럼 순차적으로 처리가 된다.

0개의 댓글