diff --git a/build.gradle b/build.gradle index 99f92299..369dbcf1 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-aop' // swagger diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/apply/service/ApplyService.java b/src/main/java/kr/co/studyhubinu/studyhubserver/apply/service/ApplyService.java index f7cda42b..8bb5edd0 100644 --- a/src/main/java/kr/co/studyhubinu/studyhubserver/apply/service/ApplyService.java +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/apply/service/ApplyService.java @@ -28,6 +28,7 @@ import kr.co.studyhubinu.studyhubserver.study.repository.StudyRepository; import kr.co.studyhubinu.studyhubserver.studypost.domain.StudyPostEntity; import kr.co.studyhubinu.studyhubserver.studypost.domain.implementations.StudyPostApplyEventPublisher; +import kr.co.studyhubinu.studyhubserver.studypost.domain.implementations.StudyPostReader; import kr.co.studyhubinu.studyhubserver.studypost.repository.StudyPostRepository; import kr.co.studyhubinu.studyhubserver.user.domain.UserEntity; import kr.co.studyhubinu.studyhubserver.user.dto.data.UserId; @@ -52,6 +53,7 @@ public class ApplyService { private final StudyPostRepository studyPostRepository; private final StudyPostApplyEventPublisher studyPostApplyEventPublisher; private final ApplyWriter applyWriter; + private final StudyPostReader studyPostReader; @Transactional public void enroll(UserId userId, EnrollApplyRequest request) { @@ -113,8 +115,9 @@ public void acceptApply(AcceptApplyRequest acceptApplyRequest, UserId userId) { userRepository.findById(userId.getId()).orElseThrow(UserNotFoundException::new); StudyEntity study = studyRepository.findById(acceptApplyRequest.getStudyId()).orElseThrow(StudyNotFoundException::new); ApplyEntity applyEntity = applyRepository.findByUserIdAndStudyId(acceptApplyRequest.getRejectedUserId(), study.getId()).orElseThrow(ApplyNotFoundException::new); + StudyPostEntity studyPost = studyPostReader.readByStudyId(study.getId()); applyWriter.applyAccept(applyEntity); - studyPostApplyEventPublisher.acceptApplyEventPublish(study.getId()); + studyPostApplyEventPublisher.acceptApplyEventPublish(studyPost.getId()); } @Transactional diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/CustomSpringELParser.java b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/CustomSpringELParser.java new file mode 100644 index 00000000..d8660e88 --- /dev/null +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/CustomSpringELParser.java @@ -0,0 +1,24 @@ +package kr.co.studyhubinu.studyhubserver.common.redisson; + +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +@Slf4j +@NoArgsConstructor +public class CustomSpringELParser { + public static String getDynamicValue(String[] parameterNames, Object[] args, String key) { + SpelExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + Object value = parser.parseExpression(key).getValue(context, Object.class); + if (value == null) { + log.warn("CustomSpringELParser evaluated value is null for key={}", key); + return null; + } + return value.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLock.java b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLock.java new file mode 100644 index 00000000..e592f352 --- /dev/null +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLock.java @@ -0,0 +1,14 @@ +package kr.co.studyhubinu.studyhubserver.common.redisson; + +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 RedissonDistributedLock { + + String hashKey(); + String field(); +} \ No newline at end of file diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLockAop.java b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLockAop.java new file mode 100644 index 00000000..d7dd5a0c --- /dev/null +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/common/redisson/RedissonDistributedLockAop.java @@ -0,0 +1,64 @@ +package kr.co.studyhubinu.studyhubserver.common.redisson; + +import kr.co.studyhubinu.studyhubserver.exception.apply.LockAcquisitionException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +@Aspect +@Slf4j +@RequiredArgsConstructor +@Component +public class RedissonDistributedLockAop { + + private final RedissonClient redissonClient; + private static final int LOCK_WAIT_TIME = 10; + private static final int LOCK_LEASE_TIME = 3; + + @Around("@annotation(kr.co.studyhubinu.studyhubserver.common.redisson.RedissonDistributedLock)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RedissonDistributedLock redissonDistributedLock = method.getAnnotation(RedissonDistributedLock.class); + String hashKey = getDynamicValue(signature, joinPoint, redissonDistributedLock.hashKey()); + String field = getDynamicValue(signature, joinPoint, redissonDistributedLock.field()); + + RLock lock = redissonClient.getLock(hashKey + ":" + field); + + Object result; + boolean available = false; + try { + available = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS); + if (!available) { + log.warn("Redisson GetLock Timeout {}", field); + throw new LockAcquisitionException(); + } + + result = joinPoint.proceed(); + } catch (InterruptedException e) { + throw new LockAcquisitionException(); + } finally { + if (available) { + lock.unlock(); + } + } + return result; + } + + // 메서드 파라미터(field와 hashkey)를 기반으로 동적으로 값을 지정 + public String getDynamicValue(MethodSignature signature, ProceedingJoinPoint joinPoint, String distributedLock) { + return CustomSpringELParser.getDynamicValue( + signature.getParameterNames(), + joinPoint.getArgs(), + distributedLock); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/config/RedissonConfig.java b/src/main/java/kr/co/studyhubinu/studyhubserver/config/RedissonConfig.java new file mode 100644 index 00000000..cddf33a8 --- /dev/null +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package kr.co.studyhubinu.studyhubserver.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; + +@Configuration +public class RedissonConfig { + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissonClient() { + RedissonClient redisson = null; + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort); + redisson = Redisson.create(config); + return redisson; + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/StudyApplyLockAcquisitionException.java b/src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/LockAcquisitionException.java similarity index 82% rename from src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/StudyApplyLockAcquisitionException.java rename to src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/LockAcquisitionException.java index d53020e5..010cd6c1 100644 --- a/src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/StudyApplyLockAcquisitionException.java +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/exception/apply/LockAcquisitionException.java @@ -3,12 +3,12 @@ import kr.co.studyhubinu.studyhubserver.exception.StatusType; import kr.co.studyhubinu.studyhubserver.exception.common.CustomException; -public class StudyApplyLockAcquisitionException extends CustomException { +public class LockAcquisitionException extends CustomException { private final StatusType status; private static final String message = "스터디 지원에 대한 락 획득에 실패했습니다."; - public StudyApplyLockAcquisitionException() { + public LockAcquisitionException() { super(message); this.status = StatusType.STUDY_APPLY_LOCK_ACQUISITION; } diff --git a/src/main/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisher.java b/src/main/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisher.java index 3c8aaea6..f19bf781 100644 --- a/src/main/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisher.java +++ b/src/main/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisher.java @@ -1,44 +1,37 @@ package kr.co.studyhubinu.studyhubserver.studypost.domain.implementations; -import kr.co.studyhubinu.studyhubserver.common.timer.Timer; -import kr.co.studyhubinu.studyhubserver.exception.apply.StudyApplyLockAcquisitionException; -import kr.co.studyhubinu.studyhubserver.studypost.domain.StudyPostEntity; +import kr.co.studyhubinu.studyhubserver.common.redisson.RedissonDistributedLock; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - @Component @RequiredArgsConstructor @Slf4j public class StudyPostApplyEventPublisher { - private final RedissonClient redissonClient; private final StudyPostWriter studyPostWriter; - private final StudyPostReader studyPostReader; - - public void acceptApplyEventPublish(Long studyId) { - StudyPostEntity studyPost = studyPostReader.readByStudyId(studyId); - RLock lock = redissonClient.getLock(studyPost.getId().toString()); - boolean available = false; - try { - available = lock.tryLock(10, 1, TimeUnit.SECONDS); - if (!available) { - throw new StudyApplyLockAcquisitionException(); - } - studyPostWriter.updateStudyPostApply(studyPost.getId()); - } catch (InterruptedException e) { - throw new StudyApplyLockAcquisitionException(); - } finally { - if (available) { - lock.unlock(); - } - } + @RedissonDistributedLock(hashKey = "'apply'", field = "#studyPostId") + public void acceptApplyEventPublish(Long studyPostId) { + studyPostWriter.updateStudyPostApply(studyPostId); } + + // RLock lock = redissonClient.getLock(studyPostId.toString()); +// boolean available = false; +// try { +// available = lock.tryLock(10, 1, TimeUnit.SECONDS); +// if (!available) { +// throw new StudyApplyLockAcquisitionException(); +// } +// studyPostWriter.updateStudyPostApply(studyPostId); +// } catch (InterruptedException e) { +// throw new StudyApplyLockAcquisitionException(); +// } finally { +// if (available) { +// lock.unlock(); +// } +// } // @Transactional // public void acceptApplyEventPublish(Long studyId) { // StudyPostEntity studyPost = studyPostRepository.findByIdWithPessimisticLock(studyId).orElseThrow(PostNotFoundException::new); diff --git a/src/test/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisherTest.java b/src/test/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisherTest.java index 31f7d0b0..d5c69371 100644 --- a/src/test/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisherTest.java +++ b/src/test/java/kr/co/studyhubinu/studyhubserver/studypost/domain/implementations/StudyPostApplyEventPublisherTest.java @@ -42,7 +42,7 @@ public void after() { for (int i = 0; i < threadCount; i++) { executorService.submit(() -> { try { - studyPostApplyEventPublisher.acceptApplyEventPublish(post.getStudyId()); + studyPostApplyEventPublisher.acceptApplyEventPublish(savedPost.getId()); } finally { latch.countDown(); }