diff --git a/.gitignore b/.gitignore index a00fec8..48842d7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ out/ .vscode/ .env + +.DS_Store diff --git a/build.gradle b/build.gradle index 630196f..3491256 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,8 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.51.0' // WebSocket + STOMP implementation 'org.springframework.boot:spring-boot-starter-websocket' @@ -73,3 +75,8 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +// 파라미터명을 SpEL에서 쓰려면 Gradle 컴파일 옵션에 -parameters가 필요 +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += ['-parameters'] +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index cd9e51b..1abab28 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -1,7 +1,11 @@ package jpa.basic.alldayprojectcommerce.application; +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedisLock; +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedissonLock; +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; +import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; -import jpa.basic.alldayprojectcommerce.domain.order.service.EventOrderService; +import jpa.basic.alldayprojectcommerce.domain.order.service.event.EventOrderService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -10,9 +14,190 @@ public class EventOrderFacade { private final EventOrderService eventOrderService; + private final RedisLockService redisLockService; - + /** + * 락을 사용하지 않은 버전 + * @param productId 제품 ID + * @param userId 주문자 ID + * @return + */ public EventOrderResponse createEventOrderWithoutLock(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); } + + /** + * Redis 분산락 - Fail Fast 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockFailFast(Long productId, Long userId) { + + // 🔥 락 키 설계 (핵심) + String key = "lock:product:" + productId; + + // TTL (초) + long timeoutSeconds = 5; + + return redisLockService.executeWithLockFailFast( + key, + timeoutSeconds, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } + + /** + * Redis 분산락 - Retry 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockRetry(Long productId, Long userId) { + + String key = "lock:product:" + productId; + + return redisLockService.executeWithLockRetry( + key, + 5, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } + + /** + * Redis 분산락 - Blocking 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockBlocking(Long productId, Long userId) { + + String key = "lock:product:" + productId; + + return redisLockService.executeWithLockBlocking( + key, + 5, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } + + /** + * Redis 분산락 - AOP FailFast 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.FAIL_FAST + ) + public EventOrderResponse createEventOrderWithRedisLockAopFailFast(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redis 분산락 - AOP Retry 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.RETRY + ) + public EventOrderResponse createEventOrderWithRedisLockAopRetry(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redis 분산락 - AOP Blocking 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.BLOCKING + ) + public EventOrderResponse createEventOrderWithRedisLockAopBlocking(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redisson 분산락 - AOP 적용 버전 + * + * waitTimeSeconds: + * - 락 획득을 최대 몇 초까지 기다릴지. + * 짧으면 Retry 전략, 길면 Blocking 전략 + * + * leaseTimeSeconds: + * - 락을 몇 초 동안 유지할지 + * - 이 시간이 지나면 자동으로 락이 해제됨 + * + * Retry + TTL + */ + @RedissonLock( + key = "'lock:product:' + #productId", + waitTimeSeconds = 2, + leaseTimeSeconds = 5 + ) + public EventOrderResponse createEventOrderWithRedissonLockAopRetry(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redisson 분산락 - AOP 적용 버전 + * Blocking + TTL + */ + @RedissonLock( + key = "'lock:product:' + #productId", + waitTimeSeconds = 10, + leaseTimeSeconds = 10 + ) + public EventOrderResponse createEventOrderWithRedissonLockAopBlocking(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + + + /** + * Redisson 분산락 - Watchdog 적용 버전 + * + * leaseTimeSeconds = -1 + * - Redisson Watchdog 사용 + * - 작업이 끝나기 전까지 락 TTL을 자동 연장 + * + * Retry + Watchdog + */ + @RedissonLock( + key = "'lock:product:' + #productId", + waitTimeSeconds = 2, + leaseTimeSeconds = -1 + ) + public EventOrderResponse createEventOrderWithRedissonLockAopRetryWatchdog(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redisson 분산락 - Watchdog 적용 버전 + * + * leaseTimeSeconds = -1 + * - Redisson Watchdog 사용 + * - 작업이 끝나기 전까지 락 TTL을 자동 연장 + * + * Blocking + Watchdog + */ + @RedissonLock( + key = "'lock:product:' + #productId", + waitTimeSeconds = 10, + leaseTimeSeconds = -1 + ) + public EventOrderResponse createEventOrderWithRedissonLockAopBlockingWatchdog(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataUser.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataUser.java new file mode 100644 index 0000000..60efd08 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataUser.java @@ -0,0 +1,57 @@ +package jpa.basic.alldayprojectcommerce.common.config; + +import jpa.basic.alldayprojectcommerce.domain.user.entity.User; +import jpa.basic.alldayprojectcommerce.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DummyDataUser implements CommandLineRunner { + + private final UserRepository userRepository; + + @Override + public void run(String... args) { + + // 이미 유저 데이터가 있으면 중복 생성하지 않음 + if (userRepository.count() > 0) { + return; + } + + // 10,000명 더미 유저 생성 + List users = new ArrayList<>(); + + for (int i = 1; i <= 10000; i++) { + // 이메일과 비밀번호를 먼저 세팅하여 User 엔티티 생성 + User user = User.createUser( + "user" + i + "@test.com", + "encoded-password-" + i + ); + + // 주문 가능 조건에 필요한 프로필 정보 세팅 + user.updateProfile( + "유저" + i, + "010-1234-" + String.format("%04d", i % 10000), + "서울시 강남구 테스트로 " + i + ); + + users.add(user); + + // 1000명 단위로 배치 저장하여 메모리 사용량 절약 + if (i % 1000 == 0) { + userRepository.saveAll(users); + users.clear(); + } + } + + // 혹시 남은 데이터가 있으면 마지막 저장 + if (!users.isEmpty()) { + userRepository.saveAll(users); + } + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedissonConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedissonConfig.java new file mode 100644 index 0000000..bc86cc5 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedissonConfig.java @@ -0,0 +1,41 @@ +package jpa.basic.alldayprojectcommerce.common.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + /** + * RedissonClient = Redis 분산락 핵심 객체 + * + * - Lettuce에서는 StringRedisTemplate으로 직접 SET NX 호출 + * - Redisson에서는 RedissonClient 하나로 Lock, Queue, Semaphore 등 다 처리함 + */ + @Bean(destroyMethod = "shutdown") + public RedissonClient redissonClient(RedisProperties redisProperties) { + Config config = new Config(); + /** + * Redis 주소 설정 + * + * - Spring Redis 설정(application.yml)에 있는 host, port 가져옴 + * - redis:// prefix 꼭 필요 + */ + String address = "redis://" + redisProperties.getHost() + ":" + redisProperties.getPort(); + + config.useSingleServer() + .setAddress(address); + + /** + * RedissonClient 생성 + * + * - 내부적으로 Netty 기반으로 Redis와 통신 + * - RLock 등 분산락 기능 제공 + */ + return Redisson.create(config); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java index 47bde1b..d3a29dd 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java @@ -52,7 +52,7 @@ public enum ErrorCode { PAYMENT_ORDER_NOT_MATCHES(HttpStatus.BAD_REQUEST, "PAY004", "입력한 주문에 대하여 생성된 결제 건이 아닙니다."), PAYMENT_INVALID_UID(HttpStatus.BAD_REQUEST, "PAY005", "유효하지 않은 결제 UID 입니다."), - // 채팅 도메인(CH001) + // 채팅 도메인(CH###) CHAT_INVALID_STATUS_TRANSITION(HttpStatus.BAD_REQUEST, "CH001", "유효하지 않은 상태 전이입니다."), CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CH002", "채팅방을 찾을 수 없습니다."), CHAT_ROOM_FORBIDDEN(HttpStatus.FORBIDDEN, "CH003", "채팅방에 접근할 권한이 없습니다."), @@ -60,7 +60,12 @@ public enum ErrorCode { CHAT_ROOM_CLOSED(HttpStatus.BAD_REQUEST, "CH005", "종료된 상담방입니다."), CHAT_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "CH006", "채팅 인증에 실패했습니다."), CHAT_MESSAGE_EMPTY(HttpStatus.BAD_REQUEST, "CH007", "메시지 내용은 비어있을 수 없습니다."), - CHAT_MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "CH008", "메시지는 1000자를 초과할 수 없습니다."); + CHAT_MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "CH008", "메시지는 1000자를 초과할 수 없습니다."), + + // Lock 관련(L###) + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT,"L001","락 획득에 실패했습니다.") + + ; diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java new file mode 100644 index 0000000..9a90763 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java @@ -0,0 +1,27 @@ +package jpa.basic.alldayprojectcommerce.common.lock.annotation; + +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedisLock { + + /** + * SpEL 기반 락 키 + * 예: "'lock:product:' + #productId" + */ + String key(); + + /** + * Redis 락 TTL + */ + long timeoutSeconds() default 5; + + /** + * 락 획득 전략 + */ + RedisLockStrategy strategy() default RedisLockStrategy.RETRY; +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java new file mode 100644 index 0000000..fae161a --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java @@ -0,0 +1,28 @@ +package jpa.basic.alldayprojectcommerce.common.lock.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedissonLock { + + /** + * SpEL 기반 락 키 + * 예: "'lock:product:' + #productId" + */ + String key(); + + /** + * 락 획득을 기다릴 최대 시간 + */ + long waitTimeSeconds() default 3; + + /** + * 락 점유 시간 + * + * - 양수: 지정 시간 후 자동 해제 + * - -1: leaseTime 없이 watchdog 사용 + */ + long leaseTimeSeconds() default 5; +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java new file mode 100644 index 0000000..aeae805 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java @@ -0,0 +1,164 @@ +package jpa.basic.alldayprojectcommerce.common.lock.aspect; + +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedisLock; +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; +import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService; +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.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +/** + * RedisLockAspect + * + * 역할: + * @RedisLock 애노테이션이 붙은 메서드를 가로채서 + * Redis 분산락 획득 → 실제 메서드 실행 → Redis 분산락 해제 흐름을 적용한다. + * + * 즉, 비즈니스 코드에서 직접 redisLockService.executeWithLock...()을 호출하지 않아도 + * 애노테이션만 붙이면 락 처리가 자동으로 적용된다. + */ +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisLockAspect { + + /** + * 이 Aspect는 "언제 어떤 key로 어떤 전략의 락을 걸지" 결정하고, + * 실제 락 처리는 RedisLockService에게 위임한다. + */ + private final RedisLockService redisLockService; + + /** + * SpEL 표현식을 해석하기 위한 파서 + * + * 예: + * "'lock:product:' + #productId" + * + * productId가 4라면 + * "lock:product:4" 로 변환한다. + */ + private final ExpressionParser parser = new SpelExpressionParser(); + + /** + * @RedisLock 애노테이션이 붙은 메서드를 감싸는 Around Advice + * + * @Around("@annotation(redisLock)") + * - @RedisLock이 붙은 메서드가 호출되면 이 메서드가 먼저 실행된다. + */ + @Around("@annotation(redisLock)") + public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) { + + /** + * redisLock 파라미터로 어노테이션에 적은 값을 가져온다. + * key = "'lock:product:' + #productId" + * timeoutSeconds = 5 + * strategy = RETRY + */ + + // 1. 애노테이션에 작성한 key 표현식을 실제 Redis Lock Key로 변환 + String key = parseKey(joinPoint, redisLock.key()); + // 2. 애노테이션에서 설정한 락 전략과 TTL 값을 가져온다. + RedisLockStrategy strategy = redisLock.strategy(); + long timeoutSeconds = redisLock.timeoutSeconds(); + log.info("[RedisLock-AOP] key={}, strategy={}", key, strategy); + + return switch (strategy) { + case FAIL_FAST -> redisLockService.executeWithLockFailFast( + key, + timeoutSeconds, + /** + * 핵심: + * () -> proceed(joinPoint) + * + * 이 람다가 바로 "락을 잡은 뒤 실행할 실제 비즈니스 로직"이다. + */ + () -> proceed(joinPoint) + ); + + case RETRY -> redisLockService.executeWithLockRetry( + key, + timeoutSeconds, + () -> proceed(joinPoint) + ); + + case BLOCKING -> redisLockService.executeWithLockBlocking( + key, + timeoutSeconds, + () -> proceed(joinPoint) + ); + }; + } + + private Object proceed(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + /* + * joinPoint.proceed()는 Throwable을 던질 수 있다. + * + * 하지만 Supplier는 checked exception을 던질 수 없으므로 + * checked exception은 RuntimeException으로 감싸서 던진다. + */ + throw new RuntimeException(e); + } + } + + private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) { + /* + * MethodSignature: + * 현재 AOP가 가로챈 메서드의 시그니처 정보 + * + * 여기서 파라미터 이름을 얻을 수 있다. + * + * 예: + * createEventOrderWithRedisLockAopRetry(Long productId, Long userId) + * + * parameterNames = ["productId", "userId"] + * args = [4L, 1L] + */ + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + + /* + * SpEL에서 사용할 변수 저장소 + * + * context에 productId=4, userId=1 같은 변수를 넣어두면 + * SpEL 표현식에서 #productId, #userId로 사용할 수 있다. + */ + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + /* + * 파라미터 이름과 실제 인자값을 매칭해서 context에 등록한다. + * + * 예: + * parameterNames[0] = "productId" + * args[0] = 4L + * + * context.setVariable("productId", 4L) + */ + context.setVariable(parameterNames[i], args[i]); + } + + /* + * SpEL 표현식을 실제 문자열로 평가한다. + * + * 예: + * "'lock:product:' + #productId" + * → "lock:product:4" + */ + return parser.parseExpression(keyExpression).getValue(context, String.class); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java new file mode 100644 index 0000000..b5bc16a --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java @@ -0,0 +1,63 @@ +package jpa.basic.alldayprojectcommerce.common.lock.aspect; + +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedissonLock; +import jpa.basic.alldayprojectcommerce.common.lock.service.RedissonLockService; +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.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RedissonLockAspect { + + private final RedissonLockService redissonLockService; + private final ExpressionParser parser = new SpelExpressionParser(); + + @Around("@annotation(redissonLock)") + public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) { + String key = parseKey(joinPoint, redissonLock.key()); + + log.info("[RedissonLock-AOP] key={}", key); + + return redissonLockService.executeWithLock( + key, + redissonLock.waitTimeSeconds(), + redissonLock.leaseTimeSeconds(), + () -> proceed(joinPoint) + ); + } + + private Object proceed(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(keyExpression).getValue(context, String.class); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java new file mode 100644 index 0000000..142ab92 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java @@ -0,0 +1,7 @@ +package jpa.basic.alldayprojectcommerce.common.lock.enums; + +public enum RedisLockStrategy { + FAIL_FAST, + RETRY, + BLOCKING +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java new file mode 100644 index 0000000..67c2925 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java @@ -0,0 +1,71 @@ +package jpa.basic.alldayprojectcommerce.common.lock.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Collections; + +/** + * Redis 명령 담당 + * setIfAbsent, Lua Script 해제 + * 저수준 인프라 역할 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class RedisLockRepository { + + private final StringRedisTemplate redisTemplate; + + /** + * Redis 락 획득 시도 + * + * setIfAbsent == SET NX + * TTL을 함께 걸어 서버 장애 시 락이 영구 점유되지 않도록 한다. + * + * @param key 락 키 (예: lock:product:4) + * @param value 락 소유자 식별값 (UUID) + * @param timeoutSeconds 락 유지 시간 + * @return 락 획득 성공 여부 + */ + public boolean tryLock(String key, String value, long timeoutSeconds) { + Boolean result = redisTemplate.opsForValue() + .setIfAbsent(key, value, Duration.ofSeconds(timeoutSeconds)); + + return Boolean.TRUE.equals(result); + } + + /** + * Redis 락 해제 + * + * 본인이 획득한 락만 해제해야 하므로 + * value(UUID)를 비교한 뒤 동일할 때만 delete 한다. + * 이 과정을 Lua Script로 원자적으로 수행한다. + * + * @param key 락 키 + * @param value 락 소유자 식별값 (UUID) + * @return 실제 삭제 여부 + */ + public boolean unlock(String key, String value) { + String script = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """; + + Long result = redisTemplate.execute( + new DefaultRedisScript<>(script, Long.class), + Collections.singletonList(key), + value + ); + log.info("[RedisLock] unlock 시도 key={}, value={}", key, value); + + return result != null && result == 1L; + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java new file mode 100644 index 0000000..ead8f5d --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java @@ -0,0 +1,150 @@ +package jpa.basic.alldayprojectcommerce.common.lock.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.common.lock.repository.RedisLockRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.UUID; +import java.util.function.Supplier; + +/** + * 실패 시 즉시 예외 + * 비즈니스 로직 실행 + * finally에서 락 해제 + * + * 락 흐름 제어 담당 + * key/value/ttl/예외 처리 + * 상위 비즈니스에서 쓰기 쉬움 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisLockService { + + private final RedisLockRepository redisLockRepository; + + /** + * Redis 분산락을 획득한 뒤 비즈니스 로직 실행 + * + * 현재 단계에서는 Fail Fast 전략을 사용한다. + * 즉, 락 획득에 실패하면 재시도하지 않고 바로 예외를 던진다. + * + * @param key 락 키 (예: lock:product:4) + * @param timeoutSeconds 락 유지 시간 + * @param supplier 락 안에서 실행할 비즈니스 로직 + * @return supplier 실행 결과 + * @param 반환 타입 + */ + public T executeWithLockFailFast(String key, long timeoutSeconds, Supplier supplier) { + String lockValue = UUID.randomUUID().toString(); + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (!locked) { + log.info("[RedisLock] 락 획득 실패 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + log.info("[RedisLock] 락 획득 성공 key={}, value={}", key, lockValue); + + try { + // 목표하는 메서드 수행 + return supplier.get(); + } finally { + // 락 해제 + boolean unlocked = redisLockRepository.unlock(key, lockValue); + + if (unlocked) { + log.info("[RedisLock] 락 해제 성공 key={}, value={}", key, lockValue); + } else { + log.warn("[RedisLock] 락 해제 실패 또는 이미 해제됨 key={}, value={}", key, lockValue); + } + } + } + + + public T executeWithLockRetry(String key, long timeoutSeconds, Supplier supplier) { + + String lockValue = UUID.randomUUID().toString(); + + // 최대 재시도 횟수 + int maxRetryCount = 15; + // 재시도 간격(ms) + long retryIntervalMillis = 100L; // 최대 대기 시간은 대략 (20-1) * 100ms = 1.9초 + // TODO : 10개보다 적게 성공한다면 + // 1. 재시도 횟수 증가 + // 2. 대기 시간 약간 증가 + + for (int attempt = 1; attempt <= maxRetryCount; attempt++) { + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (locked) { + log.info("[RedisLock-Retry] 락 획득 성공 key={}, attempt={}", key, attempt); + + try { + return supplier.get(); + } finally { + redisLockRepository.unlock(key, lockValue); + } + } + + log.info("[RedisLock-Retry] 락 획득 실패 key={}, attempt={}", key, attempt); + + if (attempt == maxRetryCount) { + break; + } + + try { + Thread.sleep(retryIntervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + public T executeWithLockBlocking(String key, long timeoutSeconds, Supplier supplier) { + + String lockValue = UUID.randomUUID().toString(); + + long waitStartTime = System.currentTimeMillis(); + + long maxWaitMillis = 5000; // 🔥 최대 5초만 기다림 + long retryIntervalMillis = 50; + + while (true) { + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (locked) { + log.info("[RedisLock-Blocking] 락 획득 성공 key={}", key); + + try { + return supplier.get(); + } finally { + redisLockRepository.unlock(key, lockValue); + } + } + + // 🔥 최대 대기 시간 초과 + if (System.currentTimeMillis() - waitStartTime > maxWaitMillis) { + log.warn("[RedisLock-Blocking] 락 획득 타임아웃 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + try { + Thread.sleep(retryIntervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + } + +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java new file mode 100644 index 0000000..b558bb7 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java @@ -0,0 +1,107 @@ +package jpa.basic.alldayprojectcommerce.common.lock.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedissonLockService { + + private final RedissonClient redissonClient; + + public T executeWithLock( + String key, + long waitTimeSeconds, + long leaseTimeSeconds, + Supplier supplier + ) { + /** + * RLock = Redisson의 분산락 객체 + * + * - key 기준으로 락 생성 + * - 내부적으로 Redis 사용 + */ + RLock lock = redissonClient.getLock(key); + + boolean locked = false; + + try { + /** + * tryLock + * + * waitTimeSeconds: 락을 기다리는 시간 (Blocking 느낌) + * leaseTimeSeconds: 락 유지 시간 + * + * Lettuce FailFast 전략 -> waitTime = 0 + * Retry -> waitTime > 0 짧게 + * Blocking -> waitTime을 크게 + */ + if (leaseTimeSeconds < 0) { + /** + * 🔥 watchdog 모드 + * + * - TTL을 자동으로 계속 연장 + * - 비즈니스 로직이 길어도 안전 + * + * Lettuce에서는 직접 구현 불가능 + */ + locked = lock.tryLock(waitTimeSeconds, TimeUnit.SECONDS); + } else { + /** + * 🔥 일반 TTL 방식 + * + * - leaseTime 지나면 자동 unlock + * - Lettuce의 TTL과 동일 개념 + */ + locked = lock.tryLock(waitTimeSeconds, leaseTimeSeconds, TimeUnit.SECONDS); + } + + /** + * 🔥 락 획득 실패 + * + * - waitTime 동안 기다렸는데도 실패하면 여기로 옴 + */ + if (!locked) { + log.info("[RedissonLock] 락 획득 실패 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + log.info("[RedissonLock] 락 획득 성공 key={}", key); + + /** + * 🔥 실제 비즈니스 로직 실행 + */ + return supplier.get(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + + } finally { + /** + * 🔥 매우 중요 + * + * lock.isHeldByCurrentThread() → 내가 잡은 락인지 확인 + * + * Lettuce에서는: + * UUID + Lua Script로 직접 구현했지만 + * + * Redisson에서는: + * 내부적으로 자동 관리됨 + */ + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("[RedissonLock] 락 해제 성공 key={}", key); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java similarity index 74% rename from src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java rename to src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java index 15b9fca..4293fbc 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java @@ -1,4 +1,4 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; +package jpa.basic.alldayprojectcommerce.domain.order.service.event; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java similarity index 96% rename from src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java rename to src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java index 2c21588..c0807a2 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java @@ -1,4 +1,4 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; +package jpa.basic.alldayprojectcommerce.domain.order.service.event; import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; @@ -9,6 +9,7 @@ import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderProductRepository; import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderRepository; +import jpa.basic.alldayprojectcommerce.domain.order.service.OrderCommandService; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a9072dd..9539735 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -24,7 +24,7 @@ logging: root: info com: example: - instagramclone: debug + alldayprojectcommerce: debug org: hibernate: SQL: debug diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 0a6168e..30069f9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -22,9 +22,9 @@ spring: logging: level: root: info - com: - example: - alldayproject: info + jpa: + basic: + alldayprojectcommerce: info org: hibernate: SQL: info diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..e492a94 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,39 @@ +# application-test.yml +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + hikari: + maximum-pool-size: 10 + minimum-idle: 10 + connection-timeout: 3000 + idle-timeout: 600000 + max-lifetime: 1800000 + pool-name: Hikari-Test-Pool + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: false + show_sql: false + + data: + redis: + host: localhost + port: 6379 + +logging: + level: + root: info + jpa.basic.alldayprojectcommerce: + debugcom.zaxxer.hikari: debug + + # 쿼리 확인이 필요할때만 활성화 +# org.hibernate.SQL: debug +# org.hibernate.orm.jdbc.bind: trace +jwt: + secret-key: ${JWT_SECRET_KEY} \ No newline at end of file diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java new file mode 100644 index 0000000..fa0d426 --- /dev/null +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -0,0 +1,489 @@ +package jpa.basic.alldayprojectcommerce.application; + +import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderRepository; +import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; +import jpa.basic.alldayprojectcommerce.domain.product.repository.ProductRepository; +import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; +import org.junit.jupiter.api.AfterEach; +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.test.context.ActiveProfiles; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class EventOrderFacadeConcurrencyTest { + + @Autowired + private EventOrderFacade eventOrderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductQueryService productQueryService; + + @Autowired + private OrderRepository orderRepository; + + private static final Long TEST_TICKET_PRODUCT_ID = 4L; + private static final int TEST_TICKET_STOCK = 10; + private static final int DEFAULT_TICKET_STOCK = 100; + private static final int TOTAL_REQUEST_COUNT = 100; + private static final int THREAD_POOL = 100; + private static final long TEST_TIMEOUT_SECONDS = 20L; + + @AfterEach + void tearDown() { + clearOrders(); + restoreStock(DEFAULT_TICKET_STOCK); + } + + @Test + @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") + void createEventOrder_withoutLock_fail() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithoutLock( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("WithoutLock", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // 락 없는 버전은 정합성이 깨지는 것이 목적 + // 즉 정상 기대값(10/90/10/0)과 달라야 한다. + boolean isExactlyCorrect = + result.successCount() == 10 && + result.failCount() == 90 && + orderCount == 10 && + product.getStock() == 0; + + assertThat(isExactlyCorrect).isFalse(); + } + + @Test + @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } + + @Test + @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_retry_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis Retry", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_blocking_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis Blocking", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } + + @Test + @DisplayName("Redis Lettuce 분산락 - AOP 버전 - Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_retry_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP Retry", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis Lettuce 분산락 - AOP 버전 - Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_blocking_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP Blocking", result, orderCount, product.getStock()); + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redisson 분산락 - AOP - Retry - TTL - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_retry_ttl() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redisson AOP", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redisson 분산락 - AOP - Blocking - TTL - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_blocking_ttl() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redisson AOP", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redisson 분산락 - AOP - Retry - Watchdog - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_retry_watchdog() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopRetryWatchdog( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redisson Watchdog AOP", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redisson 분산락 - AOP - Blocking - Watchdog - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_blocking_watchdog() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redisson Watchdog AOP", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + /** + * 공통 동시성 실행 로직 + */ + private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws Exception { + ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL); + + CountDownLatch doneLatch = new CountDownLatch(TOTAL_REQUEST_COUNT); + CyclicBarrier startBarrier = new CyclicBarrier(TOTAL_REQUEST_COUNT); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + long startTime = System.nanoTime(); + + for (int i = 0; i < TOTAL_REQUEST_COUNT; i++) { + long userId = i + 1L; + + threadPool.submit(() -> { + try { + startBarrier.await(); + + executor.execute(userId); + + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + boolean completed = doneLatch.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + threadPool.shutdown(); + + long endTime = System.nanoTime(); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + + return new ConcurrencyTestResult( + successCount.get(), + failCount.get(), + elapsedMillis, + completed + ); + } + + /** + * 테스트 시작 전 공통 준비 + */ + private void prepareTestData(int stock) { + clearOrders(); + prepareStock(stock); + } + + /** + * 주문 데이터 초기화 + */ + private void clearOrders() { + orderRepository.deleteAllInBatch(); + } + + /** + * 테스트용 티켓 재고를 원하는 값으로 맞춘다. + */ + private void prepareStock(int stock) { + Product product = getTestProduct(); + int currentStock = product.getStock(); + + if (currentStock > stock) { + product.decreaseStock(currentStock - stock); + } else if (currentStock < stock) { + product.increaseStock(stock - currentStock); + } + + productRepository.save(product); + } + + /** + * 테스트 종료 후 재고를 기본값으로 복구 + */ + private void restoreStock(int stock) { + Product product = getTestProduct(); + int currentStock = product.getStock(); + + if (currentStock > stock) { + product.decreaseStock(currentStock - stock); + } else if (currentStock < stock) { + product.increaseStock(stock - currentStock); + } + + productRepository.save(product); + } + + /** + * 테스트용 상품 조회 + */ + private Product getTestProduct() { + return productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); + } + + /** + * 결과 출력 + */ + private void printResult(String label, ConcurrencyTestResult result, long orderCount, int stock) { + System.out.println("===== 결과 : " + label + " ====="); + System.out.println("성공 수 = " + result.successCount()); + System.out.println("실패 수 = " + result.failCount()); + System.out.println("총 실행 시간(ms) = " + result.elapsedMillis()); + System.out.println("모든 요청 완료 여부 = " + result.completed()); + System.out.println("최종 주문 수 = " + orderCount); + System.out.println("최종 재고 수 = " + stock); + System.out.println("=============================="); + } + + @FunctionalInterface + interface TestExecutor { + void execute(Long userId); + } + + record ConcurrencyTestResult( + int successCount, + int failCount, + long elapsedMillis, + boolean completed + ) {} +} \ No newline at end of file