diff --git a/src/main/java/org/ezcode/codetest/domain/community/repository/BaseVoteRepository.java b/src/main/java/org/ezcode/codetest/domain/community/repository/BaseVoteRepository.java index 3bc7019d..bc892443 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/repository/BaseVoteRepository.java +++ b/src/main/java/org/ezcode/codetest/domain/community/repository/BaseVoteRepository.java @@ -9,8 +9,6 @@ public interface BaseVoteRepository { T save(T voteEntity); - // existBy~가 더 효율적이긴 하지만 - // 나중에 비추천 기능까지 생기면 재사용 가능 Optional findByVoterIdAndTargetId(Long voterId, Long targetId); boolean existsByVoterIdAndTargetId(Long voterId, Long targetId); diff --git a/src/main/java/org/ezcode/codetest/domain/community/service/BaseVoteDomainService.java b/src/main/java/org/ezcode/codetest/domain/community/service/BaseVoteDomainService.java index 09322f80..a6bfebce 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/service/BaseVoteDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/community/service/BaseVoteDomainService.java @@ -7,9 +7,12 @@ import org.ezcode.codetest.domain.community.model.enums.VoteType; import org.ezcode.codetest.domain.community.repository.BaseVoteRepository; import org.ezcode.codetest.domain.user.model.entity.User; +import org.springframework.dao.DataIntegrityViolationException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor public abstract class BaseVoteDomainService> { @@ -31,7 +34,15 @@ public VoteResult manageVote(User voter, Long targetId, VoteType voteType) { } else { T vote = buildVote(voter, targetId, voteType); - voteRepository.save(vote); + try { + voteRepository.save(vote); + } catch (DataIntegrityViolationException ex) { + // 중복 삽입 예외는 “이미 UP 상태”로 간주하고 흐름을 계속 + // 만약 이 로그가 빈번하게 발생한다면 어떤 이유로 연속 요청(따닥 문제)가 발생하는지 확인해봐야 함 + log.info("중복 추천 시도 감지 (따닥 문제): voter={} target={}", voter.getId(), targetId); + + prevVoteType = VoteType.UP; + } } } diff --git a/src/test/java/org/ezcode/codetest/application/community/service/DiscussionVoteServiceConcurrencyTest.java b/src/test/java/org/ezcode/codetest/application/community/service/DiscussionVoteServiceConcurrencyTest.java new file mode 100644 index 00000000..765060e2 --- /dev/null +++ b/src/test/java/org/ezcode/codetest/application/community/service/DiscussionVoteServiceConcurrencyTest.java @@ -0,0 +1,101 @@ +package org.ezcode.codetest.application.community.service; + +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import org.ezcode.codetest.application.community.dto.request.VoteRequest; +import org.ezcode.codetest.domain.community.model.enums.VoteType; +import org.ezcode.codetest.domain.community.repository.DiscussionRepository; +import org.ezcode.codetest.domain.community.repository.DiscussionVoteRepository; +import org.ezcode.codetest.infrastructure.persistence.repository.user.UserJpaRepository; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Disabled +@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 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 (검증) + // 최종적으로 vote 테이블에는 단 하나의 레코드만 있어야 한다. + long upvoteCount = discussionVoteRepository.countUpvotesByTargetId(1L); + + System.out.println("예외 발생 수 : " + exceptionCnt); + System.out.println("최종 투표 레코드 수: " + upvoteCount); + + // 결과적으로 데이터는 하나만 저장됨 + // voter_id, discussion_id에 유니크 제약조건을 걸었기 때문 + // 대신에 DataIntegrityViolationException 예외 발생 + assertThat(upvoteCount).isEqualTo(1L); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 00000000..125aa86d --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,107 @@ +# ======================== +# Spring Config +# ======================== +spring.application.name=min +spring.config.import=optional:file:.env[.properties] + +# ======================== +# MySQL +# ======================== +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} + +# ======================== +# JPA +# ======================== +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# ======================== +# Redis +# ======================== +spring.data.redis.host=${SPRING_REDIS_HOST} +spring.data.redis.port=${SPRING_REDIS_PORT} +spring.data.redis.password=${SPRING_REDIS_PASSWORD} + +# ======================== +# JWT +# ======================== +jwt.secret=${JWT_SECRET} + +# ======================== +# Swagger +# ======================== +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui + +# ======================== +# ActiveMQ +# ======================== +spring.message.activemq.address=${ACTIVEMQ_ADDRESS} +spring.message.activemq.username=${ACTIVEMQ_USERNAME} +spring.message.activemq.password=${ACTIVEMQ_PASSWORD} +spring.message.activemq.port=${ACTIVEMQ_PORT} + +# ======================== +# Actuator +# ======================== +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always + +# ======================== +# Judge0Client +# ======================== +external.judge0.url=${JUDGE0_URL} + +# ======================== +# ElasticSearch +# ======================== +spring.datasource.elasticsearch.address=${ELASTICSEARCH_ADDRESS} +spring.datasource.elasticsearch.username=${ELASTICSEARCH_USERNAME} +spring.datasource.elasticsearch.password=${ELASTICSEARCH_PASSWORD} +spring.datasource.elasticsearch.port=${ELASTICSEARCH_PORT} + +# ======================== +# OpenAI +# ======================== +openai.api.url=${OPEN_API_URL} +openai.api.key=${OPEN_API_KEY} + +# ======================== +# OAuth GOOGLE +# ======================== +spring.security.oauth2.client.registration.google.client-name=google +spring.security.oauth2.client.registration.google.client-id=${CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${CLIENT_SECRET} +spring.security.oauth2.client.registration.google.redirect-uri=${REDIRECT_URI} +spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.google.scope=profile,email + +# ======================== +# mongo +# ======================== +spring.data.mongodb.uri=${SPRING_MONGODB_URI} +spring.data.mongodb.auto-index-creation=true +logging.level.org.mongodb.driver.protocol.command=DEBUG +logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG + +# ======================== +# Spring Cache Caffeine +# ======================== +# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m + +spring.cache.type=caffeine +logging.level.org.springframework.cache.interceptor.CacheInterceptor=TRACE + +# ======================== +# discord +# ======================== +discord.webhook.url=${DISCORD_WEBHOOK_URL} + +#logging.level.org.springframework.security=DEBUG +#logging.level.org.springframework.security.oauth2=DEBUG +#logging.level.org.springframework.security.oauth2.client=TRACE +#logging.level.org.springframework.web.client.RestTemplate=TRACE