diff --git a/k6/event-order-load-test.js b/k6/event-order-load-test.js new file mode 100644 index 0000000..cde2574 --- /dev/null +++ b/k6/event-order-load-test.js @@ -0,0 +1,247 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +export const options = { + scenarios: { + event_order_test: { + executor: 'per-vu-iterations', + vus: Number(__ENV.VUS || '1000'), + iterations: 1, + maxDuration: '3m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.30'], + + /** + * 전체 요청 p95 + * 성공/실패를 모두 포함한 사용자 체감 기준 + */ + order_duration_ms: ['p(95)<5000'], + + /** + * 성공 요청 p95 + * 실제 주문에 성공한 사용자 기준 응답 시간 + */ + order_success_duration_ms: ['p(95)<5000'], + + /** + * 실패 요청 p95 + * 품절/락 실패 등 실패 응답을 받은 사용자 기준 응답 시간 + */ + order_fail_duration_ms: ['p(95)<5000'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://app:8090'; +const PRODUCT_ID = __ENV.PRODUCT_ID || '4'; +const START_USER_ID = Number(__ENV.START_USER_ID || '1'); +const SLOW_LIMIT_MS = Number(__ENV.SLOW_LIMIT_MS || '5000'); + +/** + * Counter = "누적 카운트" + * + * add(1) 할 때마다 +1씩 증가 + * 최종 결과에 총합이 출력됨 + */ + +const orderSuccess = new Counter('order_success_count'); // 성공 주문 개수 +const orderFail = new Counter('order_fail_count'); // 실패 주문 개수 +const expectedFail = new Counter('order_expected_fail_count'); // 예상 가능한 실패 (품절 등) + +/** + * 성능 관련 카운트 + */ +const slowRequest = new Counter('slow_request_over_5s_count'); // 5초 초과 요청 개수 +const successSlowRequest = new Counter('success_slow_request_over_5s_count'); // 성공 요청 중 5초 초과 개수 +const failSlowRequest = new Counter('fail_slow_request_over_5s_count'); // 실패 요청 중 5초 초과 개수 + +const httpConnectionFail = new Counter('http_connection_fail_count'); // 서버 연결 실패 +const httpTimeout = new Counter('http_timeout_count'); // timeout 발생 개수 + +/** + * 실패 원인별 카운트 (분석용) + */ +const lockFail = new Counter('lock_fail_count'); // 락 획득 실패 +const userError = new Counter('user_error_count'); // 유저 관련 오류 +const soldOut = new Counter('sold_out_count'); // 재고 부족 +const duplicateOrder = new Counter('duplicate_order_count'); // 중복 주문 +const unknownFail = new Counter('unknown_fail_count'); // 분류 안 된 실패 + +/** + * Rate = "비율 계산" + * + * add(true) → 성공 + * add(false) → 실패 + * + * 최종 결과: + * order_success_rate = 성공 비율 + */ +const orderSuccessRate = new Rate('order_success_rate'); // 주문 성공률 + +/** + * Trend = "값의 분포 통계" + * + * add(숫자)로 값을 계속 넣으면 + * avg / min / max / p90 / p95 등이 계산됨 + */ +const orderDuration = new Trend('order_duration_ms'); // 요청 전체 수행 시간 + +/** + * 성공 요청만 따로 저장하는 Trend + * + * 전체 p95가 높을 때, + * 실제 주문 성공자의 응답이 느린지 확인하기 위한 지표. + */ +const orderSuccessDuration = new Trend('order_success_duration_ms'); + +/** + * 실패 요청만 따로 저장하는 Trend + * + * 실패 응답이 빨리 내려오는지 확인하기 위한 지표. + * 티켓팅에서는 실패 900건이 정상일 수 있으므로 중요함. + */ +const orderFailDuration = new Trend('order_fail_duration_ms'); + +export default function () { + const userId = START_USER_ID + __VU - 1; + + const url = `${BASE_URL}/api/events/products/${PRODUCT_ID}/orders?userId=${userId}`; + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + timeout: '15s', + }; + + const startedAt = Date.now(); + const res = http.post(url, null, params); + const durationMs = Date.now() - startedAt; + + /** + * 전체 요청 시간 기록 + */ + orderDuration.add(durationMs); + + const body = parseJson(res.body); + const code = body?.code || ''; + const message = body?.data?.message || body?.message || ''; + + const isSuccess = res.status === 200 || res.status === 201; + const isExpectedFail = [400, 409, 423, 429].includes(res.status); + + if (durationMs > SLOW_LIMIT_MS) { + slowRequest.add(1); + } + + /** + * status=0은 서버에서 HTTP 응답을 받은 게 아님. + * connection refused, timeout, network error 등이 여기에 해당. + */ + if (res.status === 0) { + const error = String(res.error || '').toLowerCase(); + + /** + * 서버 응답을 못 받은 요청도 실패 요청 시간으로 기록한다. + * timeout이 p95를 얼마나 끌어올리는지 확인 가능. + */ + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (error.includes('connection refused')) { + httpConnectionFail.add(1); + } else if (error.includes('timeout')) { + httpTimeout.add(1); + } else { + unknownFail.add(1); + } + + orderFail.add(1); + orderSuccessRate.add(false); + return; + } + + if (isSuccess) { + orderSuccess.add(1); + orderSuccessRate.add(true); + + /** + * 성공 요청만 따로 p95를 계산하기 위해 기록 + */ + orderSuccessDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + successSlowRequest.add(1); + } + } else { + orderFail.add(1); + orderSuccessRate.add(false); + + /** + * 실패 요청만 따로 p95를 계산하기 위해 기록 + */ + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (isExpectedFail) { + expectedFail.add(1); + } + + classifyError(code, message); + } + + check(res, { + '성공 또는 예상 가능한 실패': () => isSuccess || isExpectedFail, + '500번대 서버 에러 없음': () => res.status < 500, + 'HTTP 연결 실패 아님': () => res.status !== 0, + '5초 이하 응답': () => durationMs <= SLOW_LIMIT_MS, + }); +} + +function classifyError(code, message) { + if (code === 'L001') { + lockFail.add(1); + return; + } + + if (code.startsWith('U')) { + userError.add(1); + return; + } + + if ( + message.includes('재고') || + message.includes('품절') || + message.toLowerCase().includes('sold') + ) { + soldOut.add(1); + return; + } + + if ( + message.includes('중복') || + message.includes('이미') || + message.toLowerCase().includes('duplicate') + ) { + duplicateOrder.add(1); + return; + } + + unknownFail.add(1); +} + +function parseJson(body) { + try { + return JSON.parse(body); + } catch (e) { + return null; + } +} \ No newline at end of file diff --git a/k6/event-order-version-test.js b/k6/event-order-version-test.js new file mode 100644 index 0000000..29e6561 --- /dev/null +++ b/k6/event-order-version-test.js @@ -0,0 +1,179 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +export const options = { + scenarios: { + event_order_test: { + executor: 'per-vu-iterations', + vus: Number(__ENV.VUS || '100'), + iterations: 1, + maxDuration: '3m', + }, + }, + summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'], + thresholds: { + order_duration_ms: ['p(95)<5000'], + order_success_duration_ms: ['p(95)<5000'], + order_fail_duration_ms: ['p(95)<5000'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://app:8090'; +const VERSION = __ENV.VERSION || 'v1'; +const PRODUCT_ID = __ENV.PRODUCT_ID || '4'; +const START_USER_ID = Number(__ENV.START_USER_ID || '1'); +const SLOW_LIMIT_MS = Number(__ENV.SLOW_LIMIT_MS || '5000'); + +const orderSuccess = new Counter('order_success_count'); +const orderFail = new Counter('order_fail_count'); +const expectedFail = new Counter('order_expected_fail_count'); + +const lockFail = new Counter('lock_fail_count'); +const userError = new Counter('user_error_count'); +const soldOut = new Counter('sold_out_count'); +const duplicateOrder = new Counter('duplicate_order_count'); +const unknownFail = new Counter('unknown_fail_count'); + +const slowRequest = new Counter('slow_request_over_5s_count'); +const successSlowRequest = new Counter('success_slow_request_over_5s_count'); +const failSlowRequest = new Counter('fail_slow_request_over_5s_count'); + +const httpConnectionFail = new Counter('http_connection_fail_count'); +const httpTimeout = new Counter('http_timeout_count'); + +const orderSuccessRate = new Rate('order_success_rate'); + +const orderDuration = new Trend('order_duration_ms', true); +const orderSuccessDuration = new Trend('order_success_duration_ms', true); +const orderFailDuration = new Trend('order_fail_duration_ms', true); + +export default function () { + const userId = START_USER_ID + __VU - 1; + + const url = `${BASE_URL}/api/events/${VERSION}/products/${PRODUCT_ID}/orders?userId=${userId}`; + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + timeout: '20s', + tags: { + version: VERSION, + }, + }; + + const startedAt = Date.now(); + const res = http.post(url, null, params); + const durationMs = Date.now() - startedAt; + + orderDuration.add(durationMs); + + const body = parseJson(res.body); + const code = body?.code || ''; + const message = body?.data?.message || body?.message || ''; + + const isSuccess = res.status === 200 || res.status === 201; + const isExpectedFail = [400, 409, 423, 429].includes(res.status); + + if (durationMs > SLOW_LIMIT_MS) { + slowRequest.add(1); + } + + if (res.status === 0) { + orderFail.add(1); + orderSuccessRate.add(false); + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + const error = String(res.error || '').toLowerCase(); + + if (error.includes('connection refused')) { + httpConnectionFail.add(1); + } else if (error.includes('timeout')) { + httpTimeout.add(1); + } else { + unknownFail.add(1); + } + + return; + } + + if (isSuccess) { + orderSuccess.add(1); + orderSuccessRate.add(true); + orderSuccessDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + successSlowRequest.add(1); + } + } else { + orderFail.add(1); + orderSuccessRate.add(false); + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (isExpectedFail) { + expectedFail.add(1); + } + + classifyError(code, message); + } + + check(res, { + [`${VERSION} 성공 또는 예상 가능한 실패`]: () => isSuccess || isExpectedFail, + [`${VERSION} 500번대 서버 에러 없음`]: () => res.status < 500, + [`${VERSION} HTTP 연결 실패 아님`]: () => res.status !== 0, + [`${VERSION} ${SLOW_LIMIT_MS}ms 이하 응답`]: () => durationMs <= SLOW_LIMIT_MS, + }); +} + +function classifyError(code, message) { + const lowerMessage = String(message || '').toLowerCase(); + + if (code === 'L001' || message.includes('락') || lowerMessage.includes('lock')) { + lockFail.add(1); + return; + } + + if (code.startsWith('U')) { + userError.add(1); + return; + } + + if ( + message.includes('재고') || + message.includes('품절') || + lowerMessage.includes('sold') || + lowerMessage.includes('stock') + ) { + soldOut.add(1); + return; + } + + if ( + message.includes('중복') || + message.includes('이미') || + lowerMessage.includes('duplicate') || + lowerMessage.includes('already') + ) { + duplicateOrder.add(1); + return; + } + + unknownFail.add(1); +} + +function parseJson(body) { + try { + return JSON.parse(body); + } catch (e) { + return null; + } +} \ No newline at end of file diff --git a/k6-test-1-1.js b/k6/k6-test-1-1.js similarity index 100% rename from k6-test-1-1.js rename to k6/k6-test-1-1.js diff --git a/k6-test-1-2.js b/k6/k6-test-1-2.js similarity index 100% rename from k6-test-1-2.js rename to k6/k6-test-1-2.js diff --git a/k6-test-1-3.js b/k6/k6-test-1-3.js similarity index 100% rename from k6-test-1-3.js rename to k6/k6-test-1-3.js diff --git a/k6-test-1-4.js b/k6/k6-test-1-4.js similarity index 100% rename from k6-test-1-4.js rename to k6/k6-test-1-4.js diff --git a/k6-test-5-1.js b/k6/k6-test-5-1.js similarity index 100% rename from k6-test-5-1.js rename to k6/k6-test-5-1.js diff --git a/k6-test-5-2.js b/k6/k6-test-5-2.js similarity index 100% rename from k6-test-5-2.js rename to k6/k6-test-5-2.js diff --git a/k6-test-6.js b/k6/k6-test-6.js similarity index 100% rename from k6-test-6.js rename to k6/k6-test-6.js diff --git a/k6-test-rampup.js b/k6/k6-test-rampup.js similarity index 100% rename from k6-test-rampup.js rename to k6/k6-test-rampup.js diff --git a/k6-test-v1.js b/k6/k6-test-v1.js similarity index 100% rename from k6-test-v1.js rename to k6/k6-test-v1.js diff --git a/k6-test-v2.js b/k6/k6-test-v2.js similarity index 100% rename from k6-test-v2.js rename to k6/k6-test-v2.js diff --git a/k6/reset-event-test.sql b/k6/reset-event-test.sql new file mode 100644 index 0000000..aa607d9 --- /dev/null +++ b/k6/reset-event-test.sql @@ -0,0 +1,17 @@ +SET FOREIGN_KEY_CHECKS = 0; + +TRUNCATE TABLE order_users; +TRUNCATE TABLE order_products; +TRUNCATE TABLE orders; +TRUNCATE TABLE product_stock_logs; + +SET FOREIGN_KEY_CHECKS = 1; + +UPDATE products +SET stock = 100, + status = 'ON_SALE' +WHERE id = ${PRODUCT_ID}; + +SELECT id, name, stock, status +FROM products +WHERE id = ${PRODUCT_ID}; \ No newline at end of file diff --git a/replacements.txt b/replacements.txt new file mode 100644 index 0000000..bd41257 --- /dev/null +++ b/replacements.txt @@ -0,0 +1 @@ +soyount7509==>REMOVED_SECRET diff --git a/run-event-order-compare.sh b/run-event-order-compare.sh new file mode 100755 index 0000000..34c20ac --- /dev/null +++ b/run-event-order-compare.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -u +VERSIONS=("v1" "v2" "v3" "v4" "v5" "v6" "v7" "v8") + +VUS=${VUS:-200} +PRODUCT_ID=${PRODUCT_ID:-4} +BASE_URL=${BASE_URL:-http://app:8090} +DB_PASSWORD=${DB_PASSWORD:-12345678} + +reset_db() { + echo "DB reset: PRODUCT_ID=${PRODUCT_ID}" + + docker compose exec -T mysql mysql \ + -uroot \ + -p"${DB_PASSWORD}" \ + allday_project_commerce < orderProducts = orderQueryService.getOrderProducts(order.getId()); + // 기본 주문에서는 분산락 적용 X, 비관락만 사용 for (OrderProductInfo orderProduct : orderProducts) { - productCommandService.decreaseStock(orderProduct.productId(), orderProduct.quantity(), order.getId()); + productCommandService.decreaseStockWithPessimisticLock(orderProduct.productId(), orderProduct.quantity()); } // 2. OrderUser 스냅샷 저장 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 index fae161a..929afa8 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java @@ -1,10 +1,12 @@ package jpa.basic.alldayprojectcommerce.common.lock.annotation; -import java.lang.annotation.*; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -@Documented public @interface RedissonLock { /** @@ -14,15 +16,9 @@ String key(); /** - * 락 획득을 기다릴 최대 시간 + * 🔥 milliseconds 기준으로 변경 */ - long waitTimeSeconds() default 3; + long waitTimeMillis() default 0; - /** - * 락 점유 시간 - * - * - 양수: 지정 시간 후 자동 해제 - * - -1: leaseTime 없이 watchdog 사용 - */ - long leaseTimeSeconds() default 5; + long leaseTimeMillis() default 3000; } \ 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 index b5bc16a..982ec47 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java @@ -30,8 +30,8 @@ public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) { return redissonLockService.executeWithLock( key, - redissonLock.waitTimeSeconds(), - redissonLock.leaseTimeSeconds(), + redissonLock.waitTimeMillis(), + redissonLock.leaseTimeMillis(), () -> proceed(joinPoint) ); } 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 index b558bb7..3a4a568 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java @@ -20,10 +20,11 @@ public class RedissonLockService { public T executeWithLock( String key, - long waitTimeSeconds, - long leaseTimeSeconds, + long waitTimeMillis, + long leaseTimeMillis, Supplier supplier ) { + /** * RLock = Redisson의 분산락 객체 * @@ -31,7 +32,6 @@ public T executeWithLock( * - 내부적으로 Redis 사용 */ RLock lock = redissonClient.getLock(key); - boolean locked = false; try { @@ -45,7 +45,8 @@ public T executeWithLock( * Retry -> waitTime > 0 짧게 * Blocking -> waitTime을 크게 */ - if (leaseTimeSeconds < 0) { + + if (leaseTimeMillis < 0) { /** * 🔥 watchdog 모드 * @@ -54,7 +55,7 @@ public T executeWithLock( * * Lettuce에서는 직접 구현 불가능 */ - locked = lock.tryLock(waitTimeSeconds, TimeUnit.SECONDS); + locked = lock.tryLock(waitTimeMillis, TimeUnit.MILLISECONDS); } else { /** * 🔥 일반 TTL 방식 @@ -62,7 +63,7 @@ public T executeWithLock( * - leaseTime 지나면 자동 unlock * - Lettuce의 TTL과 동일 개념 */ - locked = lock.tryLock(waitTimeSeconds, leaseTimeSeconds, TimeUnit.SECONDS); + locked = lock.tryLock(waitTimeMillis, leaseTimeMillis, TimeUnit.MILLISECONDS); } /** diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java index 48773dd..a631d0d 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java @@ -39,7 +39,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/auth/login", "/api/auth/signup", "/api/auth/reissue", - "/api/keywords/search" + "/api/keywords/search", + "/api/events/products/*/orders" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/auth/check-duplicate", diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java index 7aea47d..2b1283a 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java @@ -12,25 +12,130 @@ @RequiredArgsConstructor @RequestMapping("/api/events") public class EventOrderController { + private final EventOrderFacade eventOrderFacade; + // 최종 선택 + /** + * Redisson Blocking + Watchdog + */ + @PostMapping("/products/{productId}/orders") + public ResponseEntity> createEventOrder( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog(productId, userId)); + } + + + /* 동시성 확인을 위한 이벤트 티켓 무료 나눔 API 모바일 티켓이라고 가정, 배송비 X 이벤트 카테고리에 있는 제품들은 상세 조회를 눌렀을 때 장바구니 버튼이 없고 주문하기를 눌렀을 때 이 API가 바로 호출되는 것으로 프론트 구성한다고 가정 + (실제 프론트 구현X) 상세 조회에서는 상품 상태(판매중, 품절, 단종)와 상품 재고 확인 가능 장바구니 X, 주문서 X, 내 정보 수정 X 모두 거치지 않음 주문 성공 시 주문 목록 조회로 리다이렉트 주문 실패 시 '주문에 실패했습니다' 문구 띄우고 이벤트 상품 목록 조회로 리다이렉트 */ - @PostMapping("/products/{productId}/orders") - public ResponseEntity> createEventOrder( + + + // Test 용도 + + /** + * v1 - 락 없음 + */ + @PostMapping("/v1/products/{productId}/orders") + public ResponseEntity> v1( @PathVariable Long productId, @RequestParam Long userId - ){ - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(HttpStatus.CREATED, eventOrderFacade.createEventOrderWithoutLock(productId, userId))); + ) { + return ok(eventOrderFacade.createEventOrderWithoutLock(productId, userId)); + } + + /** + * v2 - DB 비관락만 + */ + @PostMapping("/v2/products/{productId}/orders") + public ResponseEntity> v2( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithPessimisticLock(productId, userId)); + } + + /** + * v3 - Redisson Retry + */ + @PostMapping("/v3/products/{productId}/orders") + public ResponseEntity> v3( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopRetry(productId, userId)); + } + + /** + * v4 - Redisson Retry + Watchdog + */ + @PostMapping("/v4/products/{productId}/orders") + public ResponseEntity> v4( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopRetryWatchdog(productId, userId)); + } + + /** + * v5 - Redisson + FailFast + */ + @PostMapping("/v5/products/{productId}/orders") + public ResponseEntity> v5( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopFailFast(productId, userId)); + } + + /** + * v6 - Redisson + Blocking + */ + @PostMapping("/v6/products/{productId}/orders") + public ResponseEntity> v6( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlocking(productId, userId)); } + /** + * v7 - Redisson Blocking + Watchdog + */ + @PostMapping("/v7/products/{productId}/orders") + public ResponseEntity> v7( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog(productId, userId)); + } + + /** + * v8 - Redisson Blocking + Watchdog + 비관락 + */ + @PostMapping("/v8/products/{productId}/orders") + public ResponseEntity> v8( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdogWithPessimisticLock(productId, userId)); + } + + + + private ResponseEntity> ok(EventOrderResponse response) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED, response)); + } } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java index 4293fbc..907d3f8 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java @@ -4,5 +4,6 @@ public interface EventOrderService { EventOrderResponse createEventOrder(Long productId, Long userId); + EventOrderResponse createEventOrderWithPessimisticLock(Long productId, Long userId); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java index c0807a2..c1a54a3 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java @@ -106,6 +106,62 @@ public EventOrderResponse createEventOrder(Long productId, Long userId) { orderCommandService.saveOrderUser(savedOrder.getId(), user.getName(), user.getPhone(), user.getAddress()); + log.info("[이벤트 주문 생성] userId: {}, orderUid: {}, productId: {}", + userId, orderUid, product.getId()); + + return EventOrderResponse.from(orderUid, savedOrder.getStatus()); + + } + + @Override + public EventOrderResponse createEventOrderWithPessimisticLock(Long productId, Long userId) { + + // 이벤트 아이템은 항상 유저 한명당 1개만 가능 + int quantity = 1; + + // 유저 검증 + // 유저 정보를 사전에 미리 등록해놨어어야 주문 가능하도록 설계 + User user = userQueryService.getById(userId); + if (!user.hasRequiredInfo()) { + throw new CustomException(ErrorCode.USER_ORDERER_INFO_REQUIRED); + } + + // 유저 한 명당 이벤트 상품은 하나만 구매 가능. + // 이 상품에 대해서 주문 상태가 COMPLETED인 주문이 있는지 검증 + if(orderProductRepository.existsCompletedEventOrder(productId,userId, OrderStatus.COMPLETED)){ + throw new CustomException(ErrorCode.EVENT_ORDER_ALREADY_EXISTS); + + } + + // TODO : 비관적 락 + Product product = productCommandService.decreaseStockWithPessimisticLock(productId, quantity); + + // orderUid 생성 + String orderUid = IdFactory.generateWithDate("ORD", 8); + + // Order 저장 - orderId 발급 + Order order = Order.builder() + .userId(userId) + .orderUid(orderUid) + .totalAmount(product.getPrice()) + .status(OrderStatus.COMPLETED) + .build(); + + Order savedOrder = orderRepository.save(order); + + // OrderItem 저장 - 스냅샷 + orderProductRepository.save(OrderProduct.builder() + .orderId(savedOrder.getId()) + .productId(product.getId()) + .productName(product.getName()) + .productPrice(product.getPrice()) + .quantity(quantity) + .build()); + + // OrderUser 저장 - 스냅샷 + orderCommandService.saveOrderUser(savedOrder.getId(), user.getName(), user.getPhone(), user.getAddress()); + + log.info("[이벤트 주문 생성] userId: {}, orderUid: {}, productId: {}", userId, orderUid, product.getId()); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java index d85cb95..eb72268 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java @@ -1,8 +1,17 @@ package jpa.basic.alldayprojectcommerce.domain.product.repository; +import jakarta.persistence.LockModeType; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface ProductRepository extends JpaRepository, ProductRepositoryCustom { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :productId") + Optional findByIdForUpdate(@Param("productId") Long productId); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java index 1690675..1164510 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java @@ -9,6 +9,7 @@ public interface ProductCommandService { void decreaseStock(Long productId, int quantity, Long orderId); void increaseStock(Long productId, int quantity, Long orderId); void saveStockHistory(Product product, int quantity, Long orderId); + Product decreaseStockWithPessimisticLock(Long productId, int quantity) ; void checkStock(Long productId, int quantity); ProductUpdateResponse updateProduct(Long productId, ProductUpdateRequest request); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java index f48113b..abaf887 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java @@ -1,5 +1,6 @@ package jpa.basic.alldayprojectcommerce.domain.product.service; +import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; import jpa.basic.alldayprojectcommerce.domain.product.dto.request.ProductUpdateRequest; import jpa.basic.alldayprojectcommerce.domain.product.dto.response.ProductUpdateResponse; import lombok.extern.slf4j.Slf4j; @@ -28,19 +29,38 @@ public class ProductCommandServiceImpl implements ProductCommandService { // 재고를 차감 한다. @Override public void decreaseStock(Long productId, int quantity, Long orderId) { - // TODO : 재고 증가 메서드인데 일반 조회 사용중. 추후 동시성 문제 해결 부분에서 비관적 락 적용 고려 Product product = productRepository.findById(productId) .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); product.decreaseStock(quantity); saveStockHistory(product, quantity, orderId); } + // 재고를 차감 한다. + @Override + public Product decreaseStockWithPessimisticLock(Long productId, int quantity) { + Product product = productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + + if (product.getStatus() != ProductStatus.ON_SALE) { + throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE); + } + + if (product.getStock() < quantity) { + throw new CustomException(ErrorCode.PRODUCT_OUT_OF_STOCK); + } + + product.decreaseStock(quantity); + // TODO 비동기 방식으로 처리하기. 동시성 문제의 성능 개선을 위함 +// saveStockHistory(product, quantity, orderId); + return product; + } + // 재고를 증가시킨다. @Override public void increaseStock(Long productId, int quantity, Long orderId) { // TODO : 재고 증가 메서드인데 일반 조회 사용중. 추후 동시성 문제 해결 부분에서 비관적 락 적용 고려 - Product product = productRepository.findById(productId) + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); product.increaseStock(quantity); saveStockHistory(product, -quantity, orderId);