✅ Answer 테이블 데이터 정합성 문제
다음의 시나리오를 보자.
- 사용자1, 사용자2가 동시에 1번글에 좋아요 요청을 한다.(간발의 차이로 1번이 빨랐다고 가정)
- 두 요청 모두 수정을 위해 좋아요가 0인 상태에서 Answer 정보가 조회된다.
- 사용자1의 요청에 의해 좋아요가 1 증가한다. (0 ➡️ 1)
- 사용자2의 요청에 의해 좋아요가 1 증가한다. (0 ➡️ 1)
분명 요청은 두 번인데 좋아요는 1만 증가하는 상황이 발생했다.
이처럼 공유데이터에 동시에 접근하려고 하면 경합 상태(Race Condition)가 발생해 데이터 정합성에 문제가 발생한다.
실제로 그런지 테스트 코드를 통해 확인해보자.
@Test
public void 답변_좋아요_추가_기본() throws Exception {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 1; i <= threadCount; i++) {
int user_num = i;
executorService.submit(() -> {
try {
answerService.addLikeToAnswer(new LikeServiceRequest("user_id_" + user_num, "ANSWER", savedAnswer.getId()));
} finally {
latch.countDown();
}
});
}
latch.await();
// when
AnswerEntity findAnswer = answerRepository.findById(savedAnswer.getId())
.orElseThrow(() -> AnswerNotFoundException.EXCEPTION);
// then
assertThat(findAnswer.getLikeCount()).isEqualTo(100);
}
100명의 사용자가 동시에 하나의 글에 좋아요 추가를 한 상황을 가정했다. 정상적이라면 해당 글의 좋아요가 100개가 되어야 할 것이다.
하지만 다음과 같이 전혀 다른 값이 나온다.
✅ 데이터 베이스 락(Lock)으로 해결하기
❓ 락(Lock)이란?
데이터베이스에서 여러 트랜잭션이 동시에 같은 데이터를 수정하게 되면 의도한대로 수정이 안돼 데이터의 불일치가 발생할 수 있다. 따라서 데이터베이스에선 락(Lock)이라는 옵션을 제공한다.
해당 옵션을 사용하면 한 트랜잭션이 데이터를 조회 및 수정하는 동안 다른 트랜잭션이 접근하지 못하도록 할 수 있다.
1️⃣ 낙관적 락(Optimistic Lock)
말그대로 데이터 충돌 가능성에 대해 낙관적으로 바라보고 마지막 단계에서 충돌여부를 확인한다.
일반적으로 버전번호를 두고 데이터 갱신 시 현재 버전과 저장된 버전을 비교해 충돌여부를 판단한다.
장점: 락을 거는 비용이 적다.
단점: 충돌 발생 시 재시도 로직을 직접 구현해야 한다.
2️⃣ 비관적 락(Perssimistic Lock)
데이터 충돌 가능성을 높게 바라보고 특정 데이터베이스 로우에 미리 락을 걸어 사전에 다른 트랜잭션이 접근하지 못하도록 한다.
한 트랜잭션이 락을 획득하게 되면 락을 갖고 있는 동안 다른 트랜잭션은 대기 상태에 들어가게 된다.
데이터베이스 내에서 SELECT ... FOR UPDATE 같은 명령을 사용한다
장점: 충돌 방지로 작업의 안정성이 보장됨
단점: 락 유지로 인한 성능 저하 및 데드락 가능성 증가
3️⃣ 네임드 락(Named Lock)
특정 이름을 가진 락을 생성하여 특정 데이터가 아닌 이름으로 락을 관리한다.
GET_LOCK('lock_name', timeout)과 같은 명령어를 사용한다.
장점: 데이터베이스외 로직도 보호가능
단점: 관리 복잡성 증가
💡 비관적 락으로 좋아요 동시성 이슈 해결하기
좋아요 동시성 이슈를 해결하려는 현재 상황에서는 비관적 락이 적합하다고 생각했다.
우선 좋아요 추가는 충돌 가능성이 매우 높은 상황이라고 생각해 낙관적 락은 적합하지 않다고 생각했다.
또한 특정 데이터 로우를 조회하고 그 로우를 수정하는 상황이기 때문에 비관적 락으로 충분히 해결 가능할 것이라고 생각했다.
@Lock 어노테이션
Spring Data Jpa를 활용하는 경우, @Lock 어노테이션을 활용하면 매우 쉽게 비관적 락을 구현할 수 있다.
// 락을 걸고 해당 Answer 가져오기
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM AnswerEntity a WHERE a.id = :id")
Optional<AnswerEntity> findByIdWithLock(Long id);
@Lock(value = LockModeType.PESSIMISTIC_WRITE) - 특정 데이터에 대해 독점적인 잠금(exclusive lock)을 설정한다. 다른 트랜잭션이 동일한 데이터를 읽거나 쓰기 위해 접근하려 하면 잠금이 해제될 때까지 대기한다.
/**
* 좋아요 추가
*/
public void addLikeToAnswer(LikeServiceRequest request) {
createLike(request); // Like 객체 생성
Answer answer = answerRepository.**findByIdWithLock**(request.targetId())
.orElseThrow(() -> AnswerNotFoundException.EXCEPTION); // findByIdWithLock으로 수정
answer.addLikeCount();// Answer의 likeCount 1증가
// 수정 반영
answerRepository.edit(answer);
}
수정 후 다시 테스트
@Test
public void 답변_좋아요_추가_기본() throws Exception {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 1; i <= threadCount; i++) {
int user_num = i;
executorService.submit(() -> {
try {
answerService.addLikeToAnswer(new LikeServiceRequest("user_id_" + user_num, "ANSWER", savedAnswer.getId()));
} finally {
latch.countDown();
}
});
}
latch.await();
// when
AnswerEntity findAnswer = answerRepository.findById(savedAnswer.getId())
.orElseThrow(() -> AnswerNotFoundException.EXCEPTION);
// then
assertThat(findAnswer.getLikeCount()).isEqualTo(100);
}
테스트 코드는 위에서 작성했던 100명의 사용자가 동시에 하나의 글에 좋아요를 누르는 상황이다.
@Lock을 적용하기 전과는 달리 테스트가 정상적으로 통과하는 것을 볼 수 있다.
쿼리를 통해 비관적락이 잘 적용된 것을 볼 수 있다. (Select … for update)
'데브코스 > 실습 & 프로젝트' 카테고리의 다른 글
[최종 프로젝트] 오늘의 질문 조회 캐싱하기 (0) | 2024.12.31 |
---|---|
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 2-2. Answer 테이블 데이터 정합성 문제 해결: 분산 락(Redisson 적용) (1) | 2024.12.31 |
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 1. Like 테이블 데이터 정합성 문제 (0) | 2024.12.31 |
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 0. 배경 설명 (1) | 2024.12.31 |
[최종 프로젝트] 질문 생성 기능 - 5. 질문 생성 로직 개선하기(질문 생성 횟수, 데이터 불일치 개선) (0) | 2024.12.30 |