diff --git a/build.gradle b/build.gradle index efe947cd..3dc338bd 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' //Redisson + testImplementation 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' // 테스트 의존성 추가 testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index f1290ae2..d8673aef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: depends_on: - mysqldb - redis + env_file: + - .env environment: - SPRING_PROFILES_ACTIVE=docker networks: @@ -61,32 +63,32 @@ services: networks: - app-tier - prometheus: - user: "root" - image: prom/prometheus - container_name: prometheus_container - volumes: - - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/config:/etc/prometheus - - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/volume:/prometheus/data - ports: - - 9090:9090 - command: - - '--config.file=/etc/prometheus/prometheus.yml' - restart: always - networks: - - app-tier - - grafana: - user: "root" - image: grafana/grafana - container_name: grafana_container - ports: - - 3000:3000 - volumes: - - ./grafana/volume:/var/lib/grafana - restart: always - networks: - - app-tier +# prometheus: +# user: "root" +# image: prom/prometheus +# container_name: prometheus_container +# volumes: +# - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/config:/etc/prometheus +# - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/volume:/prometheus/data +# ports: +# - 9090:9090 +# command: +# - '--config.file=/etc/prometheus/prometheus.yml' +# restart: always +# networks: +# - app-tier +# +# grafana: +# user: "root" +# image: grafana/grafana +# container_name: grafana_container +# ports: +# - 3000:3000 +# volumes: +# - ./grafana/volume:/var/lib/grafana +# restart: always +# networks: +# - app-tier volumes: mysqldb-data: diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java b/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java index 36b25aca..9ce36b87 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java @@ -20,6 +20,7 @@ import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; import com.threestar.trainus.domain.user.entity.User; import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.annotation.RedissonLock; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; @@ -34,12 +35,14 @@ public class CouponService { private final UserService userService; @Transactional + @RedissonLock(value = "#couponId") public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) { User user = userService.getUserById(userId); - Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) + // Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) + // .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); + Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); - - //쿠폰 발급 종료시각이 지났으면 예외처리 + // 쿠폰 발급 종료시각이 지났으면 예외처리 if (LocalDateTime.now().isAfter(coupon.getCloseAt())) { throw new BusinessException(ErrorCode.COUPON_EXPIRED); } diff --git a/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java b/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java new file mode 100644 index 00000000..6ca33486 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java @@ -0,0 +1,19 @@ +package com.threestar.trainus.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedissonLock { + + String value(); //Lock 이름 + + long waitTime() default 5000L; //Lock을 획득을 시도하는 최대 시간 ms + + long leaseTime() default 2000L; //락을 획득한 후, 점유하는 최대 시간 ms +} diff --git a/src/main/java/com/threestar/trainus/global/config/CorsConfig.java b/src/main/java/com/threestar/trainus/global/config/CorsConfig.java index 806d7c6f..3ecd5403 100644 --- a/src/main/java/com/threestar/trainus/global/config/CorsConfig.java +++ b/src/main/java/com/threestar/trainus/global/config/CorsConfig.java @@ -12,9 +12,9 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins("http://localhost:3000", "http://localhost:8080", "http://localhost:8031", - "http://43.202.206.47:8080", - "http://43.202.206.47:3000", - "http://43.202.206.47:8031") + "http://15.165.184.145:8080", + "http://15.165.184.145:3000", + "http://15.165.184.145:8031") .allowCredentials(true) // 쿠키 허용 .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*"); diff --git a/src/main/java/com/threestar/trainus/global/config/RedisConfig.java b/src/main/java/com/threestar/trainus/global/config/RedisConfig.java index f237e607..ee03451e 100644 --- a/src/main/java/com/threestar/trainus/global/config/RedisConfig.java +++ b/src/main/java/com/threestar/trainus/global/config/RedisConfig.java @@ -1,7 +1,6 @@ package com.threestar.trainus.global.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -10,8 +9,6 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; - - @Configuration public class RedisConfig { diff --git a/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java b/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java new file mode 100644 index 00000000..1dcc6b1b --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java @@ -0,0 +1,20 @@ +package com.threestar.trainus.global.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379"); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java b/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java new file mode 100644 index 00000000..e45fabec --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java @@ -0,0 +1,19 @@ +package com.threestar.trainus.global.utils; + +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public class CustomSpringELParser { + + public static Object 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]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} diff --git a/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java b/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java new file mode 100644 index 00000000..affb7db2 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java @@ -0,0 +1,59 @@ +package com.threestar.trainus.global.utils; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +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.core.annotation.Order; +import org.springframework.stereotype.Component; + +import com.threestar.trainus.global.annotation.RedissonLock; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Order(1) +@Component +@RequiredArgsConstructor +public class RedssionLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(com.threestar.trainus.global.annotation.RedissonLock)") + public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Method method = signature.getMethod(); + RedissonLock annotation = method.getAnnotation(RedissonLock.class); + String lockKey = + method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), + joinPoint.getArgs(), annotation.value()); + + RLock lock = redissonClient.getFairLock(lockKey); + + try { + boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS); + if (!lockable) { + log.info("Lock 획득 실패={}", lockKey); + return null; + } + log.info("로직 수행"); + return joinPoint.proceed(); + } catch (InterruptedException e) { + log.info("에러 발생"); + throw e; + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("락 해제 완료={}", lockKey); + } + } + + } +} diff --git a/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java index baa4e303..fd78e892 100644 --- a/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java @@ -96,7 +96,7 @@ void tearDown() { @Test @DisplayName("동시 요청 시 - 비관적 락 적용: 발급된 쿠폰 수량만큼 발급") void 쿠폰_동시_발급_테스트() throws InterruptedException { - ExecutorService executor = Executors.newFixedThreadPool(300); + ExecutorService executor = Executors.newFixedThreadPool(100); CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); AtomicInteger successCount = new AtomicInteger(); diff --git a/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java new file mode 100644 index 00000000..1c5b33e5 --- /dev/null +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java @@ -0,0 +1,139 @@ +package com.threestar.trainus.coupon.user; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.util.StopWatch; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.coupon.user.service.CouponService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +public class UserCouponServiceRedissonLockTests { + + @Autowired + CouponService couponService; + + @Autowired + CouponRepository couponRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + UserCouponRepository userCouponRepository; + + private static final int COUPON_QUANTITY = 300; + private static final int CONCURRENT_USERS = 3000; + List users; + Coupon coupon; + + @BeforeEach + void setUp() { + users = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_USERS; i++) { + User user = userRepository.save( + User.builder() + .email("user" + i + "@test.com") + .password("12341234") + .nickname("user" + i) + .role(UserRole.USER) + .build() + ); + users.add(user); + userRepository.flush(); + } + + // 쿠폰 생성 + coupon = couponRepository.save( + Coupon.builder() + .name("Redis 분산락 쿠폰") + .quantity(COUPON_QUANTITY) + .category(CouponCategory.OPEN_RUN) + .status(CouponStatus.ACTIVE) + .discountPrice("1000") + .minOrderPrice(20000) + .expirationDate(LocalDateTime.now().plusDays(1)) + .openAt(LocalDateTime.now().minusMinutes(10)) + .closeAt(LocalDateTime.now().plusDays(1)) + .build() + ); + } + + @AfterEach + void tearDown() { + userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("동시 요청 시 - Redis 분산락 적용: 발급된 쿠폰 수량만큼 발급") + void 쿠폰_동시_발급_테스트() throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + for (int i = 0; i < CONCURRENT_USERS; i++) { + final int idx = i; + + executor.submit(() -> { + try { + couponService.createUserCoupon(users.get(idx).getId(), coupon.getId()); + log.info("[성공] userId: {} | 현재 발급 수: {}", idx, + userCouponRepository.countByCouponId(coupon.getId())); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + log.error("[실패] userId: {} | message: {}", idx, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + stopWatch.stop(); + + long issuedCount = userCouponRepository.countByCouponId(coupon.getId()); + int remainingQuantity = couponRepository.findById(coupon.getId()).orElseThrow().getQuantity(); + + log.info("총 소요 시간(ms): {}", stopWatch.getTotalTimeMillis()); + log.info("총 요청 수: {}", CONCURRENT_USERS); + log.info("성공 요청 수: {}", successCount.get()); + log.info("실패 요청 수: {}", failCount.get()); + log.info("DB 기준 발급 수(userCoupon): {}", issuedCount); + log.info("남은 수량: {}", remainingQuantity); + + Assertions.assertEquals(COUPON_QUANTITY, issuedCount, "정확한 수량만큼 발급돼야 함"); + Assertions.assertEquals(successCount.get(), COUPON_QUANTITY, "성공 요청 수도 수량과 같아야 함"); + } +}