diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..03640e68 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,53 @@ +# ์„ฑ๋Šฅ ๊ฐœ์„  + +## ๐Ÿš€ ๊ฐœ์„  ๋‚ด์šฉ + +- ๊ฐœ์„  ๋Œ€์ƒ: +- ๋ณ€๊ฒฝ ๋‚ด์šฉ: + +## ๐Ÿ” ๋ฌธ์ œ ๋ฐ ์›์ธ + +- ํ™•์ธ๋œ ๋ฌธ์ œ: +- ์›์ธ: + +## ๐Ÿ“Š ๊ฐœ์„  ๊ฒฐ๊ณผ + +| ์ง€ํ‘œ | ๊ฐœ์„  ์ „ | ๊ฐœ์„  ํ›„ | +| ---------- | ------: | ------: | +| TPS / RPS | | | +| Avg | | | +| P95 | | | +| P99 | | | +| Error Rate | | | + +## โœ… ์ถ”๊ฐ€ ํ™•์ธ + +- ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ ๋˜๋Š” ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: + +--- + +# ํ…Œ์ŠคํŠธ + +## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋‚ด์šฉ + +- ํ…Œ์ŠคํŠธ ๋Œ€์ƒ: +- ํ…Œ์ŠคํŠธ ๋ชฉ์ : + +## โš™๏ธ ํ…Œ์ŠคํŠธ ์กฐ๊ฑด + +- VU: +- ํ…Œ์ŠคํŠธ ์‹œ๊ฐ„: +- ์š”์ฒญ ์ˆ˜ / ์š”์ฒญ ๋นˆ๋„: + +## ๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ + +- TPS / RPS: +- Avg: +- P95: +- P99: +- Error Rate: + +## ๐Ÿ” ํ™•์ธ๋œ ๋ฌธ์ œ + +- ํŠน์ด์‚ฌํ•ญ ๋˜๋Š” ํ™•์ธ๋œ ๋ณ‘๋ชฉ: +- ์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..75d4ae38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +**/node_modules/ + +**/.next/ + +# IDE / OS local files +.idea/ +.vscode/ +.DS_Store + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +.env +.env.* +!.env.example + +# Java / Maven build +**/target/ +.pnpm-store/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..849f79e6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} diff --git a/apps/backend/.env.template b/apps/backend/.env.template index 00296358..cf791089 100644 --- a/apps/backend/.env.template +++ b/apps/backend/.env.template @@ -5,11 +5,44 @@ ENCRYPTION_SALT=change_me_32_hex_chars________________ JWT_SECRET=change_me_secure_jwt_secret MONGO_URI=mongodb://localhost:27017/bootcamp-chat + +MONGO_MAX_POOL_SIZE=100 +MONGO_MIN_POOL_SIZE=0 +MONGO_MAX_WAIT_TIME_MS=120000 + REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=ktb PORT=5001 WS_PORT=5002 +SOCKETIO_PRESENCE_REFRESH_INTERVAL=5m +ROOM_ACTIVITY_FLUSH_DELAY=1s +ROOM_ACTIVITY_DEBOUNCE_WINDOW=1s + +# Storage: local (default) or s3. In production, prefer an instance IAM role +# instead of static AWS credentials. +FILE_STORAGE_TYPE=local +AWS_REGION=ap-northeast-2 +S3_BUCKET= +S3_ENDPOINT= +S3_PATH_STYLE=false +S3_KEY_PREFIX= +S3_PRESIGN_TTL=10m OPENAI_API_KEY=sk-... + +TOMCAT_THREADS_MAX=100 +TOMCAT_THREADS_MIN_SPARE=10 +TOMCAT_ACCEPT_COUNT=500 +TOMCAT_MAX_CONNECTIONS=1000 + +SOCKETIO_LOG_LEVEL=WARN +SOCKETIO_LIBRARY_LOG_LEVEL=WARN + +# CloudFront +CLOUDFRONT_ENABLED=false +CLOUDFRONT_BASE_URL=https://chat.goorm-ktb-012.goorm.team +CLOUDFRONT_KEY_PAIR_ID= +CLOUDFRONT_PRIVATE_KEY_PATH=/run/secrets/cloudfront-private-key.pem +CLOUDFRONT_URL_TTL=5m diff --git a/apps/backend/.mvn/wrapper/maven-wrapper.jar b/apps/backend/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 00000000..bf82ff01 Binary files /dev/null and b/apps/backend/.mvn/wrapper/maven-wrapper.jar differ diff --git a/apps/backend/Dockerfile.runtime b/apps/backend/Dockerfile.runtime new file mode 100644 index 00000000..48c02994 --- /dev/null +++ b/apps/backend/Dockerfile.runtime @@ -0,0 +1,9 @@ +FROM eclipse-temurin:25-jre + +WORKDIR /app + +COPY ktb-chat-backend-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 5001 5002 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/apps/backend/docker-compose.o11y.yaml b/apps/backend/docker-compose.o11y.yaml index 15bcb4ad..aae9af7d 100644 --- a/apps/backend/docker-compose.o11y.yaml +++ b/apps/backend/docker-compose.o11y.yaml @@ -39,7 +39,7 @@ services: container_name: grafana-ktb restart: unless-stopped ports: - - "3000:3000" + - "9091:3000" environment: - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} @@ -47,7 +47,7 @@ services: # Behind the ALB Grafana must know its public name, or its redirects and # asset links point at the container. The default is the local port # mapping in this file, not the 9091 the dev compose uses. - - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3000} + - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:9091} - GF_ANALYTICS_REPORTING_ENABLED=false volumes: - grafana_data:/var/lib/grafana diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json new file mode 100644 index 00000000..117f764b --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json @@ -0,0 +1,420 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "legendFormat": "{{method}} {{uri}} {{status}}", + "refId": "A" + } + ], + "title": "HTTP Requests/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 8, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "legendFormat": "p95 {{uri}}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "legendFormat": "p99 {{uri}}", + "refId": "B" + } + ], + "title": "HTTP Latency p95/p99", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", status=~\"5..\"}[1m]))", + "legendFormat": "5xx/sec", + "refId": "A" + } + ], + "title": "HTTP 5xx/sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (event_type) (rate(socketio_events_total{job=\"spring-boot-app\"}[1m]))", + "legendFormat": "{{event_type}}", + "refId": "A" + } + ], + "title": "Socket.IO Events/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "100 * sum(jvm_memory_used_bytes{job=\"spring-boot-app\", area=\"heap\"}) / sum(jvm_memory_max_bytes{job=\"spring-boot-app\", area=\"heap\"})", + "legendFormat": "heap used", + "refId": "A" + } + ], + "title": "JVM Heap Used", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (area, id) (jvm_memory_used_bytes{job=\"spring-boot-app\"})", + "legendFormat": "{{area}} {{id}}", + "refId": "A" + } + ], + "title": "JVM Memory Used", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (gc, cause) (rate(jvm_gc_pause_seconds_sum{job=\"spring-boot-app\"}[5m]))", + "legendFormat": "{{gc}} {{cause}}", + "refId": "A" + } + ], + "title": "JVM GC Pause Seconds/sec", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "spring", + "load-test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timezone": "browser", + "title": "KTB Chat App Load Test", + "uid": "ktb-chat-app-load-test", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json new file mode 100644 index 00000000..dc793956 --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json @@ -0,0 +1,529 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "DOWN" + }, + "1": { + "color": "green", + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "mongodb_up{job=\"mongodb\"}", + "legendFormat": "MongoDB", + "refId": "A" + } + ], + "title": "MongoDB Exporter", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "DOWN" + }, + "1": { + "color": "green", + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "redis_up{job=\"redis\"}", + "legendFormat": "Redis", + "refId": "A" + } + ], + "title": "Redis Exporter", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "red", + "value": 500 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(redis_commands_processed_total{job=\"redis\"}[1m]))", + "legendFormat": "commands/sec", + "refId": "A" + } + ], + "title": "Redis Commands/sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "100 * redis_memory_used_bytes{job=\"redis\"} / redis_memory_max_bytes{job=\"redis\"}", + "legendFormat": "memory", + "refId": "A" + } + ], + "title": "Redis Memory Used", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(rate(mongodb_collstats_latencyStats_reads_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "reads/sec", + "refId": "A" + }, + { + "expr": "sum(rate(mongodb_collstats_latencyStats_writes_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "writes/sec", + "refId": "B" + }, + { + "expr": "sum(rate(mongodb_collstats_latencyStats_commands_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "commands/sec", + "refId": "C" + } + ], + "title": "MongoDB Collection Ops/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "redis_connected_clients{job=\"redis\"}", + "legendFormat": "connected clients", + "refId": "A" + }, + { + "expr": "sum(redis_db_keys{job=\"redis\"})", + "legendFormat": "keys", + "refId": "B" + } + ], + "title": "Redis Clients / Keys", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "100 * rate(redis_keyspace_hits_total{job=\"redis\"}[5m]) / (rate(redis_keyspace_hits_total{job=\"redis\"}[5m]) + rate(redis_keyspace_misses_total{job=\"redis\"}[5m]))", + "legendFormat": "hit ratio", + "refId": "A" + } + ], + "title": "Redis Cache Hit Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 8, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "mongodb_members_health{job=\"mongodb\"}", + "legendFormat": "member {{name}} health", + "refId": "A" + }, + { + "expr": "mongodb_members_state{job=\"mongodb\"}", + "legendFormat": "member {{name}} state", + "refId": "B" + } + ], + "title": "MongoDB Replica Member Health / State", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "mongodb", + "redis", + "load-test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timezone": "browser", + "title": "KTB Datastores", + "uid": "ktb-datastores", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json new file mode 100644 index 00000000..c16abfc3 --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json @@ -0,0 +1,1009 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 1, + "type": "text", + "title": "์ด ํ™”๋ฉด์—์„œ ๋ณด๋Š” ์ˆœ์„œ", + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 4 + }, + "options": { + "mode": "markdown", + "content": "### ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ ์ค‘์—๋Š” ์œ„์—์„œ ์•„๋ž˜๋กœ ๋ด…๋‹ˆ๋‹ค\n1. **Health / 5xx / p95 / RPS**: ์„œ๋น„์Šค๊ฐ€ ์‚ด์•„ ์žˆ๊ณ , ์—๋Ÿฌ์™€ ์ง€์—ฐ์‹œ๊ฐ„์ด ํŠ€๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n2. **CPU / Memory / Root Disk**: ์–ด๋А ์„œ๋ฒ„ ์ž์›์ด ๋จผ์ € ํ•œ๊ณ„์— ๊ฐ€๊นŒ์›Œ์ง€๋Š”์ง€ ๋ด…๋‹ˆ๋‹ค.\n3. **Backend / MongoDB / Redis**: ๋ณ‘๋ชฉ์ด ์•ฑ์ธ์ง€ DB์ธ์ง€ ์บ์‹œ์ธ์ง€ ์ขํž™๋‹ˆ๋‹ค.\n\n๊ธฐ์ค€: CPU 80% ์ง€์†, Memory 85% ์ง€์†, Root Disk 80% ์ฃผ์˜/90% ์œ„ํ—˜, 5xx๊ฐ€ 0๋ณด๋‹ค ํฌ๋ฉด ์ฆ‰์‹œ ์›์ธ ํ™•์ธ." + } + }, + { + "id": 2, + "type": "stat", + "title": "Health - ์ฃฝ์€ target์ด ์žˆ๋Š”์ง€", + "description": "0์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. 1 ์ด์ƒ์ด๋ฉด Prometheus๊ฐ€ ๋ชป ๊ธ๋Š” ์„œ๋ฒ„/exporter๊ฐ€ ์žˆ๋‹ค๋Š” ๋œป์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(up == 0)", + "refId": "A", + "legendFormat": "down targets" + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "Backend 5xx/sec - ์„œ๋ฒ„ ์—๋Ÿฌ์œจ", + "description": "0์ด์–ด์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฐ’์ด ์ƒ๊ธฐ๋ฉด ๋ฐฑ์—”๋“œ ์˜ˆ์™ธ/DB ์˜ค๋ฅ˜/ํƒ€์ž„์•„์›ƒ์„ ๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 4, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.1 + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", status=~\"5..\"}[1m]))", + "refId": "A", + "legendFormat": "5xx/sec" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Backend p95 Latency - ๋А๋ ค์ง€๋Š”์ง€", + "description": "์š”์ฒญ 100๊ฐœ ์ค‘ ๋А๋ฆฐ 5๊ฐœ์˜ ๊ฒฝ๊ณ„์ž…๋‹ˆ๋‹ค. ๋ถ€ํ•˜๊ฐ€ ์˜ค๋ฅผ ๋•Œ ๊ฐ€์žฅ ๋จผ์ € ๋ด์•ผ ํ•˜๋Š” ์‘๋‹ต์‹œ๊ฐ„์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 9, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "red", + "value": 1.5 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "refId": "A", + "legendFormat": "p95" + } + ] + }, + { + "id": 5, + "type": "stat", + "title": "Backend RPS - ์ดˆ๋‹น ์š”์ฒญ ์ˆ˜", + "description": "๋ฐฑ์—”๋“œ๊ฐ€ ์‹ค์ œ๋กœ ๋ฐ›๊ณ  ์žˆ๋Š” ์ดˆ๋‹น HTTP ์š”์ฒญ ์ˆ˜์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 14, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "refId": "A", + "legendFormat": "rps" + } + ] + }, + { + "id": 6, + "type": "stat", + "title": "Socket.IO Events/sec", + "description": "์ฑ„ํŒ… ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋Ÿ‰์ž…๋‹ˆ๋‹ค. HTTP๋Š” ๊ดœ์ฐฎ์€๋ฐ ์ฑ„ํŒ…๋งŒ ๋А๋ฆฌ๋ฉด ์ด ํŒจ๋„๊ณผ Socket.IO ์ƒ์„ธ๋ฅผ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 19, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(socketio_events_total{job=\"spring-boot-app\"}[1m]))", + "refId": "A", + "legendFormat": "events/sec" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "1. CPU Usage - ์–ด๋А ์„œ๋ฒ„ CPU๊ฐ€ ๋จผ์ € ์ฐจ๋Š”์ง€", + "description": "80% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋Š” ์„œ๋ฒ„๊ฐ€ CPU ๋ณ‘๋ชฉ ํ›„๋ณด์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "100 - (avg by (service, instance) (irate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "refId": "A", + "legendFormat": "{{service}} {{instance}}" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "2. Memory Usage - ๋ฉ”๋ชจ๋ฆฌ๊ฐ€ ๋ถ€์กฑํ•ด์ง€๋Š”์ง€", + "description": "85~90% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋ฉด OOM, GC ์ฆ๊ฐ€, ์Šค์™‘ ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 75 + }, + { + "color": "red", + "value": 90 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "((node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes) * 100", + "refId": "A", + "legendFormat": "{{service}} {{instance}}" + } + ] + }, + { + "id": 9, + "type": "gauge", + "title": "3. Root Disk Usage - / ํŒŒํ‹ฐ์…˜๋งŒ ๋ณด๊ธฐ", + "description": "/boot, /boot/efi๋Š” ์ˆจ๊ธฐ๊ณ  ์‹ค์ œ ์•ฑ/๋กœ๊ทธ/Docker/DB ๋ฐ์ดํ„ฐ๊ฐ€ ์Œ“์ด๋Š” ๋ฃจํŠธ ๋””์Šคํฌ๋งŒ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 16, + "w": 24, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 80 + }, + { + "color": "red", + "value": 90 + } + ] + } + }, + "overrides": [] + }, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "expr": "100 - ((node_filesystem_avail_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"} * 100) / node_filesystem_size_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"})", + "refId": "A", + "legendFormat": "{{service}} {{instance}} /" + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "4. Backend Latency by URI - ์–ด๋–ค API๊ฐ€ ๋А๋ฆฐ์ง€", + "description": "p95๊ฐ€ ํŠ€๋Š” URI๊ฐ€ ๋ณ‘๋ชฉ API ํ›„๋ณด์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 23, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "topk(8, histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m]))))", + "refId": "A", + "legendFormat": "p95 {{uri}}" + } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "5. HTTP Status Rate - 2xx/4xx/5xx ํ๋ฆ„", + "description": "5xx๋Š” ์„œ๋ฒ„ ๋ฌธ์ œ, 4xx๋Š” ์ธ์ฆ/๊ฒ€์ฆ/์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์ œ์ผ ๊ฐ€๋Šฅ์„ฑ์ด ํฝ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 23, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (status) (rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "refId": "A", + "legendFormat": "{{status}}" + } + ] + }, + { + "id": 12, + "type": "timeseries", + "title": "6. JVM Memory / GC - ๋ฐฑ์—”๋“œ ๋‚ด๋ถ€ ์••๋ฐ•", + "description": "CPU/์‘๋‹ต์‹œ๊ฐ„์ด ํŠˆ ๋•Œ heap ์‚ฌ์šฉ๋ฅ ๊ณผ GC pause๊ฐ€ ๊ฐ™์ด ์˜ค๋ฅด๋ฉด JVM ๋ฉ”๋ชจ๋ฆฌ/GC ๋ณ‘๋ชฉ์„ ์˜์‹ฌํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 31, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{job=\"spring-boot-app\", area=\"heap\"}) / sum(jvm_memory_max_bytes{job=\"spring-boot-app\", area=\"heap\"}) * 100", + "refId": "A", + "legendFormat": "heap used %" + }, + { + "expr": "sum(rate(jvm_gc_pause_seconds_sum{job=\"spring-boot-app\"}[1m]))", + "refId": "B", + "legendFormat": "gc pause sec/sec" + } + ] + }, + { + "id": 13, + "type": "timeseries", + "title": "7. Network Traffic - ์„œ๋ฒ„ ๊ฐ„ ํ†ต์‹ /์—…๋กœ๋“œ ์ฆ๊ฐ€", + "description": "ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ, DB/Redis ํ†ต์‹  ์ฆ๊ฐ€๋ฅผ ๋ณผ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 31, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (service, instance) (rate(node_network_receive_bytes_total{device!~\"lo|docker.*|br-.*|veth.*\"}[1m]))", + "refId": "A", + "legendFormat": "RX {{service}} {{instance}}" + }, + { + "expr": "sum by (service, instance) (rate(node_network_transmit_bytes_total{device!~\"lo|docker.*|br-.*|veth.*\"}[1m]))", + "refId": "B", + "legendFormat": "TX {{service}} {{instance}}" + } + ] + }, + { + "id": 14, + "type": "timeseries", + "title": "8. MongoDB Ops - DB ์ž‘์—…๋Ÿ‰", + "description": "insert/query/update/delete๊ฐ€ ๋ถ€ํ•˜์™€ ํ•จ๊ป˜ ์ฆ๊ฐ€ํ•˜๋Š”์ง€ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 39, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (type) (rate(mongodb_op_counters_total{job=\"mongodb\"}[1m]))", + "refId": "A", + "legendFormat": "{{type}}" + } + ] + }, + { + "id": 15, + "type": "stat", + "title": "MongoDB Up", + "description": "1์ด๋ฉด exporter๊ฐ€ MongoDB์— ์ ‘์† ๊ฐ€๋Šฅ, 0์ด๋ฉด ์žฅ์• ์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 8, + "y": 39, + "w": 4, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "mongodb_up{job=\"mongodb\"}", + "refId": "A", + "legendFormat": "MongoDB" + } + ] + }, + { + "id": 16, + "type": "timeseries", + "title": "9. Redis Commands / Cache Hit - ์บ์‹œ ์ƒํƒœ", + "description": "Redis ์ฒ˜๋ฆฌ๋Ÿ‰๊ณผ hit ratio๋ฅผ ๊ฐ™์ด ๋ด…๋‹ˆ๋‹ค. hit ratio๊ฐ€ ๋‚ฎ์œผ๋ฉด ์บ์‹œ๊ฐ€ ๋œ ๋จนํžˆ๋Š” ์ƒํƒœ์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 39, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(rate(redis_commands_processed_total{job=\"redis\"}[1m]))", + "refId": "A", + "legendFormat": "commands/sec" + }, + { + "expr": "(sum(rate(redis_keyspace_hits_total{job=\"redis\"}[1m])) / (sum(rate(redis_keyspace_hits_total{job=\"redis\"}[1m])) + sum(rate(redis_keyspace_misses_total{job=\"redis\"}[1m])))) * 100", + "refId": "B", + "legendFormat": "hit ratio %" + } + ] + }, + { + "id": 17, + "type": "stat", + "title": "Redis Memory Used", + "description": "Redis ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ž…๋‹ˆ๋‹ค. ๊ธ‰์ฆํ•˜๋ฉด ํ‚ค ๋ˆ„์ /๋งŒ๋ฃŒ ์ •์ฑ…์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 20, + "y": 39, + "w": 4, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "redis_memory_used_bytes{job=\"redis\"}", + "refId": "A", + "legendFormat": "Redis memory" + } + ] + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "load-test", + "overview" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "KTB Load Test Overview - ๋จผ์ € ๋ณด๋Š” ํ™”๋ฉด", + "uid": "ktb-load-overview", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json new file mode 100644 index 00000000..f3c7071e --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json @@ -0,0 +1,886 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "100 - (avg(irate(node_cpu_seconds_total{mode=\"idle\", service=~\"$service\"}[5m])) by (instance, service) * 100)", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "CPU Usage - ์„œ๋ฒ„๊ฐ€ ๊ณ„์‚ฐ์„ ์–ผ๋งˆ๋‚˜ ๋ฐ”์˜๊ฒŒ ํ•˜๋Š”์ง€", + "type": "timeseries", + "description": "80% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋ฉด ํ•ด๋‹น ์„œ๋ฒ„ CPU๊ฐ€ ๋ณ‘๋ชฉ์ผ ๊ฐ€๋Šฅ์„ฑ์ด ํฝ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "((node_memory_MemTotal_bytes{service=~\"$service\"} - node_memory_MemAvailable_bytes{service=~\"$service\"}) / node_memory_MemTotal_bytes{service=~\"$service\"}) * 100", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Memory Usage - ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ ", + "type": "timeseries", + "description": "85~90% ์ด์ƒ์ด ๊ณ„์†๋˜๋ฉด OOM, GC ์ฆ๊ฐ€, ์Šค์™‘ ์œ„ํ—˜์„ ๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_disk_read_bytes_total{service=~\"$service\"}[5m])", + "refId": "A", + "legendFormat": "Read - {{service}} - {{instance}} - {{device}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_disk_written_bytes_total{service=~\"$service\"}[5m])", + "refId": "B", + "legendFormat": "Write - {{service}} - {{instance}} - {{device}}" + } + ], + "title": "Disk I/O - ๋””์Šคํฌ ์ฝ๊ธฐ/์“ฐ๊ธฐ๋Ÿ‰", + "type": "timeseries", + "description": "MongoDB, ๋กœ๊ทธ, ํŒŒ์ผ ์—…๋กœ๋“œ๊ฐ€ ๋งŽ์„ ๋•Œ ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์‘๋‹ต์‹œ๊ฐ„ ์ฆ๊ฐ€์™€ ๊ฐ™์ด ๋ณด๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_network_receive_bytes_total{service=~\"$service\"}[5m])", + "refId": "A", + "legendFormat": "Receive - {{service}} - {{instance}} - {{device}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_network_transmit_bytes_total{service=~\"$service\"}[5m])", + "refId": "B", + "legendFormat": "Transmit - {{service}} - {{instance}} - {{device}}" + } + ], + "title": "Network Traffic - ์„œ๋ฒ„๋ณ„ ๋„คํŠธ์›Œํฌ ์†ก์ˆ˜์‹ ", + "type": "timeseries", + "description": "ํŠธ๋ž˜ํ”ฝ ๋ถ„์‚ฐ, ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ, Redis/MongoDB ํ†ต์‹ ๋Ÿ‰์„ ๋ณผ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "legend": { + "showLegend": true + } + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "100 - ((node_filesystem_avail_bytes{service=~\"$service\", mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"} * 100) / node_filesystem_size_bytes{service=~\"$service\", mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"})", + "refId": "A", + "legendFormat": "{{service}} - {{instance}} - /" + } + ], + "title": "Root Disk Usage - ์„œ๋ฒ„๋ณ„ / ๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ ๋งŒ ๋ณด๊ธฐ", + "type": "gauge", + "description": "๋ถ€ํ•˜ํ…Œ์ŠคํŠธ ๋•Œ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๋””์Šคํฌ ์ง€ํ‘œ์ž…๋‹ˆ๋‹ค. /boot, /boot/efi๋Š” ์ˆจ๊ธฐ๊ณ  ์•ฑ/๋กœ๊ทธ/Docker/DB ๋ฐ์ดํ„ฐ๊ฐ€ ์Œ“์ด๋Š” ๋ฃจํŠธ(/) ํŒŒํ‹ฐ์…˜๋งŒ ๋ด…๋‹ˆ๋‹ค. 80% ์ด์ƒ ์ฃผ์˜, 90% ์ด์ƒ ์œ„ํ—˜์ž…๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load1{service=~\"$service\"}", + "refId": "A", + "legendFormat": "1m - {{service}} - {{instance}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load5{service=~\"$service\"}", + "refId": "B", + "legendFormat": "5m - {{service}} - {{instance}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load15{service=~\"$service\"}", + "refId": "C", + "legendFormat": "15m - {{service}} - {{instance}}" + } + ], + "title": "System Load Average - CPU ๋Œ€๊ธฐ์—ด ์••๋ฐ•", + "type": "timeseries", + "description": "CPU ์ฝ”์–ด ์ˆ˜๋ณด๋‹ค load average๊ฐ€ ์˜ค๋ž˜ ๋†’์œผ๋ฉด ์ฒ˜๋ฆฌ ๋Œ€๊ธฐ์—ด์ด ์Œ“์ด๋Š” ์ƒํƒœ์ž…๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_time_seconds{service=~\"$service\"} - node_boot_time_seconds{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "System Uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 24 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "count(node_cpu_seconds_total{mode=\"idle\", service=~\"$service\"}) by (instance, service)", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "CPU Cores", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_memory_MemTotal_bytes{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Total Memory", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_procs_running{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Running Processes", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 36, + "style": "dark", + "tags": [ + "node-exporter", + "system", + "monitoring" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "definition": "label_values(node_uname_info, service)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "service", + "options": [], + "query": { + "query": "label_values(node_uname_info, service)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Node Exporter Dashboard", + "uid": "node-exporter", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/prometheus/prometheus.prod.yml b/apps/backend/monitoring/prometheus/prometheus.prod.yml index 43132c4f..55a61732 100644 --- a/apps/backend/monitoring/prometheus/prometheus.prod.yml +++ b/apps/backend/monitoring/prometheus/prometheus.prod.yml @@ -21,13 +21,13 @@ scrape_configs: - job_name: 'mongodb' static_configs: - - targets: ['IP:9216'] # your-mongo-server + - targets: ['10.0.101.238:9216'] # ktb-mongodb mongodb-exporter labels: environment: 'production' - job_name: 'redis' static_configs: - - targets: ['IP:9121'] # your-redis-server + - targets: ['10.0.101.175:9121'] # ktb-redis redis-exporter labels: environment: 'production' diff --git a/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml b/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml index 711ae121..e7f91167 100644 --- a/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml +++ b/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml @@ -5,10 +5,7 @@ # App Server (Spring Boot actuator) - targets: - - 'IP:5001' # your-app-server1 - - 'IP:5001' # your-app-server2 - - 'IP:5001' # your-app-server3 - - 'IP:5001' # your-app-server4 + - '10.0.101.47:5001' # ktb-backend labels: application: 'ktb-chat-backend' environment: 'production' diff --git a/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml b/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml index d6571221..2d244b90 100644 --- a/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml +++ b/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml @@ -2,21 +2,19 @@ # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์˜ Node Exporter ํƒ€๊ฒŸ ๋ชฉ๋ก # ์„œ๋ฒ„ ์ถ”๊ฐ€ ์‹œ ์ด ํŒŒ์ผ๋งŒ ์ˆ˜์ •ํ•˜๋ฉด ์ž๋™์œผ๋กœ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค (Prometheus ์žฌ์‹œ์ž‘ ๋ถˆํ•„์š”) -# App Server (backend + frontend on one host) +# App Servers - targets: - - 'IP:9100' # your-app-server1 - - 'IP:9100' # your-app-server2 - - 'IP:9100' # your-app-server3 - - 'IP:9100' # your-app-server4 + - '10.0.101.12:9100' # ktb-frontend + - '10.0.101.47:9100' # ktb-backend labels: environment: 'production' region: 'ap-northeast-2' cluster: 'app' - service: 'api+next' + service: 'frontend+backend' # Database Cluster - targets: - - 'IP:9100' # your-mongo-server + - '10.0.101.238:9100' # ktb-mongodb labels: environment: 'production' region: 'ap-northeast-2' @@ -25,7 +23,7 @@ # Redis Cluster - targets: - - 'IP:9100' # your-redis-server + - '10.0.101.175:9100' # ktb-redis labels: environment: 'production' region: 'ap-northeast-2' @@ -34,9 +32,9 @@ # Monitoring Server - targets: - - 'IP:9100' # your-o11y-server + - '10.0.101.211:9100' # ktb-monitoring labels: environment: 'production' region: 'ap-northeast-2' cluster: 'monitoring' - service: 'observability' + service: 'monitoring' diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index c52460af..9c6e247a 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -16,6 +16,7 @@ 25 2.0.0 + 2.49.6 @@ -26,6 +27,13 @@ pom import + + software.amazon.awssdk + bom + ${aws.java.sdk.version} + pom + import + @@ -65,6 +73,18 @@ io.micrometer micrometer-registry-prometheus + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + cloudfront + + + software.amazon.awssdk + url-connection-client + org.projectlombok lombok diff --git a/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java b/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java index 7bdccaf1..cbeb555c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class ChatAppApplication { public static void main(String[] args) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java index 19757fd5..b094ff24 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java @@ -1,9 +1,25 @@ package com.ktb.chatapp.config; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.mongodb.autoconfigure.MongoClientSettingsBuilderCustomizer; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.EnableMongoAuditing; @Configuration @EnableMongoAuditing public class MongoConfig { + + @Bean + MongoClientSettingsBuilderCustomizer mongoConnectionPoolCustomizer( + @Value("${app.mongodb.pool.max-size}") int maxPoolSize, + @Value("${app.mongodb.pool.min-size}") int minPoolSize, + @Value("${app.mongodb.pool.max-wait-time-ms}") long maxWaitTimeMs) { + return builder -> builder.applyToConnectionPoolSettings(pool -> pool + .maxSize(maxPoolSize) + .minSize(minPoolSize) + .maxWaitTime(maxWaitTimeMs, TimeUnit.MILLISECONDS)); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java new file mode 100644 index 00000000..8a2a2625 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.ktb.chatapp.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "socketio.store.type", havingValue = "redis", matchIfMissing = true) +public class RedissonConfig { + + @Bean(destroyMethod = "shutdown") + RedissonClient redissonClient( + @Value("${spring.data.redis.host:localhost}") String host, + @Value("${spring.data.redis.port:6379}") int port, + @Value("${spring.data.redis.password:}") String password) { + Config config = new Config(); + var server = config.useSingleServer().setAddress("redis://" + host + ":" + port); + if (StringUtils.hasText(password)) { + server.setPassword(password); + } + return Redisson.create(config); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java new file mode 100644 index 00000000..05be7f9a --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java @@ -0,0 +1,53 @@ +package com.ktb.chatapp.config; + +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") +public class S3StorageConfig { + + @Bean + S3Client s3Client( + @Value("${app.s3.region}") String region, + @Value("${app.s3.endpoint:}") String endpoint, + @Value("${app.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .httpClientBuilder(UrlConnectionHttpClient.builder()) + .serviceConfiguration(software.amazon.awssdk.services.s3.S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccess) + .build()); + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } + + @Bean + S3Presigner s3Presigner( + @Value("${app.s3.region}") String region, + @Value("${app.s3.endpoint:}") String endpoint, + @Value("${app.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .serviceConfiguration(software.amazon.awssdk.services.s3.S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccess) + .build()); + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/SecurityConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/SecurityConfig.java index 4eb76010..e8f7b8a0 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/SecurityConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/SecurityConfig.java @@ -21,6 +21,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @@ -64,11 +66,13 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + CorsConfigurationSource corsConfigurationSource) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(request -> createCorsConfiguration())) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/api/health", @@ -98,7 +102,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } - private CorsConfiguration createCorsConfiguration() { + @Bean + public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); List allowedOrigins = parseCsv(corsAllowedOrigins); if (allowedOrigins.contains("*")) { @@ -121,7 +126,10 @@ private CorsConfiguration createCorsConfiguration() { config.setExposedHeaders(CORS_EXPOSED_HEADERS); config.setAllowCredentials(true); config.setMaxAge(Duration.ofHours(1).getSeconds()); - return config; + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; } private List parseCsv(String value) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java index 1ffbaf5e..b2c35433 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java @@ -7,19 +7,24 @@ import com.corundumstudio.socketio.namespace.Namespace; import com.corundumstudio.socketio.protocol.JacksonJsonSupport; import com.corundumstudio.socketio.store.MemoryStoreFactory; +import com.corundumstudio.socketio.store.RedissonStoreFactory; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.ktb.chatapp.websocket.socketio.ChatDataStore; import com.ktb.chatapp.websocket.socketio.LocalChatDataStore; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Role; +import org.redisson.api.RedissonClient; import static org.springframework.beans.factory.config.BeanDefinition.ROLE_INFRASTRUCTURE; @@ -37,18 +42,23 @@ public class SocketIOConfig { @Value("${socketio.server.origin:*}") private String origin; + @Value("${socketio.store.type:redis}") + private String storeType; + + @Value("${socketio.server.accept-backlog:1024}") + private int acceptBacklog; + + @Value("${socketio.server.tcp-no-delay:true}") + private boolean tcpNoDelay; + @Bean(initMethod = "start", destroyMethod = "stop") - public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterRegistry meterRegistry) { + public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterRegistry meterRegistry, + ObjectProvider redissonClientProvider) { com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration(); config.setHostname(host); config.setPort(port); - var socketConfig = new SocketConfig(); - socketConfig.setReuseAddress(true); - socketConfig.setTcpNoDelay(false); - socketConfig.setAcceptBackLog(10); - socketConfig.setTcpSendBufferSize(4096); - socketConfig.setTcpReceiveBufferSize(4096); + var socketConfig = createSocketConfig(acceptBacklog, tcpNoDelay); config.setSocketConfig(socketConfig); config.setOrigin(origin); @@ -59,23 +69,48 @@ public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterR config.setUpgradeTimeout(10000); config.setJsonSupport(new JacksonJsonSupport(new JavaTimeModule())); - config.setStoreFactory(new MemoryStoreFactory()); // ๋‹จ์ผ๋…ธ๋“œ ์ „์šฉ + if ("redis".equalsIgnoreCase(storeType)) { + RedissonClient redissonClient = redissonClientProvider.getIfAvailable(); + if (redissonClient == null) { + throw new IllegalStateException("socketio.store.type=redis requires a RedissonClient"); + } + config.setStoreFactory(new RedissonStoreFactory(redissonClient)); + } else if ("memory".equalsIgnoreCase(storeType)) { + log.warn("Socket.IO is using the single-node memory store"); + config.setStoreFactory(new MemoryStoreFactory()); + } else { + throw new IllegalArgumentException("Unsupported socketio.store.type: " + storeType); + } log.info("Socket.IO server configured on {}:{} with {} boss threads and {} worker threads", host, port, config.getBossThreads(), config.getWorkerThreads()); var socketIOServer = new SocketIOServer(config); socketIOServer.getNamespace(Namespace.DEFAULT_NAME).addAuthTokenListener(authTokenListener); + Map eventCounters = new ConcurrentHashMap<>(); socketIOServer.getNamespace(Namespace.DEFAULT_NAME).addEventInterceptor((client, name, data, ack) -> { - // ์ด๋ฒคํŠธ ๋ฐœ์ƒ ๋นˆ๋„ ์ˆ˜์ง‘ - Counter.builder("socketio.events.total") - .description("Total Socket.IO events received") - .tag("event_type", name) - .register(meterRegistry) - .increment(); + eventCounters.computeIfAbsent(name, eventName -> + Counter.builder("socketio.events.total") + .description("Total Socket.IO events received") + .tag("event_type", eventName) + .register(meterRegistry)) + .increment(); }); return socketIOServer; } + + static SocketConfig createSocketConfig(int acceptBacklog, boolean tcpNoDelay) { + if (acceptBacklog < 1) { + throw new IllegalArgumentException("socketio.server.accept-backlog must be positive"); + } + var socketConfig = new SocketConfig(); + socketConfig.setReuseAddress(true); + socketConfig.setTcpNoDelay(tcpNoDelay); + socketConfig.setAcceptBackLog(acceptBacklog); + socketConfig.setTcpSendBufferSize(4096); + socketConfig.setTcpReceiveBufferSize(4096); + return socketConfig; + } /** * SpringAnnotationScanner๋Š” BeanPostProcessor๋กœ์„œ @@ -89,9 +124,8 @@ public BeanPostProcessor springAnnotationScanner(@Lazy SocketIOServer socketIOSe return new SpringAnnotationScanner(socketIOServer); } - // ์ธ๋ฉ”๋ชจ๋ฆฌ ์ €์žฅ์†Œ, ๋‹จ์ผ ๋…ธ๋“œ ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉ @Bean - @ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty(name = "socketio.store.type", havingValue = "memory") public ChatDataStore chatDataStore() { return new LocalChatDataStore(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java index e95a11ed..46ed39b6 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java @@ -20,6 +20,7 @@ import jakarta.validation.Valid; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -27,9 +28,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -44,7 +43,6 @@ @RequestMapping("/api/auth") public class AuthController { - private final AuthenticationManager authenticationManager; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; @@ -85,36 +83,24 @@ public ResponseEntity getAuthStatus() { @PostMapping("/register") public ResponseEntity registerUser( @Valid @RequestBody RegisterRequest registerRequest, - BindingResult bindingResult, - HttpServletRequest request) { + BindingResult bindingResult) { // Handle validation errors ResponseEntity errors = getBindingError(bindingResult); if (errors != null) return errors; - // Check existing user - if (userRepository.findByEmail(registerRequest.getEmail()).isPresent()) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(StandardResponse.error("์ด๋ฏธ ๋“ฑ๋ก๋œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.")); - } - try { + String normalizedEmail = registerRequest.getEmail().toLowerCase(Locale.ROOT); + // Create user User user = User.builder() .name(registerRequest.getName()) - .email(registerRequest.getEmail().toLowerCase()) + .email(normalizedEmail) .password(passwordEncoder.encode(registerRequest.getPassword())) .build(); user = userRepository.save(user); - // Create session with metadata - SessionMetadata metadata = new SessionMetadata( - request.getHeader("User-Agent"), - getClientIpAddress(request), - request.getHeader("User-Agent") - ); - LoginResponse response = LoginResponse.builder() .success(true) .message("ํšŒ์›๊ฐ€์ž…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") @@ -163,20 +149,12 @@ public ResponseEntity login( if (errors != null) return errors; try { - // Authenticate user - User user = userRepository.findByEmail(loginRequest.getEmail().toLowerCase()) + String normalizedEmail = loginRequest.getEmail().toLowerCase(Locale.ROOT); + User user = userRepository.findByEmail(normalizedEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - user.getEmail(), - loginRequest.getPassword() - ) - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - // ๋‹จ์ผ ์„ธ์…˜ ์ •์ฑ…์„ ์œ„ํ•ด ๊ธฐ์กด ์„ธ์…˜ ์ œ๊ฑฐ - sessionService.removeAllUserSessions(user.getId()); + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } // Create new session SessionMetadata metadata = new SessionMetadata( diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java index db90ecba..f396ab7e 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java @@ -1,8 +1,13 @@ package com.ktb.chatapp.controller; import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.exception.DeletedFileException; +import com.ktb.chatapp.exception.DirectUploadNotSupportedException; +import com.ktb.chatapp.exception.FileAccessException; +import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.service.ChatFileDirectUploadService; import com.ktb.chatapp.service.FileAccess; import com.ktb.chatapp.service.FileAccessService; import com.ktb.chatapp.service.FileService; @@ -28,254 +33,850 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -@Tag(name = "ํŒŒ์ผ (Files)", description = "ํŒŒ์ผ ์—…๋กœ๋“œ ๋ฐ ๋‹ค์šด๋กœ๋“œ API") +@Tag( + name = "ํŒŒ์ผ (Files)", + description = "ํŒŒ์ผ ์—…๋กœ๋“œ ๋ฐ ๋‹ค์šด๋กœ๋“œ API" +) @Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/files") public class FileController { + private static final String FILE_PREVIEW_CACHE_CONTROL = "private, max-age=300"; + private final FileService fileService; private final FileAccessService fileAccessService; private final UserRepository userRepository; + private final ChatFileDirectUploadService + chatFileDirectUploadService; /** - * ํŒŒ์ผ ์—…๋กœ๋“œ + * ๊ธฐ์กด Multipart ์—…๋กœ๋“œ. + * + * ๊ธฐ์กด ๊ธฐ๋Šฅ ๋ณด์กด์šฉ์œผ๋กœ ๋‚จ๊ฒจ๋‘”๋‹ค. */ - @Operation(summary = "ํŒŒ์ผ ์—…๋กœ๋“œ", description = "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ์ตœ๋Œ€ 50MB๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "ํŒŒ์ผ ์—…๋กœ๋“œ", + description = "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ์ตœ๋Œ€ 50MB๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต"), - @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ํŒŒ์ผ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "413", description = "ํŒŒ์ผ ํฌ๊ธฐ ์ดˆ๊ณผ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", - content = @Content(schema = @Schema(implementation = StandardResponse.class))) + @ApiResponse( + responseCode = "200", + description = "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต" + ), + @ApiResponse( + responseCode = "400", + description = "์ž˜๋ชป๋œ ํŒŒ์ผ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "413", + description = "ํŒŒ์ผ ํฌ๊ธฐ ์ดˆ๊ณผ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ) }) @PostMapping("/upload") public ResponseEntity uploadFile( - @Parameter(description = "์—…๋กœ๋“œํ•  ํŒŒ์ผ") @RequestParam("file") MultipartFile file, - Principal principal) { + @Parameter(description = "์—…๋กœ๋“œํ•  ํŒŒ์ผ") + @RequestParam("file") + MultipartFile file, + Principal principal + ) { try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); - - FileUploadResult result = fileService.uploadFile(file, user.getId()); - - if (result.isSuccess()) { - Map response = new HashMap<>(); - response.put("success", true); - response.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต"); - - Map fileData = new HashMap<>(); - fileData.put("_id", result.getFile().getId()); - fileData.put("filename", result.getFile().getFilename()); - fileData.put("originalname", result.getFile().getOriginalname()); - fileData.put("mimetype", result.getFile().getMimetype()); - fileData.put("size", result.getFile().getSize()); - fileData.put("uploadDate", result.getFile().getUploadDate()); - - response.put("file", fileData); - - return ResponseEntity.ok(response); - } else { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(500).body(errorResponse); + User user = getUser(principal); + + FileUploadResult result = + fileService.uploadFile( + file, + user.getId() + ); + + if (!result.isSuccess()) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + ); } + Map response = + new HashMap<>(); + + response.put("success", true); + response.put( + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต" + ); + + response.put( + "file", + toFileResponse(result.getFile()) + ); + + return ResponseEntity.ok(response); + } catch (Exception e) { - log.error("ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ", e); - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); - errorResponse.put("error", e.getMessage()); - return ResponseEntity.status(500).body(errorResponse); + log.error( + "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ", + e + ); + + Map errorResponse = + new HashMap<>(); + + errorResponse.put( + "success", + false + ); + + errorResponse.put( + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ); + + errorResponse.put( + "error", + e.getMessage() + ); + + return ResponseEntity + .status( + HttpStatus.INTERNAL_SERVER_ERROR + ) + .body(errorResponse); + } + } + + /** + * S3 ์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ presigned PUT URL ๋ฐœ๊ธ‰. + * + * ํŒŒ์ผ ๋ณธ๋ฌธ์€ Backend๋กœ ๋“ค์–ด์˜ค์ง€ ์•Š๋Š”๋‹ค. + */ + @PostMapping({"/presign", "/upload/presign"}) + public ResponseEntity createUploadUrl( + @RequestBody + DirectUploadPresignRequest request, + Principal principal + ) { + try { + User user = getUser(principal); + + ChatFileDirectUploadService.PreparedUpload + prepared = + chatFileDirectUploadService.prepare( + request.originalname(), + request.mimetype(), + request.size(), + user.getId() + ); + + Map response = + new HashMap<>(); + + response.put("success", true); + response.put( + "uploadUrl", + prepared.uploadUrl() + ); + response.put( + "key", + prepared.key() + ); + response.put( + "filename", + prepared.filename() + ); + + return ResponseEntity.ok(response); + + } catch (DirectUploadNotSupportedException e) { + log.info("ํ˜„์žฌ ์Šคํ† ๋ฆฌ์ง€์˜ ์ง์ ‘ ์—…๋กœ๋“œ ๋ฏธ์ง€์›: {}", e.getMessage()); + + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (RuntimeException e) { + log.warn( + "ํŒŒ์ผ presign ๋ฐœ๊ธ‰ ์‹คํŒจ", + e + ); + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); + } + } + + /** + * ๋ธŒ๋ผ์šฐ์ €๊ฐ€ S3 ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ํ˜ธ์ถœ. + * + * Backend๋Š” ์‹ค์ œ S3 ๊ฐ์ฒด๋ฅผ ํ™•์ธํ•œ ํ›„ + * MongoDB metadata๋งŒ ์ €์žฅํ•œ๋‹ค. + */ + @PostMapping("/upload/complete") + public ResponseEntity completeUpload( + @RequestBody + DirectUploadCompleteRequest request, + Principal principal + ) { + try { + User user = getUser(principal); + + File savedFile = + chatFileDirectUploadService.complete( + request.key(), + request.originalname(), + request.mimetype(), + request.size(), + user.getId() + ); + + Map response = + new HashMap<>(); + + response.put("success", true); + response.put( + "file", + toFileResponse(savedFile) + ); + + return ResponseEntity.ok(response); + + } catch (RuntimeException e) { + log.warn( + "S3 ์ง์ ‘ ์—…๋กœ๋“œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ ์‹คํŒจ", + e + ); + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); } } /** - * ๋ณด์•ˆ์ด ๊ฐ•ํ™”๋œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + * ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ. */ - @Operation(summary = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ", description = "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ํŒŒ์ผ๋งŒ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ", + description = + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค." + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์„ฑ๊ณต"), - @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "403", description = "๊ถŒํ•œ ์—†์Œ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "404", description = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", - content = @Content(schema = @Schema(implementation = StandardResponse.class))) + @ApiResponse( + responseCode = "200", + description = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์„ฑ๊ณต" + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "๊ถŒํ•œ ์—†์Œ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ) }) @GetMapping("/download/{filename:.+}") public ResponseEntity downloadFile( - @Parameter(description = "๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ๋ช…") @PathVariable String filename, + @Parameter( + description = "๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ๋ช…" + ) + @PathVariable + String filename, HttpServletRequest request, - Principal principal) { + Principal principal + ) { try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + return switch ( + fileAccessService.forDownload( + filename, + getRequesterId(principal) + ) + ) { + case FileAccess.Stream stream -> + attachmentResponse(stream); - return switch (fileAccessService.forDownload(filename, user.getId())) { - case FileAccess.Stream stream -> attachmentResponse(stream); - case FileAccess.Redirect redirect -> redirectResponse(redirect); + case FileAccess.Redirect redirect -> + redirectResponse(redirect); }; + } catch (FileAccessException e) { + logFileAccessDenied("download", filename, request, e); + return handleFileError(e); + } catch (Exception e) { - log.error("ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", filename, e); + log.error( + "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + filename, + e + ); + return handleFileError(e); } } - private ResponseEntity attachmentResponse(FileAccess.Stream stream) { - String contentDisposition = String.format( - "attachment; filename*=UTF-8''%s", - encodeFilename(stream.originalname()) - ); + /** + * ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ. + * + * CloudFront ํ™œ์„ฑํ™” ์‹œ FileAccessService์—์„œ + * Signed URL์„ ๋งŒ๋“  ๋’ค 302 Redirectํ•œ๋‹ค. + */ + @GetMapping("/view/{filename:.+}") + public ResponseEntity viewFile( + @PathVariable + String filename, + HttpServletRequest request, + Principal principal + ) { + try { + return switch ( + fileAccessService.forView( + filename, + getRequesterId(principal) + ) + ) { + case FileAccess.Stream stream -> + inlineResponse(stream); + + case FileAccess.Redirect redirect -> + redirectResponse(redirect); + }; + + } catch ( + PreviewNotSupportedException e + ) { + return ResponseEntity + .status( + HttpStatus.UNSUPPORTED_MEDIA_TYPE + ) + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); + + } catch (FileAccessException e) { + logFileAccessDenied("view", filename, request, e); + return handleFileError(e); + + } catch (Exception e) { + log.error( + "ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + filename, + e + ); + + return handleFileError(e); + } + } + + /** + * Soft delete. + * + * ์‹ค์ œ ์‚ญ์ œ ์ •์ฑ…์€ FileService ๊ตฌํ˜„์—์„œ ์ˆ˜ํ–‰ํ•œ๋‹ค. + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteFile( + @PathVariable String id, + Principal principal + ) { + try { + User user = getUser(principal); + + boolean deleted = + fileService.deleteFile( + id, + user.getId() + ); + + if (deleted) { + return ResponseEntity.ok( + Map.of( + "success", + true, + "message", + "ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + ); + } + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + ); + + } catch (RuntimeException e) { + log.error( + "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + id, + e + ); + + String errorMessage = + e.getMessage(); + + if (errorMessage != null + && errorMessage.contains( + "์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + )) { - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(stream.contentType())) + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + } + + if (errorMessage != null + && errorMessage.contains( + "๊ถŒํ•œ" + )) { + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + } + + Map errorResponse = + new HashMap<>(); + + errorResponse.put( + "success", + false + ); + + errorResponse.put( + "message", + "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ); + + errorResponse.put( + "error", + errorMessage + ); + + return ResponseEntity + .status( + HttpStatus.INTERNAL_SERVER_ERROR + ) + .body(errorResponse); + } + } + + private ResponseEntity attachmentResponse( + FileAccess.Stream stream + ) { + String contentDisposition = + String.format( + "attachment; filename*=UTF-8''%s", + encodeFilename( + stream.originalname() + ) + ); + + return ResponseEntity + .ok() + .contentType( + MediaType.parseMediaType( + stream.contentType() + ) + ) .contentLength(stream.size()) - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .header(HttpHeaders.CACHE_CONTROL, "private, no-cache, no-store, must-revalidate") - .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Disposition") + .header( + HttpHeaders.CONTENT_DISPOSITION, + contentDisposition + ) + .header( + HttpHeaders.CACHE_CONTROL, + "private, no-cache, no-store, must-revalidate" + ) + .header( + HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, + "Content-Disposition" + ) .body(stream.resource()); } - private ResponseEntity redirectResponse(FileAccess.Redirect redirect) { - return ResponseEntity.status(HttpStatus.FOUND) + private ResponseEntity inlineResponse( + FileAccess.Stream stream + ) { + String contentDisposition = + String.format( + "inline; filename=\"%s\"; filename*=UTF-8''%s", + stream.originalname(), + encodeFilename( + stream.originalname() + ) + ); + + return ResponseEntity + .ok() + .contentType( + MediaType.parseMediaType( + stream.contentType() + ) + ) + .contentLength(stream.size()) + .header( + HttpHeaders.CONTENT_DISPOSITION, + contentDisposition + ) + .header( + HttpHeaders.CACHE_CONTROL, + FILE_PREVIEW_CACHE_CONTROL + ) + .body(stream.resource()); + } + + private ResponseEntity redirectResponse( + FileAccess.Redirect redirect + ) { + return ResponseEntity + .status(HttpStatus.FOUND) .location(redirect.location()) .build(); } - private String encodeFilename(String originalFilename) { - return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8) - .replaceAll("\\+", "%20"); - } + /** ํŒŒ์ผ ์ฝ๊ธฐ ์ „์šฉ ์˜ˆ์™ธ๋Š” ํƒ€์ž…์œผ๋กœ ๋งคํ•‘ํ•˜๊ณ  ๊ธฐ์กด ์“ฐ๊ธฐ ๊ฒฝ๋กœ์˜ ํ˜ธํ™˜ ๋งคํ•‘์€ ์œ ์ง€ํ•œ๋‹ค. */ + private ResponseEntity handleFileError( + Exception e + ) { + String errorMessage = + e.getMessage(); - private ResponseEntity handleFileError(Exception e) { - String errorMessage = e.getMessage(); - int statusCode = 500; - String responseMessage = "ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; - - if (errorMessage != null) { - if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…") || errorMessage.contains("Invalid filename")) { - statusCode = 400; - responseMessage = "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ธ์ฆ") || errorMessage.contains("Authentication")) { - statusCode = 401; - responseMessage = "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ") || errorMessage.contains("Invalid file path")) { - statusCode = 400; - responseMessage = "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") || errorMessage.contains("not found")) { - statusCode = 404; - responseMessage = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")) { - statusCode = 404; - responseMessage = "ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("๊ถŒํ•œ") || errorMessage.contains("Unauthorized")) { - statusCode = 403; - responseMessage = "ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."; + HttpStatus status = + HttpStatus.INTERNAL_SERVER_ERROR; + + String responseMessage = + "ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; + + if (e instanceof FileAccessException fileAccessException) { + status = fileAccessException.getReason().getStatus(); + responseMessage = fileAccessException.getReason().getMessage(); + + } else if (e instanceof DeletedFileException) { + status = HttpStatus.GONE; + responseMessage = + "์‚ญ์ œ๋œ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค."; + + } else if (errorMessage != null) { + + if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…") + || errorMessage.contains( + "Invalid filename" + )) { + + status = HttpStatus.BAD_REQUEST; + responseMessage = + "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค."; + + } else if (errorMessage.contains("์ธ์ฆ") + || errorMessage.contains( + "Authentication" + )) { + + status = HttpStatus.UNAUTHORIZED; + responseMessage = + "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ" + ) + || errorMessage.contains( + "Invalid file path" + ) + ) { + + status = HttpStatus.BAD_REQUEST; + responseMessage = + "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + ) + || errorMessage.contains( + "not found" + ) + ) { + + status = HttpStatus.NOT_FOUND; + responseMessage = + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + ) + ) { + + status = HttpStatus.NOT_FOUND; + responseMessage = + "ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains("๊ถŒํ•œ") + || errorMessage.contains( + "Unauthorized" + ) + ) { + + status = HttpStatus.FORBIDDEN; + responseMessage = + "ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."; } } - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", responseMessage); - - return ResponseEntity.status(statusCode).body(errorResponse); + return ResponseEntity + .status(status) + .body( + Map.of( + "success", + false, + "message", + responseMessage + ) + ); } - @GetMapping("/view/{filename:.+}") - public ResponseEntity viewFile( - @PathVariable String filename, + private void logFileAccessDenied( + String operation, + String filename, HttpServletRequest request, - Principal principal) { - try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + FileAccessException exception + ) { + log.warn( + "ํŒŒ์ผ ์ฝ๊ธฐ ์š”์ฒญ ๊ฑฐ๋ถ€: operation={}, reason={}, filename={}, testId={}, scenario={}, vuId={}", + operation, + exception.getReason().getCode(), + filename, + headerOrUnknown(request, "X-Test-Id"), + headerOrUnknown(request, "X-Scenario"), + headerOrUnknown(request, "X-Vu-Id") + ); + } - return switch (fileAccessService.forView(filename, user.getId())) { - case FileAccess.Stream stream -> inlineResponse(stream); - case FileAccess.Redirect redirect -> redirectResponse(redirect); - }; + private String headerOrUnknown(HttpServletRequest request, String name) { + String value = request.getHeader(name); + return value == null || value.isBlank() ? "unknown" : value; + } - } catch (PreviewNotSupportedException e) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", e.getMessage()); - return ResponseEntity.status(415).body(errorResponse); + private String getRequesterId(Principal principal) { + if (principal instanceof Authentication authentication + && authentication.getDetails() instanceof Map details) { + Object userId = details.get("userId"); + if (userId instanceof String value && !value.isBlank()) { + return value; + } + } + return getUser(principal).getId(); + } - } catch (Exception e) { - log.error("ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", filename, e); - return handleFileError(e); + private User getUser( + Principal principal + ) { + if (principal == null) { + throw new RuntimeException( + "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + ); } + + return userRepository + .findByEmail(principal.getName()) + .orElseThrow(() -> + new UsernameNotFoundException( + "User not found: " + + principal.getName() + ) + ); } - private ResponseEntity inlineResponse(FileAccess.Stream stream) { - String contentDisposition = String.format( - "inline; filename=\"%s\"; filename*=UTF-8''%s", - stream.originalname(), - encodeFilename(stream.originalname()) + private Map toFileResponse( + File file + ) { + Map fileData = + new HashMap<>(); + + fileData.put( + "_id", + file.getId() ); - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(stream.contentType())) - .contentLength(stream.size()) - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .body(stream.resource()); + fileData.put( + "filename", + file.getFilename() + ); + + fileData.put( + "originalname", + file.getOriginalname() + ); + + fileData.put( + "mimetype", + file.getMimetype() + ); + + fileData.put( + "size", + file.getSize() + ); + + fileData.put( + "uploadDate", + file.getUploadDate() + ); + + fileData.put( + "deleted", + file.isDeleted() + ); + + return fileData; } - @DeleteMapping("/{id}") - public ResponseEntity deleteFile(@PathVariable String id, Principal principal) { - try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + private String encodeFilename( + String originalFilename + ) { + return URLEncoder + .encode( + originalFilename, + StandardCharsets.UTF_8 + ) + .replaceAll( + "\\+", + "%20" + ); + } - boolean deleted = fileService.deleteFile(id, user.getId()); + private String safeMessage( + Exception e + ) { + return e.getMessage() != null + ? e.getMessage() + : "์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; + } - if (deleted) { - Map response = new HashMap<>(); - response.put("success", true); - response.put("message", "ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.ok(response); - } else { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(400).body(errorResponse); - } + public record DirectUploadPresignRequest( + String originalname, + String mimetype, + long size + ) { + } - } catch (RuntimeException e) { - log.error("ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", id, e); - String errorMessage = e.getMessage(); - - if (errorMessage != null && errorMessage.contains("์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(404).body(errorResponse); - } else if (errorMessage != null && errorMessage.contains("๊ถŒํ•œ")) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(403).body(errorResponse); - } - - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); - errorResponse.put("error", errorMessage); - return ResponseEntity.status(500).body(errorResponse); - } + public record DirectUploadCompleteRequest( + String key, + String originalname, + String mimetype, + long size + ) { } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java index 3162f55a..0f8a56e9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java @@ -11,6 +11,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; @@ -19,6 +22,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.concurrent.TimeUnit; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + /** * ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋งŒ์„ ์ต๋ช…์—๊ฒŒ ์„œ๋น™ํ•œ๋‹ค โ€” ์•ฑ ์ „์ฒด์—์„œ ์ธ๊ฐ€ ์—†์ด ์ฝํžˆ๋Š” ์œ ์ผํ•œ ํŒŒ์ผ ํ‘œ๋ฉด์ด๋‹ค. * @@ -29,6 +36,8 @@ @RequestMapping("/api/files/profiles") public class ProfileImageController { + private static final Duration OFFLOAD_URL_TTL = Duration.ofMinutes(10); + private final StoragePort storagePort; @Operation(summary = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์กฐํšŒ", description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") @@ -39,15 +48,28 @@ public class ProfileImageController { }) @SecurityRequirement(name = "") @GetMapping("/{filename:.+}") - public ResponseEntity getProfileImage( + public ResponseEntity getProfileImage( @Parameter(description = "์กฐํšŒํ•  ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ํŒŒ์ผ๋ช…") @PathVariable String filename) { if (FileUtil.containsPathTraversal(filename)) { return ResponseEntity.badRequest().build(); } + var offloadUrl = storagePort.offloadUrl( + StorageKey.profile(filename), + OFFLOAD_URL_TTL, + ContentDisposition.inline().filename(filename, StandardCharsets.UTF_8).build()); + if (offloadUrl.isPresent()) { + // ๋งŒ๋ฃŒ๋˜๋Š” presigned URL ์ž์ฒด๋Š” ์บ์‹œํ•˜์ง€ ์•Š๋Š”๋‹ค. ์‹ค์ œ S3 ๊ฐ์ฒด ์‘๋‹ต์—๋Š” immutable ์บ์‹œ๊ฐ€ ๋ถ™๋Š”๋‹ค. + return ResponseEntity.status(HttpStatus.FOUND) + .cacheControl(CacheControl.noStore()) + .location(offloadUrl.get()) + .build(); + } + return storagePort.open(StorageKey.profile(filename)) .map(resource -> ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()) .contentType(contentTypeOf(filename)) .body(resource)) .orElseGet(() -> ResponseEntity.notFound().build()); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java index 8d81ea8f..d19ec6fc 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java @@ -20,9 +20,14 @@ import java.security.Principal; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -81,11 +86,13 @@ public ResponseEntity healthCheck() { } } - // ์ „์ฒด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ - @Operation(summary = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ", description = "์ „์ฒด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. Rate Limit์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") + // ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํŽ˜์ด์ง€ ์กฐํšŒ + @Operation(summary = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ", description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ 20๊ฐœ์”ฉ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. Rate Limit์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", content = @Content(schema = @Schema(implementation = RoomsResponse.class))), + @ApiResponse(responseCode = "400", description = "์œ ํšจํ•˜์ง€ ์•Š์€ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", + content = @Content(schema = @Schema(implementation = StandardResponse.class))), @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content(schema = @Schema(implementation = StandardResponse.class))), @ApiResponse(responseCode = "429", description = "์š”์ฒญ ํ•œ๋„ ์ดˆ๊ณผ", @@ -96,10 +103,17 @@ public ResponseEntity healthCheck() { }) @GetMapping @RateLimit - public ResponseEntity getAllRooms(Principal principal) { + public ResponseEntity getAllRooms( + @Parameter(description = "0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", example = "0") + @RequestParam(defaultValue = "0") int page) { + + if (page < 0) { + return ResponseEntity.badRequest().body( + StandardResponse.error("ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")); + } try { - RoomsResponse response = roomService.getAllRooms(principal.getName()); + RoomsResponse response = roomService.getAllRooms(page); // ์บ์‹œ ์„ค์ • return ResponseEntity.ok() @@ -151,7 +165,17 @@ public ResponseEntity createRoom(@Valid @RequestBody CreateRoomRequest create } Room savedRoom = roomService.createRoom(createRoomRequest, principal.getName()); - RoomResponse roomResponse = mapToRoomResponse(savedRoom, principal.getName()); + // The create screen only needs the new id for navigation. Avoid a + // second user lookup and participant DTO expansion on every VUser. + RoomResponse roomResponse = RoomResponse.builder() + .id(savedRoom.getId()) + .name(savedRoom.getName()) + .hasPassword(savedRoom.isHasPassword()) + .participants(List.of()) + .createdAtDateTime(savedRoom.getCreatedAt()) + .isCreator(true) + .recentMessageCount(0) + .build(); return ResponseEntity.status(201).body( Map.of( @@ -187,7 +211,11 @@ public ResponseEntity createRoom(@Valid @RequestBody CreateRoomRequest create content = @Content(schema = @Schema(implementation = StandardResponse.class))) }) @GetMapping("/{roomId}") - public ResponseEntity getRoomById(@Parameter(description = "์ฑ„ํŒ…๋ฐฉ ID", example = "60d5ec49f1b2c8b9e8c4f2a1") @PathVariable String roomId, Principal principal) { + public ResponseEntity getRoomById( + @Parameter(description = "์ฑ„ํŒ…๋ฐฉ ID", example = "60d5ec49f1b2c8b9e8c4f2a1") @PathVariable String roomId, + @RequestParam(defaultValue = "true") boolean includeRecentCount, + @RequestParam(defaultValue = "true") boolean includeParticipants, + Principal principal) { try { Optional roomOpt = roomService.findRoomById(roomId); if (roomOpt.isEmpty()) { @@ -197,7 +225,8 @@ public ResponseEntity getRoomById(@Parameter(description = "์ฑ„ํŒ…๋ฐฉ ID", ex } Room room = roomOpt.get(); - RoomResponse roomResponse = mapToRoomResponse(room, principal.getName()); + RoomResponse roomResponse = mapToRoomResponse( + room, principal.getName(), includeRecentCount, includeParticipants); return ResponseEntity.ok( Map.of( @@ -241,12 +270,10 @@ public ResponseEntity joinRoom( .body(StandardResponse.error("์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } - RoomResponse roomResponse = mapToRoomResponse(joinedRoom, principal.getName()); - return ResponseEntity.ok( Map.of( "success", true, - "data", roomResponse + "data", Map.of("roomId", joinedRoom.getId()) ) ); @@ -266,27 +293,40 @@ public ResponseEntity joinRoom( } } - private RoomResponse mapToRoomResponse(Room room, String name) { - User creator = userRepository.findById(room.getCreator()).orElse(null); + private RoomResponse mapToRoomResponse(Room room, String name, boolean includeRecentCount) { + return mapToRoomResponse(room, name, includeRecentCount, true); + } + + private RoomResponse mapToRoomResponse( + Room room, + String name, + boolean includeRecentCount, + boolean includeParticipants) { + LinkedHashSet responseUserIds = new LinkedHashSet<>(); + if (includeParticipants) { + responseUserIds.addAll(room.getParticipantIds()); + } + responseUserIds.add(room.getCreator()); + Map usersById = loadUsersById(responseUserIds); + User creator = usersById.get(room.getCreator()); if (creator == null) { throw new RuntimeException("Creator not found for room " + room.getId()); } UserResponse creatorSummary = UserResponse.from(creator); - List participantSummaries = room.getParticipantIds() - .stream() - .map(userRepository::findById).peek(optUser -> { - if (optUser.isEmpty()) { - log.warn("Participant not found: roomId={}, userId={}", room.getId(), optUser); - } - }) - .filter(Optional::isPresent) - .map(Optional::get) - .map(UserResponse::from) - .toList(); + List participantSummaries = includeParticipants + ? room.getParticipantIds() + .stream() + .map(usersById::get) + .filter(java.util.Objects::nonNull) + .map(UserResponse::from) + .toList() + : List.of(); - boolean isCreator = room.getCreator().equals(name); + boolean isCreator = room.getCreator().equals(name) || creator.getEmail().equalsIgnoreCase(name); - int recentMessageCount = recentMessageCounter.countRecentMessages(room.getId()); + int recentMessageCount = includeRecentCount + ? recentMessageCounter.countRecentMessages(room.getId()) + : 0; return RoomResponse.builder() .id(room.getId()) @@ -299,4 +339,20 @@ private RoomResponse mapToRoomResponse(Room room, String name) { .recentMessageCount((int) recentMessageCount) .build(); } + + private Map loadUsersById(Collection userIds) { + LinkedHashSet uniqueIds = userIds.stream() + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (uniqueIds.isEmpty()) { + return Map.of(); + } + return userRepository.findAllById(uniqueIds) + .stream() + .collect(Collectors.toMap( + User::getId, + Function.identity(), + (left, right) -> left, + LinkedHashMap::new)); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/UserController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/UserController.java index 2146d6e2..53523d8e 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/UserController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/UserController.java @@ -5,6 +5,7 @@ import com.ktb.chatapp.dto.UpdateProfileRequest; import com.ktb.chatapp.dto.UserResponse; import com.ktb.chatapp.service.UserService; +import com.ktb.chatapp.exception.DirectUploadNotSupportedException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -28,6 +29,7 @@ import org.springframework.web.multipart.MultipartFile; import java.security.Principal; +import java.util.Map; @Tag(name = "์‚ฌ์šฉ์ž (Users)", description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๊ด€๋ฆฌ API - ํ”„๋กœํ•„ ์กฐํšŒ, ์ˆ˜์ •, ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ, ํšŒ์› ํƒˆํ‡ด") @RequiredArgsConstructor @@ -140,6 +142,46 @@ public ResponseEntity uploadProfileImage( } } + @PostMapping("/profile-image/presign") + public ResponseEntity createProfileImageUploadUrl( + Principal principal, + @RequestBody ProfileImageUploadRequest request) { + try { + var prepared = userService.prepareProfileImageUpload( + principal.getName(), request.originalname(), request.mimetype(), request.size()); + return ResponseEntity.ok(Map.of( + "success", true, + "uploadUrl", prepared.uploadUrl(), + "key", prepared.key())); + } catch (DirectUploadNotSupportedException e) { + return ResponseEntity.status(409).body(StandardResponse.error("์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(StandardResponse.error(e.getMessage())); + } + } + + @PostMapping("/profile-image/complete") + public ResponseEntity completeProfileImageUpload( + Principal principal, + @RequestBody ProfileImageCompleteRequest request) { + try { + return ResponseEntity.ok(userService.completeProfileImageUpload( + principal.getName(), request.key(), request.originalname(), + request.mimetype(), request.size())); + } catch (UsernameNotFoundException e) { + return ResponseEntity.status(404).body(StandardResponse.error(e.getMessage())); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(StandardResponse.error(e.getMessage())); + } + } + + public record ProfileImageUploadRequest(String originalname, String mimetype, long size) { + } + + public record ProfileImageCompleteRequest( + String key, String originalname, String mimetype, long size) { + } + /** * ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ */ diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java index e09b76ed..7808ca1b 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java @@ -22,6 +22,8 @@ public class FileResponse { private long size; private String user; private LocalDateTime uploadDate; + private boolean deleted; + // File ์—”ํ‹ฐํ‹ฐ์—์„œ FileResponse๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์ •์  ๋ฉ”์„œ๋“œ public static FileResponse from(File file) { @@ -32,6 +34,7 @@ public static FileResponse from(File file) { .mimetype(file.getMimetype()) .size(file.getSize()) .user(file.getUser()) + .deleted(file.isDeleted()) .uploadDate(file.getUploadDate()) .build(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java new file mode 100644 index 00000000..e612ec74 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java @@ -0,0 +1,46 @@ +package com.ktb.chatapp.dto; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํ•ญ๋ชฉ") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomListItemResponse { + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ID", example = "60d5ec49f1b2c8b9e8c4f2a1") + @JsonProperty("_id") + private String id; + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ์ด๋ฆ„", example = "ํ”„๋กœ์ ํŠธ ๋…ผ์˜๋ฐฉ") + private String name; + + @Schema(description = "๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • ์—ฌ๋ถ€", example = "false") + private boolean hasPassword; + + @Schema(description = "์ฐธ์—ฌ์ž ์ˆ˜", example = "5") + private int participantCount; + + @Schema(description = "์ตœ๊ทผ 30๋ถ„๊ฐ„ ๋ฉ”์‹œ์ง€ ์ˆ˜", example = "23") + private int recentMessageCount; + + @JsonIgnore + private LocalDateTime createdAtDateTime; + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ์‹œ๊ฐ„ (ISO 8601 ํ˜•์‹)", example = "2025-11-18T12:34:56.789Z") + @JsonGetter("createdAt") + public String getCreatedAt() { + return createdAtDateTime == null + ? null + : createdAtDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toString(); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java index f986ee9a..75c3ea57 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java @@ -12,7 +12,8 @@ @NoArgsConstructor @AllArgsConstructor public class RoomsResponse { + @Builder.Default private boolean success = true; - private List data; + private List data; private PageMetadata metadata; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java index 5ecb23de..75ab1b0f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java @@ -2,6 +2,7 @@ import com.ktb.chatapp.model.User; import com.ktb.chatapp.service.FileUrl; +import com.ktb.chatapp.websocket.socketio.SocketUser; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,4 +27,14 @@ public static UserResponse from(User user) { .profileImage(profileImageUrl != null ? profileImageUrl : "") .build(); } + + public static UserResponse from(SocketUser user) { + String profileImageUrl = FileUrl.of(user.profileImage()); + return UserResponse.builder() + .id(user.id()) + .name(user.name()) + .email(user.email()) + .profileImage(profileImageUrl != null ? profileImageUrl : "") + .build(); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomActivitiesEvent.java b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomActivitiesEvent.java new file mode 100644 index 00000000..89eb3274 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomActivitiesEvent.java @@ -0,0 +1,18 @@ +package com.ktb.chatapp.event; + +import java.util.Map; +import org.springframework.context.ApplicationEvent; + +public class RoomActivitiesEvent extends ApplicationEvent { + + private final Map recentMessageCounts; + + public RoomActivitiesEvent(Object source, Map recentMessageCounts) { + super(source); + this.recentMessageCounts = Map.copyOf(recentMessageCounts); + } + + public Map getRecentMessageCounts() { + return recentMessageCounts; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java index 00d42aca..2a7117b5 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java @@ -1,14 +1,14 @@ package com.ktb.chatapp.event; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import lombok.Getter; import org.springframework.context.ApplicationEvent; @Getter public class RoomCreatedEvent extends ApplicationEvent { - private final RoomResponse roomResponse; + private final RoomListItemResponse roomResponse; - public RoomCreatedEvent(Object source, RoomResponse roomResponse) { + public RoomCreatedEvent(Object source, RoomListItemResponse roomResponse) { super(source); this.roomResponse = roomResponse; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java index 7f4ec48c..0ce0cf91 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java @@ -1,15 +1,15 @@ package com.ktb.chatapp.event; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import lombok.Getter; import org.springframework.context.ApplicationEvent; @Getter public class RoomUpdatedEvent extends ApplicationEvent { private final String roomId; - private final RoomResponse roomResponse; + private final RoomListItemResponse roomResponse; - public RoomUpdatedEvent(Object source, String roomId, RoomResponse roomResponse) { + public RoomUpdatedEvent(Object source, String roomId, RoomListItemResponse roomResponse) { super(source); this.roomId = roomId; this.roomResponse = roomResponse; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java b/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java new file mode 100644 index 00000000..5aff45c8 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java @@ -0,0 +1,9 @@ +package com.ktb.chatapp.exception; + +public class DeletedFileException + extends RuntimeException { + + public DeletedFileException() { + super("์‚ญ์ œ๋œ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค."); + } +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/exception/DirectUploadNotSupportedException.java b/apps/backend/src/main/java/com/ktb/chatapp/exception/DirectUploadNotSupportedException.java new file mode 100644 index 00000000..741746da --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/exception/DirectUploadNotSupportedException.java @@ -0,0 +1,8 @@ +package com.ktb.chatapp.exception; + +public class DirectUploadNotSupportedException extends RuntimeException { + + public DirectUploadNotSupportedException() { + super("ํ˜„์žฌ ์Šคํ† ๋ฆฌ์ง€๋Š” ์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/exception/FileAccessException.java b/apps/backend/src/main/java/com/ktb/chatapp/exception/FileAccessException.java new file mode 100644 index 00000000..99cf02ef --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/exception/FileAccessException.java @@ -0,0 +1,36 @@ +package com.ktb.chatapp.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * ํŒŒ์ผ ์ฝ๊ธฐ ๊ฒฝ๋กœ์—์„œ ๋ฐœ์ƒํ•œ ์กฐํšŒยท์ธ๊ฐ€ ์‹คํŒจ๋ฅผ ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ์ฝ”๋“œ๋กœ ์ „๋‹ฌํ•œ๋‹ค. + */ +@Getter +public class FileAccessException extends RuntimeException { + + private final Reason reason; + + public FileAccessException(Reason reason) { + super(reason.getMessage()); + this.reason = reason; + } + + @Getter + public enum Reason { + FILE_NOT_FOUND("file_not_found", "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", HttpStatus.NOT_FOUND), + MESSAGE_NOT_FOUND("message_not_found", "ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", HttpStatus.NOT_FOUND), + ROOM_NOT_FOUND("room_not_found", "ํŒŒ์ผ์ด ์†ํ•œ ์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", HttpStatus.NOT_FOUND), + NOT_PARTICIPANT("not_participant", "ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", HttpStatus.FORBIDDEN); + + private final String code; + private final String message; + private final HttpStatus status; + + Reason(String code, String message, HttpStatus status) { + this.code = code; + this.message = message; + this.status = status; + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java index e3780e9d..3604f584 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -25,6 +26,8 @@ public class File { @Id private String id; + // ํŒŒ์ผ ์กฐํšŒ API๋Š” ์ €์žฅ๋œ filename์œผ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์œผ๋ฏ€๋กœ ๋‹ค์šด๋กœ๋“œ/๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง€์—ฐ์„ ์ค„์ธ๋‹ค. + @Indexed(name = "filename_idx") private String filename; private String originalname; @@ -42,6 +45,10 @@ public class File { @CreatedDate private LocalDateTime uploadDate; + @Builder.Default + @Field("deleted") + private boolean deleted = false; + /** * ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง€์› ์—ฌ๋ถ€ ํ™•์ธ */ @@ -78,4 +85,4 @@ public String getFileUrl(String type) { type, URLEncoder.encode(this.filename, StandardCharsets.UTF_8)); } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java index eb283faf..879d814f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java @@ -7,8 +7,11 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.index.CompoundIndex; import java.time.LocalDateTime; import java.util.ArrayList; @@ -26,6 +29,8 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "messages") +// ์ด์ „ ๋ฉ”์‹œ์ง€ ์กฐํšŒ์™€ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์นด์šดํŠธ๊ฐ€ room + timestamp ์กฐ๊ฑด์„ ๊ฐ™์ด ์“ฐ๋ฏ€๋กœ ์ปฌ๋ ‰์…˜ ์Šค์บ”์„ ์ค„์ธ๋‹ค. +@CompoundIndex(name = "room_timestamp_desc_idx", def = "{'room': 1, 'timestamp': -1}") public class Message { @Id @@ -45,6 +50,8 @@ public class Message { private MessageType type; // Mongo ๋ฌธ์„œ ํ•„๋“œ๋ช… "file" ์‚ฌ์šฉ + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ๊ถŒํ•œ ๊ฒ€์ฆ์€ fileId๋กœ ๋ฉ”์‹œ์ง€๋ฅผ ์—ญ์กฐํšŒํ•˜๋ฏ€๋กœ ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋งŒ sparse index์— ์˜ฌ๋ฆฐ๋‹ค. + @Indexed(name = "file_idx", sparse = true) @Field("file") private String fileId; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java index 073cf04e..b2dbcb83 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java @@ -10,14 +10,18 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.IndexDirection; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.index.CompoundIndex; @Data @Builder @NoArgsConstructor @AllArgsConstructor @Document(collection = "rooms") +@CompoundIndex(name = "rooms_created_at_id_desc", def = "{'createdAt': -1, '_id': -1}") public class Room { @Id @@ -32,10 +36,13 @@ public class Room { @JsonIgnore private String password; + // Health Check๊ฐ€ ์ตœ์‹  ๋ฐฉ 1๊ฐœ๋ฅผ createdAt desc๋กœ ์ฐพ์œผ๋ฏ€๋กœ ์ •๋ ฌ ๋น„์šฉ์„ ์ค„์ธ๋‹ค. + @Indexed(name = "created_at_desc_idx", direction = IndexDirection.DESCENDING) @CreatedDate private LocalDateTime createdAt; @Field("participantIds") + @Indexed(name = "participant_ids_idx") @Builder.Default private Set participantIds = new HashSet<>(); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java index 47071863..8962eede 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java @@ -7,8 +7,6 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.CompoundIndex; -import org.springframework.data.mongodb.core.index.CompoundIndexes; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -17,15 +15,13 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "sessions") -@CompoundIndexes({ - @CompoundIndex(name = "userId_sessionId_idx", def = "{'userId': 1, 'sessionId': 1}", unique = true) -}) public class Session { public static final String SESSION_TTL = "30m"; @Id private String id; + @Indexed(unique = true) private String userId; private String sessionId; @@ -36,6 +32,6 @@ public class Session { private SessionMetadata metadata; - @Indexed(expireAfter = SESSION_TTL) + @Indexed(expireAfter = "0s") private Instant expiresAt; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/User.java b/apps/backend/src/main/java/com/ktb/chatapp/model/User.java index fb16bc6a..3eb3b097 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/User.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/User.java @@ -47,6 +47,7 @@ public class User { private LocalDateTime lastLogin; + @Builder.Default private boolean isOnline = false; /** @@ -82,4 +83,4 @@ public void onBeforeConvert(BeforeConvertEvent event) { } } } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java index 932ce8e8..a5da4cbb 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java @@ -2,9 +2,12 @@ import com.ktb.chatapp.model.Message; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.Aggregation; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; @@ -19,8 +22,26 @@ public interface MessageRepository extends MongoRepository { @Query(value = "{ 'room': ?0, 'timestamp': { $gte: ?1 } }", count = true) long countRecentMessagesByRoomId(String roomId, LocalDateTime since); + @Aggregation(pipeline = { + "{ '$match': { 'room': { '$in': ?0 }, 'timestamp': { '$gte': ?1 } } }", + "{ '$group': { '_id': '$room', 'count': { '$sum': 1 } } }", + "{ '$project': { '_id': 0, 'roomKey': '$_id', 'count': 1 } }" + }) + List countRecentMessagesByRoomIds( + Set roomIds, + LocalDateTime since); + /** * fileId๋กœ ๋ฉ”์‹œ์ง€ ์กฐํšŒ (ํŒŒ์ผ ๊ถŒํ•œ ๊ฒ€์ฆ์šฉ) */ Optional findByFileId(String fileId); + + @Query(value = "{ 'file': ?0 }", fields = "{ '_id': 1, 'room': 1 }") + Optional findRoomOnlyByFileId(String fileId); + + @Query(value = "{ '_id': ?0 }", fields = "{ 'room': 1 }") + Optional findRoomOnlyById(String messageId); + + @Query(value = "{ '_id': { $in: ?0 } }", fields = "{ '_id': 1, 'room': 1 }") + List findRoomsOnlyByIdIn(List messageIds); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java new file mode 100644 index 00000000..5b60eaf4 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java @@ -0,0 +1,4 @@ +package com.ktb.chatapp.repository; + +public record RecentMessageCount(String roomKey, long count) { +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomAccessResult.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomAccessResult.java new file mode 100644 index 00000000..8b873a52 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomAccessResult.java @@ -0,0 +1,5 @@ +package com.ktb.chatapp.repository; + +/** ๋ฐฉ ์กด์žฌ ์—ฌ๋ถ€์™€ ์ฐธ๊ฐ€ ์—ฌ๋ถ€๋ฅผ ํ•œ ๋ฒˆ์˜ MongoDB ์กฐํšŒ๋กœ ํŒ์ •ํ•œ ๊ฒฐ๊ณผ. */ +public record RoomAccessResult(boolean participant) { +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java index dcf7f335..cda5def6 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java @@ -2,11 +2,13 @@ import com.ktb.chatapp.model.Room; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Aggregation; import org.springframework.data.mongodb.repository.Query; import org.springframework.data.mongodb.repository.Update; import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; @Repository public interface RoomRepository extends MongoRepository { @@ -19,11 +21,22 @@ public interface RoomRepository extends MongoRepository { @Query(value = "{}", fields = "{ '_id': 1 }") Optional findOneForHealthCheck(); + List findByParticipantIdsContaining(String userId); + + boolean existsByIdAndParticipantIdsContaining(String roomId, String userId); + + @Aggregation(pipeline = { + "{ '$match': { '_id': ?0 } }", + "{ '$project': { '_id': 0, 'participant': " + + "{ '$in': [ ?1, { '$ifNull': [ '$participantIds', [] ] } ] } } }" + }) + Optional findAccessById(String roomId, String userId); + @Query("{'_id': ?0}") @Update("{'$addToSet': {'participantIds': ?1}}") - void addParticipant(String roomId, String userId); + long addParticipant(String roomId, String userId); @Query("{'_id': ?0}") @Update("{'$pull': {'participantIds': ?1}}") - void removeParticipant(String roomId, String userId); + long removeParticipant(String roomId, String userId); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java new file mode 100644 index 00000000..63784d32 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java @@ -0,0 +1,139 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.exception.DirectUploadNotSupportedException; +import com.ktb.chatapp.model.File; +import com.ktb.chatapp.repository.FileRepository; +import com.ktb.chatapp.storage.StorageKey; +import com.ktb.chatapp.storage.StoragePort; +import com.ktb.chatapp.util.FileUtil; +import java.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatFileDirectUploadService { + + private final StoragePort storagePort; + private final FileRepository fileRepository; + + @Value("${app.s3.presign-ttl:10m}") + private Duration presignTtl; + + public PreparedUpload prepare( + String originalname, + String mimetype, + long size, + String uploaderId + ) { + FileUtil.validateFileMetadata( + originalname, + mimetype, + size + ); + + String normalizedOriginalname = + FileUtil.normalizeOriginalFilename(originalname); + + String safeFileName = + FileUtil.generateSafeFileName(normalizedOriginalname); + + String key = + StorageKey.chat(uploaderId, safeFileName); + + URI uploadUrl = storagePort + .presignUploadUrl( + key, + mimetype, + presignTtl + ) + .orElseThrow(DirectUploadNotSupportedException::new); + + return new PreparedUpload( + uploadUrl.toString(), + key, + safeFileName + ); + } + + public File complete( + String key, + String originalname, + String mimetype, + long size, + String uploaderId + ) { + FileUtil.validateFileMetadata( + originalname, + mimetype, + size + ); + + String safeFileName = + FileUtil.getFileNameFromPath(key); + + String expectedKey = + StorageKey.chat( + uploaderId, + safeFileName + ); + + if (!expectedKey.equals(key)) { + throw new RuntimeException( + "ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์—…๋กœ๋“œ key์ž…๋‹ˆ๋‹ค." + ); + } + + if (!FileUtil.isValidFilename(safeFileName)) { + throw new RuntimeException( + "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค." + ); + } + + var storedObject = storagePort + .stat(key) + .orElseThrow(() -> + new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + + if (storedObject.size() != size) { + throw new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + } + + if (storedObject.contentType() != null + && !storedObject.contentType().equals(mimetype)) { + throw new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ ํƒ€์ž…์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + } + + String normalizedOriginalname = + FileUtil.normalizeOriginalFilename(originalname); + + File fileEntity = File.builder() + .filename(safeFileName) + .originalname(normalizedOriginalname) + .mimetype(mimetype) + .size(size) + .path(key) + .user(uploaderId) + .uploadDate(LocalDateTime.now()) + .build(); + + return fileRepository.save(fileEntity); + } + + public record PreparedUpload( + String uploadUrl, + String key, + String filename + ) { + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java new file mode 100644 index 00000000..0d3c02ff --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java @@ -0,0 +1,122 @@ +package com.ktb.chatapp.service; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import software.amazon.awssdk.services.cloudfront.CloudFrontUtilities; +import software.amazon.awssdk.services.cloudfront.model.CannedSignerRequest; + +@Service +@ConditionalOnProperty( + name = "app.cloudfront.enabled", + havingValue = "true" +) +public class CloudFrontSignedUrlService { + + private final CloudFrontUtilities cloudFrontUtilities; + private final String baseUrl; + private final String keyPairId; + private final Path privateKeyPath; + private final Duration urlTtl; + + public CloudFrontSignedUrlService( + @Value("${app.cloudfront.base-url}") String baseUrl, + @Value("${app.cloudfront.key-pair-id}") String keyPairId, + @Value("${app.cloudfront.private-key-path}") String privateKeyPath, + @Value("${app.cloudfront.url-ttl:5m}") Duration urlTtl + ) { + if (!StringUtils.hasText(baseUrl)) { + throw new IllegalStateException( + "CLOUDFRONT_BASE_URL์€ CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + if (!StringUtils.hasText(keyPairId)) { + throw new IllegalStateException( + "CLOUDFRONT_KEY_PAIR_ID๋Š” CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + if (!StringUtils.hasText(privateKeyPath)) { + throw new IllegalStateException( + "CLOUDFRONT_PRIVATE_KEY_PATH๋Š” CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + Path keyPath = Path.of(privateKeyPath); + + if (!Files.isRegularFile(keyPath)) { + throw new IllegalStateException( + "CloudFront private key ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + + privateKeyPath + ); + } + + this.cloudFrontUtilities = CloudFrontUtilities.create(); + this.baseUrl = normalizeBaseUrl(baseUrl); + this.keyPairId = keyPairId; + this.privateKeyPath = keyPath; + this.urlTtl = urlTtl; + } + + public URI sign(String objectKey) { + String resourceUrl = + baseUrl + "/" + normalizeObjectKey(objectKey); + + try { + CannedSignerRequest request = + CannedSignerRequest.builder() + .resourceUrl(resourceUrl) + .privateKey(privateKeyPath) + .keyPairId(keyPairId) + .expirationDate( + Instant.now().plus(urlTtl) + ) + .build(); + + String signedUrl = + cloudFrontUtilities + .getSignedUrlWithCannedPolicy(request) + .url(); + + return URI.create(signedUrl); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create CloudFront signed URL.", + e + ); + } + } + + private static String normalizeBaseUrl(String baseUrl) { + String normalized = baseUrl.strip(); + + while (normalized.endsWith("/")) { + normalized = + normalized.substring( + 0, + normalized.length() - 1 + ); + } + + return normalized; + } + + private static String normalizeObjectKey(String objectKey) { + String normalized = objectKey; + + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + + return normalized; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java index 3cb9b0c4..4a9eda65 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java @@ -2,17 +2,25 @@ import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.exception.FileAccessException; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.repository.RoomAccessResult; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.storage.StoragePort; +import com.ktb.chatapp.exception.DeletedFileException; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Optional; -import lombok.RequiredArgsConstructor; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; import org.springframework.stereotype.Service; @@ -25,7 +33,6 @@ */ @Slf4j @Service -@RequiredArgsConstructor public class FileAccessService { /** ์˜คํ”„๋กœ๋”ฉ URL ์ˆ˜๋ช…. ๋ฐœ๊ธ‰ ์งํ›„ ๊ณง๋ฐ”๋กœ ์†Œ๋น„๋˜๋Š” ํ๋ฆ„์ด๋ฏ€๋กœ ์œ ์ถœ ์ฐฝ์„ ์งง๊ฒŒ ์œ ์ง€ํ•œ๋‹ค. */ @@ -35,17 +42,84 @@ public class FileAccessService { private final FileRepository fileRepository; private final MessageRepository messageRepository; private final RoomRepository roomRepository; + private final Optional + cloudFrontSignedUrlService; + private final MeterRegistry meterRegistry; + private final ConcurrentMap accessCounters = new ConcurrentHashMap<>(); + private final ConcurrentMap accessTimers = new ConcurrentHashMap<>(); + private final String instanceId; + + public FileAccessService( + StoragePort storagePort, + FileRepository fileRepository, + MessageRepository messageRepository, + RoomRepository roomRepository, + Optional cloudFrontSignedUrlService, + MeterRegistry meterRegistry, + @Value("${HOSTNAME:unknown}") String instanceId, + @Value("${file.storage.type:local}") String storageType, + @Value("${file.storage.require-offload:false}") boolean requireOffload) { + if (requireOffload && !"s3".equalsIgnoreCase(storageType)) { + throw new IllegalStateException( + "FILE_STORAGE_REQUIRE_OFFLOAD=true์ด๋ฉด FILE_STORAGE_TYPE=s3๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + this.storagePort = storagePort; + this.fileRepository = fileRepository; + this.messageRepository = messageRepository; + this.roomRepository = roomRepository; + this.cloudFrontSignedUrlService = cloudFrontSignedUrlService; + this.meterRegistry = meterRegistry; + this.instanceId = instanceId; + log.info( + "ํŒŒ์ผ ์ „๋‹ฌ ๊ตฌ์„ฑ: instanceId={}, storageType={}, cloudFront={}, requireOffload={}", + instanceId, + storageType, + cloudFrontSignedUrlService.isPresent(), + requireOffload); + } public FileAccess forDownload(String fileName, String requesterId) { - return issue(authorize(fileName, requesterId), Delivery.ATTACHMENT, fileName, requesterId); + File fileEntity = authorize(fileName, requesterId, Operation.DOWNLOAD); + return measureDelivery( + Operation.DOWNLOAD, + () -> issue(fileEntity, Delivery.ATTACHMENT, fileName, requesterId)); } - public FileAccess forView(String fileName, String requesterId) { - File fileEntity = authorize(fileName, requesterId); - if (!fileEntity.isPreviewable()) { - throw new PreviewNotSupportedException("๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); - } - return issue(fileEntity, Delivery.INLINE, fileName, requesterId); + public FileAccess forView( + String fileName, + String requesterId + ) { + File fileEntity = + authorize(fileName, requesterId, Operation.VIEW); + return measureDelivery(Operation.VIEW, () -> { + if (!fileEntity.isPreviewable()) { + throw new PreviewNotSupportedException( + "๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค." + ); + } + + if (cloudFrontSignedUrlService.isPresent()) { + URI signedUrl = + cloudFrontSignedUrlService + .get() + .sign(fileEntity.getPath()); + + log.info( + "CloudFront signed URL ๋ฐœ๊ธ‰: {} (์‚ฌ์šฉ์ž: {})", + fileName, + requesterId + ); + + return new FileAccess.Redirect(signedUrl); + } + + return issue( + fileEntity, + Delivery.INLINE, + fileName, + requesterId + ); + }); } /** @@ -63,28 +137,156 @@ ContentDisposition of(String filename) { } } - private File authorize(String fileName, String requesterId) { + private enum Operation { + DOWNLOAD, + VIEW + } + + private File authorize(String fileName, String requesterId, Operation operation) { + Timer.Sample sample = Timer.start(meterRegistry); + try { + File fileEntity = doAuthorize(fileName, requesterId, operation); + stopAuthorizationTimer(sample, operation, "allowed", "participant"); + return fileEntity; + } catch (FileAccessException exception) { + stopAuthorizationTimer(sample, operation, "denied", exception.getReason().getCode()); + throw exception; + } catch (DeletedFileException exception) { + stopAuthorizationTimer(sample, operation, "denied", "deleted"); + throw exception; + } catch (RuntimeException exception) { + stopAuthorizationTimer(sample, operation, "error", "exception"); + throw exception; + } + } + + private File doAuthorize(String fileName, String requesterId, Operation operation) { // 1. ํŒŒ์ผ ์กฐํšŒ File fileEntity = fileRepository.findByFilename(fileName) - .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + fileName)); + .orElseThrow(() -> failure( + operation, + FileAccessException.Reason.FILE_NOT_FOUND, + fileName, + null, + null, + null, + requesterId)); - // 2. ๋ฉ”์‹œ์ง€ ์กฐํšŒ (ํŒŒ์ผ๊ณผ ๋ฉ”์‹œ์ง€ ์—ฐ๊ฒฐ ํ™•์ธ) - ํšจ์œจ์ ์ธ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ - Message message = messageRepository.findByFileId(fileEntity.getId()) - .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ๊ณผ ์—ฐ๊ฒฐ๋œ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + if (fileEntity.isDeleted()) { + record(operation, "denied", "deleted"); + throw new DeletedFileException(); + } - // 3. ๋ฐฉ ์กฐํšŒ (์‚ฌ์šฉ์ž๊ฐ€ ๋ฐฉ ์ฐธ๊ฐ€์ž์ธ์ง€ ํ™•์ธ) - Room room = roomRepository.findById(message.getRoomId()) - .orElseThrow(() -> new RuntimeException("๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + // 2. ๋ฉ”์‹œ์ง€ ์กฐํšŒ (ํŒŒ์ผ๊ณผ ๋ฉ”์‹œ์ง€ ์—ฐ๊ฒฐ ํ™•์ธ) - ํšจ์œจ์ ์ธ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ + Message message = messageRepository.findRoomOnlyByFileId(fileEntity.getId()) + .orElseThrow(() -> failure( + operation, + FileAccessException.Reason.MESSAGE_NOT_FOUND, + fileName, + fileEntity.getId(), + null, + null, + requesterId)); - // 4. ๊ถŒํ•œ ๊ฒ€์ฆ - if (!room.getParticipantIds().contains(requesterId)) { - log.warn("ํŒŒ์ผ ์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ: {} (์‚ฌ์šฉ์ž: {})", fileName, requesterId); - throw new RuntimeException("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + // 3. ๋ฐฉ ์กด์žฌ ์—ฌ๋ถ€์™€ ์ฐธ๊ฐ€ ์—ฌ๋ถ€๋ฅผ ์ž‘์€ projection ํ•œ ๋ฒˆ์œผ๋กœ ํŒ์ •ํ•œ๋‹ค. + String roomId = message.getRoomId(); + Optional access = roomRepository.findAccessById(roomId, requesterId); + if (access.isEmpty() || !access.get().participant()) { + FileAccessException.Reason reason = access.isEmpty() + ? FileAccessException.Reason.ROOM_NOT_FOUND + : FileAccessException.Reason.NOT_PARTICIPANT; + throw failure( + operation, + reason, + fileName, + fileEntity.getId(), + message.getId(), + roomId, + requesterId); } + record(operation, "allowed", "participant"); return fileEntity; } + private FileAccess measureDelivery(Operation operation, Supplier delivery) { + Timer.Sample sample = Timer.start(meterRegistry); + try { + FileAccess access = delivery.get(); + String deliveryType = access instanceof FileAccess.Redirect ? "redirect" : "stream"; + stopDeliveryTimer(sample, operation, "success", deliveryType); + return access; + } catch (PreviewNotSupportedException exception) { + stopDeliveryTimer(sample, operation, "denied", "unsupported"); + throw exception; + } catch (RuntimeException exception) { + stopDeliveryTimer(sample, operation, "error", "exception"); + throw exception; + } + } + + private FileAccessException failure( + Operation operation, + FileAccessException.Reason reason, + String fileName, + String fileId, + String messageId, + String roomId, + String requesterId) { + record(operation, "denied", reason.getCode()); + log.warn( + "ํŒŒ์ผ ์ ‘๊ทผ ๊ฑฐ๋ถ€: instanceId={}, operation={}, reason={}, fileName={}, fileId={}, " + + "messageId={}, roomId={}, requesterId={}", + instanceId != null ? instanceId : "unknown", + operation.name().toLowerCase(), + reason.getCode(), + fileName, + fileId, + messageId, + roomId, + requesterId); + return new FileAccessException(reason); + } + + private void record(Operation operation, String outcome, String reason) { + String operationName = operation.name().toLowerCase(); + String key = operationName + ':' + outcome + ':' + reason; + accessCounters.computeIfAbsent(key, ignored -> + Counter.builder("file.access.requests") + .description("File read authorization outcomes") + .tag("operation", operationName) + .tag("outcome", outcome) + .tag("reason", reason) + .register(meterRegistry)) + .increment(); + } + + private void stopAuthorizationTimer( + Timer.Sample sample, Operation operation, String outcome, String reason) { + String operationName = operation.name().toLowerCase(); + String key = "authorization:" + operationName + ':' + outcome + ':' + reason; + sample.stop(accessTimers.computeIfAbsent(key, ignored -> + Timer.builder("file.access.authorization.duration") + .description("File authorization duration") + .tag("operation", operationName) + .tag("outcome", outcome) + .tag("reason", reason) + .register(meterRegistry))); + } + + private void stopDeliveryTimer( + Timer.Sample sample, Operation operation, String outcome, String delivery) { + String operationName = operation.name().toLowerCase(); + String key = "delivery:" + operationName + ':' + outcome + ':' + delivery; + sample.stop(accessTimers.computeIfAbsent(key, ignored -> + Timer.builder("file.access.delivery.duration") + .description("File delivery preparation duration") + .tag("operation", operationName) + .tag("outcome", outcome) + .tag("delivery", delivery) + .register(meterRegistry))); + } + private FileAccess issue(File fileEntity, Delivery delivery, String fileName, String requesterId) { Optional offloadUrl = storagePort.offloadUrl( fileEntity.getPath(), OFFLOAD_URL_TTL, delivery.of(fileEntity.getOriginalname())); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java index 7c27e8a5..7a141cef 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java @@ -42,7 +42,7 @@ public FileUploadResult uploadFile(MultipartFile file, String uploaderId) { String key = StorageKey.chat(safeFileName); storagePort.put(file.getInputStream(), key, file.getContentType(), file.getSize()); - log.info("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); + log.debug("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); // ์›๋ณธ ํŒŒ์ผ๋ช… ์ •๊ทœํ™” String normalizedOriginalname = FileUtil.normalizeOriginalFilename(originalFilename); @@ -93,7 +93,7 @@ public String storeFile(MultipartFile file, String subDirectory) { // ํŒŒ์ผ ์ €์žฅ storagePort.put(file.getInputStream(), key, file.getContentType(), file.getSize()); - log.info("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); + log.debug("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); return key; @@ -104,28 +104,36 @@ public String storeFile(MultipartFile file, String subDirectory) { } @Override - public boolean deleteFile(String fileId, String requesterId) { - try { - File fileEntity = fileRepository.findById(fileId) - .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - // ์‚ญ์ œ ๊ถŒํ•œ ๊ฒ€์ฆ (์—…๋กœ๋”๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) - if (!fileEntity.getUser().equals(requesterId)) { - throw new RuntimeException("ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); - } + public boolean deleteFile( + String fileId, + String requesterId + ) { + File fileEntity = fileRepository.findById(fileId) + .orElseThrow(() -> + new RuntimeException( + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + + if (!fileEntity.getUser().equals(requesterId)) { + throw new RuntimeException( + "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค." + ); + } - // ๋ฌผ๋ฆฌ์  ํŒŒ์ผ ์‚ญ์ œ - storagePort.delete(fileEntity.getPath()); + if (fileEntity.isDeleted()) { + return true; + } - // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ œ๊ฑฐ - fileRepository.delete(fileEntity); + fileEntity.setDeleted(true); + fileRepository.save(fileEntity); - log.info("ํŒŒ์ผ ์‚ญ์ œ ์™„๋ฃŒ: {} (์‚ฌ์šฉ์ž: {})", fileId, requesterId); - return true; + log.info( + "ํŒŒ์ผ soft delete ์™„๋ฃŒ: {} (์‚ฌ์šฉ์ž: {})", + fileId, + requesterId + ); - } catch (Exception e) { - log.error("ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ: {}", e.getMessage(), e); - throw new RuntimeException("ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", e); - } + return true; } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java new file mode 100644 index 00000000..da5fb0ac --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java @@ -0,0 +1,31 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.model.Message; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MessageReactionService { + + private final MongoTemplate mongoTemplate; + + public Optional update(String messageId, String reaction, String userId, boolean add) { + if (reaction == null || reaction.isBlank() || reaction.contains(".") || reaction.startsWith("$")) { + throw new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฆฌ์•ก์…˜์ž…๋‹ˆ๋‹ค."); + } + + Query query = Query.query(Criteria.where("_id").is(messageId)); + String field = "reactions." + reaction; + Update update = add ? new Update().addToSet(field, userId) : new Update().pull(field, userId); + Message updated = mongoTemplate.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), Message.class); + return Optional.ofNullable(updated); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java index 4dacf9cc..111187d7 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java @@ -1,14 +1,19 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +/* ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ import */ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + /** * ๋ฉ”์‹œ์ง€ ์ฝ์Œ ์ƒํƒœ ๊ด€๋ฆฌ ์„œ๋น„์Šค */ @@ -17,7 +22,7 @@ @RequiredArgsConstructor public class MessageReadStatusService { - private final MessageRepository messageRepository; + private final MongoTemplate mongoTemplate; /** * ๋ฉ”์‹œ์ง€ ์ฝ์Œ ์ƒํƒœ ์—…๋ฐ์ดํŠธ @@ -25,9 +30,13 @@ public class MessageReadStatusService { * @param messageIds ์ฝ์Œ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•  ๋ฉ”์‹œ์ง€ ๋ฆฌ์ŠคํŠธ * @param userId ์ฝ์€ ์‚ฌ์šฉ์ž ID */ - public void updateReadStatus(List messageIds, String userId) { + public long updateReadStatus(List messageIds, String userId) { + return updateReadStatus(messageIds, userId, null); + } + + public long updateReadStatus(List messageIds, String userId, String roomId) { if (messageIds.isEmpty()) { - return; + return 0; } var readerInfo = Message.MessageReader.builder() @@ -36,25 +45,41 @@ public void updateReadStatus(List messageIds, String userId) { .build(); try { - for (String messageId : messageIds) { - var messageOptional = messageRepository.findById(messageId); - if (messageOptional.isPresent()) { - var message = messageOptional.get(); - if (message.getReaders() == null) { - message.setReaders(new ArrayList<>()); - } - boolean alreadyRead = message.getReaders().stream() - .anyMatch(r -> r.getUserId().equals(userId)); - if (!alreadyRead) { - message.getReaders().add(readerInfo); - } - messageRepository.save(message); - } + Criteria criteria = Criteria.where("_id") + .in(messageIds) + .and("readers.userId") + .ne(userId); + + if (roomId != null && !roomId.isBlank()) { + criteria.and("room").is(roomId); } - log.debug("Read status updated for {} messages by user {}", - messageIds.size(), userId); + + Query query = Query.query(criteria); + + Update update = new Update() + .push("readers", readerInfo); + + var result = mongoTemplate.updateMulti( + query, + update, + Message.class + ); + + log.debug( + "Read status updated: requested={}, modified={}, userId={}", + messageIds.size(), + result.getModifiedCount(), + userId + ); + return result.getModifiedCount(); + } catch (Exception e) { - log.error("Read status update error for user {}", userId, e); + log.error( + "Read status update error for user {}", + userId, + e + ); + return 0; } } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java index bc1d9a72..5bc05cce 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java @@ -1,17 +1,11 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.model.RateLimit; import com.ktb.chatapp.service.ratelimit.RateLimitStore; -import jakarta.annotation.PostConstruct; import java.time.Duration; import java.time.Instant; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static java.net.InetAddress.*; @Slf4j @Service @@ -19,68 +13,27 @@ public class RateLimitService { private final RateLimitStore rateLimitStore; - @Value("${HOSTNAME:''}") - private String hostName; - - @PostConstruct - public void init() { - if (!hostName.isEmpty()) { - return; - } - hostName = generateHostname(); - } - - private String generateHostname() { - try { - return getLocalHost().getHostName(); - } catch (Exception e) { - return "unknown-" + java.util.UUID.randomUUID().toString().substring(0, 8); - } - } - - - @Transactional public RateLimitCheckResult checkRateLimit(String _clientId, int maxRequests, Duration window) { - String actualClientId = hostName + ":" + _clientId; - Duration effectiveWindow = window != null ? window : Duration.ofSeconds(1); + String actualClientId = String.valueOf(_clientId); + Duration effectiveWindow = window == null || window.isZero() || window.isNegative() + ? Duration.ofSeconds(1) : window; long windowSeconds = Math.max(1L, effectiveWindow.getSeconds()); Instant now = Instant.now(); long nowEpochSeconds = now.getEpochSecond(); - Instant expiresAt = now.plusSeconds(windowSeconds); try { - RateLimit rateLimit = rateLimitStore.findByClientId(actualClientId).orElse(null); - if (rateLimit != null && !rateLimit.getExpiresAt().isAfter(now)) { - rateLimit.setCount(0); - rateLimit.setExpiresAt(expiresAt); - } - - int currentCount = rateLimit != null ? rateLimit.getCount() : 0; - - if (rateLimit != null && currentCount >= maxRequests) { + var consumption = rateLimitStore.consume(actualClientId, maxRequests, effectiveWindow, now); + if (!consumption.allowed()) { long retryAfterSeconds = Math.max(1L, - rateLimit.getExpiresAt().getEpochSecond() - nowEpochSeconds); - long resetEpochSeconds = rateLimit.getExpiresAt().getEpochSecond(); + consumption.expiresAt().getEpochSecond() - nowEpochSeconds); + long resetEpochSeconds = consumption.expiresAt().getEpochSecond(); return RateLimitCheckResult.rejected( maxRequests, windowSeconds, resetEpochSeconds, retryAfterSeconds); } - // Create or update rate limit - if (rateLimit == null) { - rateLimit = RateLimit.builder() - .clientId(actualClientId) - .count(1) - .expiresAt(expiresAt) - .build(); - } else { - rateLimit.setCount(currentCount + 1); - } - rateLimitStore.save(rateLimit); - - int newCount = currentCount + 1; - int remaining = Math.max(0, maxRequests - newCount); - long ttlSeconds = Math.max(1L, rateLimit.getExpiresAt().getEpochSecond() - nowEpochSeconds); - long resetEpochSeconds = rateLimit.getExpiresAt().getEpochSecond(); + int remaining = Math.max(0, maxRequests - consumption.count()); + long ttlSeconds = Math.max(1L, consumption.expiresAt().getEpochSecond() - nowEpochSeconds); + long resetEpochSeconds = consumption.expiresAt().getEpochSecond(); return RateLimitCheckResult.allowed( maxRequests, remaining, windowSeconds, resetEpochSeconds, ttlSeconds); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java index 39a1fd00..e54daf56 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java @@ -1,8 +1,12 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.repository.RecentMessageCount; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -21,4 +25,16 @@ public int countRecentMessages(String roomId) { LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW); return (int) messageRepository.countRecentMessagesByRoomId(roomId, since); } + + public Map countRecentMessagesByRoomIds(Set roomIds) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW); + return messageRepository.countRecentMessagesByRoomIds(roomIds, since).stream() + .collect(Collectors.toMap( + RecentMessageCount::roomKey, + count -> Math.toIntExact(count.count()))); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java new file mode 100644 index 00000000..1389443e --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java @@ -0,0 +1,44 @@ +package com.ktb.chatapp.service; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RoomActivityDebouncer { + + private static final String KEY_PREFIX = "room_activity:debounce:"; + + private final ObjectProvider redissonClientProvider; + private final Duration debounceWindow; + private final AtomicBoolean fallbackWarningLogged = new AtomicBoolean(); + + public RoomActivityDebouncer( + ObjectProvider redissonClientProvider, + @Value("${room-activity.debounce-window:1s}") Duration debounceWindow) { + this.redissonClientProvider = redissonClientProvider; + this.debounceWindow = debounceWindow; + } + + public boolean tryAcquire(String roomId) { + RedissonClient redissonClient = redissonClientProvider.getIfAvailable(); + if (redissonClient == null) { + return true; + } + + try { + return redissonClient.getBucket(KEY_PREFIX + roomId) + .setIfAbsent("1", debounceWindow); + } catch (RuntimeException e) { + if (fallbackWarningLogged.compareAndSet(false, true)) { + log.warn("Redis room activity debounce unavailable; using local batching", e); + } + return true; + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java index 1687bc7e..2422077a 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java @@ -1,13 +1,20 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.event.RoomActivityEvent; +import com.ktb.chatapp.event.RoomActivitiesEvent; +import java.util.HashSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** - * ์ƒˆ ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋˜๋ฉด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์˜ ํ™œ์„ฑ๋„ ์ง€ํ‘œ๋ฅผ ๊ฐฑ์‹ ํ•˜๋„๋ก ์•Œ๋ฆฐ๋‹ค. + * ์ƒˆ ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋œ ์ฑ„ํŒ…๋ฐฉ์„ ๋ชจ์•„ + * ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ผ๊ด„ ๊ฐฑ์‹ ํ•œ๋‹ค. */ @Slf4j @Component @@ -16,17 +23,72 @@ public class RoomActivityNotifier { private final RecentMessageCounter recentMessageCounter; private final ApplicationEventPublisher eventPublisher; + private final RoomActivityDebouncer roomActivityDebouncer; + private final Set pendingRoomIds = + ConcurrentHashMap.newKeySet(); + + /** + * ๋ฉ”์‹œ์ง€ ์ €์žฅ ๊ฒฝ๋กœ์—์„œ๋Š” DB ์กฐํšŒ๋ฅผ ํ•˜์ง€ ์•Š๊ณ  + * ๊ฐฑ์‹ ์ด ํ•„์š”ํ•œ ์ฑ„ํŒ…๋ฐฉ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. + */ public void notifyMessageStored(String roomId) { if (roomId == null) { return; } + pendingRoomIds.add(roomId); + } + + /** + * ์งง์€ ์ฃผ๊ธฐ๋กœ ๋ณ€๊ฒฝ๋œ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ + * ํ•œ ๋ฒˆ์˜ ์ง‘๊ณ„ ์ฟผ๋ฆฌ๋กœ ์กฐํšŒํ•œ๋‹ค. + */ + @Scheduled(fixedDelayString = "${room-activity.flush-delay:1s}") + void flushPendingRoomActivities() { + if (pendingRoomIds.isEmpty()) { + return; + } + + Set roomIds = new HashSet<>(); + + for (String roomId : pendingRoomIds) { + if (pendingRoomIds.remove(roomId)) { + roomIds.add(roomId); + } + } + + if (roomIds.isEmpty()) { + return; + } + + roomIds.removeIf(roomId -> !roomActivityDebouncer.tryAcquire(roomId)); + if (roomIds.isEmpty()) { + return; + } + try { - int recentMessageCount = recentMessageCounter.countRecentMessages(roomId); - eventPublisher.publishEvent(new RoomActivityEvent(this, roomId, recentMessageCount)); + Map recentMessageCounts = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + Map activityCounts = new HashMap<>(); + for (String roomId : roomIds) { + activityCounts.put(roomId, recentMessageCounts.getOrDefault(roomId, 0)); + } + + if (!activityCounts.isEmpty()) { + eventPublisher.publishEvent(new RoomActivitiesEvent(this, activityCounts)); + } + } catch (Exception e) { - log.error("roomActivity ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ: roomId={}", roomId, e); + // ์ผ์‹œ์ ์œผ๋กœ ์ง‘๊ณ„์— ์‹คํŒจํ•˜๋ฉด ๋‹ค์Œ ์ฃผ๊ธฐ์— ๋‹ค์‹œ ์‹œ๋„ํ•œ๋‹ค. + pendingRoomIds.addAll(roomIds); + + log.error( + "roomActivity ์ผ๊ด„ ๊ฐฑ์‹  ์‹คํŒจ: roomIds={}", + roomIds, + e + ); } } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java index 5f5ecf40..ad25c939 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java @@ -2,21 +2,27 @@ import com.ktb.chatapp.dto.*; import com.ktb.chatapp.event.RoomCreatedEvent; -import com.ktb.chatapp.event.RoomUpdatedEvent; import com.ktb.chatapp.model.Room; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.repository.UserRepository; +import java.time.Duration; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -25,44 +31,69 @@ @RequiredArgsConstructor public class RoomService { + public static final int ROOM_LIST_PAGE_SIZE = 20; + private final RoomRepository roomRepository; private final UserRepository userRepository; private final RecentMessageCounter recentMessageCounter; private final PasswordEncoder passwordEncoder; private final ApplicationEventPublisher eventPublisher; + private final Map roomListCache = new ConcurrentHashMap<>(); + private final Map roomListLoadLocks = new ConcurrentHashMap<>(); - public RoomsResponse getAllRooms(String name) { + @Value("${rooms.list-cache-ttl:1s}") + private Duration roomListCacheTtl; - try { - // ์ „์ฒด ๋ฐฉ์„ ์กฐํšŒํ•ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค - List roomResponses = roomRepository.findAll().stream() - .map(room -> mapToRoomResponse(room, name)) - .sorted(Comparator.comparing( - RoomResponse::getCreatedAtDateTime, - Comparator.nullsLast(Comparator.reverseOrder()))) - .collect(Collectors.toList()); - - PageMetadata metadata = PageMetadata.builder() - .total(roomResponses.size()) - .page(0) - .pageSize(roomResponses.size()) - .totalPages(1) - .hasMore(false) - .currentCount(roomResponses.size()) - .build(); + public RoomsResponse getAllRooms(int page) { + long now = System.nanoTime(); + CachedRoomsResponse cached = roomListCache.get(page); + if (cached != null && cached.expiresAtNanos() > now) { + return cached.response(); + } - return RoomsResponse.builder() - .success(true) - .data(roomResponses) - .metadata(metadata) - .build(); + Object pageLock = roomListLoadLocks.computeIfAbsent(page, ignored -> new Object()); + synchronized (pageLock) { + now = System.nanoTime(); + cached = roomListCache.get(page); + if (cached != null && cached.expiresAtNanos() > now) { + return cached.response(); + } - } catch (Exception e) { - log.error("๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ ์—๋Ÿฌ", e); - return RoomsResponse.builder() - .success(false) - .data(List.of()) - .build(); + try { + PageRequest pageRequest = PageRequest.of( + page, + ROOM_LIST_PAGE_SIZE, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + Page roomPage = roomRepository.findAll(pageRequest); + List rooms = roomPage.getContent(); + if (rooms.isEmpty()) { + return successfulRoomsResponse(List.of(), roomPage); + } + + Set roomIds = rooms.stream() + .map(Room::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map recentMessageCounts = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + List roomResponses = rooms.stream() + .map(room -> mapToRoomListItemResponse( + room, + recentMessageCounts.getOrDefault(room.getId(), 0))) + .toList(); + + RoomsResponse response = successfulRoomsResponse(roomResponses, roomPage); + cacheRoomsResponse(page, response, now); + return response; + + } catch (Exception e) { + log.error("๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ ์—๋Ÿฌ", e); + return RoomsResponse.builder() + .success(false) + .data(List.of()) + .build(); + } } } @@ -127,10 +158,12 @@ public Room createRoom(CreateRoomRequest createRoomRequest, String name) { } Room savedRoom = roomRepository.save(room); + // Keep the previous page snapshot for the short TTL. Clearing every + // page here turns a creation spike into a room-list cache stampede. // Publish event for room created try { - RoomResponse roomResponse = mapToRoomResponse(savedRoom, name); + RoomListItemResponse roomResponse = mapToRoomListItemResponse(savedRoom, 0); eventPublisher.publishEvent(new RoomCreatedEvent(this, roomResponse)); } catch (Exception e) { log.error("roomCreated ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ", e); @@ -160,60 +193,56 @@ public Room joinRoom(String roomId, String password, String name) { } } - // ์ด๋ฏธ ์ฐธ์—ฌ์ค‘์ธ์ง€ ํ™•์ธ - if (!room.getParticipantIds().contains(user.getId())) { - // ์ฑ„ํŒ…๋ฐฉ ์ฐธ์—ฌ - room.getParticipantIds().add(user.getId()); - room = roomRepository.save(room); - } - - // Publish event for room updated - try { - RoomResponse roomResponse = mapToRoomResponse(room, name); - eventPublisher.publishEvent(new RoomUpdatedEvent(this, roomId, roomResponse)); - } catch (Exception e) { - log.error("roomUpdate ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ", e); - } + // ์ „์ฒด Room ๋ฌธ์„œ๋ฅผ read-modify-save ํ•˜๋ฉด ๋™์‹œ ์ž…์žฅ์ด ์„œ๋กœ์˜ + // participantIds๋ฅผ ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ๋‹ค. MongoDB $addToSet์œผ๋กœ ์ฐธ์—ฌ์ž๋งŒ + // ์›์ž์ ์œผ๋กœ ์ถ”๊ฐ€ํ•œ๋‹ค. ํ˜ธ์ถœ์ž๋Š” navigation์— ํ•„์š”ํ•œ room id๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. + roomRepository.addParticipant(roomId, user.getId()); return room; } - private RoomResponse mapToRoomResponse(Room room, String name) { - if (room == null) return null; - - User creator = null; - if (room.getCreator() != null) { - creator = userRepository.findById(room.getCreator()).orElse(null); - } - - List participants = room.getParticipantIds().stream() - .map(userRepository::findById) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - - int recentMessageCount = recentMessageCounter.countRecentMessages(room.getId()); - - return RoomResponse.builder() + private RoomListItemResponse mapToRoomListItemResponse(Room room, int recentMessageCount) { + return RoomListItemResponse.builder() .id(room.getId()) .name(room.getName() != null ? room.getName() : "์ œ๋ชฉ ์—†์Œ") .hasPassword(room.isHasPassword()) - .creator(creator != null ? UserResponse.builder() - .id(creator.getId()) - .name(creator.getName() != null ? creator.getName() : "์•Œ ์ˆ˜ ์—†์Œ") - .email(creator.getEmail() != null ? creator.getEmail() : "") - .build() : null) - .participants(participants.stream() - .filter(p -> p != null && p.getId() != null) - .map(p -> UserResponse.builder() - .id(p.getId()) - .name(p.getName() != null ? p.getName() : "์•Œ ์ˆ˜ ์—†์Œ") - .email(p.getEmail() != null ? p.getEmail() : "") - .build()) - .collect(Collectors.toList())) + .participantCount(room.getParticipantCount()) .createdAtDateTime(room.getCreatedAt()) - .isCreator(creator != null && creator.getId().equals(name)) .recentMessageCount(recentMessageCount) .build(); } + + private RoomsResponse successfulRoomsResponse( + List roomResponses, + Page roomPage) { + PageMetadata metadata = PageMetadata.builder() + .total(roomPage.getTotalElements()) + .page(roomPage.getNumber()) + .pageSize(ROOM_LIST_PAGE_SIZE) + .totalPages(roomPage.getTotalPages()) + .hasMore(roomPage.hasNext()) + .currentCount(roomResponses.size()) + .sort(PageMetadata.SortInfo.builder() + .field("createdAt") + .order("DESC") + .build()) + .build(); + + return RoomsResponse.builder() + .success(true) + .data(roomResponses) + .metadata(metadata) + .build(); + } + + private void cacheRoomsResponse(int page, RoomsResponse response, long nowNanos) { + long ttlNanos = roomListCacheTtl != null ? roomListCacheTtl.toNanos() : 0; + if (ttlNanos <= 0) { + return; + } + roomListCache.put(page, new CachedRoomsResponse(response, nowNanos + ttlNanos)); + } + + private record CachedRoomsResponse(RoomsResponse response, long expiresAtNanos) { + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java index 4618267b..d6d944f8 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java @@ -19,6 +19,7 @@ public class SessionService { private final SessionStore sessionStore; public static final long SESSION_TTL_SEC = DurationStyle.detectAndParse(SESSION_TTL).getSeconds(); private static final long SESSION_TIMEOUT = SESSION_TTL_SEC * 1000; + private static final long ACTIVITY_WRITE_INTERVAL_MS = 30_000; private String generateSessionId() { return UUID.randomUUID().toString().replace("-", ""); @@ -36,9 +37,6 @@ private SessionData toSessionData(Session session) { public SessionCreationResult createSession(String userId, SessionMetadata metadata) { try { - // Remove all existing user sessions - removeAllUserSessions(userId); - String sessionId = generateSessionId(); long now = Instant.now().toEpochMilli(); @@ -51,7 +49,7 @@ public SessionCreationResult createSession(String userId, SessionMetadata metada .expiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)) .build(); - session = sessionStore.save(session); + session = sessionStore.replaceByUserId(session); SessionData sessionData = toSessionData(session); @@ -94,10 +92,15 @@ public SessionValidationResult validateSession(String userId, String sessionId) return SessionValidationResult.invalid("SESSION_EXPIRED", "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); } - // Update last activity - session.setLastActivity(now); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - session = sessionStore.save(session); + // ์ธ์ฆ ์š”์ฒญ๋งˆ๋‹ค MongoDB write๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค. ์ตœ๊ทผ์— ๊ฐฑ์‹ ๋œ ์„ธ์…˜์€ ์ฝ๊ธฐ๋งŒ ํ•˜๊ณ  + // ์ผ์ • ์‹œ๊ฐ„์ด ์ง€๋‚œ ๊ฒฝ์šฐ์—๋งŒ lastActivity์™€ TTL์„ ์—ฐ์žฅํ•œ๋‹ค. + if (now - session.getLastActivity() >= ACTIVITY_WRITE_INTERVAL_MS) { + session = sessionStore.touch(userId, sessionId, now, + Instant.now().plusSeconds(SESSION_TTL_SEC)).orElse(null); + if (session == null) { + return SessionValidationResult.invalid("INVALID_SESSION", "์„ธ์…˜์ด ๊ต์ฒด๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } SessionData sessionData = toSessionData(session); return SessionValidationResult.valid(sessionData); @@ -121,9 +124,9 @@ public void updateLastActivity(String userId) { return; } - session.setLastActivity(Instant.now().toEpochMilli()); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - sessionStore.save(session); + long now = Instant.now().toEpochMilli(); + sessionStore.touch(userId, session.getSessionId(), now, + Instant.now().plusSeconds(SESSION_TTL_SEC)); } catch (Exception e) { log.error("Failed to update session activity for user: {}", userId, e); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java index 9c47c24c..3fd5fb91 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java @@ -5,16 +5,25 @@ import com.ktb.chatapp.dto.UserResponse; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.exception.DirectUploadNotSupportedException; +import com.ktb.chatapp.storage.StorageKey; import com.ktb.chatapp.storage.StoragePort; import com.ktb.chatapp.util.FileUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.net.URI; +import java.time.Duration; import java.util.Arrays; import java.util.List; @@ -26,10 +35,14 @@ public class UserService { private final UserRepository userRepository; private final FileService fileService; private final StoragePort storagePort; + private final MongoOperations mongoOperations; @Value("${app.profile.image.max-size:5242880}") // 5MB private long maxProfileImageSize; + @Value("${app.s3.presign-ttl:10m}") + private Duration profilePresignTtl; + private static final List ALLOWED_EXTENSIONS = Arrays.asList( "jpg", "jpeg", "png", "gif", "webp" ); @@ -49,15 +62,17 @@ public UserResponse getCurrentUserProfile(String email) { * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public UserResponse updateUserProfile(String email, UpdateProfileRequest request) { - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - // ํ”„๋กœํ•„ ์ •๋ณด ์—…๋ฐ์ดํŠธ - user.setName(request.getName()); - user.setUpdatedAt(LocalDateTime.now()); - - User updatedUser = userRepository.save(user); - log.info("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ - ID: {}, Name: {}", user.getId(), request.getName()); + User updatedUser = mongoOperations.findAndModify( + Query.query(Criteria.where("email").is(email.toLowerCase())), + new Update() + .set("name", request.getName()) + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(true), + User.class); + if (updatedUser == null) { + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + log.debug("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ - ID: {}, Name: {}", updatedUser.getId(), request.getName()); return UserResponse.from(updatedUser); } @@ -67,31 +82,118 @@ public UserResponse updateUserProfile(String email, UpdateProfileRequest request * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public ProfileImageResponse uploadProfileImage(String email, MultipartFile file) { - // ์‚ฌ์šฉ์ž ์กฐํšŒ - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - // ํŒŒ์ผ ์œ ํšจ์„ฑ ๊ฒ€์ฆ validateProfileImageFile(file); - // ๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ - if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); - } - - // ์ƒˆ ํŒŒ์ผ ์ €์žฅ (๋ณด์•ˆ ๊ฒ€์ฆ ํฌํ•จ) + // ์ƒˆ ํŒŒ์ผ์„ ๋จผ์ € ์ €์žฅํ•œ๋‹ค. ์ดํ›„ DB ์ €์žฅ์ด ์‹คํŒจํ•˜๋ฉด ์ƒˆ ํŒŒ์ผ์„ ์ •๋ฆฌํ•ด ๊ธฐ์กด ํ”„๋กœํ•„์„ ๋ณด์กดํ•œ๋‹ค. String profileImageKey = fileService.storeFile(file, "profiles"); - // DB์—๋Š” key๋งŒ ์ €์žฅํ•œ๋‹ค โ€” URL์€ ์‘๋‹ต ๊ฒฝ๊ณ„์—์„œ ์กฐ๋ฆฝ๋œ๋‹ค - user.setProfileImage(profileImageKey); - user.setUpdatedAt(LocalDateTime.now()); - userRepository.save(user); + User previousUser; + try { + previousUser = mongoOperations.findAndModify( + Query.query(Criteria.where("email").is(email.toLowerCase())), + new Update() + .set("profileImage", profileImageKey) + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(false), + User.class); + } catch (RuntimeException e) { + deleteProfileImageFile(profileImageKey, "DB ์ €์žฅ ์‹คํŒจ ํ›„ ์ƒˆ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw e; + } + if (previousUser == null) { + deleteProfileImageFile(profileImageKey, "์‚ฌ์šฉ์ž ์—†์Œ์œผ๋กœ ์ƒˆ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ์ƒˆ key๊ฐ€ DB์— ๋ฐ˜์˜๋œ ๋’ค์—๋งŒ ์ด์ „ ์‹ค๋ฌผ ํŒŒ์ผ์„ ์‚ญ์ œํ•œ๋‹ค. + String oldProfileImageKey = previousUser.getProfileImage(); + if (oldProfileImageKey != null + && !oldProfileImageKey.isEmpty() + && !oldProfileImageKey.equals(profileImageKey)) { + deleteProfileImageFile(oldProfileImageKey, "๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); + } - log.info("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์™„๋ฃŒ - User ID: {}, Key: {}", user.getId(), profileImageKey); + log.debug("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์™„๋ฃŒ - User ID: {}, Key: {}", previousUser.getId(), profileImageKey); return ProfileImageResponse.updated(profileImageKey); } + public PreparedProfileUpload prepareProfileImageUpload( + String email, + String originalFilename, + String contentType, + long size) { + validateProfileImageMetadata(originalFilename, contentType, size); + User user = userRepository.findByEmail(email.toLowerCase()) + .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + String safeFileName = user.getId() + "_" + FileUtil.generateSafeFileName(originalFilename); + String key = StorageKey.profile(safeFileName); + URI uploadUrl = storagePort.presignUploadUrl(key, contentType, profilePresignTtl) + .orElseThrow(DirectUploadNotSupportedException::new); + return new PreparedProfileUpload(uploadUrl.toString(), key); + } + + public ProfileImageResponse completeProfileImageUpload( + String email, + String key, + String originalFilename, + String contentType, + long size) { + validateProfileImageMetadata(originalFilename, contentType, size); + User user = userRepository.findByEmail(email.toLowerCase()) + .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + String expectedPrefix = StorageKey.profile(user.getId() + "_"); + if (key == null || !key.startsWith(expectedPrefix)) { + throw new IllegalArgumentException("ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ key์ž…๋‹ˆ๋‹ค."); + } + + var storedObject = storagePort.stat(key) + .orElseThrow(() -> new IllegalArgumentException("์—…๋กœ๋“œ๋œ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + if (storedObject.size() != size + || (storedObject.contentType() != null && !storedObject.contentType().equals(contentType))) { + throw new IllegalArgumentException("์—…๋กœ๋“œ๋œ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ณด๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + User previousUser; + try { + previousUser = mongoOperations.findAndModify( + Query.query(Criteria.where("email").is(email.toLowerCase())), + new Update().set("profileImage", key).set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(false), + User.class); + } catch (RuntimeException e) { + deleteProfileImageFile(key, "DB ์ €์žฅ ์‹คํŒจ ํ›„ ์ง์ ‘ ์—…๋กœ๋“œ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw e; + } + if (previousUser == null) { + deleteProfileImageFile(key, "์‚ฌ์šฉ์ž ์—†์Œ์œผ๋กœ ์ง์ ‘ ์—…๋กœ๋“œ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + String oldKey = previousUser.getProfileImage(); + if (oldKey != null && !oldKey.isEmpty() && !oldKey.equals(key)) { + deleteProfileImageFile(oldKey, "๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); + } + return ProfileImageResponse.updated(key); + } + + private void validateProfileImageMetadata(String originalFilename, String contentType, long size) { + if (size <= 0 || size > maxProfileImageSize) { + throw new IllegalArgumentException("ํŒŒ์ผ ํฌ๊ธฐ๋Š” 5MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (contentType == null || !contentType.startsWith("image/")) { + throw new IllegalArgumentException("์ด๋ฏธ์ง€ ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + try { + FileUtil.validateFileMetadata(originalFilename, contentType, size); + } catch (RuntimeException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + public record PreparedProfileUpload(String uploadUrl, String key) { + } + /** * ํŠน์ • ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ */ @@ -138,12 +240,12 @@ private void validateProfileImageFile(MultipartFile file) { * ๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‹ค๋ฌผ ์‚ญ์ œ. ์ €์žฅ๊ฐ’์ด key์ด๋ฏ€๋กœ ์Šคํ† ๋ฆฌ์ง€์— ๊ทธ๋Œ€๋กœ ๋„˜๊ธด๋‹ค โ€” ์‚ญ์ œ ์‹คํŒจ๊ฐ€ ํ”„๋กœํ•„ ๊ฐฑ์‹  * ์ž์ฒด๋ฅผ ๋ง‰์ง€๋Š” ์•Š๋Š”๋‹ค. */ - private void deleteOldProfileImage(String profileImageKey) { + private void deleteProfileImageFile(String profileImageKey, String operation) { try { storagePort.delete(profileImageKey); - log.info("๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ: {}", profileImageKey); + log.debug("{} ์™„๋ฃŒ: {}", operation, profileImageKey); } catch (RuntimeException e) { - log.warn("๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์‹คํŒจ: {}", e.getMessage()); + log.warn("{} ์‹คํŒจ - Key: {}, Cause: {}", operation, profileImageKey, e.getMessage()); } } @@ -152,16 +254,27 @@ private void deleteOldProfileImage(String profileImageKey) { * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public void deleteProfileImage(String email) { - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Query emailQuery = Query.query(Criteria.where("email").is(email.toLowerCase())); + Query imageQuery = Query.query(new Criteria().andOperator( + Criteria.where("email").is(email.toLowerCase()), + Criteria.where("profileImage").nin(null, ""))); + User previousUser = mongoOperations.findAndModify( + imageQuery, + new Update() + .set("profileImage", "") + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(false), + User.class); - if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); - user.setProfileImage(""); - user.setUpdatedAt(LocalDateTime.now()); - userRepository.save(user); - log.info("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ - User ID: {}", user.getId()); + if (previousUser == null) { + if (!mongoOperations.exists(emailQuery, User.class)) { + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return; } + + deleteProfileImageFile(previousUser.getProfileImage(), "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); + log.debug("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ - User ID: {}", previousUser.getId()); } /** @@ -173,7 +286,7 @@ public void deleteUserAccount(String email) { .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); + deleteProfileImageFile(user.getProfileImage(), "ํšŒ์› ํƒˆํ‡ด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); } userRepository.delete(user); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java new file mode 100644 index 00000000..02c4b730 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java @@ -0,0 +1,6 @@ +package com.ktb.chatapp.service.ratelimit; + +import java.time.Instant; + +public record RateLimitConsumption(int count, Instant expiresAt, boolean allowed) { +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java index 7a363d3b..a6be7d7f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java @@ -2,8 +2,16 @@ import com.ktb.chatapp.model.RateLimit; import com.ktb.chatapp.repository.RateLimitRepository; -import java.util.Optional; +import java.time.Duration; +import java.time.Instant; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Component; /** @@ -11,18 +19,50 @@ * Uses RateLimitRepository for persistence. */ @Component +@ConditionalOnProperty(name = "rate-limit.store.type", havingValue = "mongo") @RequiredArgsConstructor public class RateLimitMongoStore implements RateLimitStore { private final RateLimitRepository rateLimitRepository; + private final MongoTemplate mongoTemplate; @Override - public Optional findByClientId(String clientId) { - return rateLimitRepository.findByClientId(clientId); + public RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now) { + Instant expiresAt = now.plus(window); + + Query activeBelowLimit = Query.query(Criteria.where("clientId").is(clientId) + .and("expiresAt").gt(now).and("count").lt(maxRequests)); + RateLimit updated = mongoTemplate.findAndModify(activeBelowLimit, + new Update().inc("count", 1), + FindAndModifyOptions.options().returnNew(true), RateLimit.class); + if (updated != null) { + return result(updated, true); + } + + Query expired = Query.query(Criteria.where("clientId").is(clientId) + .and("expiresAt").lte(now)); + updated = mongoTemplate.findAndModify(expired, + new Update().set("count", 1).set("expiresAt", expiresAt), + FindAndModifyOptions.options().returnNew(true), RateLimit.class); + if (updated != null) { + return result(updated, true); + } + + RateLimit existing = rateLimitRepository.findByClientId(clientId).orElse(null); + if (existing != null) { + return result(existing, false); + } + + try { + RateLimit created = rateLimitRepository.insert(RateLimit.builder() + .clientId(clientId).count(1).expiresAt(expiresAt).build()); + return result(created, true); + } catch (DuplicateKeyException race) { + return consume(clientId, maxRequests, window, now); + } } - - @Override - public RateLimit save(RateLimit rateLimit) { - return rateLimitRepository.save(rateLimit); + + private RateLimitConsumption result(RateLimit value, boolean allowed) { + return new RateLimitConsumption(value.getCount(), value.getExpiresAt(), allowed); } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java new file mode 100644 index 00000000..fb8aa56c --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java @@ -0,0 +1,63 @@ +package com.ktb.chatapp.service.ratelimit; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "rate-limit.store.type", havingValue = "redis", matchIfMissing = true) +public class RateLimitRedisStore implements RateLimitStore { + + private static final String KEY_PREFIX = "rate_limit:"; + private static final DefaultRedisScript CONSUME_SCRIPT = new DefaultRedisScript<>(""" + local current = redis.call('GET', KEYS[1]) + if not current then + redis.call('SET', KEYS[1], 1, 'PX', ARGV[2]) + return {1, 1, tonumber(ARGV[2])} + end + + local ttl = redis.call('PTTL', KEYS[1]) + if tonumber(current) >= tonumber(ARGV[1]) then + return {0, tonumber(current), ttl} + end + + local count = redis.call('INCR', KEYS[1]) + return {1, count, ttl} + """, List.class); + + private final StringRedisTemplate redisTemplate; + + @Override + public RateLimitConsumption consume( + String clientId, int maxRequests, Duration window, Instant now) { + long windowMillis = Math.max(1L, window.toMillis()); + List result = redisTemplate.execute( + CONSUME_SCRIPT, + Collections.singletonList(KEY_PREFIX + clientId), + Integer.toString(maxRequests), + Long.toString(windowMillis)); + + if (result == null || result.size() != 3) { + throw new IllegalStateException("Redis rate limit script returned an invalid result"); + } + + boolean allowed = number(result.get(0)).longValue() == 1L; + int count = number(result.get(1)).intValue(); + long ttlMillis = Math.max(1L, number(result.get(2)).longValue()); + return new RateLimitConsumption(count, now.plusMillis(ttlMillis), allowed); + } + + private Number number(Object value) { + if (value instanceof Number number) { + return number; + } + throw new IllegalStateException("Redis rate limit script returned a non-numeric value"); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java index d30b50a8..8d6ce98d 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java @@ -1,27 +1,12 @@ package com.ktb.chatapp.service.ratelimit; -import com.ktb.chatapp.model.RateLimit; -import java.util.Optional; +import java.time.Duration; +import java.time.Instant; /** * Data store interface for rate limit storage. * Provides operations for storing and retrieving rate limit data. */ public interface RateLimitStore { - - /** - * Find rate limit by client ID - * - * @param clientId the client identifier - * @return Optional containing the RateLimit if found, empty otherwise - */ - Optional findByClientId(String clientId); - - /** - * Save or update rate limit - * - * @param rateLimit the rate limit to save - * @return the saved rate limit - */ - RateLimit save(RateLimit rateLimit); + RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java index e2725daa..9cfaef3c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java @@ -4,6 +4,12 @@ import com.ktb.chatapp.repository.SessionRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Component; /** @@ -11,27 +17,54 @@ * Uses SessionRepository for persistence. */ @Component +@ConditionalOnProperty(name = "session.store.type", havingValue = "mongo") @RequiredArgsConstructor public class SessionMongoStore implements SessionStore { - + private final SessionRepository sessionRepository; + private final MongoTemplate mongoTemplate; @Override public Optional findByUserId(String userId) { return sessionRepository.findByUserId(userId); } - + @Override public Session save(Session session) { return sessionRepository.save(session); } + + @Override + public Session replaceByUserId(Session session) { + Query query = Query.query(Criteria.where("userId").is(session.getUserId())); + Update update = new Update() + .set("userId", session.getUserId()) + .set("sessionId", session.getSessionId()) + .set("createdAt", session.getCreatedAt()) + .set("lastActivity", session.getLastActivity()) + .set("metadata", session.getMetadata()) + .set("expiresAt", session.getExpiresAt()); + + return mongoTemplate.findAndModify( + query, + update, + FindAndModifyOptions.options().upsert(true).returnNew(true), + Session.class + ); + } + + @Override + public Optional touch(String userId, String sessionId, long lastActivity, java.time.Instant expiresAt) { + Query query = Query.query(Criteria.where("userId").is(userId).and("sessionId").is(sessionId)); + Update update = new Update().set("lastActivity", lastActivity).set("expiresAt", expiresAt); + return Optional.ofNullable(mongoTemplate.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), Session.class)); + } @Override public void delete(String userId, String sessionId) { - Session session = sessionRepository.findByUserId(userId).orElse(null); - if (session != null && sessionId.equals(session.getSessionId())) { - sessionRepository.delete(session); - } + Query query = Query.query(Criteria.where("userId").is(userId).and("sessionId").is(sessionId)); + mongoTemplate.remove(query, Session.class); } @Override diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java new file mode 100644 index 00000000..a834b2c4 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java @@ -0,0 +1,136 @@ +package com.ktb.chatapp.service.session; + +import com.ktb.chatapp.model.Session; +import com.ktb.chatapp.service.SessionMetadata; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "session.store.type", havingValue = "redis", matchIfMissing = true) +public class SessionRedisStore implements SessionStore { + + private static final String KEY_PREFIX = "session:user:"; + private static final DefaultRedisScript REPLACE_SCRIPT = new DefaultRedisScript<>(""" + redis.call('HSET', KEYS[1], + 'userId', ARGV[1], 'sessionId', ARGV[2], + 'createdAt', ARGV[3], 'lastActivity', ARGV[4], + 'metadata', ARGV[5], 'expiresAt', ARGV[6]) + redis.call('PEXPIREAT', KEYS[1], ARGV[6]) + return 1 + """, Long.class); + private static final DefaultRedisScript TOUCH_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('HGET', KEYS[1], 'sessionId') ~= ARGV[1] then + return 0 + end + redis.call('HSET', KEYS[1], 'lastActivity', ARGV[2], 'expiresAt', ARGV[3]) + redis.call('PEXPIREAT', KEYS[1], ARGV[3]) + return 1 + """, Long.class); + private static final DefaultRedisScript DELETE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('HGET', KEYS[1], 'sessionId') ~= ARGV[1] then + return 0 + end + return redis.call('DEL', KEYS[1]) + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional findByUserId(String userId) { + Map values = redisTemplate.opsForHash().entries(key(userId)); + if (values.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toSession(values)); + } + + @Override + public Session save(Session session) { + return replaceByUserId(session); + } + + @Override + public Session replaceByUserId(Session session) { + redisTemplate.execute(REPLACE_SCRIPT, List.of(key(session.getUserId())), + session.getUserId(), session.getSessionId(), + Long.toString(session.getCreatedAt()), Long.toString(session.getLastActivity()), + serializeMetadata(session.getMetadata()), + Long.toString(session.getExpiresAt().toEpochMilli())); + return session; + } + + @Override + public Optional touch( + String userId, String sessionId, long lastActivity, Instant expiresAt) { + Long updated = redisTemplate.execute(TOUCH_SCRIPT, List.of(key(userId)), + sessionId, Long.toString(lastActivity), Long.toString(expiresAt.toEpochMilli())); + return updated != null && updated == 1L ? findByUserId(userId) : Optional.empty(); + } + + @Override + public void deleteAll(String userId) { + redisTemplate.delete(key(userId)); + } + + @Override + public void delete(String userId, String sessionId) { + redisTemplate.execute(DELETE_SCRIPT, List.of(key(userId)), sessionId); + } + + private Session toSession(Map values) { + String metadata = string(values, "metadata"); + return Session.builder() + .userId(string(values, "userId")) + .sessionId(string(values, "sessionId")) + .createdAt(Long.parseLong(string(values, "createdAt"))) + .lastActivity(Long.parseLong(string(values, "lastActivity"))) + .metadata(deserializeMetadata(metadata)) + .expiresAt(Instant.ofEpochMilli(Long.parseLong(string(values, "expiresAt")))) + .build(); + } + + private String key(String userId) { + return KEY_PREFIX + userId; + } + + private String string(Map values, String field) { + Object value = values.get(field); + if (value == null) { + throw new IllegalStateException("Redis session is missing field: " + field); + } + return value.toString(); + } + + private String serializeMetadata(SessionMetadata metadata) { + if (metadata == null) { + return ""; + } + try { + return objectMapper.writeValueAsString(metadata); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize session metadata", e); + } + } + + private SessionMetadata deserializeMetadata(String value) { + if (value.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(value, SessionMetadata.class); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to deserialize session metadata", e); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java index 49870236..1504729c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java @@ -8,7 +8,7 @@ * Provides operations for storing and retrieving session data. */ public interface SessionStore { - + /** * Find session by user ID * @@ -16,14 +16,18 @@ public interface SessionStore { * @return Optional containing the Session if found, empty otherwise */ Optional findByUserId(String userId); - + + Session save(Session session); + /** - * Save or update session + * Atomically replace or create the session identified by user ID. * - * @param session the session to save - * @return the saved session + * @param session the new active session + * @return the stored session */ - Session save(Session session); + Session replaceByUserId(Session session); + + Optional touch(String userId, String sessionId, long lastActivity, java.time.Instant expiresAt); /** * Delete all sessions for a user diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java new file mode 100644 index 00000000..7ee6aee9 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java @@ -0,0 +1,183 @@ +package com.ktb.chatapp.storage; + +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ContentDisposition; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") +public class S3Storage implements StoragePort { + + static final String PROFILE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + static final String CHAT_PREVIEW_CACHE_CONTROL = "private, max-age=300"; + static final String PRIVATE_NO_STORE_CACHE_CONTROL = + "private, no-cache, no-store, must-revalidate"; + + private final S3Client s3Client; + private final S3Presigner presigner; + private final String bucket; + private final String keyPrefix; + private final Duration fallbackPresignTtl; + + public S3Storage( + S3Client s3Client, + S3Presigner presigner, + @Value("${app.s3.bucket}") String bucket, + @Value("${app.s3.key-prefix:}") String keyPrefix, + @Value("${app.s3.presign-ttl:10m}") Duration fallbackPresignTtl) { + if (!StringUtils.hasText(bucket)) { + throw new IllegalStateException("S3_BUCKET์€ file.storage.type=s3์ผ ๋•Œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.s3Client = s3Client; + this.presigner = presigner; + this.bucket = bucket; + this.keyPrefix = normalizePrefix(keyPrefix); + this.fallbackPresignTtl = fallbackPresignTtl; + } + + @Override + public StoredObject put(InputStream content, String key, String contentType, long size) { + String objectKey = objectKey(key); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .contentLength(size) + .cacheControl(StorageKey.isProfile(key) ? PROFILE_CACHE_CONTROL : CHAT_PREVIEW_CACHE_CONTROL) + .build(); + s3Client.putObject(request, RequestBody.fromInputStream(content, size)); + return new StoredObject(key, size); + } + + @Override + public Optional open(String key) { + try { + s3Client.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(key)).build()); + URI location = offloadUrl(key, fallbackPresignTtl, ContentDisposition.inline().build()) + .orElseThrow(); + return Optional.of(new UrlResource(location)); + } catch (NoSuchKeyException e) { + return Optional.empty(); + } catch (MalformedURLException e) { + throw new IllegalStateException("S3 presigned URL์„ Resource๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", e); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return Optional.empty(); + } + throw e; + } + } + + @Override + public void delete(String key) { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .build()); + } + + @Override + public Optional offloadUrl(String key, Duration ttl, ContentDisposition disposition) { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .responseContentDisposition(disposition.toString()) + .responseCacheControl(disposition.isAttachment() + ? PRIVATE_NO_STORE_CACHE_CONTROL + : CHAT_PREVIEW_CACHE_CONTROL) + .build(); + return Optional.of(presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(getRequest) + .build()) + .url() + .toExternalForm()) + .map(URI::create); + } + + @Override + public Optional presignUploadUrl( + String key, + String contentType, + Duration ttl + ) { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .contentType(contentType) + .cacheControl(StorageKey.isChat(key) ? CHAT_PREVIEW_CACHE_CONTROL : PROFILE_CACHE_CONTROL) + .build(); + + return Optional.of( + presigner.presignPutObject( + PutObjectPresignRequest.builder() + .signatureDuration(ttl) + .putObjectRequest(putRequest) + .build() + ).url().toExternalForm() + ).map(URI::create); + } + + @Override + public Optional stat(String key) { + try { + HeadObjectResponse response = + s3Client.headObject( + HeadObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .build() + ); + + return Optional.of( + new StoredObjectMetadata( + response.contentLength(), + response.contentType() + ) + ); + + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return Optional.empty(); + } + throw e; + } + } + + private String objectKey(String key) { + return keyPrefix + key; + } + + private static String normalizePrefix(String prefix) { + if (!StringUtils.hasText(prefix)) { + return ""; + } + String normalized = prefix.strip(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized.endsWith("/") ? normalized : normalized + "/"; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java index 78fe2362..dda88eed 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java @@ -19,6 +19,14 @@ public static String chat(String fileName) { return CHAT_PREFIX + fileName; } + public static String chat(String userId, String fileName) { + return CHAT_PREFIX + userId + "/" + fileName; + } + + public static String chatUserPrefix(String userId) { + return CHAT_PREFIX + userId + "/"; + } + public static boolean isProfile(String key) { return key != null && key.startsWith(PROFILE_PREFIX); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java index ea492de8..726e51d9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java @@ -20,4 +20,16 @@ public interface StoragePort { default Optional offloadUrl(String key, Duration ttl, ContentDisposition disposition) { return Optional.empty(); } + + default Optional presignUploadUrl( + String key, + String contentType, + Duration ttl + ) { + return Optional.empty(); + } + + default Optional stat(String key) { + return Optional.empty(); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java new file mode 100644 index 00000000..30f6b912 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java @@ -0,0 +1,7 @@ +package com.ktb.chatapp.storage; + +public record StoredObjectMetadata( + long size, + String contentType +) { +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/util/BannedWordChecker.java b/apps/backend/src/main/java/com/ktb/chatapp/util/BannedWordChecker.java index 2e0e20f7..064b1aa8 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/util/BannedWordChecker.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/util/BannedWordChecker.java @@ -1,29 +1,94 @@ package com.ktb.chatapp.util; +import java.util.ArrayDeque; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.stream.Collectors; import org.springframework.util.Assert; public class BannedWordChecker { - - private final Set bannedWords; - + + private final Node root = new Node(); + public BannedWordChecker(Set bannedWords) { - this.bannedWords = + Set normalizedWords = bannedWords.stream() .filter(word -> word != null && !word.isBlank()) .map(word -> word.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); - Assert.notEmpty(this.bannedWords, "Banned words set must not be empty"); + Assert.notEmpty(normalizedWords, "Banned words set must not be empty"); + + normalizedWords.forEach(this::insert); + buildFailureLinks(); } - + public boolean containsBannedWord(String message) { if (message == null || message.isBlank()) { return false; } - + String normalizedMessage = message.toLowerCase(Locale.ROOT); - return bannedWords.stream().anyMatch(normalizedMessage::contains); + Node node = root; + + for (int i = 0; i < normalizedMessage.length(); i++) { + char current = normalizedMessage.charAt(i); + + while (node != root && !node.children.containsKey(current)) { + node = node.failure; + } + + node = node.children.getOrDefault(current, root); + if (node.terminal) { + return true; + } + } + + return false; + } + + private void insert(String word) { + Node node = root; + for (int i = 0; i < word.length(); i++) { + char current = word.charAt(i); + node = node.children.computeIfAbsent(current, ignored -> new Node()); + } + node.terminal = true; + } + + private void buildFailureLinks() { + root.failure = root; + Queue queue = new ArrayDeque<>(); + + for (Node child : root.children.values()) { + child.failure = root; + queue.add(child); + } + + while (!queue.isEmpty()) { + Node node = queue.remove(); + + for (Map.Entry entry : node.children.entrySet()) { + char current = entry.getKey(); + Node child = entry.getValue(); + Node fallback = node.failure; + + while (fallback != root && !fallback.children.containsKey(current)) { + fallback = fallback.failure; + } + + child.failure = fallback.children.getOrDefault(current, root); + child.terminal = child.terminal || child.failure.terminal; + queue.add(child); + } + } + } + + private static final class Node { + private final Map children = new HashMap<>(); + private Node failure; + private boolean terminal; } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java b/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java index 4c892dcc..5ead59ab 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java @@ -87,6 +87,56 @@ public static void validateFile(MultipartFile file) { } } + public static void validateFileMetadata( + String originalFilename, + String contentType, + long size + ) { + if (originalFilename == null || originalFilename.trim().isEmpty()) { + throw new RuntimeException("ํŒŒ์ผ๋ช…์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + int filenameBytes = + originalFilename.getBytes(StandardCharsets.UTF_8).length; + + if (filenameBytes > 255) { + throw new RuntimeException("ํŒŒ์ผ๋ช…์ด ๋„ˆ๋ฌด ๊น๋‹ˆ๋‹ค."); + } + + if (contentType == null || !ALLOWED_TYPES.containsKey(contentType)) { + throw new RuntimeException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); + } + + String extension = + getFileExtension(originalFilename).toLowerCase(); + + List allowedExtensions = + ALLOWED_TYPES.get(contentType); + + if (allowedExtensions == null || + !allowedExtensions.contains(extension)) { + throw new RuntimeException("ํŒŒ์ผ ํ™•์žฅ์ž๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + String type = contentType.split("/")[0]; + + long limit = FILE_SIZE_LIMITS.getOrDefault( + type, + FILE_SIZE_LIMITS.get("application") + ); + + if (size <= 0 || size > limit) { + int limitInMB = (int) (limit / 1024 / 1024); + + throw new RuntimeException( + getFileType(contentType) + + " ํŒŒ์ผ์€ " + + limitInMB + + "MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ); + } + } + /** * ํŒŒ์ผ ํƒ€์ž… ํ•œ๊ธ€๋ช… ๋ฐ˜ํ™˜ */ diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java index 6357e1c6..311a3d29 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java @@ -9,6 +9,7 @@ import com.ktb.chatapp.service.SessionService; import com.ktb.chatapp.service.SessionValidationResult; import com.ktb.chatapp.websocket.socketio.handler.ConnectionLoginHandler; +import com.ktb.chatapp.websocket.socketio.handler.ChatMessageHandler; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -60,6 +61,7 @@ public AuthTokenResult getAuthTokenResult(Object _authToken, SocketIOClient clie log.error("Session validation failed: {}", validationResult.getMessage()); return new AuthTokenResult(false, Map.of("message", "Invalid session")); } + client.set(ChatMessageHandler.SESSION_VALIDATED_AT, System.currentTimeMillis()); // Load user from database User user = userRepository.findById(userId).orElse(null); @@ -68,9 +70,11 @@ public AuthTokenResult getAuthTokenResult(Object _authToken, SocketIOClient clie return new AuthTokenResult(false, Map.of("message", "User not found")); } - log.info("Socket.IO connection authorized for user: {} ({})", user.getName(), userId); + log.debug("Socket.IO connection authorized for user: {} ({})", user.getName(), userId); - var socketUser = new SocketUser(user.getId(), user.getName(), sessionId, client.getSessionId().toString()); + var socketUser = new SocketUser( + user.getId(), user.getName(), sessionId, client.getSessionId().toString(), + user.getEmail(), user.getProfileImage()); socketIOChatHandlerProvider.getObject().onConnect(client, socketUser); return AuthTokenResult.AuthTokenResultSuccess; } catch (Exception e) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java index 4766e052..4b562e4f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java @@ -25,6 +25,12 @@ public interface ChatDataStore { * @param value the value to store */ void set(String key, Object value); + + default T getAndSet(String key, T value, Class type) { + T previous = get(key, type).orElse(null); + set(key, value); + return previous; + } /** * Delete a value by key @@ -32,6 +38,45 @@ public interface ChatDataStore { * @param key the storage key */ void delete(String key); + + default boolean refresh(String key) { + return get(key, Object.class).isPresent(); + } + + default boolean compareAndDelete(String key, Object expectedValue) { + Optional current = get(key, Object.class); + if (current.isPresent() && current.get().equals(expectedValue)) { + delete(key); + return true; + } + return false; + } + + default java.util.Set getSet(String key) { + return get(key, java.util.Set.class) + .map(value -> new java.util.HashSet<>((java.util.Set) value)) + .orElseGet(java.util.HashSet::new); + } + + default void addToSet(String key, String value) { + java.util.Set values = getSet(key); + values.add(value); + set(key, values); + } + + default boolean setContains(String key, String value) { + return getSet(key).contains(value); + } + + default void removeFromSet(String key, String value) { + java.util.Set values = getSet(key); + values.remove(value); + if (values.isEmpty()) { + delete(key); + } else { + set(key, values); + } + } int size(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java index d4c4e9c3..1ae2a382 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java @@ -20,10 +20,25 @@ public SocketUser get(String userId) { public void set(String userId, SocketUser sockerUser) { chatDataStore.set(buildKey(userId), sockerUser); } + + public SocketUser replace(String userId, SocketUser socketUser) { + return chatDataStore.getAndSet(buildKey(userId), socketUser, SocketUser.class); + } public void del(String userId) { chatDataStore.delete(buildKey(userId)); } + + public boolean delIfCurrent(String userId, SocketUser socketUser) { + return chatDataStore.compareAndDelete(buildKey(userId), socketUser); + } + + public boolean refreshIfCurrent(SocketUser socketUser) { + if (socketUser == null || !socketUser.equals(get(socketUser.id()))) { + return false; + } + return chatDataStore.refresh(buildKey(socketUser.id())); + } public int size() { return chatDataStore.size(); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java index 3fbb7efb..1fd92993 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java @@ -1,6 +1,8 @@ package com.ktb.chatapp.websocket.socketio; import java.util.Optional; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** @@ -29,6 +31,42 @@ public Optional get(String key, Class type) { public void set(String key, Object value) { storage.put(key, value); } + + @Override + public T getAndSet(String key, T value, Class type) { + Object previous = storage.put(key, value); + return type.isInstance(previous) ? type.cast(previous) : null; + } + + @Override + public boolean compareAndDelete(String key, Object expectedValue) { + return storage.remove(key, expectedValue); + } + + @Override + public Set getSet(String key) { + Object value = storage.get(key); + return value instanceof Set set ? new HashSet<>((Set) set) : new HashSet<>(); + } + + @Override + public void addToSet(String key, String value) { + storage.compute(key, (ignored, current) -> { + Set values = current instanceof Set set + ? new HashSet<>((Set) set) : new HashSet<>(); + values.add(value); + return values; + }); + } + + @Override + public void removeFromSet(String key, String value) { + storage.computeIfPresent(key, (ignored, current) -> { + Set values = new HashSet<>((Set) current); + values.remove(value); + return values.isEmpty() ? null : values; + }); + } @Override public void delete(String key) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java new file mode 100644 index 00000000..5a40b456 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java @@ -0,0 +1,54 @@ +package com.ktb.chatapp.websocket.socketio; + +import com.corundumstudio.socketio.SocketIOClient; +import com.corundumstudio.socketio.SocketIOServer; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Keeps Redis presence alive while the owning Socket.IO connection responds to pings. */ +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class PresenceLeaseRefresher { + + static final String LAST_PRESENCE_REFRESH_AT = "lastPresenceRefreshAt"; + + private final ConnectedUsers connectedUsers; + private final long refreshIntervalMillis; + + public PresenceLeaseRefresher( + SocketIOServer socketIOServer, + ConnectedUsers connectedUsers, + @Value("${socketio.presence-refresh-interval:5m}") Duration refreshInterval) { + this.connectedUsers = connectedUsers; + this.refreshIntervalMillis = Math.max(0, refreshInterval.toMillis()); + socketIOServer.addPongListener(this::onPong); + } + + void onPong(SocketIOClient client) { + SocketUser socketUser = client.get("user"); + if (socketUser == null || !shouldRefreshPresence(client)) { + return; + } + + if (connectedUsers.refreshIfCurrent(socketUser)) { + client.set(LAST_PRESENCE_REFRESH_AT, System.currentTimeMillis()); + return; + } + + if (log.isDebugEnabled()) { + log.debug("Skipped stale presence lease refresh: userId={}, socketId={}", + socketUser.id(), socketUser.socketId()); + } + } + + private boolean shouldRefreshPresence(SocketIOClient client) { + Long lastRefreshAt = client.get(LAST_PRESENCE_REFRESH_AT); + return lastRefreshAt == null + || refreshIntervalMillis == 0 + || System.currentTimeMillis() - lastRefreshAt >= refreshIntervalMillis; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java new file mode 100644 index 00000000..73be0a13 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java @@ -0,0 +1,85 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.time.Duration; +import java.util.Optional; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "socketio.store.type", havingValue = "redis", matchIfMissing = true) +public class RedisChatDataStore implements ChatDataStore { + + private static final Duration PRESENCE_TTL = Duration.ofMinutes(35); + private final RedissonClient redissonClient; + + public RedisChatDataStore(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Override + public Optional get(String key, Class type) { + Object value = redissonClient.getBucket(key).get(); + return type.isInstance(value) ? Optional.of(type.cast(value)) : Optional.empty(); + } + + @Override + public void set(String key, Object value) { + redissonClient.getBucket(key).set(value, PRESENCE_TTL); + } + + @Override + public T getAndSet(String key, T value, Class type) { + Object previous = redissonClient.getBucket(key).getAndSet(value, PRESENCE_TTL); + return type.isInstance(previous) ? type.cast(previous) : null; + } + + @Override + public void delete(String key) { + redissonClient.getBucket(key).delete(); + } + + @Override + public boolean refresh(String key) { + return redissonClient.getBucket(key).expire(PRESENCE_TTL); + } + + @Override + public boolean compareAndDelete(String key, Object expectedValue) { + return redissonClient.getBucket(key).compareAndSet(expectedValue, null); + } + + @Override + public java.util.Set getSet(String key) { + return redissonClient.getSet(key).readAll(); + } + + @Override + public void addToSet(String key, String value) { + var values = redissonClient.getSet(key); + values.add(value); + } + + @Override + public boolean setContains(String key, String value) { + return redissonClient.getSet(key).contains(value); + } + + @Override + public void removeFromSet(String key, String value) { + var values = redissonClient.getSet(key); + values.remove(value); + if (values.isEmpty()) { + values.delete(); + } + } + + @Override + public int size() { + int count = 0; + for (String ignored : redissonClient.getKeys().getKeysByPattern("conn_users:userid:*")) { + count++; + } + return count; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListener.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListener.java index ca61df4e..67e74db1 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListener.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListener.java @@ -1,5 +1,6 @@ package com.ktb.chatapp.websocket.socketio; +import com.corundumstudio.socketio.BroadcastOperations; import com.corundumstudio.socketio.SocketIOServer; import com.ktb.chatapp.event.*; import java.util.Map; @@ -39,7 +40,7 @@ public void handleSessionEndedEvent(SessionEndedEvent event) { public void handleRoomCreatedEvent(RoomCreatedEvent event) { try { socketIOServer.getRoomOperations(ROOM_LIST).sendEvent(ROOM_CREATED, event.getRoomResponse()); - log.info("roomCreated ์ด๋ฒคํŠธ ๋ฐœ์†ก: roomId={}", event.getRoomResponse().getId()); + log.debug("roomCreated ์ด๋ฒคํŠธ ๋ฐœ์†ก: roomId={}", event.getRoomResponse().getId()); } catch (Exception e) { log.error("roomCreated ์ด๋ฒคํŠธ ๋ฐœ์†ก ์‹คํŒจ", e); } @@ -49,7 +50,7 @@ public void handleRoomCreatedEvent(RoomCreatedEvent event) { public void handleRoomUpdatedEvent(RoomUpdatedEvent event) { try { socketIOServer.getRoomOperations(ROOM_LIST).sendEvent(ROOM_UPDATE, event.getRoomResponse()); - log.info("roomUpdate ์ด๋ฒคํŠธ ๋ฐœ์†ก: roomId={}", event.getRoomId()); + log.debug("roomUpdate ์ด๋ฒคํŠธ ๋ฐœ์†ก: roomId={}", event.getRoomId()); } catch (Exception e) { log.error("roomUpdate ์ด๋ฒคํŠธ ๋ฐœ์†ก ์‹คํŒจ: roomId={}", event.getRoomId(), e); } @@ -58,10 +59,10 @@ public void handleRoomUpdatedEvent(RoomUpdatedEvent event) { @EventListener public void handleRoomActivityEvent(RoomActivityEvent event) { try { - socketIOServer.getRoomOperations(ROOM_LIST).sendEvent(ROOM_ACTIVITY, Map.of( - "_id", event.getRoomId(), - "recentMessageCount", event.getRecentMessageCount() - )); + sendRoomActivity( + socketIOServer.getRoomOperations(ROOM_LIST), + event.getRoomId(), + event.getRecentMessageCount()); log.debug("roomActivity ์ด๋ฒคํŠธ ๋ฐœ์†ก: roomId={}, recentMessageCount={}", event.getRoomId(), event.getRecentMessageCount()); } catch (Exception e) { @@ -69,6 +70,28 @@ public void handleRoomActivityEvent(RoomActivityEvent event) { } } + @EventListener + public void handleRoomActivitiesEvent(RoomActivitiesEvent event) { + try { + BroadcastOperations roomListOperations = socketIOServer.getRoomOperations(ROOM_LIST); + event.getRecentMessageCounts().forEach((roomId, recentMessageCount) -> + sendRoomActivity(roomListOperations, roomId, recentMessageCount)); + log.debug("roomActivity batch event sent: count={}", event.getRecentMessageCounts().size()); + } catch (Exception e) { + log.error("roomActivity batch event failed: roomIds={}", event.getRecentMessageCounts().keySet(), e); + } + } + + private void sendRoomActivity( + BroadcastOperations roomListOperations, + String roomId, + int recentMessageCount) { + roomListOperations.sendEvent(ROOM_ACTIVITY, Map.of( + "_id", roomId, + "recentMessageCount", recentMessageCount + )); + } + @EventListener public void handleAiMessageStartEvent(AiMessageStartEvent event) { try { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEvents.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEvents.java index 0d1367e8..d3526ce3 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEvents.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketIOEvents.java @@ -35,6 +35,10 @@ private SocketIOEvents() { */ public static final String LEAVE_ROOM = "leaveRoom"; + /** Subscribe/unsubscribe to global room-list updates while that screen is visible. */ + public static final String JOIN_ROOM_LIST = "joinRoomList"; + public static final String LEAVE_ROOM_LIST = "leaveRoomList"; + /** * ์ด์ „ ๋ฉ”์‹œ์ง€ ๋กœ๋“œ ์š”์ฒญ * Payload: { roomId, before } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java index 5c29a4d4..bd8910b6 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java @@ -7,5 +7,15 @@ * @param authSessionId user auth session id * @param socketId user websocket session id */ -public record SocketUser(String id, String name, String authSessionId, String socketId) { +public record SocketUser( + String id, + String name, + String authSessionId, + String socketId, + String email, + String profileImage) { + + public SocketUser(String id, String name, String authSessionId, String socketId) { + this(id, name, authSessionId, socketId, null, null); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java new file mode 100644 index 00000000..dfc2225f --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java @@ -0,0 +1,34 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.util.concurrent.locks.ReentrantLock; +import org.springframework.stereotype.Component; + +/** + * Serializes connection lifecycle changes for the same user. + * + *

A fixed number of stripes avoids retaining one lock per user forever while + * still allowing unrelated users to connect and disconnect concurrently.

+ */ +@Component +public class UserConnectionLocks { + + private static final int STRIPE_COUNT = 256; + + private final ReentrantLock[] locks = new ReentrantLock[STRIPE_COUNT]; + + public UserConnectionLocks() { + for (int i = 0; i < locks.length; i++) { + locks[i] = new ReentrantLock(); + } + } + + public void withLock(String userId, Runnable action) { + ReentrantLock lock = locks[Math.floorMod(userId.hashCode(), locks.length)]; + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java index bcacb5a4..cb5365d9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java @@ -1,6 +1,5 @@ package com.ktb.chatapp.websocket.socketio; -import java.util.HashSet; import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,9 +27,7 @@ public class UserRooms { */ @SuppressWarnings("unchecked") public Set get(String userId) { - return chatDataStore.get(buildKey(userId), Set.class) - .map(obj -> (Set) obj) - .orElse(new HashSet<>()); + return chatDataStore.getSet(buildKey(userId)); } /** @@ -40,9 +37,7 @@ public Set get(String userId) { * @param roomId the room ID to add to the user's room set */ public void add(String userId, String roomId) { - Set rooms = new HashSet<>(get(userId)); - rooms.add(roomId); - chatDataStore.set(buildKey(userId), rooms); + chatDataStore.addToSet(buildKey(userId), roomId); } /** @@ -52,13 +47,7 @@ public void add(String userId, String roomId) { * @param roomId the room ID to remove */ public void remove(String userId, String roomId) { - Set rooms = new HashSet<>(get(userId)); - rooms.remove(roomId); - if (rooms.isEmpty()) { - chatDataStore.delete(buildKey(userId)); - } else { - chatDataStore.set(buildKey(userId), rooms); - } + chatDataStore.removeFromSet(buildKey(userId), roomId); } /** @@ -78,7 +67,7 @@ public void clear(String userId) { * @return true if the user is in the room, false otherwise */ public boolean isInRoom(String userId, String roomId) { - return get(userId).contains(roomId); + return chatDataStore.setContains(buildKey(userId), roomId); } private String buildKey(String userId) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java index 70642f44..45c92053 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java @@ -11,6 +11,7 @@ import com.ktb.chatapp.websocket.socketio.handler.StreamingSession; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -50,9 +51,9 @@ public void handleAIMentions(String roomId, String userId, MessageContent messag private void startStreaming(String roomId, String userId, String aiType, String query) { - // AI ์ŠคํŠธ๋ฆฌ๋ฐ ์„ธ์…˜ ์ƒ์„ฑ - messageId๋Š” ํƒ€์ž…๊ณผ ํƒ€์ž„์Šคํƒฌํ”„ ์กฐํ•ฉ + // ์ธ์Šคํ„ด์Šค์™€ ์‹œ๊ณ„์— ๋…๋ฆฝ์ ์ธ ์ „์—ญ ์‹๋ณ„์ž๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. var timestamp = System.currentTimeMillis(); - String messageId = aiType + "-" + timestamp; + String messageId = UUID.randomUUID().toString(); log.info("AI response started - messageId: {}, room: {}, aiType: {}, query: {}", messageId, roomId, aiType, query); @@ -123,6 +124,7 @@ public void onAiMessageCompleteEvent(AiMessageCompleteEvent event) { private Message getMessage(AiMessageCompleteEvent event) { Message aiMessage = new Message(); + aiMessage.setId(event.getMessageId()); aiMessage.setRoomId(event.getRoomId()); aiMessage.setContent(event.getContent()); aiMessage.setType(MessageType.ai); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java index 08ae04c2..2545be03 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java @@ -12,7 +12,6 @@ import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.util.BannedWordChecker; import com.ktb.chatapp.websocket.socketio.ai.AiService; import com.ktb.chatapp.service.RoomActivityNotifier; @@ -21,12 +20,14 @@ import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -39,17 +40,24 @@ @ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) @RequiredArgsConstructor public class ChatMessageHandler { + public static final String SESSION_VALIDATED_AT = "sessionValidatedAt"; + private static final long SESSION_VALIDATION_LEASE_MILLIS = Duration.ofSeconds(5).toMillis(); + private final SocketIOServer socketIOServer; private final MessageRepository messageRepository; - private final RoomRepository roomRepository; - private final UserRepository userRepository; private final FileRepository fileRepository; private final AiService aiService; private final SessionService sessionService; private final RoomActivityNotifier roomActivityNotifier; private final BannedWordChecker bannedWordChecker; private final RateLimitService rateLimitService; + private final UserRooms userRooms; + private final RoomRepository roomRepository; private final MeterRegistry meterRegistry; + private final Map processingTimers = new ConcurrentHashMap<>(); + private final Map successCounters = new ConcurrentHashMap<>(); + private final Map errorCounters = new ConcurrentHashMap<>(); + private final Map rateLimitCounters = new ConcurrentHashMap<>(); @OnEvent(CHAT_MESSAGE) public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { @@ -77,16 +85,21 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { return; } - SessionValidationResult validation = - sessionService.validateSession(socketUser.id(), socketUser.authSessionId()); - if (!validation.isValid()) { - recordError("session_expired"); - client.sendEvent(ERROR, Map.of( - "code", "SESSION_EXPIRED", - "message", "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”." - )); - timerSample.stop(createTimer("error", "session_expired")); - return; + Long sessionValidatedAt = client.get(SESSION_VALIDATED_AT); + long now = System.currentTimeMillis(); + if (sessionValidatedAt == null || now - sessionValidatedAt >= SESSION_VALIDATION_LEASE_MILLIS) { + SessionValidationResult validation = + sessionService.validateSession(socketUser.id(), socketUser.authSessionId()); + if (!validation.isValid()) { + recordError("session_expired"); + client.sendEvent(ERROR, Map.of( + "code", "SESSION_EXPIRED", + "message", "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”." + )); + timerSample.stop(createTimer("error", "session_expired")); + return; + } + client.set(SESSION_VALIDATED_AT, now); } // Rate limit check @@ -94,10 +107,7 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { rateLimitService.checkRateLimit(socketUser.id(), 10000, Duration.ofMinutes(1)); if (!rateLimitResult.allowed()) { recordError("rate_limit_exceeded"); - Counter.builder("socketio.messages.rate_limit") - .description("Socket.IO rate limit exceeded count") - .register(meterRegistry) - .increment(); + rateLimitCounter().increment(); client.sendEvent(ERROR, Map.of( "code", "RATE_LIMIT_EXCEEDED", "message", "๋ฉ”์‹œ์ง€ ์ „์†ก ํšŸ์ˆ˜ ์ œํ•œ์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", @@ -110,20 +120,8 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } try { - User sender = userRepository.findById(socketUser.id()).orElse(null); - if (sender == null) { - recordError("user_not_found"); - client.sendEvent(ERROR, Map.of( - "code", "MESSAGE_ERROR", - "message", "User not found" - )); - timerSample.stop(createTimer("error", "user_not_found")); - return; - } - String roomId = data.getRoom(); - Room room = roomRepository.findById(roomId).orElse(null); - if (room == null || !room.getParticipantIds().contains(socketUser.id())) { + if (!hasRoomAccess(socketUser.id(), roomId)) { recordError("room_access_denied"); client.sendEvent(ERROR, Map.of( "code", "MESSAGE_ERROR", @@ -149,11 +147,12 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } String messageType = data.getMessageType(); - Message message = switch (messageType) { + MessageBuildResult buildResult = switch (messageType) { case "file" -> handleFileMessage(roomId, socketUser.id(), messageContent, data.getFileData()); - case "text" -> handleTextMessage(roomId, socketUser.id(), messageContent); + case "text" -> new MessageBuildResult(handleTextMessage(roomId, socketUser.id(), messageContent), null); default -> throw new IllegalArgumentException("Unsupported message type: " + messageType); }; + Message message = buildResult.message(); if (message == null) { log.warn("Empty message - ignoring. room: {}, userId: {}, messageType: {}", roomId, socketUser.id(), messageType); @@ -162,18 +161,16 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } Message savedMessage = messageRepository.save(message); - MessageResponse messageResponse = createMessageResponse(savedMessage, sender); + MessageResponse messageResponse = createMessageResponse(savedMessage, socketUser, buildResult.file()); socketIOServer.getRoomOperations(roomId) .sendEvent(MESSAGE, messageResponse); - client.sendEvent(MESSAGE, messageResponse); roomActivityNotifier.notifyMessageStored(roomId); // AI ๋ฉ˜์…˜ ์ฒ˜๋ฆฌ aiService.handleAIMentions(roomId, socketUser.id(), messageContent); - sessionService.updateLastActivity(socketUser.id()); // Record success metrics recordMessageSuccess(messageType); @@ -193,7 +190,17 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } } - private Message handleFileMessage(String roomId, String userId, MessageContent messageContent, Map fileData) { + private boolean hasRoomAccess(String userId, String roomId) { + if (roomId == null || roomId.isBlank()) { + return false; + } + if (userRooms.isInRoom(userId, roomId)) { + return true; + } + return roomRepository.existsByIdAndParticipantIdsContaining(roomId, userId); + } + + private MessageBuildResult handleFileMessage(String roomId, String userId, MessageContent messageContent, Map fileData) { if (fileData == null || fileData.get("_id") == null) { throw new IllegalArgumentException("ํŒŒ์ผ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } @@ -221,7 +228,7 @@ private Message handleFileMessage(String roomId, String userId, MessageContent m metadata.put("originalName", file.getOriginalname()); message.setMetadata(metadata); - return message; + return new MessageBuildResult(message, file); } private Message handleTextMessage(String roomId, String userId, MessageContent messageContent) { @@ -240,7 +247,7 @@ private Message handleTextMessage(String roomId, String userId, MessageContent m return message; } - private MessageResponse createMessageResponse(Message message, User sender) { + private MessageResponse createMessageResponse(Message message, SocketUser sender, File knownFile) { var messageResponse = new MessageResponse(); messageResponse.setId(message.getId()); messageResponse.setRoomId(message.getRoomId()); @@ -251,7 +258,9 @@ private MessageResponse createMessageResponse(Message message, User sender) { messageResponse.setSender(UserResponse.from(sender)); messageResponse.setMetadata(message.getMetadata()); - if (message.getFileId() != null) { + if (knownFile != null) { + messageResponse.setFile(FileResponse.from(knownFile)); + } else if (message.getFileId() != null) { fileRepository.findById(message.getFileId()) .ifPresent(file -> messageResponse.setFile(FileResponse.from(file))); } @@ -259,29 +268,43 @@ private MessageResponse createMessageResponse(Message message, User sender) { return messageResponse; } + private record MessageBuildResult(Message message, File file) { + } + // Metrics helper methods private Timer createTimer(String status, String messageType) { - return Timer.builder("socketio.messages.processing.time") - .description("Socket.IO message processing time") - .tag("status", status) - .tag("message_type", messageType) - .register(meterRegistry); + String key = status + ":" + messageType; + return processingTimers.computeIfAbsent(key, ignored -> + Timer.builder("socketio.messages.processing.time") + .description("Socket.IO message processing time") + .tag("status", status) + .tag("message_type", messageType) + .register(meterRegistry)); } private void recordMessageSuccess(String messageType) { - Counter.builder("socketio.messages.total") - .description("Total Socket.IO messages processed") - .tag("status", "success") - .tag("message_type", messageType) - .register(meterRegistry) + successCounters.computeIfAbsent(messageType, ignored -> + Counter.builder("socketio.messages.total") + .description("Total Socket.IO messages processed") + .tag("status", "success") + .tag("message_type", messageType) + .register(meterRegistry)) .increment(); } private void recordError(String errorType) { - Counter.builder("socketio.messages.errors") - .description("Socket.IO message processing errors") - .tag("error_type", errorType) - .register(meterRegistry) + errorCounters.computeIfAbsent(errorType, ignored -> + Counter.builder("socketio.messages.errors") + .description("Socket.IO message processing errors") + .tag("error_type", errorType) + .register(meterRegistry)) .increment(); } + + private Counter rateLimitCounter() { + return rateLimitCounters.computeIfAbsent("exceeded", ignored -> + Counter.builder("socketio.messages.rate_limit") + .description("Socket.IO rate limit exceeded count") + .register(meterRegistry)); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java index aa09d64b..67e940d4 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java @@ -5,13 +5,13 @@ import com.corundumstudio.socketio.annotation.OnDisconnect; import com.ktb.chatapp.websocket.socketio.ConnectedUsers; import com.ktb.chatapp.websocket.socketio.SocketUser; -import com.ktb.chatapp.websocket.socketio.UserRooms; +import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import java.time.Duration; import java.util.Map; import java.util.Set; -import java.util.UUID; +import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -30,26 +30,20 @@ public class ConnectionLoginHandler { private final SocketIOServer socketIOServer; private final ConnectedUsers connectedUsers; - private final UserRooms userRooms; - private final RoomJoinHandler roomJoinHandler; - private final RoomLeaveHandler roomLeaveHandler; + private final UserConnectionLocks userConnectionLocks; public ConnectionLoginHandler( SocketIOServer socketIOServer, ConnectedUsers connectedUsers, - UserRooms userRooms, - RoomJoinHandler roomJoinHandler, - RoomLeaveHandler roomLeaveHandler, + UserConnectionLocks userConnectionLocks, MeterRegistry meterRegistry) { this.socketIOServer = socketIOServer; this.connectedUsers = connectedUsers; - this.userRooms = userRooms; - this.roomJoinHandler = roomJoinHandler; - this.roomLeaveHandler = roomLeaveHandler; + this.userConnectionLocks = userConnectionLocks; // Register gauge metric for concurrent users - Gauge.builder("socketio.concurrent.users", connectedUsers::size) - .description("Current number of concurrent Socket.IO users") + Gauge.builder("socketio.concurrent.connections", this::localConnectionCount) + .description("Current Socket.IO connections on this instance") .register(meterRegistry); } @@ -60,21 +54,22 @@ public void onConnect(SocketIOClient client, SocketUser user) { String userId = user.id(); try { - // ๋‹ค๋ฅธ ๋…ธ๋“œ์— ์ ‘์†๋œ ์‚ฌ์šฉ์ž๋Š” ํ†ต๋ณด ๋ถˆ๊ฐ€ - notifyDuplicateLogin(client, userId); client.set("user", user); - - userRooms.get(userId).forEach(roomId -> { - // ์žฌ์ ‘์† ์‹œ ๊ธฐ์กด ์ฐธ์—ฌ ๋ฐฉ ์žฌ์ž…์žฅ ์ฒ˜๋ฆฌ - roomJoinHandler.handleJoinRoom(client, roomId); + + userConnectionLocks.withLock(userId, () -> { + // Atomic replacement works across nodes when RedisChatDataStore is active. + SocketUser previousUser = connectedUsers.replace(userId, user); + notifyDuplicateLogin(client, previousUser); }); - - connectedUsers.set(userId, user); - log.info("Socket.IO user connected: {} ({}) - Total concurrent users: {}", - getUserName(client), userId, connectedUsers.size()); + if (log.isDebugEnabled()) { + log.debug("Socket.IO user connected: {} ({}) - Local connections: {}", + getUserName(client), userId, localConnectionCount()); + } - client.joinRooms(Set.of("user:" + userId, "room-list")); + // Global room-list broadcasts are opt-in. Joining every authenticated + // socket makes concurrent room creation O(connections * creations). + client.joinRooms(Set.of("user:" + userId, "socket:" + user.socketId())); } catch (Exception e) { log.error("Error handling Socket.IO connection", e); @@ -94,25 +89,24 @@ public void onDisconnect(SocketIOClient client) { return; } - userRooms.get(userId).forEach(roomId -> { - roomLeaveHandler.handleLeaveRoom(client, roomId); - }); String socketId = client.getSessionId().toString(); - - // ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ํ˜„์žฌ ํ™œ์„ฑ ์—ฐ๊ฒฐ์ธ ๊ฒฝ์šฐ์—๋งŒ ์ •๋ฆฌ - var socketUser = connectedUsers.get(userId); - if (socketUser != null && socketId.equals(socketUser.socketId())) { - connectedUsers.del(userId); - } else { - log.warn("Socket.IO disconnect: User {} has a different active connection. Skipping cleanup.", userId); - } - client.leaveRooms(Set.of("user:" + userId, "room-list")); + userConnectionLocks.withLock(userId, () -> { + // Disconnect only releases connection ownership. Room membership + // is persistent and is removed exclusively by an explicit LEAVE_ROOM. + if (!connectedUsers.delIfCurrent(userId, getUserDto(client))) { + log.debug("Socket.IO disconnect: User {} has a different active connection. " + + "Skipping connection cleanup.", userId); + } + }); + + client.leaveRooms(Set.of("user:" + userId, "socket:" + socketId)); client.del("user"); - client.disconnect(); - log.info("Socket.IO user disconnected: {} ({}) - Total concurrent users: {}", - userName, userId, connectedUsers.size()); + if (log.isDebugEnabled()) { + log.debug("Socket.IO user disconnected: {} ({}) - Local connections: {}", + userName, userId, localConnectionCount()); + } } catch (Exception e) { log.error("Error handling Socket.IO disconnection", e); client.sendEvent(ERROR, Map.of( @@ -125,6 +119,11 @@ public void onDisconnect(SocketIOClient client) { private SocketUser getUserDto(SocketIOClient client) { return client.get("user"); } + + private int localConnectionCount() { + var clients = socketIOServer.getAllClients(); + return clients != null ? clients.size() : 0; + } private String getUserId(SocketIOClient client) { SocketUser user = getUserDto(client); @@ -136,40 +135,23 @@ private String getUserName(SocketIOClient client) { return user != null ? user.name() : null; } - /** - * TODO ๋ฉ€ํ‹ฐ ํด๋Ÿฌ์Šคํ„ฐ์—์„œ ๋™์ž‘ ์•ˆํ•จ - * socketIOServer.getRoomOperations("user:" + userId) ๋กœ ์ฒ˜๋ฆฌ ๋ณ€๊ฒฝ. - */ - private void notifyDuplicateLogin(SocketIOClient client, String userId) { - var socketUser = connectedUsers.get(userId); - if (socketUser == null) { - return; - } - String existingSocketId = socketUser.socketId(); - SocketIOClient existingClient = socketIOServer.getClient(UUID.fromString(existingSocketId)); - if (existingClient == null) { + private void notifyDuplicateLogin(SocketIOClient client, SocketUser previousUser) { + if (previousUser == null) { return; } - - // Send duplicate login notification - existingClient.sendEvent(DUPLICATE_LOGIN, Map.of( + + String targetRoom = "socket:" + previousUser.socketId(); + socketIOServer.getRoomOperations(targetRoom).sendEvent(DUPLICATE_LOGIN, Map.of( "type", "new_login_attempt", "deviceInfo", client.getHandshakeData().getHttpHeaders().get("User-Agent"), "ipAddress", client.getRemoteAddress().toString(), "timestamp", System.currentTimeMillis() )); - - new Thread(() -> { - try { - Thread.sleep(Duration.ofSeconds(10)); - existingClient.sendEvent(SESSION_ENDED, Map.of( + + CompletableFuture.delayedExecutor(Duration.ofSeconds(10).toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .execute(() -> socketIOServer.getRoomOperations(targetRoom).sendEvent(SESSION_ENDED, Map.of( "reason", "duplicate_login", "message", "๋‹ค๋ฅธ ๊ธฐ๊ธฐ์—์„œ ๋กœ๊ทธ์ธํ•˜์—ฌ ํ˜„์žฌ ์„ธ์…˜์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." - )); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Error in duplicate login notification thread", e); - } - }).start(); + ))); } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandler.java index 8be4f9f8..fc879181 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandler.java @@ -4,7 +4,6 @@ import com.corundumstudio.socketio.annotation.OnEvent; import com.ktb.chatapp.dto.FetchMessagesRequest; import com.ktb.chatapp.dto.FetchMessagesResponse; -import com.ktb.chatapp.model.Room; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.websocket.socketio.SocketUser; import lombok.RequiredArgsConstructor; @@ -42,8 +41,7 @@ public void handleFetchMessages(SocketIOClient client, FetchMessagesRequest data try { // ๊ถŒํ•œ ์ฒดํฌ - Room room = roomRepository.findById(data.roomId()).orElse(null); - if (room == null || !room.getParticipantIds().contains(userId)) { + if (!roomRepository.existsByIdAndParticipantIdsContaining(data.roomId(), userId)) { client.sendEvent(ERROR, Map.of( "code", "LOAD_ERROR", "message", "์ฑ„ํŒ…๋ฐฉ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค." diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java index ec320cc5..2b3a4764 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java @@ -3,12 +3,15 @@ import com.ktb.chatapp.dto.FetchMessagesRequest; import com.ktb.chatapp.dto.FetchMessagesResponse; import com.ktb.chatapp.dto.MessageResponse; +import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.Message; import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.MessageReadStatusService; -import jakarta.annotation.Nullable; +import java.util.HashMap; +import java.util.Objects; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -29,6 +32,7 @@ public class MessageLoader { private final MessageRepository messageRepository; private final UserRepository userRepository; + private final FileRepository fileRepository; private final MessageResponseMapper messageResponseMapper; private final MessageReadStatusService messageReadStatusService; @@ -66,12 +70,35 @@ private FetchMessagesResponse loadMessagesInternal( var messageIds = sortedMessages.stream().map(Message::getId).toList(); messageReadStatusService.updateReadStatus(messageIds, userId); - - // ๋ฉ”์‹œ์ง€ ์‘๋‹ต ์ƒ์„ฑ + +// ๋ฉ”์‹œ์ง€ ์ž‘์„ฑ์ž ID๋ฅผ ์ค‘๋ณต ์—†์ด ์ˆ˜์ง‘ํ•œ๋‹ค. + var senderIds = sortedMessages.stream() + .map(Message::getSenderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + +// ๋ฉ”์‹œ์ง€๋งˆ๋‹ค ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๊ณ  ํ•œ ๋ฒˆ์— ์กฐํšŒํ•œ๋‹ค. + var usersById = new HashMap(); + userRepository.findAllById(senderIds) + .forEach(user -> usersById.put(user.getId(), user)); + + var fileIds = sortedMessages.stream() + .map(Message::getFileId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + var filesById = new HashMap(); + if (!fileIds.isEmpty()) { + fileRepository.findAllById(fileIds) + .forEach(file -> filesById.put(file.getId(), file)); + } + +// ๋ฉ”์‹œ์ง€ ์‘๋‹ต ์ƒ์„ฑ List messageResponses = sortedMessages.stream() .map(message -> { - var user = findUserById(message.getSenderId()); - return messageResponseMapper.mapToMessageResponse(message, user); + var user = usersById.get(message.getSenderId()); + var file = filesById.get(message.getFileId()); + return messageResponseMapper.mapToMessageResponse(message, user, file); }) .collect(Collectors.toList()); @@ -86,15 +113,4 @@ private FetchMessagesResponse loadMessagesInternal( .build(); } - /** - * AI ๊ฒฝ์šฐ null ๋ฐ˜ํ™˜ ๊ฐ€๋Šฅ - */ - @Nullable - private User findUserById(String id) { - if (id == null) { - return null; - } - return userRepository.findById(id) - .orElse(null); - } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java index cecfc5b5..9cdc99d7 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java @@ -6,7 +6,7 @@ import com.ktb.chatapp.dto.MessageReactionRequest; import com.ktb.chatapp.dto.MessageReactionResponse; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.service.MessageReactionService; import com.ktb.chatapp.websocket.socketio.SocketUser; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -27,7 +27,7 @@ public class MessageReactionHandler { private final SocketIOServer socketIOServer; - private final MessageRepository messageRepository; + private final MessageReactionService messageReactionService; @OnEvent(MESSAGE_REACTION) public void handleMessageReaction(SocketIOClient client, MessageReactionRequest data) { @@ -38,26 +38,26 @@ public void handleMessageReaction(SocketIOClient client, MessageReactionRequest return; } - Message message = messageRepository.findById(data.getMessageId()).orElse(null); - if (message == null) { - client.sendEvent(ERROR, Map.of("message", "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - return; - } - - switch (data.getType()) { - case "add" -> message.addReaction(data.getReaction(), userId); - case "remove" -> message.removeReaction(data.getReaction(), userId); + boolean add = switch (data.getType()) { + case "add" -> true; + case "remove" -> false; case null, default -> { client.sendEvent(ERROR, Map.of("message", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋ฆฌ์•ก์…˜ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.")); - return; + yield false; } + }; + if (!"add".equals(data.getType()) && !"remove".equals(data.getType())) return; + + Message message = messageReactionService + .update(data.getMessageId(), data.getReaction(), userId, add).orElse(null); + if (message == null) { + client.sendEvent(ERROR, Map.of("message", "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + return; } log.debug("Message reaction processed - type: {}, reaction: {}, messageId: {}, userId: {}", data.getType(), data.getReaction(), message.getId(), userId); - messageRepository.save(message); - MessageReactionResponse response = new MessageReactionResponse( message.getId(), message.getReactions() diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandler.java index 14eb7b5f..58afb14c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandler.java @@ -5,14 +5,16 @@ import com.corundumstudio.socketio.annotation.OnEvent; import com.ktb.chatapp.dto.MarkAsReadRequest; import com.ktb.chatapp.dto.MessagesReadResponse; -import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.MessageReadStatusService; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,8 +36,8 @@ public class MessageReadHandler { private final SocketIOServer socketIOServer; private final MessageReadStatusService messageReadStatusService; private final MessageRepository messageRepository; + private final UserRooms userRooms; private final RoomRepository roomRepository; - private final UserRepository userRepository; @OnEvent(MARK_MESSAGES_AS_READ) public void handleMarkAsRead(SocketIOClient client, MarkAsReadRequest data) { @@ -49,34 +51,43 @@ public void handleMarkAsRead(SocketIOClient client, MarkAsReadRequest data) { if (data == null || data.getMessageIds() == null || data.getMessageIds().isEmpty()) { return; } - - String roomId = messageRepository.findById(data.getMessageIds().getFirst()) - .map(Message::getRoomId).orElse(null); - - if (roomId == null || roomId.isBlank()) { - client.sendEvent(ERROR, Map.of("message", "Invalid room")); - return; - } - User user = userRepository.findById(userId).orElse(null); - if (user == null) { - client.sendEvent(ERROR, Map.of("message", "User not found")); + List messageIds = normalizeMessageIds(data.getMessageIds()); + if (messageIds.isEmpty()) { return; } - Room room = roomRepository.findById(roomId).orElse(null); - if (room == null || !room.getParticipantIds().contains(userId)) { - client.sendEvent(ERROR, Map.of("message", "Room access denied")); + Map> messageIdsByRoomId = groupMessageIdsByRoom(messageIds); + if (messageIdsByRoomId.isEmpty()) { + client.sendEvent(ERROR, Map.of("message", "Invalid room")); return; } - - messageReadStatusService.updateReadStatus(data.getMessageIds(), userId); - MessagesReadResponse response = new MessagesReadResponse(userId, data.getMessageIds()); + boolean denied = false; + boolean updated = false; + for (var entry : messageIdsByRoomId.entrySet()) { + String roomId = entry.getKey(); + List roomMessageIds = entry.getValue(); + + if (!hasRoomAccess(userId, roomId)) { + denied = true; + continue; + } - // Broadcast to room - socketIOServer.getRoomOperations(roomId) - .sendEvent(MESSAGES_READ, response); + long modifiedCount = messageReadStatusService.updateReadStatus(roomMessageIds, userId, roomId); + if (modifiedCount == 0) { + continue; + } + + updated = true; + MessagesReadResponse response = new MessagesReadResponse(userId, roomMessageIds); + socketIOServer.getRoomOperations(roomId) + .sendEvent(MESSAGES_READ, response); + } + + if (!updated && denied) { + client.sendEvent(ERROR, Map.of("message", "Room access denied")); + } } catch (Exception e) { log.error("Error handling markMessagesAsRead", e); @@ -90,4 +101,39 @@ private String getUserId(SocketIOClient client) { var user = (SocketUser) client.get("user"); return user != null ? user.id() : null; } + + private List normalizeMessageIds(List messageIds) { + var uniqueMessageIds = new LinkedHashSet(); + for (String messageId : messageIds) { + if (messageId != null && !messageId.isBlank()) { + uniqueMessageIds.add(messageId); + } + } + return new ArrayList<>(uniqueMessageIds); + } + + private Map> groupMessageIdsByRoom(List messageIds) { + Map roomIdByMessageId = new HashMap<>(); + messageRepository.findRoomsOnlyByIdIn(messageIds).forEach(message -> { + if (message.getId() != null && message.getRoomId() != null && !message.getRoomId().isBlank()) { + roomIdByMessageId.put(message.getId(), message.getRoomId()); + } + }); + + Map> messageIdsByRoomId = new LinkedHashMap<>(); + for (String messageId : messageIds) { + String roomId = roomIdByMessageId.get(messageId); + if (roomId != null) { + messageIdsByRoomId.computeIfAbsent(roomId, ignored -> new ArrayList<>()).add(messageId); + } + } + return messageIdsByRoomId; + } + + private boolean hasRoomAccess(String userId, String roomId) { + if (userRooms.isInRoom(userId, roomId)) { + return true; + } + return roomRepository.existsByIdAndParticipantIdsContaining(roomId, userId); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java index 11dfc01b..d9b641ef 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java @@ -3,6 +3,7 @@ import com.ktb.chatapp.dto.FileResponse; import com.ktb.chatapp.dto.MessageResponse; import com.ktb.chatapp.dto.UserResponse; +import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.Message; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.FileRepository; @@ -33,6 +34,14 @@ public class MessageResponseMapper { * @return MessageResponse DTO */ public MessageResponse mapToMessageResponse(Message message, User sender) { + File file = Optional.ofNullable(message.getFileId()) + .flatMap(fileRepository::findById) + .orElse(null); + + return mapToMessageResponse(message, sender, file); + } + + public MessageResponse mapToMessageResponse(Message message, User sender, File file) { MessageResponse.MessageResponseBuilder builder = MessageResponse.builder() .id(message.getId()) .content(message.getContent()) @@ -55,16 +64,16 @@ public MessageResponse mapToMessageResponse(Message message, User sender) { } // ํŒŒ์ผ ์ •๋ณด ์„ค์ • - Optional.ofNullable(message.getFileId()) - .flatMap(fileRepository::findById) - .map(file -> FileResponse.builder() - .id(file.getId()) - .filename(file.getFilename()) - .originalname(file.getOriginalname()) - .mimetype(file.getMimetype()) - .size(file.getSize()) - .build()) - .ifPresent(builder::file); + if (file != null) { + builder.file(FileResponse.builder() + .id(file.getId()) + .filename(file.getFilename()) + .originalname(file.getOriginalname()) + .mimetype(file.getMimetype()) + .size(file.getSize()) + .deleted(file.isDeleted()) + .build()); + } // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์„ค์ • if (message.getMetadata() != null) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java index fe0e4f7b..e8b8bd22 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java @@ -1,156 +1,90 @@ package com.ktb.chatapp.websocket.socketio.handler; import com.corundumstudio.socketio.SocketIOClient; -import com.corundumstudio.socketio.SocketIOServer; import com.corundumstudio.socketio.annotation.OnEvent; -import com.ktb.chatapp.dto.FetchMessagesRequest; -import com.ktb.chatapp.dto.FetchMessagesResponse; import com.ktb.chatapp.dto.JoinRoomSuccessResponse; -import com.ktb.chatapp.dto.UserResponse; -import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.MessageType; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.websocket.socketio.SocketUser; import com.ktb.chatapp.websocket.socketio.UserRooms; -import java.time.LocalDateTime; -import java.util.*; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.*; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_ERROR; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_SUCCESS; -/** - * ๋ฐฉ ์ž…์žฅ ์ฒ˜๋ฆฌ ํ•ธ๋“ค๋Ÿฌ - * ์ฑ„ํŒ…๋ฐฉ ์ž…์žฅ, ์ฐธ๊ฐ€์ž ๊ด€๋ฆฌ, ์ดˆ๊ธฐ ๋ฉ”์‹œ์ง€ ๋กœ๋“œ ๋‹ด๋‹น - */ @Slf4j @Component @ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) @RequiredArgsConstructor public class RoomJoinHandler { - private final SocketIOServer socketIOServer; - private final MessageRepository messageRepository; private final RoomRepository roomRepository; - private final UserRepository userRepository; private final UserRooms userRooms; - private final MessageLoader messageLoader; - private final MessageResponseMapper messageResponseMapper; + @SuppressWarnings("unused") private final RoomLeaveHandler roomLeaveHandler; - + private final Set joinsInProgress = ConcurrentHashMap.newKeySet(); + @OnEvent(JOIN_ROOM) public void handleJoinRoom(SocketIOClient client, String roomId) { + String joinKey = null; try { String userId = getUserId(client); - String userName = getUserName(client); - if (userId == null) { client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "Unauthorized")); return; } - - if (userRepository.findById(userId).isEmpty()) { - client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "User not found")); + + Set clientRooms = client.getAllRooms(); + if (clientRooms != null && clientRooms.contains(roomId)) { + sendJoinSuccess(client, roomId); + log.debug("Socket already joined room. userId={}, roomId={}", userId, roomId); return; } - - if (roomRepository.findById(roomId).isEmpty()) { - client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + joinKey = userId + ":" + roomId + ":" + client.getSessionId(); + if (!joinsInProgress.add(joinKey)) { + log.debug("JoinRoom request already in progress. userId={}, roomId={}", userId, roomId); return; } - - // ์ด๋ฏธ ํ•ด๋‹น ๋ฐฉ์— ์ฐธ์—ฌ ์ค‘์ธ์ง€ ํ™•์ธ - if (userRooms.isInRoom(userId, roomId)) { - log.debug("User {} already in room {}", userId, roomId); - client.joinRoom(roomId); - client.sendEvent(JOIN_ROOM_SUCCESS, Map.of("roomId", roomId)); + + if (!roomRepository.existsByIdAndParticipantIdsContaining(roomId, userId)) { + client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "Room access denied")); return; } - roomRepository.addParticipant(roomId, userId); - - // Join socket room and add to user's room set client.joinRoom(roomId); userRooms.add(userId, roomId); + sendJoinSuccess(client, roomId); - Message joinMessage = Message.builder() - .roomId(roomId) - .content(userName + "๋‹˜์ด ์ž…์žฅํ•˜์˜€์Šต๋‹ˆ๋‹ค.") - .type(MessageType.system) - .timestamp(LocalDateTime.now()) - .mentions(new ArrayList<>()) - .reactions(new HashMap<>()) - .readers(new ArrayList<>()) - .metadata(new HashMap<>()) - .build(); - - joinMessage = messageRepository.save(joinMessage); - - // ์ดˆ๊ธฐ ๋ฉ”์‹œ์ง€ ๋กœ๋“œ - FetchMessagesRequest req = new FetchMessagesRequest(roomId, 30, null); - FetchMessagesResponse messageLoadResult = messageLoader.loadMessages(req, userId); - - // ์—…๋ฐ์ดํŠธ๋œ room ๋‹ค์‹œ ์กฐํšŒํ•˜์—ฌ ์ตœ์‹  participantIds ๊ฐ€์ ธ์˜ค๊ธฐ - Optional roomOpt = roomRepository.findById(roomId); - if (roomOpt.isEmpty()) { - client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - return; - } - - // ์ฐธ๊ฐ€์ž ์ •๋ณด ์กฐํšŒ - List participants = roomOpt.get().getParticipantIds() - .stream() - .map(userRepository::findById) - .filter(Optional::isPresent) - .map(Optional::get) - .map(UserResponse::from) - .toList(); - - JoinRoomSuccessResponse response = JoinRoomSuccessResponse.builder() - .roomId(roomId) - .participants(participants) - .messages(messageLoadResult.getMessages()) - .hasMore(messageLoadResult.isHasMore()) - .activeStreams(Collections.emptyList()) - .build(); - - client.sendEvent(JOIN_ROOM_SUCCESS, response); - - // ์ž…์žฅ ๋ฉ”์‹œ์ง€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ - socketIOServer.getRoomOperations(roomId) - .sendEvent(MESSAGE, messageResponseMapper.mapToMessageResponse(joinMessage, null)); - - // ์ฐธ๊ฐ€์ž ๋ชฉ๋ก ์—…๋ฐ์ดํŠธ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ - socketIOServer.getRoomOperations(roomId) - .sendEvent(PARTICIPANTS_UPDATE, participants); - - log.info("User {} joined room {} successfully. Message count: {}, hasMore: {}", - userName, roomId, messageLoadResult.getMessages().size(), messageLoadResult.isHasMore()); - + log.debug("User joined Socket.IO room successfully. userId={}, roomId={}", userId, roomId); } catch (Exception e) { log.error("Error handling joinRoom", e); client.sendEvent(JOIN_ROOM_ERROR, Map.of( - "message", e.getMessage() != null ? e.getMessage() : "์ฑ„ํŒ…๋ฐฉ ์ž…์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + "message", e.getMessage() != null ? e.getMessage() : "Failed to join room." )); + } finally { + if (joinKey != null) { + joinsInProgress.remove(joinKey); + } } } - - private SocketUser getUser(SocketIOClient client) { - return client.get("user"); + + private void sendJoinSuccess(SocketIOClient client, String roomId) { + client.sendEvent(JOIN_ROOM_SUCCESS, JoinRoomSuccessResponse.builder() + .roomId(roomId) + .activeStreams(Collections.emptyList()) + .build()); } private String getUserId(SocketIOClient client) { - SocketUser user = getUser(client); + SocketUser user = client.get("user"); return user != null ? user.id() : null; } - - private String getUserName(SocketIOClient client) { - SocketUser user = getUser(client); - return user != null ? user.name() : null; - } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java index 0d9bab6c..360afc7f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java @@ -54,11 +54,6 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { return; } - if (!userRooms.isInRoom(userId, roomId)) { - log.debug("User {} is not in room {}", userId, roomId); - return; - } - User user = userRepository.findById(userId).orElse(null); Room room = roomRepository.findById(roomId).orElse(null); @@ -67,7 +62,10 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { return; } - roomRepository.removeParticipant(roomId, userId); + if (roomRepository.removeParticipant(roomId, userId) == 0) { + log.debug("User {} is not in room {}", userId, roomId); + return; + } client.leaveRoom(roomId); userRooms.remove(userId, roomId); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java new file mode 100644 index 00000000..5fcd40aa --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java @@ -0,0 +1,32 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import com.corundumstudio.socketio.SocketIOClient; +import com.corundumstudio.socketio.annotation.OnEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_LIST; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.LEAVE_ROOM_LIST; + +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class RoomListSubscriptionHandler { + + private static final String ROOM_LIST = "room-list"; + + @OnEvent(JOIN_ROOM_LIST) + public void joinRoomList(SocketIOClient client) { + if (client.get("user") == null) { + log.debug("Ignoring unauthenticated room-list subscription: socketId={}", client.getSessionId()); + return; + } + client.joinRoom(ROOM_LIST); + } + + @OnEvent(LEAVE_ROOM_LIST) + public void leaveRoomList(SocketIOClient client) { + client.leaveRoom(ROOM_LIST); + } +} diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index da6d0e5f..ff6ccfd9 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -1,16 +1,21 @@ # Server Configuration server.port=${PORT:5001} -server.tomcat.threads.max=10 -server.tomcat.threads.min-spare=1 -server.tomcat.accept-count=10 -server.tomcat.max-connections=50 +# Tomcat Configuration +server.tomcat.threads.max=${TOMCAT_THREADS_MAX:100} +server.tomcat.threads.min-spare=${TOMCAT_THREADS_MIN_SPARE:10} +server.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:500} +server.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:1000} # Database Configuration # Spring Boot 4: connection settings moved from spring.data.mongodb.* to spring.mongodb.* spring.mongodb.uri=${MONGO_URI} spring.data.mongodb.auto-index-creation=true +app.mongodb.pool.max-size=${MONGO_MAX_POOL_SIZE:100} +app.mongodb.pool.min-size=${MONGO_MIN_POOL_SIZE:0} +app.mongodb.pool.max-wait-time-ms=${MONGO_MAX_WAIT_TIME_MS:120000} + # Redis Configuration spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} @@ -19,7 +24,14 @@ spring.data.redis.password=${REDIS_PASSWORD:} # File Upload Configuration # Storage type: local or gridfs (default: local) file.storage.type=${FILE_STORAGE_TYPE:local} +file.storage.require-offload=${FILE_STORAGE_REQUIRE_OFFLOAD:false} file.upload-dir=./uploads +app.s3.bucket=${S3_BUCKET:} +app.s3.region=${AWS_REGION:ap-northeast-2} +app.s3.endpoint=${S3_ENDPOINT:} +app.s3.path-style-access=${S3_PATH_STYLE:false} +app.s3.key-prefix=${S3_KEY_PREFIX:} +app.s3.presign-ttl=${S3_PRESIGN_TTL:10m} # Multipart upload limits spring.servlet.multipart.max-file-size=5MB @@ -41,6 +53,14 @@ spring.ai.openai.chat.options.temperature=${OPENAI_TEMPERATURE:0.7} socketio.server.host=0.0.0.0 socketio.server.port=${WS_PORT:5002} socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} +socketio.server.accept-backlog=${SOCKETIO_ACCEPT_BACKLOG:1024} +socketio.server.tcp-no-delay=${SOCKETIO_TCP_NO_DELAY:true} +socketio.presence-refresh-interval=${SOCKETIO_PRESENCE_REFRESH_INTERVAL:5m} +socketio.store.type=${SOCKETIO_STORE_TYPE:redis} +rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis} +session.store.type=${SESSION_STORE_TYPE:redis} +room-activity.flush-delay=${ROOM_ACTIVITY_FLUSH_DELAY:1s} +room-activity.debounce-window=${ROOM_ACTIVITY_DEBOUNCE_WINDOW:1s} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} @@ -48,6 +68,8 @@ app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} # Logging Configuration logging.level.org.springframework=INFO logging.level.com.ktb.chatapp=INFO +logging.level.com.ktb.chatapp.websocket.socketio=${SOCKETIO_LOG_LEVEL:WARN} +logging.level.com.corundumstudio.socketio=${SOCKETIO_LIBRARY_LOG_LEVEL:WARN} logging.level.org.mongodb=INFO # Content Moderation @@ -74,3 +96,10 @@ management.endpoint.prometheus.access=read_only management.prometheus.metrics.export.enabled=true management.metrics.tags.application=${spring.application.name:ktb-chat-backend} management.metrics.tags.host=${HOSTNAME:unknown} + +# CloudFront signed URL +app.cloudfront.enabled=${CLOUDFRONT_ENABLED:false} +app.cloudfront.base-url=${CLOUDFRONT_BASE_URL:} +app.cloudfront.key-pair-id=${CLOUDFRONT_KEY_PAIR_ID:} +app.cloudfront.private-key-path=${CLOUDFRONT_PRIVATE_KEY_PATH:} +app.cloudfront.url-ttl=${CLOUDFRONT_URL_TTL:5m} diff --git a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml index ae1ecaae..1957ecbe 100644 --- a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml +++ b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml @@ -155,14 +155,14 @@ channels: address: roomCreated messages: roomCreatedResponse: - $ref: '#/components/messages/RoomResponse' + $ref: '#/components/messages/RoomListItemResponse' description: ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ์•Œ๋ฆผ roomUpdated: address: roomUpdated messages: roomUpdatedResponse: - $ref: '#/components/messages/RoomResponse' + $ref: '#/components/messages/RoomListItemResponse' description: ์ฑ„ํŒ…๋ฐฉ ์ •๋ณด ์—…๋ฐ์ดํŠธ roomActivity: @@ -551,13 +551,13 @@ components: items: $ref: '#/components/schemas/UserResponse' - RoomResponse: - name: RoomResponse - title: ์ฑ„ํŒ…๋ฐฉ ์‘๋‹ต - summary: ์ฑ„ํŒ…๋ฐฉ ์ •๋ณด ํŽ˜์ด๋กœ๋“œ + RoomListItemResponse: + name: RoomListItemResponse + title: ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํ•ญ๋ชฉ ์‘๋‹ต + summary: ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์šฉ ๊ฒฝ๋Ÿ‰ ํŽ˜์ด๋กœ๋“œ contentType: application/json payload: - $ref: '#/components/schemas/RoomResponse' + $ref: '#/components/schemas/RoomListItemResponse' RoomActivityResponse: name: RoomActivity @@ -1047,7 +1047,7 @@ components: - messages - hasMore - RoomResponse: + RoomListItemResponse: type: object properties: _id: @@ -1059,27 +1059,23 @@ components: hasPassword: type: boolean description: ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • ์—ฌ๋ถ€ - creator: - $ref: '#/components/schemas/UserResponse' - participants: - type: array - items: - $ref: '#/components/schemas/UserResponse' - description: ์ฐธ๊ฐ€์ž ๋ชฉ๋ก - participantsCount: + participantCount: type: integer description: ์ฐธ๊ฐ€์ž ์ˆ˜ + recentMessageCount: + type: integer + description: ์ตœ๊ทผ 30๋ถ„๊ฐ„ ๋ฉ”์‹œ์ง€ ์ˆ˜ createdAt: type: string format: date-time description: ์ƒ์„ฑ ๋‚ ์งœ - isCreator: - type: boolean - description: ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ์ž์ธ์ง€ ์—ฌ๋ถ€ required: - _id - name - hasPassword + - participantCount + - recentMessageCount + - createdAt ErrorPayload: type: object diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java index 995eb78d..18eb7cde 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java @@ -9,10 +9,13 @@ import org.springframework.context.annotation.Import; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.cors.CorsConfigurationSource; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -23,6 +26,9 @@ class SecurityConfigTest { @Autowired private MockMvc mockMvc; + @Autowired + private CorsConfigurationSource corsConfigurationSource; + @MockitoBean private JwtDecoder jwtDecoder; @@ -52,6 +58,16 @@ void otherApiEndpointsStillRequireAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + void corsConfigurationIsReusedAcrossRequests() { + MockHttpServletRequest firstRequest = new MockHttpServletRequest("GET", "/api/probe"); + MockHttpServletRequest secondRequest = new MockHttpServletRequest("GET", "/api/probe"); + + assertSame( + corsConfigurationSource.getCorsConfiguration(firstRequest), + corsConfigurationSource.getCorsConfiguration(secondRequest)); + } + @RestController static class ProbeController { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java index 0270240a..0a4b7c62 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java @@ -9,6 +9,7 @@ import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @TestPropertySource(properties = "socketio.enabled=false") @@ -21,4 +22,19 @@ void shouldNotLoadSocketIOBeansWhenDisabled(ApplicationContext context) { assertThrows(NoSuchBeanDefinitionException.class, () -> context.getBean(SocketIOServer.class)); } + + @Test + void createsSpikeReadySocketTransportSettings() { + var socketConfig = SocketIOConfig.createSocketConfig(1024, true); + + assertThat(socketConfig.getAcceptBackLog()).isEqualTo(1024); + assertThat(socketConfig.isTcpNoDelay()).isTrue(); + assertThat(socketConfig.isReuseAddress()).isTrue(); + } + + @Test + void rejectsInvalidAcceptBacklog() { + assertThrows(IllegalArgumentException.class, + () -> SocketIOConfig.createSocketConfig(0, true)); + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java index 4390d557..36d29146 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java @@ -18,6 +18,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -54,6 +55,7 @@ class UploadsResourceAccessTest { void profileImageIsServedToAnonymousRequests() throws Exception { mockMvc.perform(get("/api/files/profiles/avatar.png")) .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=31536000, public, immutable")) .andExpect(content().string(org.hamcrest.Matchers.containsString("fake-png-bytes"))); } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java index 49d4e4a3..633cadce 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java @@ -4,6 +4,7 @@ import com.ktb.chatapp.config.MongoTestContainer; import com.ktb.chatapp.dto.LoginRequest; import com.ktb.chatapp.dto.RegisterRequest; +import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.SessionCreationResult; import com.ktb.chatapp.service.SessionMetadata; import com.ktb.chatapp.service.SessionService; @@ -16,9 +17,13 @@ import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -40,6 +45,9 @@ public class AuthControllerTest { @MockitoBean private SessionService sessionService; + @MockitoSpyBean + private UserRepository userRepository; + @Test @WithAnonymousUser public void testRegisterUser() throws Exception { @@ -106,7 +114,8 @@ public void testAuthenticateUser() throws Exception { .content(objectMapper.writeValueAsString(registerRequest))) .andExpect(status().isCreated()); - LoginRequest loginRequest = new LoginRequest(email, "password"); + clearInvocations(userRepository); + LoginRequest loginRequest = new LoginRequest(email.toUpperCase(), "password"); mockMvc.perform(post("/api/auth/login") .with(csrf()) @@ -114,5 +123,7 @@ public void testAuthenticateUser() throws Exception { .content(objectMapper.writeValueAsString(loginRequest))) .andExpect(status().isOk()) .andExpect(jsonPath("$.token").exists()); + + verify(userRepository, times(1)).findByEmail(email); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java index 971ee717..f8eed2c8 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java @@ -1,19 +1,26 @@ package com.ktb.chatapp.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; 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.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.ktb.chatapp.model.User; +import com.ktb.chatapp.exception.DirectUploadNotSupportedException; +import com.ktb.chatapp.exception.FileAccessException; import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.security.SessionAwareJwtAuthenticationConverter; import com.ktb.chatapp.service.FileAccess; import com.ktb.chatapp.service.FileAccessService; +import com.ktb.chatapp.service.ChatFileDirectUploadService; import com.ktb.chatapp.service.FileService; import com.ktb.chatapp.service.PreviewNotSupportedException; import com.ktb.chatapp.service.RateLimitService; @@ -21,6 +28,8 @@ import java.nio.charset.StandardCharsets; import java.security.Principal; import java.util.Optional; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,6 +38,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -36,9 +46,7 @@ * {@code /api/files} ์ฝ๊ธฐ ๊ฒฝ๋กœ์˜ HTTP ํ‘œ๋ฉด์„ ๊ณ ์ •ํ•œ๋‹ค. {@link FileAccessService}๊ฐ€ ์กฐ๋ฆฝํ•œ * {@link FileAccess}๋ฅผ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์–ด๋–ค ์ƒํƒœ์ฝ”๋“œยทํ—ค๋”๋กœ ๋ฒˆ์—ญํ•˜๋Š”์ง€๊ฐ€ ๊ฒ€์ฆ ๋Œ€์ƒ์ด๋‹ค. * - *

๋‘ ๊ณ„์•ฝ์ด ํŠนํžˆ ์กฐ์šฉํžˆ ๊นจ์ง„๋‹ค. โ‘  {@code handleFileError}๋Š” ์˜ˆ์™ธ ๋ฉ”์‹œ์ง€ ๋ฌธ์ž์—ด๋กœ 403/404๋ฅผ - * ๋ถ„๊ธฐํ•˜๋ฏ€๋กœ ๋˜์ง€๋Š” ๋ฉ”์‹œ์ง€๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์ƒํƒœ์ฝ”๋“œ๊ฐ€ ๋ฐ”๋€๋‹ค. โ‘ก download๋Š” {@code attachment} + ์บ์‹œ ๊ธˆ์ง€, - * view๋Š” {@code inline} + ์žฅ๊ธฐ ์บ์‹œ๋กœ ํ—ค๋”๊ฐ€ ์„œ๋กœ ๋‹ฌ๋ผ์•ผ ํ•œ๋‹ค. + *

ํŒŒ์ผ ์ฝ๊ธฐ ์˜ˆ์™ธ์˜ ํƒ€์ž…๋ณ„ ์ƒํƒœ ์ฝ”๋“œ์™€ download/view์˜ ์„œ๋กœ ๋‹ค๋ฅธ ์‘๋‹ต ํ—ค๋”๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค. */ @WebMvcTest(controllers = FileController.class) @AutoConfigureMockMvc(addFilters = false) @@ -63,6 +71,9 @@ class FileControllerTest { @MockitoBean private FileAccessService fileAccessService; + @MockitoBean + private ChatFileDirectUploadService chatFileDirectUploadService; + @MockitoBean private UserRepository userRepository; @@ -110,7 +121,21 @@ void viewFile_stream_setsInlineHeader() throws Exception { "Content-Disposition", "inline; filename=\"" + ORIGINAL_NAME + "\"; filename*=UTF-8''" + ENCODED_ORIGINAL_NAME)) - .andExpect(header().doesNotExist("Cache-Control")); + .andExpect(header().string("Cache-Control", "private, max-age=300")); + } + + @Test + @DisplayName("์ธ์ฆ details์˜ userId๋ฅผ ์‚ฌ์šฉํ•ด ์‚ฌ์šฉ์ž ์žฌ์กฐํšŒ๋ฅผ ์ƒ๋žตํ•œ๋‹ค") + void viewFile_authenticatedUserId_skipsUserLookup() throws Exception { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(EMAIL, "", List.of()); + authentication.setDetails(Map.of("userId", USER_ID)); + when(fileAccessService.forView(FILE_NAME, USER_ID)).thenReturn(stream()); + + mockMvc.perform(get("/api/files/view/{filename}", FILE_NAME).principal(authentication)) + .andExpect(status().isOk()); + + verify(userRepository, never()).findByEmail(EMAIL); } @Test @@ -134,10 +159,10 @@ void viewFile_redirect_returnsFoundWithLocation() throws Exception { } @Test - @DisplayName("๊ถŒํ•œ ์˜ˆ์™ธ ๋ฉ”์‹œ์ง€ โ†’ 403") - void downloadFile_unauthorizedMessage_returnsForbidden() throws Exception { + @DisplayName("๋น„์ฐธ๊ฐ€์ž ์˜ˆ์™ธ โ†’ 403") + void downloadFile_nonParticipant_returnsForbidden() throws Exception { when(fileAccessService.forDownload(FILE_NAME, USER_ID)) - .thenThrow(new RuntimeException("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค")); + .thenThrow(new FileAccessException(FileAccessException.Reason.NOT_PARTICIPANT)); mockMvc.perform(get("/api/files/download/{filename}", FILE_NAME).principal(PRINCIPAL)) .andExpect(status().isForbidden()) @@ -146,10 +171,10 @@ void downloadFile_unauthorizedMessage_returnsForbidden() throws Exception { } @Test - @DisplayName("๋ฏธ๋ฐœ๊ฒฌ ์˜ˆ์™ธ ๋ฉ”์‹œ์ง€ โ†’ 404") - void downloadFile_notFoundMessage_returnsNotFound() throws Exception { + @DisplayName("ํŒŒ์ผ ๋ฏธ๋ฐœ๊ฒฌ ์˜ˆ์™ธ โ†’ 404") + void downloadFile_fileNotFound_returnsNotFound() throws Exception { when(fileAccessService.forDownload(FILE_NAME, USER_ID)) - .thenThrow(new RuntimeException("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + FILE_NAME)); + .thenThrow(new FileAccessException(FileAccessException.Reason.FILE_NOT_FOUND)); mockMvc.perform(get("/api/files/download/{filename}", FILE_NAME).principal(PRINCIPAL)) .andExpect(status().isNotFound()) @@ -158,10 +183,10 @@ void downloadFile_notFoundMessage_returnsNotFound() throws Exception { } @Test - @DisplayName("view์˜ ๊ถŒํ•œ ์˜ˆ์™ธ๋„ ๊ฐ™์€ 403 ๊ณ„์•ฝ์„ ๋”ฐ๋ฅธ๋‹ค") - void viewFile_unauthorizedMessage_returnsForbidden() throws Exception { + @DisplayName("view์˜ ๋น„์ฐธ๊ฐ€์ž ์˜ˆ์™ธ๋„ ๊ฐ™์€ 403 ๊ณ„์•ฝ์„ ๋”ฐ๋ฅธ๋‹ค") + void viewFile_nonParticipant_returnsForbidden() throws Exception { when(fileAccessService.forView(FILE_NAME, USER_ID)) - .thenThrow(new RuntimeException("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค")); + .thenThrow(new FileAccessException(FileAccessException.Reason.NOT_PARTICIPANT)); mockMvc.perform(get("/api/files/view/{filename}", FILE_NAME).principal(PRINCIPAL)) .andExpect(status().isForbidden()) @@ -191,6 +216,51 @@ void deleteFile_unauthorizedMessage_returnsForbidden() throws Exception { .andExpect(jsonPath("$.message").value("ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.")); } + @Test + @DisplayName("presign ์‹ ๊ทœยทํ˜ธํ™˜ ๊ฒฝ๋กœ๊ฐ€ ๊ฐ™์€ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void presignAliases_returnSameUploadContract() throws Exception { + when(chatFileDirectUploadService.prepare( + eq("profile.jpg"), eq("image/jpeg"), eq(1024L), eq(USER_ID))) + .thenReturn(new ChatFileDirectUploadService.PreparedUpload( + "https://s3.example.test/signed-upload", + "chat/user-1/safe.jpg", + "safe.jpg")); + + String requestBody = """ + {"originalname":"profile.jpg","mimetype":"image/jpeg","size":1024} + """; + + for (String path : new String[]{"/api/files/presign", "/api/files/upload/presign"}) { + mockMvc.perform(post(path) + .principal(PRINCIPAL) + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.uploadUrl").value("https://s3.example.test/signed-upload")) + .andExpect(jsonPath("$.key").value("chat/user-1/safe.jpg")); + } + } + + @Test + @DisplayName("์ง์ ‘ ์—…๋กœ๋“œ ๋ฏธ์ง€์› ์Šคํ† ๋ฆฌ์ง€๋Š” 409๋กœ ํด๋ฐฑ ๊ฐ€๋Šฅ ์—ฌ๋ถ€๋ฅผ ์•Œ๋ฆฐ๋‹ค") + void presign_notSupported_returnsConflict() throws Exception { + when(chatFileDirectUploadService.prepare( + eq("profile.jpg"), eq("image/jpeg"), eq(1024L), eq(USER_ID))) + .thenThrow(new DirectUploadNotSupportedException()); + + mockMvc.perform(post("/api/files/presign") + .principal(PRINCIPAL) + .contentType("application/json") + .content(""" + {"originalname":"profile.jpg","mimetype":"image/jpeg","size":1024} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message") + .value("ํ˜„์žฌ ์Šคํ† ๋ฆฌ์ง€๋Š” ์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + } + private FileAccess stream() { return new FileAccess.Stream(STORED_BYTES, ORIGINAL_NAME, "image/png", 11L); } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java new file mode 100644 index 00000000..e62af628 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java @@ -0,0 +1,81 @@ +package com.ktb.chatapp.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import com.ktb.chatapp.model.Message; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "spring.data.mongodb.auto-index-creation=true", + "socketio.enabled=false" +}) +@DisplayName("MessageRepository ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") +class MessageRepositoryIntegrationTest { + + @Autowired private MessageRepository messageRepository; + + @AfterEach + void tearDown() { + messageRepository.deleteAll(); + } + + @Test + @DisplayName("์—ฌ๋Ÿฌ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ํ•œ ๋ฒˆ์˜ ์ง‘๊ณ„๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void countRecentMessagesByRoomIds_GroupsRecentMessagesByRequestedRoom() { + LocalDateTime now = LocalDateTime.now(); + saveMessage("room-1", now.minusMinutes(10)); + saveMessage("room-1", now.minusMinutes(20)); + saveMessage("room-1", now.minusMinutes(40)); + saveMessage("room-2", now.minusMinutes(5)); + saveMessage("room-3", now.minusMinutes(5)); + + List result = + messageRepository.countRecentMessagesByRoomIds( + Set.of("room-1", "room-2"), + now.minusMinutes(30)); + + assertThat(result) + .extracting(RecentMessageCount::roomKey, RecentMessageCount::count) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("room-1", 2L), + org.assertj.core.groups.Tuple.tuple("room-2", 1L)); + } + + @Test + @DisplayName("ํŒŒ์ผ ์ธ๊ฐ€๋Š” ๋ฉ”์‹œ์ง€์˜ id์™€ room๋งŒ projectionํ•œ๋‹ค") + void findRoomOnlyByFileId_returnsAuthorizationProjection() { + Message saved = messageRepository.save(Message.builder() + .roomId("room-file") + .fileId("file-1") + .content("projection์—์„œ ์ œ์™ธ๋  ํฐ ๋ฉ”์‹œ์ง€ ๋ณธ๋ฌธ") + .build()); + + Message projection = messageRepository.findRoomOnlyByFileId("file-1").orElseThrow(); + + assertThat(projection.getId()).isEqualTo(saved.getId()); + assertThat(projection.getRoomId()).isEqualTo("room-file"); + assertThat(projection.getContent()).isNull(); + } + + private void saveMessage(String roomId, LocalDateTime timestamp) { + Message saved = messageRepository.save(Message.builder() + .roomId(roomId) + .content("๋ฉ”์‹œ์ง€") + .build()); + saved.setTimestamp(timestamp); + messageRepository.save(saved); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java new file mode 100644 index 00000000..3c3a8d35 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java @@ -0,0 +1,91 @@ +package com.ktb.chatapp.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.model.Room; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import(MongoTestContainer.class) +@TestPropertySource(properties = { + "spring.data.mongodb.auto-index-creation=true", + "socketio.enabled=false" +}) +class RoomRepositoryIntegrationTest { + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MongoTemplate mongoTemplate; + + @AfterEach + void tearDown() { + roomRepository.deleteAll(); + } + + @Test + void participantLookupUsesAnExplicitMultikeyIndex() { + Room room = roomRepository.save(Room.builder() + .name("indexed-room") + .participantIds(Set.of("user-1", "user-2")) + .build()); + + assertThat(roomRepository.findByParticipantIdsContaining("user-2")) + .extracting(Room::getName) + .containsExactly("indexed-room"); + + assertThat(mongoTemplate.indexOps(Room.class).getIndexInfo()) + .anySatisfy(index -> { + if ("participant_ids_idx".equals(index.getName())) { + assertThat(index.getIndexFields()) + .anySatisfy(field -> assertThat(field.getKey()).isEqualTo("participantIds")); + } else { + throw new AssertionError("not the participant index"); + } + }); + + assertThat(roomRepository.findAccessById(room.getId(), "user-2")) + .contains(new RoomAccessResult(true)); + assertThat(roomRepository.findAccessById(room.getId(), "outsider")) + .contains(new RoomAccessResult(false)); + assertThat(roomRepository.findAccessById("missing-room", "user-2")).isEmpty(); + } + + @Test + void concurrentParticipantAddsDoNotLoseMembers() { + Room room = roomRepository.save(Room.builder() + .name("concurrent-room") + .participantIds(Set.of("creator")) + .build()); + int participantCount = 40; + + try (ExecutorService executor = Executors.newFixedThreadPool(8)) { + CompletableFuture[] additions = IntStream.range(0, participantCount) + .mapToObj(index -> CompletableFuture.runAsync( + () -> roomRepository.addParticipant(room.getId(), "user-" + index), + executor)) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(additions).join(); + } + + Room updated = roomRepository.findById(room.getId()).orElseThrow(); + assertThat(updated.getParticipantIds()).hasSize(participantCount + 1); + assertThat(updated.getParticipantIds()).contains("creator", "user-0", "user-39"); + assertThat(roomRepository.existsByIdAndParticipantIdsContaining(room.getId(), "user-17")) + .isTrue(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java index e3ecd5ed..2ae26199 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java @@ -3,21 +3,25 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.exception.FileAccessException; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.repository.RoomAccessResult; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.storage.StoragePort; import com.ktb.chatapp.storage.StoredObject; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Optional; -import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -57,6 +61,8 @@ class FileAccessServiceTest { @Mock private RoomRepository roomRepository; + private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + @Test @DisplayName("์ฐธ๊ฐ€์ž + ์˜คํ”„๋กœ๋”ฉ ์ง€์› ์Šคํ† ๋ฆฌ์ง€ โ†’ Redirect๋กœ ์˜คํ”„๋กœ๋”ฉ๋œ๋‹ค") void forDownload_participantWithOffloadSupport_returnsRedirect() { @@ -69,6 +75,14 @@ void forDownload_participantWithOffloadSupport_returnsRedirect() { assertThat(((FileAccess.Redirect) access).location()).isEqualTo(OFFLOADED_URL); assertThat(storage.offloadedKey).isEqualTo(KEY); assertThat(storage.openCalls).isZero(); + verify(messageRepository).findRoomOnlyByFileId(FILE_ID); + verify(messageRepository, never()).findByFileId(FILE_ID); + assertThat(meterRegistry.get("file.access.authorization.duration") + .tags("operation", "download", "outcome", "allowed", "reason", "participant") + .timer().count()).isEqualTo(1L); + assertThat(meterRegistry.get("file.access.delivery.duration") + .tags("operation", "download", "outcome", "success", "delivery", "redirect") + .timer().count()).isEqualTo(1L); } @Test @@ -95,11 +109,16 @@ void forDownload_nonParticipantWithOffloadSupport_rejectsBeforeIssuingUrl() { FileAccessService service = serviceWith(storage, "image/png"); assertThatThrownBy(() -> service.forDownload(FILE_NAME, OUTSIDER)) - .isInstanceOf(RuntimeException.class) - .hasMessage("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + .isInstanceOfSatisfying(FileAccessException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT)); assertThat(storage.offloadCalls).isZero(); assertThat(storage.openCalls).isZero(); + assertThat(meterRegistry.get("file.access.requests") + .tags("operation", "download", "outcome", "denied", "reason", "not_participant") + .counter() + .count()).isEqualTo(1.0); } @Test @@ -109,8 +128,9 @@ void forDownload_nonParticipantWithoutOffloadSupport_rejectsWithSameMessage() { FileAccessService service = serviceWith(storage, "image/png"); assertThatThrownBy(() -> service.forDownload(FILE_NAME, OUTSIDER)) - .isInstanceOf(RuntimeException.class) - .hasMessage("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + .isInstanceOfSatisfying(FileAccessException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT)); assertThat(storage.openCalls).isZero(); } @@ -180,7 +200,9 @@ void forView_nonParticipant_failsAuthorizationBeforePreviewCheck() { FileAccessService service = serviceWith(storage, "application/zip"); assertThatThrownBy(() -> service.forView(FILE_NAME, OUTSIDER)) - .hasMessage("ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + .isInstanceOfSatisfying(FileAccessException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT)); } @Test @@ -188,10 +210,13 @@ void forView_nonParticipant_failsAuthorizationBeforePreviewCheck() { void forDownload_missingFileEntity_throwsNotFound() { when(fileRepository.findByFilename(FILE_NAME)).thenReturn(Optional.empty()); FileAccessService service = new FileAccessService( - new DirectStorage(), fileRepository, messageRepository, roomRepository); + new DirectStorage(), fileRepository, messageRepository, roomRepository, + Optional.empty(), meterRegistry, "test-instance", "local", false); assertThatThrownBy(() -> service.forDownload(FILE_NAME, PARTICIPANT)) - .hasMessage("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + FILE_NAME); + .isInstanceOfSatisfying(FileAccessException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(FileAccessException.Reason.FILE_NOT_FOUND)); } @Test @@ -205,13 +230,27 @@ void forDownload_missingStoredObject_throwsNotFound() { .hasMessage("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + FILE_NAME); } + @Test + @DisplayName("์˜คํ”„๋กœ๋”ฉ ํ•„์ˆ˜ ํ™˜๊ฒฝ์—์„œ local storage๋Š” ์‹œ์ž‘ ์ „์— ๊ฑฐ๋ถ€๋œ๋‹ค") + void constructor_requiredOffloadWithLocalStorage_rejectsConfiguration() { + assertThatThrownBy(() -> new FileAccessService( + new DirectStorage(), fileRepository, messageRepository, roomRepository, + Optional.empty(), meterRegistry, "test-instance", "local", true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("FILE_STORAGE_TYPE=s3"); + } + private FileAccessService serviceWith(StoragePort storagePort, String mimetype) { when(fileRepository.findByFilename(FILE_NAME)).thenReturn(Optional.of(fileEntity(mimetype))); - when(messageRepository.findByFileId(FILE_ID)).thenReturn(Optional.of( + when(messageRepository.findRoomOnlyByFileId(FILE_ID)).thenReturn(Optional.of( Message.builder().id("message-id").roomId(ROOM_ID).fileId(FILE_ID).build())); - when(roomRepository.findById(ROOM_ID)).thenReturn(Optional.of( - Room.builder().id(ROOM_ID).participantIds(Set.of(PARTICIPANT)).build())); - return new FileAccessService(storagePort, fileRepository, messageRepository, roomRepository); + lenient().when(roomRepository.findAccessById(ROOM_ID, PARTICIPANT)) + .thenReturn(Optional.of(new RoomAccessResult(true))); + lenient().when(roomRepository.findAccessById(ROOM_ID, OUTSIDER)) + .thenReturn(Optional.of(new RoomAccessResult(false))); + return new FileAccessService( + storagePort, fileRepository, messageRepository, roomRepository, + Optional.empty(), meterRegistry, "test-instance", "local", false); } private File fileEntity(String mimetype) { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java new file mode 100644 index 00000000..6d34e97d --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java @@ -0,0 +1,78 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import com.ktb.chatapp.service.ratelimit.RateLimitRedisStore; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +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.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "socketio.enabled=false", + "rate-limit.store.type=redis" +}) +class RateLimitRedisStoreIntegrationTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private RedisConnectionFactory connectionFactory; + + @BeforeEach + void clearRedis() { + try (var connection = connectionFactory.getConnection()) { + connection.serverCommands().flushDb(); + } + } + + @Test + void rejectsRequestsAfterTheSharedLimit() { + var service = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue(); + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue(); + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isFalse(); + } + + @Test + void concurrentInstancesNeverAllowMoreThanTheSharedLimit() throws Exception { + int maxRequests = 25; + var firstInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + var secondInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + var executor = Executors.newFixedThreadPool(20); + try { + var futures = IntStream.range(0, 100) + .mapToObj(index -> executor.submit(() -> { + var instance = index % 2 == 0 ? firstInstance : secondInstance; + return instance.checkRateLimit( + "shared-client", maxRequests, Duration.ofMinutes(1)); + })) + .toList(); + + long allowed = 0; + for (var future : futures) { + if (future.get(10, TimeUnit.SECONDS).allowed()) { + allowed++; + } + } + + assertThat(allowed).isEqualTo(maxRequests); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java index 6e6c2ac4..b2113cbb 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java @@ -4,6 +4,9 @@ import com.ktb.chatapp.repository.RateLimitRepository; import java.time.Duration; import java.time.Instant; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -131,4 +134,25 @@ void checkRateLimit_IndependentLimitsPerClient() { assertThat(result2.allowed()).isTrue(); assertThat(result2.remaining()).isEqualTo(1); } + + @Test + @DisplayName("๋™์‹œ ์š”์ฒญ๋„ ์ธ์Šคํ„ด์Šค ๊ณตํ†ต ํ•œ๋„๋ฅผ ์ดˆ๊ณผํ•ด ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + void checkRateLimit_ConcurrentRequestsRespectGlobalLimit() throws Exception { + int maxRequests = 5; + var executor = Executors.newFixedThreadPool(10); + try { + var futures = IntStream.range(0, 20) + .mapToObj(ignored -> executor.submit(() -> rateLimitService.checkRateLimit( + "concurrent-client", maxRequests, Duration.ofSeconds(60)))) + .toList(); + + long allowed = 0; + for (var future : futures) { + if (future.get(10, TimeUnit.SECONDS).allowed()) allowed++; + } + assertThat(allowed).isEqualTo(maxRequests); + } finally { + executor.shutdownNow(); + } + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java index e4ac1b4d..d314ac4e 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java @@ -1,178 +1,69 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.model.RateLimit; +import com.ktb.chatapp.service.ratelimit.RateLimitConsumption; import com.ktb.chatapp.service.ratelimit.RateLimitStore; import java.time.Duration; import java.time.Instant; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; -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.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("RateLimitService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") class RateLimitServiceUnitTest { - private static final String HOST_NAME = "test-host"; - private static final String CLIENT_ID = "client-1"; - private static final String STORE_CLIENT_ID = HOST_NAME + ":" + CLIENT_ID; - - @Mock - private RateLimitStore rateLimitStore; - - private RateLimitService rateLimitService; + @Mock private RateLimitStore rateLimitStore; + private RateLimitService service; @BeforeEach void setUp() { - rateLimitService = new RateLimitService(rateLimitStore); - ReflectionTestUtils.setField(rateLimitService, "hostName", HOST_NAME); + service = new RateLimitService(rateLimitStore); } @Test - @DisplayName("์ตœ์ดˆ ์š”์ฒญ์€ host-prefixed clientId๋กœ ์ €์žฅ๋˜๊ณ  ๋‚จ์€ ํšŸ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void checkRateLimit_FirstRequest_SavesHostPrefixedClientId() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class); + void usesGlobalClientIdAndReturnsRemainingCount() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(30), true)); - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isTrue(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isEqualTo(2); - assertThat(result.windowSeconds()).isEqualTo(30); - assertThat(result.retryAfterSeconds()).isBetween(1L, 30L); - verify(rateLimitStore).save(rateLimitCaptor.capture()); - assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(STORE_CLIENT_ID); - assertThat(rateLimitCaptor.getValue().getCount()).isEqualTo(1); - assertThat(rateLimitCaptor.getValue().getExpiresAt()).isAfter(Instant.now()); } @Test - @DisplayName("๊ธฐ์กด ์นด์šดํŠธ๊ฐ€ ํ•œ๋„ ๋ฏธ๋งŒ์ด๋ฉด ์ฆ๊ฐ€์‹œ์ผœ ์ €์žฅํ•œ๋‹ค") - void checkRateLimit_ExistingBelowLimit_IncrementsCount() { - RateLimit existing = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(1) - .expiresAt(Instant.now().plusSeconds(20)) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing)); - when(rateLimitStore.save(existing)).thenReturn(existing); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + void rejectsWhenAtomicStoreRejects() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenReturn(new RateLimitConsumption(3, Instant.now().plusSeconds(10), false)); - assertThat(result.allowed()).isTrue(); - assertThat(result.remaining()).isEqualTo(1); - assertThat(existing.getCount()).isEqualTo(2); - verify(rateLimitStore).save(existing); - } - - @Test - @DisplayName("๊ธฐ์กด ์นด์šดํŠธ๊ฐ€ ํ•œ๋„์— ๋„๋‹ฌํ•˜๋ฉด retry-after์™€ reset epoch๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") - void checkRateLimit_LimitReached_ReturnsRetryAfterWithoutSaving() { - Instant expiresAt = Instant.now().plusSeconds(10); - RateLimit existing = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(3) - .expiresAt(expiresAt) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isFalse(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isZero(); - assertThat(result.retryAfterSeconds()).isBetween(1L, 10L); - assertThat(result.resetEpochSeconds()).isEqualTo(expiresAt.getEpochSecond()); - verify(rateLimitStore, never()).save(any(RateLimit.class)); - } - - @Test - @DisplayName("0์ดˆ window๋Š” ์ตœ์†Œ 1์ดˆ window๋กœ ์ •๊ทœํ™”๋œ๋‹ค") - void checkRateLimit_ZeroWindow_NormalizesToOneSecond() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ZERO); - - assertThat(result.allowed()).isTrue(); - assertThat(result.windowSeconds()).isEqualTo(1); - assertThat(result.retryAfterSeconds()).isPositive(); - } - - @Test - @DisplayName("null window๋Š” ์ตœ์†Œ 1์ดˆ window๋กœ ์ •๊ทœํ™”๋œ๋‹ค") - void checkRateLimit_NullWindow_NormalizesToOneSecond() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, null); - - assertThat(result.allowed()).isTrue(); - assertThat(result.windowSeconds()).isEqualTo(1); - assertThat(result.retryAfterSeconds()).isPositive(); } @Test - @DisplayName("๋งŒ๋ฃŒ๋œ ์ €์žฅ์†Œ ๋ฌธ์„œ๋Š” ์ƒˆ window๋กœ ๋ฆฌ์…‹๋œ๋‹ค") - void checkRateLimit_ExpiredStoredRateLimit_StartsNewWindow() { - RateLimit expired = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(3) - .expiresAt(Instant.now().minusSeconds(10)) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(expired)); - when(rateLimitStore.save(expired)).thenReturn(expired); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + void normalizesZeroWindowToOneSecond() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(1)), any())) + .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(1), true)); - assertThat(result.allowed()).isTrue(); - assertThat(result.remaining()).isEqualTo(2); - assertThat(result.retryAfterSeconds()).isBetween(1L, 30L); - assertThat(result.resetEpochSeconds()).isGreaterThan(Instant.now().getEpochSecond()); - assertThat(expired.getCount()).isEqualTo(1); - assertThat(expired.getExpiresAt()).isAfter(Instant.now()); - verify(rateLimitStore).save(expired); + assertThat(service.checkRateLimit("client-1", 3, Duration.ZERO).windowSeconds()).isEqualTo(1); } @Test - @DisplayName("์ €์žฅ์†Œ ์‹คํŒจ ์‹œ ์š”์ฒญ์€ ํ—ˆ์šฉํ•˜๊ณ  ์ „์ฒด ํ•œ๋„๋ฅผ ๋‚จ๊ธด๋‹ค") - void checkRateLimit_StoreFailure_FailsOpenDeterministically() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenThrow(new IllegalStateException("store down")); + void failsOpenWhenStoreIsUnavailable() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenThrow(new IllegalStateException("store down")); - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isTrue(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isEqualTo(3); - assertThat(result.windowSeconds()).isEqualTo(30); - assertThat(result.retryAfterSeconds()).isEqualTo(30); - } - - @Test - @DisplayName("null clientId๋„ host prefix๊ฐ€ ์ ์šฉ๋œ ์ €์žฅ์†Œ key๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค") - void checkRateLimit_NullClientId_UsesHostPrefixedNullKey() { - String storeClientId = HOST_NAME + ":null"; - when(rateLimitStore.findByClientId(storeClientId)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(null, 3, Duration.ofSeconds(30)); - - assertThat(result.allowed()).isTrue(); - verify(rateLimitStore).save(rateLimitCaptor.capture()); - assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(storeClientId); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java new file mode 100644 index 00000000..2968d334 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java @@ -0,0 +1,78 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.repository.RecentMessageCount; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecentMessageCounter ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class RecentMessageCounterTest { + + @Mock private MessageRepository messageRepository; + + private RecentMessageCounter recentMessageCounter; + + @BeforeEach + void setUp() { + recentMessageCounter = new RecentMessageCounter(messageRepository); + } + + @Test + @DisplayName("์—ฌ๋Ÿฌ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒํ•ด Map์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค") + void countRecentMessagesByRoomIds_LoadsCountsOnceAndMapsByRoomId() { + Set roomIds = Set.of("room-1", "room-2"); + when(messageRepository.countRecentMessagesByRoomIds( + eq(roomIds), any(LocalDateTime.class))) + .thenReturn(List.of( + new RecentMessageCount("room-1", 3), + new RecentMessageCount("room-2", 5))); + + Map result = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + assertThat(result).containsExactlyInAnyOrderEntriesOf( + Map.of("room-1", 3, "room-2", 5)); + verify(messageRepository).countRecentMessagesByRoomIds( + eq(roomIds), any(LocalDateTime.class)); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ ID๊ฐ€ ์—†์œผ๋ฉด ๋ฉ”์‹œ์ง€๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void countRecentMessagesByRoomIds_EmptyRoomIdsSkipsRepository() { + Map result = + recentMessageCounter.countRecentMessagesByRoomIds(Set.of()); + + assertThat(result).isEmpty(); + verifyNoInteractions(messageRepository); + } + + @Test + @DisplayName("๋‹จ๊ฑด ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์กฐํšŒ ๋™์ž‘์„ ์œ ์ง€ํ•œ๋‹ค") + void countRecentMessages_KeepsSingleRoomQuery() { + when(messageRepository.countRecentMessagesByRoomId( + eq("room-1"), any(LocalDateTime.class))) + .thenReturn(7L); + + int result = recentMessageCounter.countRecentMessages("room-1"); + + assertThat(result).isEqualTo(7); + verify(messageRepository).countRecentMessagesByRoomId( + eq("room-1"), any(LocalDateTime.class)); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java new file mode 100644 index 00000000..ae5e479f --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java @@ -0,0 +1,40 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "socketio.enabled=false", + "socketio.store.type=redis" +}) +class RoomActivityDebouncerIntegrationTest { + + @Autowired + private RoomActivityDebouncer debouncer; + + @Autowired + private RedissonClient redissonClient; + + @BeforeEach + void clearKeys() { + redissonClient.getKeys().deleteByPattern("room_activity:debounce:*"); + } + + @Test + void onlyOneInstanceCanAggregateTheSameRoomDuringTheWindow() { + assertThat(debouncer.tryAcquire("room-1")).isTrue(); + assertThat(debouncer.tryAcquire("room-1")).isFalse(); + assertThat(debouncer.tryAcquire("room-2")).isTrue(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java index 97d8c7ef..520484f3 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java @@ -1,6 +1,8 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.event.RoomActivityEvent; +import com.ktb.chatapp.event.RoomActivitiesEvent; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -8,7 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -21,52 +23,77 @@ class RoomActivityNotifierTest { @Mock private RecentMessageCounter recentMessageCounter; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private RoomActivityDebouncer roomActivityDebouncer; private RoomActivityNotifier notifier() { - return new RoomActivityNotifier(recentMessageCounter, eventPublisher); + return new RoomActivityNotifier(recentMessageCounter, eventPublisher, roomActivityDebouncer); } @Test - void notifyMessageStored_firstMessageOfRoom_publishesRecentMessageCount() { - when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(7); + void flushPublishesRecentMessageCount() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenReturn(Map.of("room-1", 7)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); + RoomActivityNotifier notifier = notifier(); - notifier().notifyMessageStored("room-1"); + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); - ArgumentCaptor eventCaptor = - ArgumentCaptor.forClass(RoomActivityEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertEquals("room-1", eventCaptor.getValue().getRoomId()); - assertEquals(7, eventCaptor.getValue().getRecentMessageCount()); + ArgumentCaptor event = ArgumentCaptor.forClass(RoomActivitiesEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().getRecentMessageCounts()).containsEntry("room-1", 7); } @Test - void notifyMessageStored_everyMessage_publishes() { - when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(1); + void flushCoalescesRepeatedNotificationsForSameRoom() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenReturn(Map.of("room-1", 3)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); RoomActivityNotifier notifier = notifier(); notifier.notifyMessageStored("room-1"); notifier.notifyMessageStored("room-1"); notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); + + verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1")); + verify(eventPublisher).publishEvent(any(RoomActivitiesEvent.class)); + } + + @Test + void nullRoomIdDoesNothing() { + RoomActivityNotifier notifier = notifier(); + notifier.notifyMessageStored(null); + notifier.flushPendingRoomActivities(); - verify(eventPublisher, times(3)).publishEvent(any(RoomActivityEvent.class)); - verify(recentMessageCounter, times(3)).countRecentMessages("room-1"); + verifyNoInteractions(recentMessageCounter, eventPublisher); } @Test - void notifyMessageStored_nullRoomId_doesNothing() { - notifier().notifyMessageStored(null); + void failedBatchIsRetriedOnNextFlush() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenThrow(new RuntimeException("mongo down")) + .thenReturn(Map.of("room-1", 1)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); + RoomActivityNotifier notifier = notifier(); + + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); + verify(eventPublisher, never()).publishEvent(any(RoomActivitiesEvent.class)); - verifyNoInteractions(recentMessageCounter); - verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class)); + notifier.flushPendingRoomActivities(); + verify(recentMessageCounter, times(2)).countRecentMessagesByRoomIds(Set.of("room-1")); + verify(eventPublisher).publishEvent(any(RoomActivitiesEvent.class)); } @Test - void notifyMessageStored_counterFails_swallowsException() { - when(recentMessageCounter.countRecentMessages("room-1")) - .thenThrow(new RuntimeException("mongo down")); + void distributedDebounceSkipsDuplicateAggregation() { + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(false); + RoomActivityNotifier notifier = notifier(); - notifier().notifyMessageStored("room-1"); + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); - verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class)); + verifyNoInteractions(recentMessageCounter, eventPublisher); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java new file mode 100644 index 00000000..5e3027fa --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -0,0 +1,173 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.dto.RoomListItemResponse; +import com.ktb.chatapp.dto.RoomsResponse; +import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.RoomRepository; +import com.ktb.chatapp.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class RoomServiceTest { + + @Mock private RoomRepository roomRepository; + @Mock private UserRepository userRepository; + @Mock private RecentMessageCounter recentMessageCounter; + @Mock private PasswordEncoder passwordEncoder; + @Mock private ApplicationEventPublisher eventPublisher; + + private RoomService roomService; + + @BeforeEach + void setUp() { + roomService = new RoomService( + roomRepository, + userRepository, + recentMessageCounter, + passwordEncoder, + eventPublisher); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ์„ ์ตœ์‹ ์ˆœ์œผ๋กœ 20๊ฐœ์”ฉ ์กฐํšŒํ•˜๊ณ  ํ˜„์žฌ ํŽ˜์ด์ง€ ๋ฒ”์œ„๋งŒ ์ง‘๊ณ„ํ•œ๋‹ค") + void getAllRooms_LoadsOnlyRequestedPageInDescendingOrder() { + LocalDateTime createdAt = LocalDateTime.of(2026, 8, 10, 10, 0); + Room room2 = room("room-2", Set.of("user-1", "user-2"), createdAt); + Room room1 = room("room-1", Set.of("user-1"), createdAt); + PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE); + + when(roomRepository.findAll(any(Pageable.class))).thenReturn( + new PageImpl<>(List.of(room2, room1), requestedPage, 42)); + when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())) + .thenReturn(Map.of("room-2", 5)); + + RoomsResponse response = roomService.getAllRooms(1); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).extracting(RoomListItemResponse::getId) + .containsExactly("room-2", "room-1"); + assertThat(response.getData()).extracting(RoomListItemResponse::getParticipantCount) + .containsExactly(2, 1); + assertThat(response.getData()).extracting(RoomListItemResponse::getRecentMessageCount) + .containsExactly(5, 0); + assertThat(response.getMetadata().getTotal()).isEqualTo(42); + assertThat(response.getMetadata().getPage()).isEqualTo(1); + assertThat(response.getMetadata().getPageSize()).isEqualTo(20); + assertThat(response.getMetadata().getTotalPages()).isEqualTo(3); + assertThat(response.getMetadata().isHasMore()).isTrue(); + assertThat(response.getMetadata().getCurrentCount()).isEqualTo(2); + assertThat(response.getMetadata().getSort().getField()).isEqualTo("createdAt"); + assertThat(response.getMetadata().getSort().getOrder()).isEqualTo("DESC"); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(roomRepository).findAll(pageableCaptor.capture()); + Pageable pageable = pageableCaptor.getValue(); + assertThat(pageable.getPageNumber()).isEqualTo(1); + assertThat(pageable.getPageSize()).isEqualTo(20); + assertThat(pageable.getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(Sort.Direction.DESC); + assertThat(pageable.getSort().getOrderFor("id").getDirection()) + .isEqualTo(Sort.Direction.DESC); + verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1", "room-2")); + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚œ ๋นˆ ํŽ˜์ด์ง€๋Š” ์ถ”๊ฐ€ ์กฐํšŒ ์—†์ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ํ•จ๊ป˜ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getAllRooms_OutOfRangePageSkipsRelatedQueries() { + PageRequest requestedPage = PageRequest.of(3, RoomService.ROOM_LIST_PAGE_SIZE); + when(roomRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requestedPage, 2)); + + RoomsResponse response = roomService.getAllRooms(3); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + assertThat(response.getMetadata().getTotal()).isEqualTo(2); + assertThat(response.getMetadata().getPage()).isEqualTo(3); + assertThat(response.getMetadata().getCurrentCount()).isZero(); + assertThat(response.getMetadata().isHasMore()).isFalse(); + verifyNoInteractions(userRepository, recentMessageCounter); + } + + @Test + @DisplayName("๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€๋Š” ๋‹ค์Œ ํŽ˜์ด์ง€๊ฐ€ ์—†๋‹ค๊ณ  ํ‘œ์‹œํ•œ๋‹ค") + void getAllRooms_LastPageHasNoMorePages() { + Room room = room( + "room-21", + Set.of("user-1"), + LocalDateTime.of(2026, 8, 10, 10, 0)); + PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE); + when(roomRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(room), requestedPage, 21)); + when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())).thenReturn(Map.of()); + + RoomsResponse response = roomService.getAllRooms(1); + + assertThat(response.getMetadata().getTotalPages()).isEqualTo(2); + assertThat(response.getMetadata().isHasMore()).isFalse(); + verify(userRepository, never()).findAllById(anySet()); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ ์ž…์žฅ์€ ์ฐธ์—ฌ์ž๋ฅผ ์›์ž์ ์œผ๋กœ ์ถ”๊ฐ€ํ•˜๊ณ  ์ „์ฒด ๋ฌธ์„œ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") + void joinRoom_AddsParticipantAtomically() { + User user = User.builder() + .id("user-2") + .email("user-2@example.com") + .build(); + Room beforeJoin = room( + "room-1", + Set.of("user-1"), + LocalDateTime.of(2026, 8, 10, 10, 0)); + when(roomRepository.findById("room-1")) + .thenReturn(Optional.of(beforeJoin)); + when(userRepository.findByEmail("user-2@example.com")) + .thenReturn(Optional.of(user)); + when(roomRepository.addParticipant("room-1", "user-2")).thenReturn(1L); + Room joinedRoom = roomService.joinRoom("room-1", null, "user-2@example.com"); + + assertThat(joinedRoom.getId()).isEqualTo("room-1"); + verify(roomRepository).addParticipant("room-1", "user-2"); + verify(roomRepository).findById("room-1"); + verify(recentMessageCounter, never()).countRecentMessages(any()); + verify(roomRepository, never()).save(any(Room.class)); + } + + private Room room(String id, Set participantIds, LocalDateTime createdAt) { + return Room.builder() + .id(id) + .name("์ฑ„ํŒ…๋ฐฉ " + id) + .creator("user-1") + .participantIds(participantIds) + .createdAt(createdAt) + .build(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java new file mode 100644 index 00000000..43176496 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java @@ -0,0 +1,74 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import com.ktb.chatapp.service.session.SessionRedisStore; +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.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.TestPropertySource; +import tools.jackson.databind.ObjectMapper; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "socketio.enabled=false", + "session.store.type=redis" +}) +class SessionRedisStoreIntegrationTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private RedisConnectionFactory connectionFactory; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void clearRedis() { + try (var connection = connectionFactory.getConnection()) { + connection.serverCommands().flushDb(); + } + } + + @Test + void sessionCreatedOnOneInstanceIsValidatedOnAnother() { + var firstInstance = service(); + var secondInstance = service(); + + var created = firstInstance.createSession( + "shared-user", new SessionMetadata("agent", "127.0.0.1", "device")); + + var validation = secondInstance.validateSession("shared-user", created.getSessionId()); + + assertThat(validation.isValid()).isTrue(); + assertThat(validation.getSession().getMetadata().deviceInfo()).isEqualTo("device"); + } + + @Test + void replacingSessionPreventsStaleInstanceFromDeletingTheNewSession() { + var firstInstance = service(); + var secondInstance = service(); + var oldSession = firstInstance.createSession("shared-user", null); + var newSession = secondInstance.createSession("shared-user", null); + + firstInstance.removeSession("shared-user", oldSession.getSessionId()); + + assertThat(secondInstance.validateSession("shared-user", oldSession.getSessionId()).isValid()) + .isFalse(); + assertThat(secondInstance.validateSession("shared-user", newSession.getSessionId()).isValid()) + .isTrue(); + } + + private SessionService service() { + return new SessionService(new SessionRedisStore(redisTemplate, objectMapper)); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java index 1dea3427..a430a2bf 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java @@ -1,12 +1,21 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.model.Session; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.test.context.TestPropertySource; import static org.assertj.core.api.Assertions.assertThat; @@ -28,6 +37,9 @@ class SessionServiceTest { @Autowired private SessionService sessionService; + @Autowired + private MongoTemplate mongoTemplate; + private static final String TEST_USER_ID = "test-user-123"; private static final String TEST_USER_ID_2 = "test-user-456"; private static final String TEST_IP = "127.0.0.1"; @@ -70,8 +82,8 @@ void createSession_Success() { } @Test - @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์‹œ ๊ธฐ์กด ์„ธ์…˜ ์ œ๊ฑฐ") - void createSession_RemovesExistingSession() { + @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์‹œ ๊ธฐ์กด ์„ธ์…˜ ๊ต์ฒด") + void createSession_ReplacesExistingSession() { // Given - ์ฒซ ๋ฒˆ์งธ ์„ธ์…˜ ์ƒ์„ฑ SessionMetadata metadata = createTestMetadata(); SessionCreationResult firstSession = sessionService.createSession(TEST_USER_ID, metadata); @@ -167,8 +179,8 @@ void validateSession_NonExistentUser_Failure() { } @Test - @DisplayName("์„ธ์…˜ ๊ฒ€์ฆ - lastActivity ์—…๋ฐ์ดํŠธ") - void validateSession_UpdatesLastActivity() throws InterruptedException { + @DisplayName("์ตœ๊ทผ ํ™œ๋™ํ•œ ์„ธ์…˜ ๊ฒ€์ฆ์€ lastActivity๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ") + void validateSession_DoesNotUpdateRecentLastActivity() throws InterruptedException { // Given SessionMetadata metadata = createTestMetadata(); SessionCreationResult created = sessionService.createSession(TEST_USER_ID, metadata); @@ -181,7 +193,7 @@ void validateSession_UpdatesLastActivity() throws InterruptedException { // Then assertTrue(result.isValid()); - assertThat(result.getSession().getLastActivity()).isGreaterThan(initialLastActivity); + assertThat(result.getSession().getLastActivity()).isEqualTo(initialLastActivity); } // ============ ์„ธ์…˜ ํ™œ๋™ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ ============ @@ -300,6 +312,42 @@ void getActiveSession_NoSession_ReturnsNull() { // ============ ๋™์‹œ์„ฑ ๋ฐ ๋ฉ€ํ‹ฐ ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ ============ + @Test + @DisplayName("๋™์ผ ์‚ฌ์šฉ์ž์˜ ๋™์‹œ ์„ธ์…˜ ์ƒ์„ฑ์€ ํ•œ ๋ฌธ์„œ๋งŒ ์œ ์ง€ํ•œ๋‹ค") + void createSession_ConcurrentSameUser_KeepsSingleDocument() throws Exception { + int attempts = 20; + ExecutorService executor = Executors.newFixedThreadPool(attempts); + List> tasks = java.util.stream.IntStream.range(0, attempts) + .mapToObj(index -> (Callable) () -> + sessionService.createSession(TEST_USER_ID, createTestMetadata())) + .toList(); + + try { + List> futures = executor.invokeAll(tasks); + List issuedSessionIds = futures.stream() + .map(future -> { + try { + return future.get().getSessionId(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .toList(); + + long storedSessions = mongoTemplate.count( + Query.query(Criteria.where("userId").is(TEST_USER_ID)), + Session.class + ); + SessionData activeSession = sessionService.getActiveSession(TEST_USER_ID); + + assertEquals(1L, storedSessions); + assertNotNull(activeSession); + assertTrue(issuedSessionIds.contains(activeSession.getSessionId())); + } finally { + executor.shutdownNow(); + } + } + @Test @DisplayName("์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž์˜ ๋…๋ฆฝ์ ์ธ ์„ธ์…˜ ๊ด€๋ฆฌ") void multipleSessions_IndependentUsers() { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java index 55901111..bdf587d4 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java @@ -16,6 +16,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,17 +37,17 @@ class SessionServiceUnitTest { private SessionService sessionService; @Test - @DisplayName("์„ธ์…˜ ์ƒ์„ฑ์€ ๊ธฐ์กด ์‚ฌ์šฉ์ž ์„ธ์…˜์„ ์ œ๊ฑฐํ•œ ๋’ค ์ƒˆ ์„ธ์…˜์„ ์ €์žฅํ•œ๋‹ค") - void createSession_RemovesExistingSessionsBeforeSave() { + @DisplayName("์„ธ์…˜ ์ƒ์„ฑ์€ ์‚ฌ์šฉ์ž ๊ธฐ์ค€์œผ๋กœ ์›์ž์  ๊ต์ฒดํ•œ๋‹ค") + void createSession_AtomicallyReplacesSessionByUserId() { ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(Session.class); - when(sessionStore.save(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(sessionStore.replaceByUserId(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0)); SessionCreationResult result = sessionService.createSession( USER_ID, new SessionMetadata("agent", "127.0.0.1", "device")); - verify(sessionStore).deleteAll(USER_ID); - verify(sessionStore).save(sessionCaptor.capture()); + verify(sessionStore, never()).deleteAll(USER_ID); + verify(sessionStore).replaceByUserId(sessionCaptor.capture()); Session savedSession = sessionCaptor.getValue(); assertThat(result.getSessionId()).isEqualTo(savedSession.getSessionId()); assertThat(result.getExpiresIn()).isEqualTo(SessionService.SESSION_TTL_SEC); @@ -56,7 +58,7 @@ void createSession_RemovesExistingSessionsBeforeSave() { @Test @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์ค‘ ์ €์žฅ์†Œ ์‹คํŒจ๋Š” RuntimeException์œผ๋กœ ๋ž˜ํ•‘๋œ๋‹ค") void createSession_StoreFailure_ThrowsRuntimeException() { - doThrow(new IllegalStateException("store down")).when(sessionStore).deleteAll(USER_ID); + doThrow(new IllegalStateException("store down")).when(sessionStore).replaceByUserId(any(Session.class)); RuntimeException exception = assertThrows( RuntimeException.class, @@ -65,6 +67,7 @@ void createSession_StoreFailure_ThrowsRuntimeException() { assertThat(exception).hasMessage("์„ธ์…˜ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); assertThat(exception).hasRootCauseInstanceOf(IllegalStateException.class); verify(sessionStore, never()).save(any(Session.class)); + verify(sessionStore, never()).deleteAll(USER_ID); } @Test @@ -123,6 +126,42 @@ void validateSession_StoreFailure_ReturnsValidationError() { assertThat(result.getError()).isEqualTo("VALIDATION_ERROR"); } + @Test + @DisplayName("์ตœ๊ทผ ํ™œ๋™ํ•œ ์œ ํšจ ์„ธ์…˜์€ ๊ฒ€์ฆ ์‹œ ๋‹ค์‹œ ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") + void validateSession_RecentlyActive_DoesNotWriteSession() { + Session session = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .lastActivity(Instant.now().toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session)); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isTrue(); + verify(sessionStore, never()).save(any(Session.class)); + } + + @Test + @DisplayName("30์ดˆ ์ด์ƒ ์ง€๋‚œ ์œ ํšจ ์„ธ์…˜์€ ๊ฒ€์ฆ ์‹œ ํ™œ๋™ ์‹œ๊ฐ„์„ ๊ฐฑ์‹ ํ•œ๋‹ค") + void validateSession_StaleActivity_UpdatesSession() { + Session session = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .lastActivity(Instant.now().minusSeconds(31).toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session)); + when(sessionStore.touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class))) + .thenReturn(Optional.of(session)); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isTrue(); + verify(sessionStore).touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class)); + } + @Test @DisplayName("ํ™œ์„ฑ ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์ €์žฅ์†Œ ์‹คํŒจ๋Š” null๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค") void getActiveSession_StoreFailure_ReturnsNull() { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java index 46595baa..d38c78bf 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java @@ -4,10 +4,14 @@ import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.storage.LocalStorage; +import com.ktb.chatapp.storage.StoragePort; +import com.ktb.chatapp.storage.StoredObjectMetadata; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.net.URI; +import java.time.Duration; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -18,12 +22,18 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) @DisplayName("UserService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") @@ -37,6 +47,9 @@ class UserServiceTest { @Mock private FileService fileService; + @Mock + private MongoOperations mongoOperations; + private UserService userService; @TempDir @@ -48,7 +61,8 @@ class UserServiceTest { */ @BeforeEach void setUp() { - userService = new UserService(userRepository, fileService, new LocalStorage(uploadDir.toString())); + userService = new UserService( + userRepository, fileService, new LocalStorage(uploadDir.toString()), mongoOperations); ReflectionTestUtils.setField(userService, "maxProfileImageSize", 5242880L); } @@ -69,18 +83,76 @@ void uploadProfileImage_DeletesOldProfileImageFile() throws IOException { .email(EMAIL) .profileImage("profiles/old.jpg") .build(); - when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); when(fileService.storeFile(any(), eq("profiles"))).thenReturn("profiles/new.jpg"); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenReturn(user); MockMultipartFile file = new MockMultipartFile( "file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes()); ProfileImageResponse response = userService.uploadProfileImage(EMAIL, file); assertThat(Files.exists(oldFile)).isFalse(); - assertThat(user.getProfileImage()).isEqualTo("profiles/new.jpg"); assertThat(response.getImageUrl()).isEqualTo("/api/files/profiles/new.jpg"); } + @Test + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ DB ์ €์žฅ ์‹คํŒจ ์‹œ ๊ธฐ์กด ํŒŒ์ผ์„ ๋ณด์กดํ•˜๊ณ  ์ƒˆ ํŒŒ์ผ์„ ์ •๋ฆฌํ•œ๋‹ค") + void uploadProfileImage_WhenDatabaseSaveFails_PreservesOldFileAndDeletesNewFile() throws IOException { + Path oldFile = createOldProfileImageFile("old-save-failure.jpg"); + Path newFile = createOldProfileImageFile("new-save-failure.jpg"); + User user = User.builder() + .id("user-1") + .email(EMAIL) + .profileImage("profiles/old-save-failure.jpg") + .build(); + when(fileService.storeFile(any(), eq("profiles"))) + .thenReturn("profiles/new-save-failure.jpg"); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenThrow(new RuntimeException("mongo unavailable")); + MockMultipartFile file = new MockMultipartFile( + "file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes()); + + assertThatThrownBy(() -> userService.uploadProfileImage(EMAIL, file)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mongo unavailable"); + + assertThat(oldFile).exists(); + assertThat(newFile).doesNotExist(); + } + + @Test + @DisplayName("ํ”„๋กœํ•„ ์ง์ ‘ ์—…๋กœ๋“œ๋Š” ํŒŒ์ผ ๋ณธ๋ฌธ์„ API๋กœ ๋ฐ›์ง€ ์•Š๊ณ  presign๊ณผ metadata ํ™•์ธ๋งŒ ์ˆ˜ํ–‰ํ•œ๋‹ค") + void directProfileUpload_UsesPresignAndMetadataOnly() { + StoragePort storagePort = mock(StoragePort.class); + UserService directService = new UserService( + userRepository, fileService, storagePort, mongoOperations); + ReflectionTestUtils.setField(directService, "maxProfileImageSize", 5242880L); + ReflectionTestUtils.setField(directService, "profilePresignTtl", Duration.ofMinutes(10)); + User user = User.builder().id("user-1").email(EMAIL).build(); + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(storagePort.presignUploadUrl(any(), eq("image/jpeg"), eq(Duration.ofMinutes(10)))) + .thenReturn(Optional.of(URI.create("https://s3.example/upload"))); + + UserService.PreparedProfileUpload prepared = directService.prepareProfileImageUpload( + EMAIL, "profile.jpg", "image/jpeg", 1024L); + + assertThat(prepared.uploadUrl()).isEqualTo("https://s3.example/upload"); + assertThat(prepared.key()).startsWith("profiles/user-1_"); + + when(storagePort.stat(prepared.key())) + .thenReturn(Optional.of(new StoredObjectMetadata(1024L, "image/jpeg"))); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenReturn(user); + + ProfileImageResponse response = directService.completeProfileImageUpload( + EMAIL, prepared.key(), "profile.jpg", "image/jpeg", 1024L); + + assertThat(response.getImageUrl()).isEqualTo("/api/files/" + prepared.key()); + } + @Test @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์‹œ ๊ธฐ์กด ์ด๋ฏธ์ง€ ์‹ค๋ฌผ ํŒŒ์ผ์„ ์‚ญ์ œํ•œ๋‹ค") void deleteProfileImage_DeletesProfileImageFile() throws IOException { @@ -90,11 +162,32 @@ void deleteProfileImage_DeletesProfileImageFile() throws IOException { .email(EMAIL) .profileImage("profiles/old2.jpg") .build(); - when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenReturn(user); userService.deleteProfileImage(EMAIL); assertThat(Files.exists(oldFile)).isFalse(); - assertThat(user.getProfileImage()).isEmpty(); + } + + @Test + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ DB ์‚ญ์ œ ๋ฐ˜์˜ ์‹คํŒจ ์‹œ ๊ธฐ์กด ํŒŒ์ผ์„ ๋ณด์กดํ•œ๋‹ค") + void deleteProfileImage_WhenDatabaseSaveFails_PreservesProfileImageFile() throws IOException { + Path oldFile = createOldProfileImageFile("delete-save-failure.jpg"); + User user = User.builder() + .id("user-1") + .email(EMAIL) + .profileImage("profiles/delete-save-failure.jpg") + .build(); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenThrow(new RuntimeException("mongo unavailable")); + + assertThatThrownBy(() -> userService.deleteProfileImage(EMAIL)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mongo unavailable"); + + assertThat(oldFile).exists(); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java index 0b4c3ed3..d3ce2418 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java @@ -52,8 +52,10 @@ void open_returnsResourceForStoredKey() throws Exception { Optional resource = localStorage.open("chat/photo.jpg"); assertThat(resource).isPresent(); - assertThat(StreamUtils.copyToString(resource.get().getInputStream(), StandardCharsets.UTF_8)) - .isEqualTo("hello"); + try (InputStream inputStream = resource.get().getInputStream()) { + assertThat(StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8)) + .isEqualTo("hello"); + } } @Test diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java new file mode 100644 index 00000000..2bd918dd --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java @@ -0,0 +1,110 @@ +package com.ktb.chatapp.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.ContentDisposition; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@DisplayName("S3Storage ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class S3StorageTest { + + private final S3Client s3Client = mock(S3Client.class); + private final S3Presigner presigner = mock(S3Presigner.class); + private final S3Storage storage = new S3Storage( + s3Client, presigner, "profile-bucket", "loadtest/run-1", Duration.ofMinutes(10)); + + @Test + void profileUploadUsesPrefixContentTypeAndImmutableCache() { + byte[] content = "image".getBytes(); + + StoredObject result = storage.put( + new ByteArrayInputStream(content), "profiles/avatar.jpg", "image/jpeg", content.length); + + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(request.capture(), any(RequestBody.class)); + assertThat(request.getValue().bucket()).isEqualTo("profile-bucket"); + assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg"); + assertThat(request.getValue().contentType()).isEqualTo("image/jpeg"); + assertThat(request.getValue().cacheControl()).isEqualTo(S3Storage.PROFILE_CACHE_CONTROL); + assertThat(result.key()).isEqualTo("profiles/avatar.jpg"); + } + + @Test + void chatUploadUsesBoundedPrivateCache() { + byte[] content = "image".getBytes(); + + storage.put( + new ByteArrayInputStream(content), + "chat/user-1/file.jpg", + "image/jpeg", + content.length); + + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(request.capture(), any(RequestBody.class)); + assertThat(request.getValue().cacheControl()).isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL); + } + + @Test + void deleteUsesSamePhysicalPrefix() { + storage.delete("profiles/avatar.jpg"); + + ArgumentCaptor request = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(request.capture()); + assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg"); + } + + @Test + void offloadUrlReturnsPresignedGetUrl() throws Exception { + PresignedGetObjectRequest signed = mock(PresignedGetObjectRequest.class); + when(signed.url()).thenReturn(new URL("https://example.test/signed-profile")); + when(presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(signed); + + URI result = storage.offloadUrl( + "profiles/avatar.jpg", + Duration.ofMinutes(10), + ContentDisposition.inline().filename("avatar.jpg").build()) + .orElseThrow(); + + assertThat(result).isEqualTo(URI.create("https://example.test/signed-profile")); + ArgumentCaptor request = + ArgumentCaptor.forClass(GetObjectPresignRequest.class); + verify(presigner).presignGetObject(request.capture()); + assertThat(request.getValue().getObjectRequest().responseCacheControl()) + .isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL); + } + + @Test + void directUploadSignsContentTypeAndBoundedPrivateCache() throws Exception { + PresignedPutObjectRequest signed = mock(PresignedPutObjectRequest.class); + when(signed.url()).thenReturn(new URL("https://example.test/signed-upload")); + when(presigner.presignPutObject(any(PutObjectPresignRequest.class))).thenReturn(signed); + + storage.presignUploadUrl("chat/user-1/file.jpg", "image/jpeg", Duration.ofMinutes(10)); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PutObjectPresignRequest.class); + verify(presigner).presignPutObject(request.capture()); + assertThat(request.getValue().putObjectRequest().contentType()).isEqualTo("image/jpeg"); + assertThat(request.getValue().putObjectRequest().cacheControl()) + .isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java new file mode 100644 index 00000000..b077a01d --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java @@ -0,0 +1,41 @@ +package com.ktb.chatapp.websocket.socketio; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConnectedUsersTest { + + @Mock private ChatDataStore store; + + @Test + void refreshesLeaseOnlyForCurrentOwner() { + SocketUser current = new SocketUser("user-1", "name", "session-1", "socket-1"); + String key = "conn_users:userid:user-1"; + when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current)); + when(store.refresh(key)).thenReturn(true); + + assertThat(new ConnectedUsers(store).refreshIfCurrent(current)).isTrue(); + verify(store).refresh(key); + } + + @Test + void staleSocketCannotRefreshNewOwnersLease() { + SocketUser stale = new SocketUser("user-1", "name", "session-1", "socket-1"); + SocketUser current = new SocketUser("user-1", "name", "session-2", "socket-2"); + String key = "conn_users:userid:user-1"; + when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current)); + + assertThat(new ConnectedUsers(store).refreshIfCurrent(stale)).isFalse(); + verify(store, never()).refresh(key); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java new file mode 100644 index 00000000..e9df5472 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java @@ -0,0 +1,72 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.corundumstudio.socketio.SocketIOClient; +import com.corundumstudio.socketio.SocketIOServer; +import com.corundumstudio.socketio.listener.PongListener; + +@ExtendWith(MockitoExtension.class) +class PresenceLeaseRefresherTest { + + @Mock + private SocketIOServer socketIOServer; + @Mock + private ConnectedUsers connectedUsers; + @Mock + private SocketIOClient client; + + @Test + void pongRefreshesOnlyAuthenticatedSocketPresence() { + SocketUser user = new SocketUser("user-1", "name", "session-1", "socket-1"); + ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class); + new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ZERO); + verify(socketIOServer).addPongListener(listener.capture()); + + when(client.get("user")).thenReturn(user); + when(connectedUsers.refreshIfCurrent(user)).thenReturn(true); + listener.getValue().onPong(client); + + verify(connectedUsers).refreshIfCurrent(user); + verify(client).set(eq(PresenceLeaseRefresher.LAST_PRESENCE_REFRESH_AT), anyLong()); + } + + @Test + void pongWithoutAuthenticatedUserDoesNotRefreshPresence() { + ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class); + new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ZERO); + verify(socketIOServer).addPongListener(listener.capture()); + + when(client.get("user")).thenReturn(null); + listener.getValue().onPong(client); + + verify(connectedUsers, never()).refreshIfCurrent(org.mockito.ArgumentMatchers.any()); + } + + @Test + void pongWithinRefreshIntervalDoesNotTouchPresenceStore() { + SocketUser user = new SocketUser("user-1", "name", "session-1", "socket-1"); + ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class); + new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ofMinutes(1)); + verify(socketIOServer).addPongListener(listener.capture()); + + when(client.get("user")).thenReturn(user); + when(client.get(PresenceLeaseRefresher.LAST_PRESENCE_REFRESH_AT)) + .thenReturn(System.currentTimeMillis()); + + listener.getValue().onPong(client); + + verify(connectedUsers, never()).refreshIfCurrent(user); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java new file mode 100644 index 00000000..8ac4b882 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java @@ -0,0 +1,76 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class RedisChatDataStoreIntegrationTest { + + @Container + static final GenericContainer REDIS = new GenericContainer<>("redis:8.8.0-alpine") + .withExposedPorts(6379); + + private static RedissonClient firstClient; + private static RedissonClient secondClient; + private static RedisChatDataStore firstStore; + private static RedisChatDataStore secondStore; + + @BeforeAll + static void setUp() { + firstClient = createClient(); + secondClient = createClient(); + firstStore = new RedisChatDataStore(firstClient); + secondStore = new RedisChatDataStore(secondClient); + } + + @AfterAll + static void tearDown() { + if (firstClient != null) firstClient.shutdown(); + if (secondClient != null) secondClient.shutdown(); + } + + @Test + void connectionOwnershipIsSharedAndConditionallyDeletedAcrossInstances() { + String key = "conn_users:userid:integration-user"; + SocketUser first = new SocketUser("integration-user", "name", "session-1", "socket-1"); + SocketUser second = new SocketUser("integration-user", "name", "session-2", "socket-2"); + + firstStore.set(key, first); + assertThat(secondStore.getAndSet(key, second, SocketUser.class)).isEqualTo(first); + assertThat(firstStore.compareAndDelete(key, first)).isFalse(); + assertThat(firstStore.compareAndDelete(key, second)).isTrue(); + assertThat(secondStore.get(key, SocketUser.class)).isEmpty(); + } + + @Test + void roomSetUpdatesAreVisibleAcrossInstances() { + String key = "userroom:roomids:integration-user"; + + firstStore.addToSet(key, "room-1"); + secondStore.addToSet(key, "room-2"); + assertThat(firstStore.getSet(key)).isEqualTo(Set.of("room-1", "room-2")); + assertThat(secondStore.setContains(key, "room-1")).isTrue(); + assertThat(secondStore.setContains(key, "missing-room")).isFalse(); + + secondStore.removeFromSet(key, "room-1"); + assertThat(firstStore.getSet(key)).containsExactly("room-2"); + firstStore.delete(key); + } + + private static RedissonClient createClient() { + Config config = new Config(); + config.useSingleServer().setAddress( + "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379)); + return Redisson.create(config); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java index 42453f6a..5b6aa198 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java @@ -2,10 +2,12 @@ import com.corundumstudio.socketio.BroadcastOperations; import com.corundumstudio.socketio.SocketIOServer; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; +import com.ktb.chatapp.event.RoomActivitiesEvent; import com.ktb.chatapp.event.RoomActivityEvent; import com.ktb.chatapp.event.RoomUpdatedEvent; import com.ktb.chatapp.event.SessionEndedEvent; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -70,7 +73,7 @@ void handleSessionEndedEvent_swallowsBroadcastException() { @Test void handleRoomUpdatedEvent_sendsRoomUpdatedToRoomList() { - RoomResponse roomResponse = RoomResponse.builder() + RoomListItemResponse roomResponse = RoomListItemResponse.builder() .id("room-1") .name("Updated room") .build(); @@ -96,4 +99,32 @@ void handleRoomActivityEvent_sendsRecentMessageCountToRoomList() { assertEquals("room-1", payload.get("_id")); assertEquals(12, payload.get("recentMessageCount")); } + + @Test + void handleRoomActivitiesEvent_sendsBatchWithSingleRoomListLookup() { + RoomActivitiesEvent event = new RoomActivitiesEvent(this, Map.of( + "room-1", 12, + "room-2", 3 + )); + when(socketIOServer.getRoomOperations("room-list")).thenReturn(roomListOperations); + + listener.handleRoomActivitiesEvent(event); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); + verify(socketIOServer, times(1)).getRoomOperations("room-list"); + verify(roomListOperations, times(2)).sendEvent(eq(ROOM_ACTIVITY), payloadCaptor.capture()); + + List payloads = payloadCaptor.getAllValues(); + assertEquals(12, recentMessageCountFor(payloads, "room-1")); + assertEquals(3, recentMessageCountFor(payloads, "room-2")); + } + + private int recentMessageCountFor(List payloads, String roomId) { + return payloads.stream() + .map(payload -> (Map) payload) + .filter(payload -> roomId.equals(payload.get("_id"))) + .map(payload -> (Integer) payload.get("recentMessageCount")) + .findFirst() + .orElseThrow(); + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java index be9e2a56..702ccd54 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java @@ -7,12 +7,9 @@ import com.ktb.chatapp.dto.MessageResponse; import com.ktb.chatapp.model.Message; import com.ktb.chatapp.model.MessageType; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RoomActivityNotifier; @@ -20,13 +17,12 @@ import com.ktb.chatapp.service.SessionValidationResult; import com.ktb.chatapp.util.BannedWordChecker; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; import com.ktb.chatapp.websocket.socketio.ai.AiService; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.time.LocalDateTime; -import java.util.HashSet; import java.util.Map; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,14 +41,14 @@ class ChatMessageHandlerTest { @Mock private SocketIOServer socketIOServer; @Mock private MessageRepository messageRepository; - @Mock private RoomRepository roomRepository; - @Mock private UserRepository userRepository; @Mock private FileRepository fileRepository; @Mock private AiService aiService; @Mock private SessionService sessionService; @Mock private RoomActivityNotifier roomActivityNotifier; @Mock private BannedWordChecker bannedWordChecker; @Mock private RateLimitService rateLimitService; + @Mock private UserRooms userRooms; + @Mock private RoomRepository roomRepository; private MeterRegistry meterRegistry = new SimpleMeterRegistry(); private ChatMessageHandler handler; @@ -63,14 +59,14 @@ void setUp() { new ChatMessageHandler( socketIOServer, messageRepository, - roomRepository, - userRepository, fileRepository, aiService, sessionService, roomActivityNotifier, bannedWordChecker, rateLimitService, + userRooms, + roomRepository, meterRegistry); } @@ -88,14 +84,7 @@ void handleChatMessage_blocksMessagesContainingBannedWords() { when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) .thenReturn(allowedResult); - User user = new User(); - user.setId("user-1"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - Room room = new Room(); - room.setId("room-1"); - room.setParticipantIds(new HashSet<>(java.util.List.of("user-1"))); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); ChatMessageRequest request = ChatMessageRequest.builder() @@ -117,7 +106,7 @@ void handleChatMessage_blocksMessagesContainingBannedWords() { } @Test - void handleChatMessage_echoesSavedMessageToSenderSocket() { + void handleChatMessage_broadcastsSavedMessageOnceThroughRoom() { SocketIOClient client = mock(SocketIOClient.class); BroadcastOperations roomOperations = mock(BroadcastOperations.class); SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); @@ -128,15 +117,7 @@ void handleChatMessage_echoesSavedMessageToSenderSocket() { when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) .thenReturn(RateLimitCheckResult.allowed(10000, 9999, 60, System.currentTimeMillis() / 1000 + 60, 60)); - User user = new User(); - user.setId("user-1"); - user.setName("Tester"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - Room room = new Room(); - room.setId("room-1"); - room.setParticipantIds(new HashSet<>(java.util.List.of("user-1"))); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); when(bannedWordChecker.containsBannedWord("hello")).thenReturn(false); when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); when(messageRepository.save(any(Message.class))).thenAnswer(invocation -> { @@ -157,10 +138,33 @@ void handleChatMessage_echoesSavedMessageToSenderSocket() { handler.handleChatMessage(client, request); ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(MessageResponse.class); - verify(client).sendEvent(eq(MESSAGE), payloadCaptor.capture()); - verify(roomOperations).sendEvent(eq(MESSAGE), any(MessageResponse.class)); + verify(roomOperations).sendEvent(eq(MESSAGE), payloadCaptor.capture()); + verify(client, never()).sendEvent(eq(MESSAGE), any(MessageResponse.class)); verify(roomActivityNotifier).notifyMessageStored("room-1"); org.junit.jupiter.api.Assertions.assertEquals("message-1", payloadCaptor.getValue().getId()); org.junit.jupiter.api.Assertions.assertEquals("hello", payloadCaptor.getValue().getContent()); } + + @Test + void handleChatMessage_rejectsWhenSharedMembershipIsMissing() { + SocketIOClient client = mock(SocketIOClient.class); + SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); + when(client.get("user")).thenReturn(socketUser); + when(sessionService.validateSession(socketUser.id(), socketUser.authSessionId())) + .thenReturn(SessionValidationResult.valid(null)); + when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) + .thenReturn(RateLimitCheckResult.allowed(10000, 9999, 60, + System.currentTimeMillis() / 1000 + 60, 60)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-1", "user-1")).thenReturn(false); + + handler.handleChatMessage(client, ChatMessageRequest.builder() + .room("room-1") + .type("text") + .content("hello") + .build()); + + verify(client).sendEvent(eq(ERROR), any(Map.class)); + verifyNoInteractions(messageRepository); + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java index 50fb2c07..48b7e4df 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java @@ -4,7 +4,7 @@ import com.corundumstudio.socketio.SocketIOServer; import com.ktb.chatapp.websocket.socketio.ConnectedUsers; import com.ktb.chatapp.websocket.socketio.SocketUser; -import com.ktb.chatapp.websocket.socketio.UserRooms; +import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.util.Set; import java.util.UUID; @@ -14,7 +14,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -22,9 +24,6 @@ class ConnectionLoginHandlerTest { @Mock private SocketIOServer socketIOServer; @Mock private ConnectedUsers connectedUsers; - @Mock private UserRooms userRooms; - @Mock private RoomJoinHandler roomJoinHandler; - @Mock private RoomLeaveHandler roomLeaveHandler; @Mock private SocketIOClient client; private ConnectionLoginHandler handler; @@ -34,43 +33,54 @@ void setUp() { handler = new ConnectionLoginHandler( socketIOServer, connectedUsers, - userRooms, - roomJoinHandler, - roomLeaveHandler, + new UserConnectionLocks(), new SimpleMeterRegistry()); } @Test - void onConnect_setsUserRejoinsRoomsStoresUserAndJoinsUserRooms() { + void onConnect_setsAndStoresUserWithoutEagerlyLoadingRooms() { SocketUser user = new SocketUser("user-1", "tester", "session-1", "socket-1"); - when(connectedUsers.get(user.id())).thenReturn(null); - when(client.get("user")).thenReturn(user); - when(userRooms.get(user.id())).thenReturn(Set.of("room-1", "room-2")); + when(connectedUsers.replace(user.id(), user)).thenReturn(null); handler.onConnect(client, user); verify(client).set("user", user); - verify(roomJoinHandler).handleJoinRoom(client, "room-1"); - verify(roomJoinHandler).handleJoinRoom(client, "room-2"); - verify(connectedUsers).set(user.id(), user); - verify(client).joinRooms(Set.of("user:" + user.id(), "room-list")); + verify(connectedUsers).replace(user.id(), user); + verify(client).joinRooms(Set.of("user:" + user.id(), "socket:" + user.socketId())); } @Test - void onDisconnect_removesCurrentConnectionAndLeavesRooms() { + void onDisconnect_removesOnlyCurrentConnectionAndPreservesRoomMembership() { UUID socketId = UUID.randomUUID(); SocketUser user = new SocketUser("user-1", "tester", "session-1", socketId.toString()); when(client.get("user")).thenReturn(user); - when(userRooms.get(user.id())).thenReturn(Set.of("room-1")); when(client.getSessionId()).thenReturn(socketId); - when(connectedUsers.get(user.id())).thenReturn(user); + when(connectedUsers.delIfCurrent(user.id(), user)).thenReturn(true); + + handler.onDisconnect(client); + + verify(connectedUsers).delIfCurrent(user.id(), user); + verify(client).leaveRooms(Set.of("user:" + user.id(), "socket:" + socketId)); + verify(client).del("user"); + verify(client, never()).disconnect(); + } + + @Test + void onDisconnect_staleSocketDoesNotDeleteActiveConnectionOrRoomMembership() { + UUID staleSocketId = UUID.randomUUID(); + SocketUser staleUser = new SocketUser( + "user-1", "tester", "session-1", staleSocketId.toString()); + SocketUser activeUser = new SocketUser( + "user-1", "tester", "session-2", UUID.randomUUID().toString()); + when(client.get("user")).thenReturn(staleUser); + when(client.getSessionId()).thenReturn(staleSocketId); + when(connectedUsers.delIfCurrent(staleUser.id(), staleUser)).thenReturn(false); handler.onDisconnect(client); - verify(roomLeaveHandler).handleLeaveRoom(client, "room-1"); - verify(connectedUsers).del(user.id()); - verify(client).leaveRooms(Set.of("user:" + user.id(), "room-list")); + verify(connectedUsers).delIfCurrent(staleUser.id(), staleUser); + verify(client).leaveRooms(Set.of("user:" + staleUser.id(), "socket:" + staleSocketId)); verify(client).del("user"); - verify(client).disconnect(); + verify(client, never()).disconnect(); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandlerTest.java index d7faf98c..139904f2 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchHandlerTest.java @@ -3,12 +3,9 @@ import com.corundumstudio.socketio.SocketIOClient; import com.ktb.chatapp.dto.FetchMessagesRequest; import com.ktb.chatapp.dto.FetchMessagesResponse; -import com.ktb.chatapp.model.Room; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.websocket.socketio.SocketUser; import java.util.List; -import java.util.Optional; -import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -55,11 +52,9 @@ void handleFetchMessages_loadsMessagesForParticipant() { .messages(List.of()) .hasMore(false) .build(); - Room room = Room.builder().id("room-1").participantIds(Set.of("user-1")).build(); - when(client.get("user")) .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-1", "user-1")).thenReturn(true); when(messageLoader.loadMessages(request, "user-1")).thenReturn(response); handler.handleFetchMessages(client, request); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderIntegrationTest.java index d3d96d86..9c9d1bd9 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderIntegrationTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderIntegrationTest.java @@ -28,7 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; @SpringBootTest @Import({MongoTestContainer.class, RedisTestContainer.class}) @@ -67,6 +67,7 @@ void setUp() { messageLoader = new MessageLoader( messageRepository, userRepository, + fileRepository, new MessageResponseMapper(fileRepository), messageReadStatusService ); @@ -80,7 +81,7 @@ void setUp() { userRepository.save(testUser); // MessageReadStatusService mock ์„ค์ • - doNothing().when(messageReadStatusService).updateReadStatus(anyList(), anyString()); + doReturn(0L).when(messageReadStatusService).updateReadStatus(anyList(), anyString()); } @AfterEach diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderTest.java index abd8348e..7c61e146 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoaderTest.java @@ -2,7 +2,9 @@ import com.ktb.chatapp.dto.FetchMessagesRequest; import com.ktb.chatapp.dto.FetchMessagesResponse; +import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.Message; +import com.ktb.chatapp.model.MessageType; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; @@ -60,6 +62,7 @@ void setUp() { messageLoader = new MessageLoader( messageRepository, userRepository, + fileRepository, new MessageResponseMapper(fileRepository), messageReadStatusService ); @@ -81,7 +84,8 @@ void setUp() { lenient().when(userRepository.findAllById(anySet())) .thenReturn(List.of(testUser)); - lenient().doNothing().when(messageReadStatusService).updateReadStatus(anyList(), anyString()); + lenient().when(messageReadStatusService.updateReadStatus(anyList(), anyString())) + .thenReturn(0L); } private Message createMessage(String id, LocalDateTime timestamp) { @@ -176,4 +180,36 @@ void loadInitialMessages_shouldReturnEmptyOnError() { assertThat(result.getMessages()).isEmpty(); assertThat(result.isHasMore()).isFalse(); } + + @Test + void loadMessages_shouldBatchLoadFilesForFileMessages() { + List fileMessages = testMessages.subList(0, 5).stream() + .peek(message -> { + message.setType(MessageType.file); + message.setFileId("file-" + message.getId()); + }) + .toList(); + Page messagePage = getMessagePage(fileMessages); + + when(messageRepository.findByRoomIdAndTimestampBefore( + eq(roomId), any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(messagePage); + + File file = File.builder() + .id("file-" + fileMessages.getFirst().getId()) + .filename("chat-image.webp") + .originalname("chat-image.webp") + .mimetype("image/webp") + .size(1234L) + .build(); + when(fileRepository.findAllById(anySet())) + .thenReturn(List.of(file)); + + FetchMessagesRequest req = new FetchMessagesRequest(roomId, 30, null); + FetchMessagesResponse result = messageLoader.loadMessages(req, userId); + + assertThat(result.getMessages()).hasSize(5); + verify(fileRepository).findAllById(anySet()); + verify(fileRepository, never()).findById(anyString()); + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java index 3345fe0d..9389a039 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java @@ -6,7 +6,7 @@ import com.ktb.chatapp.dto.MessageReactionRequest; import com.ktb.chatapp.dto.MessageReactionResponse; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.service.MessageReactionService; import com.ktb.chatapp.websocket.socketio.SocketUser; import java.util.Optional; import java.util.Set; @@ -30,7 +30,7 @@ class MessageReactionHandlerTest { @Mock private SocketIOServer socketIOServer; - @Mock private MessageRepository messageRepository; + @Mock private MessageReactionService messageReactionService; @Mock private SocketIOClient client; @Mock private BroadcastOperations roomOperations; @@ -38,7 +38,7 @@ class MessageReactionHandlerTest { @BeforeEach void setUp() { - handler = new MessageReactionHandler(socketIOServer, messageRepository); + handler = new MessageReactionHandler(socketIOServer, messageReactionService); } @Test @@ -48,7 +48,7 @@ void handleMessageReaction_rejectsUnauthorizedClient() { handler.handleMessageReaction(client, new MessageReactionRequest("๐Ÿ‘", "message-1", "add", "๐Ÿ‘")); verify(client).sendEvent(eq(ERROR), any()); - verify(messageRepository, never()).save(any()); + verify(messageReactionService, never()).update(any(), any(), any(), eq(true)); } @Test @@ -59,13 +59,14 @@ void handleMessageReaction_addsReactionAndBroadcasts() { when(client.get("user")) .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); - when(messageRepository.findById("message-1")).thenReturn(Optional.of(message)); - when(messageRepository.save(message)).thenReturn(message); + message.addReaction("๐Ÿ‘", "user-1"); + when(messageReactionService.update("message-1", "๐Ÿ‘", "user-1", true)) + .thenReturn(Optional.of(message)); when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); handler.handleMessageReaction(client, request); - verify(messageRepository).save(message); + verify(messageReactionService).update("message-1", "๐Ÿ‘", "user-1", true); ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Object.class); verify(roomOperations).sendEvent(eq(MESSAGE_REACTION_UPDATE), responseCaptor.capture()); MessageReactionResponse response = (MessageReactionResponse) responseCaptor.getValue(); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandlerTest.java index 9bfd0953..6252097f 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReadHandlerTest.java @@ -6,16 +6,13 @@ import com.ktb.chatapp.dto.MarkAsReadRequest; import com.ktb.chatapp.dto.MessagesReadResponse; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.MessageReadStatusService; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; +import java.util.Arrays; import java.util.List; -import java.util.Optional; -import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,8 +35,8 @@ class MessageReadHandlerTest { @Mock private SocketIOServer socketIOServer; @Mock private MessageReadStatusService messageReadStatusService; @Mock private MessageRepository messageRepository; + @Mock private UserRooms userRooms; @Mock private RoomRepository roomRepository; - @Mock private UserRepository userRepository; @Mock private SocketIOClient client; @Mock private BroadcastOperations roomOperations; @@ -51,8 +48,8 @@ void setUp() { socketIOServer, messageReadStatusService, messageRepository, - roomRepository, - userRepository); + userRooms, + roomRepository); } @Test @@ -63,26 +60,25 @@ void handleMarkAsRead_rejectsUnauthorizedClient() { handler.handleMarkAsRead(client, request); verify(client).sendEvent(eq(ERROR), any()); - verify(messageReadStatusService, never()).updateReadStatus(any(), any()); + verify(messageReadStatusService, never()).updateReadStatus(any(), any(), any()); } @Test void handleMarkAsRead_updatesStatusAndBroadcasts() { MarkAsReadRequest request = request("message-1"); Message message = Message.builder().id("message-1").roomId("room-1").build(); - Room room = Room.builder().id("room-1").participantIds(Set.of("user-1")).build(); - User user = User.builder().id("user-1").name("tester").email("tester@example.com").build(); when(client.get("user")) .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); - when(messageRepository.findById("message-1")).thenReturn(Optional.of(message)); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(messageRepository.findRoomsOnlyByIdIn(List.of("message-1"))).thenReturn(List.of(message)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); + when(messageReadStatusService.updateReadStatus(List.of("message-1"), "user-1", "room-1")) + .thenReturn(1L); when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); handler.handleMarkAsRead(client, request); - verify(messageReadStatusService).updateReadStatus(List.of("message-1"), "user-1"); + verify(messageReadStatusService).updateReadStatus(List.of("message-1"), "user-1", "room-1"); ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Object.class); verify(roomOperations).sendEvent(eq(MESSAGES_READ), responseCaptor.capture()); MessagesReadResponse response = (MessagesReadResponse) responseCaptor.getValue(); @@ -90,9 +86,73 @@ void handleMarkAsRead_updatesStatusAndBroadcasts() { assertEquals(List.of("message-1"), response.getMessageIds()); } - private MarkAsReadRequest request(String messageId) { + @Test + void handleMarkAsRead_deduplicatesMessageIdsBeforeUpdate() { + MarkAsReadRequest request = request("message-1", "message-1", "", null); + Message message = Message.builder().id("message-1").roomId("room-1").build(); + + when(client.get("user")) + .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); + when(messageRepository.findRoomsOnlyByIdIn(List.of("message-1"))).thenReturn(List.of(message)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); + when(messageReadStatusService.updateReadStatus(List.of("message-1"), "user-1", "room-1")) + .thenReturn(1L); + when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); + + handler.handleMarkAsRead(client, request); + + verify(messageRepository).findRoomsOnlyByIdIn(List.of("message-1")); + verify(messageReadStatusService).updateReadStatus(List.of("message-1"), "user-1", "room-1"); + } + + @Test + void handleMarkAsRead_groupsMixedRoomBatchByRoom() { + MarkAsReadRequest request = request("message-1", "message-2"); + Message roomOneMessage = Message.builder().id("message-1").roomId("room-1").build(); + Message roomTwoMessage = Message.builder().id("message-2").roomId("room-2").build(); + BroadcastOperations roomTwoOperations = org.mockito.Mockito.mock(BroadcastOperations.class); + + when(client.get("user")) + .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); + when(messageRepository.findRoomsOnlyByIdIn(List.of("message-1", "message-2"))) + .thenReturn(List.of(roomOneMessage, roomTwoMessage)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); + when(userRooms.isInRoom("user-1", "room-2")).thenReturn(true); + when(messageReadStatusService.updateReadStatus(List.of("message-1"), "user-1", "room-1")) + .thenReturn(1L); + when(messageReadStatusService.updateReadStatus(List.of("message-2"), "user-1", "room-2")) + .thenReturn(1L); + when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); + when(socketIOServer.getRoomOperations("room-2")).thenReturn(roomTwoOperations); + + handler.handleMarkAsRead(client, request); + + verify(messageReadStatusService).updateReadStatus(List.of("message-1"), "user-1", "room-1"); + verify(messageReadStatusService).updateReadStatus(List.of("message-2"), "user-1", "room-2"); + verify(roomOperations).sendEvent(eq(MESSAGES_READ), any()); + verify(roomTwoOperations).sendEvent(eq(MESSAGES_READ), any()); + } + + @Test + void handleMarkAsRead_rejectsWhenUserHasNoRoomAccess() { + MarkAsReadRequest request = request("message-1"); + Message message = Message.builder().id("message-1").roomId("room-1").build(); + + when(client.get("user")) + .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); + when(messageRepository.findRoomsOnlyByIdIn(List.of("message-1"))).thenReturn(List.of(message)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-1", "user-1")).thenReturn(false); + + handler.handleMarkAsRead(client, request); + + verify(messageReadStatusService, never()).updateReadStatus(any(), any(), any()); + verify(client).sendEvent(eq(ERROR), any()); + } + + private MarkAsReadRequest request(String... messageIds) { MarkAsReadRequest request = new MarkAsReadRequest(); - request.setMessageIds(List.of(messageId)); + request.setMessageIds(Arrays.asList(messageIds)); return request; } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java index 372e2925..9366e3dc 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java @@ -1,66 +1,43 @@ package com.ktb.chatapp.websocket.socketio.handler; -import com.corundumstudio.socketio.BroadcastOperations; import com.corundumstudio.socketio.SocketIOClient; -import com.corundumstudio.socketio.SocketIOServer; -import com.ktb.chatapp.dto.FetchMessagesRequest; -import com.ktb.chatapp.dto.FetchMessagesResponse; -import com.ktb.chatapp.dto.MessageResponse; -import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.model.MessageType; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; -import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.dto.JoinRoomSuccessResponse; import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.websocket.socketio.SocketUser; import com.ktb.chatapp.websocket.socketio.UserRooms; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; import java.util.Set; +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.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_ERROR; import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_SUCCESS; -import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.MESSAGE; -import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.PARTICIPANTS_UPDATE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class RoomJoinHandlerTest { - @Mock private SocketIOServer socketIOServer; - @Mock private MessageRepository messageRepository; @Mock private RoomRepository roomRepository; - @Mock private UserRepository userRepository; @Mock private UserRooms userRooms; - @Mock private MessageLoader messageLoader; - @Mock private MessageResponseMapper messageResponseMapper; @Mock private RoomLeaveHandler roomLeaveHandler; @Mock private SocketIOClient client; - @Mock private BroadcastOperations roomOperations; private RoomJoinHandler handler; @BeforeEach void setUp() { - handler = new RoomJoinHandler( - socketIOServer, - messageRepository, - roomRepository, - userRepository, - userRooms, - messageLoader, - messageResponseMapper, - roomLeaveHandler); + handler = new RoomJoinHandler(roomRepository, userRooms, roomLeaveHandler); } @Test @@ -70,48 +47,65 @@ void handleJoinRoom_rejectsUnauthorizedClient() { handler.handleJoinRoom(client, "room-1"); verify(client).sendEvent(eq(JOIN_ROOM_ERROR), any()); + verify(roomRepository, never()).existsByIdAndParticipantIdsContaining(any(), any()); + verify(client, never()).joinRoom(any()); } @Test - void handleJoinRoom_addsParticipantLoadsMessagesAndBroadcasts() { - SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); - User user = User.builder().id("user-1").name("tester").email("tester@example.com").build(); - Room room = Room.builder().id("room-1").name("room").participantIds(Set.of("user-1")).build(); - MessageResponse joinMessageResponse = MessageResponse.builder() - .id("message-1") - .roomId("room-1") - .content("tester๋‹˜์ด ์ž…์žฅํ•˜์˜€์Šต๋‹ˆ๋‹ค.") - .type(MessageType.system) - .timestamp(1L) - .build(); - FetchMessagesResponse loadResponse = FetchMessagesResponse.builder() - .messages(List.of()) - .hasMore(false) - .build(); - - when(client.get("user")).thenReturn(socketUser); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); - when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); - when(messageRepository.save(any(Message.class))).thenAnswer(invocation -> { - Message message = invocation.getArgument(0); - message.setId("message-1"); - message.setTimestamp(LocalDateTime.now()); - return message; - }); - when(messageLoader.loadMessages(any(FetchMessagesRequest.class), eq("user-1"))) - .thenReturn(loadResponse); - when(messageResponseMapper.mapToMessageResponse(any(Message.class), eq(null))) - .thenReturn(joinMessageResponse); - when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); + void handleJoinRoom_rejectsNonParticipantWithoutJoiningSocketRoom() { + stubAuthenticatedClient("user-1", "room-1"); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-1", "user-1")) + .thenReturn(false); handler.handleJoinRoom(client, "room-1"); - verify(roomRepository).addParticipant("room-1", "user-1"); + verify(roomRepository, never()).addParticipant(any(), any()); + verify(client).sendEvent(eq(JOIN_ROOM_ERROR), any()); + verify(client, never()).joinRoom(any()); + verify(userRooms, never()).add(any(), any()); + } + + @Test + void handleJoinRoom_joinsExistingParticipantAndSendsLightweightAcknowledgement() { + stubAuthenticatedClient("user-1", "room-1"); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-1", "user-1")) + .thenReturn(true); + + handler.handleJoinRoom(client, "room-1"); + + verify(roomRepository, never()).addParticipant(any(), any()); verify(client).joinRoom("room-1"); verify(userRooms).add("user-1", "room-1"); - verify(client).sendEvent(eq(JOIN_ROOM_SUCCESS), any()); - verify(roomOperations).sendEvent(MESSAGE, joinMessageResponse); - verify(roomOperations).sendEvent(eq(PARTICIPANTS_UPDATE), any()); + + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(JoinRoomSuccessResponse.class); + verify(client).sendEvent(eq(JOIN_ROOM_SUCCESS), responseCaptor.capture()); + JoinRoomSuccessResponse response = responseCaptor.getValue(); + assertEquals("room-1", response.getRoomId()); + assertNull(response.getParticipants()); + assertNull(response.getMessages()); + } + + @Test + void handleJoinRoom_sendsSuccessForDuplicateJoinWithoutRepositoryOrRedisStoreAccess() { + stubAuthenticatedClient("user-1", "room-dup"); + when(client.getAllRooms()).thenReturn(Set.of(), Set.of("room-dup")); + when(roomRepository.existsByIdAndParticipantIdsContaining("room-dup", "user-1")) + .thenReturn(true); + + handler.handleJoinRoom(client, "room-dup"); + handler.handleJoinRoom(client, "room-dup"); + + verify(roomRepository).existsByIdAndParticipantIdsContaining("room-dup", "user-1"); + verify(roomRepository, never()).addParticipant(any(), any()); + verify(client).joinRoom("room-dup"); + verify(userRooms).add("user-1", "room-dup"); + verify(client, times(2)).sendEvent(eq(JOIN_ROOM_SUCCESS), any()); + } + + private void stubAuthenticatedClient(String userId, String roomId) { + when(client.get("user")).thenReturn(new SocketUser(userId, "tester", "session-1", "socket-1")); + when(client.getAllRooms()).thenReturn(Set.of()); + when(client.getSessionId()).thenReturn(UUID.nameUUIDFromBytes(roomId.getBytes())); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java index 76e6b007..9a897581 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java @@ -94,7 +94,7 @@ void handleLeaveRoom_removesParticipantAndBroadcasts() { .build(); when(client.get("user")).thenReturn(socketUser); - when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); + when(roomRepository.removeParticipant("room-1", "user-1")).thenReturn(1L); when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); when(userRepository.findById("user-2")).thenReturn(Optional.of(remainingUser)); when(roomRepository.findById("room-1")) diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandlerTest.java new file mode 100644 index 00000000..f706b20a --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandlerTest.java @@ -0,0 +1,34 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import com.corundumstudio.socketio.SocketIOClient; +import com.ktb.chatapp.websocket.socketio.SocketUser; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RoomListSubscriptionHandlerTest { + + private final RoomListSubscriptionHandler handler = new RoomListSubscriptionHandler(); + private final SocketIOClient client = mock(SocketIOClient.class); + + @Test + void authenticatedClientCanSubscribeAndUnsubscribe() { + when(client.get("user")).thenReturn(new SocketUser("user-1", "name", "session-1", "socket-1")); + + handler.joinRoomList(client); + handler.leaveRoomList(client); + + verify(client).joinRoom("room-list"); + verify(client).leaveRoom("room-list"); + } + + @Test + void unauthenticatedClientCannotSubscribe() { + handler.joinRoomList(client); + + verify(client, never()).joinRoom("room-list"); + } +} diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index 862048be..9799122a 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -2,10 +2,20 @@ server.port=5001 # make dev๋Š” ์ด ํŒŒ์ผ์ด main ์„ค์ •์„ ๋ฎ์œผ๋ฏ€๋กœ, main๊ณผ ๊ฐ™์€ ๋ฐ”์ธ๋”ฉ์„ ์—ฌ๊ธฐ์„œ ๋‹ค์‹œ ์ง€์ •ํ•œ๋‹ค. # ๋น ์ง€๋ฉด SocketIOConfig์˜ ๊ธฐ๋ณธ๊ฐ’ localhost๊ฐ€ ๊ฑธ๋ ค 127.0.0.1์—๋งŒ ๋ถ™๊ณ , ๊ฐ™์€ LAN์˜ ๋‹ค๋ฅธ ๊ธฐ๊ธฐ๊ฐ€ ์ ‘์†ํ•˜์ง€ ๋ชปํ•œ๋‹ค. socketio.server.host=0.0.0.0 +socketio.store.type=memory +rate-limit.store.type=mongo +session.store.type=mongo +app.mongodb.pool.max-size=20 +app.mongodb.pool.min-size=0 +app.mongodb.pool.max-wait-time-ms=10000 +spring.data.mongodb.auto-index-creation=true app.encryption.key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa app.encryption.salt=3132333435363738 app.jwt.secret=test-jwt-secret-for-backend-modernization-0123456789 app.jwt.expiration-ms=86400000 +app.mongodb.pool.max-size=${MONGO_MAX_POOL_SIZE:100} +app.mongodb.pool.min-size=${MONGO_MIN_POOL_SIZE:0} +app.mongodb.pool.max-wait-time-ms=${MONGO_MAX_WAIT_TIME_MS:120000} spring.ai.openai.api-key=${OPENAI_API_KEY:your_openai_api_key_here} springdoc.api-docs.path=/api/v3/api-docs springdoc.swagger-ui.path=/api/swagger-ui.html @@ -16,4 +26,3 @@ management.endpoints.web.exposure.include=health,info,metrics,prometheus management.endpoints.access.default=read-only management.endpoint.prometheus.access=read-only management.prometheus.metrics.export.enabled=true - diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index 35b55696..df10df06 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -1,3 +1,7 @@ PORT=3000 NEXT_PUBLIC_API_URL=http://localhost:5001 -NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 \ No newline at end of file +NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 +NEXT_PUBLIC_PROFILE_IMAGE_RESIZE_ENABLED=false +NEXT_PUBLIC_PROFILE_DIRECT_UPLOAD_ENABLED=true +NEXT_PUBLIC_CHAT_IMAGE_RESIZE_ENABLED=false +NEXT_PUBLIC_CHAT_DIRECT_UPLOAD_ENABLED=false diff --git a/apps/frontend/.env.production.example b/apps/frontend/.env.production.example index fd5f33bb..5eb7e5e1 100644 --- a/apps/frontend/.env.production.example +++ b/apps/frontend/.env.production.example @@ -3,3 +3,6 @@ # ๋ฐฐํฌ ๋Œ€์ƒ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ๊ณ ์น˜์„ธ์š”. (.env.production ์€ ์ปค๋ฐ‹ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค) NEXT_PUBLIC_API_URL=http://localhost:5001 NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 +NEXT_PUBLIC_PROFILE_IMAGE_RESIZE_ENABLED=false +NEXT_PUBLIC_PROFILE_DIRECT_UPLOAD_ENABLED=true +NEXT_PUBLIC_CHAT_DIRECT_UPLOAD_ENABLED=false diff --git a/apps/frontend/app/chat/[room]/page.js b/apps/frontend/app/chat/[room]/page.js index 193c8d2f..745a1b82 100644 --- a/apps/frontend/app/chat/[room]/page.js +++ b/apps/frontend/app/chat/[room]/page.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; +import AuthRequiredFallback from '@/components/AuthRequiredFallback'; import ChatHeader from '@/components/ChatHeader'; import { useAuth } from '@/contexts/AuthContext'; import ChatRoomView from '@/features/chat/room/ChatRoomView'; @@ -30,14 +31,22 @@ export default function ChatRoomPage() { useEffect(() => { if (!isLoading && !isAuthenticated) { - router.replace(`/?redirect=${pathname}`); + const redirectTimer = window.setTimeout(() => { + router.replace(`/?redirect=${pathname}`); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, pathname, router]); - if (isLoading || !isAuthenticated) { + if (isLoading) { return ; } + if (!isAuthenticated) { + return ; + } + return ( <> diff --git a/apps/frontend/app/chat/page.js b/apps/frontend/app/chat/page.js index 2c053a88..d7b61f9a 100644 --- a/apps/frontend/app/chat/page.js +++ b/apps/frontend/app/chat/page.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; +import AuthRequiredFallback from '@/components/AuthRequiredFallback'; import ChatHeader from '@/components/ChatHeader'; import { useAuth } from '@/contexts/AuthContext'; import ChatRoomsView from '@/features/chat/rooms/ChatRoomsView'; @@ -28,14 +29,22 @@ export default function ChatPage() { useEffect(() => { if (!isLoading && !isAuthenticated) { - router.replace(`/?redirect=${pathname}`); + const redirectTimer = window.setTimeout(() => { + router.replace(`/?redirect=${pathname}`); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, pathname, router]); - if (isLoading || !isAuthenticated) { + if (isLoading) { return ; } + if (!isAuthenticated) { + return ; + } + return ( <> diff --git a/apps/frontend/app/login/page.js b/apps/frontend/app/login/page.js index 925c8c89..d654eb6c 100644 --- a/apps/frontend/app/login/page.js +++ b/apps/frontend/app/login/page.js @@ -1,17 +1,18 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +const LoginRedirectPage = async ({ searchParams }) => { + const params = new URLSearchParams(); -const LoginRedirectPage = () => { - const router = useRouter(); + for (const [key, value] of Object.entries((await searchParams) ?? {})) { + if (Array.isArray(value)) { + value.forEach((item) => params.append(key, item)); + } else if (value !== undefined) { + params.append(key, value); + } + } - useEffect(() => { - const queryString = window.location.search; - router.replace(queryString ? `/${queryString}` : '/'); - }, [router]); - - return null; + const queryString = params.toString(); + redirect(queryString ? `/?${queryString}` : '/'); }; export default LoginRedirectPage; diff --git a/apps/frontend/components/AuthRequiredFallback.js b/apps/frontend/components/AuthRequiredFallback.js new file mode 100644 index 00000000..97e52740 --- /dev/null +++ b/apps/frontend/components/AuthRequiredFallback.js @@ -0,0 +1,40 @@ +import { VStack } from '@vapor-ui/core'; + +const AuthRequiredFallback = () => ( +
+ + + + + +
+); + +export default AuthRequiredFallback; diff --git a/apps/frontend/components/ChatMessages.js b/apps/frontend/components/ChatMessages.js index 10a64f24..acd7f7b8 100644 --- a/apps/frontend/components/ChatMessages.js +++ b/apps/frontend/components/ChatMessages.js @@ -29,6 +29,50 @@ const EmptyMessages = React.memo(() => ( )); EmptyMessages.displayName = 'EmptyMessages'; +const getTimestampValue = (message) => { + const timestamp = message?.timestamp; + if (!timestamp) return 0; + + const value = Date.parse(timestamp); + return Number.isNaN(value) ? 0 : value; +}; + +export const getChronologicalMessages = (messages) => { + if (!Array.isArray(messages)) return []; + + let previousTimestamp = Number.NEGATIVE_INFINITY; + const indexedMessages = new Array(messages.length); + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const timestamp = getTimestampValue(message); + indexedMessages[index] = { message, timestamp, index }; + + if (timestamp < previousTimestamp) { + for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex += 1) { + const nextMessage = messages[nextIndex]; + indexedMessages[nextIndex] = { + message: nextMessage, + timestamp: getTimestampValue(nextMessage), + index: nextIndex, + }; + } + + return indexedMessages + .sort((a, b) => ( + a.timestamp === b.timestamp + ? a.index - b.index + : a.timestamp - b.timestamp + )) + .map((item) => item.message); + } + + previousTimestamp = timestamp; + } + + return messages; +}; + const ChatMessages = ({ messages = [], currentUser = null, @@ -64,12 +108,7 @@ const ChatMessages = ({ }, [currentUser?.id]); const allMessages = useMemo(() => { - if (!Array.isArray(messages)) return []; - - return [...messages].sort((a, b) => { - if (!a?.timestamp || !b?.timestamp) return 0; - return new Date(a.timestamp) - new Date(b.timestamp); - }); + return getChronologicalMessages(messages); }, [messages]); const renderMessage = useCallback((msg, idx) => { diff --git a/apps/frontend/components/FileMessage.js b/apps/frontend/components/FileMessage.js index 3af1edc9..116c299a 100644 --- a/apps/frontend/components/FileMessage.js +++ b/apps/frontend/components/FileMessage.js @@ -6,7 +6,7 @@ import { PlayIcon as Music, ErrorCircleIcon as AlertCircle } from '@vapor-ui/icons'; -import { Button, VStack, HStack } from '@vapor-ui/core'; +import { VStack, HStack } from '@vapor-ui/core'; import CustomAvatar from './CustomAvatar'; import MessageContent from './MessageContent'; import MessageActions from './MessageActions'; @@ -26,17 +26,82 @@ const FileMessage = ({ const { user } = useAuth(); const [error, setError] = useState(null); const [previewUrl, setPreviewUrl] = useState(''); + const [shouldLoadPreview, setShouldLoadPreview] = useState(false); const messageDomRef = useRef(null); + + useEffect(() => { + setShouldLoadPreview(false); + setPreviewUrl(''); + setError(null); + }, [msg?.file?.filename]); + + useEffect(() => { + if (!msg?.file || msg?.file?.deleted) { + return; + } + + if (shouldLoadPreview || typeof IntersectionObserver === 'undefined') { + setShouldLoadPreview(true); + return; + } + + const target = messageDomRef.current; + if (!target) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setShouldLoadPreview(true); + observer.disconnect(); + } + }, + { + root: null, + rootMargin: '300px 0px', + threshold: 0.01, + } + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [msg?.file, msg?.file?.deleted, shouldLoadPreview]); + useEffect(() => { - if (msg?.file) { - const url = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); - setPreviewUrl(url); - console.debug('Preview URL generated:', { - filename: msg.file.filename, - url - }); + if (msg?.file?.deleted) { + setPreviewUrl(''); + setError(null); + return; + } + + if (!msg?.file) { + setPreviewUrl(''); + return; + } + + if (!shouldLoadPreview) { + setPreviewUrl(''); + return; } - }, [msg?.file, user?.token, user?.sessionId]); + + const url = fileService.getPreviewUrl( + msg.file, + user?.token, + user?.sessionId, + true + ); + + setPreviewUrl(url); + }, [ + msg?.file, + shouldLoadPreview, + user?.token, + user?.sessionId + ]); if (!msg?.file) { console.error('File data is missing:', msg); @@ -51,11 +116,11 @@ const FileMessage = ({ minute: '2-digit', second: '2-digit', hour12: false - }).replace(/\./g, '๋…„').replace(/\s/g, ' ').replace('์ผ ', '์ผ '); + }); const getFileIcon = () => { const mimetype = msg.file?.mimetype || ''; - const iconProps = { className: "w-5 h-5 flex-shrink-0" }; + const iconProps = { className: 'w-5 h-5 flex-shrink-0' }; if (mimetype.startsWith('image/')) return ; if (mimetype.startsWith('video/')) return ; @@ -66,21 +131,22 @@ const FileMessage = ({ const getDecodedFilename = (encodedFilename) => { try { if (!encodedFilename) return 'Unknown File'; - + const base64 = encodedFilename .replace(/-/g, '+') .replace(/_/g, '/'); - + const pad = base64.length % 4; const paddedBase64 = pad ? base64 + '='.repeat(4 - pad) : base64; - + if (paddedBase64.match(/^[A-Za-z0-9+/=]+$/)) { - return Buffer.from(paddedBase64, 'base64').toString('utf8'); + const bytes = Uint8Array.from(atob(paddedBase64), (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); } return decodeURIComponent(encodedFilename); - } catch (error) { - console.error('Filename decoding error:', error); + } catch (decodeError) { + console.error('Filename decoding error:', decodeError); return encodedFilename; } }; @@ -95,35 +161,40 @@ const FileMessage = ({ /> ); + const requireFileAuth = () => { + if (!msg.file?.filename) { + throw new Error('File information is missing.'); + } + + if (!user?.token || !user?.sessionId) { + throw new Error('Authentication information is missing.'); + } + }; + const handleFileDownload = async (e) => { e.preventDefault(); e.stopPropagation(); setError(null); try { - if (!msg.file?.filename) { - throw new Error('ํŒŒ์ผ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } - - if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } + requireFileAuth(); const baseUrl = fileService.getFileUrl(msg.file.filename, false); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}&download=true`; - + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user.token)}&sessionId=${encodeURIComponent(user.sessionId)}&download=true`; + const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = authenticatedUrl; document.body.appendChild(iframe); setTimeout(() => { - document.body.removeChild(iframe); + if (iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } }, 5000); - - } catch (error) { - console.error('File download error:', error); - setError(error.message || 'ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (downloadError) { + console.error('File download error:', downloadError); + setError(downloadError.message || 'An error occurred while downloading the file.'); } }; @@ -133,25 +204,19 @@ const FileMessage = ({ setError(null); try { - if (!msg.file?.filename) { - throw new Error('ํŒŒ์ผ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } - - if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } + requireFileAuth(); const baseUrl = fileService.getFileUrl(msg.file.filename, true); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}`; + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user.token)}&sessionId=${encodeURIComponent(user.sessionId)}`; const newWindow = window.open(authenticatedUrl, '_blank'); if (!newWindow) { - throw new Error('ํŒ์—…์ด ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŒ์—… ์ฐจ๋‹จ์„ ํ•ด์ œํ•ด์ฃผ์„ธ์š”.'); + throw new Error('Popup was blocked. Please allow popups and try again.'); } newWindow.opener = null; - } catch (error) { - console.error('File view error:', error); - setError(error.message || 'ํŒŒ์ผ ๋ณด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (viewError) { + console.error('File view error:', viewError); + setError(viewError.message || 'An error occurred while opening the file.'); } }; @@ -166,37 +231,29 @@ const FileMessage = ({ } if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + throw new Error('Authentication information is missing.'); } - const previewUrl = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); - return (
{originalname} { - console.debug('Image loaded successfully:', originalname); - }} onError={(e) => { - console.error('Image load error:', { - error: e.error, - originalname - }); + console.error('Image load error:', { originalname }); e.target.onerror = null; e.target.src = '/images/placeholder-image.png'; - setError('์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + setError('Failed to load image preview.'); }} loading="lazy" data-testid="file-image-preview" />
); - } catch (error) { - console.error('Image preview error:', error); - setError(error.message || '์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } catch (previewError) { + console.error('Image preview error:', previewError); + setError(previewError.message || 'Failed to load image preview.'); return (
@@ -205,24 +262,36 @@ const FileMessage = ({ } }; + const renderFileMetadata = (originalname, size) => ( +
+ {getFileIcon()} +
+
{originalname}
+
{size}
+
+
+ ); + const renderFilePreview = () => { const mimetype = msg.file?.mimetype || ''; const originalname = getDecodedFilename(msg.file?.originalname || 'Unknown File'); const size = fileService.formatFileSize(msg.file?.size || 0); + const previewWrapperClass = 'overflow-hidden'; - const previewWrapperClass = "overflow-hidden"; + if (msg.file?.deleted) { + return ( +
+ + Deleted file. +
+ ); + } if (mimetype.startsWith('image/')) { return (
{renderImagePreview(originalname)} -
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
); @@ -237,12 +306,12 @@ const FileMessage = ({ className="max-w-[400px] max-h-[400px] object-cover rounded-md" controls preload="metadata" - aria-label={`${originalname} ๋น„๋””์˜ค`} + aria-label={`${originalname} video`} crossOrigin="use-credentials" > - ๋น„๋””์˜ค๋ฅผ ์žฌ์ƒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + Video playback is not supported. ) : (
@@ -250,13 +319,7 @@ const FileMessage = ({
)}
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)} ); @@ -265,24 +328,18 @@ const FileMessage = ({ if (mimetype.startsWith('audio/')) { return (
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
{previewUrl && ( )}
@@ -293,13 +350,7 @@ const FileMessage = ({ return (
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
); @@ -312,15 +363,13 @@ const FileMessage = ({ align={isMine ? 'flex-end' : 'flex-start'} $css={{ gap: '$100' }} > - {/* Sender Info */} {renderAvatar()} - {isMine ? '๋‚˜' : msg.sender?.name} + {isMine ? 'Me' : msg.sender?.name || 'Unknown User'} - {/* Message Bubble - Outline Based */}
- {/* Message Content */} -
+
{error && (
{error}
)} @@ -345,7 +391,6 @@ const FileMessage = ({ )}
- {/* Message Footer */}
- {/* Message Actions */} { const { user } = useAuth(); @@ -55,20 +62,46 @@ const ProfileImageUpload = ({ currentImage, onImageChange }) => { throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); } - // FormData ์ƒ์„ฑ - const formData = new FormData(); - formData.append('profileImage', file); - - // ํŒŒ์ผ ์—…๋กœ๋“œ ์š”์ฒญ - const response = await api.post( - '/api/users/profile-image', - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, + // S3 ์ „ํ›„ ์„ฑ๋Šฅ์„ ๋ถ„๋ฆฌ ์ธก์ •ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋นŒ๋“œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๋ฆฌ์‚ฌ์ด์ง•์„ ์ผ ๋‹ค. + const uploadFile = PROFILE_IMAGE_RESIZE_ENABLED + ? await optimizeProfileImage(file) + : file; + + let response; + if (PROFILE_DIRECT_UPLOAD_ENABLED) { + try { + const presignResponse = await api.post('/api/users/profile-image/presign', { + originalname: uploadFile.name, + mimetype: uploadFile.type, + size: uploadFile.size, + }); + const { uploadUrl, key } = presignResponse.data; + await axios.put(uploadUrl, uploadFile, { + headers: { 'Content-Type': uploadFile.type }, + timeout: 30000, + }); + response = await api.post('/api/users/profile-image/complete', { + key, + originalname: uploadFile.name, + mimetype: uploadFile.type, + size: uploadFile.size, + }, { skipRetry: true }); + } catch (directUploadError) { + const status = directUploadError.response?.status; + if (status !== 400 && status !== 409) { + throw directUploadError; + } } - ); + } + + if (!response) { + const formData = new FormData(); + formData.append('profileImage', uploadFile); + response = await api.post('/api/users/profile-image', formData, { + skipRetry: true, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } const data = response.data; diff --git a/apps/frontend/components/ReadStatus.js b/apps/frontend/components/ReadStatus.js index 0efb5f1d..5eb705c3 100644 --- a/apps/frontend/components/ReadStatus.js +++ b/apps/frontend/components/ReadStatus.js @@ -3,6 +3,40 @@ import { ConfirmOutlineIcon } from '@vapor-ui/icons'; import { Text, HStack } from '@vapor-ui/core'; import socketClient from '@/lib/socket/socketClient'; +const READ_STATUS_BATCH_DELAY_MS = 300; +const pendingReadMessageIds = new Set(); +let readStatusBatchTimer = null; + +const flushReadStatusBatch = () => { + readStatusBatchTimer = null; + + if (!pendingReadMessageIds.size || !socketClient.canSend()) { + return; + } + + const messageIds = Array.from(pendingReadMessageIds); + pendingReadMessageIds.clear(); + + try { + socketClient.markMessagesAsRead(messageIds); + } catch (error) { + console.error('Error marking messages as read:', error); + } +}; + +const queueMessageAsRead = (messageId) => { + pendingReadMessageIds.add(messageId); + + if (readStatusBatchTimer) { + return; + } + + readStatusBatchTimer = setTimeout( + flushReadStatusBatch, + READ_STATUS_BATCH_DELAY_MS + ); +}; + const ReadStatus = ({ messageType = 'text', participants = [], @@ -37,7 +71,7 @@ const ReadStatus = ({ }, [unreadParticipants.length, messageType]); // ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ์Œ์œผ๋กœ ํ‘œ์‹œํ•˜๋Š” ํ•จ์ˆ˜ - const markMessageAsRead = useCallback(async () => { + const markMessageAsRead = useCallback(() => { if (!messageId || !currentUserId || hasMarkedAsRead || messageType === 'system' || !socketClient.canSend()) { return; @@ -45,7 +79,7 @@ const ReadStatus = ({ try { // Socket.IO๋ฅผ ํ†ตํ•ด ์„œ๋ฒ„์— ์ฝ์Œ ์ƒํƒœ ์ „์†ก - socketClient.markMessagesAsRead([messageId]); + queueMessageAsRead(messageId); setHasMarkedAsRead(true); diff --git a/apps/frontend/components/__tests__/ChatMessages.test.js b/apps/frontend/components/__tests__/ChatMessages.test.js index d5f37520..ac62dc87 100644 --- a/apps/frontend/components/__tests__/ChatMessages.test.js +++ b/apps/frontend/components/__tests__/ChatMessages.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import ChatMessages from '../ChatMessages'; +import ChatMessages, { getChronologicalMessages } from '../ChatMessages'; vi.mock('../../hooks/useInfiniteScroll', () => ({ useInfiniteScroll: () => ({ sentinelRef: { current: null } }), @@ -28,6 +28,28 @@ vi.mock('../UserMessage', () => ({ })); describe('ChatMessages', () => { + it('reuses an already chronological message array', () => { + const messages = [ + { _id: 'early', timestamp: '2026-06-20T11:00:00.000Z' }, + { _id: 'late', timestamp: '2026-06-20T12:00:00.000Z' }, + ]; + + expect(getChronologicalMessages(messages)).toBe(messages); + }); + + it('sorts only out-of-order messages without mutating the input array', () => { + const messages = [ + { _id: 'late', timestamp: '2026-06-20T12:00:00.000Z' }, + { _id: 'early', timestamp: '2026-06-20T11:00:00.000Z' }, + ]; + + const sortedMessages = getChronologicalMessages(messages); + + expect(sortedMessages).not.toBe(messages); + expect(sortedMessages.map((message) => message._id)).toEqual(['early', 'late']); + expect(messages.map((message) => message._id)).toEqual(['late', 'early']); + }); + it('renders messages sorted by timestamp without mutating the input array', () => { const messages = [ { diff --git a/apps/frontend/components/__tests__/FileMessage.test.js b/apps/frontend/components/__tests__/FileMessage.test.js new file mode 100644 index 00000000..3640ced4 --- /dev/null +++ b/apps/frontend/components/__tests__/FileMessage.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import FileMessage from '../FileMessage'; +import fileService from '@/services/fileService'; + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ + user: { + token: 'token-1', + sessionId: 'session-1', + }, + }), +})); + +vi.mock('@/services/fileService', () => ({ + default: { + getPreviewUrl: vi.fn(() => '/api/files/view/image.jpg?token=token-1'), + getFileUrl: vi.fn(() => '/api/files/view/image.jpg'), + formatFileSize: vi.fn(() => '1 KB'), + }, +})); + +vi.mock('../CustomAvatar', () => ({ + default: () =>
, +})); + +vi.mock('../MessageActions', () => ({ + default: () =>
, +})); + +vi.mock('../FileActions', () => ({ + default: () =>
, +})); + +vi.mock('../ReadStatus', () => ({ + default: () =>
, +})); + +describe('FileMessage', () => { + let observers; + + beforeEach(() => { + vi.clearAllMocks(); + observers = []; + + global.IntersectionObserver = vi.fn(function IntersectionObserver(callback) { + this.observe = vi.fn(); + this.disconnect = vi.fn(); + this.trigger = (isIntersecting = true) => { + callback([{ isIntersecting }]); + }; + observers.push(this); + }); + }); + + const imageMessage = { + _id: 'message-1', + content: 'uploaded image', + timestamp: '2026-08-12T00:00:00.000Z', + sender: { id: 'user-2', name: 'Uploader' }, + file: { + id: 'file-1', + filename: 'image.jpg', + originalname: 'image.jpg', + mimetype: 'image/jpeg', + size: 1024, + }, + readers: [], + }; + + it('renders the file message immediately and loads preview URL only near viewport', () => { + render( + + ); + + expect(screen.getByTestId('file-message-container')).toHaveTextContent('uploaded image'); + expect(screen.getByTestId('file-image-preview')).toHaveAttribute( + 'src', + '/images/placeholder-image.png' + ); + expect(fileService.getPreviewUrl).not.toHaveBeenCalled(); + + act(() => { + observers[0].trigger(true); + }); + + expect(fileService.getPreviewUrl).toHaveBeenCalledWith( + imageMessage.file, + 'token-1', + 'session-1', + true + ); + expect(screen.getByTestId('file-image-preview')).toHaveAttribute( + 'src', + '/api/files/view/image.jpg?token=token-1' + ); + }); +}); diff --git a/apps/frontend/components/__tests__/ProfileImageUpload.test.js b/apps/frontend/components/__tests__/ProfileImageUpload.test.js new file mode 100644 index 00000000..a78b2625 --- /dev/null +++ b/apps/frontend/components/__tests__/ProfileImageUpload.test.js @@ -0,0 +1,51 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import api from '@/lib/api/client'; +import ProfileImageUpload from '../ProfileImageUpload'; + +vi.mock('axios', () => ({ default: { put: vi.fn() } })); +vi.mock('@/lib/api/client', () => ({ default: { post: vi.fn(), delete: vi.fn() } })); +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'user-1', token: 'token', email: 'user@example.com' } }), +})); +vi.mock('@/lib/auth/authStorage', () => ({ saveStoredUser: vi.fn() })); +vi.mock('@/components/Toast', () => ({ Toast: { success: vi.fn(), error: vi.fn() } })); + +describe('ProfileImageUpload', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:preview'), + revokeObjectURL: vi.fn(), + }); + }); + + it('uploads profile bytes directly to S3 before completing metadata', async () => { + api.post + .mockResolvedValueOnce({ data: { uploadUrl: 'https://s3.example/upload', key: 'profiles/user-1_x.jpg' } }) + .mockResolvedValueOnce({ data: { imageUrl: '/api/files/profiles/user-1_x.jpg' } }); + axios.put.mockResolvedValueOnce({ status: 200 }); + const onImageChange = vi.fn(); + render(); + + const file = new File(['image-bytes'], 'profile.jpg', { type: 'image/jpeg' }); + fireEvent.change(screen.getByTestId('profile-image-file-input'), { + target: { files: [file] }, + }); + + await waitFor(() => expect(onImageChange).toHaveBeenCalledWith('/api/files/profiles/user-1_x.jpg')); + expect(axios.put).toHaveBeenCalledWith( + 'https://s3.example/upload', + file, + expect.objectContaining({ timeout: 30000 }), + ); + expect(api.post).toHaveBeenNthCalledWith( + 2, + '/api/users/profile-image/complete', + expect.objectContaining({ key: 'profiles/user-1_x.jpg' }), + { skipRetry: true }, + ); + }); +}); diff --git a/apps/frontend/components/__tests__/ReadStatus.test.js b/apps/frontend/components/__tests__/ReadStatus.test.js new file mode 100644 index 00000000..c2a3187a --- /dev/null +++ b/apps/frontend/components/__tests__/ReadStatus.test.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import socketClient from '@/lib/socket/socketClient'; +import ReadStatus from '../ReadStatus'; + +vi.mock('@/lib/socket/socketClient', () => ({ + default: { + canSend: vi.fn(() => true), + markMessagesAsRead: vi.fn(), + }, +})); + +describe('ReadStatus', () => { + let observers; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + observers = []; + + global.IntersectionObserver = vi.fn(function IntersectionObserver(callback) { + this.observe = vi.fn(); + this.disconnect = vi.fn(); + this.trigger = (isIntersecting = true) => { + callback([{ isIntersecting }]); + }; + observers.push(this); + }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete global.IntersectionObserver; + }); + + const renderReadStatus = (messageId) => { + const messageRef = { current: document.createElement('div') }; + + return render( + + ); + }; + + it('batches visible messages into one read event', () => { + renderReadStatus('message-1'); + renderReadStatus('message-2'); + renderReadStatus('message-3'); + + act(() => { + observers.forEach((observer) => observer.trigger(true)); + }); + + expect(socketClient.markMessagesAsRead).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(socketClient.markMessagesAsRead).toHaveBeenCalledTimes(1); + expect(socketClient.markMessagesAsRead).toHaveBeenCalledWith([ + 'message-1', + 'message-2', + 'message-3', + ]); + }); + + it('does not queue read events while the socket cannot send', () => { + socketClient.canSend.mockReturnValue(false); + renderReadStatus('message-1'); + + act(() => { + observers[0].trigger(true); + vi.advanceTimersByTime(300); + }); + + expect(socketClient.markMessagesAsRead).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/contexts/AuthContext.js b/apps/frontend/contexts/AuthContext.js index 8d8c0d5c..3914e4a3 100644 --- a/apps/frontend/contexts/AuthContext.js +++ b/apps/frontend/contexts/AuthContext.js @@ -2,6 +2,7 @@ import { createContext, useState, useEffect, useCallback, useRef, useContext } f import { useRouter } from 'next/router'; import socketService from '../services/socket'; import authService from '../services/authService'; +import AuthRequiredFallback from '../components/AuthRequiredFallback'; import api, { getAuthHeaders } from '../lib/api/client'; import { clearStoredUser, @@ -285,12 +286,16 @@ export const withAuth = (WrappedComponent) => { useEffect(() => { // ๋กœ๋”ฉ์ด ๋๋‚˜๊ณ  ์ธ์ฆ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ if (!isLoading && !isAuthenticated) { - router.replace('/?redirect=' + router.asPath); + const redirectTimer = window.setTimeout(() => { + router.replace('/?redirect=' + router.asPath); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, router]); // ๋กœ๋”ฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ธ์ฆ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋กœ๋”ฉ ํ™”๋ฉด ํ‘œ์‹œ - if (isLoading || !isAuthenticated) { + if (isLoading) { return (
{ ); } + if (!isAuthenticated) { + return ; + } + return ; }; diff --git a/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js b/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js index 447be2a3..a6fbd565 100644 --- a/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js +++ b/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Toast } from '@/components/Toast'; import socketClient from '@/lib/socket/socketClient'; import fileService from '@/services/fileService'; -import { useMessageHandling } from '../useMessageHandling'; +import { + getOldestMessageTimestamp, + useMessageHandling, +} from '../useMessageHandling'; vi.mock('@/components/Toast', () => ({ Toast: { @@ -122,4 +125,49 @@ describe('useMessageHandling', () => { expect(result.current.filePreview).toBeNull(); expect(result.current.uploadError).toBeNull(); }); + + it('finds the oldest message timestamp without requiring a sorted array', () => { + const messages = [ + { _id: 'late', timestamp: '2026-07-07T00:00:03.000Z' }, + { _id: 'early', timestamp: '2026-07-07T00:00:01.000Z' }, + { _id: 'middle', timestamp: '2026-07-07T00:00:02.000Z' }, + ]; + + expect(getOldestMessageTimestamp(messages)).toBe('2026-07-07T00:00:01.000Z'); + expect(messages.map((message) => message._id)).toEqual(['late', 'early', 'middle']); + }); + + it('loads previous messages before the oldest timestamp', () => { + const roomSocket = { connected: true }; + const socketRef = { current: roomSocket }; + const setLoadingMessages = vi.fn(); + const { result } = renderHook(() => + useMessageHandling( + currentUser, + roomId, + vi.fn(), + [ + { _id: 'late', timestamp: '2026-07-07T00:00:03.000Z' }, + { _id: 'early', timestamp: '2026-07-07T00:00:01.000Z' }, + ], + false, + setLoadingMessages, + socketRef, + ) + ); + + act(() => { + result.current.handleLoadMore(); + }); + + expect(setLoadingMessages).toHaveBeenCalledWith(true); + expect(socketClient.fetchPreviousMessages).toHaveBeenCalledWith( + { + roomId: 'room-1', + before: '2026-07-07T00:00:01.000Z', + limit: 30, + }, + roomSocket, + ); + }); }); diff --git a/apps/frontend/features/chat/room/__tests__/useReactionHandling.test.js b/apps/frontend/features/chat/room/__tests__/useReactionHandling.test.js index 236a7eb4..1f0ba46a 100644 --- a/apps/frontend/features/chat/room/__tests__/useReactionHandling.test.js +++ b/apps/frontend/features/chat/room/__tests__/useReactionHandling.test.js @@ -87,4 +87,25 @@ describe('useReactionHandling', () => { expect(socketClient.sendMessageReaction).not.toHaveBeenCalled(); expect(Toast.error).toHaveBeenCalledWith('๋ฆฌ์•ก์…˜ ์ œ๊ฑฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); }); + + it('rolls back to the original reactions when sending a reaction fails', async () => { + const setMessages = vi.fn(); + socketClient.sendMessageReaction.mockRejectedValueOnce(new Error('send failed')); + const { result } = renderHook(() => + useReactionHandling({ currentUser, messages, setMessages }) + ); + + await act(async () => { + await result.current.handleReactionAdd('message-1', 'fire'); + }); + + const rollbackUpdate = setMessages.mock.calls.at(-1)[0]; + expect(rollbackUpdate([ + { _id: 'message-1', reactions: { fire: ['user-1'] } }, + { _id: 'message-2', reactions: {} }, + ])).toEqual([ + { _id: 'message-1', reactions: messages[0].reactions }, + { _id: 'message-2', reactions: {} }, + ]); + }); }); diff --git a/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js b/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js index 14601bdf..0710fff0 100644 --- a/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js +++ b/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js @@ -36,6 +36,7 @@ vi.mock('@/lib/socket/socketClient', () => ({ default: { connect: vi.fn(), leaveRoom: vi.fn(), + tryLeaveRoom: vi.fn(), joinRoomAndWait: vi.fn(), fetchPreviousMessagesAndWait: vi.fn(), subscribeRoomEvents: vi.fn(), @@ -210,8 +211,6 @@ describe('useRoomHandling', () => { socketClient.connect.mockResolvedValue(createSocket()); socketClient.joinRoomAndWait.mockResolvedValue({ roomId: 'room-1', - messages: [{ _id: 'join-message-1', timestamp: '2026-07-07T00:00:00.000Z' }], - hasMore: false, }); socketClient.fetchPreviousMessagesAndWait.mockResolvedValue({ messages: [{ _id: 'message-1', timestamp: '2026-07-07T00:00:00.000Z' }], @@ -247,7 +246,12 @@ describe('useRoomHandling', () => { }); expect(socketClient.connect).toHaveBeenCalledTimes(1); - expect(api.get).toHaveBeenCalledWith('/api/rooms/room-1', expect.any(Object)); + expect(api.get).toHaveBeenCalledWith( + '/api/rooms/room-1', + expect.objectContaining({ + params: { includeRecentCount: false, includeParticipants: false }, + }), + ); expect(socketClient.subscribeRoomEvents).toHaveBeenCalledWith( harness.socketRef.current, expect.objectContaining({ @@ -261,11 +265,15 @@ describe('useRoomHandling', () => { }), ); expect(socketClient.joinRoomAndWait).toHaveBeenCalledWith('room-1', harness.socketRef.current); - expect(socketClient.fetchPreviousMessagesAndWait).not.toHaveBeenCalled(); + expect(socketClient.fetchPreviousMessagesAndWait).toHaveBeenCalledWith( + { roomId: 'room-1', limit: 30 }, + harness.socketRef.current, + expect.objectContaining({ timeoutMs: 5000 }), + ); expect(harness.setters.setMessages).toHaveBeenCalledWith(expect.any(Function)); expect(harness.setters.setHasMoreMessages).toHaveBeenCalledWith(false); expect(harness.initialLoadCompletedRef.current).toBe(true); - expect(harness.processedMessageIds.current.has('join-message-1')).toBe(true); + expect(harness.processedMessageIds.current.has('message-1')).toBe(true); expect(harness.actions.setupStarted).toHaveBeenCalledTimes(1); expect(harness.actions.setupSucceeded).toHaveBeenCalledWith( expect.objectContaining({ _id: 'room-1' }), @@ -274,19 +282,83 @@ describe('useRoomHandling', () => { expect(harness.setupCompleteRef.current).toBe(true); }); - it('falls back to fetching previous messages when join response has no messages', async () => { - socketClient.joinRoomAndWait.mockResolvedValueOnce({ roomId: 'room-1' }); + it('marks room setup complete without waiting for the initial message response', async () => { + let resolveMessageLoad; + socketClient.fetchPreviousMessagesAndWait.mockReturnValueOnce( + new Promise((resolve) => { + resolveMessageLoad = resolve; + }), + ); const harness = createHarness(); await act(async () => { await harness.result.current.setupRoom(); }); + expect(harness.actions.setupSucceeded).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'room-1' }), + ); + expect(harness.setupCompleteRef.current).toBe(true); + expect(harness.initialLoadCompletedRef.current).toBe(false); expect(socketClient.fetchPreviousMessagesAndWait).toHaveBeenCalledWith( { roomId: 'room-1', limit: 30 }, harness.socketRef.current, expect.objectContaining({ timeoutMs: 5000 }), ); + + await act(async () => { + resolveMessageLoad({ + messages: [{ _id: 'message-late', timestamp: '2026-07-07T00:00:00.000Z' }], + hasMore: false, + }); + }); + + expect(harness.initialLoadCompletedRef.current).toBe(true); + }); + + it('restores Socket room membership without waiting for missed messages', async () => { + const harness = createHarness(); + await act(async () => { + await harness.result.current.setupRoom(); + }); + + vi.clearAllMocks(); + let resolveMessageLoad; + socketClient.fetchPreviousMessagesAndWait.mockReturnValueOnce( + new Promise((resolve) => { + resolveMessageLoad = resolve; + }), + ); + + await act(async () => { + await harness.result.current.rejoinRoom(); + }); + + expect(socketClient.joinRoomAndWait).toHaveBeenCalledWith( + 'room-1', + harness.socketRef.current, + ); + expect(socketClient.fetchPreviousMessagesAndWait).toHaveBeenCalled(); + expect(harness.setupCompleteRef.current).toBe(true); + + await act(async () => { + resolveMessageLoad({ messages: [], hasMore: false }); + }); + }); + + it('leaves the active room without disconnecting the shared socket on unmount', async () => { + const socket = createSocket(); + socketClient.connect.mockResolvedValueOnce(socket); + const harness = createHarness(); + + await act(async () => { + await harness.result.current.setupRoom(); + }); + + harness.unmount(); + + expect(socketClient.tryLeaveRoom).toHaveBeenCalledWith('room-1', socket); + expect(socket.disconnect).not.toHaveBeenCalled(); }); it('records setup failure through semantic reducer actions', async () => { diff --git a/apps/frontend/features/chat/room/useMessageHandling.js b/apps/frontend/features/chat/room/useMessageHandling.js index d9d8769d..711b16f7 100644 --- a/apps/frontend/features/chat/room/useMessageHandling.js +++ b/apps/frontend/features/chat/room/useMessageHandling.js @@ -3,6 +3,30 @@ import { Toast } from '@/components/Toast'; import socketClient from '@/lib/socket/socketClient'; import { useChatFileUpload } from '../files/useChatFileUpload'; +export const getOldestMessageTimestamp = (messages) => { + if (!Array.isArray(messages) || messages.length === 0) { + return undefined; + } + + let oldestTimestamp; + let oldestValue = Number.POSITIVE_INFINITY; + + for (const message of messages) { + const timestamp = message?.timestamp; + if (!timestamp) continue; + + const value = Date.parse(timestamp); + if (Number.isNaN(value)) continue; + + if (value < oldestValue) { + oldestValue = value; + oldestTimestamp = timestamp; + } + } + + return oldestTimestamp; +}; + export const useMessageHandling = ( currentUser, roomId, @@ -45,11 +69,7 @@ export const useMessageHandling = ( } // ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ๋ฉ”์‹œ์ง€์˜ ํƒ€์ž„์Šคํƒฌํ”„ ์ฐพ๊ธฐ - const sortedMessages = [...messages].sort( - (a, b) => new Date(a.timestamp) - new Date(b.timestamp) - ); - const oldestMessage = sortedMessages[0]; - const beforeTimestamp = oldestMessage?.timestamp; + const beforeTimestamp = getOldestMessageTimestamp(messages); if (!beforeTimestamp) { return; diff --git a/apps/frontend/features/chat/room/useReactionHandling.js b/apps/frontend/features/chat/room/useReactionHandling.js index f308e087..f7e59cb4 100644 --- a/apps/frontend/features/chat/room/useReactionHandling.js +++ b/apps/frontend/features/chat/room/useReactionHandling.js @@ -1,9 +1,19 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Toast } from '@/components/Toast'; import socketClient from '@/lib/socket/socketClient'; export const useReactionHandling = ({ currentUser, messages, setMessages }) => { const [pendingReactions] = useState(new Map()); + const reactionsByMessageId = useMemo(() => { + if (!Array.isArray(messages)) { + return new Map(); + } + + return new Map(messages.map((message) => [ + message._id, + message.reactions || {}, + ])); + }, [messages]); const handleReactionAdd = useCallback(async (messageId, reaction) => { try { @@ -40,15 +50,16 @@ export const useReactionHandling = ({ currentUser, messages, setMessages }) => { Toast.error('๋ฆฌ์•ก์…˜ ์ถ”๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); // ์‹คํŒจ ์‹œ ๋กค๋ฐฑ + const previousReactions = reactionsByMessageId.get(messageId) || {}; setMessages(prevMessages => prevMessages.map(msg => msg._id === messageId ? - { ...msg, reactions: messages.find(m => m._id === messageId)?.reactions || {} } : + { ...msg, reactions: previousReactions } : msg ) ); } - }, [currentUser, messages, setMessages]); + }, [currentUser, reactionsByMessageId, setMessages]); const handleReactionRemove = useCallback(async (messageId, reaction) => { try { @@ -81,15 +92,16 @@ export const useReactionHandling = ({ currentUser, messages, setMessages }) => { Toast.error('๋ฆฌ์•ก์…˜ ์ œ๊ฑฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); // ์‹คํŒจ ์‹œ ๋กค๋ฐฑ + const previousReactions = reactionsByMessageId.get(messageId) || {}; setMessages(prevMessages => prevMessages.map(msg => msg._id === messageId ? - { ...msg, reactions: messages.find(m => m._id === messageId)?.reactions || {} } : + { ...msg, reactions: previousReactions } : msg ) ); } - }, [currentUser, messages, setMessages]); + }, [currentUser, reactionsByMessageId, setMessages]); const handleReactionUpdate = useCallback(({ messageId, reactions }) => { setMessages(prevMessages => diff --git a/apps/frontend/features/chat/room/useRoomHandling.js b/apps/frontend/features/chat/room/useRoomHandling.js index 1c42c1c8..661654d0 100644 --- a/apps/frontend/features/chat/room/useRoomHandling.js +++ b/apps/frontend/features/chat/room/useRoomHandling.js @@ -144,24 +144,7 @@ export const useRoomHandling = ({ } if (socketRef.current) { - const currentSocket = socketRef.current; - - if (userRooms.current?.get(currentSocket.id)) { - await new Promise((resolve) => { - socketClient.leaveRoom( - userRooms.current.get(currentSocket.id), - currentSocket - ); - setTimeout(resolve, 1000); - }); - userRooms.current.delete(currentSocket.id); - } - - currentSocket.disconnect(); - currentSocket.removeAllListeners(); attachSocket(null); - - await new Promise((resolve) => setTimeout(resolve, 2000)); } const socket = await socketClient.connect({ @@ -177,7 +160,6 @@ export const useRoomHandling = ({ timeout: 10000, pingTimeout: 10000, pingInterval: 8000, - forceNew: true, autoConnect: true, }); @@ -188,7 +170,7 @@ export const useRoomHandling = ({ } throw error; } - }, [userRooms, onReplace, socketRef, attachSocket, user]); + }, [onReplace, socketRef, attachSocket, user]); const fetchRoomData = useCallback( async (roomId) => { @@ -207,6 +189,9 @@ export const useRoomHandling = ({ response = await api.get(`/api/rooms/${roomId}`, { handleAuthError: false, headers: getAuthHeaders(user), + // Input readiness does not depend on the full participant list. + // Avoid serializing every user in a hot public room before join. + params: { includeRecentCount: false, includeParticipants: false }, }); } catch (error) { if (error.response?.status === 401) { @@ -250,33 +235,6 @@ export const useRoomHandling = ({ [socketRef, mountedRef, userRooms] ); - // ์žฌ์—ฐ๊ฒฐ ๋’ค ํ•„์š”ํ•œ ๊ฒƒ์€ ๋ฐฉ ์ฐธ๊ฐ€ ์ƒํƒœ ๋ณต๊ตฌ๋ฟ์ด๋‹ค. socket.io ๊ฐ€ ๊ฐ™์€ ์†Œ์ผ“์„ - // ๋˜์‚ด๋ ธ์œผ๋ฏ€๋กœ ๋ฐฉ ์ด๋ฒคํŠธ ๊ตฌ๋…๋„ ๊ทธ๋Œ€๋กœ ์‚ด์•„ ์žˆ๋‹ค โ€” ์—ฌ๊ธฐ์„œ ์†Œ์ผ“์„ ์ƒˆ๋กœ ๋งŒ๋“ค๋ฉด - // ์‚ด์•„ ์žˆ๋Š” ์—ฐ๊ฒฐ์„ ๋ฒ„๋ฆฌ๋Š” ์…ˆ์ด ๋œ๋‹ค. - const rejoinRoom = useCallback(async () => { - const socket = socketRef.current; - if (!roomId || !mountedRef.current || !socket?.connected) { - return; - } - - const joinResult = await joinRoom(roomId); - - if (Array.isArray(joinResult?.messages)) { - processMessages(joinResult.messages, joinResult.hasMore, true); - } - - if (mountedRef.current) { - setupCompleteRef.current = true; - } - }, [ - roomId, - socketRef, - mountedRef, - setupCompleteRef, - joinRoom, - processMessages, - ]); - const loadInitialMessages = useCallback( async (roomId) => { const loadMessagesWithRetry = async (retryCount = 0) => { @@ -325,6 +283,40 @@ export const useRoomHandling = ({ [socketRef, attachSocket, processMessages, setupSocket] ); + const loadInitialMessagesInBackground = useCallback( + (targetRoomId) => { + loadInitialMessages(targetRoomId).catch((error) => { + if (mountedRef.current) { + setError(error.message || '๋ฉ”์‹œ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }); + }, + [loadInitialMessages, mountedRef, setError] + ); + + // ์žฌ์—ฐ๊ฒฐ ๋’ค์—๋Š” Socket room ์ฐธ๊ฐ€ ์ƒํƒœ๋ฅผ ๋จผ์ € ๋ณต๊ตฌํ•˜๊ณ , ๋†“์นœ ๋ฉ”์‹œ์ง€๋Š” + // ์ž…์žฅ ACK๋ฅผ ๋ง‰์ง€ ์•Š๋„๋ก ๋ณ„๋„๋กœ ์กฐํšŒํ•œ๋‹ค. + const rejoinRoom = useCallback(async () => { + const socket = socketRef.current; + if (!roomId || !mountedRef.current || !socket?.connected) { + return; + } + + await joinRoom(roomId); + + if (mountedRef.current) { + setupCompleteRef.current = true; + loadInitialMessagesInBackground(roomId); + } + }, [ + roomId, + socketRef, + mountedRef, + setupCompleteRef, + joinRoom, + loadInitialMessagesInBackground, + ]); + const setupRoom = useCallback(async () => { if (setupPromiseRef.current) { return setupPromiseRef.current; @@ -334,11 +326,24 @@ export const useRoomHandling = ({ try { initializingRef.current = true; setupStarted(); - // 1. Socket Setup - attachSocket(await setupSocket()); + // Socket ์—ฐ๊ฒฐ๊ณผ ๋ฐฉ ์ƒ์„ธ ์กฐํšŒ๋Š” ์„œ๋กœ ์˜์กดํ•˜์ง€ ์•Š๋Š”๋‹ค. + // ์ˆœ์ฐจ ์‹คํ–‰ํ•˜๋ฉด ๋‘ ์ง€์—ฐ์ด ํ•ฉ์‚ฐ๋˜์–ด ์ž…๋ ฅ์ฐฝ ๋ Œ๋”๋ง์ด ๋Šฆ์–ด์ง€๋ฏ€๋กœ + // ๋ณ‘๋ ฌ๋กœ ์‹œ์ž‘ํ•œ๋‹ค. ๋‘ ์ž‘์—…์ด ๋ชจ๋‘ ์ •๋ฆฌ๋œ ํ›„ ๊ฒฐ๊ณผ๋ฅผ ์ฒ˜๋ฆฌํ•ด, + // ํ•œ์ชฝ์ด ์‹คํŒจํ•ด๋„ ์ด๋ฏธ ์—ฐ๊ฒฐ๋œ ์†Œ์ผ“์„ catch ๋ธ”๋ก์—์„œ ์ •๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. + const [socketResult, roomResult] = await Promise.allSettled([ + setupSocket(), + fetchRoomData(roomId), + ]); + + if (socketResult.status === 'rejected') { + throw socketResult.reason; + } + attachSocket(socketResult.value); - // 2. Fetch Room Data - const roomData = await fetchRoomData(roomId); + if (roomResult.status === 'rejected') { + throw roomResult.reason; + } + const roomData = roomResult.value; // Ensure current user is included in participants for display if (currentUser && roomData.participants) { @@ -366,18 +371,13 @@ export const useRoomHandling = ({ // 4. Join Room and Load Messages if (mountedRef.current && socketRef.current?.connected) { - const joinResult = await joinRoom(roomId); - - if (Array.isArray(joinResult?.messages)) { - processMessages(joinResult.messages, joinResult.hasMore, true); - } else { - await loadInitialMessages(roomId); - } + await joinRoom(roomId); } if (mountedRef.current) { setupCompleteRef.current = true; setupSucceeded(roomData); + loadInitialMessagesInBackground(roomId); } } catch (error) { if (mountedRef.current) { @@ -389,7 +389,7 @@ export const useRoomHandling = ({ cleanup('ERROR'); if (socketRef.current) { - socketRef.current.disconnect(); + socketClient.tryLeaveRoom(roomId, socketRef.current); attachSocket(null); } } @@ -413,8 +413,7 @@ export const useRoomHandling = ({ setupSocket, fetchRoomData, joinRoom, - loadInitialMessages, - processMessages, + loadInitialMessagesInBackground, cleanup, setupEventListeners, setupStarted, @@ -436,14 +435,13 @@ export const useRoomHandling = ({ roomEventsUnsubscribeRef.current = null; } - // ์–ธ๋งˆ์šดํŠธ ๊ฒฝ๋กœ๋Š” attachSocket ์„ ์“ฐ์ง€ ์•Š๋Š”๋‹ค. ์‚ฌ๋ผ์ง€๋Š” ์ปดํฌ๋„ŒํŠธ์— - // ์†Œ์ผ“ ๊ต์ฒด๋ฅผ ํ†ต์ง€ํ•  ๊ตฌ๋…์ž๊ฐ€ ์—†๋‹ค. + // Keep the shared Socket.IO connection alive for room-list or the next room. if (socketRef.current) { - socketRef.current.disconnect(); + socketClient.tryLeaveRoom(roomId, socketRef.current); socketRef.current = null; } }; - }, []); + }, [roomId, socketRef]); return { setupRoom, diff --git a/apps/frontend/features/chat/rooms/ChatRoomsView.js b/apps/frontend/features/chat/rooms/ChatRoomsView.js index 77c624d6..eef683ac 100644 --- a/apps/frontend/features/chat/rooms/ChatRoomsView.js +++ b/apps/frontend/features/chat/rooms/ChatRoomsView.js @@ -36,18 +36,22 @@ export default function ChatRoomsView({ router }) { connectionStatus, setConnectionStatus, isRetrying, - attemptConnection, } = useServerConnection(); const { rooms, setRooms, + metadata, + setMetadata, + currentPage, error, loading, refreshing, - joiningRoom, + joiningRo์„ om, + pageLoading, fetchRooms, refreshRooms, + changePage, handleJoinRoom, } = useRoomList({ currentUser, @@ -55,10 +59,8 @@ export default function ChatRoomsView({ router }) { connectionStatus, setConnectionStatus, isRetrying, - attemptConnection, }); - const connectionCheckTimerRef = useRef(null); const initialFetchStartedRef = useRef(false); const refreshRoomsRef = useRef(refreshRooms); @@ -101,20 +103,6 @@ export default function ChatRoomsView({ router }) { }; }, [currentUserKey, fetchRooms]); - useEffect(() => { - if (!currentUserKey || connectionStatus !== CONNECTION_STATUS.CHECKING) return; - - connectionCheckTimerRef.current = setInterval(() => { - attemptConnection(); - }, 5000); - - return () => { - if (connectionCheckTimerRef.current) { - clearInterval(connectionCheckTimerRef.current); - } - }; - }, [currentUserKey, connectionStatus, attemptConnection]); - // ํ™œ์„ฑ๋„ ์ง€ํ‘œ๋Š” ์†Œ์ผ“ ์ด๋ฒคํŠธ๋งŒ์œผ๋กœ ๋งŒ๋ฃŒ๋ฅผ ์•Œ ์ˆ˜ ์—†์–ด ์ฃผ๊ธฐ์ ์œผ๋กœ ๋‹ค์‹œ ์กฐํšŒํ•œ๋‹ค. // ๋ณด์ด์ง€ ์•Š๋Š” ํƒญ์—์„œ๋Š” ๊ฐฑ์‹ ์„ ๋ฉˆ์ถ”๊ณ , ๋‹ค์‹œ ๋ณด์ผ ๋•Œ ์ฆ‰์‹œ ํ•œ ๋ฒˆ ๋”ฐ๋ผ์žก๋Š”๋‹ค. useEffect(() => { @@ -134,7 +122,13 @@ export default function ChatRoomsView({ router }) { }; }, [currentUserKey, connectionStatus]); - useRoomsSocket({ currentUser, setConnectionStatus, setRooms }); + useRoomsSocket({ + currentUser, + setConnectionStatus, + setRooms, + currentPage, + setMetadata, + }); return ( 0 ? ( diff --git a/apps/frontend/features/chat/rooms/RoomsTable.js b/apps/frontend/features/chat/rooms/RoomsTable.js index 94bb1422..17aa060c 100644 --- a/apps/frontend/features/chat/rooms/RoomsTable.js +++ b/apps/frontend/features/chat/rooms/RoomsTable.js @@ -4,94 +4,184 @@ import { Button, Text, VStack, HStack } from '@vapor-ui/core'; import * as Table from '@/components/Table'; import { CONNECTION_STATUS } from './useServerConnection'; -const RoomsTable = ({ rooms, connectionStatus, onJoinRoom }) => { +export const getVisiblePages = (currentPage, totalPages) => { + const visibleCount = Math.min(5, totalPages); + const start = Math.max( + 0, + Math.min(currentPage - 2, totalPages - visibleCount) + ); + return Array.from({ length: visibleCount }, (_, index) => start + index); +}; + +const RoomsTable = ({ + rooms, + metadata, + currentPage, + pageLoading, + onPageChange, + connectionStatus, + onJoinRoom, +}) => { if (!rooms || rooms.length === 0) return null; - return ( -
- - - - - - - - + const totalPages = metadata?.totalPages || 0; + const visiblePages = getVisiblePages(currentPage, totalPages); + const visibleRooms = rooms.slice(0, metadata?.pageSize || 20); - - - ์ฑ„ํŒ…๋ฐฉ - ์ฐธ์—ฌ์ž - ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ - ์ƒ์„ฑ์ผ - ์•ก์…˜ - - + return ( + +
+ + + + + + + + - - {rooms.map((room) => ( - - - - {room.name} - {room.hasPassword && ( - - - - ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„์š” - - - )} - - - - - - {room.participants?.length || 0} - - - - {room.recentMessageCount > 0 ? room.recentMessageCount : '-'} - - - - - - - + + + ์ฑ„ํŒ…๋ฐฉ + ์ฐธ์—ฌ์ž + ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ + ์ƒ์„ฑ์ผ + ์•ก์…˜ + + + + {visibleRooms.map((room) => ( + + + + {room.name} + {room.hasPassword && ( + + + + ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„์š” + + + )} + + + + + + {room.participantCount || 0} + + + + {room.recentMessageCount > 0 ? room.recentMessageCount : '-'} + + + + + + + + + ))} + + +
+ + {totalPages > 1 && ( + + + + + {visiblePages.map((page) => ( + ))} - -
-
+ + + + + )} + ); }; diff --git a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js index 5fec65b9..7874a35b 100644 --- a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js @@ -43,12 +43,17 @@ vi.mock('../useRoomList', () => ({ useRoomList: () => ({ rooms: [], setRooms: vi.fn(), + metadata: { total: 0, page: 0, pageSize: 20, totalPages: 0 }, + setMetadata: vi.fn(), + currentPage: 0, error: mocks.error, loading: false, refreshing: false, joiningRoom: false, + pageLoading: false, fetchRooms: mocks.fetchRooms, refreshRooms: mocks.refreshRooms, + changePage: vi.fn(), handleJoinRoom: vi.fn(), }), })); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js index 9be9d1b4..b39daa57 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js @@ -11,7 +11,21 @@ vi.mock('@/services/axios', () => ({ }, })); -const roomsResponse = (rooms) => ({ data: { data: rooms } }); +const roomsResponse = (rooms, metadata = {}) => ({ + data: { + data: rooms, + metadata: { + total: rooms.length, + page: 0, + pageSize: 20, + totalPages: rooms.length > 0 ? 1 : 0, + hasMore: false, + currentCount: rooms.length, + sort: { field: 'createdAt', order: 'DESC' }, + ...metadata, + }, + }, +}); const renderRoomList = () => renderHook(() => @@ -45,6 +59,29 @@ describe('useRoomList', () => { expect(result.current.rooms).toEqual([{ _id: 'room-1' }]); expect(result.current.refreshing).toBe(false); + expect(axiosInstance.get).toHaveBeenCalledWith('/api/rooms', { params: { page: 0 } }); + }); + + it('requests rooms without waiting for the socket connection', async () => { + axiosInstance.get.mockResolvedValue(roomsResponse([])); + const neverConnects = new Promise(() => {}); + + const { result } = renderHook(() => + useRoomList({ + currentUser: { token: 'token-1' }, + router: { push: vi.fn() }, + connectionStatus: CONNECTION_STATUS.CHECKING, + setConnectionStatus: vi.fn(), + isRetrying: false, + attemptConnection: vi.fn(() => neverConnects), + }) + ); + + await act(async () => { + await result.current.fetchRooms(); + }); + + expect(axiosInstance.get).toHaveBeenCalledWith('/api/rooms', { params: { page: 0 } }); }); it('keeps the current list and stays quiet when a silent refresh fails', async () => { @@ -102,4 +139,85 @@ describe('useRoomList', () => { expect(result.current.error).toBeNull(); expect(result.current.rooms).toEqual([{ _id: 'room-1' }]); }); + + it('loads a selected page and replaces the current rows', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 21, totalPages: 2, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-21' }], + { total: 21, page: 1, totalPages: 2 }, + )); + + await act(async () => { + await result.current.changePage(1); + }); + + expect(axiosInstance.get).toHaveBeenLastCalledWith('/api/rooms', { params: { page: 1 } }); + expect(result.current.currentPage).toBe(1); + expect(result.current.rooms).toEqual([{ _id: 'room-21' }]); + expect(result.current.pageLoading).toBe(false); + }); + + it('moves to the last valid page when the current page becomes empty', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 41, totalPages: 3, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get + .mockResolvedValueOnce(roomsResponse([], { total: 40, page: 2, totalPages: 2 })) + .mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-40' }], + { total: 40, page: 1, totalPages: 2 }, + )); + + await act(async () => { + await result.current.changePage(2); + }); + + expect(axiosInstance.get).toHaveBeenNthCalledWith(3, '/api/rooms', { params: { page: 1 } }); + expect(result.current.currentPage).toBe(1); + expect(result.current.rooms).toEqual([{ _id: 'room-40' }]); + }); + + it('returns to page zero when every room has been removed', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 21, totalPages: 2, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [], + { total: 0, page: 1, totalPages: 0 }, + )); + + await act(async () => { + await result.current.changePage(1); + }); + + expect(result.current.currentPage).toBe(0); + expect(result.current.metadata.page).toBe(0); + expect(result.current.rooms).toEqual([]); + }); }); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js index c8106e6f..5716029a 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js @@ -1,119 +1,177 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import socketClient from '@/lib/socket/socketClient'; import { useRoomsSocket } from '../useRoomsSocket'; vi.mock('@/lib/socket/socketClient', () => ({ - default: { - connect: vi.fn(), - }, + default: { connect: vi.fn() }, })); -const currentUser = { - token: 'token-1', - sessionId: 'session-1', -}; - -const renderRoomsSocket = (socket, overrides = {}) => { - socketClient.connect.mockResolvedValue(socket); - - return renderHook(() => - useRoomsSocket({ - currentUser, - router: { push: vi.fn() }, - setConnectionStatus: vi.fn(), - setRooms: vi.fn(), - ...overrides, - }) - ); -}; - -const createSocket = () => ({ - on: vi.fn(), - emit: vi.fn(), - disconnect: vi.fn(), -}); - -const handlerFor = (socket, event) => - socket.on.mock.calls.find(([registered]) => registered === event)[1]; +const currentUser = { token: 'token-1', sessionId: 'session-1' }; describe('useRoomsSocket', () => { + let handlers; + let socket; + beforeEach(() => { vi.clearAllMocks(); - }); - - it('does not emit joinRoomList because the server joins room-list on connect', async () => { - const socket = { - on: vi.fn(), + handlers = {}; + socket = { + connected: true, + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), + off: vi.fn((event) => { + delete handlers[event]; + }), emit: vi.fn(), disconnect: vi.fn(), }; + socketClient.connect.mockResolvedValue(socket); + }); - renderRoomsSocket(socket); + const renderRoomsSocket = (currentPage = 0) => { + const setRooms = vi.fn(); + const setMetadata = vi.fn(); + const setConnectionStatus = vi.fn(); + const result = renderHook( + ({ page }) => useRoomsSocket({ + currentUser, + currentPage: page, + setRooms, + setMetadata, + setConnectionStatus, + }), + { initialProps: { page: currentPage } }, + ); + return { ...result, setRooms, setMetadata }; + }; + + it('subscribes only while the room-list screen is mounted', async () => { + renderRoomsSocket(); + await waitFor(() => expect(handlers.connect).toBeTypeOf('function')); + + act(() => handlers.connect()); + + expect(socket.emit).toHaveBeenCalledWith('joinRoomList'); + }); - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('connect', expect.any(Function)); - }); + it('does not register roomDeleted without a server-side room delete event', async () => { + renderRoomsSocket(); + await waitFor(() => expect(socket.on).toHaveBeenCalled()); - const connectHandler = socket.on.mock.calls.find(([event]) => event === 'connect')[1]; - connectHandler(); + expect(Object.keys(handlers)).not.toContain('roomDeleted'); + }); - expect(socket.emit).not.toHaveBeenCalledWith('joinRoomList'); + it('removes room-list listeners without closing the shared socket on cleanup', async () => { + const { unmount } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomCreated).toBeTypeOf('function')); + + unmount(); + + expect(socket.off).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(socket.off).toHaveBeenCalledWith('disconnect', expect.any(Function)); + expect(socket.off).toHaveBeenCalledWith('error', expect.any(Function)); + expect(socket.off).toHaveBeenCalledWith('roomCreated', expect.any(Function)); + expect(socket.off).toHaveBeenCalledWith('roomUpdated', expect.any(Function)); + expect(socket.off).toHaveBeenCalledWith('roomActivity', expect.any(Function)); + expect(socket.emit).toHaveBeenCalledWith('leaveRoomList'); + expect(socket.disconnect).not.toHaveBeenCalled(); }); - it('does not register roomDeleted without a server-side room delete event', async () => { - const socket = { - on: vi.fn(), - emit: vi.fn(), - disconnect: vi.fn(), - }; + it('prepends a created room on the first page and keeps only 20 rows', async () => { + const { setRooms, setMetadata } = renderRoomsSocket(0); + await waitFor(() => expect(handlers.roomCreated).toBeTypeOf('function')); - renderRoomsSocket(socket); + act(() => handlers.roomCreated({ _id: 'new-room' })); - await waitFor(() => { - expect(socket.on).toHaveBeenCalled(); + const updateRooms = setRooms.mock.calls[0][0]; + const previousRooms = Array.from({ length: 20 }, (_, index) => ({ _id: `room-${index}` })); + expect(updateRooms(previousRooms)).toHaveLength(20); + expect(updateRooms(previousRooms)[0]).toEqual({ _id: 'new-room' }); + + const updateMetadata = setMetadata.mock.calls[0][0]; + expect(updateMetadata({ total: 20, pageSize: 20, totalPages: 1 })).toMatchObject({ + total: 21, + totalPages: 2, }); - - const registeredEvents = socket.on.mock.calls.map(([event]) => event); - expect(registeredEvents).not.toContain('roomDeleted'); }); - it('merges a roomActivity update into the matching room without dropping its other fields', async () => { - const socket = createSocket(); - const setRooms = vi.fn(); + it('does not shift rows when a room is created on a later page', async () => { + const { rerender, setRooms, setMetadata } = renderRoomsSocket(0); + await waitFor(() => expect(handlers.roomCreated).toBeTypeOf('function')); + rerender({ page: 2 }); - renderRoomsSocket(socket, { setRooms }); + act(() => handlers.roomCreated({ _id: 'new-room' })); - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('roomActivity', expect.any(Function)); - }); + expect(setRooms).not.toHaveBeenCalled(); + expect(setMetadata).toHaveBeenCalledTimes(1); + }); + + it('merges room updates only into a matching visible row', async () => { + const { setRooms } = renderRoomsSocket(1); + await waitFor(() => expect(handlers.roomUpdated).toBeTypeOf('function')); - handlerFor(socket, 'roomActivity')({ _id: 'room-2', recentMessageCount: 9 }); + act(() => handlers.roomUpdated({ _id: 'room-2', participantCount: 4 })); const updateRooms = setRooms.mock.calls[0][0]; + expect(updateRooms([ + { _id: 'room-1', participantCount: 1 }, + { _id: 'room-2', participantCount: 3 }, + ])).toEqual([ + { _id: 'room-1', participantCount: 1 }, + { _id: 'room-2', participantCount: 4 }, + ]); + }); + + it('merges a roomActivity update without dropping other fields', async () => { + const { setRooms } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomActivity).toBeTypeOf('function')); - expect( - updateRooms([ - { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, - { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 2 }, - ]) - ).toEqual([ + act(() => handlers.roomActivity({ _id: 'room-2', recentMessageCount: 9 })); + + await waitFor(() => expect(setRooms).toHaveBeenCalledTimes(1)); + + const updateRooms = setRooms.mock.calls[0][0]; + expect(updateRooms([ + { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, + { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 2 }, + ])).toEqual([ { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 9 }, ]); }); - it('ignores a roomActivity payload without a room id', async () => { - const socket = createSocket(); - const setRooms = vi.fn(); - - renderRoomsSocket(socket, { setRooms }); + it('batches bursty roomActivity updates into one room state update', async () => { + const { setRooms } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomActivity).toBeTypeOf('function')); - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('roomActivity', expect.any(Function)); + act(() => { + handlers.roomActivity({ _id: 'room-1', recentMessageCount: 2 }); + handlers.roomActivity({ _id: 'room-2', recentMessageCount: 3 }); + handlers.roomActivity({ _id: 'room-1', recentMessageCount: 4 }); }); - handlerFor(socket, 'roomActivity')(undefined); + expect(setRooms).not.toHaveBeenCalled(); + await waitFor(() => expect(setRooms).toHaveBeenCalledTimes(1)); + + const updateRooms = setRooms.mock.calls[0][0]; + expect(updateRooms([ + { _id: 'room-1', recentMessageCount: 1 }, + { _id: 'room-2', recentMessageCount: 1 }, + { _id: 'room-3', recentMessageCount: 1 }, + ])).toEqual([ + { _id: 'room-1', recentMessageCount: 4 }, + { _id: 'room-2', recentMessageCount: 3 }, + { _id: 'room-3', recentMessageCount: 1 }, + ]); + }); + + it('ignores a roomActivity payload without a room id', async () => { + const { setRooms } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomActivity).toBeTypeOf('function')); + + act(() => handlers.roomActivity(undefined)); expect(setRooms).not.toHaveBeenCalled(); }); diff --git a/apps/frontend/features/chat/rooms/useRoomList.js b/apps/frontend/features/chat/rooms/useRoomList.js index a4dbf82c..a9ce5ef2 100644 --- a/apps/frontend/features/chat/rooms/useRoomList.js +++ b/apps/frontend/features/chat/rooms/useRoomList.js @@ -2,13 +2,22 @@ import { useState, useCallback, useRef } from 'react'; import axiosInstance from '@/services/axios'; import { CONNECTION_STATUS } from './useServerConnection'; +export const DEFAULT_PAGE_METADATA = { + total: 0, + page: 0, + pageSize: 20, + totalPages: 0, + hasMore: false, + currentCount: 0, + sort: { field: 'createdAt', order: 'DESC' }, +}; + export const useRoomList = ({ currentUser, router, connectionStatus, setConnectionStatus, isRetrying, - attemptConnection, }) => { const [rooms, setRooms] = useState([]); const [error, setError] = useState(null); @@ -16,6 +25,9 @@ export const useRoomList = ({ const [refreshing, setRefreshing] = useState(false); const [isInitialLoad, setIsInitialLoad] = useState(true); const [joiningRoom, setJoiningRoom] = useState(false); + const [pageLoading, setPageLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(0); + const [metadata, setMetadata] = useState(DEFAULT_PAGE_METADATA); const isLoadingRef = useRef(false); @@ -56,17 +68,37 @@ export const useRoomList = ({ setConnectionStatus(CONNECTION_STATUS.ERROR); }, [isRetrying, setConnectionStatus]); - const loadRooms = useCallback(async () => { - await attemptConnection(); - - const response = await axiosInstance.get('/api/rooms'); + const requestRoomsPage = useCallback(async (page) => { + const response = await axiosInstance.get('/api/rooms', { params: { page } }); - if (!response?.data?.data) { + if (!response?.data?.data || !response?.data?.metadata) { throw new Error('INVALID_RESPONSE'); } - setRooms(response.data.data); - }, [attemptConnection]); + return response.data; + }, []); + + const loadRooms = useCallback(async (requestedPage = currentPage) => { + let payload = await requestRoomsPage(requestedPage); + let resolvedPage = requestedPage; + + if (requestedPage > 0 && payload.data.length === 0) { + if (payload.metadata.totalPages > 0 && requestedPage >= payload.metadata.totalPages) { + resolvedPage = payload.metadata.totalPages - 1; + payload = await requestRoomsPage(resolvedPage); + } else if (payload.metadata.totalPages === 0) { + resolvedPage = 0; + payload = { + ...payload, + metadata: { ...payload.metadata, page: 0 }, + }; + } + } + + setRooms(payload.data); + setMetadata(payload.metadata); + setCurrentPage(resolvedPage); + }, [currentPage, requestRoomsPage]); const fetchRooms = useCallback(async () => { if (!currentUser?.token || isLoadingRef.current) { @@ -105,7 +137,7 @@ export const useRoomList = ({ isLoadingRef.current = true; setRefreshing(true); - await loadRooms(); + await loadRooms(currentPage); setError(null); return true; @@ -124,7 +156,38 @@ export const useRoomList = ({ setRefreshing(false); isLoadingRef.current = false; } - }, [currentUser, loadRooms]); + }, [currentUser, currentPage, loadRooms]); + + const changePage = useCallback(async (page) => { + if ( + !currentUser?.token || + isLoadingRef.current || + page < 0 || + page >= metadata.totalPages || + page === currentPage + ) { + return false; + } + + try { + isLoadingRef.current = true; + setPageLoading(true); + await loadRooms(page); + setError(null); + return true; + } catch (error) { + setError({ + title: '์ฑ„ํŒ…๋ฐฉ ํŽ˜์ด์ง€ ์ด๋™ ์‹คํŒจ', + message: '์š”์ฒญํ•œ ํŽ˜์ด์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.', + type: 'warning', + showRetry: false, + }); + return false; + } finally { + setPageLoading(false); + isLoadingRef.current = false; + } + }, [currentUser, currentPage, loadRooms, metadata.totalPages]); const handleJoinRoom = useCallback(async (roomId) => { if (connectionStatus !== CONNECTION_STATUS.CONNECTED) { @@ -165,13 +228,18 @@ export const useRoomList = ({ return { rooms, setRooms, + metadata, + setMetadata, + currentPage, error, setError, loading, refreshing, joiningRoom, + pageLoading, fetchRooms, refreshRooms, + changePage, handleJoinRoom, }; }; diff --git a/apps/frontend/features/chat/rooms/useRoomsSocket.js b/apps/frontend/features/chat/rooms/useRoomsSocket.js index 6290d3c2..8bff2c85 100644 --- a/apps/frontend/features/chat/rooms/useRoomsSocket.js +++ b/apps/frontend/features/chat/rooms/useRoomsSocket.js @@ -7,17 +7,86 @@ const CONNECTION_STATUS = { ERROR: 'error', }; +const ROOM_ACTIVITY_DEBOUNCE_MS = 250; + export const useRoomsSocket = ({ currentUser, setConnectionStatus, setRooms, + currentPage, + setMetadata, }) => { const socketRef = useRef(null); + const currentPageRef = useRef(currentPage); + const roomActivityTimerRef = useRef(null); + const pendingRoomActivityRef = useRef(new Map()); + + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); useEffect(() => { if (!currentUser?.token) return; let isSubscribed = true; + let subscribedSocket = null; + let subscribedHandlers = null; + + const flushRoomActivity = () => { + roomActivityTimerRef.current = null; + + if (!isSubscribed || pendingRoomActivityRef.current.size === 0) { + return; + } + + const activityByRoomId = new Map(pendingRoomActivityRef.current); + pendingRoomActivityRef.current.clear(); + + setRooms((prev) => + prev.map((room) => { + const activity = activityByRoomId.get(room._id); + return activity + ? { ...room, recentMessageCount: activity.recentMessageCount } + : room; + }) + ); + }; + + const queueRoomActivity = (activity) => { + if (!activity?._id) return; + + pendingRoomActivityRef.current.set(activity._id, activity); + + if (roomActivityTimerRef.current) { + return; + } + + roomActivityTimerRef.current = window.setTimeout( + flushRoomActivity, + ROOM_ACTIVITY_DEBOUNCE_MS + ); + }; + + const clearRoomActivityQueue = () => { + if (roomActivityTimerRef.current) { + window.clearTimeout(roomActivityTimerRef.current); + roomActivityTimerRef.current = null; + } + + pendingRoomActivityRef.current.clear(); + }; + + const unsubscribeSocketEvents = () => { + if (!subscribedSocket || !subscribedHandlers) { + return; + } + + Object.entries(subscribedHandlers).forEach(([event, handler]) => { + subscribedSocket.off?.(event, handler); + }); + subscribedSocket = null; + subscribedHandlers = null; + }; const connectSocket = async () => { try { @@ -36,10 +105,12 @@ export const useRoomsSocket = ({ if (!isSubscribed || !socket) return; socketRef.current = socket; + setConnectionStatus(CONNECTION_STATUS.CONNECTED); const handlers = { connect: () => { setConnectionStatus(CONNECTION_STATUS.CONNECTED); + socket.emit('joinRoomList'); }, disconnect: () => { setConnectionStatus(CONNECTION_STATUS.DISCONNECTED); @@ -48,7 +119,23 @@ export const useRoomsSocket = ({ setConnectionStatus(CONNECTION_STATUS.ERROR); }, roomCreated: (newRoom) => { - setRooms((prev) => [newRoom, ...prev]); + setMetadata((prev) => { + const total = prev.total + 1; + return { + ...prev, + total, + totalPages: Math.ceil(total / prev.pageSize), + }; + }); + + if (currentPageRef.current === 0) { + setRooms((prev) => + [ + newRoom, + ...prev.filter((room) => room._id !== newRoom._id), + ].slice(0, 20) + ); + } }, roomUpdated: (updatedRoom) => { setRooms((prev) => @@ -58,22 +145,16 @@ export const useRoomsSocket = ({ ); }, // ํ™œ์„ฑ๋„ ์ง€ํ‘œ๋งŒ ๋‹ด๊ธด ๊ฒฝ๋Ÿ‰ payload์ด๋ฏ€๋กœ ๋ฐฉ ์ •๋ณด๋ฅผ ๋ฎ์ง€ ์•Š๊ณ  ๋ณ‘ํ•ฉํ•œ๋‹ค - roomActivity: (activity) => { - if (!activity?._id) return; - - setRooms((prev) => - prev.map((room) => - room._id === activity._id - ? { ...room, recentMessageCount: activity.recentMessageCount } - : room - ) - ); - }, + roomActivity: queueRoomActivity, }; + unsubscribeSocketEvents(); + subscribedSocket = socket; + subscribedHandlers = handlers; Object.entries(handlers).forEach(([event, handler]) => { socket.on(event, handler); }); + socket.emit('joinRoomList'); } catch (error) { if (!isSubscribed) return; @@ -92,10 +173,12 @@ export const useRoomsSocket = ({ return () => { isSubscribed = false; - if (socketRef.current) { - socketRef.current.disconnect(); - socketRef.current = null; + clearRoomActivityQueue(); + if (subscribedSocket?.connected) { + subscribedSocket.emit('leaveRoomList'); } + unsubscribeSocketEvents(); + socketRef.current = null; }; }, [currentUser]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/apps/frontend/features/chat/rooms/useServerConnection.js b/apps/frontend/features/chat/rooms/useServerConnection.js index afbd1611..78fa1943 100644 --- a/apps/frontend/features/chat/rooms/useServerConnection.js +++ b/apps/frontend/features/chat/rooms/useServerConnection.js @@ -38,6 +38,10 @@ export const useServerConnection = () => { const response = await axiosInstance.get('/api/health', { timeout: HEALTH_TIMEOUT_MS, retries: 1, + skipAuth: true, + headers: { + 'Content-Type': undefined, + }, }); if (response?.status === 401) { diff --git a/apps/frontend/lib/api/__tests__/client.test.js b/apps/frontend/lib/api/__tests__/client.test.js index 983eacb7..0b6ad5ae 100644 --- a/apps/frontend/lib/api/__tests__/client.test.js +++ b/apps/frontend/lib/api/__tests__/client.test.js @@ -47,6 +47,24 @@ describe('api client', () => { expect(readHeader(response.config.headers, 'x-session-id')).toBe('session-1'); }); + it('does not retry requests marked with skipRetry', async () => { + const client = createApiClient({ baseURL: 'http://api.test', getSession: () => null }); + let attempts = 0; + + client.defaults.adapter = async (config) => { + attempts++; + const error = new Error('Gateway Timeout'); + error.config = config; + error.response = { config, data: {}, headers: {}, status: 504, statusText: 'Gateway Timeout' }; + throw error; + }; + + await expect(client.post('/api/auth/login', {}, { skipRetry: true })).rejects.toMatchObject({ + status: 504, + }); + expect(attempts).toBe(1); + }); + it('respects skipAuth requests', async () => { const client = createApiClient({ baseURL: 'http://api.test', diff --git a/apps/frontend/lib/api/client.js b/apps/frontend/lib/api/client.js index 7e0f7c42..e9da1475 100644 --- a/apps/frontend/lib/api/client.js +++ b/apps/frontend/lib/api/client.js @@ -93,7 +93,11 @@ export const createApiClient = ({ return Promise.reject(error); } - if (isRetryableError(error) && config.retryCount < RETRY_CONFIG.maxRetries) { + if ( + config.skipRetry !== true && + isRetryableError(error) && + config.retryCount < RETRY_CONFIG.maxRetries + ) { config.retryCount++; const delay = getRetryDelay(config.retryCount); diff --git a/apps/frontend/lib/images/__tests__/optimizeImageFile.test.js b/apps/frontend/lib/images/__tests__/optimizeImageFile.test.js new file mode 100644 index 00000000..ba4f9bf0 --- /dev/null +++ b/apps/frontend/lib/images/__tests__/optimizeImageFile.test.js @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { optimizeImageFile } from '../optimizeImageFile'; + +describe('optimizeImageFile', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns non-image files unchanged', async () => { + const file = new File(['pdf'], 'document.pdf', { type: 'application/pdf' }); + + await expect(optimizeImageFile(file)).resolves.toBe(file); + }); + + it('keeps GIF files unchanged by default', async () => { + const file = new File(['gif'], 'animation.gif', { type: 'image/gif' }); + + await expect(optimizeImageFile(file)).resolves.toBe(file); + }); + + it('resizes images to the requested max dimension and returns a smaller WebP file', async () => { + const bitmap = { width: 4000, height: 3000, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const context = { drawImage: vi.fn() }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + toBlob: vi.fn((callback) => callback(new Blob(['small'], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File([new Uint8Array(1024)], 'photo.jpg', { type: 'image/jpeg' }); + + const optimized = await optimizeImageFile(file, { + maxDimension: 1600, + quality: 0.84, + }); + + expect(canvas.width).toBe(1600); + expect(canvas.height).toBe(1200); + expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 1600, 1200); + expect(optimized.name).toBe('photo.webp'); + expect(optimized.type).toBe('image/webp'); + expect(optimized.size).toBeLessThan(file.size); + expect(bitmap.close).toHaveBeenCalled(); + }); + + it('returns the original file when the optimized output is larger', async () => { + const bitmap = { width: 400, height: 300, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const canvas = { + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toBlob: vi.fn((callback) => callback(new Blob([new Uint8Array(20)], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File(['tiny'], 'tiny.png', { type: 'image/png' }); + + await expect(optimizeImageFile(file)).resolves.toBe(file); + expect(bitmap.close).toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/lib/images/optimizeImageFile.js b/apps/frontend/lib/images/optimizeImageFile.js new file mode 100644 index 00000000..ad4199d6 --- /dev/null +++ b/apps/frontend/lib/images/optimizeImageFile.js @@ -0,0 +1,74 @@ +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.84; +const DEFAULT_OUTPUT_TYPE = 'image/webp'; +const DEFAULT_SKIP_TYPES = new Set(['image/gif']); + +const EXTENSION_BY_TYPE = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +}; + +const canvasToBlob = (canvas, type, quality) => + new Promise((resolve) => canvas.toBlob(resolve, type, quality)); + +const hasSkippedType = (skipTypes, type) => { + if (!skipTypes) return false; + if (typeof skipTypes.has === 'function') return skipTypes.has(type); + if (Array.isArray(skipTypes)) return skipTypes.includes(type); + return false; +}; + +const getOutputName = (name, type) => { + const extension = EXTENSION_BY_TYPE[type]; + if (!extension) return name; + + const baseName = name?.replace(/\.[^.]+$/, '') || 'image'; + return `${baseName}.${extension}`; +}; + +export async function optimizeImageFile( + file, + { + maxDimension = DEFAULT_MAX_DIMENSION, + quality = DEFAULT_QUALITY, + outputType = DEFAULT_OUTPUT_TYPE, + skipTypes = DEFAULT_SKIP_TYPES, + } = {} +) { + if (!file?.type?.startsWith('image/')) return file; + if (hasSkippedType(skipTypes, file.type)) return file; + if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') { + return file; + } + + let bitmap; + try { + bitmap = await createImageBitmap(file); + + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) return file; + + context.drawImage(bitmap, 0, 0, width, height); + + const blob = await canvasToBlob(canvas, outputType, quality); + if (!blob || blob.size >= file.size) return file; + + return new File([blob], getOutputName(file.name, outputType), { + type: outputType, + lastModified: Date.now(), + }); + } catch (error) { + console.warn('Image optimization failed:', error); + return file; + } finally { + bitmap?.close?.(); + } +} diff --git a/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js b/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js new file mode 100644 index 00000000..608aa22a --- /dev/null +++ b/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { optimizeProfileImage } from '../optimizeProfileImage'; + +describe('optimizeProfileImage', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ์›๋ณธ์„ ์œ ์ง€ํ•œ๋‹ค', async () => { + vi.stubGlobal('createImageBitmap', undefined); + const file = new File(['original'], 'profile.jpg', { type: 'image/jpeg' }); + + await expect(optimizeProfileImage(file)).resolves.toBe(file); + }); + + it('512px ์ดํ•˜ WebP๋กœ ์ถ•์†Œํ•˜๊ณ  ๋” ์ž‘์€ ํŒŒ์ผ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', async () => { + const bitmap = { width: 1024, height: 512, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const context = { drawImage: vi.fn() }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + toBlob: vi.fn((callback) => callback(new Blob(['small'], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File([new Uint8Array(1024)], 'profile.jpg', { type: 'image/jpeg' }); + + const optimized = await optimizeProfileImage(file); + + expect(canvas.width).toBe(512); + expect(canvas.height).toBe(256); + expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 512, 256); + expect(optimized.name).toBe('profile.webp'); + expect(optimized.type).toBe('image/webp'); + expect(optimized.size).toBeLessThan(file.size); + expect(bitmap.close).toHaveBeenCalled(); + }); + + it('๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ์›๋ณธ๋ณด๋‹ค ํฌ๋ฉด ์›๋ณธ์„ ์œ ์ง€ํ•œ๋‹ค', async () => { + const bitmap = { width: 256, height: 256, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const canvas = { + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toBlob: vi.fn((callback) => callback(new Blob([new Uint8Array(20)], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File(['tiny'], 'profile.png', { type: 'image/png' }); + + await expect(optimizeProfileImage(file)).resolves.toBe(file); + }); +}); diff --git a/apps/frontend/lib/profile/optimizeProfileImage.js b/apps/frontend/lib/profile/optimizeProfileImage.js new file mode 100644 index 00000000..48c1b8c7 --- /dev/null +++ b/apps/frontend/lib/profile/optimizeProfileImage.js @@ -0,0 +1,9 @@ +import { optimizeImageFile } from '../images/optimizeImageFile'; + +export async function optimizeProfileImage(file, options = {}) { + return optimizeImageFile(file, { + maxDimension: 512, + quality: 0.82, + ...options, + }); +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 914e133f..f30534e9 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "test": "vitest run --passWithNoTests", "format": "prettier . --write --ignore-path .prettierignore", - "build:production": "test -f .env.production || { echo '.env.production ์ด ์—†์Šต๋‹ˆ๋‹ค. cp .env.production.example .env.production ํ›„ ๋ฐฐํฌ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ์ฑ„์šฐ์„ธ์š”.' >&2; exit 1; } && env $(grep -vE '^[[:space:]]*(#|$)' .env.production | xargs) next build && pnpm run deploy:static", + "build:production": "test -f .env.production || { echo '.env.production ์ด ์—†์Šต๋‹ˆ๋‹ค. cp .env.production.example .env.production ํ›„ ๋ฐฐํฌ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ์ฑ„์šฐ์„ธ์š”.' >&2; exit 1; } && env $(grep -vE '^[[:space:]]*(#|$)' .env.production | xargs) next build --webpack && pnpm run deploy:static", "deploy:static": "cp -r .next/static .next/standalone/apps/frontend/.next/ && cp -r public .next/standalone/apps/frontend/" }, "dependencies": { diff --git a/apps/frontend/pages/_error.js b/apps/frontend/pages/_error.js index 812b951f..dfa02ebd 100644 --- a/apps/frontend/pages/_error.js +++ b/apps/frontend/pages/_error.js @@ -9,7 +9,7 @@ const ErrorPage = () => { -