diff --git a/build.gradle b/build.gradle index 355bb28b0..2e5f52170 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.35.0' } clean { diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index fe27dfd3b..91380300c 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -33,6 +33,7 @@ import in.koreatech.koin.domain.community.article.repository.BoardRepository; import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; +import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; @@ -112,7 +113,7 @@ public List getHotArticles() { return cacheList.stream().map(HotArticleItemResponse::from).toList(); } - @Transactional + @ConcurrencyGuard(lockName = "searchLog") public ArticlesResponse searchArticles(String query, Integer boardId, Integer page, Integer limit, String ipAddress) { if (query.length() >= MAXIMUM_SEARCH_LENGTH) { diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 0d9e3e6f6..14ad1f80c 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -32,6 +32,7 @@ import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @@ -50,7 +51,7 @@ public class KeywordService { private final ArticleRepository articleRepository; private final UserRepository userRepository; - @Transactional + @ConcurrencyGuard(lockName = "createKeyword") public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreateRequest request) { String keyword = validateAndGetKeyword(request.keyword()); if (articleKeywordUserMapRepository.countByUserId(userId) >= ARTICLE_KEYWORD_LIMIT) { diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java index eafd00c75..2e9a79a85 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java @@ -29,6 +29,7 @@ import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import lombok.RequiredArgsConstructor; @Service @@ -78,7 +79,7 @@ public List getTimetablesFrame(Integer userId, String se .toList(); } - @Transactional + @ConcurrencyGuard(lockName = "deleteFrame") public void deleteTimetablesFrame(Integer userId, Integer frameId) { TimetableFrame frame = timetableFrameRepositoryV2.getByIdWithLock(frameId); if (!Objects.equals(frame.getUser().getId(), userId)) { diff --git a/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuard.java b/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuard.java new file mode 100644 index 000000000..beb32972a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuard.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.global.concurrent; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +import org.springframework.context.annotation.Profile; + +@Documented +@Target(METHOD) +@Retention(RUNTIME) +@Profile("!test") +public @interface ConcurrencyGuard { + + String lockName(); + + long waitTime() default 5L; + + long leaseTime() default 3L; + + TimeUnit timeUnit() default TimeUnit.SECONDS; +} diff --git a/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuardAspect.java b/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuardAspect.java new file mode 100644 index 000000000..5d20fb7a4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/concurrent/ConcurrencyGuardAspect.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.global.concurrent; + +import java.lang.reflect.Method; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.global.concurrent.exception.ConcurrencyLockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@Profile("!test") +@RequiredArgsConstructor +public class ConcurrencyGuardAspect { + + private final RedissonClient redissonClient; + private final TransactionAspect transactionAspect; + + @Around("@annotation(ConcurrencyGuard) && (args(..))") + public Object handleConcurrency(ProceedingJoinPoint joinPoint) throws Throwable { + ConcurrencyGuard annotation = getAnnotation(joinPoint); + + Object[] args = joinPoint.getArgs(); + + String lockName = getLockName(args, annotation); + RLock lock = redissonClient.getLock(lockName); + + try { + boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit()); + + if (!available) { + throw ConcurrencyLockException.withDetail("Redisson GetLock 타임 아웃 lockName: " + lockName); + } + + return transactionAspect.proceed(joinPoint); + } finally { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + log.warn("Redisson 락이 이미 해제되었습니다 lockName: " + lockName); + } + } + } + + private ConcurrencyGuard getAnnotation(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + return method.getAnnotation(ConcurrencyGuard.class); + } + + private String getLockName(Object[] args, ConcurrencyGuard annotation) { + String lockNameFormat = "lock:%s:%s"; + + String relevantParameter; + if (args.length > 0) { + relevantParameter = args[0].toString(); + } else { + relevantParameter = "default"; + } + + return String.format(lockNameFormat, annotation.lockName(), relevantParameter); + } +} diff --git a/src/main/java/in/koreatech/koin/global/concurrent/TransactionAspect.java b/src/main/java/in/koreatech/koin/global/concurrent/TransactionAspect.java new file mode 100644 index 000000000..acb40c700 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/concurrent/TransactionAspect.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.global.concurrent; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Profile("!test") +@Component +public class TransactionAspect { + // leaseTime보다 트랜잭션 타임아웃은 작아야 한다. + // leastTimeOut 발생 전에 rollback 시키기 위함 + @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/concurrent/exception/ConcurrencyLockException.java b/src/main/java/in/koreatech/koin/global/concurrent/exception/ConcurrencyLockException.java new file mode 100644 index 000000000..5240be048 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/concurrent/exception/ConcurrencyLockException.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.global.concurrent.exception; + +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.exception.KoinException; + +public class ConcurrencyLockException extends KoinException { + + private static final String DEFAULT_MESSAGE = "현재 요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요."; + + public ConcurrencyLockException(String message) { + super(message); + } + + public ConcurrencyLockException(String message, String detail) { + super(message, detail); + } + + public static ConcurrencyLockException withDetail(String detail) { + return new ConcurrencyLockException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/RedissonConfig.java b/src/main/java/in/koreatech/koin/global/config/RedissonConfig.java new file mode 100644 index 000000000..7a119fdf3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/RedissonConfig.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.global.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("!test") +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + private static final String REDISSION_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissionClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSION_HOST_PREFIX + redisHost + ":" + redisPort) + .setPassword(redisPassword.isEmpty() ? null : redisPassword); + return Redisson.create(config); + } +} diff --git a/src/main/resources/db/migration/V60__alter_search_keywords_and_ip_map_unique_cascade.sql b/src/main/resources/db/migration/V60__alter_search_keywords_and_ip_map_unique_cascade.sql index b2965a323..aedb6f223 100644 --- a/src/main/resources/db/migration/V60__alter_search_keywords_and_ip_map_unique_cascade.sql +++ b/src/main/resources/db/migration/V60__alter_search_keywords_and_ip_map_unique_cascade.sql @@ -7,3 +7,4 @@ ALTER TABLE article_search_keyword_ip_map FOREIGN KEY (keyword_id) REFERENCES article_search_keywords (id) ON DELETE CASCADE; + diff --git a/src/test/java/in/koreatech/koin/AcceptanceTest.java b/src/test/java/in/koreatech/koin/AcceptanceTest.java index 7280a2d7c..8cb584506 100644 --- a/src/test/java/in/koreatech/koin/AcceptanceTest.java +++ b/src/test/java/in/koreatech/koin/AcceptanceTest.java @@ -37,7 +37,10 @@ @SpringBootTest @AutoConfigureMockMvc -@Import({DBInitializer.class, TestJpaConfiguration.class, TestTimeConfig.class, TestRedisConfiguration.class}) +@Import({DBInitializer.class, + TestJpaConfiguration.class, + TestTimeConfig.class, + TestRedisConfiguration.class}) @ActiveProfiles("test") public abstract class AcceptanceTest { diff --git a/src/test/java/in/koreatech/koin/acceptance/ArticleApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ArticleApiTest.java index 69e18f065..486664622 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ArticleApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ArticleApiTest.java @@ -1,7 +1,5 @@ package in.koreatech.koin.acceptance; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -15,9 +13,6 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; -import in.koreatech.koin.domain.community.article.model.Comment; -import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.community.article.repository.CommentRepository; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.fixture.ArticleFixture; import in.koreatech.koin.fixture.BoardFixture; @@ -116,4 +111,85 @@ void givenBeforeEach() { } """)); } + + // 클래스 단에 transactional이 붙으면 테스트 실패 함 + /* @Test + void 같은_ip_동일한_query로_4개의_스레드가_동시에_검색시_동시성_제어() throws InterruptedException { + String query = "sameQuery"; + String ipAddress = "127.0.0.1"; + + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(4); + + List responseList = new ArrayList<>(); + + Runnable searchTask = () -> { + Response response = RestAssured + .given() + .queryParam("query", query) + .queryParam("boardId", 1) + .queryParam("page", 0) + .queryParam("limit", 10) + .header("X-Forwarded-For", ipAddress) + .when() + .get("articles/search"); + responseList.add(response); + latch.countDown(); + }; + + for (int i = 0; i < 4; i++) { + executor.submit(searchTask); + } + + latch.await(); + + long successCount = responseList.stream() + .filter(response -> response.getStatusCode() == 200) + .count(); + + assertThat(successCount).isEqualTo(4); + + executor.shutdown(); + } + + @Test + void 다른_IP에서_동일한_쿼리로_동시에_검색시_동시성_처리() throws InterruptedException { + String query = "sameQuery"; + + List ipAddresses = List.of("127.0.0.1", "192.168.0.1", "10.0.0.1", "172.16.0.1"); + + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(4); + + List responseList = new ArrayList<>(); + + for (int i = 0; i < 4; i++) { + String ipAddress = ipAddresses.get(i); + Runnable searchTask = () -> { + Response response = RestAssured + .given() + .queryParam("query", query) + .queryParam("boardId", 1) + .queryParam("page", 0) + .queryParam("limit", 10) + .header("X-Forwarded-For", ipAddress) + .when() + .get("articles/search"); + responseList.add(response); + latch.countDown(); + }; + + executor.submit(searchTask); + } + + latch.await(); + + long successCount = responseList.stream() + .filter(response -> response.getStatusCode() == 200) + .count(); + + assertThat(successCount).isEqualTo(4); + + executor.shutdown(); + } */ } diff --git a/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java b/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java index 4abf625c6..66d5c4906 100644 --- a/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java @@ -276,6 +276,57 @@ void setup() { setup(); } + // 클래스 단에 transactional이 붙으면 테스트 실패 함 + /* @Test + void 키워드_생성_동시성_테스트() throws InterruptedException { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + String keyword = "testKeyword"; + + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(4); + + List responseList = new ArrayList<>(); + + Runnable createKeywordTask = () -> { + Response response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "keyword": "testKeyword" + } + """) + .when() + .post("/articles/keyword"); + responseList.add(response); + latch.countDown(); + }; + + for (int i = 0; i < 4; i++) { + executor.submit(createKeywordTask); + } + + latch.await(); + + long successCount = responseList.stream() + .filter(response -> response.getStatusCode() == 200) + .count(); + + long conflictCount = responseList.stream() + .filter(response -> response.getStatusCode() == 409) + .count(); + + assertThat(successCount).isEqualTo(1); + + assertThat(conflictCount).isEqualTo(3); + + assertThat(articleKeywordRepository.findByKeyword(keyword)).isPresent(); + + executor.shutdown(); + } */ + @Test @Transactional void 권한이_없으면_공지사항_알림_요청_실패() throws Exception { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 1e68c13f1..8eb87ff69 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -25,17 +25,20 @@ spring: multipart: max-file-size: 10MB max-request-size: 10MB + autoconfigure: + exclude: + - org.redisson.spring.starter.RedissonAutoConfigurationV2 data: redis: - repositories: - enabled: true - keyspace-events: ON_STARTUP + repositories: + enabled: true + keyspace-events: ON_STARTUP server: tomcat: max-http-form-post-size: 10MB -# MockMvc이용하면 한글 깨지는 문제 해결하는 설정 + # MockMvc이용하면 한글 깨지는 문제 해결하는 설정 servlet: encoding: force-response: true