diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..fdc9253 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index e45095b..7cb2dd0 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,16 @@ DB_PASSWORD= MYSQL_ROOT_PASSWORD= REDIS_HOST= REDIS_PORT= + JWT_SECRET= + AWS_S3_BUCKET= AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= \ No newline at end of file +AWS_SECRET_ACCESS_KEY= +AWS_CLOUDFRONT_DOMAIN= + +AWS_SQS_ORDER_QUEUE_URL= +AWS_SQS_CONSUMER_ENABLED= + +DOCS_AUTH_USERNAME= +DOCS_AUTH_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 6402fa6..77bd718 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -102,6 +102,20 @@ jobs: needs: publish if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - name: API + launch_template_id: ${{ vars.API_LAUNCH_TEMPLATE_ID }} + asg_name: ${{ vars.API_ASG_NAME }} + consumer_enabled: 'false' + - name: Worker + launch_template_id: ${{ vars.WORKER_LAUNCH_TEMPLATE_ID }} + asg_name: ${{ vars.WORKER_ASG_NAME }} + consumer_enabled: 'true' + + name: Deploy ${{ matrix.target.name }} steps: # Step 1: AWS 자격증명 - OIDC @@ -112,17 +126,18 @@ jobs: aws-region: ${{ vars.AWS_REGION }} # Step 2: Launch Template 새 버전 생성 (User Data의 IMAGE_TAG를 새 커밋 SHA로 교체) - - name: Update Launch Template + - name: Update ${{ matrix.target.name }} Launch Template env: IMAGE_TAG: ${{ needs.publish.outputs.image_tag }} AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }} AWS_REGION: ${{ vars.AWS_REGION }} ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} - LAUNCH_TEMPLATE_ID: ${{ vars.LAUNCH_TEMPLATE_ID }} + LAUNCH_TEMPLATE_ID: ${{ matrix.target.launch_template_id }} + CONSUMER_ENABLED: ${{ matrix.target.consumer_enabled }} run: | ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}" - USER_DATA=$(cat <<'USERDATA' | sed "s|__IMAGE_TAG__|${IMAGE_TAG}|g; s|__AWS_ACCOUNT_ID__|${AWS_ACCOUNT_ID}|g; s|__AWS_REGION__|${AWS_REGION}|g; s|__ECR_REPOSITORY__|${ECR_REPOSITORY}|g" | base64 -w 0 + USER_DATA=$(cat <<'USERDATA' | sed "s|__IMAGE_TAG__|${IMAGE_TAG}|g; s|__AWS_ACCOUNT_ID__|${AWS_ACCOUNT_ID}|g; s|__AWS_REGION__|${AWS_REGION}|g; s|__ECR_REPOSITORY__|${ECR_REPOSITORY}|g; s|__CONSUMER_ENABLED__|${CONSUMER_ENABLED}|g" | base64 -w 0 #!/bin/bash set -e AWS_REGION="__AWS_REGION__" @@ -136,7 +151,10 @@ jobs: systemctl start docker aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com docker pull ${ECR_URI}:${IMAGE_TAG} - docker run -d -p 8080:8080 --restart unless-stopped --name ci-demo -e SPRING_PROFILES_ACTIVE=prod ${ECR_URI}:${IMAGE_TAG} + docker run -d -p 8080:8080 --restart unless-stopped --name ci-demo \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e AWS_SQS_CONSUMER_ENABLED=__CONSUMER_ENABLED__ \ + ${ECR_URI}:${IMAGE_TAG} USERDATA ) @@ -156,10 +174,10 @@ jobs: echo "✅ 기본 버전 → v${NEW_VERSION} 변경 완료" # Step 3: Instance Refresh 시작 - - name: Start Instance Refresh + - name: Start ${{ matrix.target.name }} Instance Refresh id: refresh env: - ASG_NAME: ${{ vars.ASG_NAME }} + ASG_NAME: ${{ matrix.target.asg_name }} run: | REFRESH_ID=$(aws autoscaling start-instance-refresh \ --auto-scaling-group-name ${ASG_NAME} \ @@ -174,9 +192,9 @@ jobs: echo "✅ Instance Refresh 시작: ${REFRESH_ID}" # Step 4: Instance Refresh 완료 대기 - - name: Wait for Instance Refresh + - name: Wait for ${{ matrix.target.name }} Instance Refresh env: - ASG_NAME: ${{ vars.ASG_NAME }} + ASG_NAME: ${{ matrix.target.asg_name }} REFRESH_ID: ${{ steps.refresh.outputs.refresh_id }} run: | echo "⏳ Instance Refresh 완료 대기 중..." diff --git a/.gitignore b/.gitignore index deb9ae9..b1ffb03 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ out/ !.env.example !.env.example.DS_Store performance/reports/*.html +performance/results/raw/ +performance/results/*.zip diff --git a/README.md b/README.md index 6f841a2..9ea9333 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,23 @@ src/main/java/com/dropit ## API 문서 - +Spring REST Docs를 사용하고, 테스트 스니펫을 OpenAPI 3.0.1 명세로 변환합니다. +Swagger UI는 이 명세 파일을 읽어 화면에 보여주는 역할만 하므로, 별도의 어노테이션 기반 API 명세가 생기지 않습니다. -_링크 추가 예정_ +`bootRun`과 `bootJar`는 테스트와 OpenAPI 생성 작업을 먼저 실행한 뒤 생성된 YAML을 애플리케이션 정적 리소스에 포함합니다. +명세와 Swagger UI 리소스를 단독으로 준비하려면 `./gradlew openApiDocs`를 실행합니다. +명세만 생성하려면 `./gradlew openapi3`를 실행하며, 결과는 `build/api-spec/openapi3.yaml`에 저장됩니다. (생성물은 빌드 디렉터리에만 남으며 저장소에는 커밋하지 않습니다.) + +브라우저에서 http://localhost:8080/swagger-ui/index.html를 열면 API 문서를 확인할 수 있습니다. +원본 명세는 http://localhost:8080/openapi/openapi3.yaml 경로에서도 직접 확인할 수 있습니다. + +Swagger UI와 OpenAPI YAML은 HTTP Basic Auth로 보호됩니다. +로컬에서는 `.env.local`에 다음 두 값을 설정합니다. + +```properties +DOCS_AUTH_USERNAME= +DOCS_AUTH_PASSWORD= +``` 인증이 필요한 API는 다음 헤더를 전달합니다. diff --git a/build.gradle b/build.gradle index 4c6b2bd..aa123ee 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '4.1.1' id 'io.spring.dependency-management' version '1.1.7' + id 'com.epages.restdocs-api-spec' version '0.20.1' } group = 'com.dropit' @@ -9,6 +10,7 @@ version = '0.0.1-SNAPSHOT' description = 'dropit-server' def querydslVersion = '7.5' +def restdocsApiSpecVersion = '0.20.1' java { toolchain { @@ -28,10 +30,13 @@ dependencies { implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.webjars:swagger-ui:5.32.11' implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.1.0') implementation 'io.awspring.cloud:spring-cloud-aws-starter-parameter-store' implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-sqs' + implementation 'software.amazon.awssdk:url-connection-client' implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' @@ -51,6 +56,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test' testImplementation 'org.testcontainers:testcontainers-mysql' + testImplementation platform('org.springframework.restdocs:spring-restdocs-bom:4.0.1') + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}" + testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' @@ -58,6 +67,100 @@ dependencies { tasks.named('test') { useJUnitPlatform() + outputs.dir(file('build/generated-snippets')) +} + +openapi3 { + server = '/' + title = 'DROPIT API' + description = 'DROPIT API 문서입니다.' + version = project.version.toString() + format = 'yaml' + outputDirectory = 'build/api-spec' +} + +afterEvaluate { + tasks.named('openapi3') { + dependsOn(tasks.named('test')) + doLast { + File specFile = layout.buildDirectory.file('api-spec/openapi3.yaml').get().asFile + String spec = specFile.getText('UTF-8') + + // restdocs-api-spec models a primitive JSON response as an empty object because + // its field-descriptor schema generator only supports object/array roots. Keep + // the endpoint contract accurate for the two Long response endpoints. + [ + [path: '/drops', method: 'post', status: '201'], + [path: '/products', method: 'post', status: '201'] + ].each { endpoint -> + String pattern = /(?s)(\x20{2}${endpoint.path}:.*?\x20{4}${endpoint.method}:.*?responses:\n\x20{8}"${endpoint.status}":.*?application\/json:\n\x20{14}schema:\n)\x20{16}[$]ref: "[^"]+"\n/ + spec = spec.replaceFirst(pattern, '$1 type: integer\n format: int64\n') + } + + // Primitive response correction leaves the generator's orphaned hash schema behind. + // Remove only those generated hash components; domain schema names come from tests. + spec = spec.replaceAll( + /(?ms)^\x20{4}[A-Za-z0-9]+-\d+:\n.*?(?=^\x20{4}[A-Za-z0-9]+:|^\x20{2}securitySchemes:)/, + '' + ) + + String multipartSchema = + /(?s)(\x20{4}MultipartImage:\n)(?:\x20{6}title: MultipartImage\n)?\x20{6}type: object\n/ + spec = spec.replaceFirst( + multipartSchema, + '$1 required:\n - file\n type: object\n properties:\n file:\n type: string\n format: binary\n' + ) + + specFile.setText(spec, 'UTF-8') + } + } +} + +def validateOpenApiSpec = tasks.register('validateOpenApiSpec') { + group = 'documentation' + description = '생성된 OpenAPI 명세가 하나 이상의 API 경로를 포함하는지 검증합니다.' + dependsOn(tasks.named('openapi3')) + doLast { + File specFile = layout.buildDirectory.file('api-spec/openapi3.yaml').get().asFile + if (!specFile.isFile() || specFile.length() == 0) { + throw new GradleException( + "OpenAPI 명세가 생성되지 않았습니다: ${specFile}. ./gradlew openapi3을 실행해 문서화 테스트를 먼저 수행하세요." + ) + } + + String spec = specFile.getText('UTF-8') + boolean hasPathsSection = spec.readLines().any { it.trim() == 'paths:' } + boolean hasDocumentedPath = spec.readLines().any { it ==~ /^\s{2,}\/\S+:\s*$/ } + if (!hasPathsSection || !hasDocumentedPath) { + throw new GradleException( + "OpenAPI 명세에 문서화된 API 경로가 없습니다: ${specFile}. -x test를 사용하지 말고 ./gradlew openapi3을 실행하세요." + ) + } + } +} + +def copyOpenApiToResources = tasks.register('copyOpenApiToResources', Copy) { + dependsOn(validateOpenApiSpec) + from(layout.buildDirectory.file('api-spec/openapi3.yaml')) + into(layout.buildDirectory.dir('resources/main/static/openapi')) +} + +tasks.named('bootRun') { + dependsOn(copyOpenApiToResources) +} + +tasks.named('resolveMainClassName') { + dependsOn(copyOpenApiToResources) +} + +tasks.named('bootJar') { + dependsOn(copyOpenApiToResources) +} + +tasks.register('openApiDocs') { + group = 'development' + description = 'OpenAPI 명세를 생성하고 검증한 뒤 정적 리소스로 복사합니다.' + dependsOn(copyOpenApiToResources) } tasks.register('dbUp', Exec) { @@ -71,3 +174,42 @@ tasks.register('dbDown', Exec) { description = '로컬 DB 컨테이너를 종료합니다.' commandLine 'docker', 'compose', '--env-file', '.env.local', 'down' } + +def performanceComposeFile = file('performance/compose.performance.yml') +def performanceRawResultDirectory = file('performance/results/raw') + +tasks.register('performanceUp', Exec) { + group = 'verification' + description = '성능 관측용 InfluxDB와 Grafana를 실행합니다.' + commandLine 'docker', 'compose', '--file', performanceComposeFile, + 'up', '--detach', '--wait', 'influxdb', 'grafana' +} + +tasks.register('performanceSmoke', Exec) { + group = 'verification' + description = 'k6가 성능 관측 스택에 지표와 JSON 결과를 기록하는지 확인합니다.' + dependsOn(tasks.named('performanceUp')) + + doFirst { + performanceRawResultDirectory.mkdirs() + } + + environment 'BASE_URL', 'http://influxdb:8086' + environment 'TEST_RUN', 'performance-stack-smoke' + environment 'K6_SCRIPT', 'performance-stack-smoke.js' + commandLine 'docker', 'compose', '--file', performanceComposeFile, + '--profile', 'load', 'run', '--rm', 'k6' + + doLast { + File summary = new File(performanceRawResultDirectory, 'performance-stack-smoke.json') + if (!summary.isFile() || summary.length() == 0) { + throw new GradleException("k6 smoke 결과가 생성되지 않았습니다: ${summary}") + } + } +} + +tasks.register('performanceDown', Exec) { + group = 'verification' + description = '성능 관측용 컨테이너와 네트워크를 종료합니다.' + commandLine 'docker', 'compose', '--file', performanceComposeFile, 'down' +} diff --git a/performance/compose.performance.yml b/performance/compose.performance.yml new file mode 100644 index 0000000..7d9d3e5 --- /dev/null +++ b/performance/compose.performance.yml @@ -0,0 +1,59 @@ +name: dropit-performance + +services: + influxdb: + image: influxdb:1.8.10 + environment: + INFLUXDB_DB: k6 + ports: + - "127.0.0.1:8086:8086" + volumes: + - influxdb-data:/var/lib/influxdb + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8086/ping"] + interval: 5s + timeout: 3s + retries: 10 + + grafana: + image: grafana/grafana:12.4.10 + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_USERS_DEFAULT_THEME: light + ports: + - "127.0.0.1:3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + influxdb: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + interval: 5s + timeout: 3s + retries: 20 + + k6: + image: grafana/k6:1.7.1 + profiles: ["load"] + environment: + K6_OUT: influxdb=http://influxdb:8086/k6 + BASE_URL: ${BASE_URL:-http://host.docker.internal:8080} + TEST_RUN: ${TEST_RUN:-order-flow-smoke} + AUTH_TOKEN: ${AUTH_TOKEN:-} + volumes: + - ./k6:/scripts:ro + - ./results/raw:/results + depends_on: + influxdb: + condition: service_healthy + entrypoint: ["k6"] + command: ["run", "/scripts/${K6_SCRIPT:-drop-list-index.js}"] + restart: "no" + +volumes: + influxdb-data: + grafana-data: diff --git a/performance/data/drop-index-seed.sql b/performance/data/drop-index-seed.sql new file mode 100644 index 0000000..0990eb4 --- /dev/null +++ b/performance/data/drop-index-seed.sql @@ -0,0 +1,173 @@ +SET SESSION cte_max_recursion_depth = 1000001; + +SET @performance_marker = 'DROP_INDEX_PERFORMANCE_PRODUCT'; +SET @product_count = 100000; +SET @drop_count = 1000000; + +SET @index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'drops' + AND index_name = 'idx_drops_public_closing' +); + +SET @drop_index_sql = IF( + @index_exists > 0, + 'DROP INDEX idx_drops_public_closing ON drops', + 'SELECT 1' +); + +PREPARE drop_index_statement FROM @drop_index_sql; +EXECUTE drop_index_statement; +DEALLOCATE PREPARE drop_index_statement; + +INSERT INTO users ( + email, + password, + username, + role, + deleted_at, + created_at, + updated_at +) +SELECT + 'drop-index-seller@dropit.test', + 'local-performance-password', + 'drop-index-seller', + 'SELLER', + NULL, + NOW(), + NOW() +WHERE NOT EXISTS ( + SELECT 1 + FROM users + WHERE email = 'drop-index-seller@dropit.test' +); + +SET @seller_id = ( + SELECT id + FROM users + WHERE email = 'drop-index-seller@dropit.test' + LIMIT 1 +); + +DELETE FROM drops +WHERE product_id IN ( + SELECT id + FROM products + WHERE description = @performance_marker +); + +DELETE FROM products +WHERE description = @performance_marker; + +INSERT INTO products ( + seller_id, + name, + description, + image_url, + created_at, + updated_at +) +WITH RECURSIVE product_numbers AS ( + SELECT 1 AS number + + UNION ALL + + SELECT number + 1 + FROM product_numbers + WHERE number < @product_count +) +SELECT + @seller_id, + CONCAT('Drop Index Performance Product ', number), + @performance_marker, + NULL, + DATE_SUB(NOW(), INTERVAL MOD(number, 365) DAY), + NOW() +FROM product_numbers; + +INSERT INTO drops ( + product_id, + price, + initial_quantity, + remaining_quantity, + discount_rate, + purchase_limit, + visible, + open_at, + close_at, + created_at, + updated_at +) +WITH RECURSIVE numbers AS ( + SELECT 1 AS number + + UNION ALL + + SELECT number + 1 + FROM numbers + WHERE number < @drop_count +), +performance_products AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY id) AS product_number + FROM products + WHERE description = @performance_marker +) +SELECT + performance_products.id, + 30000 + MOD(numbers.number, 70000), + 100, + CASE MOD(numbers.number, 10) + WHEN 7 THEN 0 + ELSE 100 + END, + MOD(numbers.number, 51), + 2, + CASE MOD(numbers.number, 10) + WHEN 9 THEN b'0' + ELSE b'1' + END, + CASE + WHEN MOD(numbers.number, 10) BETWEEN 0 AND 4 + THEN DATE_SUB(NOW(), INTERVAL 7 DAY) + WHEN MOD(numbers.number, 10) BETWEEN 5 AND 6 + THEN DATE_ADD(NOW(), INTERVAL 7 DAY) + WHEN MOD(numbers.number, 10) = 7 + THEN DATE_SUB(NOW(), INTERVAL 7 DAY) + WHEN MOD(numbers.number, 10) = 8 + THEN DATE_SUB(NOW(), INTERVAL 14 DAY) + ELSE DATE_SUB(NOW(), INTERVAL 7 DAY) + END, + CASE + WHEN MOD(numbers.number, 10) BETWEEN 0 AND 4 + THEN DATE_ADD(NOW(), INTERVAL (7 + MOD(numbers.number, 30)) DAY) + WHEN MOD(numbers.number, 10) BETWEEN 5 AND 6 + THEN DATE_ADD(NOW(), INTERVAL (37 + MOD(numbers.number, 30)) DAY) + WHEN MOD(numbers.number, 10) = 7 + THEN DATE_ADD(NOW(), INTERVAL (7 + MOD(numbers.number, 30)) DAY) + WHEN MOD(numbers.number, 10) = 8 + THEN DATE_SUB(NOW(), INTERVAL 7 DAY) + ELSE DATE_ADD(NOW(), INTERVAL (7 + MOD(numbers.number, 30)) DAY) + END, + DATE_SUB(NOW(), INTERVAL MOD(numbers.number, 365) DAY), + NOW() +FROM numbers +INNER JOIN performance_products + ON performance_products.product_number = MOD(numbers.number - 1, @product_count) + 1; + +SELECT + COUNT(DISTINCT products.id) AS performance_product_count, + COUNT(drops.id) AS total_drop_count, + SUM( + drops.visible = b'1' + AND drops.open_at <= NOW() + AND drops.close_at > NOW() + AND drops.remaining_quantity > 0 + ) AS open_public_drop_count +FROM products +INNER JOIN drops ON drops.product_id = products.id +WHERE products.description = @performance_marker; diff --git a/performance/drop-index.env.example b/performance/drop-index.env.example new file mode 100644 index 0000000..86119c7 --- /dev/null +++ b/performance/drop-index.env.example @@ -0,0 +1,10 @@ +# Windows에서 Spring API를 확인하는 주소 +HEALTH_CHECK_URL=http://localhost:8080 + +# Docker k6 컨테이너에서 Windows의 Spring API에 접근하는 주소 +BASE_URL=http://host.docker.internal:8080 + +# 로컬 성능 테스트 전용 계정 +AUTH_EMAIL=drop-index-load-test@example.com +AUTH_PASSWORD=change-this-local-password +AUTH_USERNAME=drop-index-load-test diff --git a/performance/grafana/dashboards/drop-detail-concurrent-comparison.json b/performance/grafana/dashboards/drop-detail-concurrent-comparison.json new file mode 100644 index 0000000..a0c8edb --- /dev/null +++ b/performance/grafana/dashboards/drop-detail-concurrent-comparison.json @@ -0,0 +1,546 @@ +{ + "annotations": { + "list": [] + }, + "description": "One Drop detail requested once by each of 10,000 concurrent virtual users", + "editable": true, + "graphTooltip": 0, + "panels": [ + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before - 1 app · Average latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "After - 4 apps · Average latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized - no DB transaction on cache hit · Average latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 5 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before - 1 app · p95 latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 5 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "After - 4 apps · p95 latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 5 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized - no DB transaction on cache hit · p95 latency", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"success_rate\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before - 1 app · Success rate", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"success_rate\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "After - 4 apps · Success rate", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"success_rate\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized - no DB transaction on cache hit · Success rate", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 15 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"duration_seconds\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before - 1 app · Total test duration", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 15 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"duration_seconds\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "After - 4 apps · Total test duration", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 15 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT last(\"duration_seconds\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized - no DB transaction on cache hit · Total test duration", + "type": "stat" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "k6", + "drop", + "concurrent", + "10000-vu", + "before-after" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-7d", + "to": "now" + }, + "timezone": "browser", + "title": "Drop Detail 10K Concurrent Comparison", + "uid": "drop-detail-10k-concurrent", + "version": 1 +} diff --git a/performance/grafana/dashboards/drop-detail-live-stock-comparison.json b/performance/grafana/dashboards/drop-detail-live-stock-comparison.json new file mode 100644 index 0000000..c606ae8 --- /dev/null +++ b/performance/grafana/dashboards/drop-detail-live-stock-comparison.json @@ -0,0 +1,97 @@ +{ + "annotations": { "list": [] }, + "description": "DropResponse 전체 캐시와 정적 메타데이터 캐시 + 실시간 Redis 재고 결합 구조의 측정 결과 비교", + "editable": true, + "graphTooltip": 0, + "panels": [ + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_spike_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "기존 전체 응답 캐시 · 평균", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_spike_summary\" WHERE (\"stage\" = 'after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "기존 전체 응답 캐시 · p95", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_spike_summary\" WHERE (\"stage\" = 'livestock') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "실시간 재고 결합 · 평균", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_spike_summary\" WHERE (\"stage\" = 'livestock') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "실시간 재고 결합 · p95", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 5 }, + "id": 5, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "기존 10K 동시 요청 · 평균", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 5 }, + "id": 6, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'optimized') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "기존 10K 동시 요청 · p95", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 5 }, + "id": 7, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"avg_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'livestock') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "실시간 재고 10K · 평균", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 5 }, + "id": 8, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [{ "query": "SELECT last(\"p95_ms\") FROM \"drop_detail_concurrent_summary\" WHERE (\"stage\" = 'livestock') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" }], + "title": "실시간 재고 10K · p95", + "type": "stat" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["k6", "drop", "redis", "live-stock"], + "templating": { "list": [] }, + "time": { "from": "now-30d", "to": "now" }, + "timezone": "browser", + "title": "Drop Detail Cache Correctness and Load Comparison", + "uid": "drop-detail-live-stock", + "version": 1 +} diff --git a/performance/grafana/dashboards/drop-detail-spike-comparison.json b/performance/grafana/dashboards/drop-detail-spike-comparison.json new file mode 100644 index 0000000..1974d83 --- /dev/null +++ b/performance/grafana/dashboards/drop-detail-spike-comparison.json @@ -0,0 +1,139 @@ +{ + "annotations": { + "list": [] + }, + "description": "판매 시작 시 동일 Drop 상세 조회 스파이크 테스트의 Before/After 비교", + "editable": true, + "graphTooltip": 0, + "panels": [ + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-before') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "Before 평균 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "After 평균 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT percentile(\"value\", 95) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-before') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "Before p95 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT percentile(\"value\", 95) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "After p95 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 5 }, + "id": 5, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT percentile(\"value\", 99) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-before') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "Before p99 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "ms" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 5 }, + "id": 6, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT percentile(\"value\", 99) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-detail-spike-after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "After p99 응답시간", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "percentunit" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 5 }, + "id": 7, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT mean(\"value\") FROM \"http_req_failed\" WHERE (\"test_run\" = 'drop-detail-spike-before') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "Before HTTP 실패율", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 2, "unit": "percentunit" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 5 }, + "id": 8, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT mean(\"value\") FROM \"http_req_failed\" WHERE (\"test_run\" = 'drop-detail-spike-after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "After HTTP 실패율", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 0, "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 12, "x": 0, "y": 10 }, + "id": 9, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT sum(\"value\") FROM \"http_reqs\" WHERE (\"test_run\" = 'drop-detail-spike-before') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "Before 완료 HTTP 요청 수", + "type": "stat" + }, + { + "datasource": { "type": "influxdb", "uid": "k6-influxdb" }, + "fieldConfig": { "defaults": { "decimals": 0, "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 5, "w": 12, "x": 12, "y": 10 }, + "id": 10, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "targets": [ + { "query": "SELECT sum(\"value\") FROM \"http_reqs\" WHERE (\"test_run\" = 'drop-detail-spike-after') AND $timeFilter", "rawQuery": true, "refId": "A", "resultFormat": "time_series" } + ], + "title": "After 완료 HTTP 요청 수", + "type": "stat" + } + ], + "refresh": "5s", + "schemaVersion": 41, + "tags": ["k6", "drop", "spike", "before-after"], + "templating": { "list": [] }, + "time": { "from": "now-7d", "to": "now" }, + "timezone": "browser", + "title": "Drop Detail Spike Before After", + "uid": "drop-detail-spike-comparison", + "version": 1 +} diff --git a/performance/grafana/dashboards/drop-million-comparison.json b/performance/grafana/dashboards/drop-million-comparison.json new file mode 100644 index 0000000..eaccd0c --- /dev/null +++ b/performance/grafana/dashboards/drop-million-comparison.json @@ -0,0 +1,298 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT percentile(\"value\", 95) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before p95", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT percentile(\"value\", 95) FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized p95", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-before') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Before Average", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-optimized') AND $timeFilter", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Optimized Average", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "alias": "Before", + "query": "SELECT sum(\"value\") FROM \"http_reqs\" WHERE (\"test_run\" = 'drop-million-before') AND $timeFilter GROUP BY time(1s) fill(0)", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + }, + { + "alias": "Optimized", + "query": "SELECT sum(\"value\") FROM \"http_reqs\" WHERE (\"test_run\" = 'drop-million-optimized') AND $timeFilter GROUP BY time(1s) fill(0)", + "rawQuery": true, + "refId": "B", + "resultFormat": "time_series" + } + ], + "title": "Requests per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "k6-influxdb" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "alias": "Before", + "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-before') AND $timeFilter GROUP BY time(1s) fill(null)", + "rawQuery": true, + "refId": "A", + "resultFormat": "time_series" + }, + { + "alias": "Optimized", + "query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE (\"test_run\" = 'drop-million-optimized') AND $timeFilter GROUP BY time(1s) fill(null)", + "rawQuery": true, + "refId": "B", + "resultFormat": "time_series" + } + ], + "title": "Response Time", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [ + "k6", + "drop", + "index", + "performance" + ], + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Drop Million - Before Index Optimized", + "uid": "drop-million-comparison", + "version": 1 +} diff --git a/performance/grafana/provisioning/dashboards/dashboards.yml b/performance/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..d0741a3 --- /dev/null +++ b/performance/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: k6 + folder: k6 + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards diff --git a/performance/grafana/provisioning/datasources/influxdb.yml b/performance/grafana/provisioning/datasources/influxdb.yml new file mode 100644 index 0000000..97fe737 --- /dev/null +++ b/performance/grafana/provisioning/datasources/influxdb.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +datasources: + - name: k6 InfluxDB + uid: k6-influxdb + type: influxdb + access: proxy + url: http://influxdb:8086 + database: k6 + isDefault: true + editable: false + jsonData: + httpMode: GET + timeInterval: 1s diff --git a/performance/k6/drop-detail-concurrent.js b/performance/k6/drop-detail-concurrent.js new file mode 100644 index 0000000..2873c07 --- /dev/null +++ b/performance/k6/drop-detail-concurrent.js @@ -0,0 +1,91 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const baseUrl = __ENV.BASE_URL || 'http://host.docker.internal:8080'; +const testRun = __ENV.TEST_RUN || 'drop-detail-concurrent-before'; +const authToken = __ENV.AUTH_TOKEN; +const dropId = __ENV.DROP_ID; +const concurrentUsers = Number(__ENV.CONCURRENT_USERS || 10000); +const warmCache = (__ENV.WARM_CACHE || 'true').toLowerCase() === 'true'; + +if (!authToken) { + throw new Error('AUTH_TOKEN is required.'); +} + +if (!dropId) { + throw new Error('DROP_ID is required.'); +} + +if (!Number.isInteger(concurrentUsers) || concurrentUsers <= 0) { + throw new Error('CONCURRENT_USERS must be a positive integer.'); +} + +export const options = { + discardResponseBodies: false, + scenarios: { + openingBurst: { + executor: 'per-vu-iterations', + vus: concurrentUsers, + iterations: 1, + maxDuration: '3m', + }, + }, + thresholds: { + 'http_req_failed{endpoint:drop-detail}': ['rate<0.01'], + 'http_req_duration{endpoint:drop-detail}': ['p(95)<2000'], + checks: ['rate>0.99'], + }, +}; + +export function setup() { + if (!warmCache) { + return; + } + + const response = http.get(`${baseUrl}/drops/${dropId}`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + tags: { + endpoint: 'cache-warmup', + test_run: testRun, + }, + }); + + if (response.status !== 200) { + throw new Error(`Cache warm-up failed with HTTP ${response.status}.`); + } +} + +export default function () { + const response = http.get(`${baseUrl}/drops/${dropId}`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + tags: { + endpoint: 'drop-detail', + test_run: testRun, + }, + }); + + check(response, { + 'Drop 상세 조회 응답이 200이다': (result) => result.status === 200, + '요청한 Drop ID가 반환된다': (result) => { + if (result.status !== 200) { + return false; + } + + try { + return String(result.json('id')) === String(dropId); + } catch (error) { + return false; + } + }, + }); +} + +export function handleSummary(data) { + return { + [`/results/${testRun}.json`]: JSON.stringify(data, null, 2), + }; +} diff --git a/performance/k6/drop-detail-spike.js b/performance/k6/drop-detail-spike.js new file mode 100644 index 0000000..2506bda --- /dev/null +++ b/performance/k6/drop-detail-spike.js @@ -0,0 +1,87 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const baseUrl = __ENV.BASE_URL || 'http://host.docker.internal:8080'; +const testRun = __ENV.TEST_RUN || 'drop-detail-spike-before'; +const authToken = __ENV.AUTH_TOKEN; +const dropId = __ENV.DROP_ID; + +function numberFromEnvironment(name, defaultValue) { + const value = Number(__ENV[name] || defaultValue); + + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number.`); + } + + return value; +} + +if (!authToken) { + throw new Error('AUTH_TOKEN is required.'); +} + +if (!dropId) { + throw new Error('DROP_ID is required.'); +} + +const normalRps = numberFromEnvironment('NORMAL_RPS', 100); +const spikeRps = numberFromEnvironment('SPIKE_RPS', 1000); +const preAllocatedVUs = numberFromEnvironment('PRE_ALLOCATED_VUS', 200); +const maxVUs = numberFromEnvironment('MAX_VUS', 2000); + +export const options = { + discardResponseBodies: false, + scenarios: { + dropDetailAtOpeningTime: { + executor: 'ramping-arrival-rate', + startRate: normalRps, + timeUnit: '1s', + preAllocatedVUs, + maxVUs, + stages: [ + { duration: '30s', target: normalRps }, + { duration: '1s', target: spikeRps }, + { duration: '1m', target: spikeRps }, + { duration: '15s', target: 0 }, + ], + gracefulStop: '30s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<2000'], + }, +}; + +export default function () { + const response = http.get(`${baseUrl}/drops/${dropId}`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + tags: { + endpoint: 'drop-detail', + test_run: testRun, + }, + }); + + check(response, { + 'Drop 상세 조회 응답이 200이다': (result) => result.status === 200, + '요청한 Drop ID가 반환된다': (result) => { + if (result.status !== 200) { + return false; + } + + try { + return String(result.json('id')) === String(dropId); + } catch (error) { + return false; + } + }, + }); +} + +export function handleSummary(data) { + return { + [`/results/${testRun}.json`]: JSON.stringify(data, null, 2), + }; +} diff --git a/performance/k6/drop-list-index.js b/performance/k6/drop-list-index.js new file mode 100644 index 0000000..ef8a1fb --- /dev/null +++ b/performance/k6/drop-list-index.js @@ -0,0 +1,62 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const baseUrl = __ENV.BASE_URL || 'http://host.docker.internal:8080'; +const testRun = __ENV.TEST_RUN || 'drop-list-before-index'; +const authToken = __ENV.AUTH_TOKEN; + +if (!authToken) { + throw new Error('AUTH_TOKEN is required.'); +} + +export const options = { + scenarios: { + openDropsClosingSoon: { + executor: 'ramping-arrival-rate', + startRate: 10, + timeUnit: '1s', + preAllocatedVUs: 50, + maxVUs: 500, + stages: [ + { duration: '15s', target: 20 }, + { duration: '15s', target: 50 }, + { duration: '15s', target: 100 }, + { duration: '15s', target: 200 }, + { duration: '15s', target: 0 }, + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<2000'], + }, +}; + +export default function () { + const response = http.get( + `${baseUrl}/drops?status=OPEN&sortType=CLOSING_SOON&page=0&size=20`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + tags: { + endpoint: 'drop-list-open-closing-soon', + test_run: testRun, + }, + } + ); + + check(response, { + 'Drop 목록 조회 응답이 200이다': (result) => result.status === 200, + '한 페이지는 최대 20개다': (result) => { + const content = result.json('content'); + return Array.isArray(content) && content.length <= 20; + }, + }); +} + +export function handleSummary(data) { + return { + [`/results/${testRun}.json`]: JSON.stringify(data, null, 2), + }; +} diff --git a/performance/k6/performance-stack-smoke.js b/performance/k6/performance-stack-smoke.js new file mode 100644 index 0000000..fcef2b3 --- /dev/null +++ b/performance/k6/performance-stack-smoke.js @@ -0,0 +1,37 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const baseUrl = __ENV.BASE_URL; +const testRun = __ENV.TEST_RUN || 'performance-stack-smoke'; + +if (!baseUrl) { + throw new Error('BASE_URL is required.'); +} + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + checks: ['rate==1'], + http_req_failed: ['rate==0'], + }, +}; + +export default function () { + const response = http.get(`${baseUrl}/ping`, { + tags: { + endpoint: 'performance-stack-ping', + test_run: testRun, + }, + }); + + check(response, { + 'InfluxDB ping 응답이 204이다': (result) => result.status === 204, + }); +} + +export function handleSummary(data) { + return { + [`/results/${testRun}.json`]: JSON.stringify(data, null, 2), + }; +} diff --git a/performance/nginx/drop-detail-cluster.conf b/performance/nginx/drop-detail-cluster.conf new file mode 100644 index 0000000..a8d47be --- /dev/null +++ b/performance/nginx/drop-detail-cluster.conf @@ -0,0 +1,29 @@ +worker_processes auto; + +events { + worker_connections 16384; +} + +http { + upstream dropit_backend { + least_conn; + server dropit-performance-app-1:8080; + server dropit-performance-app-2:8080; + server dropit-performance-app-3:8080; + server dropit-performance-app-4:8080; + keepalive 256; + } + + server { + listen 8080 backlog=16384; + + location / { + proxy_pass http://dropit_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + } + } +} diff --git a/performance/publish-drop-detail-concurrent-summary.ps1 b/performance/publish-drop-detail-concurrent-summary.ps1 new file mode 100644 index 0000000..aaf8f3d --- /dev/null +++ b/performance/publish-drop-detail-concurrent-summary.ps1 @@ -0,0 +1,55 @@ +param( + [string]$InfluxUrl = 'http://localhost:8086', + [string]$Database = 'k6' +) + +$ErrorActionPreference = 'Stop' + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$rawDirectory = Join-Path $performanceDirectory 'results\raw' +$stages = @('before', 'after', 'optimized', 'livestock') +$points = @() +$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() * 1000000 + +foreach ($stage in $stages) { + $resultPath = Join-Path $rawDirectory "drop-detail-concurrent-$stage-10000-vu.json" + + if (-not (Test-Path -LiteralPath $resultPath)) { + throw "k6 summary is missing: $resultPath" + } + + $summary = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + $metrics = $summary.metrics + $duration = $metrics.'http_req_duration{endpoint:drop-detail}'.values + $failure = $metrics.'http_req_failed{endpoint:drop-detail}'.values.rate + $success = $metrics.checks.values.rate + $requests = $metrics.iterations.values.count + $durationSeconds = $summary.state.testRunDurationMs / 1000 + + $fields = @( + "concurrent_users=10000i", + "requests=${requests}i", + "avg_ms=$($duration.avg)", + "p95_ms=$($duration.'p(95)')", + "max_ms=$($duration.max)", + "failure_rate=$failure", + "success_rate=$success", + "duration_seconds=$durationSeconds" + ) -join ',' + + $points += "drop_detail_concurrent_summary,stage=$stage $fields $timestamp" + $timestamp++ +} + +$dropQuery = [Uri]::EscapeDataString('DROP MEASUREMENT drop_detail_concurrent_summary') +Invoke-RestMethod ` + -Uri "$InfluxUrl/query?db=$Database&q=$dropQuery" ` + -Method Post | Out-Null + +Invoke-RestMethod ` + -Uri "$InfluxUrl/write?db=$Database&precision=ns" ` + -Method Post ` + -ContentType 'text/plain' ` + -Body ($points -join "`n") | Out-Null + +Write-Host 'Published exact k6 JSON summaries for Before, After, Optimized, and LiveStock.' diff --git a/performance/publish-drop-detail-spike-summary.ps1 b/performance/publish-drop-detail-spike-summary.ps1 new file mode 100644 index 0000000..1328b81 --- /dev/null +++ b/performance/publish-drop-detail-spike-summary.ps1 @@ -0,0 +1,53 @@ +param( + [string]$InfluxUrl = 'http://localhost:8086', + [string]$Database = 'k6' +) + +$ErrorActionPreference = 'Stop' + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$rawDirectory = Join-Path $performanceDirectory 'results\raw' +$stages = @('before', 'query', 'after', 'livestock') +$points = @() +$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() * 1000000 + +foreach ($stage in $stages) { + $resultPath = Join-Path $rawDirectory "drop-detail-spike-$stage.json" + + if (-not (Test-Path -LiteralPath $resultPath)) { + throw "k6 summary is missing: $resultPath" + } + + $summary = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + $metrics = $summary.metrics + $duration = $metrics.http_req_duration.values + $requests = $metrics.http_reqs.values + $failure = $metrics.http_req_failed.values.rate + $dropped = $metrics.dropped_iterations.values.count + + $fields = @( + "requests=$($requests.count)i", + "rps=$($requests.rate)", + "avg_ms=$($duration.avg)", + "p95_ms=$($duration.'p(95)')", + "max_ms=$($duration.max)", + "failure_rate=$failure", + "dropped_iterations=$($dropped)i" + ) -join ',' + + $points += "drop_detail_spike_summary,stage=$stage $fields $timestamp" + $timestamp++ +} + +$dropQuery = [Uri]::EscapeDataString('DROP MEASUREMENT drop_detail_spike_summary') +Invoke-RestMethod ` + -Uri "$InfluxUrl/query?db=$Database&q=$dropQuery" ` + -Method Post | Out-Null + +Invoke-RestMethod ` + -Uri "$InfluxUrl/write?db=$Database&precision=ns" ` + -Method Post ` + -ContentType 'text/plain' ` + -Body ($points -join "`n") | Out-Null + +Write-Host 'Published exact k6 JSON summaries for Before, Query, After, and LiveStock.' diff --git a/performance/results/2026-09-10-product-drop-concurrency.md b/performance/results/2026-09-10-product-drop-concurrency.md new file mode 100644 index 0000000..591a7f7 --- /dev/null +++ b/performance/results/2026-09-10-product-drop-concurrency.md @@ -0,0 +1,68 @@ +# Product 삭제와 Drop 생성 동시성 제어 + +## 문제 상황 + +판매자가 상품을 삭제하는 순간 다른 요청에서 같은 상품의 Drop을 생성하면, +상품 삭제의 Drop 존재 확인과 실제 DELETE 사이에 Drop INSERT가 들어올 수 있다. + +```text +상품 삭제 요청: Product 조회 → Drop 존재 확인(false) → 상품 DELETE +Drop 생성 요청: └→ Drop INSERT +``` + +이 경우 DB 외래키가 데이터 무결성은 지키지만 상품 삭제 요청에는 +의도한 `409 Conflict` 대신 `DataIntegrityViolationException`이 발생할 수 있다. + +## 테스트 조건 + +| 항목 | 조건 | +|---|---| +| 대상 기능 | 동일 Product 삭제와 Drop 생성 | +| 동시 실행 수 | 매회 2개 요청 | +| 반복 횟수 | 100회 | +| 동시 실행 도구 | `ExecutorService`, `CountDownLatch` | +| 데이터베이스 | MySQL 8.4.11, InnoDB | +| 트랜잭션 격리 수준 | `REPEATABLE_READ` | + +## Before / After 비교 + +![Product 삭제와 Drop 생성 동시성 Before After 비교](images/2026-09-10-product-drop-concurrency-before-after.png) + +| 측정 항목 | Before: 락 미적용 | After: 비관적 락 적용 | +|---|---:|---:| +| 반복 횟수 | 100회 | 100회 | +| Drop 생성 성공 | 100회 | 49회 | +| 상품 삭제 성공 | 0회 | 51회 | +| `404 PRODUCT_NOT_FOUND` | 0회 | 51회 | +| `409 PRODUCT_IN_USE_BY_DROP` | 0회 | 49회 | +| 예상 밖 DB 예외 | **100회** | **0회** | +| 고아 Drop | 0개 | 0개 | +| 총 실행 시간 | 8,478ms | 7,366ms | + +## Before 분석 + +- 100회 모두 Drop 생성이 먼저 완료됐다. +- 상품 삭제는 100회 모두 외래키 제약으로 실패했다. +- DB가 고아 Drop은 방지했지만 도메인에서 정의한 `409 Conflict`로 처리하지 못했다. +- 현재 문제는 데이터 손실보다 예상 가능한 경쟁 상황이 서버 오류로 노출되는 것이다. + +## 개선 기준 + +- Product를 기준으로 상품 삭제와 Drop 생성이 동일한 잠금 순서를 사용한다. +- 먼저 처리된 요청에 따라 `404 Not Found` 또는 `409 Conflict`로 종료한다. +- `DataIntegrityViolationException`과 고아 Drop을 모두 0회로 만든다. +- 같은 조건으로 100회 재측정해 Before와 After를 비교한다. + +## 적용 내용 + +- `ProductRepository.findByIdForUpdate`에 `PESSIMISTIC_WRITE` 잠금을 적용했다. +- Product 삭제와 Drop 생성이 모두 같은 Product 행을 잠근 뒤 작업하게 했다. +- 먼저 Product를 삭제하면 뒤의 Drop 생성은 `404 PRODUCT_NOT_FOUND`로 종료된다. +- 먼저 Drop을 생성하면 뒤의 Product 삭제는 `409 PRODUCT_IN_USE_BY_DROP`으로 종료된다. + +## After 분석 + +- 100회 모두 두 요청 중 하나만 성공하고 나머지 하나는 정상적인 도메인 충돌로 종료됐다. +- 예상 밖 `DataIntegrityViolationException`이 100회에서 0회로 감소했다. +- Before와 After 모두 고아 Drop은 0개로 DB 무결성을 유지했다. +- 총 실행 시간은 실행 환경에 따라 달라질 수 있으므로 성능 향상 근거가 아니라 정확성 검증 참고값으로만 사용한다. diff --git a/performance/results/drop-detail-concurrent/README.md b/performance/results/drop-detail-concurrent/README.md new file mode 100644 index 0000000..24e192b --- /dev/null +++ b/performance/results/drop-detail-concurrent/README.md @@ -0,0 +1,123 @@ +# Drop 상세 1만 명 동시 조회 개선 결과 + +## 테스트 목적 + +한정판 판매 시작 시각에 사용자가 같은 상품 화면으로 동시에 들어오는 상황을 재현했다. 가상 사용자 10,000명이 `GET /drops/{dropId}`를 한 번씩 호출하고, 한 서버가 요청을 감당하지 못하는 상태부터 서버 분산과 DB 연결 개선까지 같은 조건으로 비교했다. + +프론트 화면에서는 사용자가 한정판 상품의 가격, 남은 수량, 할인율, 판매 시간을 확인하려고 **같은 Drop 상세 페이지를 동시에 새로 여는 상황**이다. + +## 고정한 조건 + +| 항목 | 조건 | +|---|---| +| 대상 API | `GET /drops/1261706` | +| 가상 사용자 | 10,000 VU | +| 사용자 행동 | 각 VU가 상세 조회 1회 실행 | +| 캐시 | Redis Warm Cache, TTL 3초 | +| 애플리케이션 | Java 21, Spring Boot, 가상 스레드 | +| 서버 자원 | 컨테이너당 메모리 768MB, JVM Heap 512MB | +| 실행 환경 | 로컬 Windows Docker, CPU 12개, Docker 메모리 약 7.7GB | +| 계정 | JWT 한 개를 10,000 VU가 공유 | + +## 단계별 구조 + +```text +Before +k6 10,000명 → Spring 1대 → Redis → MySQL + +After +k6 10,000명 → Nginx → Spring 4대 → Redis → MySQL + +Optimized +k6 10,000명 → Nginx → Spring 4대 + ├─ 캐시 적중: Redis 응답 후 종료 + └─ 캐시 미스: 짧은 Repository 읽기 트랜잭션 → MySQL +``` + +## 측정 결과 + +| 지표 | Before: 1대 | After: 4대 분산 | Optimized: DB 연결 개선 | +|---|---:|---:|---:| +| 실행 요청 | 10,000건 | 10,000건 | 10,000건 | +| 성공률 | 21.73% | **100%** | **100%** | +| 실패율 | 78.27% | **0%** | **0%** | +| 평균 응답 시간 | 53.36초 | **16.60초** | 21.38초 | +| p95 응답 시간 | 59.95초 | 36.32초 | **32.28초** | +| 최대 응답 시간 | 59.96초 | 37.04초 | **35.06초** | +| 테스트 전체 완료 시간 | 62.94초 | 42.83초 | **39.23초** | +| Hikari 연결 타임아웃 | 발생 | 발생 | **0건** | + +![1만 명 동시 조회 비교](./comparison.png) + +![Grafana 3단계 비교](./grafana-10k-concurrent-comparison.png) + +## 어떤 코드가 병목을 줄였는가 + +기존 `DropService.getOne()`에는 캐시와 읽기 전용 트랜잭션이 함께 선언되어 있었다. + +```java +@Transactional(readOnly = true) +@Cacheable(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId", sync = true) +public DropResponse getOne(Long dropId) { + // DB 조회 후 응답 생성 +} +``` + +부하 로그에서 Hikari 연결 10개가 모두 사용되어 최대 36초 동안 연결을 기다리는 현상을 확인했다. Redis에 응답이 있어도 서비스 트랜잭션이 먼저 열리면서 DB 연결을 점유했기 때문이다. + +서비스의 트랜잭션을 제거하고, 실제 DB 조회가 필요한 Repository 메서드로 읽기 전용 트랜잭션 범위를 줄였다. + +```java +@Cacheable(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId", sync = true) +public DropResponse getOne(Long dropId) { + // 캐시 적중 시 DB 트랜잭션 없이 바로 반환 +} + +@Transactional(readOnly = true) +Optional findDetailById(Long dropId); +``` + +그 결과 캐시 적중 요청은 Hikari 연결을 사용하지 않고, 캐시 미스만 Repository 조회 중에 DB 연결을 사용한다. 최적화 후 네 서버의 Hikari 연결 타임아웃은 모두 0건이었다. + +## 결과 해석 + +- 서버 1대에서는 10,000건 중 7,827건이 60초 타임아웃으로 실패했다. +- 4대 분산 후 모든 요청이 성공했고, Before 대비 p95가 약 39.4% 감소했다. +- DB 연결 범위를 줄인 뒤 p95는 36.32초에서 32.28초로 약 11.1%, 전체 완료 시간은 약 8.4% 추가 감소했다. +- 마지막 단계의 평균은 16.60초에서 21.38초로 증가했다. 단일 로컬 장비에서 k6, 4개 JVM, Nginx, Redis, MySQL, InfluxDB를 함께 실행한 측정 편차가 크므로 평균 개선으로 주장하지 않는다. +- 최종 p95 32.28초는 목표 2초에 미달한다. DB 연결 풀 고갈은 제거됐고 다음 병목은 한 장비의 CPU·메모리와 동시에 생성되는 10,000개 연결이다. + +## 재현 방법 + +```powershell +# 4대 Spring 서버와 Nginx 시작 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\start-drop-detail-cluster.ps1 + +# 1만 명 동시 조회 실행 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\start-drop-detail-concurrent-test.ps1 ` + -Phase Optimized -DropId 1261706 -ConcurrentUsers 10000 ` + -CacheMode Warm ` + -TargetBaseUrl http://dropit-performance-gateway:8080 + +# JSON 최종 통계를 Grafana 비교용 InfluxDB 측정값으로 발행 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\publish-drop-detail-concurrent-summary.ps1 +``` + +Grafana 주소는 `http://localhost:3000/d/drop-detail-10k-concurrent/drop-detail-10k-concurrent-comparison`이다. + +## 측정 한계와 다음 실험 + +- 한 번의 로컬 측정이므로 운영 환경 처리량으로 해석하지 않는다. +- 10,000 VU가 같은 JWT를 사용했다. 10,000개 실제 계정의 서로 다른 JWT 검증 비용은 별도 실험이 필요하다. +- 1만 동시 사용자는 누적 조회 1억 건과 다른 지표다. 1억 건은 장시간 지속 부하와 비용을 확인하는 Soak Test로 분리해야 한다. +- 다음 단계는 별도 부하 발생기와 여러 서버 장비를 사용하는 분산 k6, 애플리케이션 CPU·GC·Hikari 활성 연결 모니터링, 목표 RPS 기준 용량 산정이다. + +## 검증 + +- Drop 관련 테스트 61개 통과 +- 실패 0개, 오류 0개 +- 세 단계 모두 k6 iteration 10,000건 완료 +- Optimized 단계 HTTP 실패율 0%, 응답 검증 성공률 100% diff --git a/performance/results/drop-detail-concurrent/comparison.png b/performance/results/drop-detail-concurrent/comparison.png new file mode 100644 index 0000000..5a91aa9 Binary files /dev/null and b/performance/results/drop-detail-concurrent/comparison.png differ diff --git a/performance/results/drop-detail-concurrent/grafana-10k-concurrent-comparison.png b/performance/results/drop-detail-concurrent/grafana-10k-concurrent-comparison.png new file mode 100644 index 0000000..e95f33b Binary files /dev/null and b/performance/results/drop-detail-concurrent/grafana-10k-concurrent-comparison.png differ diff --git a/performance/results/drop-detail-live-stock/README.md b/performance/results/drop-detail-live-stock/README.md new file mode 100644 index 0000000..b553855 --- /dev/null +++ b/performance/results/drop-detail-live-stock/README.md @@ -0,0 +1,145 @@ +# Drop 상세 캐시 정확성 개선 및 부하 검증 + +## 문제 + +판매 시작 시 사용자가 호출하는 API는 다음과 같다. + +```http +GET /drops/{dropId} +``` + +기존에는 `DropResponse` 전체를 Redis에 3초 동안 저장했다. 상품명·가격·판매 기간뿐 아니라 계속 바뀌는 `remainingQuantity`, `soldQuantity`, `status`도 함께 캐싱되어 구매 직후 최대 3초 동안 이전 재고가 보일 수 있었다. + +## Before / After 구조 + +| 구분 | Before: 전체 응답 캐시 | After: 정적 캐시 + 실시간 재고 | +|---|---|---| +| 캐시 데이터 | 상품·가격·기간·재고·상태 | 상품·가격·기간 등 정적 정보 | +| 남은 재고 | 최대 3초 전 캐시 값 | 주문과 같은 Redis 재고 키를 매 요청 조회 | +| 판매 수량 | 캐시된 값 | `initialQuantity - currentStock` | +| 판매 상태 | 캐시된 값 | 현재 시각과 현재 재고로 계산 | +| DB 접근 | 캐시 미스에만 발생 | 캐시 미스에만 발생 | +| 추가 비용 | 없음 | 상세 요청마다 Redis GET 1회 | +| 핵심 효과 | 매우 빠른 조회 | DB 절감 효과를 유지하면서 재고 정확성 확보 | + +![Drop 상세 캐시 Before After](./drop-detail-live-stock-before-after.jpg) + +## 요청 처리 흐름 + +```text +사용자 → GET /drops/{dropId} + → DropDetailCacheReader + ├─ 캐시 적중: 정적 정보 반환 + └─ 캐시 미스: DB 조회 → 정적 정보 캐시 + → DropStockReader + └─ {drop:}:stock 현재 값 조회 + → DropResponse + └─ 정적 정보 + 현재 재고 + 현재 상태 결합 +``` + +핵심 코드는 다음처럼 역할을 나눴다. + +```java +public DropResponse getOne(Long dropId) { + DropDetailCacheValue detail = dropDetailCacheReader.get(dropId); + int remainingQuantity = dropStockReader.getRemainingQuantity(dropId); + + return DropResponse.from(detail, remainingQuantity, LocalDateTime.now()); +} +``` + +- `DropDetailCacheReader`: 변하지 않는 상세 정보만 캐싱한다. +- `DropStockReader`: 주문 처리에서 사용하는 Redis 재고 키를 읽는다. +- `DropResponse.from(...)`: 현재 재고로 판매 수량과 상태를 계산한다. +- Redis 재고 키가 없거나 잘못된 경우 임의로 DB 값을 쓰지 않고 `503 DROP_STOCK_NOT_READY`를 반환한다. 준비되지 않은 재고로 주문 화면을 잘못 노출하지 않기 위한 fail-closed 정책이다. + +## 현재 구조 부하 검증 + +### 판매 시작 스파이크 + +평상시 100 RPS에서 1초 안에 1,000 RPS로 증가시켜 1분 동안 유지했다. + +| 지표 | LiveStock 결과 | 목표 | 판정 | +|---|---:|---:|---| +| 완료 HTTP 요청 | 69,379건 | - | - | +| 평균 처리량 | 654.53 RPS | - | - | +| 평균 응답 시간 | 105.48ms | - | - | +| p95 응답 시간 | 328.65ms | 2,000ms 미만 | 통과 | +| p99 응답 시간 | 600.59ms | - | - | +| HTTP 실패율 | 0% | 1% 미만 | 통과 | +| 시작하지 못한 요청 | 1,670건 | 0건 | 추가 개선 필요 | +| 최대 활성 VU | 428명 | 최대 2,000명 | - | + +응답 목표는 충족했지만 정해진 도착률에 맞춰 시작하지 못한 요청이 남았다. 따라서 로컬 환경에서 1,000 RPS를 완전히 수용했다고 표현하지 않는다. + +### 10,000명 동시 조회 + +가상 사용자 10,000명이 같은 Drop 상세 API를 한 번씩 동시에 호출했다. + +| 지표 | LiveStock 결과 | +|---|---:| +| 완료 요청 | 10,000건 | +| 성공률 | 100% | +| 실패율 | 0% | +| 평균 응답 시간 | 523.96ms | +| p95 응답 시간 | 1,184.50ms | +| 최대 응답 시간 | 2,512.91ms | +| 전체 완료 시간 | 9.66초 | +| 애플리케이션 오류·Hikari timeout | 0건 | + +테스트 종료 후 Redis 재고가 100으로 유지됨을 확인했다. 상세 조회는 재고를 읽기만 하므로 조회 부하가 재고를 차감하지 않는 것도 함께 검증했다. + +## 기존 수치와 비교할 때의 주의점 + +| 시나리오 | 기존 전체 응답 캐시 | 현재 실시간 재고 결합 | +|---|---:|---:| +| 1,000 RPS 스파이크 p95 | 20.12ms | 328.65ms | +| 1,000 RPS 스파이크 실패율 | 0% | 0% | +| 10,000명 동시 조회 p95 | 32.28초 | 1.18초 | +| 10,000명 동시 조회 성공률 | 100% | 100% | + +기존 전체 응답 캐시는 Redis 응답 한 번만 사용하므로 스파이크 지연이 더 낮다. 현재 구조는 재고 정확성을 얻는 대신 Redis GET 한 번이 추가됐다. + +반대로 10,000명 결과가 크게 좋아졌지만, 기존 측정은 단일 Spring 단계부터 이어진 과거 환경이고 현재 측정은 Nginx와 Spring 4대 및 이후 병합된 설정을 포함한다. 따라서 32.28초에서 1.18초로 줄어든 전체 차이를 캐시 분리 코드 하나의 효과라고 주장하지 않는다. 이번 변경의 직접적인 효과는 **재고 정확성 확보**이며, 현재 구조가 목표 부하에서 동작하는지를 별도로 검증한 결과다. + +## 테스트 검증 + +- 같은 Drop을 두 번 조회할 때 정적 메타데이터 DB 조회 1회 +- 두 조회 사이 Redis 재고 변경 시 두 번째 응답에 즉시 반영 +- 현재 재고로 `soldQuantity`, `READY`, `OPEN`, `SOLDOUT`, `CLOSED` 계산 +- 수정·공개 여부 변경·삭제 시 정적 상세 캐시 제거 +- Redis 재고 누락·음수·잘못된 문자열·연결 장애 시 503 처리 +- Drop 테스트 총 66개 통과(실패 0개, 오류 0개) + +## 재현 + +```powershell +# 판매 시작 스파이크 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\start-drop-detail-spike-test.ps1 ` + -Phase LiveStock -DropId 1262071 ` + -NormalRps 100 -SpikeRps 1000 ` + -PreAllocatedVUs 200 -MaxVUs 2000 -StockQuantity 100 + +# 10,000명 동시 조회 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\start-drop-detail-concurrent-test.ps1 ` + -Phase LiveStock -DropId 1261706 -ConcurrentUsers 10000 ` + -CacheMode Warm -StockQuantity 100 ` + -TargetBaseUrl http://dropit-performance-gateway:8080 + +# 정확한 k6 JSON 통계를 Grafana용 InfluxDB에 발행 +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\publish-drop-detail-spike-summary.ps1 + +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\performance\publish-drop-detail-concurrent-summary.ps1 +``` + +Grafana 대시보드 UID는 `drop-detail-live-stock`이다. 원본 k6 JSON은 `performance/results/raw`에 로컬로만 보관하며 Git에는 포함하지 않는다. + +## 다음 개선 + +1. 동일 테스트를 3회 이상 반복해 중앙값으로 비교한다. +2. Spring CPU·GC, Redis latency, 연결 수를 함께 수집해 시작하지 못한 1,670건의 병목을 찾는다. +3. 운영에서는 판매 시작 전에 정적 캐시와 재고 키를 미리 준비하고, Redis 장애 시 상세 조회의 실패 정책을 알림과 연결한다. diff --git a/performance/results/drop-detail-live-stock/drop-detail-live-stock-before-after.jpg b/performance/results/drop-detail-live-stock/drop-detail-live-stock-before-after.jpg new file mode 100644 index 0000000..c10dfcf Binary files /dev/null and b/performance/results/drop-detail-live-stock/drop-detail-live-stock-before-after.jpg differ diff --git a/performance/results/drop-detail-spike/README.md b/performance/results/drop-detail-spike/README.md new file mode 100644 index 0000000..947030d --- /dev/null +++ b/performance/results/drop-detail-spike/README.md @@ -0,0 +1,118 @@ +# Drop 상세 조회 판매 시작 스파이크 테스트 + +## 테스트 목적 + +판매 시작 시각에 사용자가 같은 한정판 상품의 Drop 상세 페이지로 몰리는 상황을 재현했다. +프론트 화면으로 보면 사용자가 판매 페이지를 새로고침하거나 상세 정보를 확인하는 요청이다. + +```http +GET /drops/{dropId} +``` + +목표는 1초 안에 100 RPS에서 1,000 RPS로 증가해도 p95 응답시간 2초 미만과 HTTP 실패율 1% 미만을 유지하는 것이다. + +## 테스트 조건 + +| 항목 | 값 | +|---|---:| +| 측정일 | 2026-09-12 | +| 대상 Drop ID | 1,262,071 | +| 평상시 부하 | 100 RPS, 30초 | +| 판매 시작 스파이크 | 1초 안에 1,000 RPS | +| 스파이크 유지 | 1분 | +| 감소 구간 | 15초 | +| 최대 VU | 2,000 | +| 실행 환경 | Windows 로컬 Spring 서버, Docker k6·InfluxDB·Grafana·MySQL·Redis | + +각 단계는 같은 Drop ID, 부하 단계, JWT 인증, 실행 시간과 최대 VU로 1회씩 측정했다. + +## Before / After 결과 + +| 지표 | 기본 JPA | fetch join | Redis Cache | Before → After | +|---|---:|---:|---:|---:| +| 완료 HTTP 요청 | 40,084건 | 52,944건 | 71,049건 | 77.25% 증가 | +| 실제 평균 처리량 | 377.31 RPS | 499.50 RPS | 670.31 RPS | 77.66% 증가 | +| 평균 응답시간 | 3,428.22ms | 2,377.30ms | 6.49ms | 99.81% 감소 | +| p95 응답시간 | 5,177.79ms | 3,243.02ms | 20.12ms | 99.61% 감소 | +| p99 응답시간 | 5,573.63ms | 3,675.47ms | 51.93ms | 99.07% 감소 | +| 최대 응답시간 | 6,934.71ms | 4,560.20ms | 381.40ms | 94.50% 감소 | +| HTTP 실패율 | 0% | 0% | 0% | 동일 | +| 시작하지 못한 요청 | 30,965건 | 18,105건 | 0건 | 100% 감소 | +| 최대 활성 VU | 2,000명 | 2,000명 | 75명 | 96.25% 감소 | + +![Drop 상세 조회 성능 비교](./comparison.png) + +## 단계별 코드 변화 + +### 1. 기본 JPA 조회 + +```java +dropRepository.findById(dropId) +``` + +요청마다 DB에서 Drop을 조회했다. 응답을 만들면서 Product와 Seller도 사용하므로 연관 데이터 조회 비용까지 반복됐다. + +### 2. fetch join 적용 + +```java +@Query(""" + select d + from Drop d + join fetch d.product p + join fetch p.seller + where d.id = :dropId + """) +Optional findDetailById(@Param("dropId") Long dropId); +``` + +Drop·Product·Seller를 한 SQL로 가져와 DB 왕복을 줄였다. 그 결과 p95는 5.18초에서 3.24초로 줄었지만, 같은 데이터를 요청마다 DB에서 읽는 구조는 남았다. + +### 3. Redis Cache 적용 + +```java +@Cacheable(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, + key = "#dropId", sync = true) +public DropResponse getOne(Long dropId) { + // 캐시가 없을 때만 DB 조회 +} +``` + +첫 요청의 `DropResponse`를 Redis에 저장하고 같은 Drop ID 요청은 3초 동안 저장된 응답을 재사용한다. `sync = true`는 한 애플리케이션 인스턴스에서 캐시가 비었을 때 여러 요청이 동시에 DB로 몰리는 현상을 줄인다. + +수정·공개 여부 변경·삭제 시에는 다음 설정으로 해당 Drop 캐시를 제거한다. + +```java +@CacheEvict(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId") +``` + +## 캐시 정책 + +| 정책 | 선택 | 이유 | +|---|---|---| +| 캐시 대상 | Drop 상세 응답 | 판매 시작 시 같은 Drop에 조회가 집중됨 | +| TTL | 3초 | 상태와 남은 재고가 오래된 값으로 보이는 시간을 제한 | +| 캐시 키 | Drop ID | 상세 API의 조회 단위와 일치 | +| 무효화 | 수정·공개 변경·삭제 시 즉시 제거 | 변경 전 응답이 남는 문제 방지 | +| 재고 정확성 | 구매 시 DB에서 다시 검증 | 조회 캐시는 화면 표시용이며 주문 성공 여부를 결정하지 않음 | + +실제 확인 결과 상세 조회 직후 `dropit::dropDetail::1262071` 키가 생성됐고 약 3초 TTL이 적용됐다. 통합 테스트에서는 같은 Drop을 두 번 조회해 Repository가 한 번만 호출되는 것과 공개 여부 변경 후 캐시가 제거되는 것을 검증했다. + +## Grafana 측정 화면 + +![Grafana Drop 상세 조회 Before After](./grafana-before-after.png) + +## 결론 + +- 기본 JPA 단계는 1,000 RPS를 만들기 위해 최대 VU 2,000명을 모두 사용했지만 30,965건을 시작하지 못했다. +- fetch join은 연관 조회 비용을 줄였으나 p95 3.24초와 누락 요청 18,105건이 남았다. +- Redis Cache 적용 후 목표 요청 71,049건을 모두 처리했고 시작하지 못한 요청은 0건이었다. +- p95는 20.12ms로 목표 2초를 충족했고 HTTP 실패율은 0%였다. + +## 한계와 다음 검증 + +- 로컬 단일 서버에서 각 단계를 1회 측정한 결과이므로 운영 용량으로 일반화할 수 없다. +- `sync = true`는 단일 인스턴스 안에서만 동시 캐시 생성을 막는다. 다중 서버 환경에서는 분산 락이나 사전 캐시 적재를 별도로 검토해야 한다. +- 3초 동안 상태와 표시용 남은 재고가 실제 값보다 늦게 보일 수 있다. 구매 처리는 반드시 DB 또는 원자적 재고 저장소를 기준으로 판단해야 한다. +- 다음 단계에서는 테스트를 여러 번 반복해 중앙값을 비교하고, Spring·JVM·DB·Redis 지표를 함께 수집해 병목 이동을 확인한다. + +원본 k6 JSON은 `performance/results/raw`에 로컬로만 보관하며 Git에는 포함하지 않는다. diff --git a/performance/results/drop-detail-spike/comparison.png b/performance/results/drop-detail-spike/comparison.png new file mode 100644 index 0000000..666634e Binary files /dev/null and b/performance/results/drop-detail-spike/comparison.png differ diff --git a/performance/results/drop-detail-spike/grafana-before-after.png b/performance/results/drop-detail-spike/grafana-before-after.png new file mode 100644 index 0000000..6b4bd3c Binary files /dev/null and b/performance/results/drop-detail-spike/grafana-before-after.png differ diff --git a/performance/results/drop-million-report/PR-review.md b/performance/results/drop-million-report/PR-review.md new file mode 100644 index 0000000..d794c31 --- /dev/null +++ b/performance/results/drop-million-report/PR-review.md @@ -0,0 +1,83 @@ +# Drop 100만 건 조회 성능 개선 + +## 🛠️ 주요 변경 사항 + +- 판매 중 Drop 첫 페이지를 마감 임박순으로 조회할 때 대량 조인·정렬이 발생하는 문제를 개선했습니다. +- 복합 인덱스를 적용한 뒤 효과를 확인하고, ID 선조회 및 조건부 count 조인 제거를 추가했습니다. +- 기존 Page 응답, 전체 개수, 검색·정렬 계약을 유지합니다. + +## ✨ Before / After + +| 지표 | Before | 인덱스 | 인덱스 + 쿼리 개선 | +|---|---:|---:|---:| +| 완료 HTTP 요청 | 633건 | 635건 | 1,897건 | +| 평균 응답 시간 | 45,193.56 ms | 44,797.13 ms | 13,892.99 ms | +| p95 응답 시간 | 59,998.36 ms | 59,998.19 ms | 23,460.92 ms | +| HTTP 실패율 | 82.46 % | 84.25 % | 0.00 % | +| 시작하지 못한 요청 | 4,807건 | 4,799건 | 3,727건 | + +![성능 비교](comparison.png) + +HTTP 완료 수에는 오류 응답·시간 초과도 포함됩니다. 시작하지 못한 요청은 k6의 dropped_iterations이며 서버가 거절한 요청 수와 다릅니다. p95는 실패 요청을 포함하며 Before의 약 60초는 클라이언트 시간 초과의 영향을 받습니다. + +## 실험 조건 + +- API: `GET /drops?status=OPEN&sortType=CLOSING_SOON&page=0&size=20` +- 상품 100,000건, Drop 1,000,000건, 공개 판매 중 500,000건. 판매자는 한 명인 집중 시나리오이며 실제 운영 규모라는 가정은 아닙니다. +- Docker MySQL 8.4, Spring 서버, Docker k6·InfluxDB·Grafana를 같은 Windows PC에서 실행했습니다. +- 75초 동안 10→20→50→100→200→0 RPS, 최대 500 VU, 종료 대기 30초. 단계별 1회 측정했습니다. +- 모든 단계에서 서버를 같은 설정으로 실행하고 API 사전 호출 후 측정했습니다. DB 버퍼 캐시는 초기화하지 않았습니다. +- 요청량·데이터·페이지 크기·인증·연결 풀 설정은 동일합니다. 인덱스 단계에서는 통계를 갱신했습니다. + +## 원인과 변경 코드 + +기존에는 상품·판매자를 조인한 결과에서 조건에 맞는 50만 건을 정렬했습니다. 인덱스를 추가해도 진단 실행 계획은 기존 외래키 조회를 선택했습니다. + +```java +// Before: 조인한 전체 후보를 정렬하고 페이지 조회 +selectFrom(drop).join(drop.product, product).fetchJoin() + .join(product.seller, user).fetchJoin() + .where(conditions).orderBy(order).offset(offset).limit(size); + +// After: 필요한 ID만 선조회하고 그 ID에 한해 상세 조인 +select(drop.id).from(drop).where(conditions) + .orderBy(order).offset(offset).limit(size); +selectFrom(drop).join(drop.product, product).fetchJoin() + .join(product.seller, user).fetchJoin() + .where(drop.id.in(ids)).orderBy(order); +``` + +위 코드는 흐름을 설명하는 발췌이며 실제 구현은 `DropRepositoryCustomImpl`입니다. 키워드가 있으면 ID·count 쿼리에도 상품·판매자 조인을 유지합니다. 키워드가 없으면 필수 FK 관계를 전제로 count 조인을 생략합니다. 상세 조회에서 정렬을 다시 지정해 IN 조건으로 순서가 바뀌지 않게 했습니다. + +인덱스: `(visible, close_at, id DESC, open_at, remaining_quantity, product_id)`. + +## SQL 진단 + +| 단계 | 목록 또는 ID 쿼리 | count | +|---|---:|---:| +| Before | 2,683ms | 1,916ms | +| 인덱스 | 3,293ms | 2,362ms | +| 쿼리 개선 | ID 20개 3.26ms | 714ms | + +진단용 SQL은 Drop 컬럼만 선택하므로 실제 API의 fetch join 전체 투영과 같지 않습니다. 최종 ID 쿼리 시간에는 후속 상세 쿼리가 포함되지 않습니다. 이 값들을 API 개선율로 환산하지 않았으며 API 비교는 위 k6 표를 사용합니다. + +## ⚠️ 주의 사항 및 남은 한계 + +- 정확한 totalElements를 유지하므로 최종 count도 인덱스 후보 약 80만 건을 읽습니다. 인덱스만으로 목표 부하를 모두 처리한다고 주장하지 않습니다. +- 단일 실행이라 반복 편차·통계적 유의성을 확인하지 않았습니다. 키워드 검색, 뒷페이지, 여러 판매자 분포의 성능은 이번 측정 범위 밖입니다. +- ID 선조회로 쿼리는 2회에서 3회로 증가합니다. 기존 read-only 트랜잭션 및 MySQL REPEATABLE READ 안에서 페이지와 상세를 읽습니다. +- 재고 컬럼을 포함하는 인덱스는 재고 변경 시 유지 비용이 있습니다. 재고 쓰기 부하 영향은 별도 검증이 필요합니다. +- Before/인덱스 단계의 시간 초과에서 기존 k6 JSON 검증이 null body 예외를 냈습니다. HTTP 지표는 저장됐으며 checks를 정확성 근거로 사용하지 않았습니다. +- 다음 후보: 정확한 전체 개수가 불필요한 경우 별도 Slice API, 검색어별 실행 계획 개선. API 계약 변경은 팀 합의 후 진행합니다. + +## 🧪 테스트 결과 + +- [x] Drop 테스트 43개 통과, 실패 0개, 오류 0개 +- [x] MySQL 통합 테스트 2개 포함: 키워드 검색 페이징·빈 페이지·전체 개수와 기본 조회의 기존 조인 쿼리 결과 일치 +- [x] k6 세 단계 측정 완료 (원본 JSON은 로컬 생성물로 Git 제외) +- [ ] 최종 p95 2초 목표 미달 + +## 💡 관련 이슈 + +- 이슈 번호 미확인. 실제 번호를 확인한 뒤 연결합니다. + diff --git a/performance/results/drop-million-report/comparison.png b/performance/results/drop-million-report/comparison.png new file mode 100644 index 0000000..76824dc Binary files /dev/null and b/performance/results/drop-million-report/comparison.png differ diff --git a/performance/results/images/2026-09-10-product-drop-concurrency-before-after.png b/performance/results/images/2026-09-10-product-drop-concurrency-before-after.png new file mode 100644 index 0000000..775788c Binary files /dev/null and b/performance/results/images/2026-09-10-product-drop-concurrency-before-after.png differ diff --git a/performance/start-drop-detail-cluster.ps1 b/performance/start-drop-detail-cluster.ps1 new file mode 100644 index 0000000..46fd502 --- /dev/null +++ b/performance/start-drop-detail-cluster.ps1 @@ -0,0 +1,161 @@ +param( + [ValidateRange(4, 4)] + [int]$InstanceCount = 4, + + [string]$JarPath = 'tmp\dropit-server-performance.jar' +) + +$ErrorActionPreference = 'Stop' + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDirectory = Split-Path -Parent $performanceDirectory +$environmentFile = Join-Path $projectDirectory '.env.local' +$composeFile = Join-Path $projectDirectory 'compose.yml' +$performanceComposeFile = Join-Path $performanceDirectory 'compose.performance.yml' +$nginxConfig = Join-Path $performanceDirectory 'nginx\drop-detail-cluster.conf' + +if (-not [System.IO.Path]::IsPathRooted($JarPath)) { + $JarPath = Join-Path $projectDirectory $JarPath +} + +foreach ($requiredFile in @($environmentFile, $composeFile, $performanceComposeFile, $nginxConfig, $JarPath)) { + if (-not (Test-Path -LiteralPath $requiredFile)) { + throw "Required file is missing: $requiredFile" + } +} + +docker compose ` + --file $composeFile ` + --env-file $environmentFile ` + up --detach db redis | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw 'MySQL or Redis could not be started.' +} + +docker compose ` + --file $performanceComposeFile ` + --env-file (Join-Path $projectDirectory '.env.performance.local') ` + up --detach influxdb grafana | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw 'InfluxDB or Grafana could not be started.' +} + +$singleApp = docker ps -a --filter 'name=^/dropit-performance-app$' --format '{{.Names}}' +if ($singleApp) { + docker rm --force dropit-performance-app | Out-Null +} + +$existingGateway = docker ps -a --filter 'name=^/dropit-performance-gateway$' --format '{{.Names}}' +if ($existingGateway) { + docker rm --force dropit-performance-gateway | Out-Null +} + +for ($index = 1; $index -le $InstanceCount; $index++) { + $containerName = "dropit-performance-app-$index" + $existingApp = docker ps -a --filter "name=^/$containerName$" --format '{{.Names}}' + + if ($existingApp) { + docker rm --force $containerName | Out-Null + } + + docker run ` + --detach ` + --name $containerName ` + --network dropit_default ` + --memory 768m ` + --env-file $environmentFile ` + --env DB_HOST=db ` + --env DB_PORT=3306 ` + --env REDIS_HOST=redis ` + --env REDIS_PORT=6379 ` + --env SPRING_PROFILES_ACTIVE=performance ` + --env SPRING_JPA_HIBERNATE_DDL_AUTO=none ` + --env 'spring.jpa.hibernate.ddl-auto=none' ` + --env 'JAVA_TOOL_OPTIONS=-Xms128m -Xmx512m' ` + --mount "type=bind,source=$JarPath,destination=/app/app.jar,readonly" ` + eclipse-temurin:21-jre ` + java -jar /app/app.jar | Out-Null + + if ($LASTEXITCODE -ne 0) { + throw "Spring container could not be started: $containerName" + } + + docker network connect dropit-performance_default $containerName + + if ($LASTEXITCODE -ne 0) { + throw "Spring container could not join the performance network: $containerName" + } +} + +$deadline = (Get-Date).AddSeconds(120) +do { + $startedCount = 0 + + for ($index = 1; $index -le $InstanceCount; $index++) { + $containerName = "dropit-performance-app-$index" + $state = docker inspect --format '{{.State.Status}}' $containerName + + if ($state -ne 'running') { + & cmd.exe /d /c "docker logs --tail 80 $containerName 2>&1" + throw "Spring container stopped during startup: $containerName" + } + + # Windows PowerShell 5.1 treats normal container stderr as a NativeCommandError. + # cmd.exe merges both streams so startup messages can be inspected as plain text. + $logs = & cmd.exe /d /c "docker logs $containerName 2>&1" + if ($LASTEXITCODE -ne 0) { + throw "Spring container logs could not be read: $containerName" + } + + if ($logs -match 'Started DropitServerApplication') { + $startedCount++ + } + } + + if ($startedCount -eq $InstanceCount) { + break + } + + Start-Sleep -Seconds 3 +} while ((Get-Date) -lt $deadline) + +if ($startedCount -ne $InstanceCount) { + throw "Only $startedCount of $InstanceCount Spring containers became ready." +} + +docker run ` + --detach ` + --name dropit-performance-gateway ` + --network dropit-performance_default ` + --publish '127.0.0.1:8080:8080' ` + --mount "type=bind,source=$nginxConfig,destination=/etc/nginx/nginx.conf,readonly" ` + nginx:1.29-alpine | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw 'Nginx gateway could not be started.' +} + +$deadline = (Get-Date).AddSeconds(30) +do { + Start-Sleep -Seconds 2 + $gatewayState = docker inspect --format '{{.State.Status}}' dropit-performance-gateway + + if ($gatewayState -ne 'running') { + & cmd.exe /d /c 'docker logs --tail 80 dropit-performance-gateway 2>&1' + throw "Nginx gateway state: $gatewayState" + } + + $listener = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($listener) { + Write-Host "Drop performance cluster is ready: $InstanceCount Spring instances and Nginx." + Write-Host 'Docker test URL: http://dropit-performance-gateway:8080' + Write-Host 'Host test URL: http://localhost:8080' + exit 0 + } +} while ((Get-Date) -lt $deadline) + +throw 'Nginx gateway did not bind port 8080.' diff --git a/performance/start-drop-detail-concurrent-test.ps1 b/performance/start-drop-detail-concurrent-test.ps1 new file mode 100644 index 0000000..a1b8229 --- /dev/null +++ b/performance/start-drop-detail-concurrent-test.ps1 @@ -0,0 +1,232 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Before', 'After', 'Optimized', 'LiveStock')] + [string]$Phase, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, [long]::MaxValue)] + [long]$DropId, + + [ValidateRange(1, 100000)] + [int]$ConcurrentUsers = 10000, + + [ValidateSet('Warm', 'Cold')] + [string]$CacheMode = 'Warm', + + [string]$TargetBaseUrl, + + [ValidateRange(0, [int]::MaxValue)] + [int]$StockQuantity = 100 +) + +$ErrorActionPreference = 'Stop' + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDirectory = Split-Path -Parent $performanceDirectory +$environmentFile = Join-Path $projectDirectory '.env.performance.local' +$composeFile = Join-Path $performanceDirectory 'compose.performance.yml' +$k6Directory = Join-Path $performanceDirectory 'k6' +$resultDirectory = Join-Path $performanceDirectory 'results\raw' +$phaseName = $Phase.ToLower() +$testRun = "drop-detail-concurrent-$phaseName-$ConcurrentUsers-vu" +$containerName = "dropit-k6-$testRun" + +if (-not (Test-Path -LiteralPath $environmentFile)) { + throw '.env.performance.local is missing.' +} + +$settings = @{} +Get-Content -LiteralPath $environmentFile | ForEach-Object { + $line = $_.Trim() + + if ($line -and -not $line.StartsWith('#')) { + $parts = $line -split '=', 2 + + if ($parts.Count -eq 2) { + $settings[$parts[0].Trim()] = $parts[1].Trim() + } + } +} + +$healthCheckUrl = $settings['HEALTH_CHECK_URL'] +$baseUrl = $settings['BASE_URL'] +$authEmail = $settings['AUTH_EMAIL'] +$authPassword = $settings['AUTH_PASSWORD'] +$authUsername = $settings['AUTH_USERNAME'] + +if (-not $healthCheckUrl -or -not $baseUrl -or + -not $authEmail -or -not $authPassword -or -not $authUsername) { + throw 'Required performance environment values are missing.' +} + +if ($TargetBaseUrl) { + $baseUrl = $TargetBaseUrl.TrimEnd('/') +} + +function Invoke-LoginWithRetry { + param( + [string]$Url, + [string]$Body, + [int]$MaxAttempts = 5 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return Invoke-RestMethod ` + -Uri $Url ` + -Method Post ` + -ContentType 'application/json' ` + -Body $Body + } catch { + if ($attempt -eq $MaxAttempts) { + throw + } + + Start-Sleep -Seconds 2 + } + } +} + +$loginBody = @{ + email = $authEmail + password = $authPassword +} | ConvertTo-Json + +try { + $tokenResponse = Invoke-LoginWithRetry ` + -Url "$healthCheckUrl/auth/login" ` + -Body $loginBody ` + -MaxAttempts 3 +} catch { + $signupBody = @{ + email = $authEmail + password = $authPassword + username = $authUsername + role = 'USER' + } | ConvertTo-Json + + try { + Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/signup" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $signupBody | Out-Null + } catch { + $statusCode = [int]$_.Exception.Response.StatusCode + + if ($statusCode -ne 409) { + throw + } + } + + $tokenResponse = Invoke-LoginWithRetry ` + -Url "$healthCheckUrl/auth/login" ` + -Body $loginBody ` + -MaxAttempts 10 +} + +$authToken = $tokenResponse.accessToken + +if (-not $authToken) { + throw 'Access token was not returned by the login API.' +} + +$redisContainer = docker ps --filter 'name=redis' --format '{{.Names}}' | Select-Object -First 1 +if (-not $redisContainer) { + throw 'Redis container is not running.' +} + +docker exec $redisContainer redis-cli SET "{drop:$DropId}:stock" $StockQuantity | Out-Null +if ($LASTEXITCODE -ne 0) { + throw 'The Drop stock key could not be prepared.' +} + +try { + $dropResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/drops/$DropId" ` + -Headers @{ Authorization = "Bearer $authToken" } ` + -Method Get + + if ([long]$dropResponse.id -ne $DropId) { + throw 'The returned Drop ID does not match the requested Drop ID.' + } +} catch { + throw "Drop $DropId cannot be tested. Cause: $($_.Exception.Message)" +} + +if ($CacheMode -eq 'Cold') { + docker exec $redisContainer redis-cli DEL "dropit::dropDetail::$DropId" | Out-Null + + if ($LASTEXITCODE -ne 0) { + throw 'The Drop detail cache key could not be removed.' + } +} + +New-Item -ItemType Directory -Path $resultDirectory -Force | Out-Null + +docker compose ` + --file $composeFile ` + --env-file $environmentFile ` + up --detach influxdb grafana | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw 'InfluxDB or Grafana could not be started.' +} + +$grafanaReady = $false +for ($attempt = 1; $attempt -le 30; $attempt++) { + curl.exe --silent --fail --output NUL 'http://localhost:3000/api/health' + + if ($LASTEXITCODE -eq 0) { + $grafanaReady = $true + break + } + + Start-Sleep -Seconds 1 +} + +if (-not $grafanaReady) { + throw 'Grafana did not become ready within 30 seconds.' +} + +$existingContainer = docker ps -a --filter "name=^/$containerName$" --format '{{.Names}}' +if ($existingContainer) { + docker rm --force $containerName | Out-Null +} + +$warmCache = if ($CacheMode -eq 'Warm') { 'true' } else { 'false' } +$dockerArguments = @( + 'run', '--rm', + '--name', $containerName, + '--network', 'dropit-performance_default', + '--volume', "${k6Directory}:/scripts:ro", + '--volume', "${resultDirectory}:/results", + 'grafana/k6:latest', + 'run', + '--out', 'influxdb=http://influxdb:8086/k6', + '--env', "BASE_URL=$baseUrl", + '--env', "TEST_RUN=$testRun", + '--env', "AUTH_TOKEN=$authToken", + '--env', "DROP_ID=$DropId", + '--env', "CONCURRENT_USERS=$ConcurrentUsers", + '--env', "WARM_CACHE=$warmCache", + '--tag', "test_run=$testRun", + '/scripts/drop-detail-concurrent.js' +) + +Write-Host "Running $testRun against Drop $DropId." +Write-Host "Concurrent users: $ConcurrentUsers, cache: $CacheMode." +docker @dockerArguments + +if ($LASTEXITCODE -ne 0) { + $resultFile = Join-Path $resultDirectory "$testRun.json" + + if (-not (Test-Path -LiteralPath $resultFile)) { + throw 'k6 failed before producing a result file.' + } + + Write-Warning 'The test completed, but one or more performance thresholds were exceeded.' +} + +Write-Host "Result: $resultDirectory\$testRun.json" +Write-Host 'Grafana: http://localhost:3000' diff --git a/performance/start-drop-detail-spike-test.ps1 b/performance/start-drop-detail-spike-test.ps1 new file mode 100644 index 0000000..c057b52 --- /dev/null +++ b/performance/start-drop-detail-spike-test.ps1 @@ -0,0 +1,202 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Before', 'Query', 'After', 'LiveStock')] + [string]$Phase, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, [long]::MaxValue)] + [long]$DropId, + + [ValidateRange(1, 1000000)] + [int]$NormalRps = 100, + + [ValidateRange(1, 1000000)] + [int]$SpikeRps = 1000, + + [ValidateRange(1, 1000000)] + [int]$PreAllocatedVUs = 200, + + [ValidateRange(1, 1000000)] + [int]$MaxVUs = 2000, + + [ValidateRange(0, [int]::MaxValue)] + [int]$StockQuantity = 100 +) + +$ErrorActionPreference = 'Stop' + +if ($SpikeRps -lt $NormalRps) { + throw 'SpikeRps must be greater than or equal to NormalRps.' +} + +if ($MaxVUs -lt $PreAllocatedVUs) { + throw 'MaxVUs must be greater than or equal to PreAllocatedVUs.' +} + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDirectory = Split-Path -Parent $performanceDirectory +$environmentFile = Join-Path $projectDirectory '.env.performance.local' +$composeFile = Join-Path $performanceDirectory 'compose.performance.yml' +$k6Directory = Join-Path $performanceDirectory 'k6' +$resultDirectory = Join-Path $performanceDirectory 'results\raw' +$testRun = "drop-detail-spike-$($Phase.ToLower())" +$containerName = "dropit-k6-$testRun" + +if (-not (Test-Path -LiteralPath $environmentFile)) { + throw '.env.performance.local is missing.' +} + +$settings = @{} +Get-Content -LiteralPath $environmentFile | ForEach-Object { + $line = $_.Trim() + + if ($line -and -not $line.StartsWith('#')) { + $parts = $line -split '=', 2 + + if ($parts.Count -eq 2) { + $settings[$parts[0].Trim()] = $parts[1].Trim() + } + } +} + +$healthCheckUrl = $settings['HEALTH_CHECK_URL'] +$baseUrl = $settings['BASE_URL'] +$authEmail = $settings['AUTH_EMAIL'] +$authPassword = $settings['AUTH_PASSWORD'] +$authUsername = $settings['AUTH_USERNAME'] + +if (-not $healthCheckUrl -or -not $baseUrl -or + -not $authEmail -or -not $authPassword -or -not $authUsername) { + throw 'Required performance environment values are missing.' +} + +$loginBody = @{ + email = $authEmail + password = $authPassword +} | ConvertTo-Json + +try { + $tokenResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $loginBody +} catch { + $signupBody = @{ + email = $authEmail + password = $authPassword + username = $authUsername + role = 'USER' + } | ConvertTo-Json + + Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/signup" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $signupBody | Out-Null + + $tokenResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $loginBody +} + +$authToken = $tokenResponse.accessToken + +if (-not $authToken) { + throw 'Access token was not returned by the login API.' +} + +$redisContainer = docker ps --filter 'name=redis' --format '{{.Names}}' | Select-Object -First 1 +if (-not $redisContainer) { + throw 'Redis container is not running.' +} + +docker exec $redisContainer redis-cli SET "{drop:$DropId}:stock" $StockQuantity | Out-Null +if ($LASTEXITCODE -ne 0) { + throw 'The Drop stock key could not be prepared.' +} + +try { + $dropResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/drops/$DropId" ` + -Headers @{ Authorization = "Bearer $authToken" } ` + -Method Get + + if ([long]$dropResponse.id -ne $DropId) { + throw 'The returned Drop ID does not match the requested Drop ID.' + } +} catch { + throw "Drop $DropId cannot be tested. Cause: $($_.Exception.Message)" +} + +New-Item -ItemType Directory -Path $resultDirectory -Force | Out-Null + +docker compose ` + --file $composeFile ` + --env-file $environmentFile ` + up --detach influxdb grafana | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw 'InfluxDB or Grafana could not be started.' +} + +$grafanaReady = $false +for ($attempt = 1; $attempt -le 30; $attempt++) { + curl.exe --silent --fail --output NUL 'http://localhost:3000/api/health' + + if ($LASTEXITCODE -eq 0) { + $grafanaReady = $true + break + } + + Start-Sleep -Seconds 1 +} + +if (-not $grafanaReady) { + throw 'Grafana did not become ready within 30 seconds.' +} + +$existingContainer = docker ps -a --filter "name=^/$containerName$" --format '{{.Names}}' +if ($existingContainer) { + docker rm --force $containerName | Out-Null +} + +$dockerArguments = @( + 'run', '--rm', + '--name', $containerName, + '--network', 'dropit-performance_default', + '--volume', "${k6Directory}:/scripts:ro", + '--volume', "${resultDirectory}:/results", + 'grafana/k6:latest', + 'run', + '--out', 'influxdb=http://influxdb:8086/k6', + '--env', "BASE_URL=$baseUrl", + '--env', "TEST_RUN=$testRun", + '--env', "AUTH_TOKEN=$authToken", + '--env', "DROP_ID=$DropId", + '--env', "NORMAL_RPS=$NormalRps", + '--env', "SPIKE_RPS=$SpikeRps", + '--env', "PRE_ALLOCATED_VUS=$PreAllocatedVUs", + '--env', "MAX_VUS=$MaxVUs", + '--tag', "test_run=$testRun", + '/scripts/drop-detail-spike.js' +) + +Write-Host "Running $testRun against Drop $DropId." +Write-Host "Traffic: $NormalRps RPS to $SpikeRps RPS." +docker @dockerArguments + +if ($LASTEXITCODE -ne 0) { + $resultFile = Join-Path $resultDirectory "$testRun.json" + + if (-not (Test-Path -LiteralPath $resultFile)) { + throw 'k6 failed before producing a result file.' + } + + Write-Warning 'The test completed, but one or more performance thresholds were exceeded.' +} + +Write-Host "Result: $resultDirectory\$testRun.json" +Write-Host 'Grafana: http://localhost:3000' diff --git a/performance/start-drop-index-test.ps1 b/performance/start-drop-index-test.ps1 new file mode 100644 index 0000000..7be6cec --- /dev/null +++ b/performance/start-drop-index-test.ps1 @@ -0,0 +1,151 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Before', 'After', 'Optimized')] + [string]$Phase +) + +$ErrorActionPreference = 'Stop' + +$performanceDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDirectory = Split-Path -Parent $performanceDirectory +$environmentFile = Join-Path $projectDirectory '.env.performance.local' +$composeFile = Join-Path $performanceDirectory 'compose.performance.yml' +$scriptDirectory = Join-Path $performanceDirectory 'k6' +$rawResultDirectory = Join-Path $performanceDirectory 'results\raw' +$containerName = "dropit-k6-drop-index-$($Phase.ToLower())" +$testRun = "drop-million-$($Phase.ToLower())" +$grafanaUrl = 'http://localhost:3000/d/drop-million-comparison/drop-million-before-index-optimized?orgId=1&from=now-24h&to=now' + +if (-not (Test-Path -LiteralPath $environmentFile)) { + throw '.env.performance.local is missing.' +} + +$settings = @{} +Get-Content -LiteralPath $environmentFile | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $parts = $line -split '=', 2 + if ($parts.Count -eq 2) { + $settings[$parts[0].Trim()] = $parts[1].Trim() + } + } +} + +$healthCheckUrl = $settings['HEALTH_CHECK_URL'] +$baseUrl = $settings['BASE_URL'] +$authEmail = $settings['AUTH_EMAIL'] +$authPassword = $settings['AUTH_PASSWORD'] +$authUsername = $settings['AUTH_USERNAME'] + +if (-not $healthCheckUrl -or -not $baseUrl -or + -not $authEmail -or -not $authPassword -or -not $authUsername) { + throw 'HEALTH_CHECK_URL, BASE_URL, AUTH_EMAIL, AUTH_PASSWORD, and AUTH_USERNAME are required.' +} + +$dropListUrl = "$healthCheckUrl/drops?status=OPEN&sortType=CLOSING_SOON&page=0&size=20" +$loginBody = @{ + email = $authEmail + password = $authPassword +} | ConvertTo-Json + +try { + $tokenResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $loginBody +} catch { + $signupBody = @{ + email = $authEmail + password = $authPassword + username = $authUsername + role = 'USER' + } | ConvertTo-Json + + Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/signup" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $signupBody | Out-Null + + $tokenResponse = Invoke-RestMethod ` + -Uri "$healthCheckUrl/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body $loginBody +} + +$authToken = $tokenResponse.accessToken + +if (-not $authToken) { + throw 'Access token was not returned by the login API.' +} + +try { + $response = Invoke-WebRequest ` + -Uri $dropListUrl ` + -Headers @{ Authorization = "Bearer $authToken" } ` + -TimeoutSec 10 ` + -UseBasicParsing + if ($response.StatusCode -ne 200) { + throw "Drop API returned HTTP $($response.StatusCode)." + } +} catch { + throw "Cannot reach the Spring server or Drop API. Cause: $($_.Exception.Message)" +} + +New-Item -ItemType Directory -Path $rawResultDirectory -Force | Out-Null + +docker compose ` + --file $composeFile ` + --env-file $environmentFile ` + up --detach influxdb grafana | Out-Null + +$grafanaReady = $false +for ($attempt = 1; $attempt -le 30; $attempt++) { + curl.exe --silent --fail --output NUL 'http://localhost:3000/api/health' + if ($LASTEXITCODE -eq 0) { + $grafanaReady = $true + break + } + Start-Sleep -Seconds 1 +} + +if (-not $grafanaReady) { + throw 'Grafana did not become ready within 30 seconds.' +} + +$existingContainer = docker ps -a --filter "name=^/$containerName$" --format '{{.Names}}' +if ($existingContainer) { + docker rm --force $containerName | Out-Null +} + +$dockerArguments = @( + 'run', '--rm', + '--name', $containerName, + '--network', 'dropit-performance_default', + '--volume', "${scriptDirectory}:/scripts:ro", + '--volume', "${rawResultDirectory}:/results", + '--env', "BASE_URL=$baseUrl", + '--env', "TEST_RUN=$testRun", + '--env', "AUTH_TOKEN=$authToken", + 'grafana/k6:latest', + 'run', + '--out', 'influxdb=http://influxdb:8086/k6', + '--tag', "test_run=$testRun", + '/scripts/drop-list-index.js' +) + +Write-Host "Running $testRun with 10 to 200 requests per second." +docker @dockerArguments + +if ($LASTEXITCODE -ne 0) { + $resultFile = Join-Path $rawResultDirectory "$testRun.json" + if (-not (Test-Path -LiteralPath $resultFile)) { + throw "k6 $Phase test failed before producing a result file." + } + Write-Warning "k6 thresholds were exceeded. The measured result was still saved." +} + +Write-Host "Raw result: $rawResultDirectory\$testRun.json" +Write-Host "Dashboard: $grafanaUrl" diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..1319f90 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000..768bdbe Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000..55f8e88 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/com/.DS_Store b/src/main/java/com/.DS_Store new file mode 100644 index 0000000..61d40a3 Binary files /dev/null and b/src/main/java/com/.DS_Store differ diff --git a/src/main/java/com/dropit/.DS_Store b/src/main/java/com/dropit/.DS_Store new file mode 100644 index 0000000..f75f7ce Binary files /dev/null and b/src/main/java/com/dropit/.DS_Store differ diff --git a/src/main/java/com/dropit/DropitServerApplication.java b/src/main/java/com/dropit/DropitServerApplication.java index b10bd26..beac310 100644 --- a/src/main/java/com/dropit/DropitServerApplication.java +++ b/src/main/java/com/dropit/DropitServerApplication.java @@ -1,14 +1,17 @@ package com.dropit; import com.dropit.global.security.jwt.JwtProperties; +import com.dropit.order.messaging.SqsProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing -@EnableConfigurationProperties(JwtProperties.class) +@EnableScheduling +@EnableConfigurationProperties({JwtProperties.class, SqsProperties.class}) @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) public class DropitServerApplication { diff --git a/src/main/java/com/dropit/auth/controller/AuthController.java b/src/main/java/com/dropit/auth/controller/AuthController.java index 51c5909..76185ac 100644 --- a/src/main/java/com/dropit/auth/controller/AuthController.java +++ b/src/main/java/com/dropit/auth/controller/AuthController.java @@ -5,10 +5,14 @@ import com.dropit.auth.dto.request.SignupRequest; import com.dropit.auth.dto.response.TokenResponse; import com.dropit.auth.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.web.bind.annotation.*; @RestController @@ -17,6 +21,8 @@ public class AuthController { private final AuthService authService; + private final SecurityContextLogoutHandler logoutHandler = + new SecurityContextLogoutHandler(); @PostMapping("/signup") public ResponseEntity signup( @@ -46,4 +52,15 @@ public ResponseEntity reissue( authService.reissue(request) ); } + + @PostMapping("/logout") + public ResponseEntity logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) { + logoutHandler.logout(request, response, authentication); + + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/SignupRequest.java b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java index 7e5c49f..da8d1b6 100644 --- a/src/main/java/com/dropit/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java @@ -16,6 +16,8 @@ public record SignupRequest( @NotBlank(message = "사용자 이름은 필수입니다.") @Size(min = 1, max = 100, message = "사용자 이름은 1자 이상 100자 이하여야 합니다.") - String username + String username, + + SignupRole role ) { } \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/SignupRole.java b/src/main/java/com/dropit/auth/dto/request/SignupRole.java new file mode 100644 index 0000000..b958b28 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/SignupRole.java @@ -0,0 +1,15 @@ +package com.dropit.auth.dto.request; + +import com.dropit.user.entity.UserRole; + +public enum SignupRole { + USER, + SELLER; + + public UserRole toUserRole() { + return switch (this) { + case USER -> UserRole.USER; + case SELLER -> UserRole.SELLER; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/service/AuthService.java b/src/main/java/com/dropit/auth/service/AuthService.java index c4da670..bf80476 100644 --- a/src/main/java/com/dropit/auth/service/AuthService.java +++ b/src/main/java/com/dropit/auth/service/AuthService.java @@ -41,11 +41,15 @@ public void signup(SignupRequest request) { ); } + UserRole role = request.role() == null + ? UserRole.USER + : request.role().toUserRole(); + User user = new User( request.email(), passwordEncoder.encode(request.password()), request.username(), - UserRole.USER + role ); userRepository.save(user); diff --git a/src/main/java/com/dropit/drop/cache/DropDetailCacheReader.java b/src/main/java/com/dropit/drop/cache/DropDetailCacheReader.java new file mode 100644 index 0000000..9bfdd18 --- /dev/null +++ b/src/main/java/com/dropit/drop/cache/DropDetailCacheReader.java @@ -0,0 +1,29 @@ +package com.dropit.drop.cache; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.config.RedisCacheConfig; +import com.dropit.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DropDetailCacheReader { + + private final DropRepository dropRepository; + + @Cacheable(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId", sync = true) + public DropDetailCacheValue get(Long dropId) { + Drop drop = dropRepository.findDetailById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!drop.isVisible()) { + throw new ServiceException(DropErrorCode.DROP_NOT_FOUND); + } + + return DropDetailCacheValue.from(drop); + } +} diff --git a/src/main/java/com/dropit/drop/cache/DropDetailCacheValue.java b/src/main/java/com/dropit/drop/cache/DropDetailCacheValue.java new file mode 100644 index 0000000..903ea12 --- /dev/null +++ b/src/main/java/com/dropit/drop/cache/DropDetailCacheValue.java @@ -0,0 +1,42 @@ +package com.dropit.drop.cache; + +import com.dropit.drop.entity.Drop; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Drop 상세 조회에서 반복 사용되는 메타데이터 캐시 값입니다. + * 주문마다 변경되는 재고와 재고 기반 상태는 포함하지 않습니다. + */ +public record DropDetailCacheValue( + Long id, + Long sellerId, + String sellerName, + Long productId, + String productName, + String imageUrl, + BigDecimal price, + int discountRate, + int initialQuantity, + int purchaseLimit, + LocalDateTime openAt, + LocalDateTime closeAt +) { + public static DropDetailCacheValue from(Drop drop) { + return new DropDetailCacheValue( + drop.getId(), + drop.getProduct().getSeller().getId(), + drop.getProduct().getSeller().getUsername(), + drop.getProduct().getId(), + drop.getProduct().getName(), + drop.getProduct().getImageUrl(), + drop.getPrice(), + drop.getDiscountRate(), + drop.getInitialQuantity(), + drop.getPurchaseLimit(), + drop.getOpenAt(), + drop.getCloseAt() + ); + } +} diff --git a/src/main/java/com/dropit/drop/cache/DropSaleCacheWriter.java b/src/main/java/com/dropit/drop/cache/DropSaleCacheWriter.java new file mode 100644 index 0000000..90fb3e8 --- /dev/null +++ b/src/main/java/com/dropit/drop/cache/DropSaleCacheWriter.java @@ -0,0 +1,52 @@ +package com.dropit.drop.cache; + +import com.dropit.order.redis.RedisOrderKeyFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DropSaleCacheWriter { + + private static final DefaultRedisScript SCRIPT = script(); + private final StringRedisTemplate redisTemplate; + + public DropSaleCacheWriter(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void apply(DropSaleSnapshot snapshot) { + Long result = redisTemplate.execute( + SCRIPT, + List.of( + RedisOrderKeyFactory.saleKey(snapshot.dropId()), + RedisOrderKeyFactory.stockKey(snapshot.dropId()), + RedisOrderKeyFactory.admissionControlKey(snapshot.dropId()) + ), + Long.toString(snapshot.operationVersion()), + Long.toString(snapshot.saleVersion()), + Boolean.toString(snapshot.visible()), + Long.toString(snapshot.openAtEpochMillis()), + Long.toString(snapshot.closeAtEpochMillis()), + snapshot.unitPrice().toPlainString(), + Integer.toString(snapshot.discountRate()), + Integer.toString(snapshot.purchaseLimit()), + snapshot.productName(), + Integer.toString(snapshot.remainingQuantity()), + Long.toString(snapshot.retentionUntilEpochMillis()) + ); + if (result == null || result < 0) { + throw new IllegalStateException("Redis 판매 준비 결과가 올바르지 않습니다."); + } + } + + private static DefaultRedisScript script() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/prepare-drop-sale.lua")); + script.setResultType(Long.class); + return script; + } +} diff --git a/src/main/java/com/dropit/drop/cache/DropSaleSnapshot.java b/src/main/java/com/dropit/drop/cache/DropSaleSnapshot.java new file mode 100644 index 0000000..9c2eeef --- /dev/null +++ b/src/main/java/com/dropit/drop/cache/DropSaleSnapshot.java @@ -0,0 +1,37 @@ +package com.dropit.drop.cache; + +import com.dropit.drop.entity.Drop; + +import java.math.BigDecimal; +import java.time.ZoneId; + +public record DropSaleSnapshot( + Long dropId, + boolean visible, + long openAtEpochMillis, + long closeAtEpochMillis, + BigDecimal unitPrice, + int discountRate, + int purchaseLimit, + String productName, + long saleVersion, + long operationVersion, + int remainingQuantity +) { + private static final ZoneId SALE_ZONE = ZoneId.of("Asia/Seoul"); + + public static DropSaleSnapshot from(Drop drop) { + return new DropSaleSnapshot( + drop.getId(), drop.isVisible(), + drop.getOpenAt().atZone(SALE_ZONE).toInstant().toEpochMilli(), + drop.getCloseAt().atZone(SALE_ZONE).toInstant().toEpochMilli(), + drop.getPrice(), drop.getDiscountRate(), drop.getPurchaseLimit(), + drop.getProduct().getName(), drop.getSaleVersion(), + drop.getAdmissionOperationVersion(), drop.getRemainingQuantity() + ); + } + + public long retentionUntilEpochMillis() { + return closeAtEpochMillis + 86_400_000L; + } +} diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 4dd4d01..df853db 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -6,7 +6,9 @@ import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; import com.dropit.drop.service.DropService; +import com.dropit.drop.service.DropVisibilityService; import com.dropit.global.security.principal.CurrentUserId; +import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -23,6 +25,7 @@ public class DropController { private final DropService dropService; + private final DropVisibilityService dropVisibilityService; @PostMapping("/drops") public ResponseEntity create( @@ -36,6 +39,7 @@ public ResponseEntity create( .body(dropId); } + @PermitAll @GetMapping("/drops") public ResponseEntity> getAll( @ModelAttribute DropSearchCondition condition, @@ -46,6 +50,7 @@ public ResponseEntity> getAll( return ResponseEntity.ok(response); } + @PermitAll @GetMapping("/drops/{dropId}") public ResponseEntity getOne( @PathVariable Long dropId @@ -102,7 +107,7 @@ public ResponseEntity changeVisibility( @PathVariable Long dropId, @Valid @RequestBody DropVisibilityUpdateRequest request ) { - return ResponseEntity.ok(dropService.changeVisibility(sellerId, dropId, request)); + return ResponseEntity.ok(dropVisibilityService.changeVisibility(sellerId, dropId, request)); } @DeleteMapping("/drops/{dropId}") diff --git a/src/main/java/com/dropit/drop/dto/response/DropResponse.java b/src/main/java/com/dropit/drop/dto/response/DropResponse.java index 04a9833..4a0b06f 100644 --- a/src/main/java/com/dropit/drop/dto/response/DropResponse.java +++ b/src/main/java/com/dropit/drop/dto/response/DropResponse.java @@ -1,5 +1,6 @@ package com.dropit.drop.dto.response; +import com.dropit.drop.cache.DropDetailCacheValue; import com.dropit.drop.entity.Drop; import com.dropit.drop.entity.DropStatus; @@ -58,4 +59,35 @@ public static DropResponse from(Drop drop) { drop.currentStatus(LocalDateTime.now()) ); } + + public static DropResponse from( + DropDetailCacheValue detail, + int remainingQuantity, + boolean visible, + LocalDateTime now + ) { + return new DropResponse( + detail.id(), + detail.sellerId(), + detail.sellerName(), + detail.productId(), + detail.productName(), + detail.imageUrl(), + detail.price(), + detail.discountRate(), + detail.initialQuantity(), + remainingQuantity, + detail.initialQuantity() - remainingQuantity, + detail.purchaseLimit(), + visible, + detail.openAt(), + detail.closeAt(), + DropStatus.resolve( + detail.openAt(), + detail.closeAt(), + remainingQuantity, + now + ) + ); + } } diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index 35d05cf..d062c1e 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -14,7 +14,13 @@ @Getter @Entity -@Table(name = "drops") +@Table( + name = "drops", + indexes = @Index( + name = "idx_drops_public_closing", + columnList = "visible, close_at, id DESC, open_at, remaining_quantity, product_id" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Drop extends BaseEntity { @@ -50,6 +56,15 @@ public class Drop extends BaseEntity { @Column(name = "close_at", nullable = false) private LocalDateTime closeAt; + @Column(name = "sale_version", nullable = false) + private long saleVersion; + + @Column(name = "admission_operation_version", nullable = false) + private long admissionOperationVersion; + + @Column(name = "sale_prepared", nullable = false) + private boolean salePrepared; + public Drop( Product product, BigDecimal price, @@ -59,6 +74,7 @@ public Drop( LocalDateTime openAt, LocalDateTime closeAt ) { + validateSalesValues(price, initialQuantity, discountRate, purchaseLimit); validatePeriod(openAt, closeAt); this.product = product; @@ -72,12 +88,7 @@ public Drop( } public DropStatus currentStatus(LocalDateTime now) { - if (!now.isBefore(closeAt)) return DropStatus.CLOSED; - if (now.isBefore(openAt)) return DropStatus.READY; - if (remainingQuantity == 0) return DropStatus.SOLDOUT; - - // openAt <= now < closeAt - return DropStatus.OPEN; + return DropStatus.resolve(openAt, closeAt, remainingQuantity, now); } public void update( @@ -91,26 +102,54 @@ public void update( int updatedInitialQuantity = initialQuantity != null ? initialQuantity : this.initialQuantity; LocalDateTime updatedOpenAt = openAt != null ? openAt : this.openAt; LocalDateTime updatedCloseAt = closeAt != null ? closeAt : this.closeAt; + int updatedDiscountRate = discountRate != null ? discountRate : this.discountRate; + int updatedPurchaseLimit = purchaseLimit != null ? purchaseLimit : this.purchaseLimit; + BigDecimal updatedPrice = price != null ? price : this.price; + + validateSalesValues(updatedPrice, updatedInitialQuantity, updatedDiscountRate, updatedPurchaseLimit); validatePeriod(updatedOpenAt, updatedCloseAt); - if (this.remainingQuantity == this.initialQuantity) { - this.remainingQuantity = updatedInitialQuantity; + int soldQuantity = this.initialQuantity - this.remainingQuantity; + if (updatedInitialQuantity < soldQuantity) { + throw new ServiceException(DropErrorCode.INVALID_DROP_VALUE); } + this.remainingQuantity = updatedInitialQuantity - soldQuantity; this.initialQuantity = updatedInitialQuantity; - this.price = price != null ? price : this.price; - this.discountRate = discountRate != null ? discountRate : this.discountRate; - this.purchaseLimit = purchaseLimit != null ? purchaseLimit : this.purchaseLimit; + this.price = updatedPrice; + this.discountRate = updatedDiscountRate; + this.purchaseLimit = updatedPurchaseLimit; this.openAt = updatedOpenAt; this.closeAt = updatedCloseAt; } private static void validatePeriod(LocalDateTime openAt, LocalDateTime closeAt) { - if (!openAt.isBefore(closeAt)) { + if (openAt == null || closeAt == null || !openAt.isBefore(closeAt)) { throw new ServiceException(DropErrorCode.INVALID_DROP_PERIOD); } } + private static void validateSalesValues( + BigDecimal price, + int initialQuantity, + int discountRate, + int purchaseLimit + ) { + if (price == null + || price.signum() <= 0 + || price.scale() > 0 + || price.precision() - price.scale() > 13 + || initialQuantity <= 0 + || discountRate < 0 + || discountRate > 100 + || purchaseLimit < 0) { + throw new ServiceException(DropErrorCode.INVALID_DROP_VALUE); + } + } + public void ensureEditable(LocalDateTime now) { + if (salePrepared) { + throw new ServiceException(DropErrorCode.DROP_SALE_PREPARED); + } if (!now.isBefore(openAt)) { throw new ServiceException(DropErrorCode.DROP_ALREADY_STARTED); } @@ -118,9 +157,26 @@ public void ensureEditable(LocalDateTime now) { public void changeVisibility(boolean visible) { this.visible = visible; + this.admissionOperationVersion++; + } + + public void prepareSale() { + if (salePrepared) { + return; + } + this.salePrepared = true; + this.saleVersion++; + this.admissionOperationVersion++; + } + + public void restoreStock(int quantity) { + this.remainingQuantity += quantity; } public void decreaseStock(int quantity, LocalDateTime now) { + if (quantity <= 0) { + throw new ServiceException(DropErrorCode.INVALID_DROP_VALUE); + } if (!visible || currentStatus(now) != DropStatus.OPEN) { throw new ServiceException(DropErrorCode.DROP_NOT_OPEN); } @@ -131,7 +187,17 @@ public void decreaseStock(int quantity, LocalDateTime now) { this.remainingQuantity -= quantity; } - public void restoreStock(int quantity) { - this.remainingQuantity += quantity; + public void decreaseStockForFinalization(int quantity) { + validateStockForFinalization(quantity); + this.remainingQuantity -= quantity; + } + + public void validateStockForFinalization(int quantity) { + if (quantity <= 0) { + throw new ServiceException(DropErrorCode.INVALID_DROP_VALUE); + } + if (remainingQuantity < quantity) { + throw new ServiceException(DropErrorCode.INSUFFICIENT_STOCK); + } } } diff --git a/src/main/java/com/dropit/drop/entity/DropStatus.java b/src/main/java/com/dropit/drop/entity/DropStatus.java index cb5b022..72528cc 100644 --- a/src/main/java/com/dropit/drop/entity/DropStatus.java +++ b/src/main/java/com/dropit/drop/entity/DropStatus.java @@ -1,8 +1,22 @@ package com.dropit.drop.entity; +import java.time.LocalDateTime; + public enum DropStatus { READY, OPEN, SOLDOUT, - CLOSED + CLOSED; + + public static DropStatus resolve( + LocalDateTime openAt, + LocalDateTime closeAt, + int remainingQuantity, + LocalDateTime now + ) { + if (!now.isBefore(closeAt)) return CLOSED; + if (now.isBefore(openAt)) return READY; + if (remainingQuantity == 0) return SOLDOUT; + return OPEN; + } } diff --git a/src/main/java/com/dropit/drop/exception/DropErrorCode.java b/src/main/java/com/dropit/drop/exception/DropErrorCode.java index f0f4e45..f794d0e 100644 --- a/src/main/java/com/dropit/drop/exception/DropErrorCode.java +++ b/src/main/java/com/dropit/drop/exception/DropErrorCode.java @@ -11,7 +11,10 @@ public enum DropErrorCode implements ErrorCode { DROP_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "해당 드랍은 판매자만 관리할 수 있습니다."), DROP_NOT_OPEN(HttpStatus.CONFLICT, "현재 주문할 수 없는 드랍입니다."), INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "드랍 재고가 부족합니다."), + INVALID_DROP_VALUE(HttpStatus.BAD_REQUEST, "드랍 판매 값이 올바르지 않습니다."), INVALID_DROP_PERIOD(HttpStatus.BAD_REQUEST, "판매 종료 시각은 시작 시각보다 늦어야 합니다."), + DROP_SALE_PREPARED(HttpStatus.CONFLICT, "판매 준비가 완료되어 판매 설정을 변경할 수 없습니다."), + DROP_ADMISSION_NOT_READY(HttpStatus.SERVICE_UNAVAILABLE, "주문 접수용 판매 상태를 준비할 수 없습니다."), DROP_ALREADY_STARTED(HttpStatus.CONFLICT, "이미 판매가 시작된 드랍입니다."); private final HttpStatus status; diff --git a/src/main/java/com/dropit/drop/repository/DropLiveState.java b/src/main/java/com/dropit/drop/repository/DropLiveState.java new file mode 100644 index 0000000..0518c50 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropLiveState.java @@ -0,0 +1,8 @@ +package com.dropit.drop.repository; + +public interface DropLiveState { + + int getRemainingQuantity(); + + boolean isVisible(); +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java index 828f9d2..998b7e2 100644 --- a/src/main/java/com/dropit/drop/repository/DropRepository.java +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -3,10 +3,40 @@ import com.dropit.drop.entity.Drop; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.LockModeType; + +import java.util.Optional; public interface DropRepository extends JpaRepository, DropRepositoryCustom { + @Transactional(readOnly = true) + @Query(""" + select d + from Drop d + join fetch d.product p + join fetch p.seller + where d.id = :dropId + """) + Optional findDetailById(@Param("dropId") Long dropId); + + @Transactional(readOnly = true) + @Query(""" + select d.remainingQuantity as remainingQuantity, + d.visible as visible + from Drop d + where d.id = :dropId + """) + Optional findLiveStateById(@Param("dropId") Long dropId); + + boolean existsByProductId(Long productId); + Page findAllByVisibleTrue(Pageable pageable); Page findAllByProductSellerIdAndVisibleTrue( @@ -18,4 +48,21 @@ Page findAllByProductSellerId( Long sellerId, Pageable pageable ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select d from Drop d where d.id = :dropId") + Optional findByIdForUpdate(@Param("dropId") Long dropId); + + @Modifying(flushAutomatically = true) + @Query(""" + update Drop d + set d.remainingQuantity = d.remainingQuantity - :quantity + where d.id = :dropId + and :quantity > 0 + and d.remainingQuantity >= :quantity + """) + int decreaseStockIfAvailable( + @Param("dropId") Long dropId, + @Param("quantity") int quantity + ); } diff --git a/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java index a410b50..0a9be9e 100644 --- a/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java +++ b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java @@ -7,6 +7,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.jpa.impl.JPAQuery; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -31,10 +32,13 @@ public Page searchPublicDrops( ) { LocalDateTime now = LocalDateTime.now(); - List content = queryFactory - .selectFrom(drop) - .join(drop.product, product).fetchJoin() - .join(product.seller, user).fetchJoin() + // 먼저 페이지에 포함할 ID만 조회해 대량 fetch join 이후 정렬을 피한다. + JPAQuery idQuery = queryFactory.select(drop.id).from(drop); + boolean hasKeyword = condition.keyword() != null && !condition.keyword().isBlank(); + if (hasKeyword) { + idQuery.join(drop.product, product).join(product.seller, user); + } + List ids = idQuery .where( drop.visible.isTrue(), keywordContains(condition.keyword()), @@ -45,11 +49,23 @@ public Page searchPublicDrops( .limit(pageable.getPageSize()) .fetch(); - Long total = queryFactory + List content = ids.isEmpty() ? List.of() : queryFactory + .selectFrom(drop) + .join(drop.product, product).fetchJoin() + .join(product.seller, user).fetchJoin() + .where(drop.id.in(ids)) + .orderBy(orderSpecifiers(condition.sortType())) + .fetch(); + + // 검색어가 없으면 필터에 필요한 컬럼이 모두 Drop에 있다. + // 필수 FK 관계이므로 count에서 상품/판매자 조인을 생략할 수 있다. + JPAQuery countQuery = queryFactory .select(drop.count()) - .from(drop) - .join(drop.product, product) - .join(product.seller, user) + .from(drop); + if (hasKeyword) { + countQuery.join(drop.product, product).join(product.seller, user); + } + Long total = countQuery .where( drop.visible.isTrue(), keywordContains(condition.keyword()), diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index fc4f494..71ba156 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -1,18 +1,22 @@ package com.dropit.drop.service; +import com.dropit.drop.cache.DropDetailCacheReader; +import com.dropit.drop.cache.DropDetailCacheValue; import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.dto.request.DropUpdateRequest; -import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; import com.dropit.drop.entity.Drop; import com.dropit.drop.exception.DropErrorCode; import com.dropit.drop.repository.DropRepository; +import com.dropit.drop.repository.DropLiveState; +import com.dropit.global.config.RedisCacheConfig; import com.dropit.global.exception.ServiceException; import com.dropit.product.entity.Product; import com.dropit.product.exception.ProductErrorCode; import com.dropit.product.repository.ProductRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -27,10 +31,11 @@ public class DropService { private final DropRepository dropRepository; private final ProductRepository productRepository; + private final DropDetailCacheReader dropDetailCacheReader; @Transactional public Long save(Long userId, DropCreateRequest request) { - Product product = productRepository.findById(request.productId()) + Product product = productRepository.findByIdForUpdate(request.productId()) .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); if (!Objects.equals(product.getSeller().getId(), userId)) { @@ -62,16 +67,21 @@ public Page getAll( return drops.map(DropResponse::from); } - @Transactional(readOnly = true) public DropResponse getOne(Long dropId) { - Drop drop = dropRepository.findById(dropId) + DropDetailCacheValue detail = dropDetailCacheReader.get(dropId); + DropLiveState liveState = dropRepository.findLiveStateById(dropId) .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); - if (!drop.isVisible()) { + if (!liveState.isVisible()) { throw new ServiceException(DropErrorCode.DROP_NOT_FOUND); } - return DropResponse.from(drop); + return DropResponse.from( + detail, + liveState.getRemainingQuantity(), + liveState.isVisible(), + LocalDateTime.now() + ); } @Transactional(readOnly = true) @@ -101,6 +111,7 @@ public Page getDropsForSellerManagement( } @Transactional + @CacheEvict(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId") public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request) { Drop drop = dropRepository.findById(dropId) .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); @@ -124,20 +135,7 @@ public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request } @Transactional - public DropResponse changeVisibility(Long sellerId, Long dropId, DropVisibilityUpdateRequest request) { - Drop drop = dropRepository.findById(dropId) - .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); - - if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { - throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); - } - - drop.changeVisibility(request.visible()); - - return DropResponse.from(drop); - } - - @Transactional + @CacheEvict(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId") public void delete(Long sellerId, Long dropId) { Drop drop = dropRepository.findById(dropId) .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); diff --git a/src/main/java/com/dropit/drop/service/DropVisibilityService.java b/src/main/java/com/dropit/drop/service/DropVisibilityService.java new file mode 100644 index 0000000..665d92f --- /dev/null +++ b/src/main/java/com/dropit/drop/service/DropVisibilityService.java @@ -0,0 +1,65 @@ +package com.dropit.drop.service; + +import com.dropit.drop.cache.DropSaleCacheWriter; +import com.dropit.drop.cache.DropSaleSnapshot; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.config.RedisCacheConfig; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class DropVisibilityService { + + private final DropRepository dropRepository; + private final OrderItemRepository orderItemRepository; + private final DropUserPurchaseRepository purchaseRepository; + private final DropSaleCacheWriter cacheWriter; + private final TransactionTemplate transactionTemplate; + + @CacheEvict(cacheNames = RedisCacheConfig.DROP_DETAIL_CACHE, key = "#dropId", beforeInvocation = true) + public DropResponse changeVisibility(Long sellerId, Long dropId, DropVisibilityUpdateRequest request) { + ChangeResult result = transactionTemplate.execute(status -> changeInTransaction(sellerId, dropId, request)); + if (result == null) { + throw new ServiceException(DropErrorCode.DROP_ADMISSION_NOT_READY); + } + try { + cacheWriter.apply(result.snapshot()); + } catch (RuntimeException exception) { + throw new ServiceException(DropErrorCode.DROP_ADMISSION_NOT_READY, exception); + } + return result.response(); + } + + private ChangeResult changeInTransaction(Long sellerId, Long dropId, DropVisibilityUpdateRequest request) { + Drop drop = dropRepository.findByIdForUpdate(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + if (request.visible() && !drop.isSalePrepared()) { + drop.ensureEditable(LocalDateTime.now()); + if (orderItemRepository.existsByDropId(dropId) || purchaseRepository.existsByDropId(dropId)) { + throw new ServiceException(DropErrorCode.DROP_SALE_PREPARED); + } + drop.prepareSale(); + } + drop.changeVisibility(request.visible()); + return new ChangeResult(DropResponse.from(drop), DropSaleSnapshot.from(drop)); + } + + private record ChangeResult(DropResponse response, DropSaleSnapshot snapshot) { + } +} diff --git a/src/main/java/com/dropit/global/config/FailOpenCacheErrorHandler.java b/src/main/java/com/dropit/global/config/FailOpenCacheErrorHandler.java new file mode 100644 index 0000000..3fba924 --- /dev/null +++ b/src/main/java/com/dropit/global/config/FailOpenCacheErrorHandler.java @@ -0,0 +1,29 @@ +package com.dropit.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +@Slf4j +public class FailOpenCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("캐시 조회 실패로 원본 저장소를 사용합니다. cache={}, key={}", cache.getName(), key, exception); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("캐시 저장에 실패했습니다. cache={}, key={}", cache.getName(), key, exception); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("캐시 삭제에 실패했습니다. cache={}, key={}", cache.getName(), key, exception); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("캐시 초기화에 실패했습니다. cache={}", cache.getName(), exception); + } +} diff --git a/src/main/java/com/dropit/global/config/RedisCacheConfig.java b/src/main/java/com/dropit/global/config/RedisCacheConfig.java new file mode 100644 index 0000000..81cdedc --- /dev/null +++ b/src/main/java/com/dropit/global/config/RedisCacheConfig.java @@ -0,0 +1,109 @@ +package com.dropit.global.config; + +import org.springframework.cache.Cache; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.transaction.TransactionAwareCacheDecorator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.CacheStatisticsCollector; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + + public static final String DROP_DETAIL_CACHE = "dropDetail"; + public static final Duration DROP_DETAIL_TTL = Duration.ofSeconds(3); + + @Bean + public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration defaultConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .disableCachingNullValues() + .computePrefixWith(cacheName -> "dropit::" + cacheName + "::") + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + RedisSerializer.string() + )) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + RedisSerializer.json() + )); + RedisCacheConfiguration dropDetailConfiguration = defaultConfiguration.entryTtl(DROP_DETAIL_TTL); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory) + .withStatisticsCollector(CacheStatisticsCollector.create()); + + return new FailOpenRedisCacheManager( + cacheWriter, + defaultConfiguration, + Map.of(DROP_DETAIL_CACHE, dropDetailConfiguration), + errorHandler() + ); + } + + @Override + public CacheErrorHandler errorHandler() { + return new FailOpenCacheErrorHandler(); + } + + private static final class FailOpenRedisCacheManager extends RedisCacheManager { + + private final CacheErrorHandler errorHandler; + + private FailOpenRedisCacheManager( + RedisCacheWriter cacheWriter, + RedisCacheConfiguration defaultConfiguration, + Map cacheConfigurations, + CacheErrorHandler errorHandler + ) { + super(cacheWriter, defaultConfiguration, false, cacheConfigurations); + this.errorHandler = errorHandler; + } + + @Override + protected Cache decorateCache(Cache cache) { + return new FailOpenTransactionAwareCacheDecorator(cache, errorHandler); + } + } + + private static final class FailOpenTransactionAwareCacheDecorator extends TransactionAwareCacheDecorator { + + private final CacheErrorHandler errorHandler; + + private FailOpenTransactionAwareCacheDecorator(Cache targetCache, CacheErrorHandler errorHandler) { + super(targetCache); + this.errorHandler = errorHandler; + } + + @Override + public void evict(Object key) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + evictNow(key); + } + }); + return; + } + evictNow(key); + } + + private void evictNow(Object key) { + try { + getTargetCache().evict(key); + } catch (RuntimeException exception) { + errorHandler.handleCacheEvictError(exception, getTargetCache(), key); + } + } + } +} diff --git a/src/main/java/com/dropit/global/documentation/SwaggerUiSettingsController.java b/src/main/java/com/dropit/global/documentation/SwaggerUiSettingsController.java new file mode 100644 index 0000000..71ce740 --- /dev/null +++ b/src/main/java/com/dropit/global/documentation/SwaggerUiSettingsController.java @@ -0,0 +1,20 @@ +package com.dropit.global.documentation; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SwaggerUiSettingsController { + + private final boolean readOnly; + + public SwaggerUiSettingsController(@Value("${docs.swagger-ui.read-only:false}") boolean readOnly) { + this.readOnly = readOnly; + } + + @GetMapping(value = "/swagger-ui/settings.js", produces = "application/javascript") + public String settings() { + return "window.swaggerUiConfig = " + (readOnly ? "{ supportedSubmitMethods: [] }" : "{}") + ";"; + } +} diff --git a/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java index 1b48e0d..2512e6e 100644 --- a/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java @@ -1,5 +1,9 @@ package com.dropit.global.exception; +import com.dropit.order.dto.response.OrderPublicationErrorResponse; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.service.OrderPublicationUnconfirmedException; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -12,6 +16,19 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(OrderPublicationUnconfirmedException.class) + public ResponseEntity handleOrderPublicationUnconfirmed( + OrderPublicationUnconfirmedException exception + ) { + OrderErrorCode errorCode = OrderErrorCode.ORDER_PUBLICATION_UNCONFIRMED; + return ResponseEntity + .status(errorCode.status()) + .header(HttpHeaders.LOCATION, "/order-requests/" + exception.getRequestId()) + .body(new OrderPublicationErrorResponse( + errorCode.code(), errorCode.message(), exception.getRequestId() + )); + } + @ExceptionHandler(ServiceException.class) public ResponseEntity handleServiceException(ServiceException e) { ErrorCode errorCode = e.getErrorCode(); diff --git a/src/main/java/com/dropit/global/exception/ServiceException.java b/src/main/java/com/dropit/global/exception/ServiceException.java index 3974e1e..c68fab4 100644 --- a/src/main/java/com/dropit/global/exception/ServiceException.java +++ b/src/main/java/com/dropit/global/exception/ServiceException.java @@ -11,4 +11,9 @@ public ServiceException(ErrorCode errorCode) { super(errorCode.message()); this.errorCode = errorCode; } -} \ No newline at end of file + + public ServiceException(ErrorCode errorCode, Throwable cause) { + super(errorCode.message(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dropit/global/security/SecurityConfig.java b/src/main/java/com/dropit/global/security/SecurityConfig.java index d95a3d0..8cb224b 100644 --- a/src/main/java/com/dropit/global/security/SecurityConfig.java +++ b/src/main/java/com/dropit/global/security/SecurityConfig.java @@ -3,26 +3,45 @@ import com.dropit.global.security.jwt.JwtErrorCode; import com.dropit.global.security.jwt.JwtFilter; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StringUtils; @Configuration @RequiredArgsConstructor @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(jsr250Enabled = true) public class SecurityConfig { + public static final RequestMatcher PUBLIC_ENDPOINTS = new OrRequestMatcher( + PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/actuator/health"), + PathPatternRequestMatcher.pathPattern(HttpMethod.POST, "/auth/signup"), + PathPatternRequestMatcher.pathPattern(HttpMethod.POST, "/auth/login"), + PathPatternRequestMatcher.pathPattern(HttpMethod.POST, "/auth/reissue"), + PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/drops"), + PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/drops/{dropId}") + ); + private final JwtFilter jwtFilter; private final SecurityErrorResponseSender errorResponseSender; @@ -31,6 +50,43 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public UserDetailsService documentationUserDetailsService( + @Value("${docs.auth.username:}") String username, + @Value("${docs.auth.password:}") String password, + PasswordEncoder passwordEncoder + ) { + validateDocumentationCredentials(username, password); + + return new InMemoryUserDetailsManager( + User.withUsername(username) + .password(passwordEncoder.encode(password)) + .roles("DOCS") + .build() + ); + } + + @Bean + @Order(1) + public SecurityFilterChain documentationSecurityFilterChain( + HttpSecurity http, + UserDetailsService documentationUserDetailsService + ) throws Exception { + return http + .securityMatcher("/swagger-ui/**", "/openapi/**", "/webjars/**") + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .requestCache(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) + .logout(AbstractHttpConfigurer::disable) + .userDetailsService(documentationUserDetailsService) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .build(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http @@ -50,8 +106,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti errorResponseSender.send(response, JwtErrorCode.JWT_ACCESS_DENIED)) ) .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() - .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/auth/reissue").permitAll() + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() .anyRequest().authenticated() ) // JWT 인증이 익명 인증보다 먼저 처리되도록 순서 지정 @@ -59,6 +114,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .build(); } + private void validateDocumentationCredentials(String username, String password) { + if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) { + throw new IllegalStateException( + "Swagger UI Basic Auth credentials are required. " + + "Set DOCS_AUTH_USERNAME and DOCS_AUTH_PASSWORD." + ); + } + } + // JwtFilter가 Spring Security 체인에서만 실행되도록 자동 등록을 막는다. (서블릿 컨테이너 중복 등록 방지) @Bean public FilterRegistrationBean jwtFilterRegistration(JwtFilter filter) { @@ -67,4 +131,3 @@ public FilterRegistrationBean jwtFilterRegistration(JwtFilter filter) return registration; } } - diff --git a/src/main/java/com/dropit/global/security/jwt/JwtFilter.java b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java index c0a0296..b5d484b 100644 --- a/src/main/java/com/dropit/global/security/jwt/JwtFilter.java +++ b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java @@ -21,6 +21,8 @@ import java.io.IOException; import java.util.List; +import static com.dropit.global.security.SecurityConfig.PUBLIC_ENDPOINTS; + @Component @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { @@ -28,6 +30,11 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final SecurityErrorResponseSender errorResponseSender; + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return PUBLIC_ENDPOINTS.matches(request); + } + @Override protected void doFilterInternal( HttpServletRequest request, @@ -86,4 +93,3 @@ private UserRole parseRole(String roleClaim) { } } } - diff --git a/src/main/java/com/dropit/global/storage/S3ImageService.java b/src/main/java/com/dropit/global/storage/S3ImageService.java index 8308397..93ecf6f 100644 --- a/src/main/java/com/dropit/global/storage/S3ImageService.java +++ b/src/main/java/com/dropit/global/storage/S3ImageService.java @@ -2,6 +2,7 @@ import com.dropit.global.exception.ServiceException; import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,8 @@ public class S3ImageService { private static final Duration URL_EXPIRATION = Duration.ofMinutes(10); + private static final String IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + private static final Map ALLOWED_IMAGE_TYPES = Map.of( "image/jpeg", ".jpg", "image/png", ".png", @@ -33,6 +36,9 @@ public class S3ImageService { @Value("${spring.cloud.aws.s3.bucket}") private String bucket; + @Value("${app.cdn.domain}") + private String cdnDomain; + public String upload(MultipartFile file, String directory) { validate(file); @@ -47,6 +53,7 @@ public String upload(MultipartFile file, String directory) { try { ObjectMetadata metadata = ObjectMetadata.builder() .contentType(contentType) + .cacheControl(IMAGE_CACHE_CONTROL) .build(); s3Template.upload( @@ -82,6 +89,13 @@ public String createDownloadUrl(String key) { } } + public String createPublicUrl(String key) { + if (key == null) { + return null; + } + return "https://" + cdnDomain + "/" + key; + } + public void deleteQuietly(String key) { if (key == null) { return; @@ -116,4 +130,4 @@ private void validate(MultipartFile file) { ); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/dropit/notification/controller/NotificationController.java b/src/main/java/com/dropit/notification/controller/NotificationController.java new file mode 100644 index 0000000..4a76695 --- /dev/null +++ b/src/main/java/com/dropit/notification/controller/NotificationController.java @@ -0,0 +1,41 @@ +package com.dropit.notification.controller; + +import com.dropit.global.security.principal.CurrentUserId; +import com.dropit.notification.dto.response.NotificationResponse; +import com.dropit.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/me") + public ResponseEntity> getMine( + @CurrentUserId Long userId + ) { + return ResponseEntity.ok( + notificationService.getMine(userId) + ); + } + + @PatchMapping("/{notificationId}/read") + public ResponseEntity markAsRead( + @CurrentUserId Long userId, + @PathVariable Long notificationId + ) { + return ResponseEntity.ok( + notificationService.markAsRead(userId, notificationId) + ); + } +} diff --git a/src/main/java/com/dropit/notification/dto/response/NotificationResponse.java b/src/main/java/com/dropit/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..148770e --- /dev/null +++ b/src/main/java/com/dropit/notification/dto/response/NotificationResponse.java @@ -0,0 +1,24 @@ +package com.dropit.notification.dto.response; + +import com.dropit.notification.entity.Notification; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class NotificationResponse { + + private final Long id; + private final String title; + private final String message; + private final boolean read; + private final LocalDateTime createdAt; + + public NotificationResponse(Notification notification) { + this.id = notification.getId(); + this.title = notification.getTitle(); + this.message = notification.getMessage(); + this.read = notification.isRead(); + this.createdAt = notification.getCreatedAt(); + } +} diff --git a/src/main/java/com/dropit/notification/entity/Notification.java b/src/main/java/com/dropit/notification/entity/Notification.java new file mode 100644 index 0000000..7dc68b8 --- /dev/null +++ b/src/main/java/com/dropit/notification/entity/Notification.java @@ -0,0 +1,51 @@ +package com.dropit.notification.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, length = 500) + private String message; + + @Column(nullable = false) + private boolean read; + + public Notification(User user, String title, String message) { + this.user = user; + this.title = title; + this.message = message; + this.read = false; + } + + public void markAsRead() { + this.read = true; + } +} diff --git a/src/main/java/com/dropit/notification/exception/NotificationErrorCode.java b/src/main/java/com/dropit/notification/exception/NotificationErrorCode.java new file mode 100644 index 0000000..dc8aef4 --- /dev/null +++ b/src/main/java/com/dropit/notification/exception/NotificationErrorCode.java @@ -0,0 +1,25 @@ +package com.dropit.notification.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 알림입니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/notification/repository/NotificationRepository.java b/src/main/java/com/dropit/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..58e9caf --- /dev/null +++ b/src/main/java/com/dropit/notification/repository/NotificationRepository.java @@ -0,0 +1,14 @@ +package com.dropit.notification.repository; + +import com.dropit.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + + List findAllByUser_IdOrderByCreatedAtDesc(Long userId); + + Optional findByIdAndUser_Id(Long notificationId, Long userId); +} diff --git a/src/main/java/com/dropit/notification/service/NotificationService.java b/src/main/java/com/dropit/notification/service/NotificationService.java new file mode 100644 index 0000000..4487603 --- /dev/null +++ b/src/main/java/com/dropit/notification/service/NotificationService.java @@ -0,0 +1,78 @@ +package com.dropit.notification.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.notification.dto.response.NotificationResponse; +import com.dropit.notification.entity.Notification; +import com.dropit.notification.exception.NotificationErrorCode; +import com.dropit.notification.repository.NotificationRepository; +import com.dropit.user.entity.User; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + @Transactional + public NotificationResponse create( + Long userId, + String title, + String message + ) { + User user = findUser(userId); + + Notification notification = + new Notification(user, title, message); + + Notification savedNotification = + notificationRepository.save(notification); + + return new NotificationResponse(savedNotification); + } + + public List getMine(Long userId) { + findUser(userId); + + return notificationRepository + .findAllByUser_IdOrderByCreatedAtDesc(userId) + .stream() + .map(NotificationResponse::new) + .toList(); + } + + @Transactional + public NotificationResponse markAsRead( + Long userId, + Long notificationId + ) { + Notification notification = + notificationRepository + .findByIdAndUser_Id(notificationId, userId) + .orElseThrow(() -> + new ServiceException( + NotificationErrorCode.NOTIFICATION_NOT_FOUND + ) + ); + + notification.markAsRead(); + + return new NotificationResponse(notification); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> + new ServiceException( + NotificationErrorCode.USER_NOT_FOUND + ) + ); + } +} diff --git a/src/main/java/com/dropit/order/controller/OrderController.java b/src/main/java/com/dropit/order/controller/OrderController.java index 170cba6..0f44124 100644 --- a/src/main/java/com/dropit/order/controller/OrderController.java +++ b/src/main/java/com/dropit/order/controller/OrderController.java @@ -3,6 +3,12 @@ import com.dropit.global.security.principal.CurrentUserId; import com.dropit.order.dto.request.OrderCreateRequest; import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderAdmissionResponse; +import com.dropit.order.dto.response.OrderRequestStatusResponse; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.service.OrderAdmissionService; +import com.dropit.order.service.OrderRequestQueryService; +import com.dropit.order.service.OrderCancellationService; import com.dropit.order.dto.response.OrderSummaryResponse; import com.dropit.order.service.OrderService; import jakarta.validation.Valid; @@ -18,19 +24,33 @@ public class OrderController { private final OrderService orderService; + private final OrderAdmissionService orderAdmissionService; + private final OrderRequestQueryService orderRequestQueryService; + private final OrderCancellationService orderCancellationService; @PostMapping("/orders") - public ResponseEntity create( + public ResponseEntity create( @CurrentUserId Long userId, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, @Valid @RequestBody OrderCreateRequest request ) { - OrderResponse response = orderService.create(userId, request); + ReservedOrderSnapshot admitted = orderAdmissionService.admit(userId, idempotencyKey, request); + OrderAdmissionResponse response = new OrderAdmissionResponse(admitted.requestId()); return ResponseEntity - .created(URI.create("/orders/" + response.id())) + .accepted() + .location(URI.create("/order-requests/" + response.requestId())) .body(response); } + @GetMapping("/order-requests/{requestId}") + public ResponseEntity getOrderRequest( + @CurrentUserId Long userId, + @PathVariable java.util.UUID requestId + ) { + return ResponseEntity.ok(orderRequestQueryService.get(userId, requestId)); + } + @GetMapping("/orders/me") public ResponseEntity> getMyOrders( @CurrentUserId Long userId @@ -51,7 +71,7 @@ public ResponseEntity cancel( @CurrentUserId Long userId, @PathVariable Long orderId ) { - orderService.cancel(userId, orderId); + orderCancellationService.cancel(userId, orderId); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/dropit/order/dto/request/OrderFinalizationRequest.java b/src/main/java/com/dropit/order/dto/request/OrderFinalizationRequest.java new file mode 100644 index 0000000..ee2caa5 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/request/OrderFinalizationRequest.java @@ -0,0 +1,18 @@ +package com.dropit.order.dto.request; + +import java.math.BigDecimal; +import java.util.UUID; + +/** Consumer 내부 주문 확정 입력용 */ +public record OrderFinalizationRequest( + UUID requestId, + Long userId, + Long dropId, + String idempotencyKeyHash, + String payloadHash, + String productName, + int quantity, + BigDecimal unitPrice, + int discountRate +) { +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderAdmissionResponse.java b/src/main/java/com/dropit/order/dto/response/OrderAdmissionResponse.java new file mode 100644 index 0000000..f34a1fb --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderAdmissionResponse.java @@ -0,0 +1,6 @@ +package com.dropit.order.dto.response; + +import java.util.UUID; + +public record OrderAdmissionResponse(UUID requestId) { +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderPublicationErrorResponse.java b/src/main/java/com/dropit/order/dto/response/OrderPublicationErrorResponse.java new file mode 100644 index 0000000..cb87441 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderPublicationErrorResponse.java @@ -0,0 +1,6 @@ +package com.dropit.order.dto.response; + +import java.util.UUID; + +public record OrderPublicationErrorResponse(String code, String message, UUID requestId) { +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderRequestStatusResponse.java b/src/main/java/com/dropit/order/dto/response/OrderRequestStatusResponse.java new file mode 100644 index 0000000..0e213c3 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderRequestStatusResponse.java @@ -0,0 +1,15 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.OrderRequestStatus; +import com.dropit.order.entity.OrderStatus; + +import java.util.UUID; + +public record OrderRequestStatusResponse( + UUID requestId, + OrderRequestStatus status, + Long orderId, + OrderStatus orderStatus, + String failureCode +) { +} diff --git a/src/main/java/com/dropit/order/entity/DropUserPurchase.java b/src/main/java/com/dropit/order/entity/DropUserPurchase.java new file mode 100644 index 0000000..f512a98 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/DropUserPurchase.java @@ -0,0 +1,44 @@ +package com.dropit.order.entity; + +import com.dropit.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "drop_user_purchases", + uniqueConstraints = @UniqueConstraint( + name = "uk_drop_user_purchases_drop_user", + columnNames = {"drop_id", "user_id"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DropUserPurchase extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "drop_id", nullable = false) + private Long dropId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "confirmed_quantity", nullable = false) + private int confirmedQuantity; + + public DropUserPurchase(Long dropId, Long userId) { + this.dropId = dropId; + this.userId = userId; + } +} diff --git a/src/main/java/com/dropit/order/entity/Order.java b/src/main/java/com/dropit/order/entity/Order.java index 5a82926..a1175df 100644 --- a/src/main/java/com/dropit/order/entity/Order.java +++ b/src/main/java/com/dropit/order/entity/Order.java @@ -33,6 +33,9 @@ public class Order extends BaseEntity { @Column(nullable = false) private OrderStatus status; + @OneToOne(mappedBy = "order") + private OrderRequest orderRequest; + public Order(User user, BigDecimal totalPrice) { this.user = user; this.totalPrice = totalPrice; diff --git a/src/main/java/com/dropit/order/entity/OrderRequest.java b/src/main/java/com/dropit/order/entity/OrderRequest.java new file mode 100644 index 0000000..9c54d12 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderRequest.java @@ -0,0 +1,205 @@ +package com.dropit.order.entity; + +import com.dropit.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.Instant; +import com.dropit.order.redis.RedisOrderRequestState; +import java.util.Objects; +import java.util.UUID; + +@Getter +@Entity +@Table( + name = "order_requests", + uniqueConstraints = @UniqueConstraint( + name = "uk_order_requests_user_drop_key_hash", + columnNames = {"user_id", "drop_id", "idempotency_key_hash"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderRequest extends BaseEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OrderRequestStatus status; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", unique = true) + private Order order; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "drop_id", nullable = false) + private Long dropId; + + @Column(name = "idempotency_key_hash", nullable = false, length = 64) + private String idempotencyKeyHash; + + @Column(name = "payload_hash", nullable = false, length = 64) + private String payloadHash; + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + @Column(name = "unit_price", nullable = false, precision = 13, scale = 0) + private BigDecimal unitPrice; + + @Column(nullable = false) + private int quantity; + + @Column(name = "discount_rate", nullable = false) + private int discountRate; + + @Column(name = "accepted_at", nullable = false) + private LocalDateTime acceptedAt; + + @Column(name = "failure_code", length = 50) + private String failureCode; + + @Column(name = "redis_sync_pending", nullable = false) + private boolean redisSyncPending; + + @Column(name = "desired_version", nullable = false) + private long desiredVersion; + + @Enumerated(EnumType.STRING) + @Column(name = "desired_redis_state", length = 20) + private RedisOrderRequestState desiredRedisState; + + @Column(name = "redis_expires_at", nullable = false) + private Instant redisExpiresAt; + + @Column(name = "redis_sync_attempt_count", nullable = false) + private int redisSyncAttemptCount; + + @Column(name = "redis_sync_next_attempt_at") + private Instant redisSyncNextAttemptAt; + + public OrderRequest( + UUID requestId, + Long userId, + Long dropId, + String idempotencyKeyHash, + String payloadHash, + String productName, + BigDecimal unitPrice, + int quantity, + int discountRate, + LocalDateTime acceptedAt, + Instant redisExpiresAt + ) { + this.id = requestId; + this.status = OrderRequestStatus.PENDING; + this.userId = userId; + this.dropId = dropId; + this.idempotencyKeyHash = idempotencyKeyHash; + this.payloadHash = payloadHash; + this.productName = productName; + this.unitPrice = unitPrice; + this.quantity = quantity; + this.discountRate = discountRate; + this.acceptedAt = acceptedAt; + this.redisExpiresAt = redisExpiresAt; + } + + public boolean matches( + Long userId, + Long dropId, + String idempotencyKeyHash, + String payloadHash, + int quantity, + String productName, + BigDecimal unitPrice, + int discountRate + ) { + return Objects.equals(this.userId, userId) + && Objects.equals(this.dropId, dropId) + && Objects.equals(this.idempotencyKeyHash, idempotencyKeyHash) + && Objects.equals(this.payloadHash, payloadHash) + && this.quantity == quantity + && Objects.equals(this.productName, productName) + && this.unitPrice.compareTo(unitPrice) == 0 + && this.discountRate == discountRate; + } + + public boolean isTerminal() { + return status == OrderRequestStatus.SUCCEEDED || status == OrderRequestStatus.FAILED; + } + + public boolean succeed(Order order) { + if (status != OrderRequestStatus.PENDING) { + return false; + } + + this.status = OrderRequestStatus.SUCCEEDED; + this.order = order; + this.failureCode = null; + requestRedisSync(RedisOrderRequestState.SUCCEEDED); + return true; + } + + public boolean fail(String failureCode) { + if (status != OrderRequestStatus.PENDING) { + return false; + } + + this.status = OrderRequestStatus.FAILED; + this.failureCode = failureCode; + requestRedisSync(RedisOrderRequestState.FAILED); + return true; + } + + public Long getOrderId() { + return order == null ? null : order.getId(); + } + + public boolean completeRedisSync(long appliedVersion) { + if (!redisSyncPending || desiredVersion != appliedVersion) { + return false; + } + + redisSyncPending = false; + redisSyncNextAttemptAt = null; + return true; + } + + public void deferRedisSync(Instant nextAttemptAt) { + if (!redisSyncPending) { + return; + } + redisSyncAttemptCount++; + redisSyncNextAttemptAt = nextAttemptAt; + } + + public void requestCancellationSync() { + requestRedisSync(RedisOrderRequestState.CANCELED); + } + + private void requestRedisSync(RedisOrderRequestState desiredState) { + redisSyncPending = true; + desiredRedisState = desiredState; + desiredVersion++; + redisSyncAttemptCount = 0; + redisSyncNextAttemptAt = Instant.now(); + } +} diff --git a/src/main/java/com/dropit/order/entity/OrderRequestStatus.java b/src/main/java/com/dropit/order/entity/OrderRequestStatus.java new file mode 100644 index 0000000..e9421c5 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderRequestStatus.java @@ -0,0 +1,7 @@ +package com.dropit.order.entity; + +public enum OrderRequestStatus { + PENDING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/dropit/order/exception/OrderErrorCode.java b/src/main/java/com/dropit/order/exception/OrderErrorCode.java index db8aad9..e443de8 100644 --- a/src/main/java/com/dropit/order/exception/OrderErrorCode.java +++ b/src/main/java/com/dropit/order/exception/OrderErrorCode.java @@ -8,8 +8,14 @@ public enum OrderErrorCode implements ErrorCode { ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문입니다."), + ORDER_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문 요청입니다."), ORDER_ALREADY_CANCELED(HttpStatus.CONFLICT, "이미 취소된 주문입니다."), - PURCHASE_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "드랍의 1인당 구매 제한 수량을 초과했습니다."); + PURCHASE_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "드랍의 1인당 구매 제한 수량을 초과했습니다."), + ORDER_REQUEST_PAYLOAD_MISMATCH(HttpStatus.CONFLICT, "주문 요청 내용이 기존 요청과 다릅니다."), + IDEMPOTENCY_KEY_REUSED(HttpStatus.CONFLICT, "이미 다른 주문 요청에 사용된 멱등키입니다."), + ORDER_ADMISSION_NOT_READY(HttpStatus.SERVICE_UNAVAILABLE, "주문 접수 상태를 사용할 수 없습니다."), + ORDER_PUBLICATION_UNCONFIRMED(HttpStatus.SERVICE_UNAVAILABLE, "주문 메시지 발행 결과를 확인할 수 없습니다."), + IDEMPOTENCY_KEY_INVALID(HttpStatus.BAD_REQUEST, "Idempotency-Key 헤더가 올바르지 않습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/dropit/order/messaging/InvalidOrderMessageException.java b/src/main/java/com/dropit/order/messaging/InvalidOrderMessageException.java new file mode 100644 index 0000000..4f332fc --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/InvalidOrderMessageException.java @@ -0,0 +1,8 @@ +package com.dropit.order.messaging; + +public class InvalidOrderMessageException extends RuntimeException { + + public InvalidOrderMessageException(String message) { + super(message); + } +} diff --git a/src/main/java/com/dropit/order/messaging/OrderMessage.java b/src/main/java/com/dropit/order/messaging/OrderMessage.java new file mode 100644 index 0000000..f6a6aef --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/OrderMessage.java @@ -0,0 +1,22 @@ +package com.dropit.order.messaging; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record OrderMessage( + int schemaVersion, + UUID requestId, + Long userId, + Long dropId, + String idempotencyKeyHash, + String requestFingerprint, + String productName, + BigDecimal unitPrice, + int quantity, + int discountRate, + Instant acceptedAt +) { + + public static final int CURRENT_SCHEMA_VERSION = 1; +} diff --git a/src/main/java/com/dropit/order/messaging/OrderMessageProcessor.java b/src/main/java/com/dropit/order/messaging/OrderMessageProcessor.java new file mode 100644 index 0000000..44b4b9c --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/OrderMessageProcessor.java @@ -0,0 +1,36 @@ +package com.dropit.order.messaging; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.service.OrderFinalizationService; +import com.dropit.order.service.OrderRequestFailureService; +import com.dropit.order.service.OrderRequestRedisSyncService; +import com.dropit.order.service.OrderRequestRegistrationService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderMessageProcessor { + + private final OrderRequestRegistrationService registrationService; + private final OrderFinalizationService finalizationService; + private final OrderRequestFailureService failureService; + private final OrderRequestRedisSyncService redisSyncService; + + public void process(OrderMessage message) { + try { + registrationService.register(message); + } catch (DataIntegrityViolationException exception) { + registrationService.verifyExisting(message); + } + + try { + finalizationService.finalizeOrder(message.requestId()); + } catch (ServiceException exception) { + failureService.fail(message.requestId(), exception.getErrorCode().toString()); + } + + redisSyncService.sync(message.requestId()); + } +} diff --git a/src/main/java/com/dropit/order/messaging/OrderMessagePublisher.java b/src/main/java/com/dropit/order/messaging/OrderMessagePublisher.java new file mode 100644 index 0000000..aeaee9e --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/OrderMessagePublisher.java @@ -0,0 +1,35 @@ +package com.dropit.order.messaging; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.SendMessageRequest; +import software.amazon.awssdk.services.sqs.model.SendMessageResponse; +import tools.jackson.databind.ObjectMapper; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class OrderMessagePublisher { + + private final SqsAsyncClient sqsClient; + private final SqsProperties properties; + private final ObjectMapper objectMapper; + + public String publish(OrderMessage message) { + if (properties.orderQueueUrl() == null || properties.orderQueueUrl().isBlank()) { + throw new IllegalStateException("주문 SQS 큐 URL이 설정되지 않았습니다."); + } + SendMessageResponse response = sqsClient.sendMessage(SendMessageRequest.builder() + .queueUrl(properties.orderQueueUrl()) + .messageBody(objectMapper.writeValueAsString(message)) + .build()) + .orTimeout(properties.publisherTimeoutMillis(), TimeUnit.MILLISECONDS) + .join(); + if (response.messageId() == null || response.messageId().isBlank()) { + throw new IllegalStateException("SQS가 주문 메시지 ID를 반환하지 않았습니다."); + } + return response.messageId(); + } +} diff --git a/src/main/java/com/dropit/order/messaging/OrderSqsListenerConfig.java b/src/main/java/com/dropit/order/messaging/OrderSqsListenerConfig.java new file mode 100644 index 0000000..fcf14ed --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/OrderSqsListenerConfig.java @@ -0,0 +1,62 @@ +package com.dropit.order.messaging; + +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.QueueNotFoundStrategy; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +import java.time.Duration; + +@Configuration +public class OrderSqsListenerConfig { + + @Bean + SqsMessageListenerContainerFactory orderSqsListenerContainerFactory( + SqsAsyncClient sqsAsyncClient, + SqsProperties properties + ) { + validate(properties); + + return SqsMessageListenerContainerFactory.builder() + .sqsAsyncClient(sqsAsyncClient) + .configure(options -> options + .autoStartup(properties.consumerEnabled()) + .acknowledgementMode(AcknowledgementMode.MANUAL) + .acknowledgementInterval(Duration.ZERO) + .acknowledgementThreshold(0) + .maxConcurrentMessages(properties.consumerConcurrency()) + .maxMessagesPerPoll(properties.consumerMaxNumberOfMessages()) + .pollTimeout(Duration.ofSeconds(properties.consumerWaitTimeSeconds())) + .messageVisibility(Duration.ofSeconds(properties.consumerVisibilityTimeoutSeconds())) + .queueNotFoundStrategy(QueueNotFoundStrategy.FAIL)) + .build(); + } + + private void validate(SqsProperties properties) { + if (properties.publisherTimeoutMillis() <= 0) { + throw new IllegalArgumentException("app.order.sqs.publisher-timeout-millis must be positive"); + } + if (properties.orderQueueUrl() == null || properties.orderQueueUrl().isBlank()) { + throw new IllegalArgumentException("app.order.sqs.order-queue-url must be configured"); + } + if (!properties.consumerEnabled()) { + return; + } + requireRange(properties.consumerConcurrency(), 1, Integer.MAX_VALUE, + "app.order.sqs.consumer-concurrency"); + requireRange(properties.consumerMaxNumberOfMessages(), 1, 10, + "app.order.sqs.consumer-max-number-of-messages"); + requireRange(properties.consumerWaitTimeSeconds(), 0, 20, + "app.order.sqs.consumer-wait-time-seconds"); + requireRange(properties.consumerVisibilityTimeoutSeconds(), 0, 43_200, + "app.order.sqs.consumer-visibility-timeout-seconds"); + } + + private void requireRange(int value, int min, int max, String propertyName) { + if (value < min || value > max) { + throw new IllegalArgumentException(propertyName + " must be between " + min + " and " + max); + } + } +} diff --git a/src/main/java/com/dropit/order/messaging/SqsOrderMessageListener.java b/src/main/java/com/dropit/order/messaging/SqsOrderMessageListener.java new file mode 100644 index 0000000..9d53ded --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/SqsOrderMessageListener.java @@ -0,0 +1,22 @@ +package com.dropit.order.messaging; + +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SqsOrderMessageListener { + + private final OrderMessageProcessor messageProcessor; + + @SqsListener( + value = "${app.order.sqs.order-queue-url}", + factory = "orderSqsListenerContainerFactory" + ) + public void listen(OrderMessage message, Acknowledgement acknowledgement) { + messageProcessor.process(message); + acknowledgement.acknowledge(); + } +} diff --git a/src/main/java/com/dropit/order/messaging/SqsProperties.java b/src/main/java/com/dropit/order/messaging/SqsProperties.java new file mode 100644 index 0000000..344bb37 --- /dev/null +++ b/src/main/java/com/dropit/order/messaging/SqsProperties.java @@ -0,0 +1,15 @@ +package com.dropit.order.messaging; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.order.sqs") +public record SqsProperties( + String orderQueueUrl, + boolean consumerEnabled, + int consumerConcurrency, + int consumerMaxNumberOfMessages, + int consumerWaitTimeSeconds, + int consumerVisibilityTimeoutSeconds, + long publisherTimeoutMillis +) { +} diff --git a/src/main/java/com/dropit/order/redis/OrderAdmissionRequest.java b/src/main/java/com/dropit/order/redis/OrderAdmissionRequest.java new file mode 100644 index 0000000..133391d --- /dev/null +++ b/src/main/java/com/dropit/order/redis/OrderAdmissionRequest.java @@ -0,0 +1,32 @@ +package com.dropit.order.redis; + +import java.util.UUID; + +public record OrderAdmissionRequest( + UUID requestId, + Long userId, + Long dropId, + String idempotencyKeyHash, + String fingerprint, + int quantity, + long acceptedAtEpochMicros +) { + + public static OrderAdmissionRequest create( + Long userId, + Long dropId, + String idempotencyKeyHash, + int quantity, + long acceptedAtEpochMicros + ) { + return new OrderAdmissionRequest( + UUID.randomUUID(), + userId, + dropId, + idempotencyKeyHash, + com.dropit.order.validation.OrderRequestFingerprint.of(dropId, quantity), + quantity, + acceptedAtEpochMicros + ); + } +} diff --git a/src/main/java/com/dropit/order/redis/OrderAdmissionResult.java b/src/main/java/com/dropit/order/redis/OrderAdmissionResult.java new file mode 100644 index 0000000..b2d91a2 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/OrderAdmissionResult.java @@ -0,0 +1,21 @@ +package com.dropit.order.redis; + +import java.util.UUID; + +public record OrderAdmissionResult( + OrderAdmissionResultType type, + UUID requestId +) { + + public static OrderAdmissionResult newReservation(UUID requestId) { + return new OrderAdmissionResult(OrderAdmissionResultType.NEW, requestId); + } + + public static OrderAdmissionResult replay(UUID requestId) { + return new OrderAdmissionResult(OrderAdmissionResultType.REPLAY, requestId); + } + + public static OrderAdmissionResult of(OrderAdmissionResultType type) { + return new OrderAdmissionResult(type, null); + } +} diff --git a/src/main/java/com/dropit/order/redis/OrderAdmissionResultType.java b/src/main/java/com/dropit/order/redis/OrderAdmissionResultType.java new file mode 100644 index 0000000..20f369f --- /dev/null +++ b/src/main/java/com/dropit/order/redis/OrderAdmissionResultType.java @@ -0,0 +1,11 @@ +package com.dropit.order.redis; + +public enum OrderAdmissionResultType { + NEW, + REPLAY, + CONFLICT, + OUT_OF_STOCK, + PURCHASE_LIMIT_EXCEEDED, + NOT_OPEN, + NOT_READY +} diff --git a/src/main/java/com/dropit/order/redis/OrderRequestSyncCommand.java b/src/main/java/com/dropit/order/redis/OrderRequestSyncCommand.java new file mode 100644 index 0000000..dcddbc4 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/OrderRequestSyncCommand.java @@ -0,0 +1,18 @@ +package com.dropit.order.redis; + +import java.time.Instant; +import java.util.UUID; + +public record OrderRequestSyncCommand( + UUID requestId, + RedisOrderRequestState status, + Long orderId, + Long userId, + Long dropId, + String idempotencyKeyHash, + int quantity, + String failureCode, + long desiredVersion, + Instant expiresAt +) { +} diff --git a/src/main/java/com/dropit/order/redis/OrderRequestSyncResult.java b/src/main/java/com/dropit/order/redis/OrderRequestSyncResult.java new file mode 100644 index 0000000..465bc65 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/OrderRequestSyncResult.java @@ -0,0 +1,8 @@ +package com.dropit.order.redis; + +public enum OrderRequestSyncResult { + APPLIED, + ALREADY_APPLIED, + MISSING, + INVALID +} diff --git a/src/main/java/com/dropit/order/redis/RedisOrderAdmissionAdapter.java b/src/main/java/com/dropit/order/redis/RedisOrderAdmissionAdapter.java new file mode 100644 index 0000000..3037228 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/RedisOrderAdmissionAdapter.java @@ -0,0 +1,234 @@ +package com.dropit.order.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class RedisOrderAdmissionAdapter { + + private static final DefaultRedisScript RESERVE_ORDER_SCRIPT = createScript(); + private static final DefaultRedisScript CONFIRM_PUBLICATION_SCRIPT = confirmPublicationScript(); + private static final DefaultRedisScript INDEX_REQUEST_SCRIPT = indexRequestScript(); + + private final StringRedisTemplate redisTemplate; + + private static DefaultRedisScript createScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/reserve-order.lua")); + script.setResultType(String.class); + return script; + } + + public OrderAdmissionResult reserve(OrderAdmissionRequest request) { + try { + String result = redisTemplate.execute( + RESERVE_ORDER_SCRIPT, + List.of( + RedisOrderKeyFactory.saleKey(request.dropId()), + RedisOrderKeyFactory.stockKey(request.dropId()), + RedisOrderKeyFactory.purchaseKey(request.dropId()), + RedisOrderKeyFactory.idempotencyKey( + request.dropId(), + request.userId(), + request.idempotencyKeyHash() + ) + ), + request.requestId().toString(), + request.fingerprint(), + String.valueOf(request.quantity()), + Long.toString(request.acceptedAtEpochMicros()), + Long.toString(request.userId()), + Long.toString(request.dropId()), + request.idempotencyKeyHash() + ); + return parseResult(result); + } catch (RuntimeException exception) { + return OrderAdmissionResult.of(OrderAdmissionResultType.NOT_READY); + } + } + + public ReservedOrderSnapshot getSnapshot(Long dropId, Long userId, String keyHash) { + return parseSnapshot(redisTemplate.opsForHash().entries( + RedisOrderKeyFactory.idempotencyKey(dropId, userId, keyHash))); + } + + public Optional findByRequestId(UUID requestId, Long userId) { + Map index = redisTemplate.opsForHash().entries( + RedisOrderKeyFactory.requestIndexKey(requestId) + ); + if (index.isEmpty() || !requestId.toString().equals(optional(index, "requestId")) + || !userId.toString().equals(optional(index, "userId"))) { + return Optional.empty(); + } + String idempotencyKey = required(index, "idempotencyKey"); + Map values = redisTemplate.opsForHash().entries(idempotencyKey); + if (values.isEmpty()) { + return Optional.empty(); + } + ReservedOrderSnapshot snapshot = parseSnapshot(values); + if (!requestId.equals(snapshot.requestId()) || !userId.equals(snapshot.userId())) { + return Optional.empty(); + } + return Optional.of(snapshot); + } + + private ReservedOrderSnapshot parseSnapshot(Map values) { + if (values.isEmpty()) { + throw new IllegalStateException("Redis 주문 예약 snapshot이 없습니다."); + } + long acceptedAtMicros = Long.parseLong(required(values, "acceptedAtEpochMicros")); + long seconds = Math.floorDiv(acceptedAtMicros, 1_000_000L); + long micros = Math.floorMod(acceptedAtMicros, 1_000_000L); + return new ReservedOrderSnapshot( + UUID.fromString(required(values, "requestId")), + Long.valueOf(required(values, "userId")), + Long.valueOf(required(values, "dropId")), + required(values, "idempotencyKeyHash"), + required(values, "fingerprint"), + Integer.parseInt(required(values, "quantity")), + Instant.ofEpochSecond(seconds, micros * 1_000L), + required(values, "productName"), + new java.math.BigDecimal(required(values, "unitPrice")), + Integer.parseInt(required(values, "discountRate")), + required(values, "publicationState"), + parseStatus(required(values, "status")), + optionalLong(values, "orderId"), + optional(values, "failureCode"), + Long.parseLong(required(values, "expiresAtEpochMillis")) + ); + } + + public void confirmPublication(ReservedOrderSnapshot snapshot) { + String key = RedisOrderKeyFactory.idempotencyKey( + snapshot.dropId(), snapshot.userId(), snapshot.idempotencyKeyHash() + ); + Long result = redisTemplate.execute( + CONFIRM_PUBLICATION_SCRIPT, + List.of(key), + snapshot.requestId().toString() + ); + if (result == null || result != 1L) { + throw new IllegalStateException("Redis 주문 발행 상태를 확인할 수 없습니다."); + } + } + + public void indexRequest(ReservedOrderSnapshot snapshot) { + String key = RedisOrderKeyFactory.requestIndexKey(snapshot.requestId()); + Long result = redisTemplate.execute( + INDEX_REQUEST_SCRIPT, + List.of(key), + snapshot.requestId().toString(), + snapshot.userId().toString(), + snapshot.dropId().toString(), + RedisOrderKeyFactory.idempotencyKey( + snapshot.dropId(), snapshot.userId(), snapshot.idempotencyKeyHash()), + Long.toString(snapshot.expiresAtEpochMillis()) + ); + if (result == null || result != 1L) { + throw new IllegalStateException("Redis 주문 요청 index를 저장할 수 없습니다."); + } + } + + private static String required(Map values, String field) { + String value = optional(values, field); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Redis 주문 예약 필드가 없습니다: " + field); + } + return value; + } + + private static String optional(Map values, String field) { + Object value = values.get(field); + return value == null ? null : value.toString(); + } + + private static Long optionalLong(Map values, String field) { + String value = optional(values, field); + return value == null || value.isBlank() ? null : Long.valueOf(value); + } + + private static com.dropit.order.entity.OrderRequestStatus parseStatus(String status) { + if ("RESERVED".equals(status)) { + return com.dropit.order.entity.OrderRequestStatus.PENDING; + } + if ("CANCELED".equals(status)) { + return com.dropit.order.entity.OrderRequestStatus.SUCCEEDED; + } + return com.dropit.order.entity.OrderRequestStatus.valueOf(status); + } + + private static OrderAdmissionResult parseResult(String result) { + if (result == null) { + return OrderAdmissionResult.of(OrderAdmissionResultType.NOT_READY); + } + + String[] parts = result.split("\\|", 2); + return switch (parts[0]) { + case "NEW" -> resultWithRequestId(parts, OrderAdmissionResultType.NEW); + case "REPLAY" -> resultWithRequestId(parts, OrderAdmissionResultType.REPLAY); + case "CONFLICT" -> OrderAdmissionResult.of(OrderAdmissionResultType.CONFLICT); + case "OUT_OF_STOCK" -> OrderAdmissionResult.of(OrderAdmissionResultType.OUT_OF_STOCK); + case "PURCHASE_LIMIT_EXCEEDED" -> + OrderAdmissionResult.of(OrderAdmissionResultType.PURCHASE_LIMIT_EXCEEDED); + case "NOT_OPEN" -> OrderAdmissionResult.of(OrderAdmissionResultType.NOT_OPEN); + default -> OrderAdmissionResult.of(OrderAdmissionResultType.NOT_READY); + }; + } + + private static DefaultRedisScript confirmPublicationScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + if redis.call('HGET', KEYS[1], 'requestId') ~= ARGV[1] then + return 0 + end + redis.call('HSET', KEYS[1], 'publicationState', 'CONFIRMED') + return 1 + """); + script.setResultType(Long.class); + return script; + } + + private static DefaultRedisScript indexRequestScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + redis.call('HSET', KEYS[1], + 'requestId', ARGV[1], + 'userId', ARGV[2], + 'dropId', ARGV[3], + 'idempotencyKey', ARGV[4]) + if redis.call('PEXPIREAT', KEYS[1], ARGV[5]) ~= 1 then + redis.call('DEL', KEYS[1]) + return 0 + end + return 1 + """); + script.setResultType(Long.class); + return script; + } + + private static OrderAdmissionResult resultWithRequestId( + String[] parts, + OrderAdmissionResultType type + ) { + if (parts.length != 2) { + return OrderAdmissionResult.of(OrderAdmissionResultType.NOT_READY); + } + + try { + UUID requestId = UUID.fromString(parts[1]); + return new OrderAdmissionResult(type, requestId); + } catch (IllegalArgumentException exception) { + return OrderAdmissionResult.of(OrderAdmissionResultType.NOT_READY); + } + } +} diff --git a/src/main/java/com/dropit/order/redis/RedisOrderFinalizationSyncAdapter.java b/src/main/java/com/dropit/order/redis/RedisOrderFinalizationSyncAdapter.java new file mode 100644 index 0000000..5a1591d --- /dev/null +++ b/src/main/java/com/dropit/order/redis/RedisOrderFinalizationSyncAdapter.java @@ -0,0 +1,51 @@ +package com.dropit.order.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RedisOrderFinalizationSyncAdapter { + + private static final DefaultRedisScript SYNC_SCRIPT = createScript(); + + private final StringRedisTemplate redisTemplate; + + public OrderRequestSyncResult sync(OrderRequestSyncCommand command) { + String result = redisTemplate.execute( + SYNC_SCRIPT, + List.of( + RedisOrderKeyFactory.idempotencyKey( + command.dropId(), + command.userId(), + command.idempotencyKeyHash() + ), + RedisOrderKeyFactory.stockKey(command.dropId()), + RedisOrderKeyFactory.purchaseKey(command.dropId(), command.userId()) + ), + command.requestId().toString(), + command.status().name(), + Integer.toString(command.quantity()), + command.orderId() == null ? "" : command.orderId().toString(), + command.failureCode() == null ? "" : command.failureCode(), + Long.toString(command.desiredVersion()), + command.userId().toString() + ); + if (result == null) { + throw new IllegalStateException("Redis 주문 최종 상태 반영 결과가 없습니다."); + } + return OrderRequestSyncResult.valueOf(result); + } + + private static DefaultRedisScript createScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/sync-order-request.lua")); + script.setResultType(String.class); + return script; + } +} diff --git a/src/main/java/com/dropit/order/redis/RedisOrderKeyFactory.java b/src/main/java/com/dropit/order/redis/RedisOrderKeyFactory.java new file mode 100644 index 0000000..bce7ec1 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/RedisOrderKeyFactory.java @@ -0,0 +1,46 @@ +package com.dropit.order.redis; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RedisOrderKeyFactory { + + public static String requestIndexKey(java.util.UUID requestId) { + if (requestId == null) { + throw new IllegalArgumentException("requestId is required"); + } + return "order-request:" + requestId; + } + + public static String stockKey(Long dropId) { + return dropKey(dropId) + ":stock"; + } + + public static String saleKey(Long dropId) { + return dropKey(dropId) + ":sale"; + } + + public static String admissionControlKey(Long dropId) { + return dropKey(dropId) + ":admission"; + } + + public static String purchaseKey(Long dropId) { + return dropKey(dropId) + ":purchases"; + } + + public static String purchaseKey(Long dropId, Long userId) { + return purchaseKey(dropId); + } + + public static String idempotencyKey(Long dropId, Long userId, String idempotencyKeyHash) { + return dropKey(dropId) + ":idempotency:user:" + userId + ":" + idempotencyKeyHash; + } + + private static String dropKey(Long dropId) { + if (dropId == null || dropId <= 0) { + throw new IllegalArgumentException("dropId must be positive"); + } + return "{drop:" + dropId + "}"; + } +} diff --git a/src/main/java/com/dropit/order/redis/RedisOrderRequestState.java b/src/main/java/com/dropit/order/redis/RedisOrderRequestState.java new file mode 100644 index 0000000..f36f1c6 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/RedisOrderRequestState.java @@ -0,0 +1,7 @@ +package com.dropit.order.redis; + +public enum RedisOrderRequestState { + SUCCEEDED, + FAILED, + CANCELED +} diff --git a/src/main/java/com/dropit/order/redis/ReservedOrderSnapshot.java b/src/main/java/com/dropit/order/redis/ReservedOrderSnapshot.java new file mode 100644 index 0000000..d048766 --- /dev/null +++ b/src/main/java/com/dropit/order/redis/ReservedOrderSnapshot.java @@ -0,0 +1,42 @@ +package com.dropit.order.redis; + +import com.dropit.order.entity.OrderRequestStatus; +import com.dropit.order.messaging.OrderMessage; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record ReservedOrderSnapshot( + UUID requestId, + Long userId, + Long dropId, + String idempotencyKeyHash, + String fingerprint, + int quantity, + Instant acceptedAt, + String productName, + BigDecimal unitPrice, + int discountRate, + String publicationState, + OrderRequestStatus status, + Long orderId, + String failureCode, + long expiresAtEpochMillis +) { + public OrderMessage toMessage() { + return new OrderMessage( + OrderMessage.CURRENT_SCHEMA_VERSION, requestId, userId, dropId, + idempotencyKeyHash, fingerprint, productName, unitPrice, + quantity, discountRate, acceptedAt + ); + } + + public boolean terminal() { + return status == OrderRequestStatus.SUCCEEDED || status == OrderRequestStatus.FAILED; + } + + public boolean publicationConfirmed() { + return "CONFIRMED".equals(publicationState); + } +} diff --git a/src/main/java/com/dropit/order/repository/DropUserPurchaseRepository.java b/src/main/java/com/dropit/order/repository/DropUserPurchaseRepository.java new file mode 100644 index 0000000..d03d669 --- /dev/null +++ b/src/main/java/com/dropit/order/repository/DropUserPurchaseRepository.java @@ -0,0 +1,71 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.DropUserPurchase; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; + +public interface DropUserPurchaseRepository extends JpaRepository { + + boolean existsByDropId(Long dropId); + + Optional findByDropIdAndUserId(Long dropId, Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from DropUserPurchase p where p.dropId = :dropId and p.userId = :userId") + Optional findByDropIdAndUserIdForUpdate( + @Param("dropId") Long dropId, + @Param("userId") Long userId + ); + + @Modifying + @Query(value = """ + INSERT IGNORE INTO drop_user_purchases ( + drop_id, + user_id, + confirmed_quantity, + created_at, + updated_at + ) VALUES (:dropId, :userId, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, nativeQuery = true) + int createCounterIfAbsent( + @Param("dropId") Long dropId, + @Param("userId") Long userId + ); + + @Modifying + @Query(value = """ + UPDATE drop_user_purchases + SET confirmed_quantity = confirmed_quantity + :quantity, + updated_at = CURRENT_TIMESTAMP + WHERE drop_id = :dropId + AND user_id = :userId + AND (:purchaseLimit = 0 OR confirmed_quantity + :quantity <= :purchaseLimit) + """, nativeQuery = true) + int increaseWithinLimit( + @Param("dropId") Long dropId, + @Param("userId") Long userId, + @Param("quantity") int quantity, + @Param("purchaseLimit") int purchaseLimit + ); + + @Modifying + @Query(value = """ + UPDATE drop_user_purchases + SET confirmed_quantity = confirmed_quantity - :quantity, + updated_at = CURRENT_TIMESTAMP + WHERE drop_id = :dropId + AND user_id = :userId + AND confirmed_quantity >= :quantity + """, nativeQuery = true) + int decreaseConfirmedQuantity( + @Param("dropId") Long dropId, + @Param("userId") Long userId, + @Param("quantity") int quantity + ); +} diff --git a/src/main/java/com/dropit/order/repository/OrderItemRepository.java b/src/main/java/com/dropit/order/repository/OrderItemRepository.java index 19b7442..0e47405 100644 --- a/src/main/java/com/dropit/order/repository/OrderItemRepository.java +++ b/src/main/java/com/dropit/order/repository/OrderItemRepository.java @@ -1,7 +1,6 @@ package com.dropit.order.repository; import com.dropit.order.entity.OrderItem; -import com.dropit.order.entity.OrderStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -10,18 +9,14 @@ public interface OrderItemRepository extends JpaRepository { + boolean existsByDropId(Long dropId); + List findAllByOrder_IdOrderByIdAsc(Long orderId); @Query(""" - select coalesce(sum(oi.quantity), 0) - from OrderItem oi - where oi.order.user.id = :userId - and oi.drop.id = :dropId - and oi.order.status = :status + select i from OrderItem i + where i.order.id = :orderId + order by i.drop.id asc, i.id asc """) - long sumQuantityByUserAndDropAndStatus( - @Param("userId") Long userId, - @Param("dropId") Long dropId, - @Param("status") OrderStatus status - ); + List findAllForCancellationOrderByDropIdAsc(@Param("orderId") Long orderId); } diff --git a/src/main/java/com/dropit/order/repository/OrderRepository.java b/src/main/java/com/dropit/order/repository/OrderRepository.java index 52b3dfd..f34ed9a 100644 --- a/src/main/java/com/dropit/order/repository/OrderRepository.java +++ b/src/main/java/com/dropit/order/repository/OrderRepository.java @@ -1,7 +1,11 @@ package com.dropit.order.repository; import com.dropit.order.entity.Order; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -11,4 +15,11 @@ public interface OrderRepository extends JpaRepository { List findAllByUser_IdOrderByCreatedAtDesc(Long userId); Optional findByIdAndUser_Id(Long orderId, Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select o from Order o where o.id = :orderId and o.user.id = :userId") + Optional findByIdAndUser_IdForUpdate( + @Param("orderId") Long orderId, + @Param("userId") Long userId + ); } diff --git a/src/main/java/com/dropit/order/repository/OrderRequestRepository.java b/src/main/java/com/dropit/order/repository/OrderRequestRepository.java new file mode 100644 index 0000000..7deed83 --- /dev/null +++ b/src/main/java/com/dropit/order/repository/OrderRequestRepository.java @@ -0,0 +1,43 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.OrderRequest; +import jakarta.persistence.LockModeType; +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; +import java.util.UUID; +import java.util.List; +import java.time.Instant; + +public interface OrderRequestRepository extends JpaRepository { + + Optional findByIdAndUserId(UUID id, Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select r from OrderRequest r where r.order.id = :orderId") + Optional findByOrderIdForUpdate(@Param("orderId") Long orderId); + + List findTop100ByRedisSyncPendingTrueAndRedisSyncNextAttemptAtLessThanEqualOrderByRedisSyncNextAttemptAtAsc( + Instant now + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select r from OrderRequest r where r.id = :requestId") + Optional findByRequestIdForUpdate(@Param("requestId") UUID requestId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select r from OrderRequest r + where r.userId = :userId + and r.dropId = :dropId + and r.idempotencyKeyHash = :idempotencyKeyHash + """) + Optional findByUserIdAndDropIdAndIdempotencyKeyHashForUpdate( + @Param("userId") Long userId, + @Param("dropId") Long dropId, + @Param("idempotencyKeyHash") String idempotencyKeyHash + ); +} diff --git a/src/main/java/com/dropit/order/service/OrderAdmissionService.java b/src/main/java/com/dropit/order/service/OrderAdmissionService.java new file mode 100644 index 0000000..0d6cb20 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderAdmissionService.java @@ -0,0 +1,99 @@ +package com.dropit.order.service; + +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.global.exception.CommonErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.messaging.OrderMessagePublisher; +import com.dropit.order.redis.OrderAdmissionRequest; +import com.dropit.order.redis.OrderAdmissionResult; +import com.dropit.order.redis.RedisOrderAdmissionAdapter; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.validation.OrderAdmissionValidator; +import com.dropit.order.validation.OrderIdempotencyKeyHasher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Service +@RequiredArgsConstructor +public class OrderAdmissionService { + + private final RedisOrderAdmissionAdapter redisAdapter; + private final OrderMessagePublisher messagePublisher; + + public ReservedOrderSnapshot admit(Long userId, String rawIdempotencyKey, OrderCreateRequest request) { + validateInput(rawIdempotencyKey, request); + OrderItemCreateRequest item = request.items().getFirst(); + String keyHash = OrderIdempotencyKeyHasher.sha256(rawIdempotencyKey); + Instant acceptedAt = Instant.now().truncatedTo(ChronoUnit.MICROS); + long acceptedAtMicros = Math.addExact( + Math.multiplyExact(acceptedAt.getEpochSecond(), 1_000_000L), + acceptedAt.getNano() / 1_000L + ); + + OrderAdmissionRequest command = OrderAdmissionRequest.create( + userId, item.dropId(), keyHash, item.quantity(), acceptedAtMicros + ); + OrderAdmissionResult result = reserve(command); + validateResult(result); + + ReservedOrderSnapshot snapshot; + try { + snapshot = redisAdapter.getSnapshot(item.dropId(), userId, keyHash); + redisAdapter.indexRequest(snapshot); + } catch (RuntimeException exception) { + throw new ServiceException(OrderErrorCode.ORDER_ADMISSION_NOT_READY, exception); + } + + if (snapshot.terminal() || snapshot.publicationConfirmed()) { + return snapshot; + } + try { + messagePublisher.publish(snapshot.toMessage()); + } catch (RuntimeException exception) { + throw new OrderPublicationUnconfirmedException(snapshot.requestId(), exception); + } + try { + redisAdapter.confirmPublication(snapshot); + } catch (RuntimeException ignored) { + // SQS가 이미 수락했으므로 접수 성공을 반환한다. replay는 같은 requestId로 안전하게 재발행할 수 있다. + } + return snapshot; + } + + public OrderAdmissionResult reserve(OrderAdmissionRequest request) { + OrderAdmissionValidator.validate(request); + return redisAdapter.reserve(request); + } + + public OrderAdmissionResult reserve(Long userId, Long dropId, String keyHash, int quantity, int ignoredLimit) { + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + long micros = now.getEpochSecond() * 1_000_000L + now.getNano() / 1_000L; + return reserve(OrderAdmissionRequest.create(userId, dropId, keyHash, quantity, micros)); + } + + private void validateInput(String key, OrderCreateRequest request) { + if (key == null || key.isBlank() || key.length() > 128) { + throw new ServiceException(OrderErrorCode.IDEMPOTENCY_KEY_INVALID); + } + if (request == null || request.items() == null || request.items().size() != 1) { + throw new ServiceException(CommonErrorCode.INVALID_INPUT); + } + } + + private void validateResult(OrderAdmissionResult result) { + switch (result.type()) { + case NEW, REPLAY -> { } + case CONFLICT -> throw new ServiceException(OrderErrorCode.IDEMPOTENCY_KEY_REUSED); + case OUT_OF_STOCK -> throw new ServiceException(DropErrorCode.INSUFFICIENT_STOCK); + case PURCHASE_LIMIT_EXCEEDED -> throw new ServiceException(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED); + case NOT_OPEN -> throw new ServiceException(DropErrorCode.DROP_NOT_OPEN); + case NOT_READY -> throw new ServiceException(OrderErrorCode.ORDER_ADMISSION_NOT_READY); + } + } +} diff --git a/src/main/java/com/dropit/order/service/OrderCancellationService.java b/src/main/java/com/dropit/order/service/OrderCancellationService.java new file mode 100644 index 0000000..a682c61 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderCancellationService.java @@ -0,0 +1,70 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.CommonErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.order.repository.OrderRequestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderCancellationService { + + private final OrderRepository orderRepository; + private final OrderRequestRepository requestRepository; + private final OrderItemRepository itemRepository; + private final DropUserPurchaseRepository purchaseRepository; + private final DropRepository dropRepository; + private final OrderRequestRedisSyncService redisSyncService; + private final TransactionTemplate transactionTemplate; + + public void cancel(Long userId, Long orderId) { + OrderRequest request = transactionTemplate.execute(status -> cancelInTransaction(userId, orderId)); + if (request == null) { + return; + } + try { + redisSyncService.sync(request.getId()); + } catch (RuntimeException exception) { + log.warn("주문 취소 Redis 동기화를 지연 처리합니다. requestId={}", request.getId(), exception); + } + } + + private OrderRequest cancelInTransaction(Long userId, Long orderId) { + OrderRequest request = requestRepository.findByOrderIdForUpdate(orderId).orElse(null); + Order order = orderRepository.findByIdAndUser_IdForUpdate(orderId, userId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); + List items = itemRepository.findAllForCancellationOrderByDropIdAsc(orderId); + order.cancel(); + for (OrderItem item : items) { + Long dropId = item.getDrop().getId(); + purchaseRepository.findByDropIdAndUserIdForUpdate(dropId, userId) + .orElseThrow(() -> new ServiceException(CommonErrorCode.INTERNAL_SERVER_ERROR)); + Drop drop = dropRepository.findByIdForUpdate(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + if (purchaseRepository.decreaseConfirmedQuantity(dropId, userId, item.getQuantity()) != 1) { + throw new ServiceException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + drop.restoreStock(item.getQuantity()); + } + if (request != null) { + request.requestCancellationSync(); + } + return request; + } +} diff --git a/src/main/java/com/dropit/order/service/OrderFinalizationService.java b/src/main/java/com/dropit/order/service/OrderFinalizationService.java new file mode 100644 index 0000000..ab5d0cd --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderFinalizationService.java @@ -0,0 +1,101 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.order.repository.OrderRequestRepository; +import com.dropit.user.entity.User; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Consumer가 전달한 주문 요청을 MySQL 트랜잭션으로 중복 없이 최종 주문 처리 + */ +@Service +@RequiredArgsConstructor +public class OrderFinalizationService { + + private final OrderRequestRepository orderRequestRepository; + private final DropUserPurchaseRepository dropUserPurchaseRepository; + private final DropRepository dropRepository; + private final UserRepository userRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public void finalizeOrder(UUID requestId) { + // 1. 중복 orderRequest 처리 방지 + OrderRequest request = orderRequestRepository.findByRequestIdForUpdate(requestId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_REQUEST_NOT_FOUND)); + if (request.isTerminal()) { + return; + } + + // 2. 주문 확정용 user, drop 조회 + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new ServiceException(UserErrorCode.USER_NOT_FOUND)); + Drop drop = dropRepository.findById(request.getDropId()) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + // 3. 인당 한도 내 구매량 증가 및 조건부 재고 차감 + reservePurchaseLimit(user.getId(), drop, request.getQuantity()); + decreaseStock(drop.getId(), request.getQuantity()); + + // 4. Order, OrderItem 생성 및 주문 요청 확정 + BigDecimal totalPrice = OrderItem.calculateItemTotalPrice( + request.getUnitPrice(), + request.getDiscountRate(), + request.getQuantity() + ); + + Order order = orderRepository.save(new Order(user, totalPrice)); + orderItemRepository.save(new OrderItem( + order, + drop, + request.getProductName(), + request.getUnitPrice(), + request.getDiscountRate(), + request.getQuantity() + )); + + request.succeed(order); + } + + private void reservePurchaseLimit(Long userId, Drop drop, int quantity) { + dropUserPurchaseRepository.createCounterIfAbsent(drop.getId(), userId); + + int increasedCount = dropUserPurchaseRepository.increaseWithinLimit( + drop.getId(), + userId, + quantity, + drop.getPurchaseLimit() + ); + if (increasedCount != 1) { + throw new ServiceException(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED); + } + } + + private void decreaseStock(Long dropId, int quantity) { + int decreasedCount = dropRepository.decreaseStockIfAvailable( + dropId, + quantity + ); + if (decreasedCount != 1) { + throw new ServiceException(DropErrorCode.INSUFFICIENT_STOCK); + } + } +} diff --git a/src/main/java/com/dropit/order/service/OrderPublicationUnconfirmedException.java b/src/main/java/com/dropit/order/service/OrderPublicationUnconfirmedException.java new file mode 100644 index 0000000..9c54f6a --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderPublicationUnconfirmedException.java @@ -0,0 +1,17 @@ +package com.dropit.order.service; + +import java.util.UUID; + +public class OrderPublicationUnconfirmedException extends RuntimeException { + + private final UUID requestId; + + public OrderPublicationUnconfirmedException(UUID requestId, Throwable cause) { + super("주문 메시지 발행 결과를 확인할 수 없습니다.", cause); + this.requestId = requestId; + } + + public UUID getRequestId() { + return requestId; + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRedisSyncRetryScheduler.java b/src/main/java/com/dropit/order/service/OrderRedisSyncRetryScheduler.java new file mode 100644 index 0000000..f6ba4a5 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRedisSyncRetryScheduler.java @@ -0,0 +1,45 @@ +package com.dropit.order.service; + +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.repository.OrderRequestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; + +@Slf4j +@Component +@ConditionalOnProperty( + prefix = "app.order.sqs", + name = "consumer-enabled", + havingValue = "true" +) +@RequiredArgsConstructor +public class OrderRedisSyncRetryScheduler { + + private static final Duration RETRY_DELAY = Duration.ofSeconds(5); + + private final OrderRequestRepository repository; + private final OrderRequestRedisSyncService syncService; + + @Scheduled(fixedDelayString = "${app.order.redis-sync-retry-delay:5000}") + public void retryPending() { + for (OrderRequest request : repository + .findTop100ByRedisSyncPendingTrueAndRedisSyncNextAttemptAtLessThanEqualOrderByRedisSyncNextAttemptAtAsc( + Instant.now())) { + try { + syncService.sync(request.getId()); + } catch (RuntimeException exception) { + try { + syncService.defer(request.getId(), RETRY_DELAY); + } catch (RuntimeException deferException) { + exception.addSuppressed(deferException); + } + log.warn("주문 Redis 동기화 재시도에 실패했습니다. requestId={}", request.getId(), exception); + } + } + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRequestFailureService.java b/src/main/java/com/dropit/order/service/OrderRequestFailureService.java new file mode 100644 index 0000000..79f955d --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRequestFailureService.java @@ -0,0 +1,29 @@ +package com.dropit.order.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.OrderRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * 주문요청 확정 실패 시 별도의 트랜잭션으로 failureCode 기록 + */ +@Service +@RequiredArgsConstructor +public class OrderRequestFailureService { + + private final OrderRequestRepository orderRequestRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void fail(UUID requestId, String failureCode) { + OrderRequest request = orderRequestRepository.findByRequestIdForUpdate(requestId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_REQUEST_NOT_FOUND)); + request.fail(failureCode); + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRequestQueryService.java b/src/main/java/com/dropit/order/service/OrderRequestQueryService.java new file mode 100644 index 0000000..db7b603 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRequestQueryService.java @@ -0,0 +1,72 @@ +package com.dropit.order.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.response.OrderRequestStatusResponse; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.entity.OrderRequestStatus; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.redis.RedisOrderAdmissionAdapter; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.repository.OrderRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrderRequestQueryService { + + private static final Set EXPOSED_FAILURE_CODES = Set.of( + "INSUFFICIENT_STOCK", "PURCHASE_LIMIT_EXCEEDED", "DROP_NOT_OPEN" + ); + + private final OrderRequestRepository repository; + private final RedisOrderAdmissionAdapter redisAdapter; + + @Transactional(readOnly = true) + public OrderRequestStatusResponse get(Long userId, UUID requestId) { + Optional stored; + try { + stored = repository.findByIdAndUserId(requestId, userId); + } catch (RuntimeException exception) { + throw new ServiceException(OrderErrorCode.ORDER_ADMISSION_NOT_READY, exception); + } + if (stored.isPresent() && stored.get().isTerminal()) { + OrderRequest request = stored.get(); + return new OrderRequestStatusResponse( + request.getId(), request.getStatus(), request.getOrderId(), + request.getOrder() == null ? null : request.getOrder().getStatus(), + exposedFailureCode(request.getFailureCode()) + ); + } + + Optional pending; + try { + pending = redisAdapter.findByRequestId(requestId, userId); + } catch (RuntimeException exception) { + throw new ServiceException(OrderErrorCode.ORDER_ADMISSION_NOT_READY, exception); + } + if (pending.isPresent()) { + ReservedOrderSnapshot snapshot = pending.get(); + return new OrderRequestStatusResponse( + snapshot.requestId(), snapshot.status(), snapshot.orderId(), null, + exposedFailureCode(snapshot.failureCode()) + ); + } + if (stored.isPresent()) { + throw new ServiceException(OrderErrorCode.ORDER_ADMISSION_NOT_READY); + } + throw new ServiceException(OrderErrorCode.ORDER_REQUEST_NOT_FOUND); + } + + private String exposedFailureCode(String failureCode) { + if (failureCode == null) { + return null; + } + return EXPOSED_FAILURE_CODES.contains(failureCode) ? failureCode : "ORDER_FINALIZATION_FAILED"; + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRequestRedisSyncService.java b/src/main/java/com/dropit/order/service/OrderRequestRedisSyncService.java new file mode 100644 index 0000000..6fe7eed --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRequestRedisSyncService.java @@ -0,0 +1,43 @@ +package com.dropit.order.service; + +import com.dropit.order.redis.OrderRequestSyncCommand; +import com.dropit.order.redis.OrderRequestSyncResult; +import com.dropit.order.redis.RedisOrderFinalizationSyncAdapter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; +import java.time.Instant; +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class OrderRequestRedisSyncService { + + private final OrderRequestRedisSyncStateService stateService; + private final RedisOrderFinalizationSyncAdapter syncAdapter; + + public void sync(UUID requestId) { + Optional pendingCommand = stateService.loadPending(requestId); + if (pendingCommand.isEmpty()) { + return; + } + + OrderRequestSyncCommand command = pendingCommand.get(); + OrderRequestSyncResult result = syncAdapter.sync(command); + if (result == OrderRequestSyncResult.MISSING && !Instant.now().isBefore(command.expiresAt())) { + stateService.clearPending(command.requestId(), command.desiredVersion()); + return; + } + if (result != OrderRequestSyncResult.APPLIED + && result != OrderRequestSyncResult.ALREADY_APPLIED) { + throw new IllegalStateException("Redis 주문 최종 상태를 반영할 수 없습니다: " + result); + } + stateService.clearPending(command.requestId(), command.desiredVersion()); + } + + public void defer(UUID requestId, Duration delay) { + stateService.defer(requestId, Instant.now().plus(delay)); + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRequestRedisSyncStateService.java b/src/main/java/com/dropit/order/service/OrderRequestRedisSyncStateService.java new file mode 100644 index 0000000..093c78a --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRequestRedisSyncStateService.java @@ -0,0 +1,59 @@ +package com.dropit.order.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.redis.OrderRequestSyncCommand; +import com.dropit.order.repository.OrderRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class OrderRequestRedisSyncStateService { + + private final OrderRequestRepository orderRequestRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional loadPending(UUID requestId) { + OrderRequest request = findForUpdate(requestId); + if (!request.isRedisSyncPending()) { + return Optional.empty(); + } + + return Optional.of(new OrderRequestSyncCommand( + request.getId(), + request.getDesiredRedisState(), + request.getOrderId(), + request.getUserId(), + request.getDropId(), + request.getIdempotencyKeyHash(), + request.getQuantity(), + request.getFailureCode(), + request.getDesiredVersion(), + request.getRedisExpiresAt() + )); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void clearPending(UUID requestId, long desiredVersion) { + OrderRequest request = findForUpdate(requestId); + request.completeRedisSync(desiredVersion); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void defer(UUID requestId, Instant nextAttemptAt) { + findForUpdate(requestId).deferRedisSync(nextAttemptAt); + } + + private OrderRequest findForUpdate(UUID requestId) { + return orderRequestRepository.findByRequestIdForUpdate(requestId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_REQUEST_NOT_FOUND)); + } +} diff --git a/src/main/java/com/dropit/order/service/OrderRequestRegistrationService.java b/src/main/java/com/dropit/order/service/OrderRequestRegistrationService.java new file mode 100644 index 0000000..e4ab61d --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderRequestRegistrationService.java @@ -0,0 +1,61 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.messaging.InvalidOrderMessageException; +import com.dropit.order.messaging.OrderMessage; +import com.dropit.order.repository.OrderRequestRepository; +import com.dropit.order.validation.OrderMessageValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderRequestRegistrationService { + + private final OrderRequestRepository orderRequestRepository; + private final DropRepository dropRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void register(OrderMessage message) { + OrderMessageValidator.validate(message); + + OrderRequest existing = orderRequestRepository.findById(message.requestId()).orElse(null); + if (existing != null) { + OrderMessageValidator.validateSameRequest(existing, message); + return; + } + + Drop drop = dropRepository.findById(message.dropId()) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + java.time.Instant redisExpiresAt = drop.getCloseAt() + .atZone(java.time.ZoneId.of("Asia/Seoul")) + .toInstant() + .plus(java.time.Duration.ofHours(24)); + orderRequestRepository.saveAndFlush(new OrderRequest( + message.requestId(), + message.userId(), + message.dropId(), + message.idempotencyKeyHash(), + message.requestFingerprint(), + message.productName(), + message.unitPrice(), + message.quantity(), + message.discountRate(), + OrderMessageValidator.acceptedAt(message), + redisExpiresAt + )); + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + public void verifyExisting(OrderMessage message) { + OrderRequest existing = orderRequestRepository.findById(message.requestId()) + .orElseThrow(() -> new InvalidOrderMessageException("중복된 주문 요청 원장을 찾을 수 없습니다.")); + OrderMessageValidator.validateSameRequest(existing, message); + } +} diff --git a/src/main/java/com/dropit/order/service/OrderService.java b/src/main/java/com/dropit/order/service/OrderService.java index 9e17ef2..ccea7d1 100644 --- a/src/main/java/com/dropit/order/service/OrderService.java +++ b/src/main/java/com/dropit/order/service/OrderService.java @@ -1,63 +1,26 @@ package com.dropit.order.service; -import com.dropit.drop.entity.Drop; -import com.dropit.drop.exception.DropErrorCode; -import com.dropit.drop.repository.DropRepository; import com.dropit.global.exception.ServiceException; -import com.dropit.order.dto.request.OrderCreateRequest; -import com.dropit.order.dto.request.OrderItemCreateRequest; import com.dropit.order.dto.response.OrderResponse; import com.dropit.order.dto.response.OrderSummaryResponse; import com.dropit.order.entity.Order; import com.dropit.order.entity.OrderItem; -import com.dropit.order.entity.OrderStatus; import com.dropit.order.exception.OrderErrorCode; import com.dropit.order.repository.OrderItemRepository; import com.dropit.order.repository.OrderRepository; -import com.dropit.user.entity.User; -import com.dropit.user.exception.UserErrorCode; -import com.dropit.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; @Service @RequiredArgsConstructor public class OrderService { - private final UserRepository userRepository; - private final DropRepository dropRepository; private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; - @Transactional - public OrderResponse create(Long userId, OrderCreateRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ServiceException(UserErrorCode.USER_NOT_FOUND)); - - OrderItemCreateRequest itemRequest = request.items().getFirst(); - Drop drop = dropRepository.findById(itemRequest.dropId()) - .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); - LocalDateTime now = LocalDateTime.now(); - validatePurchaseLimit(userId, drop, itemRequest.quantity()); - drop.decreaseStock(itemRequest.quantity(), now); - BigDecimal totalPrice = OrderItem.calculateItemTotalPrice( - drop.getPrice(), - drop.getDiscountRate(), - itemRequest.quantity() - ); - - Order order = orderRepository.save(new Order(user, totalPrice)); - List orderItems = List.of(createOrderItem(order, drop, itemRequest.quantity())); - List savedOrderItems = orderItemRepository.saveAll(orderItems); - - return OrderResponse.from(order, savedOrderItems); - } - @Transactional(readOnly = true) public List getMyOrders(Long userId) { return orderRepository.findAllByUser_IdOrderByCreatedAtDesc(userId).stream() @@ -74,41 +37,4 @@ public OrderResponse getMyOrder(Long userId, Long orderId) { return OrderResponse.from(order, orderItems); } - @Transactional - public void cancel(Long userId, Long orderId) { - Order order = orderRepository.findByIdAndUser_Id(orderId, userId) - .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); - List orderItems = orderItemRepository.findAllByOrder_IdOrderByIdAsc(orderId); - - order.cancel(); - for (OrderItem orderItem : orderItems) { - orderItem.getDrop().restoreStock(orderItem.getQuantity()); - } - } - - private void validatePurchaseLimit(Long userId, Drop drop, int quantity) { - if (drop.getPurchaseLimit() == 0) { - return; - } - - long purchasedQuantity = orderItemRepository.sumQuantityByUserAndDropAndStatus( - userId, - drop.getId(), - OrderStatus.ORDERED - ); - if (purchasedQuantity + quantity > drop.getPurchaseLimit()) { - throw new ServiceException(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED); - } - } - - private OrderItem createOrderItem(Order order, Drop drop, int quantity) { - return new OrderItem( - order, - drop, - drop.getProduct().getName(), - drop.getPrice(), - drop.getDiscountRate(), - quantity - ); - } } diff --git a/src/main/java/com/dropit/order/validation/OrderAdmissionValidator.java b/src/main/java/com/dropit/order/validation/OrderAdmissionValidator.java new file mode 100644 index 0000000..f492db1 --- /dev/null +++ b/src/main/java/com/dropit/order/validation/OrderAdmissionValidator.java @@ -0,0 +1,31 @@ +package com.dropit.order.validation; + +import com.dropit.global.exception.CommonErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.redis.OrderAdmissionRequest; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrderAdmissionValidator { + + public static void validate(OrderAdmissionRequest request) { + if (request == null + || request.requestId() == null + || request.userId() == null + || request.userId() <= 0 + || request.dropId() == null + || request.dropId() <= 0 + || request.idempotencyKeyHash() == null + || request.idempotencyKeyHash().isBlank() + || request.idempotencyKeyHash().length() > 64 + || request.quantity() <= 0 + || request.fingerprint() == null + || !request.fingerprint().equals( + OrderRequestFingerprint.of(request.dropId(), request.quantity()) + ) + || request.acceptedAtEpochMicros() <= 0) { + throw new ServiceException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/src/main/java/com/dropit/order/validation/OrderFinalizationValidator.java b/src/main/java/com/dropit/order/validation/OrderFinalizationValidator.java new file mode 100644 index 0000000..bc928d4 --- /dev/null +++ b/src/main/java/com/dropit/order/validation/OrderFinalizationValidator.java @@ -0,0 +1,54 @@ +package com.dropit.order.validation; + +import com.dropit.global.exception.CommonErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderFinalizationRequest; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.exception.OrderErrorCode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrderFinalizationValidator { + + public static void validate(OrderFinalizationRequest input) { + if (input == null + || input.requestId() == null + || input.userId() == null + || input.dropId() == null + || isBlank(input.idempotencyKeyHash()) + || input.idempotencyKeyHash().length() > 64 + || isBlank(input.payloadHash()) + || input.payloadHash().length() > 64 + || input.quantity() <= 0 + || isBlank(input.productName()) + || input.productName().length() > 100 + || input.unitPrice() == null + || input.unitPrice().signum() <= 0 + || input.unitPrice().scale() > 0 + || input.unitPrice().precision() - input.unitPrice().scale() > 13 + || input.discountRate() < 0 + || input.discountRate() > 100) { + throw new ServiceException(CommonErrorCode.INVALID_INPUT); + } + } + + public static void validateStoredRequest(OrderRequest request, OrderFinalizationRequest input) { + if (!request.matches( + input.userId(), + input.dropId(), + input.idempotencyKeyHash(), + input.payloadHash(), + input.quantity(), + input.productName(), + input.unitPrice(), + input.discountRate() + )) { + throw new ServiceException(OrderErrorCode.ORDER_REQUEST_PAYLOAD_MISMATCH); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/dropit/order/validation/OrderIdempotencyKeyHasher.java b/src/main/java/com/dropit/order/validation/OrderIdempotencyKeyHasher.java new file mode 100644 index 0000000..5a4cf7c --- /dev/null +++ b/src/main/java/com/dropit/order/validation/OrderIdempotencyKeyHasher.java @@ -0,0 +1,22 @@ +package com.dropit.order.validation; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrderIdempotencyKeyHasher { + + public static String sha256(String rawKey) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(rawKey.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 해시를 사용할 수 없습니다.", exception); + } + } +} diff --git a/src/main/java/com/dropit/order/validation/OrderMessageValidator.java b/src/main/java/com/dropit/order/validation/OrderMessageValidator.java new file mode 100644 index 0000000..149735c --- /dev/null +++ b/src/main/java/com/dropit/order/validation/OrderMessageValidator.java @@ -0,0 +1,75 @@ +package com.dropit.order.validation; + +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.messaging.InvalidOrderMessageException; +import com.dropit.order.messaging.OrderMessage; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrderMessageValidator { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Seoul"); + + public static void validate(OrderMessage message) { + if (message == null + || message.schemaVersion() != OrderMessage.CURRENT_SCHEMA_VERSION + || message.requestId() == null + || message.userId() == null || message.userId() <= 0 + || message.dropId() == null || message.dropId() <= 0 + || invalidHash(message.idempotencyKeyHash()) + || invalidHash(message.requestFingerprint()) + || !Objects.equals( + message.requestFingerprint(), + expectedFingerprint(message) + ) + || message.quantity() <= 0 + || message.acceptedAt() == null + || message.productName() == null || message.productName().isBlank() + || message.productName().length() > 100 + || message.unitPrice() == null + || message.unitPrice().signum() <= 0 + || message.unitPrice().scale() > 0 + || message.unitPrice().precision() - message.unitPrice().scale() > 13 + || message.discountRate() < 0 || message.discountRate() > 100) { + throw new InvalidOrderMessageException("지원하지 않거나 필수 값이 없는 주문 메시지입니다."); + } + } + + public static void validateSameRequest(OrderRequest request, OrderMessage message) { + if (!Objects.equals(request.getUserId(), message.userId()) + || !Objects.equals(request.getDropId(), message.dropId()) + || !Objects.equals(request.getIdempotencyKeyHash(), message.idempotencyKeyHash()) + || !Objects.equals(request.getPayloadHash(), message.requestFingerprint()) + || request.getQuantity() != message.quantity() + || !Objects.equals(request.getAcceptedAt(), acceptedAt(message)) + || !Objects.equals(request.getProductName(), message.productName()) + || request.getUnitPrice().compareTo(message.unitPrice()) != 0 + || request.getDiscountRate() != message.discountRate()) { + throw new InvalidOrderMessageException("동일 requestId에 서로 다른 주문 payload가 전달되었습니다."); + } + } + + public static LocalDateTime acceptedAt(OrderMessage message) { + return LocalDateTime.ofInstant( + message.acceptedAt().truncatedTo(ChronoUnit.MICROS), + BUSINESS_ZONE + ); + } + + private static boolean invalidHash(String value) { + return value == null || value.isBlank() || value.length() > 64; + } + + private static String expectedFingerprint(OrderMessage message) { + if (message == null || message.dropId() == null || message.dropId() <= 0 || message.quantity() <= 0) { + return null; + } + return OrderRequestFingerprint.of(message.dropId(), message.quantity()); + } +} diff --git a/src/main/java/com/dropit/order/validation/OrderRequestFingerprint.java b/src/main/java/com/dropit/order/validation/OrderRequestFingerprint.java new file mode 100644 index 0000000..de479fe --- /dev/null +++ b/src/main/java/com/dropit/order/validation/OrderRequestFingerprint.java @@ -0,0 +1,15 @@ +package com.dropit.order.validation; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrderRequestFingerprint { + + public static String of(Long dropId, int quantity) { + if (dropId == null || dropId <= 0 || quantity <= 0) { + throw new IllegalArgumentException("dropId and quantity must be positive"); + } + return dropId + ":" + quantity; + } +} diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java index 3cfc494..59280b6 100644 --- a/src/main/java/com/dropit/product/controller/ProductController.java +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -100,5 +100,13 @@ public ResponseEntity uploadImage( return ResponseEntity.ok(response); } - + @DeleteMapping("/products/{productId}/image") + @PreAuthorize("hasRole('SELLER')") + public ResponseEntity deleteImage( + @CurrentUserId Long sellerId, + @PathVariable Long productId + ) { + productService.deleteImage(sellerId, productId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/dropit/product/exception/ProductErrorCode.java b/src/main/java/com/dropit/product/exception/ProductErrorCode.java index ca63a44..db809ab 100644 --- a/src/main/java/com/dropit/product/exception/ProductErrorCode.java +++ b/src/main/java/com/dropit/product/exception/ProductErrorCode.java @@ -10,7 +10,8 @@ public enum ProductErrorCode implements ErrorCode { SELLER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 판매자입니다."), SELLER_ROLE_REQUIRED(HttpStatus.FORBIDDEN, "판매자만 상품을 등록할 수 있습니다."), PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다."), - PRODUCT_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "상품 소유자만 변경할 수 있습니다."); + PRODUCT_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "상품 소유자만 변경할 수 있습니다."), + PRODUCT_IN_USE_BY_DROP(HttpStatus.CONFLICT, "판매 일정에서 사용 중인 상품은 삭제할 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/dropit/product/repository/ProductRepository.java b/src/main/java/com/dropit/product/repository/ProductRepository.java index 5b13567..94947f6 100644 --- a/src/main/java/com/dropit/product/repository/ProductRepository.java +++ b/src/main/java/com/dropit/product/repository/ProductRepository.java @@ -4,8 +4,18 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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 jakarta.persistence.LockModeType; +import java.util.Optional; public interface ProductRepository extends JpaRepository { Page findAllBySellerId(Long sellerId, Pageable pageable); + + @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/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index 0d8cdfc..774f7cf 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -1,5 +1,6 @@ package com.dropit.product.service; +import com.dropit.drop.repository.DropRepository; import com.dropit.global.exception.ServiceException; import com.dropit.global.storage.S3ImageService; import com.dropit.product.dto.request.ProductCreateRequest; @@ -26,6 +27,7 @@ public class ProductService { private final ProductRepository productRepository; private final UserRepository userRepository; + private final DropRepository dropRepository; private final S3ImageService s3ImageService; @Transactional @@ -142,7 +144,14 @@ public ProductResponse uploadImage( @Transactional public void delete(Long sellerId, Long productId) { - Product product = findOwnedProduct(sellerId, productId); + Product product = findOwnedProductForUpdate(sellerId, productId); + + if (dropRepository.existsByProductId(productId)) { + throw new ServiceException( + ProductErrorCode.PRODUCT_IN_USE_BY_DROP + ); + } + String imageKey = product.getImageUrl(); productRepository.delete(product); @@ -150,6 +159,15 @@ public void delete(Long sellerId, Long productId) { deleteImageAfterCommit(imageKey); } + @Transactional + public void deleteImage(Long sellerId, Long productId) { + Product product = findOwnedProduct(sellerId, productId); + + String imageKey = product.getImageUrl(); + product.changeImage(null); + deleteImageAfterCommit(imageKey); + } + private Product findOwnedProduct( Long sellerId, Long productId @@ -161,23 +179,43 @@ private Product findOwnedProduct( ) ); + validateOwner(sellerId, product); + + return product; + } + + private Product findOwnedProductForUpdate( + Long sellerId, + Long productId + ) { + Product product = productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> + new ServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND + ) + ); + + validateOwner(sellerId, product); + + return product; + } + + private void validateOwner(Long sellerId, Product product) { if (!product.getSeller().getId().equals(sellerId)) { throw new ServiceException( ProductErrorCode.PRODUCT_OWNER_REQUIRED ); } - + if (product.getSeller().getRole() != UserRole.SELLER) { throw new ServiceException( ProductErrorCode.SELLER_ROLE_REQUIRED ); } - - return product; } private ProductResponse toResponse(Product product) { - String imageUrl = s3ImageService.createDownloadUrl( + String imageUrl = s3ImageService.createPublicUrl( product.getImageUrl() ); @@ -229,4 +267,4 @@ public void afterCommit() { } ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java index 238449d..aa57b21 100644 --- a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java +++ b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java @@ -53,6 +53,13 @@ public ResponseEntity getMine( return ResponseEntity.ok(response); } + @DeleteMapping("/me/image") + @PreAuthorize("hasRole('SELLER')") + public ResponseEntity deleteImage(@CurrentUserId Long userId) { + sellerProfileService.deleteImage(userId); + return ResponseEntity.noContent().build(); + } + @GetMapping("/{sellerProfileId}") public ResponseEntity getById( @PathVariable Long sellerProfileId diff --git a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java index d8aab91..e68c96e 100644 --- a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java +++ b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java @@ -141,6 +141,15 @@ public void delete(Long userId) { deleteImageAfterCommit(imageKey); } + @Transactional + public void deleteImage(Long userId) { + SellerProfile sellerProfile = findByUserId(userId); + + String imageKey = sellerProfile.getImageUrl(); + sellerProfile.changeImage(null); + deleteImageAfterCommit(imageKey); + } + private User findUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> @@ -162,12 +171,12 @@ private SellerProfile findByUserId(Long userId) { /* * The entity stores the private S3 object key. - * The response receives a temporary presigned URL. + * The response receives a public S3 object URL. */ private SellerProfileResponse toResponse( SellerProfile sellerProfile ) { - String imageUrl = s3ImageService.createDownloadUrl( + String imageUrl = s3ImageService.createPublicUrl( sellerProfile.getImageUrl() ); @@ -228,4 +237,4 @@ public void afterCommit() { } ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dropit/wishlist/.DS_Store b/src/main/java/com/dropit/wishlist/.DS_Store new file mode 100644 index 0000000..5008bcc Binary files /dev/null and b/src/main/java/com/dropit/wishlist/.DS_Store differ diff --git a/src/main/java/com/dropit/wishlist/controller/WishlistController.java b/src/main/java/com/dropit/wishlist/controller/WishlistController.java new file mode 100644 index 0000000..c2f323d --- /dev/null +++ b/src/main/java/com/dropit/wishlist/controller/WishlistController.java @@ -0,0 +1,53 @@ +package com.dropit.wishlist.controller; + +import com.dropit.global.security.principal.CurrentUserId; +import com.dropit.wishlist.dto.response.WishlistResponse; +import com.dropit.wishlist.service.WishlistService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/wishlists") +public class WishlistController { + + private final WishlistService wishlistService; + + @PostMapping("/{productId}") + public ResponseEntity add( + @CurrentUserId Long userId, + @PathVariable Long productId + ) { + WishlistResponse response = + wishlistService.add(userId, productId); + + return ResponseEntity.ok(response); + } + + @GetMapping("/me") + public ResponseEntity> getMine( + @CurrentUserId Long userId + ) { + return ResponseEntity.ok( + wishlistService.getMine(userId) + ); + } + + @DeleteMapping("/{productId}") + public ResponseEntity delete( + @CurrentUserId Long userId, + @PathVariable Long productId + ) { + wishlistService.delete(userId, productId); + + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/wishlist/dto/response/WishlistResponse.java b/src/main/java/com/dropit/wishlist/dto/response/WishlistResponse.java new file mode 100644 index 0000000..cc53801 --- /dev/null +++ b/src/main/java/com/dropit/wishlist/dto/response/WishlistResponse.java @@ -0,0 +1,24 @@ +package com.dropit.wishlist.dto.response; + +import com.dropit.wishlist.entity.Wishlist; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class WishlistResponse { + + private final Long id; + private final Long productId; + private final String productName; + private final String productImageUrl; + private final LocalDateTime createdAt; + + public WishlistResponse(Wishlist wishlist) { + this.id = wishlist.getId(); + this.productId = wishlist.getProduct().getId(); + this.productName = wishlist.getProduct().getName(); + this.productImageUrl = wishlist.getProduct().getImageUrl(); + this.createdAt = wishlist.getCreatedAt(); + } +} diff --git a/src/main/java/com/dropit/wishlist/entity/Wishlist.java b/src/main/java/com/dropit/wishlist/entity/Wishlist.java new file mode 100644 index 0000000..4889e85 --- /dev/null +++ b/src/main/java/com/dropit/wishlist/entity/Wishlist.java @@ -0,0 +1,49 @@ +package com.dropit.wishlist.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "wishlists", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_wishlist_user_product", + columnNames = {"user_id", "product_id"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Wishlist extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + public Wishlist(User user, Product product) { + this.user = user; + this.product = product; + } +} diff --git a/src/main/java/com/dropit/wishlist/exception/WishlistErrorCode.java b/src/main/java/com/dropit/wishlist/exception/WishlistErrorCode.java new file mode 100644 index 0000000..74290e8 --- /dev/null +++ b/src/main/java/com/dropit/wishlist/exception/WishlistErrorCode.java @@ -0,0 +1,28 @@ +package com.dropit.wishlist.exception; + + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum WishlistErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다."), + WISHLIST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 찜입니다."), + WISHLIST_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 찜한 상품입니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/wishlist/repository/WishlistRepository.java b/src/main/java/com/dropit/wishlist/repository/WishlistRepository.java new file mode 100644 index 0000000..70ee6c6 --- /dev/null +++ b/src/main/java/com/dropit/wishlist/repository/WishlistRepository.java @@ -0,0 +1,16 @@ +package com.dropit.wishlist.repository; + +import com.dropit.wishlist.entity.Wishlist; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface WishlistRepository extends JpaRepository { + + boolean existsByUser_IdAndProduct_Id(Long userId, Long productId); + + Optional findByUser_IdAndProduct_Id(Long userId, Long productId); + + List findAllByUser_Id(Long userId); +} diff --git a/src/main/java/com/dropit/wishlist/service/WishlistService.java b/src/main/java/com/dropit/wishlist/service/WishlistService.java new file mode 100644 index 0000000..2caae7b --- /dev/null +++ b/src/main/java/com/dropit/wishlist/service/WishlistService.java @@ -0,0 +1,83 @@ +package com.dropit.wishlist.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.repository.UserRepository; +import com.dropit.wishlist.dto.response.WishlistResponse; +import com.dropit.wishlist.entity.Wishlist; +import com.dropit.wishlist.exception.WishlistErrorCode; +import com.dropit.wishlist.repository.WishlistRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WishlistService { + + private final WishlistRepository wishlistRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + + @Transactional + public WishlistResponse add(Long userId, Long productId) { + User user = findUser(userId); + Product product = findProduct(productId); + + if (wishlistRepository.existsByUser_IdAndProduct_Id(userId, productId)) { + throw new ServiceException( + WishlistErrorCode.WISHLIST_ALREADY_EXISTS + ); + } + + Wishlist wishlist = new Wishlist(user, product); + Wishlist savedWishlist = wishlistRepository.save(wishlist); + + return new WishlistResponse(savedWishlist); + } + + public List getMine(Long userId) { + findUser(userId); + + return wishlistRepository.findAllByUser_Id(userId) + .stream() + .map(WishlistResponse::new) + .toList(); + } + + @Transactional + public void delete(Long userId, Long productId) { + Wishlist wishlist = + wishlistRepository.findByUser_IdAndProduct_Id(userId, productId) + .orElseThrow(() -> + new ServiceException( + WishlistErrorCode.WISHLIST_NOT_FOUND + ) + ); + + wishlistRepository.delete(wishlist); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> + new ServiceException( + WishlistErrorCode.USER_NOT_FOUND + ) + ); + } + + private Product findProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> + new ServiceException( + WishlistErrorCode.PRODUCT_NOT_FOUND + ) + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application-performance.properties b/src/main/resources/application-performance.properties new file mode 100644 index 0000000..6437227 --- /dev/null +++ b/src/main/resources/application-performance.properties @@ -0,0 +1,7 @@ +# Buffer the short connection burst generated at Drop opening time. +# This profile is enabled only while measuring the local performance scenario. +server.tomcat.accept-count=10000 +server.tomcat.max-connections=12000 + +# Let blocking HTTP requests wait on lightweight Java 21 virtual threads. +spring.threads.virtual.enabled=true diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 72bd2a3..f6b44f1 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,2 +1,3 @@ management.endpoint.health.show-details=never -spring.config.import=aws-parameterstore:/dropit-server/prod/ \ No newline at end of file +spring.config.import=aws-parameterstore:/dropit-server/prod/ +docs.swagger-ui.read-only=true diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d2acfc8..834f587 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -19,6 +19,22 @@ management.endpoints.web.exposure.include=health spring.cloud.aws.region.static=ap-northeast-2 spring.cloud.aws.s3.bucket=${AWS_S3_BUCKET} +# CloudFront CDN +app.cdn.domain=${AWS_CLOUDFRONT_DOMAIN} + +# SQS - order +app.order.sqs.order-queue-url=${AWS_SQS_ORDER_QUEUE_URL:} +app.order.sqs.consumer-enabled=${AWS_SQS_CONSUMER_ENABLED:false} +app.order.sqs.consumer-concurrency=${AWS_SQS_CONSUMER_CONCURRENCY:2} +app.order.sqs.consumer-max-number-of-messages=${AWS_SQS_CONSUMER_MAX_NUMBER_OF_MESSAGES:2} +app.order.sqs.consumer-wait-time-seconds=${AWS_SQS_CONSUMER_WAIT_TIME_SECONDS:10} +app.order.sqs.consumer-visibility-timeout-seconds=${AWS_SQS_CONSUMER_VISIBILITY_TIMEOUT_SECONDS:60} +app.order.sqs.publisher-timeout-millis=${AWS_SQS_PUBLISHER_TIMEOUT_MILLIS:3000} + # Multipart upload limits spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB \ No newline at end of file +spring.servlet.multipart.max-request-size=10MB + +# Swagger UI Basic Auth credentials +docs.auth.username=${DOCS_AUTH_USERNAME} +docs.auth.password=${DOCS_AUTH_PASSWORD} diff --git a/src/main/resources/redis/prepare-drop-sale.lua b/src/main/resources/redis/prepare-drop-sale.lua new file mode 100644 index 0000000..3121718 --- /dev/null +++ b/src/main/resources/redis/prepare-drop-sale.lua @@ -0,0 +1,37 @@ +local saleKey = KEYS[1] +local stockKey = KEYS[2] +local controlKey = KEYS[3] + +local operationVersion = tonumber(ARGV[1]) +local saleVersion = tonumber(ARGV[2]) +local currentOperationVersion = tonumber(redis.call('HGET', controlKey, 'operationVersion') or '-1') +local currentSaleVersion = tonumber(redis.call('HGET', controlKey, 'saleVersion') or '-1') + +if currentOperationVersion > operationVersion then + return 0 +end + +redis.call('HSET', controlKey, 'operationVersion', ARGV[1], 'saleVersion', ARGV[2]) +redis.call('PEXPIREAT', controlKey, ARGV[11]) + +if ARGV[3] ~= 'true' then + redis.call('DEL', saleKey) + return 2 +end + +if currentSaleVersion < saleVersion then + redis.call('SET', stockKey, ARGV[10]) +end + +redis.call('HSET', saleKey, + 'operationVersion', ARGV[1], + 'saleVersion', ARGV[2], + 'openAt', ARGV[4], + 'closeAt', ARGV[5], + 'unitPrice', ARGV[6], + 'discountRate', ARGV[7], + 'purchaseLimit', ARGV[8], + 'productName', ARGV[9]) +redis.call('PEXPIREAT', saleKey, ARGV[5]) +redis.call('PEXPIREAT', stockKey, ARGV[11]) +return 1 diff --git a/src/main/resources/redis/reserve-order.lua b/src/main/resources/redis/reserve-order.lua new file mode 100644 index 0000000..25b34e0 --- /dev/null +++ b/src/main/resources/redis/reserve-order.lua @@ -0,0 +1,85 @@ +-- Redis는 이 스크립트 전체를 하나의 원자적 연산으로 실행한다. +-- 따라서 idempotency, 재고, 사용자 예약량의 결과를 이 실행 안에서 함께 결정한다. +-- KEYS[1] = {drop:}:sale +-- KEYS[2] = {drop:}:stock +-- KEYS[3] = {drop:}:purchases +-- KEYS[4] = {drop:}:idempotency:user:: +-- ARGV[1..7] = requestId, fingerprint, quantity, acceptedAt micros, userId, dropId, key hash +local request_id = ARGV[1] +local fingerprint = ARGV[2] +local quantity = tonumber(ARGV[3]) +local accepted_at = ARGV[4] +local user_id = ARGV[5] +local drop_id = ARGV[6] +local key_hash = ARGV[7] + +local existing_request_id = redis.call('HGET', KEYS[4], 'requestId') +if existing_request_id then + -- replay/conflict는 기존 idempotency 기록만 읽으며 재고와 예약량을 변경하지 않는다. + local existing_fingerprint = redis.call('HGET', KEYS[4], 'fingerprint') + if existing_fingerprint == fingerprint then + return 'REPLAY|' .. existing_request_id + end + return 'CONFLICT' +end + +if redis.call('TYPE', KEYS[1]).ok ~= 'hash' + or redis.call('TYPE', KEYS[2]).ok ~= 'string' + or (redis.call('TYPE', KEYS[3]).ok ~= 'none' and redis.call('TYPE', KEYS[3]).ok ~= 'hash') then + return 'NOT_READY' +end + +local purchase_limit = tonumber(redis.call('HGET', KEYS[1], 'purchaseLimit')) +local open_at = tonumber(redis.call('HGET', KEYS[1], 'openAt')) +local close_at = tonumber(redis.call('HGET', KEYS[1], 'closeAt')) +local accepted_at_millis = math.floor(tonumber(accepted_at) / 1000) +local expires_at = close_at + 86400000 +if not quantity or quantity < 1 or quantity % 1 ~= 0 + or not purchase_limit or not open_at or not close_at or not accepted_at_millis then + return 'NOT_READY' +end +if accepted_at_millis < open_at or accepted_at_millis >= close_at then + return 'NOT_OPEN' +end + +local stock = tonumber(redis.call('GET', KEYS[2])) +if not stock then + return 'NOT_READY' +end +if stock < quantity then + -- 실패 결과는 여기서 즉시 반환하므로 신규 idempotency 기록을 남기지 않는다. + return 'OUT_OF_STOCK' +end + +local purchased_quantity = tonumber(redis.call('HGET', KEYS[3], user_id) or '0') +if purchase_limit ~= 0 and purchased_quantity + quantity > purchase_limit then + -- 실패 결과는 여기서 즉시 반환하므로 신규 idempotency 기록을 남기지 않는다. + return 'PURCHASE_LIMIT_EXCEEDED' +end + +-- 신규 성공 요청의 차감, 예약량 증가, idempotency 기록, 24시간 TTL 설정도 +-- 위 검증과 같은 원자적 스크립트 실행 안에서 함께 완료한다. +redis.call('DECRBY', KEYS[2], quantity) +redis.call('HINCRBY', KEYS[3], user_id, quantity) +redis.call( + 'HSET', + KEYS[4], + 'requestId', request_id, + 'userId', user_id, + 'dropId', drop_id, + 'idempotencyKeyHash', key_hash, + 'fingerprint', fingerprint, + 'quantity', quantity, + 'acceptedAtEpochMicros', accepted_at, + 'status', 'RESERVED', + 'publicationState', 'UNCONFIRMED', + 'productName', redis.call('HGET', KEYS[1], 'productName'), + 'unitPrice', redis.call('HGET', KEYS[1], 'unitPrice'), + 'discountRate', redis.call('HGET', KEYS[1], 'discountRate'), + 'expiresAtEpochMillis', expires_at +) +redis.call('PEXPIREAT', KEYS[2], expires_at) +redis.call('PEXPIREAT', KEYS[3], expires_at) +redis.call('PEXPIREAT', KEYS[4], expires_at) + +return 'NEW|' .. request_id diff --git a/src/main/resources/redis/sync-order-request.lua b/src/main/resources/redis/sync-order-request.lua new file mode 100644 index 0000000..a36a426 --- /dev/null +++ b/src/main/resources/redis/sync-order-request.lua @@ -0,0 +1,73 @@ +-- Redis는 이 스크립트 전체를 하나의 원자적 연산으로 실행한다. +-- DB 주문 처리가 끝난 뒤, Redis에 남아 있는 예약을 최종 상태로 바꾸는 후처리 스크립트 +-- SUCCEEDED: (확정) 이미 차감된 예약 수량은 그대로 두고 상태만 확정한다. +-- FAILED: (복원) 예약 당시 차감했던 재고와 구매량을 한 번 되돌린 뒤 상태를 확정한다. +-- KEYS[1] = {drop:}:idempotency:user:: +-- KEYS[2] = {drop:}:stock +-- KEYS[3] = {drop:}:purchase:user: +-- ARGV[1] = requestId, ARGV[2] = 최종 상태(SUCCEEDED/FAILED), ARGV[3] = quantity, +-- ARGV[4] = orderId, ARGV[5] = failureCode, ARGV[6] = DB 동기화 버전, ARGV[7] = userId +local reservationKey = KEYS[1] +local stockKey = KEYS[2] +local purchaseKey = KEYS[3] + +local requestId = ARGV[1] +local targetStatus = ARGV[2] +local quantity = tonumber(ARGV[3]) +local orderId = ARGV[4] +local failureCode = ARGV[5] +local desiredVersion = ARGV[6] +local userId = ARGV[7] + +if redis.call('TYPE', reservationKey).ok ~= 'hash' then + -- 어떤 예약을 확정해야 하는지 모르므로 재고·구매량을 임의로 바꾸지 않는다. + return 'MISSING' +end + +if redis.call('HGET', reservationKey, 'requestId') ~= requestId then + -- TTL 만료 뒤 같은 키가 재사용되어도 다른 요청의 예약을 변경하지 않는다. + return 'INVALID' +end + +local currentStatus = redis.call('HGET', reservationKey, 'status') +if tonumber(redis.call('HGET', reservationKey, 'quantity')) ~= quantity then + -- 같은 키가 재사용됐더라도 DB 요청 수량과 다른 예약은 절대 변경하지 않는다. + return 'INVALID' +end +if currentStatus == targetStatus then + -- 같은 SQS 메시지가 다시 와도 이미 끝난 작업이므로 아무것도 하지 않는다. + return 'ALREADY_APPLIED' +end +if targetStatus == 'CANCELED' then + if currentStatus ~= 'RESERVED' and currentStatus ~= 'SUCCEEDED' then + return 'INVALID' + end +elseif currentStatus ~= 'RESERVED' then + -- 이미 다른 최종 상태가 기록됐다면 그 결정을 바꾸지 않는다. + return 'INVALID' +end +if targetStatus == 'FAILED' or targetStatus == 'CANCELED' then + -- DB 주문 생성이 실패했으므로 Redis에 미리 잡아 둔 자리를 다시 돌려준다. + if redis.call('TYPE', stockKey).ok ~= 'string' + or redis.call('TYPE', purchaseKey).ok ~= 'hash' then + return 'INVALID' + end + local purchased = tonumber(redis.call('HGET', purchaseKey, userId)) + if not purchased or purchased < quantity then + return 'INVALID' + end + redis.call('INCRBY', stockKey, quantity) + redis.call('HINCRBY', purchaseKey, userId, -quantity) +elseif targetStatus ~= 'SUCCEEDED' then + -- 예약은 성공 확정 또는 실패 반환만 가능하다. + return 'INVALID' +end + +-- 상태를 RESERVED에서 최종 상태로 바꾸면, 다음 재시도는 위의 ALREADY_APPLIED로 끝난다. +redis.call('HSET', reservationKey, + 'status', targetStatus, + 'orderId', orderId, + 'failureCode', failureCode, + 'appliedVersion', desiredVersion) + +return 'APPLIED' diff --git a/src/main/resources/static/swagger-ui/index.html b/src/main/resources/static/swagger-ui/index.html new file mode 100644 index 0000000..2d57074 --- /dev/null +++ b/src/main/resources/static/swagger-ui/index.html @@ -0,0 +1,26 @@ + + + + + + DROPIT API + + + +
+ + + + + diff --git a/src/test/java/com/.DS_Store b/src/test/java/com/.DS_Store new file mode 100644 index 0000000..61d40a3 Binary files /dev/null and b/src/test/java/com/.DS_Store differ diff --git a/src/test/java/com/dropit/.DS_Store b/src/test/java/com/dropit/.DS_Store new file mode 100644 index 0000000..8a9b308 Binary files /dev/null and b/src/test/java/com/dropit/.DS_Store differ diff --git a/src/test/java/com/dropit/auth/controller/AuthDocumentationTest.java b/src/test/java/com/dropit/auth/controller/AuthDocumentationTest.java new file mode 100644 index 0000000..3916164 --- /dev/null +++ b/src/test/java/com/dropit/auth/controller/AuthDocumentationTest.java @@ -0,0 +1,91 @@ +package com.dropit.auth.controller; + +import com.dropit.auth.dto.response.TokenResponse; +import com.dropit.auth.service.AuthService; +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(RestDocumentationExtension.class) +class AuthDocumentationTest extends DocumentationTestSupport { + + private AuthService authService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + authService = mock(AuthService.class); + configure(restDocumentation, new AuthController(authService)); + } + + @Test + void signup() throws Exception { + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"user@example.com","password":"password123","username":"user","role":"USER"} + """)) + .andExpect(status().isCreated()) + .andDo(document("auth-signup", resource(builder() + .tag("Auth").summary("회원가입").description("사용자 계정을 생성합니다.") + .requestSchema(new Schema("SignupRequest")) + .requestFields( + fieldWithPath("email").description("이메일 주소"), + fieldWithPath("password").description("8자 이상의 비밀번호"), + fieldWithPath("username").description("사용자 이름"), + fieldWithPath("role").description("사용자 역할 (USER 또는 SELLER)") + ).build()))); + } + + @Test + void login() throws Exception { + when(authService.login(any())).thenReturn(new TokenResponse("access-token", "refresh-token")); + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"user@example.com\",\"password\":\"password123\"}")) + .andExpect(status().isOk()) + .andDo(document("auth-login", resource(builder() + .tag("Auth").summary("로그인").description("이메일과 비밀번호로 로그인합니다.") + .requestSchema(new Schema("LoginRequest")) + .requestFields( + fieldWithPath("email").description("이메일 주소"), + fieldWithPath("password").description("비밀번호") + ) + .responseFields( + fieldWithPath("accessToken").type(STRING).description("접근 토큰"), + fieldWithPath("refreshToken").type(STRING).description("갱신 토큰") + ).responseSchema(new Schema("AuthTokenResponse")).build()))); + } + + @Test + void reissue() throws Exception { + when(authService.reissue(any())).thenReturn(new TokenResponse("new-access-token", "new-refresh-token")); + mockMvc.perform(post("/auth/reissue") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"refresh-token\"}")) + .andExpect(status().isOk()) + .andDo(document("auth-reissue", resource(builder() + .tag("Auth").summary("토큰 재발급").description("Refresh Token으로 토큰을 재발급합니다.") + .requestSchema(new Schema("ReissueRequest")) + .requestFields(fieldWithPath("refreshToken").description("갱신 토큰")) + .responseFields( + fieldWithPath("accessToken").type(STRING).description("접근 토큰"), + fieldWithPath("refreshToken").type(STRING).description("갱신 토큰") + ).responseSchema(new Schema("AuthTokenResponse")).build()))); + } +} diff --git a/src/test/java/com/dropit/documentation/DocumentationTestSupport.java b/src/test/java/com/dropit/documentation/DocumentationTestSupport.java new file mode 100644 index 0000000..13fbd3a --- /dev/null +++ b/src/test/java/com/dropit/documentation/DocumentationTestSupport.java @@ -0,0 +1,75 @@ +package com.dropit.documentation; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.junit.jupiter.api.AfterEach; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; + +/** Common MockMvc wiring for isolated REST Docs contract tests. */ +public abstract class DocumentationTestSupport { + + protected static final String DOCUMENTATION_ACCESS_TOKEN = + "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwidHlwZSI6IkFDQ0VTUyJ9.signature"; + + protected MockMvc mockMvc; + + protected void configure( + RestDocumentationContextProvider restDocumentation, + Object controller, + HandlerMethodArgumentResolver... additionalResolvers + ) { + List resolvers = new ArrayList<>(); + resolvers.add(new AuthenticationPrincipalArgumentResolver()); + resolvers.addAll(Arrays.asList(additionalResolvers)); + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(resolvers.toArray(HandlerMethodArgumentResolver[]::new)) + .apply(documentationConfiguration(restDocumentation)) + .build(); + + authenticate(1L); + } + + protected void authenticate(Long userId) { + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken(new AuthUser(userId), List.of()) + ); + } + + protected RequestPostProcessor bearerToken() { + return request -> { + request.addHeader("Authorization", DOCUMENTATION_ACCESS_TOKEN); + return request; + }; + } + + protected org.springframework.restdocs.headers.HeaderDescriptor authorizationHeader() { + return headerWithName("Authorization").description("Bearer access token"); + } + + protected MockHttpServletRequestBuilder authenticated(MockHttpServletRequestBuilder request) { + return request.with(bearerToken()); + } + + @AfterEach + protected void clearAuthentication() { + SecurityContextHolder.clearContext(); + } +} diff --git a/src/test/java/com/dropit/drop/cache/DropDetailCacheFallbackTest.java b/src/test/java/com/dropit/drop/cache/DropDetailCacheFallbackTest.java new file mode 100644 index 0000000..6cd0666 --- /dev/null +++ b/src/test/java/com/dropit/drop/cache/DropDetailCacheFallbackTest.java @@ -0,0 +1,112 @@ +package com.dropit.drop.cache; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.config.FailOpenCacheErrorHandler; +import com.dropit.global.config.RedisCacheConfig; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig +@Import({DropDetailCacheReader.class, DropDetailCacheFallbackTest.FailingCacheConfig.class}) +class DropDetailCacheFallbackTest { + + @MockitoBean + private DropRepository dropRepository; + + @Autowired + private DropDetailCacheReader cacheReader; + + @Test + void cacheFailureFallsBackToDatabase() { + Drop drop = visibleDrop(); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(drop)); + + DropDetailCacheValue result = cacheReader.get(100L); + + assertEquals(100L, result.id()); + verify(dropRepository).findDetailById(100L); + } + + private Drop visibleDrop() { + User seller = new User("seller@cache.test", "password", "cache-seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + Product product = new Product(seller, "Limited Hoodie", "description", null); + ReflectionTestUtils.setField(product, "id", 10L); + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 100, 10, 2, openAt, openAt.plusDays(1)); + drop.changeVisibility(true); + ReflectionTestUtils.setField(drop, "id", 100L); + return drop; + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableCaching + static class FailingCacheConfig implements CachingConfigurer { + + @Bean + @Override + public CacheManager cacheManager() { + Cache cache = new FailingCache(RedisCacheConfig.DROP_DETAIL_CACHE); + return new CacheManager() { + @Override + public Cache getCache(String name) { + return RedisCacheConfig.DROP_DETAIL_CACHE.equals(name) ? cache : null; + } + + @Override + public Collection getCacheNames() { + return List.of(RedisCacheConfig.DROP_DETAIL_CACHE); + } + }; + } + + @Override + public CacheErrorHandler errorHandler() { + return new FailOpenCacheErrorHandler(); + } + } + + static class FailingCache extends ConcurrentMapCache { + + FailingCache(String name) { + super(name); + } + + @Override + public T get(Object key, Callable valueLoader) { + throw new IllegalStateException("Redis unavailable"); + } + + @Override + public void put(Object key, Object value) { + throw new IllegalStateException("Redis unavailable"); + } + } +} diff --git a/src/test/java/com/dropit/drop/cache/DropDetailCacheReaderTest.java b/src/test/java/com/dropit/drop/cache/DropDetailCacheReaderTest.java new file mode 100644 index 0000000..e030e38 --- /dev/null +++ b/src/test/java/com/dropit/drop/cache/DropDetailCacheReaderTest.java @@ -0,0 +1,56 @@ +package com.dropit.drop.cache; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DropDetailCacheReaderTest { + + @Mock + private DropRepository dropRepository; + + @InjectMocks + private DropDetailCacheReader cacheReader; + + @Test + void hiddenDropIsNotCachedAsPublicDetail() { + User seller = new User("seller@example.com", "password", "seller", UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + openAt, + openAt.plusDays(1) + ); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(hiddenDrop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> cacheReader.get(100L) + ); + + assertEquals(DropErrorCode.DROP_NOT_FOUND, exception.getErrorCode()); + } +} diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java index dd2ee4f..2ee61a0 100644 --- a/src/test/java/com/dropit/drop/controller/DropControllerTest.java +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -4,6 +4,7 @@ import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.entity.DropStatus; import com.dropit.drop.service.DropService; +import com.dropit.drop.service.DropVisibilityService; import com.dropit.global.exception.GlobalExceptionHandler; import com.dropit.global.security.authentication.JwtAuthenticationToken; import com.dropit.global.security.principal.AuthUser; @@ -42,6 +43,9 @@ class DropControllerTest { @Mock private DropService dropService; + @Mock + private DropVisibilityService dropVisibilityService; + private MockMvc mockMvc; @BeforeEach @@ -50,7 +54,7 @@ void setUp() { validator.afterPropertiesSet(); mockMvc = MockMvcBuilders - .standaloneSetup(new DropController(dropService)) + .standaloneSetup(new DropController(dropService, dropVisibilityService)) .setControllerAdvice(new GlobalExceptionHandler()) .setCustomArgumentResolvers( new PageableHandlerMethodArgumentResolver(), @@ -201,7 +205,7 @@ void updateDrop() throws Exception { @Test @DisplayName("드랍 공개 여부 변경 요청 시 200 상태를 반환한다") void changeVisibility() throws Exception { - when(dropService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(response()); + when(dropVisibilityService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(response()); mockMvc.perform(patch("/drops/100/visibility") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/com/dropit/drop/controller/DropDocumentationTest.java b/src/test/java/com/dropit/drop/controller/DropDocumentationTest.java new file mode 100644 index 0000000..fe9e372 --- /dev/null +++ b/src/test/java/com/dropit/drop/controller/DropDocumentationTest.java @@ -0,0 +1,207 @@ +package com.dropit.drop.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.service.DropService; +import com.dropit.drop.service.DropVisibilityService; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class DropDocumentationTest extends DocumentationTestSupport { + + private DropService dropService; + private DropVisibilityService dropVisibilityService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + dropService = mock(DropService.class); + dropVisibilityService = mock(DropVisibilityService.class); + configure(restDocumentation, new DropController(dropService, dropVisibilityService), + new PageableHandlerMethodArgumentResolver()); + } + + @Test + void create() throws Exception { + when(dropService.save(eq(1L), any())).thenReturn(100L); + mockMvc.perform(authenticated(post("/drops").contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\":10,\"price\":59000,\"initialQuantity\":100,\"discountRate\":20,\"purchaseLimit\":2,\"openAt\":\"2030-01-01T10:00:00\",\"closeAt\":\"2030-01-02T10:00:00\"}"))) + .andExpect(status().isCreated()) + .andDo(document("drops-create", resource(builder() + .tag("Drops").summary("드랍 생성").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("DropCreateRequest")) + .requestFields( + fieldWithPath("productId").type(NUMBER).description("상품 ID"), + fieldWithPath("price").type(NUMBER).description("판매 가격"), + fieldWithPath("initialQuantity").type(NUMBER).description("초기 수량"), + fieldWithPath("discountRate").type(NUMBER).description("할인율"), + fieldWithPath("purchaseLimit").type(NUMBER).description("구매 제한 수량"), + fieldWithPath("openAt").type(STRING).description("판매 시작 시각"), + fieldWithPath("closeAt").type(STRING).description("판매 종료 시각") + ).build()))); + } + + @Test + void getAll() throws Exception { + when(dropService.getAll(any(), any())).thenReturn(new PageImpl<>(List.of(dropResponse()), PageRequest.of(0, 20), 1)); + mockMvc.perform(get("/drops").param("keyword", "hoodie").param("status", "OPEN").param("page", "0").param("size", "20")) + .andExpect(status().isOk()) + .andDo(document("drops-get-all", resource(builder() + .tag("Drops").summary("드랍 목록 조회").description("검색 조건과 페이징 조건으로 드랍을 조회합니다.") + .queryParameters( + parameterWithName("keyword").optional().description("상품명 검색어"), + parameterWithName("status").optional().description("드랍 상태"), + parameterWithName("sortType").optional().description("정렬 기준"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기"), + parameterWithName("sort").optional().description("정렬 조건") + ).responseSchema(new Schema("DropPageResponse")).build()))); + } + + @Test + void getOne() throws Exception { + when(dropService.getOne(100L)).thenReturn(dropResponse()); + mockMvc.perform(get("/drops/{dropId}", 100L)) + .andExpect(status().isOk()) + .andDo(document("drops-get", resource(builder() + .tag("Drops").summary("드랍 단건 조회") + .pathParameters(parameterWithName("dropId").description("드랍 ID")) + .responseSchema(new Schema("DropResponse")).responseFields(dropFields()).build()))); + } + + @Test + void getPublicDropsBySeller() throws Exception { + when(dropService.getPublicDropsBySeller(eq(1L), any())).thenReturn(new PageImpl<>(List.of(dropResponse()), PageRequest.of(0, 20), 1)); + mockMvc.perform(authenticated(get("/creators/{sellerId}/drops", 1L).param("page", "0").param("size", "20"))) + .andExpect(status().isOk()) + .andDo(document("drops-get-by-creator", resource(builder() + .tag("Drops").summary("판매자 공개 드랍 목록 조회").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("sellerId").description("판매자 ID")) + .queryParameters(parameterWithName("page").description("페이지 번호"), parameterWithName("size").description("페이지 크기"), parameterWithName("sort").optional().description("정렬 조건")) + .responseSchema(new Schema("DropPageResponse")) + .build()))); + } + + @Test + void getDropsForSellerManagement() throws Exception { + when(dropService.getDropsForSellerManagement(eq(1L), any())).thenReturn(new PageImpl<>(List.of(dropResponse()), PageRequest.of(0, 20), 1)); + mockMvc.perform(authenticated(get("/users/me/drops")).param("page", "0").param("size", "20")) + .andExpect(status().isOk()) + .andDo(document("drops-get-my-drops", resource(builder() + .tag("Drops").summary("내 드랍 목록 조회").requestHeaders(authorizationHeader()) + .queryParameters(parameterWithName("page").description("페이지 번호"), parameterWithName("size").description("페이지 크기"), parameterWithName("sort").optional().description("정렬 조건")) + .responseSchema(new Schema("DropPageResponse")) + .build()))); + } + + @Test + void update() throws Exception { + when(dropService.update(eq(1L), eq(100L), any())).thenReturn(dropResponse()); + mockMvc.perform(authenticated(patch("/drops/{dropId}", 100L).contentType(MediaType.APPLICATION_JSON) + .content("{\"price\":60000,\"initialQuantity\":120,\"discountRate\":10,\"purchaseLimit\":3,\"openAt\":\"2030-01-01T10:00:00\",\"closeAt\":\"2030-01-02T10:00:00\"}"))) + .andExpect(status().isOk()) + .andDo(document("drops-update", resource(builder() + .tag("Drops").summary("드랍 수정").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("dropId").description("드랍 ID")) + .requestSchema(new Schema("DropUpdateRequest")) + .requestFields( + fieldWithPath("price").optional().type(NUMBER).description("판매 가격"), + fieldWithPath("initialQuantity").optional().type(NUMBER).description("초기 수량"), + fieldWithPath("discountRate").optional().type(NUMBER).description("할인율"), + fieldWithPath("purchaseLimit").optional().type(NUMBER).description("구매 제한 수량"), + fieldWithPath("openAt").optional().type(STRING).description("판매 시작 시각"), + fieldWithPath("closeAt").optional().type(STRING).description("판매 종료 시각") + ).responseSchema(new Schema("DropResponse")).responseFields(dropFields()).build()))); + } + + @Test + void changeVisibility() throws Exception { + when(dropVisibilityService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(dropResponse()); + mockMvc.perform(authenticated(patch("/drops/{dropId}/visibility", 100L).contentType(MediaType.APPLICATION_JSON) + .content("{\"visible\":true}"))) + .andExpect(status().isOk()) + .andDo(document("drops-change-visibility", resource(builder() + .tag("Drops").summary("드랍 공개 여부 변경").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("dropId").description("드랍 ID")) + .requestSchema(new Schema("DropVisibilityUpdateRequest")) + .requestFields(fieldWithPath("visible").type(BOOLEAN).description("공개 여부")) + .responseSchema(new Schema("DropResponse")).responseFields(dropFields()).build()))); + } + + @Test + void deleteDrop() throws Exception { + mockMvc.perform(authenticated(delete("/drops/{dropId}", 100L))) + .andExpect(status().isNoContent()) + .andDo(document("drops-delete", resource(builder() + .tag("Drops").summary("드랍 삭제").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("dropId").description("드랍 ID")).build()))); + } + + private DropResponse dropResponse() { + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + Product product = new Product(seller, "Limited Hoodie", "Limited edition hoodie", "products/10/image.jpg"); + ReflectionTestUtils.setField(product, "id", 10L); + Drop drop = new Drop(product, new BigDecimal("59000"), 100, 20, 2, + LocalDateTime.of(2030, 1, 1, 10, 0), LocalDateTime.of(2030, 1, 2, 10, 0)); + ReflectionTestUtils.setField(drop, "id", 100L); + drop.changeVisibility(true); + return DropResponse.from(drop); + } + + private org.springframework.restdocs.payload.FieldDescriptor[] dropFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("id").type(NUMBER).description("드랍 ID"), + fieldWithPath("sellerId").type(NUMBER).description("판매자 ID"), + fieldWithPath("sellerName").type(STRING).description("판매자 이름"), + fieldWithPath("productId").type(NUMBER).description("상품 ID"), + fieldWithPath("productName").type(STRING).description("상품명"), + fieldWithPath("imageUrl").type(STRING).optional().description("상품 이미지 URL"), + fieldWithPath("price").type(NUMBER).description("판매 가격"), + fieldWithPath("discountRate").type(NUMBER).description("할인율"), + fieldWithPath("initialQuantity").type(NUMBER).description("초기 수량"), + fieldWithPath("remainingQuantity").type(NUMBER).description("잔여 수량"), + fieldWithPath("soldQuantity").type(NUMBER).description("판매 수량"), + fieldWithPath("purchaseLimit").type(NUMBER).description("구매 제한 수량"), + fieldWithPath("visible").type(BOOLEAN).description("공개 여부"), + fieldWithPath("openAt").type(STRING).description("판매 시작 시각"), + fieldWithPath("closeAt").type(STRING).description("판매 종료 시각"), + fieldWithPath("status").type(STRING).description("드랍 상태") + }; + } +} diff --git a/src/test/java/com/dropit/drop/entity/DropTest.java b/src/test/java/com/dropit/drop/entity/DropTest.java index 082f76a..f0a3808 100644 --- a/src/test/java/com/dropit/drop/entity/DropTest.java +++ b/src/test/java/com/dropit/drop/entity/DropTest.java @@ -5,12 +5,12 @@ import com.dropit.product.entity.Product; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import java.math.BigDecimal; import java.time.LocalDateTime; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; class DropTest { @@ -38,13 +38,142 @@ void acceptZeroPurchaseLimitAsUnlimited() { assertEquals(0, drop.getPurchaseLimit()); } + @Test + @DisplayName("드랍 생성 시 숫자 판매 정책의 유효 범위를 검증한다") + void rejectInvalidSalesValuesOnCreate() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + assertAll( + () -> assertInvalidSalesValue(() -> new Drop(product, null, 10, 20, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("-1"), 10, 20, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, BigDecimal.ZERO, 10, 20, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("59000"), 0, 20, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("59000"), 10, -1, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("59000"), 10, 101, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("59000"), 10, 20, -1, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("59000.50"), 10, 20, 0, openAt, openAt.plusDays(1))), + () -> assertInvalidSalesValue(() -> new Drop(product, new BigDecimal("10000000000000"), 10, 20, 0, openAt, openAt.plusDays(1))) + ); + } + + @Test + @DisplayName("할인율 0과 100, 최대 가격은 유효한 판매 값으로 허용한다") + void acceptSalesValueBoundariesOnCreate() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + Drop noDiscount = new Drop(product, new BigDecimal("9999999999999"), 10, 0, 0, openAt, openAt.plusDays(1)); + Drop fullDiscount = new Drop(product, new BigDecimal("59000"), 10, 100, 0, openAt, openAt.plusDays(1)); + + assertAll( + () -> assertEquals(new BigDecimal("9999999999999"), noDiscount.getPrice()), + () -> assertEquals(0, noDiscount.getDiscountRate()), + () -> assertEquals(100, fullDiscount.getDiscountRate()) + ); + } + + @Test + @DisplayName("드랍 수정은 최종 값 전체를 검증하고 실패 시 기존 값을 보존한다") + void preserveAllValuesWhenUpdateIsInvalid() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + LocalDateTime closeAt = openAt.plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, closeAt); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.update( + new BigDecimal("49000"), + 20, + 101, + 3, + openAt, + closeAt + ) + ); + + assertEquals(DropErrorCode.INVALID_DROP_VALUE, exception.getErrorCode()); + assertDropValues(drop, new BigDecimal("59000"), 10, 10, 20, 2, openAt, closeAt); + } + + @Test + @DisplayName("모든 값이 null인 부분 수정은 기존 값을 유지한다") + void preserveAllValuesWhenUpdateIsEmpty() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + LocalDateTime closeAt = openAt.plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, closeAt); + + drop.update(null, null, null, null, null, null); + + assertDropValues(drop, new BigDecimal("59000"), 10, 10, 20, 2, openAt, closeAt); + } + + @Test + @DisplayName("판매 후 초기 재고를 변경하면 판매 수량을 보존한 잔여 재고로 계산한다") + void recalculateRemainingQuantityAfterInitialQuantityUpdate() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + drop.decreaseStock(2, now); + + drop.update(null, 5, null, null, null, null); + + assertEquals(5, drop.getInitialQuantity()); + assertEquals(3, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("판매 수량보다 작은 초기 재고로 수정할 수 없다") + void rejectInitialQuantityBelowSoldQuantity() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + drop.decreaseStock(6, now); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.update(null, 5, null, null, null, null) + ); + + assertEquals(DropErrorCode.INVALID_DROP_VALUE, exception.getErrorCode()); + assertEquals(10, drop.getInitialQuantity()); + assertEquals(4, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("부분 수정으로 판매 기간이 역전되면 INVALID_DROP_PERIOD를 반환하고 기존 기간을 보존한다") + void rejectReversedPeriodOnPartialUpdate() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + LocalDateTime closeAt = openAt.plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, closeAt); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.update(null, null, null, null, closeAt.plusDays(1), null) + ); + + assertEquals(DropErrorCode.INVALID_DROP_PERIOD, exception.getErrorCode()); + assertEquals(openAt, drop.getOpenAt()); + assertEquals(closeAt, drop.getCloseAt()); + } + + @Test + @DisplayName("재고 차감 수량은 양수만 허용한다") + void rejectNonPositiveDecreaseQuantity() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + + assertThrows(ServiceException.class, () -> drop.decreaseStock(0, now)); + assertThrows(ServiceException.class, () -> drop.decreaseStock(-1, now)); + assertEquals(10, drop.getRemainingQuantity()); + } + @Test @DisplayName("드랍은 생성 시 기본적으로 비공개 상태이다") void defaultVisibilityIsFalse() { LocalDateTime openAt = LocalDateTime.now().plusDays(1); Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); - assertEquals(false, drop.isVisible()); + assertFalse(drop.isVisible()); } @Test @@ -55,7 +184,7 @@ void changeVisibility() { drop.changeVisibility(true); - assertEquals(true, drop.isVisible()); + assertTrue(drop.isVisible()); } @Test @@ -63,14 +192,45 @@ void changeVisibility() { void rejectInvalidDropPeriodWithDropErrorCode() { LocalDateTime openAt = LocalDateTime.now().plusDays(1); - ServiceException exception = assertThrows( - ServiceException.class, - () -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt) + assertAll( + () -> assertInvalidDropPeriod(() -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt)), + () -> assertInvalidDropPeriod(() -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt.plusDays(1), openAt)), + () -> assertInvalidDropPeriod(() -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, null, openAt)), + () -> assertInvalidDropPeriod(() -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, null)) ); + } + private void assertInvalidSalesValue(Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); + assertEquals(DropErrorCode.INVALID_DROP_VALUE, exception.getErrorCode()); + } + + private void assertInvalidDropPeriod(Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); assertEquals(DropErrorCode.INVALID_DROP_PERIOD, exception.getErrorCode()); } + private void assertDropValues( + Drop drop, + BigDecimal price, + int initialQuantity, + int remainingQuantity, + int discountRate, + int purchaseLimit, + LocalDateTime openAt, + LocalDateTime closeAt + ) { + assertAll( + () -> assertEquals(price, drop.getPrice()), + () -> assertEquals(initialQuantity, drop.getInitialQuantity()), + () -> assertEquals(remainingQuantity, drop.getRemainingQuantity()), + () -> assertEquals(discountRate, drop.getDiscountRate()), + () -> assertEquals(purchaseLimit, drop.getPurchaseLimit()), + () -> assertEquals(openAt, drop.getOpenAt()), + () -> assertEquals(closeAt, drop.getCloseAt()) + ); + } + @Test @DisplayName("판매 중인 공개 드랍의 재고를 차감하고 취소 시 복구할 수 있다") void decreaseAndRestoreStock() { diff --git a/src/test/java/com/dropit/drop/repository/DropSearchIntegrationTest.java b/src/test/java/com/dropit/drop/repository/DropSearchIntegrationTest.java new file mode 100644 index 0000000..3460d25 --- /dev/null +++ b/src/test/java/com/dropit/drop/repository/DropSearchIntegrationTest.java @@ -0,0 +1,115 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropSortType; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.entity.DropStatus; +import com.dropit.global.config.QuerydslConfig; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.mysql.MySQLContainer; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QuerydslConfig.class) +@Transactional +class DropSearchIntegrationTest { + private static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4"); + + static { + MYSQL.start(); + } + + @Autowired EntityManager em; + @Autowired DropRepository repository; + + @DynamicPropertySource + static void registerDatasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + @Test + void keywordPaginationKeepsOrderCountAndFetchJoins() { + String marker = "search-" + UUID.randomUUID(); + User seller = new User(marker + "@test.invalid", "unused", marker, UserRole.SELLER); + em.persist(seller); + Product product = new Product(seller, marker, "fixture", null); + em.persist(product); + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < 5; i++) { + Drop drop = new Drop(product, BigDecimal.valueOf(100 + i), 10, 0, 1, + now.minusDays(1), now.plusDays(1)); + drop.changeVisibility(i != 4); + em.persist(drop); + } + em.flush(); + em.clear(); + var condition = new DropSearchCondition(marker, DropStatus.OPEN, DropSortType.CLOSING_SOON); + var first = repository.searchPublicDrops(condition, PageRequest.of(0, 2)); + var second = repository.searchPublicDrops(condition, PageRequest.of(1, 2)); + assertEquals(4, first.getTotalElements()); + assertEquals(2, first.getNumberOfElements()); + assertEquals(2, second.getNumberOfElements()); + assertTrue(first.getContent().getLast().getId() > second.getContent().getFirst().getId()); + assertEquals(marker, first.getContent().getFirst().getProduct().getSeller().getUsername()); + var empty = repository.searchPublicDrops(condition, PageRequest.of(2, 2)); + assertTrue(empty.isEmpty()); + assertEquals(4, empty.getTotalElements()); + } + + @Test + void noKeywordMatchesOriginalJoinQuery() { + // 시간 경계에 영향을 받지 않도록 공개 여부만 비교한다. + var actual = repository.searchPublicDrops( + new DropSearchCondition(null, null, DropSortType.CLOSING_SOON), PageRequest.of(0, 20)); + List expected = em.createQuery("select d.id from Drop d join d.product p join p.seller s " + + "where d.visible = true order by d.closeAt asc, d.id desc", Long.class) + .setMaxResults(20).getResultList(); + Long count = em.createQuery("select count(d) from Drop d join d.product p join p.seller s " + + "where d.visible = true", Long.class).getSingleResult(); + assertEquals(expected, actual.getContent().stream().map(Drop::getId).toList()); + assertEquals(count.longValue(), actual.getTotalElements()); + } + + @Test + void liveStateReadsCommittedStockAndVisibility() { + String marker = "live-state-" + UUID.randomUUID(); + User seller = new User(marker + "@test.invalid", "unused", marker, UserRole.SELLER); + em.persist(seller); + Product product = new Product(seller, marker, "fixture", null); + em.persist(product); + LocalDateTime now = LocalDateTime.now(); + Drop drop = new Drop(product, BigDecimal.valueOf(100), 10, 0, 1, + now.minusDays(1), now.plusDays(1)); + drop.changeVisibility(true); + drop.decreaseStock(3, now); + em.persist(drop); + em.flush(); + em.clear(); + + DropLiveState result = repository.findLiveStateById(drop.getId()).orElseThrow(); + + assertEquals(7, result.getRemainingQuantity()); + assertTrue(result.isVisible()); + } +} diff --git a/src/test/java/com/dropit/drop/service/DropCacheEvictionFailureIntegrationTest.java b/src/test/java/com/dropit/drop/service/DropCacheEvictionFailureIntegrationTest.java new file mode 100644 index 0000000..b1bca0b --- /dev/null +++ b/src/test/java/com/dropit/drop/service/DropCacheEvictionFailureIntegrationTest.java @@ -0,0 +1,139 @@ +package com.dropit.drop.service; + +import com.dropit.drop.cache.DropDetailCacheReader; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.config.QuerydslConfig; +import com.dropit.global.config.RedisCacheConfig; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.mysql.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({DropService.class, DropDetailCacheReader.class, QuerydslConfig.class, RedisCacheConfig.class, + DropCacheEvictionFailureIntegrationTest.RedisTestConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class DropCacheEvictionFailureIntegrationTest { + + private static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4"); + private static final GenericContainer REDIS = new GenericContainer<>( + DockerImageName.parse("redis:7.4-alpine") + ).withExposedPorts(6379); + + static { + MYSQL.start(); + REDIS.start(); + } + + @Autowired + private DropService dropService; + + @Autowired + private DropRepository dropRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private LettuceConnectionFactory redisConnectionFactory; + + @DynamicPropertySource + static void registerDatasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + @Test + void redisEvictionFailureAfterCommitDoesNotFailUpdateOrDelete() { + User seller = userRepository.save(new User( + "cache-eviction@test.invalid", + "unused", + "cache-eviction-seller", + UserRole.SELLER + )); + Product product = productRepository.save(new Product(seller, "product", "description", null)); + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop updatedDrop = dropRepository.save(new Drop( + product, BigDecimal.valueOf(1000), 10, 0, 1, openAt, openAt.plusDays(1) + )); + Drop deletedDrop = dropRepository.save(new Drop( + product, BigDecimal.valueOf(2000), 10, 0, 1, openAt, openAt.plusDays(1) + )); + + Cache cache = cacheManager.getCache(RedisCacheConfig.DROP_DETAIL_CACHE); + assertNotNull(cache); + cache.put(updatedDrop.getId(), "cached"); + cache.put(deletedDrop.getId(), "cached"); + REDIS.stop(); + redisConnectionFactory.destroy(); + assertThrows(IllegalStateException.class, redisConnectionFactory::getConnection); + + assertDoesNotThrow(() -> dropService.update( + seller.getId(), + updatedDrop.getId(), + new DropUpdateRequest(BigDecimal.valueOf(1500), null, null, null, null, null) + )); + assertDoesNotThrow(() -> dropService.delete(seller.getId(), deletedDrop.getId())); + + assertEquals(BigDecimal.valueOf(1500), dropRepository.findById(updatedDrop.getId()).orElseThrow().getPrice()); + assertFalse(dropRepository.existsById(deletedDrop.getId())); + } + + @TestConfiguration(proxyBeanMethods = false) + static class RedisTestConfig { + + @Bean + RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redis = new RedisStandaloneConfiguration( + REDIS.getHost(), + REDIS.getMappedPort(6379) + ); + LettuceClientConfiguration client = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(200)) + .shutdownTimeout(Duration.ZERO) + .build(); + return new LettuceConnectionFactory(redis, client); + } + } +} diff --git a/src/test/java/com/dropit/drop/service/DropCacheIntegrationTest.java b/src/test/java/com/dropit/drop/service/DropCacheIntegrationTest.java new file mode 100644 index 0000000..c40bbaf --- /dev/null +++ b/src/test/java/com/dropit/drop/service/DropCacheIntegrationTest.java @@ -0,0 +1,217 @@ +package com.dropit.drop.service; + +import com.dropit.drop.cache.DropDetailCacheReader; +import com.dropit.drop.cache.DropDetailCacheValue; +import com.dropit.drop.cache.DropSaleCacheWriter; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropLiveState; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.config.RedisCacheConfig; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig +@Import({DropService.class, DropVisibilityService.class, DropDetailCacheReader.class, RedisCacheConfig.class, + DropCacheIntegrationTest.RedisTestConfig.class}) +class DropCacheIntegrationTest { + + private static final GenericContainer REDIS = new GenericContainer<>( + DockerImageName.parse("redis:7.4-alpine") + ).withExposedPorts(6379); + + static { + REDIS.start(); + } + + @AfterAll + static void stopRedis() { + REDIS.stop(); + } + + @Autowired + private DropService dropService; + + @Autowired + private DropVisibilityService dropVisibilityService; + + @Autowired + private CacheManager cacheManager; + + @MockitoBean + private DropRepository dropRepository; + + @MockitoBean + private ProductRepository productRepository; + + @MockitoBean + private OrderItemRepository orderItemRepository; + + @MockitoBean + private DropUserPurchaseRepository dropUserPurchaseRepository; + + @MockitoBean + private DropSaleCacheWriter dropSaleCacheWriter; + + @MockitoBean + private TransactionTemplate transactionTemplate; + + private Cache dropDetailCache; + + @BeforeEach + void clearCache() { + when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + dropDetailCache = cacheManager.getCache(RedisCacheConfig.DROP_DETAIL_CACHE); + assertNotNull(dropDetailCache); + dropDetailCache.clear(); + } + + @Test + void repeatedDetailRequestUsesRedisCache() { + Drop drop = visibleDrop(); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(drop)); + when(dropRepository.findLiveStateById(100L)).thenReturn(Optional.of(liveState(100, true))); + + DropResponse first = dropService.getOne(100L); + DropResponse second = dropService.getOne(100L); + + assertEquals(first, second); + verify(dropRepository, times(1)).findDetailById(100L); + verify(dropRepository, times(2)).findLiveStateById(100L); + assertNotNull(dropDetailCache.get(100L, DropDetailCacheValue.class)); + } + + @Test + void changedDatabaseStockIsReflectedWithoutReloadingMetadata() { + Drop drop = visibleDrop(); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(drop)); + when(dropRepository.findLiveStateById(100L)).thenReturn( + Optional.of(liveState(100, true)), + Optional.of(liveState(97, true)) + ); + + DropResponse first = dropService.getOne(100L); + DropResponse second = dropService.getOne(100L); + + assertEquals(100, first.remainingQuantity()); + assertEquals(97, second.remainingQuantity()); + assertEquals(3, second.soldQuantity()); + verify(dropRepository, times(1)).findDetailById(100L); + verify(dropRepository, times(2)).findLiveStateById(100L); + } + + @Test + void changingVisibilityEvictsCachedDetail() { + Drop drop = visibleDrop(); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(drop)); + when(dropRepository.findByIdForUpdate(100L)).thenReturn(Optional.of(drop)); + when(dropRepository.findLiveStateById(100L)).thenReturn(Optional.of(liveState(100, true))); + dropService.getOne(100L); + + dropVisibilityService.changeVisibility(1L, 100L, new DropVisibilityUpdateRequest(false)); + + assertNull(dropDetailCache.get(100L)); + } + + @Test + void cacheSyncFailureAfterVisibilityChangeStillEvictsCachedDetail() { + Drop drop = visibleDrop(); + when(dropRepository.findDetailById(100L)).thenReturn(Optional.of(drop)); + when(dropRepository.findByIdForUpdate(100L)).thenReturn(Optional.of(drop)); + doThrow(new IllegalStateException("Redis unavailable")) + .when(dropSaleCacheWriter).apply(any()); + when(dropRepository.findLiveStateById(100L)).thenReturn(Optional.of(liveState(100, true))); + dropService.getOne(100L); + + assertThrows(ServiceException.class, () -> + dropVisibilityService.changeVisibility(1L, 100L, new DropVisibilityUpdateRequest(false))); + + assertNull(dropDetailCache.get(100L)); + } + + private Drop visibleDrop() { + User seller = new User("seller@cache.test", "password", "cache-seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + Product product = new Product(seller, "Limited Hoodie", "Cache integration fixture", null); + ReflectionTestUtils.setField(product, "id", 10L); + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop( + product, + new BigDecimal("59000"), + 100, + 10, + 2, + openAt, + openAt.plusDays(1) + ); + drop.changeVisibility(true); + ReflectionTestUtils.setField(drop, "id", 100L); + return drop; + } + + private DropLiveState liveState(int remainingQuantity, boolean visible) { + return new DropLiveState() { + @Override + public int getRemainingQuantity() { + return remainingQuantity; + } + + @Override + public boolean isVisible() { + return visible; + } + }; + } + + @TestConfiguration(proxyBeanMethods = false) + static class RedisTestConfig { + + @Bean + RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + } + } +} diff --git a/src/test/java/com/dropit/drop/service/DropServiceTest.java b/src/test/java/com/dropit/drop/service/DropServiceTest.java index 5744354..9c9c21d 100644 --- a/src/test/java/com/dropit/drop/service/DropServiceTest.java +++ b/src/test/java/com/dropit/drop/service/DropServiceTest.java @@ -1,5 +1,7 @@ package com.dropit.drop.service; +import com.dropit.drop.cache.DropDetailCacheReader; +import com.dropit.drop.cache.DropDetailCacheValue; import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.dto.request.DropUpdateRequest; @@ -8,6 +10,7 @@ import com.dropit.drop.entity.Drop; import com.dropit.drop.exception.DropErrorCode; import com.dropit.drop.repository.DropRepository; +import com.dropit.drop.repository.DropLiveState; import com.dropit.global.exception.ServiceException; import com.dropit.product.entity.Product; import com.dropit.product.exception.ProductErrorCode; @@ -46,6 +49,9 @@ class DropServiceTest { @Mock private ProductRepository productRepository; + @Mock + private DropDetailCacheReader dropDetailCacheReader; + @InjectMocks private DropService dropService; @@ -54,7 +60,7 @@ class DropServiceTest { void saveDropOwnedBySeller() { Product product = saveProduct(1L, 10L); DropCreateRequest request = saveRequest(); - when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + when(productRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(product)); when(dropRepository.save(any(Drop.class))).thenAnswer(invocation -> { Drop drop = invocation.getArgument(0); ReflectionTestUtils.setField(drop, "id", 100L); @@ -77,7 +83,7 @@ void saveDropOwnedBySeller() { @Test @DisplayName("존재하지 않는 상품으로 드랍을 생성할 수 없다") void rejectMissingProduct() { - when(productRepository.findById(10L)).thenReturn(Optional.empty()); + when(productRepository.findByIdForUpdate(10L)).thenReturn(Optional.empty()); ServiceException exception = assertThrows( ServiceException.class, @@ -92,7 +98,7 @@ void rejectMissingProduct() { @DisplayName("다른 판매자의 상품으로 드랍을 생성할 수 없다") void rejectCreatingDropForAnotherSellersProduct() { Product product = saveProduct(1L, 10L); - when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + when(productRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(product)); ServiceException exception = assertThrows( ServiceException.class, @@ -107,12 +113,15 @@ void rejectCreatingDropForAnotherSellersProduct() { @DisplayName("공개된 드랍 상세 정보를 조회할 수 있다") void getOneDrop() { Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); - when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + when(dropDetailCacheReader.get(100L)).thenReturn(DropDetailCacheValue.from(drop)); + when(dropRepository.findLiveStateById(100L)).thenReturn(Optional.of(liveState(7, true))); DropResponse response = dropService.getOne(100L); assertEquals(100L, response.id()); assertEquals(10L, response.productId()); + assertEquals(7, response.remainingQuantity()); + assertEquals(3, response.soldQuantity()); } @Test @@ -206,17 +215,24 @@ void getDropsForSellerManagement() { @Test @DisplayName("비공개 드랍 상세 조회는 존재하지 않는 드랍으로 처리한다") void rejectHiddenDropFromPublicDetail() { - Product product = saveProduct(1L, 10L); - Drop hiddenDrop = new Drop( - product, - new BigDecimal("59000"), - 10, - 20, - 2, - LocalDateTime.now().plusDays(1), - LocalDateTime.now().plusDays(2) + when(dropDetailCacheReader.get(100L)) + .thenThrow(new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.getOne(100L) ); - when(dropRepository.findById(100L)).thenReturn(Optional.of(hiddenDrop)); + + assertEquals(DropErrorCode.DROP_NOT_FOUND, exception.getErrorCode()); + verify(dropRepository, never()).findLiveStateById(anyLong()); + } + + @Test + @DisplayName("캐시된 상세 정보가 있어도 현재 비공개 드랍은 조회할 수 없다") + void rejectDropHiddenAfterMetadataWasCached() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropDetailCacheReader.get(100L)).thenReturn(DropDetailCacheValue.from(drop)); + when(dropRepository.findLiveStateById(100L)).thenReturn(Optional.of(liveState(10, false))); ServiceException exception = assertThrows( ServiceException.class, @@ -290,36 +306,6 @@ void rejectUpdatingAnotherSellersDrop() { assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); } - @Test - @DisplayName("드랍 소유자는 공개 여부를 변경할 수 있다") - void changeVisibilityOwnedBySeller() { - Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); - when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); - - DropResponse response = dropService.changeVisibility( - 1L, - 100L, - new DropVisibilityUpdateRequest(false) - ); - - assertEquals(false, response.visible()); - } - - @Test - @DisplayName("다른 판매자는 드랍 공개 여부를 변경할 수 없다") - void rejectChangingVisibilityOfAnotherSellersDrop() { - Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); - when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); - - ServiceException exception = assertThrows( - ServiceException.class, - () -> dropService.changeVisibility(2L, 100L, new DropVisibilityUpdateRequest(false)) - ); - - assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); - assertEquals(true, drop.isVisible()); - } - @Test @DisplayName("판매가 시작된 드랍은 삭제할 수 없다") void rejectDeletingStartedDrop() { @@ -348,6 +334,20 @@ private Drop saveDrop(Long sellerId, Long productId, Long dropId, LocalDateTime return drop; } + private DropLiveState liveState(int remainingQuantity, boolean visible) { + return new DropLiveState() { + @Override + public int getRemainingQuantity() { + return remainingQuantity; + } + + @Override + public boolean isVisible() { + return visible; + } + }; + } + private Product saveProduct(Long sellerId, Long productId) { User seller = new User("seller@example.com", "password", "seller", UserRole.SELLER); ReflectionTestUtils.setField(seller, "id", sellerId); diff --git a/src/test/java/com/dropit/drop/service/DropVisibilityServiceTest.java b/src/test/java/com/dropit/drop/service/DropVisibilityServiceTest.java new file mode 100644 index 0000000..6892d57 --- /dev/null +++ b/src/test/java/com/dropit/drop/service/DropVisibilityServiceTest.java @@ -0,0 +1,90 @@ +package com.dropit.drop.service; + +import com.dropit.drop.cache.DropSaleCacheWriter; +import com.dropit.drop.cache.DropSaleSnapshot; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class DropVisibilityServiceTest { + + @Test + @DisplayName("최초 공개는 판매 조건을 동결하고 DB 트랜잭션 뒤 Redis에 준비한다") + @SuppressWarnings("unchecked") + void prepareSaleOnFirstPublication() { + DropRepository dropRepository = mock(DropRepository.class); + OrderItemRepository itemRepository = mock(OrderItemRepository.class); + DropUserPurchaseRepository purchaseRepository = mock(DropUserPurchaseRepository.class); + DropSaleCacheWriter cacheWriter = mock(DropSaleCacheWriter.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + when(transactionTemplate.execute(any())).thenAnswer(invocation -> + ((TransactionCallback) invocation.getArgument(0)) + .doInTransaction(mock(TransactionStatus.class))); + Drop drop = drop(); + when(dropRepository.findByIdForUpdate(100L)).thenReturn(Optional.of(drop)); + + var response = new DropVisibilityService( + dropRepository, itemRepository, purchaseRepository, cacheWriter, transactionTemplate) + .changeVisibility(1L, 100L, new DropVisibilityUpdateRequest(true)); + + assertAll( + () -> assertTrue(response.visible()), + () -> assertTrue(drop.isSalePrepared()), + () -> assertEquals(1L, drop.getSaleVersion()) + ); + verify(cacheWriter).apply(any(DropSaleSnapshot.class)); + } + + @Test + @DisplayName("재공개는 준비된 판매 재고를 초기화하는 새 saleVersion을 만들지 않는다") + @SuppressWarnings("unchecked") + void doNotCreateNewSaleVersionOnReplay() { + DropRepository dropRepository = mock(DropRepository.class); + OrderItemRepository itemRepository = mock(OrderItemRepository.class); + DropUserPurchaseRepository purchaseRepository = mock(DropUserPurchaseRepository.class); + DropSaleCacheWriter cacheWriter = mock(DropSaleCacheWriter.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + when(transactionTemplate.execute(any())).thenAnswer(invocation -> + ((TransactionCallback) invocation.getArgument(0)) + .doInTransaction(mock(TransactionStatus.class))); + Drop drop = drop(); + drop.prepareSale(); + drop.changeVisibility(false); + when(dropRepository.findByIdForUpdate(100L)).thenReturn(Optional.of(drop)); + + new DropVisibilityService(dropRepository, itemRepository, purchaseRepository, cacheWriter, transactionTemplate) + .changeVisibility(1L, 100L, new DropVisibilityUpdateRequest(true)); + + assertEquals(1L, drop.getSaleVersion()); + verifyNoInteractions(itemRepository, purchaseRepository); + } + + private Drop drop() { + User seller = new User("seller@example.com", "password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + Product product = new Product(seller, "product", "description", null); + Drop drop = new Drop(product, new BigDecimal("1000"), 10, 0, 3, + LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2)); + ReflectionTestUtils.setField(drop, "id", 100L); + return drop; + } +} diff --git a/src/test/java/com/dropit/global/security/SecurityConfigTest.java b/src/test/java/com/dropit/global/security/SecurityConfigTest.java new file mode 100644 index 0000000..980b523 --- /dev/null +++ b/src/test/java/com/dropit/global/security/SecurityConfigTest.java @@ -0,0 +1,170 @@ +package com.dropit.global.security; + +import com.dropit.global.security.jwt.JwtFilter; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.mockito.Mockito.mock; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = SecurityConfigTest.TestConfig.class) +@WebAppConfiguration +@TestPropertySource(properties = { + "docs.auth.username=docs", + "docs.auth.password=secret" +}) +class SecurityConfigTest { + + @Autowired + private SecurityConfig securityConfig; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build(); + } + + @Test + void documentationPasswordIsStoredAsBcryptHash() { + UserDetailsService userDetailsService = securityConfig.documentationUserDetailsService( + "docs", + "secret", + passwordEncoder + ); + + UserDetails user = userDetailsService.loadUserByUsername("docs"); + + assertTrue(passwordEncoder.matches("secret", user.getPassword())); + } + + @Test + void documentationCredentialsAreRequired() { + SecurityConfig standaloneSecurityConfig = new SecurityConfig(null, null); + + assertThrows( + IllegalStateException.class, + () -> standaloneSecurityConfig.documentationUserDetailsService( + "", + "secret", + passwordEncoder + ) + ); + } + + @Test + void documentationWithoutCredentialsReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/openapi/test")) + .andExpect(status().isUnauthorized()); + } + + @Test + void documentationWithValidCredentialsReturnsOk() throws Exception { + mockMvc.perform(get("/openapi/test").with(httpBasic("docs", "secret"))) + .andExpect(status().isOk()); + } + + @Test + void documentationWithInvalidCredentialsReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/openapi/test").with(httpBasic("docs", "wrong"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void publicDropListDoesNotRequireAuthentication() throws Exception { + mockMvc.perform(get("/drops")) + .andExpect(status().isOk()); + } + + @Test + void publicDropDetailDoesNotRequireAuthentication() throws Exception { + mockMvc.perform(get("/drops/100")) + .andExpect(status().isOk()); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @EnableWebMvc + @Import(SecurityConfig.class) + static class TestConfig { + + @Bean + JwtFilter jwtFilter() { + return mock(JwtFilter.class); + } + + @Bean + SecurityErrorResponseSender errorResponseSender() { + return mock(SecurityErrorResponseSender.class); + } + + @Bean + DocumentationTestController documentationTestController() { + return new DocumentationTestController(); + } + + @Bean + PublicDropTestController publicDropTestController() { + return new PublicDropTestController(); + } + } + + @RestController + static class DocumentationTestController { + + @GetMapping("/openapi/test") + void getDocumentation(HttpServletResponse response) throws Exception { + response.setStatus(HttpServletResponse.SC_OK); + } + } + + @RestController + static class PublicDropTestController { + + @GetMapping("/drops") + void getDrops(HttpServletResponse response) throws Exception { + response.setStatus(HttpServletResponse.SC_OK); + } + + @GetMapping("/drops/{dropId}") + void getDrop(HttpServletResponse response) throws Exception { + response.setStatus(HttpServletResponse.SC_OK); + } + } +} diff --git a/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java index 291728e..5a9490d 100644 --- a/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java +++ b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java @@ -7,6 +7,8 @@ import jakarta.servlet.FilterChain; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.Authentication; @@ -41,6 +43,30 @@ void requestWithoutBearerTokenContinuesWithoutAuthentication() throws Exception assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } + @ParameterizedTest + @CsvSource({ + "GET, /actuator/health", + "POST, /auth/signup", + "POST, /auth/login", + "POST, /auth/reissue", + "GET, /drops", + "GET, /drops/100" + }) + void invalidTokenOnPermitAllPathIsIgnored(String method, String path) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setServletPath(path); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + when(jwtUtil.getAccessTokenClaims("invalid-token")) + .thenThrow(new ServiceException(JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR)); + + jwtFilter.doFilter(request, response, filterChain); + + verify(jwtUtil, never()).getAccessTokenClaims("invalid-token"); + verify(filterChain).doFilter(request, response); + } + @Test void validAccessTokenCreatesAuthenticatedSecurityContext() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); @@ -77,4 +103,4 @@ void invalidTokenWritesErrorAndStopsFilterChain() throws Exception { verify(errorResponseSender).send(response, exception); verify(filterChain, never()).doFilter(request, response); } -} \ No newline at end of file +} diff --git a/src/test/java/com/dropit/global/storage/S3ImageServiceTest.java b/src/test/java/com/dropit/global/storage/S3ImageServiceTest.java index 9577eef..c987738 100644 --- a/src/test/java/com/dropit/global/storage/S3ImageServiceTest.java +++ b/src/test/java/com/dropit/global/storage/S3ImageServiceTest.java @@ -2,6 +2,7 @@ import com.dropit.global.exception.ServiceException; import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -63,6 +64,10 @@ void uploadImage() { ); assertEquals(keyCaptor.getValue(), key); assertEquals("image/png", metadataCaptor.getValue().getContentType()); + assertEquals( + "public, max-age=31536000, immutable", + metadataCaptor.getValue().getCacheControl() + ); assertTrue(key.matches("products/100/[\\w-]+\\.png")); } @@ -107,6 +112,28 @@ void createDownloadUrl() throws Exception { ); } + @Test + @DisplayName("이미지 키를 CloudFront CDN URL로 변환한다") + void createPublicUrl() throws Exception { + ReflectionTestUtils.setField( + s3ImageService, + "cdnDomain", + "d3czchk38dd04k.cloudfront.net" + ); + + String key = + "products/100/image.png"; + + assertEquals( + "https://d3czchk38dd04k.cloudfront.net/products/100/image.png", + s3ImageService.createPublicUrl(key) + ); + + assertNull( + s3ImageService.createPublicUrl(null) + ); + } + private void assertErrorCode( StorageErrorCode errorCode, org.junit.jupiter.api.function.Executable executable diff --git a/src/test/java/com/dropit/notification/controller/NotificationControllerTest.java b/src/test/java/com/dropit/notification/controller/NotificationControllerTest.java new file mode 100644 index 0000000..3f6f5e7 --- /dev/null +++ b/src/test/java/com/dropit/notification/controller/NotificationControllerTest.java @@ -0,0 +1,129 @@ +package com.dropit.notification.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.notification.dto.response.NotificationResponse; +import com.dropit.notification.entity.Notification; +import com.dropit.notification.service.NotificationService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class NotificationControllerTest { + + private MockMvc mockMvc; + private NotificationService notificationService; + + @BeforeEach + void setUp() { + notificationService = mock(NotificationService.class); + + mockMvc = MockMvcBuilders + .standaloneSetup( + new NotificationController(notificationService) + ) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new AuthenticationPrincipalArgumentResolver() + ) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken( + new AuthUser(1L), + List.of() + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("현재 사용자의 알림 목록을 조회한다") + void getMine() throws Exception { + NotificationResponse response = + createResponse(100L, false); + + when(notificationService.getMine(1L)) + .thenReturn(List.of(response)); + + mockMvc.perform(get("/notifications/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(100L)) + .andExpect(jsonPath("$[0].read").value(false)); + + verify(notificationService).getMine(1L); + } + + @Test + @DisplayName("현재 사용자의 알림을 읽음 처리한다") + void markAsRead() throws Exception { + NotificationResponse response = + createResponse(100L, true); + + when(notificationService.markAsRead(1L, 100L)) + .thenReturn(response); + + mockMvc.perform( + patch( + "/notifications/{notificationId}/read", + 100L + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100L)) + .andExpect(jsonPath("$.read").value(true)); + + verify(notificationService) + .markAsRead(1L, 100L); + } + + private NotificationResponse createResponse( + Long notificationId, + boolean read + ) { + User user = new User( + "user@example.com", + "password", + "user", + UserRole.USER + ); + ReflectionTestUtils.setField(user, "id", 1L); + + Notification notification = + new Notification(user, "알림", "내용"); + ReflectionTestUtils.setField( + notification, + "id", + notificationId + ); + + if (read) { + notification.markAsRead(); + } + + return new NotificationResponse(notification); + } +} diff --git a/src/test/java/com/dropit/notification/controller/NotificationDocumentationTest.java b/src/test/java/com/dropit/notification/controller/NotificationDocumentationTest.java new file mode 100644 index 0000000..1b34280 --- /dev/null +++ b/src/test/java/com/dropit/notification/controller/NotificationDocumentationTest.java @@ -0,0 +1,89 @@ +package com.dropit.notification.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import com.dropit.notification.dto.response.NotificationResponse; +import com.dropit.notification.entity.Notification; +import com.dropit.notification.service.NotificationService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class NotificationDocumentationTest extends DocumentationTestSupport { + + private NotificationService notificationService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + notificationService = mock(NotificationService.class); + configure(restDocumentation, new NotificationController(notificationService)); + } + + @Test + void getMine() throws Exception { + when(notificationService.getMine(1L)).thenReturn(List.of(response(false))); + mockMvc.perform(authenticated(get("/notifications/me"))) + .andExpect(status().isOk()) + .andDo(document("notifications-get-me", resource(builder() + .tag("Notifications").summary("내 알림 목록 조회").requestHeaders(authorizationHeader()) + .responseSchema(new Schema("NotificationList")) + .responseFields( + fieldWithPath("[]").type(ARRAY).description("알림 목록"), + fieldWithPath("[].id").type(NUMBER).description("알림 ID"), + fieldWithPath("[].title").type(STRING).description("알림 제목"), + fieldWithPath("[].message").type(STRING).description("알림 내용"), + fieldWithPath("[].read").type(BOOLEAN).description("읽음 여부"), + fieldWithPath("[].createdAt").type(STRING).optional().description("생성 시각") + ).build()))); + } + + @Test + void markAsRead() throws Exception { + when(notificationService.markAsRead(1L, 100L)).thenReturn(response(true)); + mockMvc.perform(authenticated(patch("/notifications/{notificationId}/read", 100L))) + .andExpect(status().isOk()) + .andDo(document("notifications-mark-read", resource(builder() + .tag("Notifications").summary("알림 읽음 처리").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("notificationId").description("알림 ID")) + .responseSchema(new Schema("NotificationResponse")) + .responseFields( + fieldWithPath("id").type(NUMBER).description("알림 ID"), + fieldWithPath("title").type(STRING).description("알림 제목"), + fieldWithPath("message").type(STRING).description("알림 내용"), + fieldWithPath("read").type(BOOLEAN).description("읽음 여부"), + fieldWithPath("createdAt").type(STRING).optional().description("생성 시각") + ).build()))); + } + + private NotificationResponse response(boolean read) { + User user = new User("user@example.com", "encoded-password", "user", UserRole.USER); + ReflectionTestUtils.setField(user, "id", 1L); + Notification notification = new Notification(user, "주문 알림", "주문 상태가 변경되었습니다."); + ReflectionTestUtils.setField(notification, "id", 100L); + if (read) notification.markAsRead(); + return new NotificationResponse(notification); + } +} diff --git a/src/test/java/com/dropit/notification/service/NotificationServiceTest.java b/src/test/java/com/dropit/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..b740a01 --- /dev/null +++ b/src/test/java/com/dropit/notification/service/NotificationServiceTest.java @@ -0,0 +1,175 @@ +package com.dropit.notification.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.notification.dto.response.NotificationResponse; +import com.dropit.notification.entity.Notification; +import com.dropit.notification.exception.NotificationErrorCode; +import com.dropit.notification.repository.NotificationRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("알림을 생성한다") + void createNotification() { + User user = createUser(1L); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> { + Notification notification = invocation.getArgument(0); + ReflectionTestUtils.setField(notification, "id", 100L); + return notification; + }); + + NotificationResponse response = + notificationService.create( + 1L, + "판매 시작 임박", + "찜한 상품의 판매 시작이 임박했습니다." + ); + + assertEquals(100L, response.getId()); + assertEquals("판매 시작 임박", response.getTitle()); + assertEquals( + "찜한 상품의 판매 시작이 임박했습니다.", + response.getMessage() + ); + assertFalse(response.isRead()); + + verify(notificationRepository).save(any(Notification.class)); + } + + @Test + @DisplayName("존재하지 않는 사용자에게 알림을 생성할 수 없다") + void rejectCreateWhenUserNotFound() { + when(userRepository.findById(1L)) + .thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> notificationService.create( + 1L, + "알림", + "내용" + ) + ); + + assertEquals( + NotificationErrorCode.USER_NOT_FOUND, + exception.getErrorCode() + ); + verify(notificationRepository, never()) + .save(any(Notification.class)); + } + + @Test + @DisplayName("현재 사용자의 알림 목록을 조회한다") + void getMine() { + User user = createUser(1L); + + Notification first = + new Notification(user, "첫 번째 알림", "첫 번째 내용"); + Notification second = + new Notification(user, "두 번째 알림", "두 번째 내용"); + + ReflectionTestUtils.setField(first, "id", 100L); + ReflectionTestUtils.setField(second, "id", 101L); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + + when(notificationRepository + .findAllByUser_IdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(second, first)); + + List responses = + notificationService.getMine(1L); + + assertEquals(2, responses.size()); + assertEquals(101L, responses.get(0).getId()); + assertEquals(100L, responses.get(1).getId()); + } + + @Test + @DisplayName("현재 사용자의 알림을 읽음 처리한다") + void markAsRead() { + User user = createUser(1L); + Notification notification = + new Notification(user, "알림", "내용"); + + ReflectionTestUtils.setField(notification, "id", 100L); + + when(notificationRepository + .findByIdAndUser_Id(100L, 1L)) + .thenReturn(Optional.of(notification)); + + NotificationResponse response = + notificationService.markAsRead(1L, 100L); + + assertTrue(response.isRead()); + assertTrue(notification.isRead()); + } + + @Test + @DisplayName("본인의 알림이 아니면 읽음 처리할 수 없다") + void rejectMarkAsReadForAnotherUsersNotification() { + when(notificationRepository + .findByIdAndUser_Id(100L, 1L)) + .thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> notificationService.markAsRead(1L, 100L) + ); + + assertEquals( + NotificationErrorCode.NOTIFICATION_NOT_FOUND, + exception.getErrorCode() + ); + } + + private User createUser(Long id) { + User user = new User( + "user@example.com", + "password", + "user", + UserRole.USER + ); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/dropit/order/controller/OrderControllerTest.java b/src/test/java/com/dropit/order/controller/OrderControllerTest.java index 7b3bb34..e546a2b 100644 --- a/src/test/java/com/dropit/order/controller/OrderControllerTest.java +++ b/src/test/java/com/dropit/order/controller/OrderControllerTest.java @@ -3,16 +3,17 @@ import com.dropit.global.exception.GlobalExceptionHandler; import com.dropit.global.security.authentication.JwtAuthenticationToken; import com.dropit.global.security.principal.AuthUser; -import com.dropit.order.dto.request.OrderCreateRequest; -import com.dropit.order.dto.response.OrderResponse; -import com.dropit.order.dto.response.OrderSummaryResponse; -import com.dropit.order.entity.OrderStatus; +import com.dropit.order.dto.response.OrderRequestStatusResponse; +import com.dropit.order.entity.OrderRequestStatus; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.service.OrderAdmissionService; +import com.dropit.order.service.OrderCancellationService; +import com.dropit.order.service.OrderRequestQueryService; import com.dropit.order.service.OrderService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; @@ -20,9 +21,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.math.BigDecimal; +import java.time.Instant; import java.util.List; +import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -34,12 +36,18 @@ class OrderControllerTest { private MockMvc mockMvc; private OrderService orderService; + private OrderAdmissionService admissionService; + private OrderRequestQueryService queryService; + private OrderCancellationService cancellationService; @BeforeEach void setUp() { orderService = mock(OrderService.class); - mockMvc = MockMvcBuilders - .standaloneSetup(new OrderController(orderService)) + admissionService = mock(OrderAdmissionService.class); + queryService = mock(OrderRequestQueryService.class); + cancellationService = mock(OrderCancellationService.class); + mockMvc = MockMvcBuilders.standaloneSetup( + new OrderController(orderService, admissionService, queryService, cancellationService)) .setControllerAdvice(new GlobalExceptionHandler()) .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) .build(); @@ -54,90 +62,58 @@ void tearDown() { } @Test - @DisplayName("배열 형태의 주문 항목으로 주문을 생성한다") - void createOrder() throws Exception { - OrderResponse response = new OrderResponse( - 1000L, - OrderStatus.ORDERED, - new BigDecimal("94400"), - null, - List.of() - ); - when(orderService.create(eq(1L), any(OrderCreateRequest.class))).thenReturn(response); + @DisplayName("Redis 예약과 SQS 발행이 확인되면 같은 requestId로 202를 반환한다") + void admitOrder() throws Exception { + UUID requestId = UUID.randomUUID(); + when(admissionService.admit(eq(1L), eq("key-1"), any())).thenReturn(snapshot(requestId)); mockMvc.perform(post("/orders") + .header("Idempotency-Key", "key-1") .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "items": [ - {"dropId": 100, "quantity": 2} - ] - } - """)) - .andExpect(status().isCreated()) - .andExpect(header().string("Location", "/orders/1000")) - .andExpect(jsonPath("$.id").value(1000L)) - .andExpect(jsonPath("$.status").value("ORDERED")) - .andExpect(jsonPath("$.totalPrice").value(94400)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(OrderCreateRequest.class); - verify(orderService).create(eq(1L), captor.capture()); - assertEquals(1, captor.getValue().items().size()); - assertEquals(100L, captor.getValue().items().getFirst().dropId()); - assertEquals(2, captor.getValue().items().getFirst().quantity()); + .content("{\"items\":[{\"dropId\":100,\"quantity\":2}]}")) + .andExpect(status().isAccepted()) + .andExpect(header().string("Location", "/order-requests/" + requestId)) + .andExpect(jsonPath("$.requestId").value(requestId.toString())); } @Test - @DisplayName("현재 MVP에서는 주문 항목을 두 개 이상 요청할 수 없다") - void rejectMultipleOrderItems() throws Exception { + @DisplayName("여러 Drop 주문은 admission을 호출하기 전에 거절한다") + void rejectMultipleItems() throws Exception { mockMvc.perform(post("/orders") + .header("Idempotency-Key", "key-1") .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "items": [ - {"dropId": 100, "quantity": 1}, - {"dropId": 101, "quantity": 1} - ] - } - """)) + .content("{\"items\":[{\"dropId\":100,\"quantity\":1},{\"dropId\":101,\"quantity\":1}]}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("INVALID_INPUT")); - - verifyNoInteractions(orderService); + verifyNoInteractions(admissionService); } @Test - @DisplayName("내 주문 목록을 조회한다") - void getMyOrders() throws Exception { - when(orderService.getMyOrders(1L)).thenReturn(List.of( - new OrderSummaryResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null) - )); + @DisplayName("본인의 주문 요청 상태를 조회한다") + void getOrderRequest() throws Exception { + UUID requestId = UUID.randomUUID(); + when(queryService.get(1L, requestId)).thenReturn( + new OrderRequestStatusResponse(requestId, OrderRequestStatus.PENDING, null, null, null)); - mockMvc.perform(get("/orders/me")) + mockMvc.perform(get("/order-requests/{requestId}", requestId)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(1000L)) - .andExpect(jsonPath("$[0].status").value("ORDERED")); + .andExpect(jsonPath("$.status").value("PENDING")); } @Test - @DisplayName("내 주문 상세를 조회한다") - void getMyOrder() throws Exception { - when(orderService.getMyOrder(1L, 1000L)).thenReturn( - new OrderResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null, List.of()) - ); - - mockMvc.perform(get("/orders/{orderId}", 1000L)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1000L)) - .andExpect(jsonPath("$.totalPrice").value(94400)); - } - - @Test - @DisplayName("내 주문을 취소한다") + @DisplayName("주문 취소는 취소 조정 서비스에 위임한다") void cancelOrder() throws Exception { mockMvc.perform(post("/orders/{orderId}/cancel", 1000L)) .andExpect(status().isNoContent()); + verify(cancellationService).cancel(1L, 1000L); + } - verify(orderService).cancel(1L, 1000L); + private ReservedOrderSnapshot snapshot(UUID requestId) { + return new ReservedOrderSnapshot( + requestId, 1L, 100L, "hash", "100:2", 2, Instant.now(), + "Limited Hoodie", new BigDecimal("59000"), 20, + "CONFIRMED", OrderRequestStatus.PENDING, null, null, + Instant.now().plusSeconds(86_400).toEpochMilli() + ); } } diff --git a/src/test/java/com/dropit/order/controller/OrderDocumentationTest.java b/src/test/java/com/dropit/order/controller/OrderDocumentationTest.java new file mode 100644 index 0000000..83451ff --- /dev/null +++ b/src/test/java/com/dropit/order/controller/OrderDocumentationTest.java @@ -0,0 +1,146 @@ +package com.dropit.order.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import com.dropit.order.dto.response.OrderItemResponse; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.service.OrderService; +import com.dropit.order.service.OrderAdmissionService; +import com.dropit.order.service.OrderRequestQueryService; +import com.dropit.order.service.OrderCancellationService; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.entity.OrderRequestStatus; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; + +import java.math.BigDecimal; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class OrderDocumentationTest extends DocumentationTestSupport { + + private OrderService orderService; + private OrderAdmissionService admissionService; + private OrderRequestQueryService queryService; + private OrderCancellationService cancellationService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + orderService = mock(OrderService.class); + admissionService = mock(OrderAdmissionService.class); + queryService = mock(OrderRequestQueryService.class); + cancellationService = mock(OrderCancellationService.class); + configure(restDocumentation, + new OrderController(orderService, admissionService, queryService, cancellationService)); + } + + @Test + void create() throws Exception { + UUID requestId = UUID.randomUUID(); + when(admissionService.admit(eq(1L), eq("order-key"), any())).thenReturn(new ReservedOrderSnapshot( + requestId, 1L, 100L, "hash", "100:2", 2, Instant.now(), + "Limited Hoodie", new BigDecimal("59000"), 20, + "CONFIRMED", OrderRequestStatus.PENDING, null, null, + Instant.now().plusSeconds(86400).toEpochMilli())); + mockMvc.perform(authenticated(post("/orders").contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", "order-key") + .content("{\"items\":[{\"dropId\":100,\"quantity\":2}]}"))) + .andExpect(status().isAccepted()) + .andDo(document("orders-create", resource(builder() + .tag("Orders").summary("주문 접수").description("Redis 예약 후 SQS가 수락한 주문 요청을 비동기로 접수합니다.") + .requestHeaders(authorizationHeader(), + org.springframework.restdocs.headers.HeaderDocumentation.headerWithName("Idempotency-Key") + .description("주문 재전송을 식별하는 멱등키")) + .requestSchema(new Schema("OrderCreateRequest")) + .requestFields( + fieldWithPath("items").type(ARRAY).description("주문 항목 목록 (현재 한 항목만 허용)"), + fieldWithPath("items[].dropId").type(NUMBER).description("주문할 드랍 ID"), + fieldWithPath("items[].quantity").type(NUMBER).description("주문 수량") + ).responseSchema(new Schema("OrderAdmissionResponse")).responseFields( + fieldWithPath("requestId").type(STRING).description("주문 요청 ID") + ).build()))); + } + + @Test + void getMyOrders() throws Exception { + when(orderService.getMyOrders(1L)).thenReturn(List.of(new OrderSummaryResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null))); + mockMvc.perform(authenticated(get("/orders/me"))) + .andExpect(status().isOk()) + .andDo(document("orders-get-me", resource(builder() + .tag("Orders").summary("내 주문 목록 조회").requestHeaders(authorizationHeader()) + .responseSchema(new Schema("OrderSummaryList")) + .responseFields( + fieldWithPath("[]").type(ARRAY).description("주문 요약 목록"), + fieldWithPath("[].id").type(NUMBER).description("주문 ID"), + fieldWithPath("[].status").type(STRING).description("주문 상태"), + fieldWithPath("[].totalPrice").type(NUMBER).description("총 주문 금액"), + fieldWithPath("[].createdAt").type(STRING).optional().description("주문 생성 시각") + ).build()))); + } + + @Test + void getMyOrder() throws Exception { + when(orderService.getMyOrder(1L, 1000L)).thenReturn(orderResponse()); + mockMvc.perform(authenticated(get("/orders/{orderId}", 1000L))) + .andExpect(status().isOk()) + .andDo(document("orders-get", resource(builder() + .tag("Orders").summary("내 주문 상세 조회").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("orderId").description("주문 ID")) + .responseSchema(new Schema("OrderResponse")).responseFields(orderFields()).build()))); + } + + @Test + void cancel() throws Exception { + mockMvc.perform(authenticated(post("/orders/{orderId}/cancel", 1000L))) + .andExpect(status().isNoContent()) + .andDo(document("orders-cancel", resource(builder() + .tag("Orders").summary("주문 취소").description("현재 사용자의 주문을 취소합니다.").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("orderId").description("주문 ID")).build()))); + } + + private OrderResponse orderResponse() { + return new OrderResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null, + List.of(new OrderItemResponse(2000L, 100L, "Limited Hoodie", new BigDecimal("59000"), 20, 2, new BigDecimal("94400")))); + } + + private org.springframework.restdocs.payload.FieldDescriptor[] orderFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("id").type(NUMBER).description("주문 ID"), + fieldWithPath("status").type(STRING).description("주문 상태"), + fieldWithPath("totalPrice").type(NUMBER).description("총 주문 금액"), + fieldWithPath("createdAt").type(STRING).optional().description("주문 생성 시각"), + fieldWithPath("items").type(ARRAY).description("주문 항목 목록"), + fieldWithPath("items[].id").type(NUMBER).description("주문 항목 ID"), + fieldWithPath("items[].dropId").type(NUMBER).description("주문한 드랍 ID"), + fieldWithPath("items[].productName").type(STRING).description("주문 당시 상품명"), + fieldWithPath("items[].unitPrice").type(NUMBER).description("주문 당시 단가"), + fieldWithPath("items[].discountRate").type(NUMBER).description("주문 당시 할인율"), + fieldWithPath("items[].quantity").type(NUMBER).description("주문 수량"), + fieldWithPath("items[].itemTotalPrice").type(NUMBER).description("주문 항목 금액") + }; + } +} diff --git a/src/test/java/com/dropit/order/messaging/OrderSqsListenerConfigTest.java b/src/test/java/com/dropit/order/messaging/OrderSqsListenerConfigTest.java new file mode 100644 index 0000000..d3462b0 --- /dev/null +++ b/src/test/java/com/dropit/order/messaging/OrderSqsListenerConfigTest.java @@ -0,0 +1,58 @@ +package com.dropit.order.messaging; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class OrderSqsListenerConfigTest { + + private final OrderSqsListenerConfig config = new OrderSqsListenerConfig(); + private final SqsAsyncClient client = mock(SqsAsyncClient.class); + + @Test + @DisplayName("Consumer가 꺼져 있어도 Producer가 사용할 queue URL은 필수다") + void requireQueueUrlWhenConsumerIsDisabled() { + assertThrows(IllegalArgumentException.class, + () -> config.orderSqsListenerContainerFactory(client, properties(false, "", 0, -1, 43_201))); + } + + @Test + @DisplayName("Consumer가 꺼져 있으면 수신 전용 설정 범위는 검사하지 않는다") + void ignoreReceivePropertiesWhenConsumerIsDisabled() { + assertDoesNotThrow(() -> config.orderSqsListenerContainerFactory( + client, properties(false, "queue-url", 0, -1, 43_201))); + } + + @ParameterizedTest + @MethodSource("invalidActiveProperties") + @DisplayName("활성 Consumer의 SQS 수신 설정이 유효하지 않으면 시작에 실패한다") + void rejectInvalidActiveConsumerProperties(SqsProperties properties) { + assertThrows(IllegalArgumentException.class, + () -> config.orderSqsListenerContainerFactory(client, properties)); + } + + private static Stream invalidActiveProperties() { + return Stream.of( + properties(true, "queue-url", 0, 0, 0), + properties(true, "queue-url", 11, 0, 0), + properties(true, "queue-url", 1, -1, 0), + properties(true, "queue-url", 1, 21, 0), + properties(true, "queue-url", 1, 0, -1), + properties(true, "queue-url", 1, 0, 43_201) + ); + } + + private static SqsProperties properties( + boolean enabled, String queueUrl, int maxMessages, int waitSeconds, int visibilitySeconds + ) { + return new SqsProperties(queueUrl, enabled, 1, maxMessages, waitSeconds, visibilitySeconds, 3_000); + } +} diff --git a/src/test/java/com/dropit/order/messaging/SqsOrderMessageListenerTest.java b/src/test/java/com/dropit/order/messaging/SqsOrderMessageListenerTest.java new file mode 100644 index 0000000..e4784c4 --- /dev/null +++ b/src/test/java/com/dropit/order/messaging/SqsOrderMessageListenerTest.java @@ -0,0 +1,38 @@ +package com.dropit.order.messaging; + +import io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class SqsOrderMessageListenerTest { + + private final OrderMessageProcessor processor = mock(OrderMessageProcessor.class); + private final Acknowledgement acknowledgement = mock(Acknowledgement.class); + private final SqsOrderMessageListener listener = new SqsOrderMessageListener(processor); + + @Test + @DisplayName("DB와 Redis 처리가 완료된 뒤에만 ACK한다") + void acknowledgeAfterProcessing() { + OrderMessage message = mock(OrderMessage.class); + + listener.listen(message, acknowledgement); + + var order = inOrder(processor, acknowledgement); + order.verify(processor).process(message); + order.verify(acknowledgement).acknowledge(); + } + + @Test + @DisplayName("처리 실패는 ACK하지 않고 listener 밖으로 전달한다") + void doNotAcknowledgeProcessingFailure() { + OrderMessage message = mock(OrderMessage.class); + doThrow(new IllegalStateException("redis unavailable")).when(processor).process(message); + + assertThrows(IllegalStateException.class, () -> listener.listen(message, acknowledgement)); + + verifyNoInteractions(acknowledgement); + } +} diff --git a/src/test/java/com/dropit/order/redis/RedisOrderAdmissionAdapterTest.java b/src/test/java/com/dropit/order/redis/RedisOrderAdmissionAdapterTest.java new file mode 100644 index 0000000..3ab0378 --- /dev/null +++ b/src/test/java/com/dropit/order/redis/RedisOrderAdmissionAdapterTest.java @@ -0,0 +1,59 @@ +package com.dropit.order.redis; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class RedisOrderAdmissionAdapterTest { + + @Test + @DisplayName("request index와 절대 만료를 하나의 Lua 실행으로 저장한다") + @SuppressWarnings({"rawtypes", "unchecked"}) + void indexRequestAtomicallyWithExpiration() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class))).thenReturn(1L); + RedisOrderAdmissionAdapter adapter = new RedisOrderAdmissionAdapter(redisTemplate); + + adapter.indexRequest(snapshot()); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(RedisScript.class); + verify(redisTemplate).execute(scriptCaptor.capture(), anyList(), any(Object[].class)); + String script = scriptCaptor.getValue().getScriptAsString(); + assertAll( + () -> assertTrue(script.contains("HSET")), + () -> assertTrue(script.contains("PEXPIREAT")), + () -> assertTrue(script.contains("DEL")) + ); + } + + @Test + @DisplayName("index TTL 설정이 확인되지 않으면 저장 실패로 처리한다") + @SuppressWarnings({"rawtypes", "unchecked"}) + void rejectUnconfirmedIndexExpiration() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class))).thenReturn(0L); + RedisOrderAdmissionAdapter adapter = new RedisOrderAdmissionAdapter(redisTemplate); + + assertThrows(IllegalStateException.class, () -> adapter.indexRequest(snapshot())); + } + + private ReservedOrderSnapshot snapshot() { + return new ReservedOrderSnapshot( + UUID.randomUUID(), 1L, 10L, "hash", "10:1", 1, Instant.now(), + "product", new BigDecimal("1000"), 0, "UNCONFIRMED", + com.dropit.order.entity.OrderRequestStatus.PENDING, null, null, + Instant.now().plusSeconds(3600).toEpochMilli() + ); + } +} diff --git a/src/test/java/com/dropit/order/redis/RedisOrderFlowIntegrationTest.java b/src/test/java/com/dropit/order/redis/RedisOrderFlowIntegrationTest.java new file mode 100644 index 0000000..12a70e2 --- /dev/null +++ b/src/test/java/com/dropit/order/redis/RedisOrderFlowIntegrationTest.java @@ -0,0 +1,125 @@ +package com.dropit.order.redis; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RedisOrderFlowIntegrationTest { + + private static final GenericContainer REDIS = new GenericContainer<>( + DockerImageName.parse("redis:7.4-alpine")) + .withExposedPorts(6379); + + private static LettuceConnectionFactory connectionFactory; + private static StringRedisTemplate redisTemplate; + + @BeforeAll + static void setUpRedis() { + REDIS.start(); + connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + connectionFactory.afterPropertiesSet(); + redisTemplate = new StringRedisTemplate(connectionFactory); + redisTemplate.afterPropertiesSet(); + } + + @AfterAll + static void tearDownRedis() { + connectionFactory.destroy(); + REDIS.stop(); + } + + @Test + @DisplayName("예약·중복·실패 복원이 Redis에서 원자적이고 멱등하게 처리된다") + void reserveReplayAndRestoreOnce() { + long dropId = 101L; + long userId = 201L; + long closeAt = Instant.now().plusSeconds(3600).toEpochMilli(); + redisTemplate.opsForHash().putAll(RedisOrderKeyFactory.saleKey(dropId), Map.of( + "purchaseLimit", "3", "openAt", "0", "closeAt", Long.toString(closeAt), + "productName", "product", "unitPrice", "1000", "discountRate", "0")); + redisTemplate.opsForValue().set(RedisOrderKeyFactory.stockKey(dropId), "2"); + + UUID requestId = UUID.randomUUID(); + long acceptedMicros = Instant.now().toEpochMilli() * 1_000L; + OrderAdmissionRequest request = new OrderAdmissionRequest( + requestId, userId, dropId, "hash", "101:1", 1, acceptedMicros); + RedisOrderAdmissionAdapter admission = new RedisOrderAdmissionAdapter(redisTemplate); + + assertEquals(OrderAdmissionResultType.NEW, admission.reserve(request).type()); + assertEquals(com.dropit.order.entity.OrderRequestStatus.PENDING, + admission.getSnapshot(dropId, userId, "hash").status()); + assertEquals(OrderAdmissionResultType.REPLAY, admission.reserve(request).type()); + assertEquals("1", redisTemplate.opsForValue().get(RedisOrderKeyFactory.stockKey(dropId))); + assertEquals("1", redisTemplate.opsForHash().get(RedisOrderKeyFactory.purchaseKey(dropId), "201")); + + RedisOrderFinalizationSyncAdapter finalization = new RedisOrderFinalizationSyncAdapter(redisTemplate); + OrderRequestSyncCommand command = new OrderRequestSyncCommand( + requestId, RedisOrderRequestState.FAILED, null, userId, dropId, + "hash", 1, "INSUFFICIENT_STOCK", 1L, Instant.ofEpochMilli(closeAt + 86_400_000L)); + assertEquals(OrderRequestSyncResult.APPLIED, finalization.sync(command)); + assertEquals(OrderRequestSyncResult.ALREADY_APPLIED, finalization.sync(command)); + assertEquals("2", redisTemplate.opsForValue().get(RedisOrderKeyFactory.stockKey(dropId))); + assertEquals("0", redisTemplate.opsForHash().get(RedisOrderKeyFactory.purchaseKey(dropId), "201")); + } + + @Test + @DisplayName("취소된 주문의 Redis 상태는 유지하고 주문 요청은 성공 완료로 조회한다") + void readCanceledOrderAsCompletedRequest() { + long dropId = 102L; + long userId = 202L; + long closeAt = Instant.now().plusSeconds(3600).toEpochMilli(); + redisTemplate.opsForHash().putAll(RedisOrderKeyFactory.saleKey(dropId), Map.of( + "purchaseLimit", "3", "openAt", "0", "closeAt", Long.toString(closeAt), + "productName", "product", "unitPrice", "1000", "discountRate", "0")); + redisTemplate.opsForValue().set(RedisOrderKeyFactory.stockKey(dropId), "1"); + + UUID requestId = UUID.randomUUID(); + OrderAdmissionRequest request = new OrderAdmissionRequest( + requestId, userId, dropId, "canceled-hash", "102:1", 1, + Instant.now().toEpochMilli() * 1_000L); + RedisOrderAdmissionAdapter admission = new RedisOrderAdmissionAdapter(redisTemplate); + assertEquals(OrderAdmissionResultType.NEW, admission.reserve(request).type()); + + RedisOrderFinalizationSyncAdapter finalization = new RedisOrderFinalizationSyncAdapter(redisTemplate); + OrderRequestSyncCommand command = new OrderRequestSyncCommand( + requestId, RedisOrderRequestState.CANCELED, 301L, userId, dropId, + "canceled-hash", 1, null, 1L, Instant.ofEpochMilli(closeAt + 86_400_000L)); + assertEquals(OrderRequestSyncResult.APPLIED, finalization.sync(command)); + + String reservationKey = RedisOrderKeyFactory.idempotencyKey(dropId, userId, "canceled-hash"); + assertEquals("CANCELED", redisTemplate.opsForHash().get(reservationKey, "status")); + ReservedOrderSnapshot snapshot = admission.getSnapshot(dropId, userId, "canceled-hash"); + assertEquals(com.dropit.order.entity.OrderRequestStatus.SUCCEEDED, snapshot.status()); + assertTrue(snapshot.terminal()); + } + + @Test + @DisplayName("requestId index는 생성과 동시에 절대 만료 시각을 가진다") + void indexHasExpiration() { + RedisOrderAdmissionAdapter admission = new RedisOrderAdmissionAdapter(redisTemplate); + UUID requestId = UUID.randomUUID(); + long expiresAt = Instant.now().plusSeconds(3600).toEpochMilli(); + ReservedOrderSnapshot snapshot = new ReservedOrderSnapshot( + requestId, 1L, 1L, "hash", "1:1", 1, Instant.now(), "product", + new BigDecimal("1000"), 0, "UNCONFIRMED", + com.dropit.order.entity.OrderRequestStatus.PENDING, null, null, expiresAt); + + admission.indexRequest(snapshot); + + Long ttl = redisTemplate.getExpire(RedisOrderKeyFactory.requestIndexKey(requestId)); + assertNotNull(ttl); + assertTrue(ttl > 0 && ttl <= 3600); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderAdmissionServiceTest.java b/src/test/java/com/dropit/order/service/OrderAdmissionServiceTest.java new file mode 100644 index 0000000..b229f32 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderAdmissionServiceTest.java @@ -0,0 +1,92 @@ +package com.dropit.order.service; + +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.messaging.OrderMessagePublisher; +import com.dropit.order.redis.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OrderAdmissionServiceTest { + + private final RedisOrderAdmissionAdapter redisAdapter = mock(RedisOrderAdmissionAdapter.class); + private final OrderMessagePublisher publisher = mock(OrderMessagePublisher.class); + private final OrderAdmissionService service = new OrderAdmissionService(redisAdapter, publisher); + + @Test + @DisplayName("신규 예약은 원본 멱등키를 SHA-256으로 바꾼 뒤 snapshot 메시지를 발행한다") + void publishNewReservationWithoutRawKey() { + UUID requestId = UUID.randomUUID(); + when(redisAdapter.reserve(any())).thenReturn(OrderAdmissionResult.newReservation(requestId)); + when(redisAdapter.getSnapshot(eq(10L), eq(1L), any())).thenAnswer(invocation -> + snapshot(requestId, invocation.getArgument(2), "UNCONFIRMED")); + + ReservedOrderSnapshot result = service.admit(1L, " raw-key ", request()); + + ArgumentCaptor command = ArgumentCaptor.forClass(OrderAdmissionRequest.class); + verify(redisAdapter).reserve(command.capture()); + assertAll( + () -> assertEquals(requestId, result.requestId()), + () -> assertEquals(64, command.getValue().idempotencyKeyHash().length()), + () -> assertNotEquals(" raw-key ", command.getValue().idempotencyKeyHash()), + () -> assertEquals("10:2", command.getValue().fingerprint()) + ); + verify(redisAdapter).indexRequest(result); + verify(publisher).publish(result.toMessage()); + verify(redisAdapter).confirmPublication(result); + } + + @Test + @DisplayName("발행 확인된 replay는 같은 requestId를 반환하고 다시 발행하지 않는다") + void doNotRepublishConfirmedReplay() { + UUID requestId = UUID.randomUUID(); + when(redisAdapter.reserve(any())).thenReturn(OrderAdmissionResult.replay(requestId)); + when(redisAdapter.getSnapshot(eq(10L), eq(1L), any())).thenAnswer(invocation -> + snapshot(requestId, invocation.getArgument(2), "CONFIRMED")); + + ReservedOrderSnapshot result = service.admit(1L, "key", request()); + + assertEquals(requestId, result.requestId()); + verifyNoInteractions(publisher); + } + + @Test + @DisplayName("SQS 발행 결과 불명은 예약을 복원하지 않고 같은 requestId를 전달한다") + void keepReservationWhenPublicationIsUnconfirmed() { + UUID requestId = UUID.randomUUID(); + when(redisAdapter.reserve(any())).thenReturn(OrderAdmissionResult.newReservation(requestId)); + when(redisAdapter.getSnapshot(eq(10L), eq(1L), any())).thenAnswer(invocation -> + snapshot(requestId, invocation.getArgument(2), "UNCONFIRMED")); + doThrow(new IllegalStateException("timeout")).when(publisher).publish(any()); + + OrderPublicationUnconfirmedException exception = assertThrows( + OrderPublicationUnconfirmedException.class, + () -> service.admit(1L, "key", request())); + + assertEquals(requestId, exception.getRequestId()); + verify(redisAdapter, never()).confirmPublication(any()); + } + + private OrderCreateRequest request() { + return new OrderCreateRequest(List.of(new OrderItemCreateRequest(10L, 2))); + } + + private ReservedOrderSnapshot snapshot(UUID requestId, String keyHash, String publicationState) { + return new ReservedOrderSnapshot( + requestId, 1L, 10L, keyHash, "10:2", 2, Instant.now(), + "product", new BigDecimal("1000"), 10, publicationState, + com.dropit.order.entity.OrderRequestStatus.PENDING, null, null, + Instant.now().plusSeconds(3600).toEpochMilli() + ); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderCancellationServiceTest.java b/src/test/java/com/dropit/order/service/OrderCancellationServiceTest.java new file mode 100644 index 0000000..cc24030 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderCancellationServiceTest.java @@ -0,0 +1,88 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.order.entity.DropUserPurchase; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.repository.DropUserPurchaseRepository; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.order.repository.OrderRequestRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OrderCancellationServiceTest { + + @Test + @DisplayName("취소 대상 항목은 dropId 순서로 조회해 구매량과 Drop 락 순서를 고정한다") + @SuppressWarnings("unchecked") + void acquireLocksInDropIdOrder() { + OrderRepository orderRepository = mock(OrderRepository.class); + OrderRequestRepository requestRepository = mock(OrderRequestRepository.class); + OrderItemRepository itemRepository = mock(OrderItemRepository.class); + DropUserPurchaseRepository purchaseRepository = mock(DropUserPurchaseRepository.class); + DropRepository dropRepository = mock(DropRepository.class); + OrderRequestRedisSyncService redisSyncService = mock(OrderRequestRedisSyncService.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + TransactionStatus transactionStatus = mock(TransactionStatus.class); + when(transactionTemplate.execute(any())).thenAnswer(invocation -> + ((TransactionCallback) invocation.getArgument(0)).doInTransaction(transactionStatus)); + + Order order = order(); + OrderItem first = item(10L, order); + OrderItem second = item(20L, order); + Drop firstDrop = first.getDrop(); + Drop secondDrop = second.getDrop(); + when(requestRepository.findByOrderIdForUpdate(100L)).thenReturn(Optional.empty()); + when(orderRepository.findByIdAndUser_IdForUpdate(100L, 1L)).thenReturn(Optional.of(order)); + when(itemRepository.findAllForCancellationOrderByDropIdAsc(100L)).thenReturn(List.of(first, second)); + when(purchaseRepository.findByDropIdAndUserIdForUpdate(anyLong(), eq(1L))) + .thenReturn(Optional.of(mock(DropUserPurchase.class))); + when(dropRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(firstDrop)); + when(dropRepository.findByIdForUpdate(20L)).thenReturn(Optional.of(secondDrop)); + when(purchaseRepository.decreaseConfirmedQuantity(anyLong(), eq(1L), eq(1))).thenReturn(1); + + new OrderCancellationService( + orderRepository, requestRepository, itemRepository, purchaseRepository, + dropRepository, redisSyncService, transactionTemplate + ).cancel(1L, 100L); + + verify(itemRepository).findAllForCancellationOrderByDropIdAsc(100L); + var locks = inOrder(purchaseRepository, dropRepository); + locks.verify(purchaseRepository).findByDropIdAndUserIdForUpdate(10L, 1L); + locks.verify(dropRepository).findByIdForUpdate(10L); + locks.verify(purchaseRepository).findByDropIdAndUserIdForUpdate(20L, 1L); + locks.verify(dropRepository).findByIdForUpdate(20L); + } + + private Order order() { + User buyer = new User("buyer@example.com", "password", "buyer", UserRole.USER); + ReflectionTestUtils.setField(buyer, "id", 1L); + Order order = new Order(buyer, new BigDecimal("2000")); + ReflectionTestUtils.setField(order, "id", 100L); + return order; + } + + private OrderItem item(Long dropId, Order order) { + Drop drop = mock(Drop.class); + when(drop.getId()).thenReturn(dropId); + OrderItem item = mock(OrderItem.class); + when(item.getDrop()).thenReturn(drop); + when(item.getQuantity()).thenReturn(1); + return item; + } +} diff --git a/src/test/java/com/dropit/order/service/OrderRedisSyncRetrySchedulerTest.java b/src/test/java/com/dropit/order/service/OrderRedisSyncRetrySchedulerTest.java new file mode 100644 index 0000000..84a59d3 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderRedisSyncRetrySchedulerTest.java @@ -0,0 +1,42 @@ +package com.dropit.order.service; + +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.repository.OrderRequestRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OrderRedisSyncRetrySchedulerTest { + + @Test + @DisplayName("한 요청의 실패를 다음 시각으로 미루고 같은 배치의 다음 요청을 계속 처리한다") + void deferFailureAndContinueBatch() { + OrderRequestRepository repository = mock(OrderRequestRepository.class); + OrderRequestRedisSyncService syncService = mock(OrderRequestRedisSyncService.class); + OrderRequest first = mock(OrderRequest.class); + OrderRequest second = mock(OrderRequest.class); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + when(first.getId()).thenReturn(firstId); + when(second.getId()).thenReturn(secondId); + when(repository + .findTop100ByRedisSyncPendingTrueAndRedisSyncNextAttemptAtLessThanEqualOrderByRedisSyncNextAttemptAtAsc( + any(Instant.class))) + .thenReturn(List.of(first, second)); + doThrow(new IllegalStateException("redis unavailable")).when(syncService).sync(firstId); + doThrow(new IllegalStateException("database unavailable")) + .when(syncService).defer(firstId, Duration.ofSeconds(5)); + + new OrderRedisSyncRetryScheduler(repository, syncService).retryPending(); + + verify(syncService).defer(firstId, Duration.ofSeconds(5)); + verify(syncService).sync(secondId); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderRequestQueryServiceTest.java b/src/test/java/com/dropit/order/service/OrderRequestQueryServiceTest.java new file mode 100644 index 0000000..ebb8c38 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderRequestQueryServiceTest.java @@ -0,0 +1,56 @@ +package com.dropit.order.service; + +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.entity.OrderRequestStatus; +import com.dropit.order.redis.RedisOrderAdmissionAdapter; +import com.dropit.order.redis.ReservedOrderSnapshot; +import com.dropit.order.repository.OrderRequestRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class OrderRequestQueryServiceTest { + + private final OrderRequestRepository repository = mock(OrderRequestRepository.class); + private final RedisOrderAdmissionAdapter redisAdapter = mock(RedisOrderAdmissionAdapter.class); + private final OrderRequestQueryService service = new OrderRequestQueryService(repository, redisAdapter); + + @Test + @DisplayName("DB terminal 결과가 있으면 Redis를 조회하지 않는다") + void preferDatabaseTerminalResult() { + UUID requestId = UUID.randomUUID(); + OrderRequest request = mock(OrderRequest.class); + Order order = mock(Order.class); + when(request.isTerminal()).thenReturn(true); + when(request.getId()).thenReturn(requestId); + when(request.getStatus()).thenReturn(OrderRequestStatus.SUCCEEDED); + when(request.getOrder()).thenReturn(order); + when(repository.findByIdAndUserId(requestId, 1L)).thenReturn(Optional.of(request)); + + var response = service.get(1L, requestId); + + assertEquals(OrderRequestStatus.SUCCEEDED, response.status()); + verifyNoInteractions(redisAdapter); + } + + @Test + @DisplayName("DB 반영 전에는 본인 소유 Redis 예약을 PENDING으로 반환한다") + void returnRedisPendingBeforeDatabaseRegistration() { + UUID requestId = UUID.randomUUID(); + ReservedOrderSnapshot snapshot = mock(ReservedOrderSnapshot.class); + when(snapshot.requestId()).thenReturn(requestId); + when(snapshot.status()).thenReturn(OrderRequestStatus.PENDING); + when(repository.findByIdAndUserId(requestId, 1L)).thenReturn(Optional.empty()); + when(redisAdapter.findByRequestId(requestId, 1L)).thenReturn(Optional.of(snapshot)); + + var response = service.get(1L, requestId); + + assertEquals(OrderRequestStatus.PENDING, response.status()); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderServiceTest.java b/src/test/java/com/dropit/order/service/OrderServiceTest.java index 5c8fd89..2d4898b 100644 --- a/src/test/java/com/dropit/order/service/OrderServiceTest.java +++ b/src/test/java/com/dropit/order/service/OrderServiceTest.java @@ -1,193 +1,80 @@ package com.dropit.order.service; -import com.dropit.drop.entity.Drop; -import com.dropit.drop.repository.DropRepository; import com.dropit.global.exception.ServiceException; -import com.dropit.order.dto.request.OrderCreateRequest; -import com.dropit.order.dto.request.OrderItemCreateRequest; import com.dropit.order.dto.response.OrderResponse; import com.dropit.order.dto.response.OrderSummaryResponse; import com.dropit.order.entity.Order; -import com.dropit.order.entity.OrderItem; import com.dropit.order.entity.OrderStatus; import com.dropit.order.exception.OrderErrorCode; import com.dropit.order.repository.OrderItemRepository; import com.dropit.order.repository.OrderRepository; -import com.dropit.product.entity.Product; import com.dropit.user.entity.User; import com.dropit.user.entity.UserRole; -import com.dropit.user.repository.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { - @Mock private UserRepository userRepository; - @Mock private DropRepository dropRepository; @Mock private OrderRepository orderRepository; @Mock private OrderItemRepository orderItemRepository; @InjectMocks private OrderService orderService; @Test - @DisplayName("주문을 생성하면 재고를 차감하고 주문 시점 정보를 저장한다") - void createOrder() { - User buyer = createUser(1L, UserRole.USER); - Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); - OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); - - when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); - when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); - when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) - .thenReturn(0L); - when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> { - Order order = invocation.getArgument(0); - ReflectionTestUtils.setField(order, "id", 1000L); - return order; - }); - when(orderItemRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); - - OrderResponse response = orderService.create(1L, request); - - assertEquals(1000L, response.id()); - assertEquals(OrderStatus.ORDERED, response.status()); - assertEquals(new BigDecimal("94400"), response.totalPrice()); - assertEquals(8, drop.getRemainingQuantity()); - assertEquals(1, response.items().size()); - assertEquals("Limited Hoodie", response.items().getFirst().productName()); - assertEquals(new BigDecimal("59000"), response.items().getFirst().unitPrice()); - assertEquals(20, response.items().getFirst().discountRate()); - assertEquals(new BigDecimal("94400"), response.items().getFirst().itemTotalPrice()); - } - - @Test - @DisplayName("기존 구매 수량과 새 수량의 합이 구매 제한을 넘으면 주문할 수 없다") - void rejectPurchaseLimitExceeded() { - User buyer = createUser(1L, UserRole.USER); - Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); - OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); - - when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); - when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); - when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) - .thenReturn(1L); - - ServiceException exception = assertThrows( - ServiceException.class, - () -> orderService.create(1L, request) - ); - - assertEquals(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED, exception.getErrorCode()); - assertEquals(10, drop.getRemainingQuantity()); - verify(orderRepository, never()).save(any()); - } - - @Test - @DisplayName("내 주문 목록을 최신순 조회 결과로 반환한다") + @DisplayName("내 주문 목록은 최신순 조회 결과를 반환한다") void getMyOrders() { - User buyer = createUser(1L, UserRole.USER); - Order order = new Order(buyer, new BigDecimal("94400")); - ReflectionTestUtils.setField(order, "id", 1000L); + Order order = order(1000L); when(orderRepository.findAllByUser_IdOrderByCreatedAtDesc(1L)).thenReturn(List.of(order)); List responses = orderService.getMyOrders(1L); assertEquals(1, responses.size()); assertEquals(1000L, responses.getFirst().id()); - assertEquals(new BigDecimal("94400"), responses.getFirst().totalPrice()); - } - - @Test - @DisplayName("내 주문 상세는 주문 항목을 함께 반환한다") - void getMyOrder() { - User buyer = createUser(1L, UserRole.USER); - Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); - Order order = new Order(buyer, new BigDecimal("94400")); - ReflectionTestUtils.setField(order, "id", 1000L); - OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); - - when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); - when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); - - OrderResponse response = orderService.getMyOrder(1L, 1000L); - - assertEquals(1000L, response.id()); - assertEquals(1, response.items().size()); - assertEquals(100L, response.items().getFirst().dropId()); } @Test - @DisplayName("본인 주문이 아니면 주문 상세를 조회할 수 없다") + @DisplayName("본인 소유가 아닌 주문 상세는 노출하지 않는다") void rejectAnotherUsersOrder() { when(orderRepository.findByIdAndUser_Id(1000L, 2L)).thenReturn(Optional.empty()); ServiceException exception = assertThrows( - ServiceException.class, - () -> orderService.getMyOrder(2L, 1000L) - ); + ServiceException.class, () -> orderService.getMyOrder(2L, 1000L)); assertEquals(OrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); - verify(orderItemRepository, never()).findAllByOrder_IdOrderByIdAsc(any()); + verifyNoInteractions(orderItemRepository); } @Test - @DisplayName("주문을 취소하면 주문 상태를 변경하고 재고를 복구한다") - void cancelOrder() { - User buyer = createUser(1L, UserRole.USER); - Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); - drop.decreaseStock(2, LocalDateTime.now()); - Order order = new Order(buyer, new BigDecimal("94400")); - ReflectionTestUtils.setField(order, "id", 1000L); - OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); - + @DisplayName("내 주문 상세는 저장된 주문 항목과 함께 반환한다") + void getMyOrder() { + Order order = order(1000L); when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); - when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); - - orderService.cancel(1L, 1000L); + when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of()); - assertEquals(OrderStatus.CANCELED, order.getStatus()); - assertEquals(10, drop.getRemainingQuantity()); - } + OrderResponse response = orderService.getMyOrder(1L, 1000L); - private User createUser(Long id, UserRole role) { - User user = new User("user@example.com", "password", "user", role); - ReflectionTestUtils.setField(user, "id", id); - return user; + assertEquals(1000L, response.id()); + assertEquals(OrderStatus.ORDERED, response.status()); } - private Drop createOpenDrop(Long sellerId, Long dropId, int stock, int discountRate, int purchaseLimit) { - User seller = createUser(sellerId, UserRole.SELLER); - Product product = new Product(seller, "Limited Hoodie", "description", null); - ReflectionTestUtils.setField(product, "id", 10L); - Drop drop = new Drop( - product, - new BigDecimal("59000"), - stock, - discountRate, - purchaseLimit, - LocalDateTime.now().minusHours(1), - LocalDateTime.now().plusHours(1) - ); - ReflectionTestUtils.setField(drop, "id", dropId); - drop.changeVisibility(true); - return drop; + private Order order(Long id) { + User buyer = new User("buyer@example.com", "password", "buyer", UserRole.USER); + ReflectionTestUtils.setField(buyer, "id", 1L); + Order order = new Order(buyer, new BigDecimal("59000")); + ReflectionTestUtils.setField(order, "id", id); + return order; } } diff --git a/src/test/java/com/dropit/order/validation/OrderMessageValidatorTest.java b/src/test/java/com/dropit/order/validation/OrderMessageValidatorTest.java new file mode 100644 index 0000000..3308371 --- /dev/null +++ b/src/test/java/com/dropit/order/validation/OrderMessageValidatorTest.java @@ -0,0 +1,52 @@ +package com.dropit.order.validation; + +import com.dropit.order.entity.OrderRequest; +import com.dropit.order.messaging.OrderMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OrderMessageValidatorTest { + + @Test + @DisplayName("acceptedAt의 나노초 차이는 MySQL 저장 정밀도에 맞춰 동일 요청으로 판단한다") + void normalizeAcceptedAtToMicroseconds() { + OrderMessage message = new OrderMessage( + OrderMessage.CURRENT_SCHEMA_VERSION, + UUID.randomUUID(), + 1L, + 2L, + "idempotency-key-hash", + "2:1", + "Limited Hoodie", + new BigDecimal("59000"), + 1, + 20, + Instant.parse("2026-09-13T12:34:56.123456789Z") + ); + LocalDateTime acceptedAt = OrderMessageValidator.acceptedAt(message); + OrderRequest request = new OrderRequest( + message.requestId(), + message.userId(), + message.dropId(), + message.idempotencyKeyHash(), + message.requestFingerprint(), + message.productName(), + message.unitPrice(), + message.quantity(), + message.discountRate(), + acceptedAt, + Instant.parse("2026-09-14T12:34:56Z") + ); + + assertEquals(123_456_000, acceptedAt.getNano()); + assertDoesNotThrow(() -> OrderMessageValidator.validateSameRequest(request, message)); + } +} diff --git a/src/test/java/com/dropit/product/concurrency/ProductDropConcurrencyTest.java b/src/test/java/com/dropit/product/concurrency/ProductDropConcurrencyTest.java new file mode 100644 index 0000000..b4f0098 --- /dev/null +++ b/src/test/java/com/dropit/product/concurrency/ProductDropConcurrencyTest.java @@ -0,0 +1,304 @@ +package com.dropit.product.concurrency; + +import com.dropit.drop.cache.DropDetailCacheReader; +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.repository.DropRepository; +import com.dropit.drop.service.DropService; +import com.dropit.global.config.QuerydslConfig; +import com.dropit.global.exception.ServiceException; +import com.dropit.global.storage.S3ImageService; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import com.dropit.product.service.ProductService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.mysql.MySQLContainer; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest(properties = { + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.open-in-view=false" +}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({ProductService.class, DropService.class, QuerydslConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class ProductDropConcurrencyTest { + + private static final int REPEAT_COUNT = 100; + private static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4"); + + static { + MYSQL.start(); + } + + @Autowired + private ProductService productService; + + @Autowired + private DropService dropService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DropRepository dropRepository; + + @MockitoBean + private S3ImageService s3ImageService; + + @MockitoBean + private DropDetailCacheReader dropDetailCacheReader; + + @DynamicPropertySource + static void registerDatasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + @AfterEach + void cleanUp() { + dropRepository.deleteAllInBatch(); + productRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("비관적 락으로 상품 삭제와 Drop 생성을 순차 처리한다") + void controlProductDeleteAndDropCreateWithPessimisticLock() throws Exception { + User seller = userRepository.saveAndFlush(new User( + "concurrency-seller@dropit.test", + "encoded-password", + "concurrency-seller", + UserRole.SELLER + )); + + AtomicInteger deleteSuccess = new AtomicInteger(); + AtomicInteger createSuccess = new AtomicInteger(); + AtomicInteger expectedConflict = new AtomicInteger(); + AtomicInteger unexpectedFailure = new AtomicInteger(); + AtomicInteger orphanDrop = new AtomicInteger(); + Map expectedTypes = new ConcurrentHashMap<>(); + Map unexpectedTypes = new ConcurrentHashMap<>(); + + long startedAt = System.nanoTime(); + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + for (int attempt = 0; attempt < REPEAT_COUNT; attempt++) { + Product product = productRepository.saveAndFlush(new Product( + seller, + "Concurrency Product " + attempt, + "Before lock measurement", + null + )); + + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + Future deleteFuture = executor.submit(() -> { + awaitStart(ready, start); + return executeDelete(seller.getId(), product.getId()); + }); + + Future createFuture = executor.submit(() -> { + awaitStart(ready, start); + return executeCreate(seller.getId(), product.getId()); + }); + + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + + countResult( + deleteFuture.get(10, TimeUnit.SECONDS), + deleteSuccess, + expectedConflict, + unexpectedFailure, + expectedTypes, + unexpectedTypes + ); + countResult( + createFuture.get(10, TimeUnit.SECONDS), + createSuccess, + expectedConflict, + unexpectedFailure, + expectedTypes, + unexpectedTypes + ); + + boolean productExists = productRepository.existsById(product.getId()); + boolean dropExists = dropRepository.existsByProductId(product.getId()); + + if (!productExists && dropExists) { + orphanDrop.incrementAndGet(); + } + + dropRepository.deleteAllInBatch(); + productRepository.deleteById(product.getId()); + productRepository.flush(); + } + } + + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedAt + ); + + System.out.printf( + "CONCURRENCY_AFTER repeat=%d deleteSuccess=%d createSuccess=%d " + + "expectedConflict=%d unexpectedFailure=%d orphanDrop=%d " + + "elapsedMs=%d expectedTypes=%s unexpectedTypes=%s%n", + REPEAT_COUNT, + deleteSuccess.get(), + createSuccess.get(), + expectedConflict.get(), + unexpectedFailure.get(), + orphanDrop.get(), + elapsedMillis, + expectedTypes, + unexpectedTypes + ); + + assertEquals(REPEAT_COUNT, deleteSuccess.get() + createSuccess.get()); + assertEquals(REPEAT_COUNT, expectedConflict.get()); + assertEquals(0, unexpectedFailure.get()); + assertEquals(REPEAT_COUNT * 2, + deleteSuccess.get() + + createSuccess.get() + + expectedConflict.get() + + unexpectedFailure.get()); + assertEquals(0, orphanDrop.get()); + } + + private OperationResult executeDelete(Long sellerId, Long productId) { + try { + productService.delete(sellerId, productId); + return OperationResult.success(); + } catch (RuntimeException exception) { + return classify(exception); + } + } + + private OperationResult executeCreate(Long sellerId, Long productId) { + try { + dropService.save(sellerId, createRequest(productId)); + return OperationResult.success(); + } catch (RuntimeException exception) { + return classify(exception); + } + } + + private OperationResult classify(RuntimeException exception) { + if (exception instanceof ServiceException serviceException + && (serviceException.getErrorCode() == ProductErrorCode.PRODUCT_NOT_FOUND + || serviceException.getErrorCode() == ProductErrorCode.PRODUCT_IN_USE_BY_DROP)) { + return OperationResult.expectedConflict( + serviceException.getErrorCode().code() + ); + } + + return OperationResult.unexpected(exception.getClass().getSimpleName()); + } + + private void countResult( + OperationResult result, + AtomicInteger success, + AtomicInteger expectedConflict, + AtomicInteger unexpectedFailure, + Map expectedTypes, + Map unexpectedTypes + ) { + if (result.status() == ResultStatus.SUCCESS) { + success.incrementAndGet(); + return; + } + + if (result.status() == ResultStatus.EXPECTED_CONFLICT) { + expectedConflict.incrementAndGet(); + expectedTypes.computeIfAbsent( + result.exceptionType(), + key -> new AtomicInteger() + ).incrementAndGet(); + return; + } + + unexpectedFailure.incrementAndGet(); + unexpectedTypes.computeIfAbsent( + result.exceptionType(), + key -> new AtomicInteger() + ).incrementAndGet(); + } + + private void awaitStart( + CountDownLatch ready, + CountDownLatch start + ) throws InterruptedException { + ready.countDown(); + start.await(); + } + + private DropCreateRequest createRequest(Long productId) { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + return new DropCreateRequest( + productId, + new BigDecimal("59000"), + 100, + 0, + 1, + openAt, + openAt.plusDays(1) + ); + } + + private enum ResultStatus { + SUCCESS, + EXPECTED_CONFLICT, + UNEXPECTED_FAILURE + } + + private record OperationResult( + ResultStatus status, + String exceptionType + ) { + private static OperationResult success() { + return new OperationResult(ResultStatus.SUCCESS, null); + } + + private static OperationResult expectedConflict(String errorCode) { + return new OperationResult(ResultStatus.EXPECTED_CONFLICT, errorCode); + } + + private static OperationResult unexpected(String exceptionType) { + return new OperationResult(ResultStatus.UNEXPECTED_FAILURE, exceptionType); + } + } +} diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index 56193b7..2221cde 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -36,6 +36,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -373,6 +374,21 @@ void deleteProduct() throws Exception { verify(productService).delete(sellerId, productId); } + @Test + @DisplayName("Drop에서 사용 중인 상품을 삭제하면 409 오류 응답을 반환한다") + void rejectDeletingProductUsedByDrop() throws Exception { + doThrow(new ServiceException(ProductErrorCode.PRODUCT_IN_USE_BY_DROP)) + .when(productService).delete(1L, 100L); + + mockMvc.perform(delete("/products/{productId}", 100L)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCT_IN_USE_BY_DROP")) + .andExpect(jsonPath("$.message") + .value("판매 일정에서 사용 중인 상품은 삭제할 수 없습니다.")); + + verify(productService).delete(1L, 100L); + } + @Test @DisplayName("판매자는 상품 이미지를 업로드하면 200 상태와 서명된 이미지 URL을 반환한다") void uploadImage() throws Exception { diff --git a/src/test/java/com/dropit/product/controller/ProductDocumentationTest.java b/src/test/java/com/dropit/product/controller/ProductDocumentationTest.java new file mode 100644 index 0000000..4d90aab --- /dev/null +++ b/src/test/java/com/dropit/product/controller/ProductDocumentationTest.java @@ -0,0 +1,196 @@ +package com.dropit.product.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.service.ProductService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class ProductDocumentationTest extends DocumentationTestSupport { + + private ProductService productService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + productService = mock(ProductService.class); + configure(restDocumentation, new ProductController(productService), new PageableHandlerMethodArgumentResolver()); + } + + @Test + void create() throws Exception { + when(productService.create(eq(1L), any())).thenReturn(100L); + mockMvc.perform(authenticated(post("/products").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Limited Hoodie\",\"description\":\"Limited edition hoodie\"}"))) + .andExpect(status().isCreated()) + .andDo(document("products-create", resource(builder() + .tag("Products").summary("상품 생성").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("ProductCreateRequest")) + .requestFields(fieldWithPath("name").description("상품명"), fieldWithPath("description").optional().description("상품 설명")) + .build()))); + } + + @Test + void getProduct() throws Exception { + when(productService.getProduct(100L)).thenReturn(productResponse()); + mockMvc.perform(authenticated(get("/products/{productId}", 100L))) + .andExpect(status().isOk()) + .andDo(document("products-get", resource(builder() + .tag("Products").summary("상품 단건 조회").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")) + .responseSchema(new Schema("ProductResponse")) + .responseFields(productFields()).build()))); + } + + @Test + void getProducts() throws Exception { + when(productService.getProducts(any())).thenReturn(new PageImpl<>(List.of(productResponse()), PageRequest.of(0, 20), 1)); + mockMvc.perform(authenticated(get("/products").param("page", "0").param("size", "20"))) + .andExpect(status().isOk()) + .andDo(document("products-get-all", resource(builder() + .tag("Products").summary("상품 목록 조회").description("상품 목록을 페이징하여 조회합니다.") + .requestHeaders(authorizationHeader()) + .queryParameters(parameterWithName("page").description("페이지 번호"), parameterWithName("size").description("페이지 크기"), parameterWithName("sort").optional().description("정렬 조건")) + .responseSchema(new Schema("ProductPageResponse")) + .responseFields(pageFields()).build()))); + } + + @Test + void getProductsBySeller() throws Exception { + when(productService.getProductsBySeller(eq(1L), any())).thenReturn(new PageImpl<>(List.of(productResponse()), PageRequest.of(0, 20), 1)); + mockMvc.perform(authenticated(get("/sellers/{sellerId}/products", 1L).param("page", "0").param("size", "20"))) + .andExpect(status().isOk()) + .andDo(document("products-get-by-seller", resource(builder() + .tag("Products").summary("판매자 상품 목록 조회").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("sellerId").description("판매자 ID")) + .queryParameters(parameterWithName("page").description("페이지 번호"), parameterWithName("size").description("페이지 크기"), parameterWithName("sort").optional().description("정렬 조건")) + .responseSchema(new Schema("ProductPageResponse")) + .responseFields(pageFields()).build()))); + } + + @Test + void update() throws Exception { + when(productService.update(eq(1L), eq(100L), any())).thenReturn(productResponse()); + mockMvc.perform(authenticated(put("/products/{productId}", 100L).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Updated Hoodie\",\"description\":\"Updated description\"}"))) + .andExpect(status().isOk()) + .andDo(document("products-update", resource(builder() + .tag("Products").summary("상품 수정").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")) + .requestSchema(new Schema("ProductUpdateRequest")) + .requestFields(fieldWithPath("name").description("상품명"), fieldWithPath("description").optional().description("상품 설명")) + .responseSchema(new Schema("ProductResponse")) + .responseFields(productFields()).build()))); + } + + @Test + void deleteProduct() throws Exception { + mockMvc.perform(authenticated(delete("/products/{productId}", 100L))) + .andExpect(status().isNoContent()) + .andDo(document("products-delete", resource(builder() + .tag("Products").summary("상품 삭제").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")).build()))); + } + + @Test + void uploadImage() throws Exception { + when(productService.uploadImage(eq(1L), eq(100L), any())).thenReturn(productResponse()); + MockMultipartFile file = new MockMultipartFile("file", "product.jpg", "image/jpeg", "image".getBytes()); + mockMvc.perform(multipart("/products/{productId}/image", 100L).file(file).content(" ").with(bearerToken()).with(request -> { + request.setMethod("PUT"); + return request; + })) + .andExpect(status().isOk()) + .andDo(document("products-upload-image", resource(builder() + .tag("Products").summary("상품 이미지 업로드").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")) + .requestSchema(new Schema("MultipartImage")) + .responseSchema(new Schema("ProductResponse")) + .responseFields(productFields()).build()), + requestParts(partWithName("file").description("업로드할 이미지 파일")))); + } + + private ProductResponse productResponse() { + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + Product product = new Product(seller, "Limited Hoodie", "Limited edition hoodie", null); + ReflectionTestUtils.setField(product, "id", 100L); + return new ProductResponse(product, "https://cdn.example.com/product.jpg"); + } + + private org.springframework.restdocs.payload.FieldDescriptor[] productFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("id").type(NUMBER).description("상품 ID"), + fieldWithPath("sellerId").type(NUMBER).description("판매자 ID"), + fieldWithPath("sellerName").type(STRING).description("판매자 이름"), + fieldWithPath("name").type(STRING).description("상품명"), + fieldWithPath("description").type(STRING).optional().description("상품 설명"), + fieldWithPath("imageUrl").type(STRING).optional().description("상품 이미지 URL"), + fieldWithPath("createdAt").type(STRING).optional().description("생성 시각"), + fieldWithPath("updatedAt").type(STRING).optional().description("수정 시각") + }; + } + + private org.springframework.restdocs.payload.FieldDescriptor[] pageFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("content").description("상품 목록"), + fieldWithPath("content[].id").type(NUMBER).description("상품 ID"), + fieldWithPath("content[].sellerId").type(NUMBER).description("판매자 ID"), + fieldWithPath("content[].sellerName").type(STRING).description("판매자 이름"), + fieldWithPath("content[].name").type(STRING).description("상품명"), + fieldWithPath("content[].description").type(STRING).optional().description("상품 설명"), + fieldWithPath("content[].imageUrl").type(STRING).optional().description("상품 이미지 URL"), + fieldWithPath("content[].createdAt").type(STRING).optional().description("생성 시각"), + fieldWithPath("content[].updatedAt").type(STRING).optional().description("수정 시각"), + fieldWithPath("pageable").description("페이징 정보"), + fieldWithPath("pageable.*").ignored(), + fieldWithPath("pageable.sort").ignored(), + fieldWithPath("pageable.sort.*").ignored(), + fieldWithPath("last").description("마지막 페이지 여부"), + fieldWithPath("totalElements").description("전체 요소 수"), + fieldWithPath("totalPages").description("전체 페이지 수"), + fieldWithPath("size").description("페이지 크기"), + fieldWithPath("number").description("현재 페이지 번호"), + fieldWithPath("sort").description("정렬 정보"), + fieldWithPath("sort.*").ignored(), + fieldWithPath("first").description("첫 페이지 여부"), + fieldWithPath("numberOfElements").description("현재 페이지 요소 수"), + fieldWithPath("empty").description("빈 페이지 여부") + }; + } +} diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 5368364..94588ff 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -1,5 +1,6 @@ package com.dropit.product.service; +import com.dropit.drop.repository.DropRepository; import com.dropit.global.exception.ServiceException; import com.dropit.global.storage.S3ImageService; import com.dropit.product.dto.request.ProductCreateRequest; @@ -46,6 +47,9 @@ class ProductServiceTest { @Mock private UserRepository userRepository; + @Mock + private DropRepository dropRepository; + @Mock private S3ImageService s3ImageService; @@ -130,8 +134,13 @@ void getProduct() { ReflectionTestUtils.setField(product, "id", productId); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(s3ImageService.createDownloadUrl("products/limited-hoodie.webp")) - .thenReturn("https://signed.example.com/limited-hoodie.webp"); + when( + s3ImageService.createPublicUrl( + "products/limited-hoodie.webp" + ) + ).thenReturn( + "https://d3czchk38dd04k.cloudfront.net/products/limited-hoodie.webp" + ); ProductResponse response = productService.getProduct(productId); @@ -141,7 +150,7 @@ void getProduct() { assertEquals("Limited Hoodie", response.getName()); assertEquals("Limited edition hoodie", response.getDescription()); assertEquals( - "https://signed.example.com/limited-hoodie.webp", + "https://d3czchk38dd04k.cloudfront.net/products/limited-hoodie.webp", response.getImageUrl() ); } @@ -318,8 +327,8 @@ void uploadImage() { when(productRepository.findById(productId)).thenReturn(Optional.of(product)); when(s3ImageService.upload(file, "products/100")) .thenReturn("products/100/new.png"); - when(s3ImageService.createDownloadUrl("products/100/new.png")) - .thenReturn("https://signed.example.com/new.png"); + when(s3ImageService.createPublicUrl("products/100/new.png")) + .thenReturn("https://public.example.com/new.png"); ProductResponse response = productService.uploadImage( sellerId, @@ -328,7 +337,7 @@ void uploadImage() { ); assertEquals("products/100/new.png", product.getImageUrl()); - assertEquals("https://signed.example.com/new.png", response.getImageUrl()); + assertEquals("https://public.example.com/new.png", response.getImageUrl()); verify(s3ImageService).upload(file, "products/100"); } @@ -367,18 +376,42 @@ void deleteProduct() { null ); ReflectionTestUtils.setField(product, "id", productId); - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(product)); + when(dropRepository.existsByProductId(productId)).thenReturn(false); productService.delete(sellerId, productId); + verify(dropRepository).existsByProductId(productId); verify(productRepository).delete(product); } + @Test + @DisplayName("Drop에서 사용 중인 상품은 삭제할 수 없다") + void rejectDeletingProductUsedByDrop() { + Long sellerId = 1L; + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product(seller, "Product", null, null); + ReflectionTestUtils.setField(product, "id", productId); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(product)); + when(dropRepository.existsByProductId(productId)).thenReturn(true); + + assertServiceException( + ProductErrorCode.PRODUCT_IN_USE_BY_DROP, + () -> productService.delete(sellerId, productId) + ); + + verify(dropRepository).existsByProductId(productId); + verify(productRepository, never()).delete(any(Product.class)); + } + @Test @DisplayName("존재하지 않는 상품은 삭제할 수 없다") void rejectDeletingMissingProduct() { Long productId = 999L; - when(productRepository.findById(productId)).thenReturn(Optional.empty()); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.empty()); assertServiceException( ProductErrorCode.PRODUCT_NOT_FOUND, @@ -404,13 +437,14 @@ void rejectDeletingAnotherSellersProduct() { null ); ReflectionTestUtils.setField(product, "id", productId); - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(product)); assertServiceException( ProductErrorCode.PRODUCT_OWNER_REQUIRED, () -> productService.delete(otherSellerId, productId) ); + verify(dropRepository, never()).existsByProductId(productId); verify(productRepository, never()).delete(any(Product.class)); } diff --git a/src/test/java/com/dropit/sellerprofile/controller/SellerProfileDocumentationTest.java b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileDocumentationTest.java new file mode 100644 index 0000000..bae3945 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileDocumentationTest.java @@ -0,0 +1,140 @@ +package com.dropit.sellerprofile.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.entity.SellerProfile; +import com.dropit.sellerprofile.service.SellerProfileService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.epages.restdocs.apispec.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class SellerProfileDocumentationTest extends DocumentationTestSupport { + + private SellerProfileService sellerProfileService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + sellerProfileService = mock(SellerProfileService.class); + configure(restDocumentation, new SellerProfileController(sellerProfileService)); + } + + @Test + void create() throws Exception { + when(sellerProfileService.create(eq(1L), any())).thenReturn(response()); + mockMvc.perform(authenticated(post("/seller-profiles") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\":\"소개\",\"instagramUrl\":\"https://instagram.com/seller\",\"youtubeUrl\":\"https://youtube.com/@seller\"}"))) + .andExpect(status().isCreated()) + .andDo(document("seller-profiles-create", resource(builder() + .tag("Seller Profiles").summary("판매자 프로필 생성").description("로그인한 판매자의 프로필을 생성합니다.").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("SellerProfileCreateRequest")) + .requestFields( + fieldWithPath("description").optional().description("판매자 소개"), + fieldWithPath("instagramUrl").optional().description("인스타그램 URL"), + fieldWithPath("youtubeUrl").optional().description("유튜브 URL") + ).responseSchema(new Schema("SellerProfileResponse")).responseFields(profileFields()).build()))); + } + + @Test + void getMine() throws Exception { + when(sellerProfileService.getMine(1L)).thenReturn(response()); + mockMvc.perform(authenticated(get("/seller-profiles/me"))) + .andExpect(status().isOk()) + .andDo(document("seller-profiles-get-me", resource(builder() + .tag("Seller Profiles").summary("내 판매자 프로필 조회").requestHeaders(authorizationHeader()).responseSchema(new Schema("SellerProfileResponse")).responseFields(profileFields()).build()))); + } + + @Test + void getById() throws Exception { + when(sellerProfileService.getById(10L)).thenReturn(response()); + mockMvc.perform(authenticated(get("/seller-profiles/{sellerProfileId}", 10L))) + .andExpect(status().isOk()) + .andDo(document("seller-profiles-get", resource(builder() + .tag("Seller Profiles").summary("판매자 프로필 조회").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("sellerProfileId").description("판매자 프로필 ID")) + .responseSchema(new Schema("SellerProfileResponse")).responseFields(profileFields()).build()))); + } + + @Test + void update() throws Exception { + when(sellerProfileService.update(eq(1L), any())).thenReturn(response()); + mockMvc.perform(authenticated(patch("/seller-profiles/me") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\":\"수정된 소개\",\"instagramUrl\":\"https://instagram.com/new\",\"youtubeUrl\":\"https://youtube.com/@new\"}"))) + .andExpect(status().isOk()) + .andDo(document("seller-profiles-update", resource(builder() + .tag("Seller Profiles").summary("판매자 프로필 수정").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("SellerProfileUpdateRequest")) + .requestFields( + fieldWithPath("description").optional().description("판매자 소개"), + fieldWithPath("instagramUrl").optional().description("인스타그램 URL"), + fieldWithPath("youtubeUrl").optional().description("유튜브 URL") + ).responseSchema(new Schema("SellerProfileResponse")).responseFields(profileFields()).build()))); + } + + @Test + void uploadImage() throws Exception { + when(sellerProfileService.uploadImage(eq(1L), any())).thenReturn(response()); + MockMultipartFile file = new MockMultipartFile("file", "profile.jpg", "image/jpeg", "image".getBytes()); + mockMvc.perform(multipart("/seller-profiles/me/image").file(file).content(" ").with(bearerToken()).with(request -> { + request.setMethod("PUT"); + return request; + })) + .andExpect(status().isOk()) + .andDo(document("seller-profiles-upload-image", resource(builder() + .tag("Seller Profiles").summary("판매자 프로필 이미지 업로드").description("프로필 이미지를 업로드합니다.").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("MultipartImage")) + .responseSchema(new Schema("SellerProfileResponse")).responseFields(profileFields()).build()), + requestParts(partWithName("file").description("업로드할 이미지 파일")))); + } + + @Test + void deleteProfile() throws Exception { + mockMvc.perform(authenticated(delete("/seller-profiles/me"))) + .andExpect(status().isNoContent()) + .andDo(document("seller-profiles-delete", resource(builder() + .tag("Seller Profiles").summary("판매자 프로필 삭제").requestHeaders(authorizationHeader()).build()))); + } + + private SellerProfileResponse response() { + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = new SellerProfile(seller, "소개", null, "https://instagram.com/seller", "https://youtube.com/@seller"); + ReflectionTestUtils.setField(profile, "id", 10L); + return SellerProfileResponse.from(profile, "https://cdn.example.com/profile.jpg"); + } + + private org.springframework.restdocs.payload.FieldDescriptor[] profileFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("id").type(NUMBER).description("판매자 프로필 ID"), + fieldWithPath("userId").type(NUMBER).description("사용자 ID"), + fieldWithPath("description").type(STRING).optional().description("판매자 소개"), + fieldWithPath("imageUrl").type(STRING).optional().description("프로필 이미지 URL"), + fieldWithPath("instagramUrl").type(STRING).optional().description("인스타그램 URL"), + fieldWithPath("youtubeUrl").type(STRING).optional().description("유튜브 URL") + }; + } +} diff --git a/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java index d7c34b9..926ff77 100644 --- a/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java +++ b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java @@ -178,13 +178,21 @@ void uploadImage() { .thenReturn(Optional.of(profile)); when(s3ImageService.upload(file, "seller-profiles/1")) .thenReturn("seller-profiles/1/new.png"); - when(s3ImageService.createDownloadUrl("seller-profiles/1/new.png")) - .thenReturn("https://signed.example.com/new.png"); + when( + s3ImageService.createPublicUrl( + "seller-profiles/1/new.png" + ) + ).thenReturn( + "https://d3czchk38dd04k.cloudfront.net/seller-profiles/1/new.png" + ); SellerProfileResponse response = sellerProfileService.uploadImage(userId, file); assertEquals("seller-profiles/1/new.png", profile.getImageUrl()); - assertEquals("https://signed.example.com/new.png", response.imageUrl()); + assertEquals( + "https://d3czchk38dd04k.cloudfront.net/seller-profiles/1/new.png", + response.imageUrl() + ); verify(s3ImageService).upload(file, "seller-profiles/1"); } diff --git a/src/test/java/com/dropit/user/controller/UserDocumentationTest.java b/src/test/java/com/dropit/user/controller/UserDocumentationTest.java new file mode 100644 index 0000000..e6086f3 --- /dev/null +++ b/src/test/java/com/dropit/user/controller/UserDocumentationTest.java @@ -0,0 +1,114 @@ +package com.dropit.user.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.epages.restdocs.apispec.Schema; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class UserDocumentationTest extends DocumentationTestSupport { + + private UserService userService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + userService = mock(UserService.class); + configure(restDocumentation, new UserController(userService)); + } + + @Test + void getMyInfo() throws Exception { + when(userService.findUser(1L)).thenReturn(user()); + mockMvc.perform(authenticated(get("/users/me"))) + .andExpect(status().isOk()) + .andDo(document("users-get-me", resource(builder() + .tag("Users").summary("내 정보 조회").description("현재 로그인한 사용자의 정보를 조회합니다.").requestHeaders(authorizationHeader()) + .responseSchema(new Schema("UserResponse")) + .build()))); + } + + @Test + void updateMyProfile() throws Exception { + when(userService.updateProfile(org.mockito.ArgumentMatchers.eq(1L), any())).thenReturn(user()); + mockMvc.perform(authenticated(patch("/users/me") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"new@example.com\",\"username\":\"new-user\"}"))) + .andExpect(status().isOk()) + .andDo(document("users-update-me", resource(builder() + .tag("Users").summary("내 정보 수정").description("현재 로그인한 사용자의 이메일과 이름을 수정합니다.").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("UserUpdateRequest")) + .requestFields( + fieldWithPath("email").optional().description("변경할 이메일 주소"), + fieldWithPath("username").optional().description("변경할 사용자 이름") + ) + .responseFields( + fieldWithPath("id").type(NUMBER).description("사용자 ID"), + fieldWithPath("email").type(STRING).description("이메일 주소"), + fieldWithPath("username").type(STRING).description("사용자 이름"), + fieldWithPath("role").type(STRING).description("사용자 역할"), + fieldWithPath("createdAt").type(STRING).optional().description("가입 시각"), + fieldWithPath("updatedAt").type(STRING).optional().description("수정 시각") + ).responseSchema(new Schema("UserResponse")).build()))); + } + + @Test + void updateMyPassword() throws Exception { + mockMvc.perform(authenticated(patch("/users/me/password") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old-password\",\"newPassword\":\"new-password\"}"))) + .andExpect(status().isNoContent()) + .andDo(document("users-update-password", resource(builder() + .tag("Users").summary("비밀번호 변경").description("현재 비밀번호를 확인하고 새 비밀번호로 변경합니다.").requestHeaders(authorizationHeader()) + .requestSchema(new Schema("PasswordUpdateRequest")) + .requestFields( + fieldWithPath("currentPassword").description("현재 비밀번호"), + fieldWithPath("newPassword").description("새 비밀번호 (8자 이상)") + ).build()))); + } + + @Test + void deleteMyAccount() throws Exception { + mockMvc.perform(authenticated(delete("/users/me"))) + .andExpect(status().isNoContent()) + .andDo(document("users-delete-me", resource(builder() + .tag("Users").summary("내 계정 삭제").description("현재 로그인한 사용자의 계정을 삭제합니다.").requestHeaders(authorizationHeader()).build()))); + } + + @Test + void deleteUserByAdmin() throws Exception { + mockMvc.perform(authenticated(delete("/users/{userId}", 2L))) + .andExpect(status().isNoContent()) + .andDo(document("users-delete-by-admin", resource(builder() + .tag("Users").summary("사용자 삭제").description("관리자가 지정한 사용자를 삭제합니다.").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("userId").description("사용자 ID")).build()))); + } + + private User user() { + User user = new User("user@example.com", "encoded-password", "user", UserRole.USER); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + } +} diff --git a/src/test/java/com/dropit/wishlist/controller/WishlistControllerTest.java b/src/test/java/com/dropit/wishlist/controller/WishlistControllerTest.java new file mode 100644 index 0000000..a1c2f8d --- /dev/null +++ b/src/test/java/com/dropit/wishlist/controller/WishlistControllerTest.java @@ -0,0 +1,80 @@ +package com.dropit.wishlist.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.wishlist.service.WishlistService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class WishlistControllerTest { + + private MockMvc mockMvc; + private WishlistService wishlistService; + + @BeforeEach + void setUp() { + wishlistService = mock(WishlistService.class); + + mockMvc = MockMvcBuilders + .standaloneSetup(new WishlistController(wishlistService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken(new AuthUser(1L), List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("현재 사용자가 상품을 찜한다") + void addWishlist() throws Exception { + mockMvc.perform(post("/wishlists/{productId}", 10L)) + .andExpect(status().isOk()); + + verify(wishlistService).add(1L, 10L); + } + + @Test + @DisplayName("현재 사용자의 찜 목록을 조회한다") + void getMine() throws Exception { + when(wishlistService.getMine(1L)) + .thenReturn(List.of()); + + mockMvc.perform(get("/wishlists/me")) + .andExpect(status().isOk()); + + verify(wishlistService).getMine(1L); + } + + @Test + @DisplayName("현재 사용자가 찜한 상품을 삭제한다") + void deleteWishlist() throws Exception { + mockMvc.perform(delete("/wishlists/{productId}", 10L)) + .andExpect(status().isNoContent()); + + verify(wishlistService).delete(1L, 10L); + } +} diff --git a/src/test/java/com/dropit/wishlist/controller/WishlistDocumentationTest.java b/src/test/java/com/dropit/wishlist/controller/WishlistDocumentationTest.java new file mode 100644 index 0000000..0e03171 --- /dev/null +++ b/src/test/java/com/dropit/wishlist/controller/WishlistDocumentationTest.java @@ -0,0 +1,99 @@ +package com.dropit.wishlist.controller; + +import com.dropit.documentation.DocumentationTestSupport; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.wishlist.dto.response.WishlistResponse; +import com.dropit.wishlist.entity.Wishlist; +import com.dropit.wishlist.service.WishlistService; +import com.epages.restdocs.apispec.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(RestDocumentationExtension.class) +class WishlistDocumentationTest extends DocumentationTestSupport { + + private WishlistService wishlistService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + wishlistService = mock(WishlistService.class); + configure(restDocumentation, new WishlistController(wishlistService)); + } + + @Test + void add() throws Exception { + when(wishlistService.add(1L, 10L)).thenReturn(response()); + mockMvc.perform(authenticated(post("/wishlists/{productId}", 10L))) + .andExpect(status().isOk()) + .andDo(document("wishlists-add", resource(builder() + .tag("Wishlists").summary("상품 찜하기").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")) + .responseSchema(new Schema("WishlistResponse")).responseFields(wishlistFields()).build()))); + } + + @Test + void getMine() throws Exception { + when(wishlistService.getMine(1L)).thenReturn(List.of(response())); + mockMvc.perform(authenticated(get("/wishlists/me"))) + .andExpect(status().isOk()) + .andDo(document("wishlists-get-me", resource(builder() + .tag("Wishlists").summary("내 찜 목록 조회").requestHeaders(authorizationHeader()) + .responseSchema(new Schema("WishlistList")) + .responseFields( + fieldWithPath("[]").type(ARRAY).description("찜 목록"), + fieldWithPath("[].id").type(NUMBER).description("찜 ID"), + fieldWithPath("[].productId").type(NUMBER).description("상품 ID"), + fieldWithPath("[].productName").type(STRING).description("상품명"), + fieldWithPath("[].productImageUrl").type(STRING).optional().description("상품 이미지 URL"), + fieldWithPath("[].createdAt").type(STRING).optional().description("찜 생성 시각") + ).build()))); + } + + @Test + void deleteWishlist() throws Exception { + mockMvc.perform(authenticated(delete("/wishlists/{productId}", 10L))) + .andExpect(status().isNoContent()) + .andDo(document("wishlists-delete", resource(builder() + .tag("Wishlists").summary("상품 찜 삭제").requestHeaders(authorizationHeader()) + .pathParameters(parameterWithName("productId").description("상품 ID")).build()))); + } + + private WishlistResponse response() { + User user = new User("user@example.com", "encoded-password", "user", UserRole.USER); + ReflectionTestUtils.setField(user, "id", 1L); + Product product = new Product(user, "Limited Hoodie", "Limited edition hoodie", "products/10/image.jpg"); + ReflectionTestUtils.setField(product, "id", 10L); + Wishlist wishlist = new Wishlist(user, product); + ReflectionTestUtils.setField(wishlist, "id", 100L); + return new WishlistResponse(wishlist); + } + + private org.springframework.restdocs.payload.FieldDescriptor[] wishlistFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("id").type(NUMBER).description("찜 ID"), + fieldWithPath("productId").type(NUMBER).description("상품 ID"), + fieldWithPath("productName").type(STRING).description("상품명"), + fieldWithPath("productImageUrl").type(STRING).optional().description("상품 이미지 URL"), + fieldWithPath("createdAt").type(STRING).optional().description("찜 생성 시각") + }; + } +} diff --git a/src/test/java/com/dropit/wishlist/service/WishlistServiceTest.java b/src/test/java/com/dropit/wishlist/service/WishlistServiceTest.java new file mode 100644 index 0000000..927992f --- /dev/null +++ b/src/test/java/com/dropit/wishlist/service/WishlistServiceTest.java @@ -0,0 +1,186 @@ +package com.dropit.wishlist.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import com.dropit.wishlist.dto.response.WishlistResponse; +import com.dropit.wishlist.entity.Wishlist; +import com.dropit.wishlist.exception.WishlistErrorCode; +import com.dropit.wishlist.repository.WishlistRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WishlistServiceTest { + + @Mock + private WishlistRepository wishlistRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private WishlistService wishlistService; + + @Test + @DisplayName("상품을 찜 목록에 추가한다") + void addWishlist() { + User user = createUser(1L); + Product product = createProduct(10L); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + when(productRepository.findById(10L)) + .thenReturn(Optional.of(product)); + when(wishlistRepository.existsByUser_IdAndProduct_Id(1L, 10L)) + .thenReturn(false); + when(wishlistRepository.save(any(Wishlist.class))) + .thenAnswer(invocation -> { + Wishlist wishlist = invocation.getArgument(0); + ReflectionTestUtils.setField(wishlist, "id", 100L); + return wishlist; + }); + + WishlistResponse response = wishlistService.add(1L, 10L); + + assertEquals(100L, response.getId()); + assertEquals(10L, response.getProductId()); + + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("이미 찜한 상품은 다시 추가할 수 없다") + void rejectDuplicateWishlist() { + User user = createUser(1L); + Product product = createProduct(10L); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + when(productRepository.findById(10L)) + .thenReturn(Optional.of(product)); + when(wishlistRepository.existsByUser_IdAndProduct_Id(1L, 10L)) + .thenReturn(true); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> wishlistService.add(1L, 10L) + ); + + assertEquals( + WishlistErrorCode.WISHLIST_ALREADY_EXISTS, + exception.getErrorCode() + ); + + verify(wishlistRepository, never()) + .save(any(Wishlist.class)); + } + + @Test + @DisplayName("내 찜 목록을 조회한다") + void getMine() { + User user = createUser(1L); + Product product = createProduct(10L); + + Wishlist wishlist = new Wishlist(user, product); + ReflectionTestUtils.setField(wishlist, "id", 100L); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + when(wishlistRepository.findAllByUser_Id(1L)) + .thenReturn(List.of(wishlist)); + + List responses = wishlistService.getMine(1L); + + assertEquals(1, responses.size()); + assertEquals(100L, responses.getFirst().getId()); + assertEquals(10L, responses.getFirst().getProductId()); + } + + @Test + @DisplayName("찜한 상품을 삭제한다") + void deleteWishlist() { + User user = createUser(1L); + Product product = createProduct(10L); + Wishlist wishlist = new Wishlist(user, product); + + when(wishlistRepository.findByUser_IdAndProduct_Id(1L, 10L)) + .thenReturn(Optional.of(wishlist)); + + wishlistService.delete(1L, 10L); + + verify(wishlistRepository).delete(wishlist); + } + + @Test + @DisplayName("존재하지 않는 찜은 삭제할 수 없다") + void rejectDeleteMissingWishlist() { + when(wishlistRepository.findByUser_IdAndProduct_Id(1L, 10L)) + .thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> wishlistService.delete(1L, 10L) + ); + + assertEquals( + WishlistErrorCode.WISHLIST_NOT_FOUND, + exception.getErrorCode() + ); + + verify(wishlistRepository, never()) + .delete(any(Wishlist.class)); + } + + private User createUser(Long id) { + User user = new User( + "user@example.com", + "password", + "user", + UserRole.USER + ); + + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Product createProduct(Long id) { + User seller = new User( + "seller@example.com", + "password", + "seller", + UserRole.SELLER + ); + + Product product = new Product( + seller, + "Limited Product", + "description", + "image.jpg" + ); + + ReflectionTestUtils.setField(product, "id", id); + return product; + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..ffaf438 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,2 @@ +DOCS_AUTH_USERNAME=test-docs +DOCS_AUTH_PASSWORD=test-docs-password