diff --git a/queue/build.gradle b/queue/build.gradle index 56c077c..12288c3 100644 --- a/queue/build.gradle +++ b/queue/build.gradle @@ -18,8 +18,9 @@ dependencies { //eureka-client implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' - //openfeign - implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + // redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' + implementation 'org.springframework.boot:spring-boot-starter-aop' //embedded redis for test testImplementation 'it.ozimov:embedded-redis:0.7.2' diff --git a/queue/src/main/java/com/codeterian/queue/QueueApplication.java b/queue/src/main/java/com/codeterian/queue/QueueApplication.java index ad9b957..4cceb42 100644 --- a/queue/src/main/java/com/codeterian/queue/QueueApplication.java +++ b/queue/src/main/java/com/codeterian/queue/QueueApplication.java @@ -2,14 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = { "com.codeterian.queue", "com.codeterian.common" }) -@EnableFeignClients @EnableScheduling public class QueueApplication { diff --git a/queue/src/main/java/com/codeterian/queue/application/service/QueueService.java b/queue/src/main/java/com/codeterian/queue/application/service/QueueService.java index aecb55b..fdcf20b 100644 --- a/queue/src/main/java/com/codeterian/queue/application/service/QueueService.java +++ b/queue/src/main/java/com/codeterian/queue/application/service/QueueService.java @@ -1,6 +1,7 @@ package com.codeterian.queue.application.service; import com.codeterian.common.infrastructure.util.Passport; +import com.codeterian.queue.infrastructure.redisson.aspect.DistributedLock; import com.codeterian.queue.presentation.dto.QueueResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,6 +32,7 @@ public class QueueService { /** * 사용자를 대기큐 or 실행큐에 추가 (새로고침 시 뒤로 밀리도록 처리) */ + @DistributedLock(key = "#lockName") public void joinQueue(Passport passport) { String userId = passport.getUserId().toString(); @@ -40,10 +42,10 @@ public void joinQueue(Passport passport) { Long currentRunningQueueSize = redisTemplate.opsForZSet().size(RUNNING_QUEUE); - if (currentRunningQueueSize != null && currentRunningQueueSize >= TRAFFIC_THRESHOLD) { - // 임계치 초과: 대기큐에 사용자 추가 + if (currentRunningQueueSize != null && currentRunningQueueSize > TRAFFIC_THRESHOLD-1) { redisTemplate.opsForZSet().add(WAITING_QUEUE, userId, System.currentTimeMillis()); log.info("사용자가 대기큐에 추가되었습니다."); + } else { // 실행큐에 사용자 추가 redisTemplate.opsForZSet().add(RUNNING_QUEUE, userId, System.currentTimeMillis()); @@ -65,8 +67,10 @@ public void joinQueue(Passport passport) { @Scheduled(fixedDelay = 2000) public void getNextUserFrom() { Set nextUsers = redisTemplate.opsForZSet().range(WAITING_QUEUE, 0, 0); + Long currentRunningQueueSize = redisTemplate.opsForZSet().size(RUNNING_QUEUE); - if (nextUsers != null && !nextUsers.isEmpty()) { + if (nextUsers != null && !nextUsers.isEmpty() && + currentRunningQueueSize != null && currentRunningQueueSize < TRAFFIC_THRESHOLD) { String nextUser = nextUsers.iterator().next(); redisTemplate.opsForZSet().remove(WAITING_QUEUE, nextUser); diff --git a/queue/src/main/java/com/codeterian/queue/infrastructure/config/RedisConfig.java b/queue/src/main/java/com/codeterian/queue/infrastructure/config/RedisConfig.java index ed5860f..b930c53 100644 --- a/queue/src/main/java/com/codeterian/queue/infrastructure/config/RedisConfig.java +++ b/queue/src/main/java/com/codeterian/queue/infrastructure/config/RedisConfig.java @@ -1,5 +1,9 @@ package com.codeterian.queue.infrastructure.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.data.redis.connection.RedisConnectionFactory; @@ -10,6 +14,14 @@ @Configuration public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + private static final String REDISSON_HOST_PREFIX = "redis://"; + @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { var redisTemplate = new RedisTemplate(); @@ -21,4 +33,14 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + @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; + } + } diff --git a/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/AopForTransaction.java b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/AopForTransaction.java new file mode 100644 index 0000000..ff54d3f --- /dev/null +++ b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/AopForTransaction.java @@ -0,0 +1,20 @@ +package com.codeterian.queue.infrastructure.redisson.aspect; + +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 { + + /** + 부모 트랜잭션에 관계없이 별도의 트랜잭션으로 동작하도록 설정하여 반드시 트랜잭션 커밋 이후 + 락이 해제되도록 REQUIRES_NEW 옵션 부여 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } + +} diff --git a/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/CustomSpringELParser.java b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/CustomSpringELParser.java new file mode 100644 index 0000000..e953ac0 --- /dev/null +++ b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/CustomSpringELParser.java @@ -0,0 +1,25 @@ +package com.codeterian.queue.infrastructure.redisson.aspect; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * CustomSpringELParser 는 전달받은 락의 이름을 Spring Expression Language 로 파싱하여 읽어옵니다. + */ +public class CustomSpringELParser { + + private CustomSpringELParser(){} + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} diff --git a/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributeLockAop.java b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributeLockAop.java new file mode 100644 index 0000000..12ba1f9 --- /dev/null +++ b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributeLockAop.java @@ -0,0 +1,54 @@ +package com.codeterian.queue.infrastructure.redisson.aspect; + +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; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class DistributeLockAop { + + private static final String REDISSON_LOCK_PREFIX = "LOCK: "; + + private final RedissonClient redissonClient; + private final AopForTransaction aopForTransaction; + + @Around("@annotation(com.codeterian.queue.infrastructure.redisson.aspect.DistributedLock)") + public Object redissonLock(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); + RLock rLock = redissonClient.getLock(key); + + try { + boolean lockable = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + if (!lockable) { + log.info("Lock 획득 실패={}", key); + return false; + } + + log.info("로직 수행"); + return aopForTransaction.proceed(joinPoint); + + } catch (Throwable e) { + log.info("에러 발생!"); + throw new RuntimeException(e); + } finally { + rLock.unlock(); + log.info("이미 언락 상태 입니다."); + } + } + +} diff --git a/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributedLock.java b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributedLock.java new file mode 100644 index 0000000..26fc3bc --- /dev/null +++ b/queue/src/main/java/com/codeterian/queue/infrastructure/redisson/aspect/DistributedLock.java @@ -0,0 +1,21 @@ +package com.codeterian.queue.infrastructure.redisson.aspect; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + + //락의 이름 + String key(); + + //락의 시간 단위 + TimeUnit timeUnit() default TimeUnit.SECONDS; + + long waitTime() default 5000L; // 락 획득을 시도하는 최대 시간 (ms) + long leaseTime() default 3000L; // 락을 획득한 후, 점유하는 최대 시간 (ms) +} diff --git a/queue/src/main/java/com/codeterian/queue/presentation/controller/QueueController.java b/queue/src/main/java/com/codeterian/queue/presentation/controller/QueueController.java index a01be2c..8176ccf 100644 --- a/queue/src/main/java/com/codeterian/queue/presentation/controller/QueueController.java +++ b/queue/src/main/java/com/codeterian/queue/presentation/controller/QueueController.java @@ -8,7 +8,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,7 +20,7 @@ public class QueueController { //사용자 대기열 진입 - @PostMapping("/join") + @GetMapping("/join") public ResponseEntity> joinQueue(@CurrentPassport Passport passport) { //서비스 로직