Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ public interface BaseVoteRepository<T extends BaseVote> {

T save(T voteEntity);

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

boolean existsByVoterIdAndTargetId(Long voterId, Long targetId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends BaseVote, R extends BaseVoteRepository<T>> {

Expand All @@ -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;
}
}
}

Expand Down
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);
}
}
107 changes: 107 additions & 0 deletions src/test/resources/application-test.properties
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