-
Notifications
You must be signed in to change notification settings - Fork 3
feat : 추천 동시성 문제 해결 및 테스트 완료 #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
101 changes: 101 additions & 0 deletions
101
...g/ezcode/codetest/application/community/service/DiscussionVoteServiceConcurrencyTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("[email protected]")); | ||
| // 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 (검증) | ||
| // 최종적으로 vote 테이블에는 단 하나의 레코드만 있어야 한다. | ||
| long upvoteCount = discussionVoteRepository.countUpvotesByTargetId(1L); | ||
|
|
||
| System.out.println("예외 발생 수 : " + exceptionCnt); | ||
| System.out.println("최종 투표 레코드 수: " + upvoteCount); | ||
|
|
||
| // 결과적으로 데이터는 하나만 저장됨 | ||
| // voter_id, discussion_id에 유니크 제약조건을 걸었기 때문 | ||
| // 대신에 DataIntegrityViolationException 예외 발생 | ||
| assertThat(upvoteCount).isEqualTo(1L); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.