From cd9f1324d05a8e71f5eb084ca128ace15f7630c8 Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Wed, 26 Nov 2025 02:44:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Lesson=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Redis=20=EB=B6=84=EC=82=B0=EB=9D=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EB=B6=84=EC=82=B0=EB=9D=BD=20config,?= =?UTF-8?q?=20aspect=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../coupon/user/service/CouponService.java | 4 +- .../controller/StudentLessonController.java | 1 + .../student/service/StudentLessonService.java | 20 ++++- .../global/annotation/DistributedLock.java | 16 ++++ .../global/annotation/RedissonLock.java | 19 ----- .../global/aop/DistributedLockAspect.java | 75 +++++++++++++++++++ .../trainus/global/config/RedisConfig.java | 12 +++ .../trainus/global/config/RedissonConfig.java | 20 ----- .../global/exception/domain/ErrorCode.java | 5 ++ .../global/utils/CustomSpringELParser.java | 19 ----- .../global/utils/RedssionLockAspect.java | 59 --------------- .../lesson/student/LessonApplyLockTest.java | 4 +- 13 files changed, 134 insertions(+), 122 deletions(-) create mode 100644 src/main/java/com/threestar/trainus/global/annotation/DistributedLock.java delete mode 100644 src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java create mode 100644 src/main/java/com/threestar/trainus/global/aop/DistributedLockAspect.java delete mode 100644 src/main/java/com/threestar/trainus/global/config/RedissonConfig.java delete mode 100644 src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java delete mode 100644 src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java diff --git a/build.gradle b/build.gradle index 3dc338bd..326de2a6 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' 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 9ce36b87..e09bd11f 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,7 +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.annotation.DistributedLock; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; @@ -35,7 +35,7 @@ public class CouponService { private final UserService userService; @Transactional - @RedissonLock(value = "#couponId") + @DistributedLock(key = "'coupon:' + #couponId") public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) { User user = userService.getUserById(userId); // Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java index db5f6e61..6128c295 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java @@ -80,6 +80,7 @@ public ResponseEntity> createLessonAp @PathVariable Long lessonId, @LoginUser Long userId ) { + // 잔여 좌석 확인 메서드 LessonApplicationResponseDto response = studentLessonService.applyToLesson(lessonId, userId); return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java index e2cbe053..2a55a4c8 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java @@ -40,6 +40,7 @@ import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; +import com.threestar.trainus.global.annotation.DistributedLock; import com.threestar.trainus.global.utils.PageLimitCalculator; import lombok.RequiredArgsConstructor; @@ -212,26 +213,42 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) { } @Transactional + @DistributedLock(key = "'lesson_apply:' + #lessonId") public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long userId) { - Lesson lesson = adminLessonService.findLessonByIdWithLock(lessonId); // 락적용 find 메서드 + // 레슨 조회 + Lesson lesson = adminLessonService.findLessonById(lessonId); + // 유저 조회 User user = userService.getUserById(userId); + // 개설자 신청 불가 체크 if (lesson.getLessonLeader().equals(userId)) { throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY); } + // 중복 체크 boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId); boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId); if (alreadyParticipated || alreadyApplied) { throw new BusinessException(ErrorCode.ALREADY_APPLIED); } + // 레슨 상태 체크 if (lesson.getStatus() != LessonStatus.RECRUITING) { throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); } + // 선착순 여부에 따라 저장 처리 분기 if (lesson.getOpenRun()) { + // 락 내부에서 정원 체크 + if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); + } + // 신청 시간 체크 + if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) { + throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN); + } + LessonParticipant participant = LessonParticipant.builder() .lesson(lesson) .user(user) @@ -246,6 +263,7 @@ public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long us participant.getJoinAt() ); } else { + // 신청만 등록 LessonApplication application = LessonApplication.builder() .lesson(lesson) .user(user) diff --git a/src/main/java/com/threestar/trainus/global/annotation/DistributedLock.java b/src/main/java/com/threestar/trainus/global/annotation/DistributedLock.java new file mode 100644 index 00000000..de871956 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/annotation/DistributedLock.java @@ -0,0 +1,16 @@ +package com.threestar.trainus.global.annotation; + +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(); + long waitTime() default 20L; + long leaseTime() default 3L; + TimeUnit timeUnit() default TimeUnit.SECONDS; +} diff --git a/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java b/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java deleted file mode 100644 index 6ca33486..00000000 --- a/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java +++ /dev/null @@ -1,19 +0,0 @@ -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/aop/DistributedLockAspect.java b/src/main/java/com/threestar/trainus/global/aop/DistributedLockAspect.java new file mode 100644 index 00000000..aab5aad9 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/aop/DistributedLockAspect.java @@ -0,0 +1,75 @@ +package com.threestar.trainus.global.aop; + +import com.threestar.trainus.global.annotation.DistributedLock; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +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.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; +import org.springframework.core.annotation.Order; + +@Aspect +@Component +@Order(1) +@RequiredArgsConstructor +@Slf4j +public class DistributedLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(distributedLock)") + public Object lock(final ProceedingJoinPoint joinPoint, final DistributedLock distributedLock) throws Throwable { + String lockName = createDynamicKey(joinPoint, distributedLock.key()); + RLock lock = redissonClient.getFairLock(lockName); // FairLock으로 순서 보장 + + try { + // 락 획득 시도 + boolean isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), + distributedLock.timeUnit()); + if (!isLocked) { + log.warn("Failed to acquire lock: {}", lockName); + throw new BusinessException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + log.info("Acquired lock: {}", lockName); + + // 실제 타겟 메소드 실행 + return joinPoint.proceed(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BusinessException(ErrorCode.LOCK_INTERRUPTED); + } finally { + // 락 해제 + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("Released lock: {}", lockName); + } + } + } + + private String createDynamicKey(ProceedingJoinPoint joinPoint, String key) { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + 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, String.class); + } +} 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 ee03451e..ea5537a6 100644 --- a/src/main/java/com/threestar/trainus/global/config/RedisConfig.java +++ b/src/main/java/com/threestar/trainus/global/config/RedisConfig.java @@ -1,5 +1,8 @@ package com.threestar.trainus.global.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; @@ -18,6 +21,15 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int port; + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port); + return Redisson.create(config); + } + @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(host, port); diff --git a/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java b/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java deleted file mode 100644 index 1dcc6b1b..00000000 --- a/src/main/java/com/threestar/trainus/global/config/RedissonConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -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/exception/domain/ErrorCode.java b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java index d225ab20..eb5b807e 100644 --- a/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java +++ b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java @@ -96,6 +96,8 @@ public enum ErrorCode { // 409 ALREADY_APPLIED(HttpStatus.CONFLICT, "이미 신청한 레슨입니다."), + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."), + /* * Review : 리뷰 관련 예외처리 */ @@ -126,6 +128,9 @@ public enum ErrorCode { INSTRUCTOR_HAS_LESSONS(HttpStatus.BAD_REQUEST, "등록된 레슨이 있는 강사는 탈퇴할 수 없습니다. 먼저 모든 레슨을 삭제해주세요."), USER_HAS_ACTIVE_APPLICATIONS(HttpStatus.BAD_REQUEST, "참여 중인 레슨이 있어 탈퇴할 수 없습니다."), + + LOCK_INTERRUPTED(HttpStatus.INTERNAL_SERVER_ERROR, "락을 대기하던 중 오류가 발생했습니다."), + /* * Profile : 프로필 관련 예외처리 */ diff --git a/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java b/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java deleted file mode 100644 index e45fabec..00000000 --- a/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index affb7db2..00000000 --- a/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java +++ /dev/null @@ -1,59 +0,0 @@ -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/domain/lesson/student/LessonApplyLockTest.java b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java index ef35e230..9526c1d9 100644 --- a/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java +++ b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java @@ -46,7 +46,7 @@ public class LessonApplyLockTest { private Long lessonId; private static final int MAX_PARTICIPANTS = 300; - private static final int CONCURRENT_USERS = 3000; + private static final int CONCURRENT_USERS = 10000; @BeforeEach void setUp() { @@ -149,7 +149,7 @@ void tearDown() { @Test @DisplayName("동시 요청 시 - 락 적용: 인원 수 초과 없이 정상 처리") void applyToLessonWithLock_동시요청_락적용_최대참가자수를초과하지않음() throws InterruptedException { - ExecutorService executor = Executors.newFixedThreadPool(300); // 병렬 쓰레드 수 조절 + ExecutorService executor = Executors.newFixedThreadPool(100); // 병렬 쓰레드 수 조절 CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); AtomicInteger successCount = new AtomicInteger(); From 0aad27d71315c3777a0a8955048e36bb7d811cc0 Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Wed, 26 Nov 2025 12:18:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=9D=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20API=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/CouponController.java | 2 +- .../coupon/user/service/CouponService.java | 74 ++++++++++++++++++- .../controller/StudentLessonController.java | 2 +- .../student/service/StudentLessonService.java | 71 +++++++++++++++++- .../controller/TestConcurrencyController.java | 66 ++++++++++++++--- .../lesson/student/LessonApplyLockTest.java | 8 +- 6 files changed, 204 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/controller/CouponController.java b/src/main/java/com/threestar/trainus/domain/coupon/user/controller/CouponController.java index 11c0d803..32e52f32 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/controller/CouponController.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/controller/CouponController.java @@ -35,7 +35,7 @@ public ResponseEntity> createUserCoupo @PathVariable Long couponId, @LoginUser Long userId ) { - CreateUserCouponResponseDto dto = couponService.createUserCoupon(userId, couponId); + CreateUserCouponResponseDto dto = couponService.createUserCouponWithDistributedLock(userId, couponId); return BaseResponse.ok("쿠폰 발급 완료", dto, HttpStatus.CREATED); } 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 e09bd11f..5022e10d 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 @@ -34,9 +34,81 @@ public class CouponService { private final UserCouponRepository userCouponRepository; private final UserService userService; + @Transactional + public CreateUserCouponResponseDto createUserCouponWithoutLock(Long userId, Long couponId) { + User user = userService.getUserById(userId); + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); + // 쿠폰 발급 종료시각이 지났으면 예외처리 + if (LocalDateTime.now().isAfter(coupon.getCloseAt())) { + throw new BusinessException(ErrorCode.COUPON_EXPIRED); + } + //중복 발급 방지 + boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId); + if (alreadyIssued) { + throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED); + } + + //쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통) + if (LocalDateTime.now().isBefore(coupon.getOpenAt())) { + throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN); + } + + if (coupon.getCategory() == CouponCategory.OPEN_RUN) { + //선착순 쿠폰 발급 시 수량이 소진되면 예외처리 + if (coupon.getQuantity() <= 0) { + throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED); + } + coupon.decreaseQuantity(); + } + + LocalDateTime expirationDate = coupon.getExpirationDate(); + + UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate); + userCouponRepository.save(userCoupon); + + return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon); + } + + @Transactional + public CreateUserCouponResponseDto createUserCouponWithPessimisticLock(Long userId, Long couponId) { + User user = userService.getUserById(userId); + Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) + .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); + // 쿠폰 발급 종료시각이 지났으면 예외처리 + if (LocalDateTime.now().isAfter(coupon.getCloseAt())) { + throw new BusinessException(ErrorCode.COUPON_EXPIRED); + } + //중복 발급 방지 + boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId); + if (alreadyIssued) { + throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED); + } + + //쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통) + if (LocalDateTime.now().isBefore(coupon.getOpenAt())) { + throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN); + } + + if (coupon.getCategory() == CouponCategory.OPEN_RUN) { + //선착순 쿠폰 발급 시 수량이 소진되면 예외처리 + if (coupon.getQuantity() <= 0) { + throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED); + } + coupon.decreaseQuantity(); + } + + LocalDateTime expirationDate = coupon.getExpirationDate(); + + UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate); + userCouponRepository.save(userCoupon); + + return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon); + } + @Transactional @DistributedLock(key = "'coupon:' + #couponId") - public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) { + public CreateUserCouponResponseDto createUserCouponWithDistributedLock(Long userId, Long couponId) { User user = userService.getUserById(userId); // Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) // .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java index 6128c295..2f2a485f 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java @@ -81,7 +81,7 @@ public ResponseEntity> createLessonAp @LoginUser Long userId ) { // 잔여 좌석 확인 메서드 - LessonApplicationResponseDto response = studentLessonService.applyToLesson(lessonId, userId); + LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, userId); return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java index 2a55a4c8..5271516d 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java @@ -149,7 +149,7 @@ public LessonDetailResponseDto getLessonDetail(Long lessonId) { } @Transactional - public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) { + public LessonApplicationResponseDto applyToLessonWithoutLock(Long lessonId, Long userId) { // 레슨 조회 Lesson lesson = adminLessonService.findLessonById(lessonId); @@ -214,7 +214,7 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) { @Transactional @DistributedLock(key = "'lesson_apply:' + #lessonId") - public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long userId) { + public LessonApplicationResponseDto applyToLessonWithDistributedLock(Long lessonId, Long userId) { // 레슨 조회 Lesson lesson = adminLessonService.findLessonById(lessonId); @@ -279,6 +279,73 @@ public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long us } } + @Transactional + public LessonApplicationResponseDto applyToLessonWithPessimisticLock(Long lessonId, Long userId) { + // 레슨 조회 (비관적 락) + Lesson lesson = lessonRepository.findByIdWithLock(lessonId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + + // 유저 조회 + User user = userService.getUserById(userId); + + // 개설자 신청 불가 체크 + if (lesson.getLessonLeader().equals(userId)) { + throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY); + } + + // 중복 체크 + boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId); + boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId); + if (alreadyParticipated || alreadyApplied) { + throw new BusinessException(ErrorCode.ALREADY_APPLIED); + } + + // 레슨 상태 체크 + if (lesson.getStatus() != LessonStatus.RECRUITING) { + throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); + } + + // 선착순 여부에 따라 저장 처리 분기 + if (lesson.getOpenRun()) { + // 락 내부에서 정원 체크 + if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); + } + // 신청 시간 체크 + if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) { + throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN); + } + + LessonParticipant participant = LessonParticipant.builder() + .lesson(lesson) + .user(user) + .build(); + lessonParticipantRepository.save(participant); + lesson.incrementParticipantCount(); + + return LessonApplyMapper.toLessonApplicationResponseDto( + lesson.getId(), + user.getId(), + ApplicationStatus.APPROVED, + participant.getJoinAt() + ); + } else { + // 신청만 등록 + LessonApplication application = LessonApplication.builder() + .lesson(lesson) + .user(user) + .build(); + lessonApplicationRepository.save(application); + + return LessonApplyMapper.toLessonApplicationResponseDto( + lesson.getId(), + user.getId(), + ApplicationStatus.PENDING, + application.getCreatedAt() + ); + } + } + @Transactional public void cancelLessonApplication(Long lessonId, Long userId) { // 레슨 조회 diff --git a/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java b/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java index 2fd627d6..c1d1510d 100644 --- a/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java +++ b/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java @@ -27,25 +27,71 @@ public class TestConcurrencyController { private final StudentLessonService studentLessonService; private final TestUserService testUserService; - @PostMapping("/coupons/{couponId}") - @Operation(summary = "쿠폰 발급 동시성 테스트", description = "쿠폰을 발급받는 테스트 API") - public ResponseEntity> issueCouponForTest( + // 쿠폰 동시성 테스트 + @PostMapping("/coupons/{couponId}/no-lock") + @Operation(summary = "쿠폰 발급 동시성 테스트 (락 미적용)", description = "쿠폰을 발급받는 테스트 API (락 미적용)") + public ResponseEntity> issueCouponNoLock( @PathVariable Long couponId, @RequestBody TestRequestDto testRequestDto ) { User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); - CreateUserCouponResponseDto responseDto = couponService.createUserCoupon(user.getId(), couponId); - return BaseResponse.ok("쿠폰 발급 완료", responseDto, HttpStatus.CREATED); + CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithoutLock(user.getId(), couponId); + return BaseResponse.ok("쿠폰 발급 완료 (락 미적용)", responseDto, HttpStatus.CREATED); } - @PostMapping("/lessons/{lessonId}/application") - @Operation(summary = "레슨 신청 동시성 테스트", description = "레슨을 신청하는 테스트 API") - public ResponseEntity> applyToLessonForTest( + @PostMapping("/coupons/{couponId}/pessimistic-lock") + @Operation(summary = "쿠폰 발급 동시성 테스트 (비관적 락)", description = "쿠폰을 발급받는 테스트 API (비관적 락)") + public ResponseEntity> issueCouponPessimisticLock( + @PathVariable Long couponId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithPessimisticLock(user.getId(), couponId); + return BaseResponse.ok("쿠폰 발급 완료 (비관적 락)", responseDto, HttpStatus.CREATED); + } + + @PostMapping("/coupons/{couponId}/distributed-lock") + @Operation(summary = "쿠폰 발급 동시성 테스트 (분산 락)", description = "쿠폰을 발급받는 테스트 API (분산 락)") + public ResponseEntity> issueCouponDistributedLock( + @PathVariable Long couponId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithDistributedLock(user.getId(), couponId); + return BaseResponse.ok("쿠폰 발급 완료 (분산 락)", responseDto, HttpStatus.CREATED); + } + + // 레슨 신청 동시성 테스트 + @PostMapping("/lessons/{lessonId}/application/no-lock") + @Operation(summary = "레슨 신청 동시성 테스트 (락 미적용)", description = "레슨을 신청하는 테스트 API (락 미적용)") + public ResponseEntity> applyToLessonNoLock( + @PathVariable Long lessonId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + LessonApplicationResponseDto response = studentLessonService.applyToLessonWithoutLock(lessonId, user.getId()); + return BaseResponse.ok("레슨 신청 완료 (락 미적용)", response, HttpStatus.OK); + } + + @PostMapping("/lessons/{lessonId}/application/pessimistic-lock") + @Operation(summary = "레슨 신청 동시성 테스트 (비관적 락)", description = "레슨을 신청하는 테스트 API (비관적 락)") + public ResponseEntity> applyToLessonPessimisticLock( + @PathVariable Long lessonId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + LessonApplicationResponseDto response = studentLessonService.applyToLessonWithPessimisticLock(lessonId, user.getId()); + return BaseResponse.ok("레슨 신청 완료 (비관적 락)", response, HttpStatus.OK); + } + + @PostMapping("/lessons/{lessonId}/application/distributed-lock") + @Operation(summary = "레슨 신청 동시성 테스트 (분산 락)", description = "레슨을 신청하는 테스트 API (분산 락)") + public ResponseEntity> applyToLessonDistributedLock( @PathVariable Long lessonId, @RequestBody TestRequestDto testRequestDto ) { User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); - LessonApplicationResponseDto response = studentLessonService.applyToLessonWithLock(lessonId, user.getId()); - return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK); + LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, user.getId()); + return BaseResponse.ok("레슨 신청 완료 (분산 락)", response, HttpStatus.OK); } } diff --git a/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java index 9526c1d9..4d539bb8 100644 --- a/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java +++ b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java @@ -116,7 +116,7 @@ void tearDown() { try { Lesson updatedLesson = lessonRepository.findById(lessonId).orElseThrow(); User user = userRepository.findByEmail(email).orElseThrow(); - lessonService.applyToLesson(lessonId, user.getId()); + lessonService.applyToLessonWithoutLock(lessonId, user.getId()); log.info("신청 성공 - email: {} | participantCount: {}", email, updatedLesson.getParticipantCount()); successCount.incrementAndGet(); } catch (Exception e) { @@ -147,8 +147,8 @@ void tearDown() { } @Test - @DisplayName("동시 요청 시 - 락 적용: 인원 수 초과 없이 정상 처리") - void applyToLessonWithLock_동시요청_락적용_최대참가자수를초과하지않음() throws InterruptedException { + @DisplayName("동시 요청 시 - 비관락 적용: 인원 수 초과 없이 정상 처리") + void applyToLessonWithLock_동시요청_비관락적용_최대참가자수를초과하지않음() throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(100); // 병렬 쓰레드 수 조절 CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); @@ -165,7 +165,7 @@ void tearDown() { try { Lesson updatedLesson = lessonRepository.findById(lessonId).orElseThrow(); User user = userRepository.findByEmail(email).orElseThrow(); - lessonService.applyToLessonWithLock(lessonId, user.getId()); + lessonService.applyToLessonWithPessimisticLock(lessonId, user.getId()); log.info("신청 성공 - email: {} | participantCount: {}", email, updatedLesson.getParticipantCount()); successCount.incrementAndGet(); } catch (Exception e) {