1. 문제
리뷰삭제 요청에서 단일결과를 예상했지만, 9개의 결과를 리턴한다고 합니다. 예상대로라면 리뷰좋아요는 member당 하나씩 밖에 갖지 못합니다.
1-1. 문제 원인
리뷰를 저장할 때 동시성 이슈가 발생해서 그렇습니다.
- Transaction1과 Transaction2가 거의 동시에 시작되어 각각 BEGIN을 합니다.
- 두 트랜잭션 모두 memberId=1, reviewId=1인 데이터를 찾기 위해 findByMemberIdAndReviewId를 호출합니다.
- 빨간색 박스로 표시된 Race Condition 구간에서:
- Transaction1이 조회했을 때 "결과 없음"을 받습니다 (아직 좋아요가 없으므로)
- Transaction2도 조회했을 때 "결과 없음"을 받습니다 (Transaction1이 아직 커밋하지 않았으므로)
- 두 트랜잭션 모두 데이터가 없다고 판단하여 각각 ReviewLike를 생성합니다
- 두 트랜잭션이 모두 COMMIT에 성공하면서 결과적으로 같은 (memberId, reviewId) 조합의 좋아요 데이터가 두 번 저장됩니다.
2. 해결
2-1. 기술 선택
처음에 고유 제약 조건, 낙관적/비관적 잠금, 트랜잭션 격리 수준 높임, 네임드락, Synchronized들을해결방법으로 생각하였습니다.
낙관적 잠금은 충돌 가능성이 낮은 환경에서 사용합니다. 잠금 없이 데이터 갱신을 시도하고 충돌이 발생했을 때만 재처리합니다. 그러나 필드를 하나 추가한다는 점과 재시도 로직을 구현해야 한다는 점에서 패스하겠습니다.
비관적 잠금은 충돌 가능성이 높은 환경에서 사용합니다. 락을 획득하고 해제하는 오버헤드가 발생하기 때문에 패스하겠습니다.
네임드 락은 특정 키를 기준으로 락을 설정합니다. 이는 애플리케이션 레벨에서의 제어를 가능하게 하며, 분산 시스템 환경에서도 효과적입니다. 그러나 락 오버헤드와 잠금 유지 시간에 따른 성능 저하 가능성이 있기 때문에 패스하겠습니다.
트랜잭션 격리 수준을 높이는 방법은 간단하게 동시성을 제어할 수 있는 방법이지만, 락 방식과 마찬가지로 성능에 영향을 미칠 수 있으므로 최후의 선택이 되어야 할 것입니다.
Synchronized는 키워드 추가로 간단하게 동시성 문제를 해결할 수 있지만, 분산 환경에서는 적용할 수 없을 뿐더러(확장성 고려), Synchronized는 성능상 최대한 지양해야 하기 때문에 패스하겠습니다.
이러한 트레이드오프를 종합적으로 분석한 결과, 유니크 제약 조건을 선택했습니다.
기존에 잇던 validation 조회 로직을 없앨 뿐더러 가장 간단하게 처리할 수 있고 코드에서 데이터 정합성을 보장하기 보단 데이터베이스 자체에서 무결성을 보장하는 것이 더 안전하다고 판단하였기 때문입니다.
@Test
@DisplayName("동시에 한 리뷰에 대해 좋아요를 할 경우 한번만 요청에 성공해 데이터를 저장한다")
void concurrentLike() {
//given
Member member = memberRepository.save(FixtureMonkeyConfig.fixtureMonkey
.giveMeOne(Member.class));
Review review = reviewRepository.save(FixtureMonkeyConfig.fixtureMonkey
.giveMeBuilder(Review.class)
.set("memberId", member.getId())
.sample());
final int threadCount = 2;
final ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
reviewService.insertLike(review.getId(), member.getId());
} finally {
countDownLatch.countDown();
}
});
}
//then
Assertions.assertThatCode(() ->
reviewLikeRepository.findByMemberIdAndReviewId(member.getId(), review.getId()))
.doesNotThrowAnyException();
}
2-2. 코드 개선
또한 기존에는 findByMemberIdAndReviewId
으로 존재했는지 검증하였는데 이 코드는 필요없어졌습니다. 복합 유니크 인덱스를 설정함에 따라 ReviewLike를 저장하기 전에 복합 유니크 인덱스(memberId, reviewLike)를 먼저 살펴보기 때문입니다. 중복된 값이 있었을 때 발생하는 DataIntegerityViolationException
를 처리해는 로직으로 수정하였습니다.
DataIntegerityViolationException
를 처리하는 대신 Upsert를 사용할 수도 있습니다. Upsert는insert와 update가 함께 실행되는 쿼리문입니다. 해당하는 레코드가 존재한다면 값을 업데이트하는 방법이고, 레코드가 존재하지 않는다면 삽입하는 쿼리문입니다. 그러나 Upsert를 사용하려면 데이터베이스 종속적인 쿼리문을 하나 짜야하기 때문에 더 쉬운길인 DataIntegerityViolationException
를 예외처리하는 방향으로 해결하였습니다.
+) Upsert는 인덱스 키를 활용하여 이루어집니다. 저의 상황과 같이 memberId와 reviewId로 upsert를 하는 경우에는 복합 인덱스가 필수입니다.
'스프링' 카테고리의 다른 글
[쿼리문 개선] 댓글/대댓글 조회 dto로 받기 && 반복 루프로 인한 추가 쿼리 호출 문제 해결 (2) | 2024.12.18 |
---|---|
DB ↔ ENUM 컨버터, 상하위 ENUM으로 명함 도메인 최적화 (0) | 2024.12.18 |
orElse함수는 잘못 쓰기 쉽다(with orElseGet함수) (1) | 2024.10.09 |
Redis 클라이언트 lettuce에 대해서 (0) | 2024.09.12 |
몰랐던 어노테이션 정리 (0) | 2024.08.07 |