Skip to content

Commit b72f9fd

Browse files
authored
feat : 추천 동시성 문제 해결 및 테스트 완료 (#83)
* feat : 추천 동시성 문제 해결 및 테스트 완료 - 추천에서 발생할 수 있는 연속 요청(따닥 문제) 문제는 따로 락을 걸지 않고, 유니크 제약조건을 걸어서 해결함 - 이 문제의 경우 여러 스레드가 하나의 공유 자원을 두고 경쟁(race condition)하는 케이스가 아니기 때문 - 단순 insert는 유니크 제약조건으로 충분히 제어 가능 - DataIntegrityViolationException 예외 처리 코드 추가 * feat : 동시성 문제 테스트 코드 비활성화 - 동시성 문제 해결이 된 것을 확인했으므로 더 이상 사용되지 않음 * feat : DataIntegrityViolationException 예외 발생 시 동작 추가
1 parent a84ed50 commit b72f9fd

File tree

4 files changed

+220
-3
lines changed

4 files changed

+220
-3
lines changed

src/main/java/org/ezcode/codetest/domain/community/repository/BaseVoteRepository.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ public interface BaseVoteRepository<T extends BaseVote> {
99

1010
T save(T voteEntity);
1111

12-
// existBy~가 더 효율적이긴 하지만
13-
// 나중에 비추천 기능까지 생기면 재사용 가능
1412
Optional<T> findByVoterIdAndTargetId(Long voterId, Long targetId);
1513

1614
boolean existsByVoterIdAndTargetId(Long voterId, Long targetId);

src/main/java/org/ezcode/codetest/domain/community/service/BaseVoteDomainService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import org.ezcode.codetest.domain.community.model.enums.VoteType;
88
import org.ezcode.codetest.domain.community.repository.BaseVoteRepository;
99
import org.ezcode.codetest.domain.user.model.entity.User;
10+
import org.springframework.dao.DataIntegrityViolationException;
1011

1112
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
1214

15+
@Slf4j
1316
@RequiredArgsConstructor
1417
public abstract class BaseVoteDomainService<T extends BaseVote, R extends BaseVoteRepository<T>> {
1518

@@ -31,7 +34,15 @@ public VoteResult manageVote(User voter, Long targetId, VoteType voteType) {
3134
} else {
3235
T vote = buildVote(voter, targetId, voteType);
3336

34-
voteRepository.save(vote);
37+
try {
38+
voteRepository.save(vote);
39+
} catch (DataIntegrityViolationException ex) {
40+
// 중복 삽입 예외는 “이미 UP 상태”로 간주하고 흐름을 계속
41+
// 만약 이 로그가 빈번하게 발생한다면 어떤 이유로 연속 요청(따닥 문제)가 발생하는지 확인해봐야 함
42+
log.info("중복 추천 시도 감지 (따닥 문제): voter={} target={}", voter.getId(), targetId);
43+
44+
prevVoteType = VoteType.UP;
45+
}
3546
}
3647
}
3748

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package org.ezcode.codetest.application.community.service;
2+
3+
import static org.assertj.core.api.Assertions.*;
4+
5+
import java.util.concurrent.CountDownLatch;
6+
import java.util.concurrent.ExecutorService;
7+
import java.util.concurrent.Executors;
8+
import java.util.concurrent.atomic.AtomicReference;
9+
10+
import org.ezcode.codetest.application.community.dto.request.VoteRequest;
11+
import org.ezcode.codetest.domain.community.model.enums.VoteType;
12+
import org.ezcode.codetest.domain.community.repository.DiscussionRepository;
13+
import org.ezcode.codetest.domain.community.repository.DiscussionVoteRepository;
14+
import org.ezcode.codetest.infrastructure.persistence.repository.user.UserJpaRepository;
15+
import org.junit.jupiter.api.Disabled;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Test;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
20+
import org.springframework.boot.test.context.SpringBootTest;
21+
import org.springframework.dao.DataIntegrityViolationException;
22+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
23+
import org.springframework.test.context.ActiveProfiles;
24+
import org.springframework.transaction.annotation.Transactional;
25+
26+
@Disabled
27+
@SpringBootTest
28+
@ActiveProfiles("test")
29+
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
30+
class DiscussionVoteServiceConcurrencyTest {
31+
32+
@Autowired
33+
private DiscussionVoteService discussionVoteService;
34+
35+
@Autowired
36+
private DiscussionVoteRepository discussionVoteRepository;
37+
38+
@Autowired
39+
private UserJpaRepository userRepository;
40+
41+
@Autowired
42+
private DiscussionRepository discussionRepository;
43+
44+
@Transactional
45+
@DisplayName("한 명의 유저가 동시에 추천을 요청할 경우 Race Condition이 발생하여 데이터 정합성이 깨진다")
46+
@Test
47+
void manageVote_concurrency_issue_test() throws InterruptedException {
48+
// given (준비)
49+
// 1. 테스트용 유저와 게시글 생성 및 저장
50+
// User voter = userRepository.save(CommunityFixture.createUser("[email protected]"));
51+
// Discussion targetDiscussion = discussionRepository.save(Discussion.builder().build());
52+
53+
AtomicReference<Long> exceptionCnt = new AtomicReference<>(0L);
54+
55+
// 2. 동시에 실행할 스레드 수 설정 (예: 100개)
56+
int threadCount = 100;
57+
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
58+
CountDownLatch startLatch = new CountDownLatch(1); // 모든 스레드가 준비될 때까지 대기
59+
CountDownLatch doneLatch = new CountDownLatch(threadCount); // 모든 스레드가 끝날 때까지 대기
60+
61+
// when (실행)
62+
for (int i = 0; i < threadCount; i++) {
63+
executorService.submit(() -> {
64+
try {
65+
startLatch.await(); // 모든 스레드가 여기서 대기
66+
// 모든 스레드가 동일한 유저, 동일한 게시글에 UP 추천을 요청
67+
discussionVoteService.manageVoteOnDiscussion(1L, 1L, new VoteRequest(VoteType.UP), 1L);
68+
} catch (InterruptedException e) {
69+
Thread.currentThread().interrupt();
70+
} catch (DataIntegrityViolationException | ObjectOptimisticLockingFailureException e) {
71+
// DB 유니크 제약조건이 있거나, JPA의 낙관적 락이 있다면 예외가 발생할 수 있음
72+
// 테스트에서는 이 예외를 정상적인 실패 시나리오 중 하나로 간주
73+
exceptionCnt.getAndSet(exceptionCnt.get() + 1);
74+
System.out.println("예상된 동시성 예외 발생: " + e.getMessage());
75+
} finally {
76+
doneLatch.countDown();
77+
}
78+
});
79+
}
80+
81+
// 모든 스레드를 동시에 시작
82+
startLatch.countDown();
83+
// 모든 스레드가 끝날 때까지 대기
84+
doneLatch.await();
85+
executorService.shutdown();
86+
87+
Thread.sleep(500);
88+
89+
// then (검증)
90+
// 최종적으로 vote 테이블에는 단 하나의 레코드만 있어야 한다.
91+
long upvoteCount = discussionVoteRepository.countUpvotesByTargetId(1L);
92+
93+
System.out.println("예외 발생 수 : " + exceptionCnt);
94+
System.out.println("최종 투표 레코드 수: " + upvoteCount);
95+
96+
// 결과적으로 데이터는 하나만 저장됨
97+
// voter_id, discussion_id에 유니크 제약조건을 걸었기 때문
98+
// 대신에 DataIntegrityViolationException 예외 발생
99+
assertThat(upvoteCount).isEqualTo(1L);
100+
}
101+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# ========================
2+
# Spring Config
3+
# ========================
4+
spring.application.name=min
5+
spring.config.import=optional:file:.env[.properties]
6+
7+
# ========================
8+
# MySQL
9+
# ========================
10+
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
11+
spring.datasource.url=${SPRING_DATASOURCE_URL}
12+
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
13+
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
14+
15+
# ========================
16+
# JPA
17+
# ========================
18+
spring.jpa.hibernate.ddl-auto=update
19+
spring.jpa.show-sql=true
20+
spring.jpa.properties.hibernate.format_sql=true
21+
22+
# ========================
23+
# Redis
24+
# ========================
25+
spring.data.redis.host=${SPRING_REDIS_HOST}
26+
spring.data.redis.port=${SPRING_REDIS_PORT}
27+
spring.data.redis.password=${SPRING_REDIS_PASSWORD}
28+
29+
# ========================
30+
# JWT
31+
# ========================
32+
jwt.secret=${JWT_SECRET}
33+
34+
# ========================
35+
# Swagger
36+
# ========================
37+
springdoc.api-docs.path=/v3/api-docs
38+
springdoc.swagger-ui.path=/swagger-ui
39+
40+
# ========================
41+
# ActiveMQ
42+
# ========================
43+
spring.message.activemq.address=${ACTIVEMQ_ADDRESS}
44+
spring.message.activemq.username=${ACTIVEMQ_USERNAME}
45+
spring.message.activemq.password=${ACTIVEMQ_PASSWORD}
46+
spring.message.activemq.port=${ACTIVEMQ_PORT}
47+
48+
# ========================
49+
# Actuator
50+
# ========================
51+
management.endpoints.web.exposure.include=health,info
52+
management.endpoint.health.show-details=always
53+
54+
# ========================
55+
# Judge0Client
56+
# ========================
57+
external.judge0.url=${JUDGE0_URL}
58+
59+
# ========================
60+
# ElasticSearch
61+
# ========================
62+
spring.datasource.elasticsearch.address=${ELASTICSEARCH_ADDRESS}
63+
spring.datasource.elasticsearch.username=${ELASTICSEARCH_USERNAME}
64+
spring.datasource.elasticsearch.password=${ELASTICSEARCH_PASSWORD}
65+
spring.datasource.elasticsearch.port=${ELASTICSEARCH_PORT}
66+
67+
# ========================
68+
# OpenAI
69+
# ========================
70+
openai.api.url=${OPEN_API_URL}
71+
openai.api.key=${OPEN_API_KEY}
72+
73+
# ========================
74+
# OAuth GOOGLE
75+
# ========================
76+
spring.security.oauth2.client.registration.google.client-name=google
77+
spring.security.oauth2.client.registration.google.client-id=${CLIENT_ID}
78+
spring.security.oauth2.client.registration.google.client-secret=${CLIENT_SECRET}
79+
spring.security.oauth2.client.registration.google.redirect-uri=${REDIRECT_URI}
80+
spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code
81+
spring.security.oauth2.client.registration.google.scope=profile,email
82+
83+
# ========================
84+
# mongo
85+
# ========================
86+
spring.data.mongodb.uri=${SPRING_MONGODB_URI}
87+
spring.data.mongodb.auto-index-creation=true
88+
logging.level.org.mongodb.driver.protocol.command=DEBUG
89+
logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG
90+
91+
# ========================
92+
# Spring Cache Caffeine
93+
# ========================
94+
# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
95+
96+
spring.cache.type=caffeine
97+
logging.level.org.springframework.cache.interceptor.CacheInterceptor=TRACE
98+
99+
# ========================
100+
# discord
101+
# ========================
102+
discord.webhook.url=${DISCORD_WEBHOOK_URL}
103+
104+
#logging.level.org.springframework.security=DEBUG
105+
#logging.level.org.springframework.security.oauth2=DEBUG
106+
#logging.level.org.springframework.security.oauth2.client=TRACE
107+
#logging.level.org.springframework.web.client.RestTemplate=TRACE

0 commit comments

Comments
 (0)