Skip to content
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

#346 [feat] 모임명 중복 처리 분산락 적용 #359

Merged
merged 7 commits into from
Jun 13, 2024
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
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

}

Expand Down Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MvcResult> 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);
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions module-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
22 changes: 17 additions & 5 deletions module-domain/src/main/java/com/mile/moim/service/MoimService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -51,7 +51,6 @@
import java.util.stream.Collectors;

@Service
@Slf4j
@RequiredArgsConstructor
public class MoimService {

Expand Down Expand Up @@ -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
Expand All @@ -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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P5) (제가 이해한 바에 따르면).. 분산락은 현재 메서드 별로 생성되고 있는데, 중복 확인을 수행하는 메서드가 여러 개 있다면 락의 효과가 감소하지 않나요!? 예를 들어서 두 유저가 동시에 각각 같은 이름으로 글모임 수정/글모임 생성을 요청한다고 가정해 본다면. 한 유저는 글모임 수정을 할 때 validateMoimName 메서드가 호출되고, 한 유저는 글모임 생성할 때 동시에 checkMoimNameUnique가 호출된다면, 락은 각각 다른 이름의 키로 별도이기 때문에 분산락의 효과가 없지 않는지 궁금합니닷...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P5) 그래서 제가 생각했을 땐 validateMoimName과 checkMoimNameUnique에서 이뤄지고 있는 중복확인 로직을 단일 메서드로 통일해야 분산락의 효과를 볼 수 있지 않나 싶었는데요..! 그렇게 되면 createMoim이나 modifyMoiminformation에 @AtomicValidateUniqueMoimName 어노테이션을 붙일 필요 없이, 중복확인용 메서드에만 어노테이션을 붙여서 모든 중복확인이 동일한 락을 사용하게 하면 되지 않을까 싶습니다

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

흠 이 부분에서 제가 실수한 부분이 있었습니다! 모든 메서드에서 락을 걸고 joinpoint에서 signature로 락을 걸면 안됐는데요! 이 부분 수정하겠습니다! :) 감사합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중복 체크 메서드에만 락을 걸어주는 것은 맞지 않습니다!
그 이유는 자원의 생성과 중복 체크가 동시에 일어날 수 있기 때문에 분산락을 적용한 것인데, 중복 체크만 락을 걸어줄 경우 그 사이에 이름의 자원이 생성되었을 때에는 원자성을 보장하지 못하기 때문입니다!
그래서 어노테이션을 통해서 락의 범위를 정해준 것입니다!

Moim moim = moimRepository.saveAndFlush(Moim.create(createRequest));
User user = userService.findById(userId);

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading