find-save (addIfAbsent) 구조에서 2개의 데이터가 INSERT 되는 문제가 발생할 수 있음findByVoterIdAndTargetId 메서드 결과에 따라 값을 삽입/수정/삭제함
save() 를 호출해서 중복 레코드를 만드려고 시도함DataIntegrityViolationException이 발생할 수 있음syncronized 키워드 사용은 제외public VoteResult manageVote(User voter, Long targetId, VoteType voteType) {
Optional<T> existing = voteRepository.findByVoterIdAndTargetId(voter.getId(), targetId);
VoteType prevVoteType = VoteType.NONE;
if (voteType == VoteType.NONE) {
existing.ifPresent(voteRepository::delete);
} else {
if (existing.isPresent()) {
T vote = existing.get();
prevVoteType = vote.getVoteType();
voteRepository.update(vote, voteType);
} else {
T vote = buildVote(voter, targetId, voteType);
voteRepository.save(vote);
}
}
Long upvoteCount = voteRepository.countUpvotesByTargetId(targetId);
Long downvoteCount = voteRepository.countDownvotesByTargetId(targetId);
return new VoteResult(voteType, prevVoteType, upvoteCount, downvoteCount);
}
테스트 코드 실행 결과 테이블에 제약조건이 걸려 있다면 동시성 문제는 발생하지 않음
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class DiscussionVoteServiceConcurrencyTest {
@Autowired
private DiscussionVoteService discussionVoteService;
@Autowired
private DiscussionVoteRepository discussionVoteRepository;
@Autowired
private UserJpaRepository userRepository;
@Autowired
private DiscussionRepository discussionRepository;
@Transactional
@DisplayName("한 명의 유저가 동시에 투표를 요청할 경우 Race Condition이 발생하여 데이터 정합성이 깨진다")
@Test
void manageVote_concurrency_issue_test() throws InterruptedException {
// given (준비)
// 1. 테스트용 유저와 게시글 생성 및 저장
// User voter = userRepository.save(CommunityFixture.createUser("test@email.com"));
// Discussion targetDiscussion = discussionRepository.save(Discussion.builder().build());
AtomicReference<Long> exceptionCnt = new AtomicReference<>(0L);
// 2. 동시에 실행할 스레드 수 설정 (예: 100개)
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1); // 모든 스레드가 준비될 때까지 대기
CountDownLatch doneLatch = new CountDownLatch(threadCount); // 모든 스레드가 끝날 때까지 대기
// when (실행)
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 여기서 대기
// 모든 스레드가 동일한 유저, 동일한 게시글에 UP 추천을 요청
discussionVoteService.manageVoteOnDiscussion(1L, 1L, new VoteRequest(VoteType.UP), 1L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (DataIntegrityViolationException | ObjectOptimisticLockingFailureException e) {
// DB 유니크 제약조건이 있거나, JPA의 낙관적 락이 있다면 예외가 발생할 수 있음
// 테스트에서는 이 예외를 정상적인 실패 시나리오 중 하나로 간주
exceptionCnt.getAndSet(exceptionCnt.get() + 1);
System.out.println("예상된 동시성 예외 발생: " + e.getMessage());
} finally {
doneLatch.countDown();
}
});
}
// 모든 스레드를 동시에 시작!
startLatch.countDown();
// 모든 스레드가 끝날 때까지 대기
doneLatch.await();
executorService.shutdown();
Thread.sleep(500);
// then (검증)
// 1. 최종적으로 vote 테이블에는 단 하나의 레코드만 있어야 한다.
long upvoteCount = discussionVoteRepository.countUpvotesByTargetId(1L);
System.out.println("예외 발생 수 : " + exceptionCnt);
System.out.println("최종 투표 레코드 수: " + upvoteCount);
// 결과적으로 데이터는 하나만 저장됨
// voter_id, discussion_id에 유니크 제약조건을 걸었기 때문
// 대신에 DataIntegrityViolationException 예외 발생
assertThat(upvoteCount).isEqualTo(1L);
}
}
박성규 튜터님
2025-06-19T16:47:32.844+09:00 WARN 25424 --- [min] [ool-4-thread-34] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1062, SQLState: 23000 2025-06-19T16:47:32.844+09:00 ERROR 25424 --- [min] [ool-4-thread-34] o.h.engine.jdbc.spi.SqlExceptionHelper : Duplicate entry '1-1' for key 'discussion_vote.unique_voter_discussion'
질문 1. 연속 추천 요청이 들어오면 여러 개의 데이터가 생길 수 있음 (동시성 제어) -> 이미 (oter_id, discussion_id)를 유니크 제약조건으로 사용 중 -> 테스트 코드 돌렸을 때 DataIntegrityViolationException 예외가 발생하기는 하지만 최종적으로는 하나의 데이터만 저장됨
굳이 리소스를 할당해가며 락 처리를 해줘야 하는가?
질문 2. 데이터가 만, 십만개 단위가 되지 않는 이상 서브 쿼리로 추천/비추천 수 조회해도 성능 상 큰 차이가 없다고 이태현 튜터님이 말씀하심 튜터님은 어떻게 생각하시는지
구현 우선순위를 낮게 둬도 괜찮나?