diff --git a/build.gradle b/build.gradle index ceeb29f5..021370a3 100644 --- a/build.gradle +++ b/build.gradle @@ -25,8 +25,6 @@ dependencies { //Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' - //Test - testImplementation 'org.springframework.boot:spring-boot-starter-test' } @@ -55,6 +53,10 @@ subprojects { annotationProcessor "org.projectlombok:lombok" testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") + + //Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + // VALIDATION implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/module-api/src/test/java/com/mile/cocurrency/UniqueNameLockTest.java b/module-api/src/test/java/com/mile/cocurrency/UniqueNameLockTest.java new file mode 100644 index 00000000..711c1dab --- /dev/null +++ b/module-api/src/test/java/com/mile/cocurrency/UniqueNameLockTest.java @@ -0,0 +1,90 @@ +package com.mile.cocurrency; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mile.authentication.UserAuthentication; +import com.mile.moim.service.dto.MoimCreateRequest; +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +@SpringBootTest +@AutoConfigureMockMvc +public class UniqueNameLockTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + + @Test + @DisplayName("중복 요청에 대한 Lock으로 인해 동시에 같은 제목으로 요청을 보내면 400 에러를 반환한다.") + public void uniqueMoimNameTest() throws Exception { + // given + int numberOfThread = 4; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThread); + CountDownLatch latch = new CountDownLatch(numberOfThread); + + MoimCreateRequest bodyDto = new + MoimCreateRequest("이정해봅시다", "string", false, "string", "string", "string", "string", "str", "string"); + + String body = objectMapper.writeValueAsString(bodyDto); + + // when + List results = new ArrayList<>(); + UserAuthentication testUser = new UserAuthentication(1L, null, null); + + for (int i = 0; i < numberOfThread; i++) { + executorService.submit(() -> { + try { + MvcResult result = mockMvc.perform( + post("/api/moim") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(authentication(testUser)) + ) + .andDo(print()) + .andReturn(); + synchronized (results) { + results.add(result); + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + int count200 = 0; + int count400 = 0; + for (MvcResult mvcResult : results) { + if (mvcResult.getResponse().getStatus() == HttpStatus.OK.value()) count200++; + else if (mvcResult.getResponse().getStatus() == HttpStatus.BAD_REQUEST.value()) count400++; + } + System.out.println(count400); + + assertThat(count200).isEqualTo(1); + assertThat(count400).isEqualTo(numberOfThread - 1); + } + +} + diff --git a/module-common/src/main/java/com/mile/exception/message/ErrorMessage.java b/module-common/src/main/java/com/mile/exception/message/ErrorMessage.java index 9120a05d..85f26d86 100644 --- a/module-common/src/main/java/com/mile/exception/message/ErrorMessage.java +++ b/module-common/src/main/java/com/mile/exception/message/ErrorMessage.java @@ -4,7 +4,6 @@ import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; -import org.springframework.web.client.HttpClientErrorException.Unauthorized; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -49,7 +48,7 @@ public enum ErrorMessage { LEAST_TOPIC_SIZE_OF_MOIM_ERROR(HttpStatus.BAD_REQUEST.value(), "모임에는 최소 하나의 글감이 있어야 합니다."), USER_MOIM_ALREADY_JOIN(HttpStatus.BAD_REQUEST.value(), "사용자는 이미 모임에 가입했습니다."), WRITER_NAME_LENGTH_WRONG(HttpStatus.BAD_REQUEST.value(), "사용 불가능한 필명입니다."), - MOIM_NAME_LENGTH_WRONG(HttpStatus.BAD_REQUEST.value(), "사용 불가능한 모임명입니다."), + MOIM_NAME_VALIDATE_ERROR(HttpStatus.BAD_REQUEST.value(), "사용 불가능한 모임명입니다."), EXCEED_MOIM_MAX_SIZE(HttpStatus.BAD_REQUEST.value(), "최대 가입 가능 모임 개수(5개)를 초과하였습니다."), /* Conflict @@ -88,6 +87,7 @@ public enum ErrorMessage { IMAGE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "S3 버킷으로부터 이미지를 삭제하는 데 실패했습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류입니다."), DISCORD_LOG_APPENDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "디스코드 로그 전송에 실패하였습니다"), + TIME_OUT_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "락을 획득하는 과정에서 Time Out이 발생했습니다."), ; final int status; diff --git a/module-domain/build.gradle b/module-domain/build.gradle index acfa2a5d..c3787542 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -17,4 +17,14 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //Redisson + implementation "org.redisson:redisson:3.29.0" + //Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' } + + +tasks.named("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/module-domain/src/main/java/com/mile/moim/service/MoimService.java b/module-domain/src/main/java/com/mile/moim/service/MoimService.java index 15ffef5d..abd34f32 100644 --- a/module-domain/src/main/java/com/mile/moim/service/MoimService.java +++ b/module-domain/src/main/java/com/mile/moim/service/MoimService.java @@ -27,6 +27,7 @@ import com.mile.moim.service.dto.TopicListResponse; import com.mile.moim.service.dto.WriterMemberJoinRequest; import com.mile.moim.service.dto.WriterNameConflictCheckResponse; +import com.mile.moim.service.lock.AtomicValidateUniqueMoimName; import com.mile.post.domain.Post; import com.mile.post.service.PostAuthenticateService; import com.mile.post.service.PostDeleteService; @@ -40,7 +41,6 @@ import com.mile.writername.service.WriterNameService; import com.mile.writername.service.dto.WriterNameShortResponse; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,7 +51,6 @@ import java.util.stream.Collectors; @Service -@Slf4j @RequiredArgsConstructor public class MoimService { @@ -253,26 +252,38 @@ private void getAuthenticateOwnerOfMoim( } } - @Transactional + @AtomicValidateUniqueMoimName public void modifyMoimInforation( final Long moimId, final Long userId, final MoimInfoModifyRequest modifyRequest ) { + validateMoimName(modifyRequest.moimTitle()); Moim moim = findById(moimId); moim.modifyMoimInfo(modifyRequest); authenticateOwnerOfMoim(moim, userId); } + @AtomicValidateUniqueMoimName public MoimNameConflictCheckResponse validateMoimName( final String moimName ) { if (moimName.length() > MOIM_NAME_MAX_VALUE) { - throw new BadRequestException(ErrorMessage.MOIM_NAME_LENGTH_WRONG); + throw new BadRequestException(ErrorMessage.MOIM_NAME_VALIDATE_ERROR); } return MoimNameConflictCheckResponse.of(!moimRepository.existsByName(moimName)); } + + @AtomicValidateUniqueMoimName + public void checkMoimNameUnique( + final String moimName + ) { + if (moimRepository.existsByName(moimName)) { + throw new BadRequestException(ErrorMessage.MOIM_NAME_VALIDATE_ERROR); + } + } + public InvitationCodeGetResponse getInvitationCode( final Long moimId, final Long userId @@ -292,11 +303,12 @@ public String createTopic( return topicService.createTopicOfMoim(moim, createRequest).toString(); } - @Transactional + @AtomicValidateUniqueMoimName public MoimCreateResponse createMoim( final Long userId, final MoimCreateRequest createRequest ) { + checkMoimNameUnique(createRequest.moimName()); Moim moim = moimRepository.saveAndFlush(Moim.create(createRequest)); User user = userService.findById(userId); diff --git a/module-domain/src/main/java/com/mile/moim/service/lock/AopForTransaction.java b/module-domain/src/main/java/com/mile/moim/service/lock/AopForTransaction.java new file mode 100644 index 00000000..545d1a34 --- /dev/null +++ b/module-domain/src/main/java/com/mile/moim/service/lock/AopForTransaction.java @@ -0,0 +1,15 @@ +package com.mile.moim.service.lock; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class AopForTransaction { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} \ No newline at end of file diff --git a/module-domain/src/main/java/com/mile/moim/service/lock/AtomicValidateUniqueMoimName.java b/module-domain/src/main/java/com/mile/moim/service/lock/AtomicValidateUniqueMoimName.java new file mode 100644 index 00000000..3b0579f1 --- /dev/null +++ b/module-domain/src/main/java/com/mile/moim/service/lock/AtomicValidateUniqueMoimName.java @@ -0,0 +1,11 @@ +package com.mile.moim.service.lock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AtomicValidateUniqueMoimName { +} diff --git a/module-domain/src/main/java/com/mile/moim/service/lock/MoimNameRequestAspect.java b/module-domain/src/main/java/com/mile/moim/service/lock/MoimNameRequestAspect.java new file mode 100644 index 00000000..719b6ed6 --- /dev/null +++ b/module-domain/src/main/java/com/mile/moim/service/lock/MoimNameRequestAspect.java @@ -0,0 +1,46 @@ +package com.mile.moim.service.lock; + +import com.mile.exception.message.ErrorMessage; +import com.mile.exception.model.MileException; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Aspect +@RequiredArgsConstructor +@Component +public class MoimNameRequestAspect { + + private final RedissonClient redissonClient; + private final static String MOIM_NAME_LOCK = "MOIM_NAME_LOCK : "; + private final AopForTransaction aopForTransaction; + + @Pointcut("@annotation(com.mile.moim.service.lock.AtomicValidateUniqueMoimName)") + public void uniqueMoimNameCut() { + } + + @Around("uniqueMoimNameCut()") + public Object validateUniqueName(final ProceedingJoinPoint joinPoint) throws Throwable { + final RLock lock = redissonClient.getLock(MOIM_NAME_LOCK); + try { + checkAvailability(lock.tryLock(3, 4, TimeUnit.SECONDS)); + return aopForTransaction.proceed(joinPoint); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } + + public void checkAvailability(final Boolean available) { + if (!available) throw new MileException(ErrorMessage.TIME_OUT_EXCEPTION); + } + +}