diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..28bf822c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,67 @@ +## 한눈에 보기 + +| 구분 | 내용 | +|---|---| +| 문제 | | +| 확인된 원인 | | +| 해결 방법 | | +| 사용자·개발자 영향 | | + + + +## 확인 과정 + +| 단계 | 확인한 내용 | 판단 근거 | +|---:|---|---| +| 1. 재현 | | | +| 2. 원인 확인 | | | +| 3. 해결안 선택 | | | + + + +## 구현 내용 + +| 영역 | 변경 내용 | 변경 이유 | +|---|---|---| +| | | | + + + +## 검증 조건 + +| 항목 | 조건 | +|---|---| +| 테스트 대상 | | +| 데이터·환경 | | +| 실행 횟수·부하 | | +| 통과 기준 | | + + + +## 검증 결과 + +| 확인 항목 | 변경 전 | 변경 후 | 판정 | +|---|---:|---:|---| +| | | | | + +| 지표 | 변경 전 | 변경 후 | 변화 | +|---|---:|---:|---:| +| p95 | | | | +| p99 | | | | +| 오류율 | | | | +| 병목 지표 | | | | + + + +## 영향 범위와 후속 작업 + +- 영향받는 기능: +- 이번 PR에서 제외한 범위: +- 다음 작업: + +## 체크리스트 + +- [ ] 문제를 재현하고 원인을 확인했습니다. +- [ ] 변경에 직접 관련된 테스트를 통과했습니다. +- [ ] 기존 API·이벤트·화면 동작의 유지 여부를 확인했습니다. +- [ ] 실행하지 못한 검증이나 남은 위험을 작성했습니다. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..60889873 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +apps/backend/target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..bc0803f9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index caca7882..6b3cdacf 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1,4 +1,5 @@ uploads/* +.env # Eclipse JDT / LSP 생성 파일 .classpath diff --git a/apps/backend/DEPLOY.md b/apps/backend/DEPLOY.md index 79b41722..6d05e718 100644 --- a/apps/backend/DEPLOY.md +++ b/apps/backend/DEPLOY.md @@ -117,6 +117,13 @@ tail -f logs/app.log REDIS_HOST=localhost REDIS_PORT=6379 + # 원본 파일은 서버에 유지하고, 선택적 S3 mirror는 IAM Role로 PUT만 수행 + FILE_STORAGE_TYPE=local + S3_BUCKET=your-s3-bucket + AWS_REGION=ap-northeast-2 + S3_ENDPOINT= + S3_PATH_STYLE_ACCESS=false + # 서버 포트 PORT=5001 WS_PORT=5002 diff --git a/apps/backend/README.md b/apps/backend/README.md index 17548bf1..55d0e65f 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -91,6 +91,11 @@ make verify-java | `MONGO_URI` | ✅ | 없음 | MongoDB 연결 문자열 | | `REDIS_HOST` | ✅ | 없음 | Redis 호스트 | | `REDIS_PORT` | ✅ | 없음 | Redis 포트 | +| `FILE_STORAGE_TYPE` | ❌ | `local` | 원본 파일 저장 방식. S3 mirror 모드에서도 `local` 유지 | +| `S3_BUCKET` | ✅ | 없음 | 이미지 및 첨부 파일을 저장할 S3 버킷 | +| `AWS_REGION` | ❌ | `ap-northeast-2` | S3 버킷 리전 | +| `S3_ENDPOINT` | ❌ | 빈 값 | AWS S3 호환 스토리지의 사용자 지정 엔드포인트 | +| `S3_PATH_STYLE_ACCESS` | ❌ | `false` | path-style S3 URL 사용 여부 | | `PORT` | ❌ | `5001` | HTTP API 포트 (`server.port`) | | `WS_PORT` | ❌ | `5002` | Socket.IO 서버 포트 | | `CORS_ALLOWED_ORIGINS` | ❌ | `*` | REST API CORS 허용 Origin 목록. 쉼표로 구분 | diff --git a/apps/backend/docs/S3_DIRECT_UPLOAD.md b/apps/backend/docs/S3_DIRECT_UPLOAD.md new file mode 100644 index 00000000..9cb350dd --- /dev/null +++ b/apps/backend/docs/S3_DIRECT_UPLOAD.md @@ -0,0 +1,37 @@ +# S3 PutObject mirror upload + +`NEXT_PUBLIC_FILE_UPLOAD_MODE=mirror`는 기존 multipart 업로드가 성공한 뒤 같은 파일을 +S3 `pending/mirror/`에 best-effort로 복제한다. 서비스 원본과 응답 계약은 기존 서버 +저장소를 유지하며, S3 실패는 사용자 업로드 성공을 되돌리지 않는다. 기본값은 `server`다. + +백엔드는 `FILE_STORAGE_TYPE=local`, `S3_BUCKET`, `AWS_REGION`을 사용한다. IAM Role에는 +`s3:PutObject`만 필요하고 애플리케이션은 미러 객체에 GET, HEAD, Copy, Delete를 호출하지 +않는다. AWS 자격증명은 이미지나 환경변수에 넣지 않는다. + +## Bucket CORS + +`https://frontend.example.com`을 실제 운영 origin으로 바꾸고 `*`는 사용하지 않는다. + +```json +[ + { + "AllowedOrigins": ["https://frontend.example.com"], + "AllowedMethods": ["PUT"], + "AllowedHeaders": ["content-type", "x-amz-*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 300 + } +] +``` + +`pending/` prefix 객체를 1일 후 삭제하는 lifecycle rule을 설정한다. PUT URL은 5분, +MongoDB intent는 15분 후 만료된다. + +## Rollout + +1. 기존 서버 저장소가 정상인지 확인한다. +2. 운영 이미지를 `--build-arg NEXT_PUBLIC_FILE_UPLOAD_MODE=mirror`로 빌드한다. +3. `mirror_presign_success`, `mirror_put_success`, `mirror_put_failure`를 관찰한다. +4. 문제가 생기면 프론트만 `server`로 다시 빌드한다. + +이 모드는 백엔드 업로드 부하를 제거하거나 S3를 영구 원본으로 사용하지 않는다. diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-monitoring.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-monitoring.json new file mode 100644 index 00000000..646f347f --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-monitoring.json @@ -0,0 +1,70 @@ +{ + "uid": "ktb-chat-monitoring", + "title": "KTB Chat Monitoring", + "tags": ["ktb", "loadtest"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "5s", + "time": { "from": "now-15m", "to": "now" }, + "templating": { "list": [] }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Monitored targets", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "targets": [{ "expr": "count(up{job!=\"cadvisor\"})", "legendFormat": "targets", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "none", "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, "overrides": [] }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "value" } + }, + { + "id": 2, + "type": "timeseries", + "title": "HTTP requests / sec", + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 0 }, + "targets": [{ "expr": "sum by (status) (rate(http_server_requests_seconds_count{application=\"ktb-chat-backend\"}[1m]))", "legendFormat": "status {{status}}", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } } + }, + { + "id": 3, + "type": "timeseries", + "title": "HTTP average latency", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [{ "expr": "sum(rate(http_server_requests_seconds_sum{application=\"ktb-chat-backend\"}[1m])) / sum(rate(http_server_requests_seconds_count{application=\"ktb-chat-backend\"}[1m]))", "legendFormat": "average", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } } + }, + { + "id": 4, + "type": "timeseries", + "title": "JVM heap used", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [{ "expr": "sum(jvm_memory_used_bytes{application=\"ktb-chat-backend\",area=\"heap\"})", "legendFormat": "heap", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } } + }, + { + "id": 5, + "type": "timeseries", + "title": "JVM CPU", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "targets": [{ "expr": "process_cpu_usage{application=\"ktb-chat-backend\"}", "legendFormat": "process", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, "overrides": [] }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } } + }, + { + "id": 6, + "type": "timeseries", + "title": "MongoDB and Redis exporter up", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "targets": [ + { "expr": "up{job=\"mongodb\"}", "legendFormat": "mongodb", "refId": "A" }, + { "expr": "up{job=\"redis\"}", "legendFormat": "redis", "refId": "B" } + ], + "fieldConfig": { "defaults": { "unit": "none", "min": 0, "max": 1 }, "overrides": [] }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } } + } + ] +} diff --git a/apps/backend/monitoring/prometheus/prometheus.dev.yml b/apps/backend/monitoring/prometheus/prometheus.dev.yml index 39f13f1b..efd2dfab 100644 --- a/apps/backend/monitoring/prometheus/prometheus.dev.yml +++ b/apps/backend/monitoring/prometheus/prometheus.dev.yml @@ -13,7 +13,8 @@ scrape_configs: - job_name: 'spring-boot-app' metrics_path: '/actuator/prometheus' - scrape_interval: 5s + # Performance diagnostics need to see short-lived pool saturation spikes. + scrape_interval: 1s static_configs: - targets: ['host.docker.internal:5001'] labels: @@ -31,17 +32,3 @@ scrape_configs: - targets: ['redis-exporter:9121'] labels: environment: 'dev' - - - job_name: 'cadvisor' - static_configs: - - targets: ['cadvisor:8080'] - labels: - environment: 'dev' - metric_relabel_configs: - # Extract container name from container_label_com_docker_compose_service - - source_labels: [container_label_com_docker_compose_service] - target_label: container_name - # Extract container name from id (fallback) - - source_labels: [id] - regex: '/docker/([a-f0-9]+)' - target_label: container_id diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index c52460af..f301e628 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -16,9 +16,17 @@ 25 2.0.0 + 2.47.3 + + software.amazon.awssdk + bom + ${aws-sdk.version} + pom + import + org.springframework.ai spring-ai-bom @@ -61,6 +69,10 @@ org.springframework.boot spring-boot-starter-actuator + + software.amazon.awssdk + s3 + io.micrometer micrometer-registry-prometheus 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..e6866e42 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,29 @@ package com.ktb.chatapp.config; +import com.ktb.chatapp.model.MessageType; +import java.util.List; +import java.util.Locale; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; import org.springframework.data.mongodb.config.EnableMongoAuditing; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.convert.ReadingConverter; @Configuration @EnableMongoAuditing public class MongoConfig { + + @Bean + MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions(List.of(new MessageTypeReadConverter())); + } + + @ReadingConverter + static class MessageTypeReadConverter implements Converter { + @Override + public MessageType convert(String source) { + return MessageType.valueOf(source.toLowerCase(Locale.ROOT)); + } + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/MongoPoolMetricsConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoPoolMetricsConfig.java new file mode 100644 index 00000000..8765954c --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoPoolMetricsConfig.java @@ -0,0 +1,21 @@ +package com.ktb.chatapp.config; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.mongodb.DefaultMongoConnectionPoolTagsProvider; +import io.micrometer.core.instrument.binder.mongodb.MongoMetricsConnectionPoolListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Supplies Micrometer's standard MongoDB pool listener. Spring Boot's Mongo metrics + * auto-configuration attaches this bean to the auto-configured MongoClient. + */ +@Configuration(proxyBeanMethods = false) +public class MongoPoolMetricsConfig { + + @Bean + MongoMetricsConnectionPoolListener mongoMetricsConnectionPoolListener(MeterRegistry registry) { + return new MongoMetricsConnectionPoolListener( + registry, new DefaultMongoConnectionPoolTagsProvider()); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/OpenApiConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/OpenApiConfig.java index 60e87e67..46e242ff 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/OpenApiConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/OpenApiConfig.java @@ -42,7 +42,6 @@ public OpenAPI customOpenAPI() { // 보안 스키마 이름 String jwtSchemeName = "Bearer Authentication"; - String sessionSchemeName = "Session ID"; return new OpenAPI() .info(new Info() @@ -52,10 +51,8 @@ public OpenAPI customOpenAPI() { ## 인증 방법 1. `/api/auth/register` 또는 `/api/auth/login`으로 회원가입/로그인 - 2. 응답으로 받은 `token`과 `sessionId`를 사용 - 3. 이후 모든 요청에 다음 헤더 포함: - - `Authorization: Bearer {token}` - - `x-session-id: {sessionId}` + 2. 응답으로 받은 `token`을 사용 + 3. 이후 모든 요청에 `Authorization: Bearer {token}` 헤더 포함 ## 주요 기능 - 사용자 인증 및 관리 @@ -88,12 +85,6 @@ public OpenAPI customOpenAPI() { .scheme("bearer") .bearerFormat("JWT") .description("JWT 토큰을 입력하세요 (Bearer 접두사 제외)")) - // Session ID 헤더 보안 스키마 - .addSecuritySchemes(sessionSchemeName, new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .in(SecurityScheme.In.HEADER) - .name("x-session-id") - .description("세션 ID를 입력하세요")) // 공통 에러 응답 스키마 .addSchemas("ApiErrorResponse", new Schema<>() .type("object") @@ -153,7 +144,6 @@ public OpenAPI customOpenAPI() { .example("{ \"success\": false, \"code\": \"RATE_LIMIT_EXCEEDED\", \"message\": \"요청 한도를 초과했습니다.\" }"))))) // 글로벌 보안 요구사항 (일부 엔드포인트는 개별적으로 재정의) .addSecurityItem(new SecurityRequirement() - .addList(jwtSchemeName) - .addList(sessionSchemeName)); + .addList(jwtSchemeName)); } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/RoomPaginationIndexMigration.java b/apps/backend/src/main/java/com/ktb/chatapp/config/RoomPaginationIndexMigration.java new file mode 100644 index 00000000..e5463dd6 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/RoomPaginationIndexMigration.java @@ -0,0 +1,82 @@ +package com.ktb.chatapp.config; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.UpdateOneModel; +import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.model.WriteModel; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RoomPaginationIndexMigration implements ApplicationRunner { + + static final String COLLECTION = "rooms"; + static final String INDEX_NAME = "rooms_created_at_id_desc"; + private static final Document INDEX_KEY = new Document("createdAt", -1).append("_id", -1); + private static final Document MISSING_CREATED_AT = new Document("$or", List.of( + new Document("createdAt", new Document("$exists", false)), + new Document("createdAt", null) + )); + + private final MongoTemplate mongoTemplate; + + @Override + public void run(ApplicationArguments args) { + MongoCollection rooms = mongoTemplate.getCollection(COLLECTION); + backfillCreatedAt(rooms); + ensureIndex(rooms); + } + + private void backfillCreatedAt(MongoCollection rooms) { + List missing = rooms.find(MISSING_CREATED_AT) + .projection(new Document("_id", 1)) + .into(new ArrayList<>()); + List> updates = new ArrayList<>(); + for (Document room : missing) { + Object rawId = room.get("_id"); + ObjectId objectId = objectId(rawId); + if (objectId == null) { + throw new IllegalStateException( + "Cannot backfill rooms.createdAt: invalid ObjectId=" + rawId + ); + } + Date createdAt = Date.from(Instant.ofEpochSecond(objectId.getTimestamp())); + updates.add(new UpdateOneModel<>( + new Document("_id", rawId).append("$or", MISSING_CREATED_AT.get("$or")), + new Document("$set", new Document("createdAt", createdAt)), + new UpdateOptions() + )); + } + if (!updates.isEmpty()) rooms.bulkWrite(updates); + } + + private void ensureIndex(MongoCollection rooms) { + for (Document index : rooms.listIndexes()) { + Document key = index.get("key", Document.class); + if (INDEX_KEY.equals(key)) return; + if (INDEX_NAME.equals(index.getString("name"))) { + throw new IllegalStateException( + "Cannot create room pagination index: conflicting index name=" + INDEX_NAME + ); + } + } + rooms.createIndex(INDEX_KEY, new IndexOptions().name(INDEX_NAME)); + } + + private ObjectId objectId(Object rawId) { + if (rawId instanceof ObjectId objectId) return objectId; + if (rawId instanceof String id && ObjectId.isValid(id)) return new ObjectId(id); + return null; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java b/apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java new file mode 100644 index 00000000..b4e0186d --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java @@ -0,0 +1,44 @@ +package com.ktb.chatapp.config; + +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + @Bean + S3Client s3Client( + @Value("${aws.s3.region}") String region, + @Value("${aws.s3.endpoint:}") String endpoint, + @Value("${aws.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Client.builder() + .region(Region.of(region)) + .forcePathStyle(pathStyleAccess); + if (!endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } + + @Bean + S3Presigner s3Presigner( + @Value("${aws.s3.region}") String region, + @Value("${aws.s3.endpoint:}") String endpoint, + @Value("${aws.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Presigner.builder() + .region(Region.of(region)) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccess) + .build()); + if (!endpoint.isBlank()) { + 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..a0609638 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 @@ -39,15 +39,11 @@ public class SecurityConfig { "Content-Type", "Authorization", "x-auth-token", - "x-session-id", "Cache-Control", "Pragma" ); - private static final List CORS_EXPOSED_HEADERS = List.of( - "x-auth-token", - "x-session-id" - ); + private static final List CORS_EXPOSED_HEADERS = List.of("Authorization"); private static final List CORS_ALLOWED_METHODS = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/UserEmailIndexMigration.java b/apps/backend/src/main/java/com/ktb/chatapp/config/UserEmailIndexMigration.java new file mode 100644 index 00000000..9b129e9b --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/UserEmailIndexMigration.java @@ -0,0 +1,62 @@ +package com.ktb.chatapp.config; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.IndexOptions; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserEmailIndexMigration implements ApplicationRunner { + + static final String COLLECTION = "users"; + static final String INDEX_NAME = "email_unique"; + private static final Document EMAIL_KEY = new Document("email", 1); + + private final MongoTemplate mongoTemplate; + + @Override + public void run(ApplicationArguments args) { + MongoCollection users = mongoTemplate.getCollection(COLLECTION); + for (Document index : users.listIndexes()) { + if (!EMAIL_KEY.equals(index.get("key", Document.class))) { + continue; + } + if (Boolean.TRUE.equals(index.getBoolean("unique"))) { + return; + } + throw new IllegalStateException( + "Cannot create unique users.email index: conflicting non-unique index " + + index.getString("name") + ); + } + + Document duplicate = users.aggregate(List.of( + new Document("$group", new Document("_id", "$email") + .append("ids", new Document("$push", "$_id")) + .append("count", new Document("$sum", 1))), + new Document("$match", new Document("count", new Document("$gt", 1))), + new Document("$limit", 1) + )).first(); + if (duplicate != null) { + throw new IllegalStateException( + "Cannot create unique users.email index: duplicate email=" + + duplicate.get("_id") + ", userIds=" + duplicate.getList("ids", Object.class) + ); + } + + try { + users.createIndex(EMAIL_KEY, new IndexOptions().unique(true).name(INDEX_NAME)); + } catch (RuntimeException exception) { + throw new IllegalStateException( + "Failed to create unique users.email index on collection " + COLLECTION, + exception + ); + } + } +} 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..840aa246 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 @@ -4,6 +4,7 @@ import com.ktb.chatapp.event.SessionEndedEvent; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.security.AuthenticatedUserPrincipal; import com.ktb.chatapp.service.JwtService; import com.ktb.chatapp.service.SessionCreationResult; import com.ktb.chatapp.service.SessionMetadata; @@ -68,7 +69,7 @@ public ResponseEntity getAuthStatus() { return ResponseEntity.ok(Map.of("status", "active", "routes", routes)); } - @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다. 등록 성공 시 JWT 토큰과 세션 ID가 반환됩니다.") + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다. 세션과 JWT는 로그인 시 생성됩니다.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "회원가입 성공", content = @Content(schema = @Schema(implementation = LoginResponse.class))), @@ -85,8 +86,7 @@ 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); @@ -108,13 +108,6 @@ public ResponseEntity registerUser( 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("회원가입이 완료되었습니다.") @@ -139,7 +132,7 @@ public ResponseEntity registerUser( } } - @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다. 성공 시 JWT 토큰과 세션 ID가 반환됩니다. 기존 세션은 자동으로 종료됩니다.") + @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다. 성공 시 세션 ID가 포함된 JWT가 반환되며 기존 세션은 자동으로 종료됩니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = LoginResponse.class))), @@ -163,20 +156,18 @@ public ResponseEntity login( if (errors != null) return errors; try { - // Authenticate user - User user = userRepository.findByEmail(loginRequest.getEmail().toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( - user.getEmail(), + loginRequest.getEmail().toLowerCase(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); - - // 단일 세션 정책을 위해 기존 세션 제거 - sessionService.removeAllUserSessions(user.getId()); + AuthenticatedUserPrincipal principal = + (AuthenticatedUserPrincipal) authentication.getPrincipal(); + User user = principal.getUser(); + boolean replacingExistingSession = sessionService.getActiveSession(user.getId()) != null; // Create new session SessionMetadata metadata = new SessionMetadata( @@ -188,6 +179,15 @@ public ResponseEntity login( SessionCreationResult sessionInfo = sessionService.createSession(user.getId(), metadata); + if (replacingExistingSession) { + eventPublisher.publishEvent(new SessionEndedEvent( + this, + user.getId(), + "duplicate_login", + "다른 기기에서 로그인하여 현재 세션이 종료되었습니다." + )); + } + // Generate JWT token String token = jwtService.generateToken( sessionInfo.getSessionId(), @@ -198,13 +198,11 @@ public ResponseEntity login( LoginResponse response = LoginResponse.builder() .success(true) .token(token) - .sessionId(sessionInfo.getSessionId()) .user(AuthUserDto.from(user)) .build(); return ResponseEntity.ok() .header("Authorization", "Bearer " + token) - .header("x-session-id", sessionInfo.getSessionId()) .body(response); } catch (UsernameNotFoundException | BadCredentialsException e) { @@ -217,14 +215,11 @@ public ResponseEntity login( } } - @Operation(summary = "로그아웃", description = "현재 세션을 종료합니다. x-session-id 헤더가 필요합니다.") + @Operation(summary = "로그아웃", description = "JWT가 가리키는 현재 세션을 종료합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = StandardResponse.class), examples = @ExampleObject(value = "{\"success\":true,\"message\":\"로그아웃이 완료되었습니다.\"}"))), - @ApiResponse(responseCode = "400", description = "x-session-id 헤더 누락", - content = @Content(schema = @Schema(implementation = StandardResponse.class), - examples = @ExampleObject(value = "{\"success\":false,\"message\":\"x-session-id 헤더가 필요합니다.\"}"))), @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(implementation = StandardResponse.class))), @ApiResponse(responseCode = "500", description = "서버 내부 오류", @@ -232,22 +227,15 @@ public ResponseEntity login( }) @PostMapping("/logout") public ResponseEntity> logout( - HttpServletRequest request, Authentication authentication) { try { - // x-session-id 헤더 필수 - String sessionId = extractSessionId(request); - if (sessionId == null || sessionId.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(StandardResponse.error("x-session-id 헤더가 필요합니다.")); - } - if (authentication != null) { // Spring Security 6 패턴: Authentication의 Details에서 userId 추출 @SuppressWarnings("unchecked") Map details = (Map) authentication.getDetails(); String userId = (String) details.get("userId"); + String sessionId = (String) details.get("sessionId"); if (userId != null) { sessionService.removeSession(userId, sessionId); @@ -271,13 +259,13 @@ public ResponseEntity> logout( } - @Operation(summary = "토큰 검증", description = "JWT 토큰과 세션의 유효성을 검증합니다. x-auth-token 또는 Authorization 헤더와 x-session-id 헤더가 필요합니다.") + @Operation(summary = "토큰 검증", description = "JWT 토큰과 해당 토큰이 가리키는 세션의 유효성을 검증합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "토큰 검증 성공", content = @Content(schema = @Schema(implementation = TokenVerifyResponse.class))), - @ApiResponse(responseCode = "400", description = "토큰 또는 세션 ID 누락", + @ApiResponse(responseCode = "400", description = "토큰 또는 JWT 세션 ID 누락", content = @Content(schema = @Schema(implementation = TokenVerifyResponse.class), - examples = @ExampleObject(value = "{\"valid\":false,\"message\":\"토큰 또는 세션 ID가 필요합니다.\"}"))), + examples = @ExampleObject(value = "{\"valid\":false,\"message\":\"유효한 JWT가 필요합니다.\"}"))), @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰 또는 만료된 세션", content = @Content(schema = @Schema(implementation = TokenVerifyResponse.class), examples = @ExampleObject(value = "{\"valid\":false,\"message\":\"유효하지 않은 토큰입니다.\"}"))), @@ -289,11 +277,11 @@ public ResponseEntity> logout( public ResponseEntity verifyToken(HttpServletRequest request) { try { String token = extractToken(request); - String sessionId = extractSessionId(request); + String sessionId = token != null ? jwtService.extractSessionId(token) : null; if (token == null || sessionId == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new TokenVerifyResponse(false, "토큰 또는 세션 ID가 필요합니다.", null)); + .body(new TokenVerifyResponse(false, "유효한 JWT가 필요합니다.", null)); } // 토큰 유효성 검증 @@ -329,13 +317,13 @@ public ResponseEntity verifyToken(HttpServletRequest request) { } } - @Operation(summary = "토큰 갱신", description = "만료된 토큰을 갱신합니다. 새로운 토큰과 세션 ID가 발급됩니다. 기존 세션은 종료됩니다.") + @Operation(summary = "토큰 갱신", description = "유효한 Redis 세션을 기준으로 JWT를 갱신합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", content = @Content(schema = @Schema(implementation = TokenRefreshResponse.class))), - @ApiResponse(responseCode = "400", description = "토큰 또는 세션 ID 누락", + @ApiResponse(responseCode = "400", description = "토큰 또는 JWT 세션 ID 누락", content = @Content(schema = @Schema(implementation = TokenRefreshResponse.class), - examples = @ExampleObject(value = "{\"success\":false,\"message\":\"토큰 또는 세션 ID가 필요합니다.\"}"))), + examples = @ExampleObject(value = "{\"success\":false,\"message\":\"유효한 JWT가 필요합니다.\"}"))), @ApiResponse(responseCode = "401", description = "유효하지 않은 사용자 또는 만료된 세션", content = @Content(schema = @Schema(implementation = TokenRefreshResponse.class), examples = @ExampleObject(value = "{\"success\":false,\"message\":\"만료된 세션입니다.\"}"))), @@ -347,11 +335,11 @@ public ResponseEntity verifyToken(HttpServletRequest request) { public ResponseEntity refreshToken(HttpServletRequest request) { try { String token = extractToken(request); - String sessionId = extractSessionId(request); + String sessionId = token != null ? jwtService.extractSessionIdFromExpiredToken(token) : null; if (token == null || sessionId == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new TokenRefreshResponse(false, "토큰 또는 세션 ID가 필요합니다.", null, null)); + .body(new TokenRefreshResponse(false, "유효한 JWT가 필요합니다.", null)); } // 만료된 토큰이라도 사용자 정보는 추출 가능 @@ -361,7 +349,7 @@ public ResponseEntity refreshToken(HttpServletRequest request) { if (userOpt.isEmpty()) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new TokenRefreshResponse(false, "사용자를 찾을 수 없습니다.", null, null)); + .body(new TokenRefreshResponse(false, "사용자를 찾을 수 없습니다.", null)); } @@ -369,31 +357,20 @@ public ResponseEntity refreshToken(HttpServletRequest request) { var user = userOpt.get(); if (!sessionService.validateSession(user.getId(), sessionId).isValid()) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new TokenRefreshResponse(false, "만료된 세션입니다.", null, null)); + .body(new TokenRefreshResponse(false, "만료된 세션입니다.", null)); } - // 세션 갱신 - 새로운 세션 ID 생성 - sessionService.removeSession(user.getId(), sessionId); - SessionMetadata metadata = new SessionMetadata( - request.getHeader("User-Agent"), - getClientIpAddress(request), - request.getHeader("User-Agent") - ); - - SessionCreationResult newSessionInfo = sessionService.createSession(user.getId(), metadata); - - // 새로운 토큰과 세션 ID 생성 String newToken = jwtService.generateToken( - newSessionInfo.getSessionId(), + sessionId, user.getEmail(), user.getId() ); - return ResponseEntity.ok(new TokenRefreshResponse(true, "토큰이 갱신되었습니다.", newToken, newSessionInfo.getSessionId())); + return ResponseEntity.ok(new TokenRefreshResponse(true, "토큰이 갱신되었습니다.", newToken)); } catch (Exception e) { log.error("Token refresh error: ", e); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new TokenRefreshResponse(false, "토큰 갱신 중 오류가 발생했습니다.", null, null)); + .body(new TokenRefreshResponse(false, "토큰 갱신 중 오류가 발생했습니다.", null)); } } @@ -411,14 +388,6 @@ private String getClientIpAddress(HttpServletRequest request) { return request.getRemoteAddr(); } - private String extractSessionId(HttpServletRequest request) { - String sessionId = request.getHeader("x-session-id"); - if (sessionId != null && !sessionId.isEmpty()) { - return sessionId; - } - return request.getParameter("sessionId"); - } - private String extractToken(HttpServletRequest request) { String token = request.getHeader("x-auth-token"); if (token != null && !token.isEmpty()) { 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..586d8c2f 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 @@ -45,7 +45,7 @@ public class FileController { /** * 파일 업로드 */ - @Operation(summary = "파일 업로드", description = "파일을 업로드합니다. 최대 50MB까지 가능합니다.") + @Operation(summary = "파일 업로드", description = "server 모드에서 파일을 업로드합니다. 최대 5MB입니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "파일 업로드 성공"), @ApiResponse(responseCode = "400", description = "잘못된 파일", 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..708ee06c 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 @@ -9,8 +9,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import lombok.RequiredArgsConstructor; -import org.springframework.core.io.Resource; +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; @@ -39,18 +42,25 @@ 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(); } - return storagePort.open(StorageKey.profile(filename)) + String key = StorageKey.profile(filename); + ContentDisposition disposition = ContentDisposition.inline() + .filename(filename, StandardCharsets.UTF_8) + .build(); + + return storagePort.offloadUrl(key, Duration.ofMinutes(5), disposition) + .>map(uri -> ResponseEntity.status(HttpStatus.FOUND).location(uri).build()) + .orElseGet(() -> storagePort.open(key) .map(resource -> ResponseEntity.ok() .contentType(contentTypeOf(filename)) .body(resource)) - .orElseGet(() -> ResponseEntity.notFound().build()); + .orElseGet(() -> ResponseEntity.notFound().build())); } private MediaType contentTypeOf(String filename) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileUploadIntentController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileUploadIntentController.java new file mode 100644 index 00000000..96aafb41 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileUploadIntentController.java @@ -0,0 +1,45 @@ +package com.ktb.chatapp.controller; + +import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.dto.MirrorUploadResultRequest; +import com.ktb.chatapp.dto.UploadIntentRequest; +import com.ktb.chatapp.model.PendingUpload; +import com.ktb.chatapp.service.UploadIntentException; +import com.ktb.chatapp.service.UploadIntentService; +import jakarta.validation.Valid; +import java.security.Principal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +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.RestController; + +@RestController +@RequestMapping("/api/users/profile-image") +@RequiredArgsConstructor +public class ProfileUploadIntentController { + private final UploadIntentService service; + + @PostMapping("/presign") + public ResponseEntity create(@Valid @RequestBody UploadIntentRequest request, Principal principal) { + return ResponseEntity.ok(service.create(principal.getName(), request, PendingUpload.Purpose.PROFILE)); + } + + @PostMapping("/mirror-result") + public ResponseEntity result(@Valid @RequestBody MirrorUploadResultRequest request, Principal principal) { + service.recordResult(principal.getName(), request, PendingUpload.Purpose.PROFILE); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(UploadIntentException.class) + ResponseEntity handle(UploadIntentException ex) { + return ResponseEntity.status(ex.status()).body(StandardResponse.error(ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity handleInvalid(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(StandardResponse.error(ex.getMessage())); + } +} 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..232fff3b 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 @@ -7,6 +7,7 @@ import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.RecentMessageCounter; import com.ktb.chatapp.service.RoomService; +import com.ktb.chatapp.exception.InvalidRoomCursorException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -20,15 +21,20 @@ import java.security.Principal; import java.time.Duration; import java.time.LocalDateTime; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +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; import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.bind.annotation.*; @Tag(name = "채팅방 (Rooms)", description = "채팅방 생성 및 관리 API - 채팅방 목록 조회, 생성, 참여, 헬스체크") @@ -81,8 +87,8 @@ public ResponseEntity healthCheck() { } } - // 전체 채팅방 목록 조회 - @Operation(summary = "채팅방 목록 조회", description = "전체 채팅방 목록을 최신순으로 조회합니다. Rate Limit이 적용됩니다.") + // 채팅방 목록 조회 + @Operation(summary = "채팅방 목록 조회", description = "채팅방 목록을 최신순 커서 페이지로 조회합니다. Rate Limit이 적용됩니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "채팅방 목록 조회 성공", content = @Content(schema = @Schema(implementation = RoomsResponse.class))), @@ -96,10 +102,22 @@ public ResponseEntity healthCheck() { }) @GetMapping @RateLimit - public ResponseEntity getAllRooms(Principal principal) { + public ResponseEntity getAllRooms( + Principal principal, + @RequestParam(defaultValue = "30") String size, + @RequestParam(required = false) String cursor) { try { - RoomsResponse response = roomService.getAllRooms(principal.getName()); + int pageSize; + try { + pageSize = Integer.parseInt(size); + } catch (NumberFormatException exception) { + return invalidPageRequest("INVALID_PAGE_SIZE", "size는 1에서 100 사이의 정수여야 합니다."); + } + if (pageSize < 1 || pageSize > 100) { + return invalidPageRequest("INVALID_PAGE_SIZE", "size는 1에서 100 사이의 정수여야 합니다."); + } + RoomsResponse response = roomService.getAllRooms(principal.getName(), pageSize, cursor); // 캐시 설정 return ResponseEntity.ok() @@ -107,6 +125,8 @@ public ResponseEntity getAllRooms(Principal principal) { .header("Last-Modified", java.time.Instant.now().toString()) .body(response); + } catch (InvalidRoomCursorException exception) { + return invalidPageRequest("INVALID_CURSOR", "유효하지 않은 채팅방 페이지 커서입니다."); } catch (Exception e) { log.error("방 목록 조회 에러", e); @@ -129,6 +149,14 @@ public ResponseEntity getAllRooms(Principal principal) { } } + private ResponseEntity> invalidPageRequest(String code, String message) { + return ResponseEntity.badRequest().body(StandardResponse.builder() + .success(false) + .code(code) + .message(message) + .build()); + } + @Operation(summary = "채팅방 생성", description = "새로운 채팅방을 생성합니다. 비밀번호를 설정하여 비공개 방을 만들 수 있습니다.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "채팅방 생성 성공", @@ -150,8 +178,7 @@ public ResponseEntity createRoom(@Valid @RequestBody CreateRoomRequest create ); } - Room savedRoom = roomService.createRoom(createRoomRequest, principal.getName()); - RoomResponse roomResponse = mapToRoomResponse(savedRoom, principal.getName()); + RoomResponse roomResponse = roomService.createRoom(createRoomRequest, principal.getName()); return ResponseEntity.status(201).body( Map.of( @@ -232,16 +259,19 @@ public ResponseEntity getRoomById(@Parameter(description = "채팅방 ID", ex public ResponseEntity joinRoom( @Parameter(description = "채팅방 ID", example = "60d5ec49f1b2c8b9e8c4f2a1") @PathVariable String roomId, @RequestBody JoinRoomRequest joinRoomRequest, - Principal principal) { + JwtAuthenticationToken principal) { try { - Room joinedRoom = roomService.joinRoom(roomId, joinRoomRequest.getPassword(), principal.getName()); + String userId = principal.getToken().getClaimAsString("userId"); + RoomResponse roomResponse = roomService.joinRoom( + roomId, + joinRoomRequest.getPassword(), + userId + ); - if (joinedRoom == null) { + if (roomResponse == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(StandardResponse.error("채팅방을 찾을 수 없습니다.")); } - - RoomResponse roomResponse = mapToRoomResponse(joinedRoom, principal.getName()); return ResponseEntity.ok( Map.of( @@ -267,20 +297,27 @@ public ResponseEntity joinRoom( } private RoomResponse mapToRoomResponse(Room room, String name) { - User creator = userRepository.findById(room.getCreator()).orElse(null); + Set userIds = new LinkedHashSet<>(); + userIds.add(room.getCreator()); + userIds.addAll(room.getParticipantIds()); + Map usersById = userRepository.findSummariesByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + + 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); + .map(userId -> { + User participant = usersById.get(userId); + if (participant == null) { + log.warn("Participant not found: roomId={}, userId={}", room.getId(), userId); } + return participant; }) - .filter(Optional::isPresent) - .map(Optional::get) + .filter(java.util.Objects::nonNull) .map(UserResponse::from) .toList(); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/UploadIntentController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/UploadIntentController.java new file mode 100644 index 00000000..f131db80 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/UploadIntentController.java @@ -0,0 +1,45 @@ +package com.ktb.chatapp.controller; + +import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.dto.MirrorUploadResultRequest; +import com.ktb.chatapp.dto.UploadIntentRequest; +import com.ktb.chatapp.model.PendingUpload; +import com.ktb.chatapp.service.UploadIntentException; +import com.ktb.chatapp.service.UploadIntentService; +import jakarta.validation.Valid; +import java.security.Principal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +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.RestController; + +@RestController +@RequestMapping("/api/files/upload") +@RequiredArgsConstructor +public class UploadIntentController { + private final UploadIntentService service; + + @PostMapping("/presign") + public ResponseEntity create(@Valid @RequestBody UploadIntentRequest request, Principal principal) { + return ResponseEntity.ok(service.create(principal.getName(), request, PendingUpload.Purpose.CHAT)); + } + + @PostMapping("/mirror-result") + public ResponseEntity result(@Valid @RequestBody MirrorUploadResultRequest request, Principal principal) { + service.recordResult(principal.getName(), request, PendingUpload.Purpose.CHAT); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(UploadIntentException.class) + ResponseEntity handle(UploadIntentException ex) { + return ResponseEntity.status(ex.status()).body(StandardResponse.error(ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity handleInvalid(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(StandardResponse.error(ex.getMessage())); + } +} 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..f9306f9f 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 @@ -103,7 +103,7 @@ public ResponseEntity updateCurrentUserProfile( /** * 프로필 이미지 업로드 */ - @Operation(summary = "프로필 이미지 업로드", description = "프로필 이미지를 업로드합니다. 최대 5MB까지 가능합니다.") + @Operation(summary = "프로필 이미지 업로드", description = "server 모드에서 프로필 이미지를 업로드합니다. 최대 5MB입니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "이미지 업로드 성공", content = @Content(schema = @Schema(implementation = ProfileImageResponse.class))), diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/LoginResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/LoginResponse.java index 403fe804..863c2448 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/LoginResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/LoginResponse.java @@ -12,7 +12,6 @@ public class LoginResponse { private boolean success; private String token; - private String sessionId; private AuthUserDto user; private String message; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/MirrorUploadResultRequest.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/MirrorUploadResultRequest.java new file mode 100644 index 00000000..04542989 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/MirrorUploadResultRequest.java @@ -0,0 +1,11 @@ +package com.ktb.chatapp.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; + +public record MirrorUploadResultRequest( + @NotBlank String uploadId, + boolean success, + @Min(0) @Max(599) int status, + @Min(0) long durationMs) {} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/PageMetadata.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/PageMetadata.java index 7b62fd92..ccd6ef23 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/PageMetadata.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/PageMetadata.java @@ -1,5 +1,6 @@ package com.ktb.chatapp.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -9,21 +10,10 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) public class PageMetadata { - private long total; - private int page; private int pageSize; - private long totalPages; private boolean hasMore; private int currentCount; - private SortInfo sort; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class SortInfo { - private String field; - private String order; - } + private String nextCursor; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/TokenRefreshResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/TokenRefreshResponse.java index 6d0711d3..76fd7a81 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/TokenRefreshResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/TokenRefreshResponse.java @@ -3,7 +3,6 @@ public record TokenRefreshResponse( boolean success, String message, - String token, - String sessionId + String token ) { } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentRequest.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentRequest.java new file mode 100644 index 00000000..85bc365d --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentRequest.java @@ -0,0 +1,9 @@ +package com.ktb.chatapp.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public record UploadIntentRequest( + @NotBlank String originalFilename, + @NotBlank String contentType, + @Positive long size) {} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentResponse.java new file mode 100644 index 00000000..92af7483 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/UploadIntentResponse.java @@ -0,0 +1,8 @@ +package com.ktb.chatapp.dto; + +import java.time.Instant; +import java.util.Map; + +public record UploadIntentResponse( + String uploadId, String key, String uploadUrl, String method, + Map headers, Instant expiresAt) {} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/exception/InvalidRoomCursorException.java b/apps/backend/src/main/java/com/ktb/chatapp/exception/InvalidRoomCursorException.java new file mode 100644 index 00000000..af7b908e --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/exception/InvalidRoomCursorException.java @@ -0,0 +1,12 @@ +package com.ktb.chatapp.exception; + +public class InvalidRoomCursorException extends RuntimeException { + + public InvalidRoomCursorException() { + super("INVALID_CURSOR"); + } + + public InvalidRoomCursorException(Throwable cause) { + super("INVALID_CURSOR", cause); + } +} 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..ed56a2bf 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,7 @@ public class File { @Id private String id; + @Indexed(name = "filename_idx") private String filename; private String originalname; @@ -78,4 +80,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..a2fe71fb 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 @@ -9,6 +9,8 @@ import org.springframework.data.annotation.Id; 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 org.springframework.data.mongodb.core.index.Indexed; import java.time.LocalDateTime; import java.util.ArrayList; @@ -26,6 +28,10 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "messages") +@CompoundIndex( + name = "room_timestamp_idx", + def = "{'room': 1, 'timestamp': 1}" +) public class Message { @Id @@ -45,6 +51,7 @@ public class Message { private MessageType type; // Mongo 문서 필드명 "file" 사용 + @Indexed(name = "file_idx") @Field("file") private String fileId; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/PendingUpload.java b/apps/backend/src/main/java/com/ktb/chatapp/model/PendingUpload.java new file mode 100644 index 00000000..2fc9d8e3 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/PendingUpload.java @@ -0,0 +1,31 @@ +package com.ktb.chatapp.model; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "pending_uploads") +public class PendingUpload { + @Id private String id; + @Indexed private String userId; + private Purpose purpose; + @Indexed(unique = true) private String pendingKey; + private String originalFilename; + private String contentType; + private long expectedSize; + private Status status; + private Instant createdAt; + @Indexed(expireAfter = "0s") private Instant expiresAt; + + public enum Purpose { CHAT, PROFILE } + public enum Status { PENDING, COMPLETED, FAILED } +} 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..4222cf1d 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 @@ -6,26 +6,14 @@ import lombok.Builder; 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; @Data @Builder @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; - private String userId; private String sessionId; @@ -36,6 +24,5 @@ public class Session { private SessionMetadata metadata; - @Indexed(expireAfter = SESSION_TTL) 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..bff5ef77 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 @@ -8,7 +8,6 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener; import org.springframework.data.mongodb.core.mapping.event.BeforeConvertEvent; @@ -28,7 +27,6 @@ public class User { private String name; - @Indexed(unique = true) private String email; private String encryptedEmail; @@ -82,4 +80,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..300b1c82 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 @@ -3,15 +3,15 @@ import com.ktb.chatapp.model.Message; import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface MessageRepository extends MongoRepository { - Page findByRoomIdAndTimestampBefore(String roomId, LocalDateTime timestamp, Pageable pageable); + Slice findByRoomIdAndTimestampBefore(String roomId, LocalDateTime timestamp, Pageable pageable); /** * 특정 시간 이후의 메시지 수 카운트 * 최근 N분간 메시지 수를 조회할 때 사용 diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/PendingUploadRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/PendingUploadRepository.java new file mode 100644 index 00000000..a6710b2a --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/PendingUploadRepository.java @@ -0,0 +1,12 @@ +package com.ktb.chatapp.repository; + +import com.ktb.chatapp.model.PendingUpload; +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface PendingUploadRepository extends MongoRepository { + Optional findByIdAndUserIdAndPurpose(String id, String userId, PendingUpload.Purpose purpose); + long countByUserIdAndStatusAndExpiresAtAfter(String userId, PendingUpload.Status status, Instant now); + long countByUserIdAndCreatedAtAfter(String userId, Instant since); +} 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..2477f0cc 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 @@ -9,7 +9,7 @@ import java.util.Optional; @Repository -public interface RoomRepository extends MongoRepository { +public interface RoomRepository extends MongoRepository, RoomRepositoryCustom { // 가장 최근에 생성된 방 조회 (Health Check용) @Query(value = "{}", sort = "{ 'createdAt': -1 }") diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryCustom.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryCustom.java new file mode 100644 index 00000000..a34fbb45 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ktb.chatapp.repository; + +import com.ktb.chatapp.model.Room; +import java.time.LocalDateTime; +import java.util.List; + +public interface RoomRepositoryCustom { + + Room addParticipantAndReturn(String roomId, String userId); + + List findPage(LocalDateTime cursorCreatedAt, String cursorId, int limit); +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryImpl.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryImpl.java new file mode 100644 index 00000000..4ad1424c --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.ktb.chatapp.repository; + +import com.ktb.chatapp.model.Room; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Sort; +import org.bson.types.ObjectId; + +@RequiredArgsConstructor +public class RoomRepositoryImpl implements RoomRepositoryCustom { + + private final MongoTemplate mongoTemplate; + + @Override + public Room addParticipantAndReturn(String roomId, String userId) { + Query query = Query.query(Criteria.where("_id").is(roomId) + .and("participantIds").ne(userId)); + Update update = new Update().addToSet("participantIds", userId); + return mongoTemplate.findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true), + Room.class + ); + } + + @Override + public List findPage(LocalDateTime cursorCreatedAt, String cursorId, int limit) { + Query query = new Query().limit(limit).with(Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("_id") + )); + if (cursorCreatedAt != null && cursorId != null) { + query.addCriteria(new Criteria().orOperator( + Criteria.where("createdAt").lt(cursorCreatedAt), + new Criteria().andOperator( + Criteria.where("createdAt").is(cursorCreatedAt), + Criteria.where("_id").lt(new ObjectId(cursorId)) + ) + )); + } + return mongoTemplate.find(query, Room.class); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/SessionRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/SessionRepository.java deleted file mode 100644 index db3ed042..00000000 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/SessionRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ktb.chatapp.repository; - -import com.ktb.chatapp.model.Session; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface SessionRepository extends MongoRepository { - Optional findByUserId(String userId); - void deleteByUserId(String userId); -} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/UserRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/UserRepository.java index 9ffc4ebf..4397ea30 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/UserRepository.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/UserRepository.java @@ -2,11 +2,20 @@ import com.ktb.chatapp.model.User; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; @Repository public interface UserRepository extends MongoRepository { Optional findByEmail(String email); + + @Query( + value = "{ '_id': { $in: ?0 } }", + fields = "{ '_id': 1, 'name': 1, 'email': 1, 'profileImage': 1 }" + ) + List findSummariesByIdIn(Collection userIds); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/security/AuthenticatedUserPrincipal.java b/apps/backend/src/main/java/com/ktb/chatapp/security/AuthenticatedUserPrincipal.java new file mode 100644 index 00000000..d3e1fb3f --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/security/AuthenticatedUserPrincipal.java @@ -0,0 +1,31 @@ +package com.ktb.chatapp.security; + +import com.ktb.chatapp.model.User; +import java.util.Collection; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@RequiredArgsConstructor +public class AuthenticatedUserPrincipal implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/security/CustomBearerTokenResolver.java b/apps/backend/src/main/java/com/ktb/chatapp/security/CustomBearerTokenResolver.java index 4e4c34e4..a1cf3c5c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/security/CustomBearerTokenResolver.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/security/CustomBearerTokenResolver.java @@ -10,7 +10,6 @@ public class CustomBearerTokenResolver implements BearerTokenResolver { private static final String CUSTOM_HEADER = "x-auth-token"; - private static final String SESSION_HEADER = "x-session-id"; @Override public String resolve(HttpServletRequest request) { 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..ee83cc78 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 @@ -18,17 +18,19 @@ public class LocalFileService implements FileService { private final StoragePort storagePort; private final FileRepository fileRepository; + private final UploadPolicy uploadPolicy; - public LocalFileService(StoragePort storagePort, FileRepository fileRepository) { + public LocalFileService(StoragePort storagePort, FileRepository fileRepository, UploadPolicy uploadPolicy) { this.storagePort = storagePort; this.fileRepository = fileRepository; + this.uploadPolicy = uploadPolicy; } @Override public FileUploadResult uploadFile(MultipartFile file, String uploaderId) { try { // 파일 보안 검증 - FileUtil.validateFile(file); + uploadPolicy.validate(file.getOriginalFilename(), file.getContentType(), file.getSize(), false); // 안전한 파일명 생성 String originalFilename = file.getOriginalFilename(); @@ -75,7 +77,8 @@ public FileUploadResult uploadFile(MultipartFile file, String uploaderId) { public String storeFile(MultipartFile file, String subDirectory) { try { // 파일 보안 검증 - FileUtil.validateFile(file); + uploadPolicy.validate(file.getOriginalFilename(), file.getContentType(), file.getSize(), + "profiles".equals(subDirectory)); // 안전한 파일명 생성 String originalFilename = file.getOriginalFilename(); 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..75d0408a 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,13 +1,15 @@ 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 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 +19,7 @@ @RequiredArgsConstructor public class MessageReadStatusService { - private final MessageRepository messageRepository; + private final MongoTemplate mongoTemplate; /** * 메시지 읽음 상태 업데이트 @@ -26,7 +28,7 @@ public class MessageReadStatusService { * @param userId 읽은 사용자 ID */ public void updateReadStatus(List messageIds, String userId) { - if (messageIds.isEmpty()) { + if (messageIds == null || messageIds.isEmpty() || userId == null) { return; } @@ -36,23 +38,19 @@ 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); - } + List distinctMessageIds = messageIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (distinctMessageIds.isEmpty()) { + return; } + + Query query = Query.query(Criteria.where("_id").in(distinctMessageIds) + .and("readers.userId").ne(userId)); + mongoTemplate.updateMulti(query, new Update().push("readers", readerInfo), Message.class); log.debug("Read status updated for {} messages by user {}", - messageIds.size(), userId); + distinctMessageIds.size(), userId); } catch (Exception e) { log.error("Read status update error for user {}", userId, e); } 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..477ee80d 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 @@ -3,7 +3,16 @@ import com.ktb.chatapp.repository.MessageRepository; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.stereotype.Component; /** @@ -16,9 +25,41 @@ public class RecentMessageCounter { static final Duration RECENT_WINDOW = Duration.ofMinutes(30); private final MessageRepository messageRepository; + private final MongoTemplate mongoTemplate; public int countRecentMessages(String roomId) { LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW); return (int) messageRepository.countRecentMessagesByRoomId(roomId, since); } + + public Map countRecentMessages(Collection roomIds) { + return countRecentMessages(roomIds, LocalDateTime.now().minus(RECENT_WINDOW)); + } + + Map countRecentMessages( + Collection roomIds, + LocalDateTime since) { + if (roomIds == null || roomIds.isEmpty()) { + return Collections.emptyMap(); + } + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where("room").in(roomIds) + .and("timestamp").gte(since)), + Aggregation.group("room").count().as("count") + ); + + AggregationResults results = mongoTemplate.aggregate( + aggregation, + "messages", + Document.class + ); + + return results.getMappedResults().stream() + .filter(result -> result.getString("_id") != null) + .collect(Collectors.toMap( + result -> result.getString("_id"), + result -> result.get("count", Number.class).intValue() + )); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomCursorCodec.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomCursorCodec.java new file mode 100644 index 00000000..49feeb4b --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomCursorCodec.java @@ -0,0 +1,47 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.exception.InvalidRoomCursorException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import org.bson.types.ObjectId; + +final class RoomCursorCodec { + + private static final String VERSION = "v1"; + + private RoomCursorCodec() { + } + + static String encode(LocalDateTime createdAt, String id) { + if (createdAt == null || !ObjectId.isValid(id)) { + throw new InvalidRoomCursorException(); + } + String payload = String.join("|", VERSION, createdAt.toString(), id); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + } + + static Cursor decode(String cursor) { + if (cursor == null || cursor.isBlank()) return null; + try { + String payload = new String( + Base64.getUrlDecoder().decode(cursor), + StandardCharsets.UTF_8 + ); + String[] parts = payload.split("\\|", -1); + if (parts.length != 3 || !VERSION.equals(parts[0]) || !ObjectId.isValid(parts[2])) { + throw new InvalidRoomCursorException(); + } + return new Cursor(LocalDateTime.parse(parts[1]), parts[2]); + } catch (InvalidRoomCursorException exception) { + throw exception; + } catch (IllegalArgumentException | DateTimeParseException exception) { + throw new InvalidRoomCursorException(exception); + } + } + + record Cursor(LocalDateTime createdAt, String id) { + } +} 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..dcb44978 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 @@ -8,11 +8,13 @@ import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.repository.UserRepository; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,39 +33,66 @@ public class RoomService { private final PasswordEncoder passwordEncoder; private final ApplicationEventPublisher eventPublisher; - public RoomsResponse getAllRooms(String name) { + public RoomsResponse getAllRooms(String name, int pageSize, String encodedCursor) { + RoomCursorCodec.Cursor cursor = RoomCursorCodec.decode(encodedCursor); + List page = roomRepository.findPage( + cursor == null ? null : cursor.createdAt(), + cursor == null ? null : cursor.id(), + pageSize + 1 + ); + boolean hasMore = page.size() > pageSize; + List rooms = hasMore ? page.subList(0, pageSize) : page; + Set userIds = rooms.stream() + .flatMap(room -> { + Set ids = new LinkedHashSet<>(); + if (room.getCreator() != null) { + ids.add(room.getCreator()); + } + if (room.getParticipantIds() != null) { + ids.addAll(room.getParticipantIds()); + } + return ids.stream(); + }) + .collect(Collectors.toSet()); + Map usersById = userIds.isEmpty() + ? Map.of() + : userRepository.findSummariesByIdIn(userIds).stream() + .filter(user -> user != null && user.getId() != null) + .collect(Collectors.toMap(User::getId, Function.identity())); + Set roomIds = rooms.stream() + .map(Room::getId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + Map recentMessageCounts = roomIds.isEmpty() + ? Map.of() + : recentMessageCounter.countRecentMessages(roomIds); - 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(); + List roomResponses = rooms.stream() + .map(room -> mapToRoomResponse( + room, + name, + usersById, + recentMessageCounts.getOrDefault(room.getId(), 0))) + .collect(Collectors.toList()); - return RoomsResponse.builder() - .success(true) - .data(roomResponses) - .metadata(metadata) - .build(); + String nextCursor = hasMore && !rooms.isEmpty() + ? RoomCursorCodec.encode( + rooms.get(rooms.size() - 1).getCreatedAt(), + rooms.get(rooms.size() - 1).getId()) + : null; - } catch (Exception e) { - log.error("방 목록 조회 에러", e); - return RoomsResponse.builder() - .success(false) - .data(List.of()) - .build(); - } + PageMetadata metadata = PageMetadata.builder() + .pageSize(pageSize) + .hasMore(hasMore) + .currentCount(roomResponses.size()) + .nextCursor(nextCursor) + .build(); + + return RoomsResponse.builder() + .success(true) + .data(roomResponses) + .metadata(metadata) + .build(); } public HealthResponse getHealthStatus() { @@ -112,7 +141,7 @@ public HealthResponse getHealthStatus() { } } - public Room createRoom(CreateRoomRequest createRoomRequest, String name) { + public RoomResponse createRoom(CreateRoomRequest createRoomRequest, String name) { User creator = userRepository.findByEmail(name) .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다: " + name)); @@ -127,31 +156,29 @@ public Room createRoom(CreateRoomRequest createRoomRequest, String name) { } Room savedRoom = roomRepository.save(room); + RoomResponse roomResponse = mapToRoomResponse(savedRoom, name); // Publish event for room created try { - RoomResponse roomResponse = mapToRoomResponse(savedRoom, name); eventPublisher.publishEvent(new RoomCreatedEvent(this, roomResponse)); } catch (Exception e) { log.error("roomCreated 이벤트 발행 실패", e); } - return savedRoom; + return roomResponse; } public Optional findRoomById(String roomId) { return roomRepository.findById(roomId); } - public Room joinRoom(String roomId, String password, String name) { + public RoomResponse joinRoom(String roomId, String password, String userId) { Optional roomOpt = roomRepository.findById(roomId); if (roomOpt.isEmpty()) { return null; } Room room = roomOpt.get(); - User user = userRepository.findByEmail(name) - .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다: " + name)); // 비밀번호 확인 if (room.isHasPassword()) { @@ -160,40 +187,88 @@ public Room joinRoom(String roomId, String password, String name) { } } - // 이미 참여중인지 확인 - if (!room.getParticipantIds().contains(user.getId())) { - // 채팅방 참여 - room.getParticipantIds().add(user.getId()); - room = roomRepository.save(room); + boolean participantAdded = false; + if (room.getParticipantIds() == null || !room.getParticipantIds().contains(userId)) { + Room updatedRoom = roomRepository.addParticipantAndReturn(roomId, userId); + if (updatedRoom != null) { + room = updatedRoom; + participantAdded = true; + } else { + room = roomRepository.findById(roomId).orElse(null); + if (room == null) { + return null; + } + } } - - // 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); + + RoomResponse roomResponse = mapToRoomResponse(room, userId); + + if (participantAdded) { + try { + eventPublisher.publishEvent(new RoomUpdatedEvent(this, roomId, roomResponse)); + } catch (Exception e) { + log.error("roomUpdate 이벤트 발행 실패", e); + } } - return room; + return roomResponse; } private RoomResponse mapToRoomResponse(Room room, String name) { if (room == null) return null; - User creator = null; + Set userIds = new LinkedHashSet<>(); if (room.getCreator() != null) { - creator = userRepository.findById(room.getCreator()).orElse(null); + userIds.add(room.getCreator()); } - - List participants = room.getParticipantIds().stream() - .map(userRepository::findById) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); + if (room.getParticipantIds() != null) { + userIds.addAll(room.getParticipantIds()); + } + Map usersById = userIds.isEmpty() + ? Map.of() + : userRepository.findSummariesByIdIn(userIds).stream() + .filter(user -> user != null && user.getId() != null) + .collect(Collectors.toMap(User::getId, Function.identity())); + User creator = room.getCreator() != null ? usersById.get(room.getCreator()) : null; + if (creator == null) { + throw new RuntimeException("Creator not found for room " + room.getId()); + } + List participants = room.getParticipantIds() == null + ? List.of() + : room.getParticipantIds().stream() + .map(usersById::get) + .filter(java.util.Objects::nonNull) + .toList(); int recentMessageCount = recentMessageCounter.countRecentMessages(room.getId()); + return buildRoomResponse(room, name, creator, participants, recentMessageCount); + } + + private RoomResponse mapToRoomResponse( + Room room, + String name, + Map usersById, + int recentMessageCount) { + if (room == null) return null; + + User creator = room.getCreator() != null ? usersById.get(room.getCreator()) : null; + List participants = room.getParticipantIds() == null + ? List.of() + : room.getParticipantIds().stream() + .map(usersById::get) + .filter(java.util.Objects::nonNull) + .toList(); + + return buildRoomResponse(room, name, creator, participants, recentMessageCount); + } + + private RoomResponse buildRoomResponse( + Room room, + String name, + User creator, + List participants, + int recentMessageCount) { return RoomResponse.builder() .id(room.getId()) .name(room.getName() != null ? room.getName() : "제목 없음") 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..35b3a369 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_UPDATE_INTERVAL = 5 * 60 * 1000; 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(); @@ -82,54 +80,35 @@ public SessionValidationResult validateSession(String userId, String sessionId) } if (!sessionId.equals(session.getSessionId())) { - log.warn("Session ID mismatch for userId: {}. Provided: {}, Expected: {}", userId, sessionId, session.getSessionId()); + log.warn("Session ID mismatch for userId: {}", userId); return SessionValidationResult.invalid("INVALID_SESSION", "잘못된 세션 ID입니다."); } // Check if session has timed out long now = Instant.now().toEpochMilli(); if (now - session.getLastActivity() > SESSION_TIMEOUT) { - log.warn("Session timed out for userId: {}, sessionId: {}", userId, sessionId); + log.warn("Session timed out for userId: {}", userId); removeSession(userId, sessionId); return SessionValidationResult.invalid("SESSION_EXPIRED", "세션이 만료되었습니다."); } - // Update last activity - session.setLastActivity(now); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - session = sessionStore.save(session); + if (now - session.getLastActivity() >= ACTIVITY_UPDATE_INTERVAL) { + if (!sessionStore.touch(userId, sessionId, now)) { + return SessionValidationResult.invalid("INVALID_SESSION", "교체된 세션입니다."); + } + session.setLastActivity(now); + session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); + } SessionData sessionData = toSessionData(session); return SessionValidationResult.valid(sessionData); } catch (Exception e) { - log.error("Session validation error for userId: {}, sessionId: {}", userId, sessionId, e); + log.error("Session validation error for userId: {}", userId, e); return SessionValidationResult.invalid("VALIDATION_ERROR", "세션 검증 중 오류가 발생했습니다."); } } - public void updateLastActivity(String userId) { - try { - if (userId == null) { - log.warn("updateLastActivity called with null userId"); - return; - } - - Session session = sessionStore.findByUserId(userId).orElse(null); - if (session == null) { - log.debug("No session found to update last activity for user: {}", userId); - return; - } - - session.setLastActivity(Instant.now().toEpochMilli()); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - sessionStore.save(session); - - } catch (Exception e) { - log.error("Failed to update session activity for user: {}", userId, e); - } - } - public void removeSession(String userId, String sessionId) { try { if (sessionId != null) { @@ -138,7 +117,7 @@ public void removeSession(String userId, String sessionId) { sessionStore.deleteAll(userId); } } catch (Exception e) { - log.error("Session removal error for userId: {}, sessionId: {}", userId, sessionId, e); + log.error("Session removal error for userId: {}", userId, e); throw new RuntimeException("세션 삭제 중 오류가 발생했습니다.", e); } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentException.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentException.java new file mode 100644 index 00000000..5c7a897a --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentException.java @@ -0,0 +1,12 @@ +package com.ktb.chatapp.service; + +import org.springframework.http.HttpStatus; + +public class UploadIntentException extends RuntimeException { + private final HttpStatus status; + public UploadIntentException(HttpStatus status, String message) { + super(message); + this.status = status; + } + public HttpStatus status() { return status; } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentService.java new file mode 100644 index 00000000..490f7b20 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadIntentService.java @@ -0,0 +1,91 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.dto.MirrorUploadResultRequest; +import com.ktb.chatapp.dto.UploadIntentRequest; +import com.ktb.chatapp.dto.UploadIntentResponse; +import com.ktb.chatapp.model.PendingUpload; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.PendingUploadRepository; +import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.storage.PresignedUpload; +import com.ktb.chatapp.storage.MirrorUploadPresigner; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UploadIntentService { + private static final Duration URL_TTL = Duration.ofMinutes(5); + private static final Duration PENDING_TTL = Duration.ofMinutes(15); + private static final int MAX_PENDING = 5; + + private final PendingUploadRepository pendingRepository; + private final UserRepository userRepository; + private final MirrorUploadPresigner presigner; + private final UploadPolicy policy; + private final MeterRegistry meterRegistry; + + public UploadIntentResponse create(String email, UploadIntentRequest request, PendingUpload.Purpose purpose) { + User user = user(email); + policy.validate(request.originalFilename(), request.contentType(), request.size(), + purpose == PendingUpload.Purpose.PROFILE); + if (pendingRepository.countByUserIdAndCreatedAtAfter(user.getId(), Instant.now().minusSeconds(60)) >= 20) { + throw new UploadIntentException(HttpStatus.TOO_MANY_REQUESTS, "업로드 요청은 분당 20회까지 가능합니다."); + } + if (pendingRepository.countByUserIdAndStatusAndExpiresAtAfter( + user.getId(), PendingUpload.Status.PENDING, Instant.now()) >= MAX_PENDING) { + throw new UploadIntentException(HttpStatus.TOO_MANY_REQUESTS, "동시에 진행할 수 있는 업로드는 최대 5개입니다."); + } + + String id = UUID.randomUUID().toString(); + String pendingKey = "pending/mirror/" + purpose.name().toLowerCase() + "/" + UUID.randomUUID(); + Instant now = Instant.now(); + PendingUpload pending = PendingUpload.builder().id(id).userId(user.getId()).purpose(purpose) + .pendingKey(pendingKey).originalFilename(request.originalFilename()) + .contentType(request.contentType()).expectedSize(request.size()).status(PendingUpload.Status.PENDING) + .createdAt(now).expiresAt(now.plus(PENDING_TTL)).build(); + pendingRepository.save(pending); + try { + PresignedUpload signed = presigner.presign(pendingKey, request.contentType(), request.size(), URL_TTL); + meterRegistry.counter("mirror_presign_success", "purpose", purpose.name().toLowerCase()).increment(); + return new UploadIntentResponse(id, pendingKey, signed.url().toString(), "PUT", + signed.headers(), signed.expiresAt()); + } catch (RuntimeException ex) { + pendingRepository.deleteById(id); + throw ex; + } + } + + public void recordResult(String email, MirrorUploadResultRequest request, PendingUpload.Purpose purpose) { + User user = user(email); + PendingUpload pending = owned(request.uploadId(), user.getId(), purpose); + pending.setStatus(request.success() ? PendingUpload.Status.COMPLETED : PendingUpload.Status.FAILED); + pendingRepository.save(pending); + meterRegistry.counter(request.success() ? "mirror_put_success" : "mirror_put_failure", + "purpose", purpose.name().toLowerCase(), "status", Integer.toString(request.status())).increment(); + Timer.builder("mirror_put_latency").tag("purpose", purpose.name().toLowerCase()) + .register(meterRegistry).record(Duration.ofMillis(request.durationMs())); + if (!request.success()) { + log.warn("S3 미러 업로드 실패 purpose={} size={} mime={} status={} durationMs={}", + purpose, pending.getExpectedSize(), pending.getContentType(), request.status(), request.durationMs()); + } + } + + private PendingUpload owned(String id, String userId, PendingUpload.Purpose purpose) { + return pendingRepository.findByIdAndUserIdAndPurpose(id, userId, purpose) + .orElseThrow(() -> new UploadIntentException(HttpStatus.NOT_FOUND, "업로드 요청을 찾을 수 없습니다.")); + } + private User user(String email) { + return userRepository.findByEmail(email.toLowerCase()) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UploadPolicy.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadPolicy.java new file mode 100644 index 00000000..6e4d986f --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UploadPolicy.java @@ -0,0 +1,30 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.util.FileUtil; +import java.nio.charset.StandardCharsets; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.http.HttpStatus; + +@Component +public class UploadPolicy { + public static final long MAX_SIZE = 5L * 1024 * 1024; + + public void validate(String originalFilename, String contentType, long size, boolean profileImage) { + if (!StringUtils.hasText(originalFilename) || originalFilename.getBytes(StandardCharsets.UTF_8).length > 255) { + throw new IllegalArgumentException("파일명이 올바르지 않습니다."); + } + if (size <= 0) { + throw new IllegalArgumentException("파일이 비어있습니다."); + } + if (size > MAX_SIZE) { + throw new UploadIntentException(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 5MB를 초과할 수 없습니다."); + } + if (!FileUtil.isAllowedType(originalFilename, contentType)) { + throw new IllegalArgumentException("지원하지 않는 파일 형식입니다."); + } + if (profileImage && !contentType.startsWith("image/")) { + throw new IllegalArgumentException("이미지 파일만 업로드할 수 있습니다."); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UserDetailsServiceImpl.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UserDetailsServiceImpl.java index e6dbc587..ca9236da 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/UserDetailsServiceImpl.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UserDetailsServiceImpl.java @@ -2,14 +2,13 @@ import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.security.AuthenticatedUserPrincipal; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -import java.util.ArrayList; - @RequiredArgsConstructor @Service public class UserDetailsServiceImpl implements UserDetailsService { @@ -21,11 +20,6 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep User user = userRepository.findByEmail(email.toLowerCase()) .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); - // username은 email로 설정 - return new org.springframework.security.core.userdetails.User( - user.getEmail(), - user.getPassword(), - new ArrayList<>() - ); + return new AuthenticatedUserPrincipal(user); } } 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..79379369 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 @@ -26,6 +26,7 @@ public class UserService { private final UserRepository userRepository; private final FileService fileService; private final StoragePort storagePort; + private final UploadPolicy uploadPolicy; @Value("${app.profile.image.max-size:5242880}") // 5MB private long maxProfileImageSize; @@ -110,6 +111,8 @@ private void validateProfileImageFile(MultipartFile file) { throw new IllegalArgumentException("이미지가 제공되지 않았습니다."); } + uploadPolicy.validate(file.getOriginalFilename(), file.getContentType(), file.getSize(), true); + // 파일 크기 검증 if (file.getSize() > maxProfileImageSize) { throw new IllegalArgumentException("파일 크기는 5MB를 초과할 수 없습니다."); 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 deleted file mode 100644 index e2725daa..00000000 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.ktb.chatapp.service.session; - -import com.ktb.chatapp.model.Session; -import com.ktb.chatapp.repository.SessionRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * MongoDB implementation of SessionStore. - * Uses SessionRepository for persistence. - */ -@Component -@RequiredArgsConstructor -public class SessionMongoStore implements SessionStore { - - private final SessionRepository sessionRepository; - - @Override - public Optional findByUserId(String userId) { - return sessionRepository.findByUserId(userId); - } - - @Override - public Session save(Session session) { - return sessionRepository.save(session); - } - - @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); - } - } - - @Override - public void deleteAll(String userId) { - sessionRepository.deleteByUserId(userId); - } -} 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..9dcfcbc3 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java @@ -0,0 +1,148 @@ +package com.ktb.chatapp.service.session; + +import com.ktb.chatapp.model.Session; +import com.ktb.chatapp.service.SessionMetadata; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import static com.ktb.chatapp.service.SessionService.SESSION_TTL_SEC; + +@Component +@RequiredArgsConstructor +public class SessionRedisStore implements SessionStore { + + private static final String KEY_PREFIX = "auth:session:"; + private static final String SESSION_ID = "sessionId"; + private static final String USER_ID = "userId"; + private static final String CREATED_AT = "createdAt"; + private static final String LAST_ACTIVITY = "lastActivity"; + private static final String USER_AGENT = "userAgent"; + private static final String IP_ADDRESS = "ipAddress"; + private static final String DEVICE_INFO = "deviceInfo"; + private static final DefaultRedisScript DELETE_IF_SESSION_MATCHES = + new DefaultRedisScript<>( + "if redis.call('HGET', KEYS[1], 'sessionId') == ARGV[1] " + + "then return redis.call('DEL', KEYS[1]) else return 0 end", + Long.class + ); + private static final DefaultRedisScript TOUCH_IF_SESSION_MATCHES = + new DefaultRedisScript<>( + "if redis.call('HGET', KEYS[1], 'sessionId') == ARGV[1] then " + + "redis.call('HSET', KEYS[1], 'lastActivity', ARGV[2]); " + + "redis.call('EXPIRE', KEYS[1], ARGV[3]); return 1 else return 0 end", + Long.class + ); + + private final StringRedisTemplate redisTemplate; + + @Override + public Optional findByUserId(String userId) { + String key = buildKey(userId); + Map values = redisTemplate.opsForHash().entries(key); + if (values.isEmpty()) { + return Optional.empty(); + } + + Long ttlSeconds = redisTemplate.getExpire(key); + Instant expiresAt = ttlSeconds != null && ttlSeconds > 0 + ? Instant.now().plusSeconds(ttlSeconds) + : Instant.now(); + + return Optional.of(Session.builder() + .userId(value(values, USER_ID)) + .sessionId(value(values, SESSION_ID)) + .createdAt(longValue(values, CREATED_AT)) + .lastActivity(longValue(values, LAST_ACTIVITY)) + .metadata(readMetadata(values)) + .expiresAt(expiresAt) + .build()); + } + + @Override + public Session save(Session session) { + String key = buildKey(session.getUserId()); + Map values = new HashMap<>(); + values.put(USER_ID, session.getUserId()); + values.put(SESSION_ID, session.getSessionId()); + values.put(CREATED_AT, Long.toString(session.getCreatedAt())); + values.put(LAST_ACTIVITY, Long.toString(session.getLastActivity())); + writeMetadata(values, session.getMetadata()); + + redisTemplate.opsForHash().putAll(key, values); + redisTemplate.expire(key, Duration.ofSeconds(SESSION_TTL_SEC)); + return session; + } + + @Override + public boolean touch(String userId, String sessionId, long lastActivity) { + Long result = redisTemplate.execute( + TOUCH_IF_SESSION_MATCHES, + List.of(buildKey(userId)), + sessionId, + Long.toString(lastActivity), + Long.toString(SESSION_TTL_SEC) + ); + return result != null && result == 1L; + } + + @Override + public void delete(String userId, String sessionId) { + redisTemplate.execute( + DELETE_IF_SESSION_MATCHES, + List.of(buildKey(userId)), + sessionId + ); + } + + @Override + public void deleteAll(String userId) { + redisTemplate.delete(buildKey(userId)); + } + + private String buildKey(String userId) { + return KEY_PREFIX + userId; + } + + private String value(Map values, String key) { + Object value = values.get(key); + return value != null ? value.toString() : null; + } + + private long longValue(Map values, String key) { + String value = value(values, key); + return value != null ? Long.parseLong(value) : 0L; + } + + private SessionMetadata readMetadata(Map values) { + String userAgent = nullableValue(values, USER_AGENT); + String ipAddress = nullableValue(values, IP_ADDRESS); + String deviceInfo = nullableValue(values, DEVICE_INFO); + if (userAgent == null && ipAddress == null && deviceInfo == null) { + return null; + } + return new SessionMetadata(userAgent, ipAddress, deviceInfo); + } + + private void writeMetadata(Map values, SessionMetadata metadata) { + values.put(USER_AGENT, metadata != null ? emptyIfNull(metadata.userAgent()) : ""); + values.put(IP_ADDRESS, metadata != null ? emptyIfNull(metadata.ipAddress()) : ""); + values.put(DEVICE_INFO, metadata != null ? emptyIfNull(metadata.deviceInfo()) : ""); + } + + private String nullableValue(Map values, String key) { + String value = value(values, key); + return value == null || value.isEmpty() ? null : value; + } + + private String emptyIfNull(String value) { + return value != null ? value : ""; + } +} 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..138f5742 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 @@ -24,6 +24,13 @@ public interface SessionStore { * @return the saved session */ Session save(Session session); + + /** + * Refresh session activity only if the stored session ID still matches. + * + * @return true when the current session was refreshed + */ + boolean touch(String userId, String sessionId, long lastActivity); /** * Delete all sessions for a user diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/LocalStorage.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/LocalStorage.java index 1454c5c6..03f7d07d 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/LocalStorage.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/LocalStorage.java @@ -16,8 +16,9 @@ import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Component; +/** 로컬 파일 스토리지 구현. file.storage.type=local인 환경에서만 등록된다. */ @Component -@ConditionalOnProperty(name = "file.storage.type", havingValue = "local", matchIfMissing = true) +@ConditionalOnProperty(name = "file.storage.type", havingValue = "local") public class LocalStorage implements StoragePort { private final Path rootLocation; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/MirrorUploadPresigner.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/MirrorUploadPresigner.java new file mode 100644 index 00000000..d55cfcfa --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/MirrorUploadPresigner.java @@ -0,0 +1,7 @@ +package com.ktb.chatapp.storage; + +import java.time.Duration; + +public interface MirrorUploadPresigner { + PresignedUpload presign(String key, String contentType, long size, Duration ttl); +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/PresignedUpload.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/PresignedUpload.java new file mode 100644 index 00000000..56043161 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/PresignedUpload.java @@ -0,0 +1,7 @@ +package com.ktb.chatapp.storage; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; + +public record PresignedUpload(URI url, Map headers, Instant expiresAt) {} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/S3MirrorUploadPresigner.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3MirrorUploadPresigner.java new file mode 100644 index 00000000..8f34a046 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3MirrorUploadPresigner.java @@ -0,0 +1,31 @@ +package com.ktb.chatapp.storage; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@RequiredArgsConstructor +public class S3MirrorUploadPresigner implements MirrorUploadPresigner { + private final S3Presigner presigner; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Override + public PresignedUpload presign(String key, String contentType, long size, Duration ttl) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket).key(key).contentType(contentType).contentLength(size).build(); + var signed = presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(ttl).putObjectRequest(request).build()); + return new PresignedUpload(URI.create(signed.url().toString()), + Map.of("Content-Type", contentType), Instant.now().plus(ttl)); + } +} 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..f2ec887e --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java @@ -0,0 +1,77 @@ +package com.ktb.chatapp.storage; + +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.stereotype.Component; +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.NoSuchKeyException; +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; + +@Component +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3", matchIfMissing = true) +@RequiredArgsConstructor +public class S3Storage implements StoragePort { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Override + public StoredObject put(InputStream content, String key, String contentType, long size) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .contentLength(size) + .build(); + s3Client.putObject(request, RequestBody.fromInputStream(content, size)); + return new StoredObject(key, size); + } + + @Override + public Optional open(String key) { + try { + byte[] content = s3Client.getObjectAsBytes(GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()).asByteArray(); + return Optional.of(new ByteArrayResource(content)); + } catch (NoSuchKeyException ex) { + return Optional.empty(); + } + } + + @Override + public void delete(String key) { + s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build()); + } + + @Override + public Optional offloadUrl(String key, Duration ttl, ContentDisposition disposition) { + GetObjectRequest objectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentDisposition(disposition.toString()) + .build(); + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(objectRequest) + .build(); + return Optional.of(URI.create(s3Presigner.presignGetObject(presignRequest).url().toString())); + } +} 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..56306055 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,12 @@ public static void validateFile(MultipartFile file) { } } + public static boolean isAllowedType(String filename, String contentType) { + if (filename == null || contentType == null) return false; + List extensions = ALLOWED_TYPES.get(contentType); + return extensions != null && extensions.contains(getFileExtension(filename).toLowerCase()); + } + /** * 파일 타입 한글명 반환 */ 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..36248851 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 @@ -19,7 +19,7 @@ /** * Socket.IO Authorization Handler - * socket.handshake.auth.token과 sessionId를 처리한다. + * socket.handshake.auth.token을 처리한다. */ @Slf4j @Component @@ -37,17 +37,17 @@ public AuthTokenResult getAuthTokenResult(Object _authToken, SocketIOClient clie try { var authToken = (Map) _authToken; String token = authToken.get("token") != null ? authToken.get("token").toString() : null; - String sessionId = authToken.get("sessionId") != null ? authToken.get("sessionId").toString() : null; - if (token == null || sessionId == null) { - log.warn("Missing authentication credentials in Socket.IO handshake - token: {}, sessionId: {}", - token != null, sessionId != null); + if (token == null) { + log.warn("Missing token in Socket.IO handshake"); return new AuthTokenResult(false, "Authentication error"); } String userId; + String sessionId; try { userId = jwtService.extractUserId(token); + sessionId = jwtService.extractSessionId(token); } catch (JwtException e) { return new AuthTokenResult(false, Map.of("message", "Invalid token")); } 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..9a3c5556 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 @@ -24,11 +24,12 @@ public class SocketIOEventListener { @EventListener public void handleSessionEndedEvent(SessionEndedEvent event) { try { - socketIOServer.getRoomOperations("user:" + event.getUserId()) - .sendEvent(SESSION_ENDED, Map.of( - "reason", event.getReason(), - "message", event.getMessage() - )); + var userOperations = socketIOServer.getRoomOperations("user:" + event.getUserId()); + userOperations.sendEvent(SESSION_ENDED, Map.of( + "reason", event.getReason(), + "message", event.getMessage() + )); + userOperations.getClients().forEach(client -> client.disconnect()); log.info("session_ended 이벤트 발송: userId={}, reason={}", event.getUserId(), event.getReason()); } catch (Exception e) { log.error("session_ended 이벤트 발송 실패: userId={}", event.getUserId(), e); 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..4894162c 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 @@ -16,8 +16,6 @@ import com.ktb.chatapp.util.BannedWordChecker; import com.ktb.chatapp.websocket.socketio.ai.AiService; import com.ktb.chatapp.service.RoomActivityNotifier; -import com.ktb.chatapp.service.SessionService; -import com.ktb.chatapp.service.SessionValidationResult; import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.websocket.socketio.SocketUser; @@ -45,7 +43,6 @@ public class ChatMessageHandler { 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; @@ -77,18 +74,6 @@ 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; - } - // Rate limit check RateLimitCheckResult rateLimitResult = rateLimitService.checkRateLimit(socketUser.id(), 10000, Duration.ofMinutes(1)); @@ -173,8 +158,6 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { // AI 멘션 처리 aiService.handleAIMentions(roomId, socketUser.id(), messageContent); - sessionService.updateLastActivity(socketUser.id()); - // Record success metrics recordMessageSuccess(messageType); timerSample.stop(createTimer("success", messageType)); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcher.java new file mode 100644 index 00000000..007aa13e --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcher.java @@ -0,0 +1,144 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_SUCCESS; + +import com.corundumstudio.socketio.SocketIOClient; +import com.ktb.chatapp.dto.FetchMessagesRequest; +import com.ktb.chatapp.dto.FetchMessagesResponse; +import com.ktb.chatapp.dto.JoinRoomSuccessResponse; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class JoinAckDispatcher { + + private final MessageLoader messageLoader; + private final ReadStatusDispatcher readStatusDispatcher; + private final RoomPostJoinDispatcher postJoinDispatcher; + private final ScheduledExecutorService scheduler; + private final ThreadPoolExecutor worker; + private final Map pending = new ConcurrentHashMap<>(); + private final long debounceMs; + private final Counter requestCounter; + private final Counter queryCounter; + + public JoinAckDispatcher( + MessageLoader messageLoader, + ReadStatusDispatcher readStatusDispatcher, + RoomPostJoinDispatcher postJoinDispatcher, + MeterRegistry meterRegistry, + @Value("${socketio.join-ack.workers:8}") int workers, + @Value("${socketio.join-ack.queue-capacity:1000}") int queueCapacity, + @Value("${socketio.join-ack.debounce-ms:100}") long debounceMs) { + this.messageLoader = messageLoader; + this.readStatusDispatcher = readStatusDispatcher; + this.postJoinDispatcher = postJoinDispatcher; + this.debounceMs = debounceMs; + this.scheduler = Executors.newSingleThreadScheduledExecutor(namedThreadFactory("join-ack-debounce-")); + this.worker = new ThreadPoolExecutor( + Math.max(1, workers), + Math.max(1, workers), + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(1, queueCapacity)), + namedThreadFactory("join-ack-worker-"), + new ThreadPoolExecutor.CallerRunsPolicy()); + this.requestCounter = meterRegistry.counter("socketio.join_ack.requests"); + this.queryCounter = meterRegistry.counter("socketio.join_ack.history_queries"); + Gauge.builder("socketio.join_ack.pending", pending, Map::size).register(meterRegistry); + Gauge.builder("socketio.join_ack.queue.size", worker.getQueue(), java.util.Collection::size) + .register(meterRegistry); + } + + public void request(SocketIOClient client, String roomId, String userId, String userName) { + requestCounter.increment(); + pending.compute(roomId, (key, existing) -> { + JoinBatch batch = existing != null ? existing : new JoinBatch(); + batch.add(new WaitingJoin(client, userId, userName)); + if (existing == null) { + scheduler.schedule(() -> flush(roomId, batch), debounceMs, TimeUnit.MILLISECONDS); + } + return batch; + }); + } + + private void flush(String roomId, JoinBatch batch) { + if (!pending.remove(roomId, batch)) { + return; + } + worker.execute(() -> { + queryCounter.increment(); + FetchMessagesResponse history = messageLoader.loadMessages( + new FetchMessagesRequest(roomId, 30, null), null); + JoinRoomSuccessResponse response = JoinRoomSuccessResponse.builder() + .roomId(roomId) + .messages(history.getMessages()) + .hasMore(history.isHasMore()) + .activeStreams(Collections.emptyList()) + .build(); + List messageIds = history.getMessages().stream() + .map(com.ktb.chatapp.dto.MessageResponse::getId) + .toList(); + List clients = batch.clients(); + + // 모든 참가자에게 화면 준비 ACK를 먼저 큐잉한 뒤 입장 fan-out을 시작한다. + for (WaitingJoin waiting : clients) { + waiting.client().sendEvent(JOIN_ROOM_SUCCESS, response); + } + for (WaitingJoin waiting : clients) { + readStatusDispatcher.schedule(messageIds, waiting.userId()); + postJoinDispatcher.scheduleSystemMessage( + roomId, waiting.userName() + "님이 입장하였습니다."); + } + }); + } + + private static ThreadFactory namedThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + @PreDestroy + void shutdown() { + scheduler.shutdown(); + worker.shutdown(); + } + + private record WaitingJoin(SocketIOClient client, String userId, String userName) {} + + private static final class JoinBatch { + private final List clients = new ArrayList<>(); + + private synchronized void add(WaitingJoin client) { + clients.add(client); + } + + private synchronized List clients() { + return List.copyOf(clients); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcher.java new file mode 100644 index 00000000..df82c5c4 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcher.java @@ -0,0 +1,147 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.ERROR; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.PREVIOUS_MESSAGES_LOADED; + +import com.corundumstudio.socketio.SocketIOClient; +import com.ktb.chatapp.dto.FetchMessagesRequest; +import com.ktb.chatapp.dto.FetchMessagesResponse; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class MessageFetchDispatcher { + + private final MessageLoader messageLoader; + private final ReadStatusDispatcher readStatusDispatcher; + private final ScheduledExecutorService scheduler; + private final ThreadPoolExecutor worker; + private final Map pending = new ConcurrentHashMap<>(); + private final long debounceMs; + private final Counter requestCounter; + private final Counter queryCounter; + + public MessageFetchDispatcher( + MessageLoader messageLoader, + ReadStatusDispatcher readStatusDispatcher, + MeterRegistry meterRegistry, + @Value("${socketio.message-fetch.workers:8}") int workers, + @Value("${socketio.message-fetch.queue-capacity:1000}") int queueCapacity, + @Value("${socketio.message-fetch.debounce-ms:100}") long debounceMs) { + this.messageLoader = messageLoader; + this.readStatusDispatcher = readStatusDispatcher; + this.debounceMs = debounceMs; + this.scheduler = Executors.newSingleThreadScheduledExecutor(namedThreadFactory("message-fetch-debounce-")); + this.worker = new ThreadPoolExecutor( + Math.max(1, workers), + Math.max(1, workers), + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(1, queueCapacity)), + namedThreadFactory("message-fetch-worker-"), + new ThreadPoolExecutor.CallerRunsPolicy()); + this.requestCounter = meterRegistry.counter("socketio.message_fetch.requests"); + this.queryCounter = meterRegistry.counter("socketio.message_fetch.queries"); + Gauge.builder("socketio.message_fetch.pending", pending, Map::size).register(meterRegistry); + Gauge.builder("socketio.message_fetch.queue.size", worker.getQueue(), java.util.Collection::size) + .register(meterRegistry); + } + + public void request(SocketIOClient client, FetchMessagesRequest request, String userId) { + requestCounter.increment(); + FetchKey key = new FetchKey(request.roomId(), request.limit(), request.before()); + pending.compute(key, (ignored, existing) -> { + FetchBatch batch = existing != null ? existing : new FetchBatch(request); + batch.add(new WaitingClient(client, userId)); + if (existing == null) { + scheduler.schedule(() -> flush(key, batch), debounceMs, TimeUnit.MILLISECONDS); + } + return batch; + }); + } + + private void flush(FetchKey key, FetchBatch batch) { + if (!pending.remove(key, batch)) { + return; + } + worker.execute(() -> { + queryCounter.increment(); + try { + FetchMessagesResponse result = messageLoader.loadMessages(batch.request(), null); + List messageIds = result.getMessages().stream() + .map(com.ktb.chatapp.dto.MessageResponse::getId) + .toList(); + for (WaitingClient waiting : batch.clients()) { + waiting.client().sendEvent(PREVIOUS_MESSAGES_LOADED, result); + readStatusDispatcher.schedule(messageIds, waiting.userId()); + } + } catch (Exception e) { + log.error("Failed to load messages for room {}", key.roomId(), e); + for (WaitingClient waiting : batch.clients()) { + waiting.client().sendEvent(ERROR, Map.of( + "code", "LOAD_ERROR", + "message", "이전 메시지를 불러오는 중 오류가 발생했습니다.")); + } + } + }); + } + + private static ThreadFactory namedThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + @PreDestroy + void shutdown() { + scheduler.shutdown(); + worker.shutdown(); + } + + private record FetchKey(String roomId, Integer limit, Long before) {} + + private record WaitingClient(SocketIOClient client, String userId) {} + + private static final class FetchBatch { + private final FetchMessagesRequest request; + private final List clients = new ArrayList<>(); + + private FetchBatch(FetchMessagesRequest request) { + this.request = request; + } + + private synchronized void add(WaitingClient client) { + clients.add(client); + } + + private FetchMessagesRequest request() { + return request; + } + + private synchronized List clients() { + return List.copyOf(clients); + } + } +} 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..92ff0cb9 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 @@ -3,10 +3,8 @@ import com.corundumstudio.socketio.SocketIOClient; 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 com.ktb.chatapp.websocket.socketio.UserRooms; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,8 +24,8 @@ @RequiredArgsConstructor public class MessageFetchHandler { - private final RoomRepository roomRepository; - private final MessageLoader messageLoader; + private final UserRooms userRooms; + private final MessageFetchDispatcher messageFetchDispatcher; @OnEvent(FETCH_PREVIOUS_MESSAGES) public void handleFetchMessages(SocketIOClient client, FetchMessagesRequest data) { @@ -42,8 +40,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 (!userRooms.isInRoom(userId, data.roomId())) { client.sendEvent(ERROR, Map.of( "code", "LOAD_ERROR", "message", "채팅방 접근 권한이 없습니다." @@ -54,14 +51,7 @@ public void handleFetchMessages(SocketIOClient client, FetchMessagesRequest data log.debug("Starting message load for user {} in room {}, limit: {}, before: {}", userId, data.roomId(), data.limit(), data.before()); - log.debug("Loading messages for room {}", data.roomId()); - FetchMessagesResponse result = messageLoader.loadMessages(data, userId); - - log.debug("Previous messages loaded - room: {}, count: {}, hasMore: {}", - data.roomId(), result.getMessages().size(), - result.isHasMore()); - - client.sendEvent(PREVIOUS_MESSAGES_LOADED, result); + messageFetchDispatcher.request(client, data, userId); } catch (Exception e) { log.error("Error handling fetchPreviousMessages", e); 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..78ca88e5 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 @@ -7,16 +7,18 @@ import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.UserRepository; -import com.ktb.chatapp.service.MessageReadStatusService; -import jakarta.annotation.Nullable; import java.time.LocalDateTime; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.List; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @@ -30,7 +32,6 @@ public class MessageLoader { private final MessageRepository messageRepository; private final UserRepository userRepository; private final MessageResponseMapper messageResponseMapper; - private final MessageReadStatusService messageReadStatusService; private static final int BATCH_SIZE = 30; @@ -56,7 +57,7 @@ private FetchMessagesResponse loadMessagesInternal( String userId) { Pageable pageable = PageRequest.of(0, limit, Sort.by("timestamp").descending()); - Page messagePage = messageRepository + Slice messagePage = messageRepository .findByRoomIdAndTimestampBefore(roomId, before, pageable); List messages = messagePage.getContent(); @@ -64,15 +65,20 @@ private FetchMessagesResponse loadMessagesInternal( // DESC로 조회했으므로 ASC로 재정렬 (채팅 UI 표시 순서) List sortedMessages = messages.reversed(); - var messageIds = sortedMessages.stream().map(Message::getId).toList(); - messageReadStatusService.updateReadStatus(messageIds, userId); - + Set senderIds = sortedMessages.stream() + .map(Message::getSenderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map sendersById = senderIds.isEmpty() + ? Map.of() + : userRepository.findAllById(senderIds).stream() + .filter(sender -> sender != null && sender.getId() != null) + .collect(Collectors.toMap(User::getId, Function.identity())); + // 메시지 응답 생성 List messageResponses = sortedMessages.stream() - .map(message -> { - var user = findUserById(message.getSenderId()); - return messageResponseMapper.mapToMessageResponse(message, user); - }) + .map(message -> messageResponseMapper.mapToMessageResponse( + message, sendersById.get(message.getSenderId()))) .collect(Collectors.toList()); boolean hasMore = messagePage.hasNext(); @@ -86,15 +92,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/ReadStatusDispatcher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ReadStatusDispatcher.java new file mode 100644 index 00000000..657c0dc9 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ReadStatusDispatcher.java @@ -0,0 +1,82 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import com.ktb.chatapp.service.MessageReadStatusService; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PreDestroy; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class ReadStatusDispatcher { + + private final MessageReadStatusService readStatusService; + private final ScheduledExecutorService scheduler; + private final ThreadPoolExecutor worker; + private final long delayMs; + + public ReadStatusDispatcher( + MessageReadStatusService readStatusService, + MeterRegistry meterRegistry, + @Value("${socketio.read-status.workers:8}") int workers, + @Value("${socketio.read-status.queue-capacity:1000}") int queueCapacity, + @Value("${socketio.read-status.delay-ms:2000}") long delayMs) { + this.readStatusService = readStatusService; + this.delayMs = delayMs; + this.scheduler = Executors.newSingleThreadScheduledExecutor(namedThreadFactory("read-status-delay-")); + this.worker = new ThreadPoolExecutor( + Math.max(1, workers), + Math.max(1, workers), + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(1, queueCapacity)), + namedThreadFactory("read-status-worker-"), + new ThreadPoolExecutor.CallerRunsPolicy()); + Gauge.builder("socketio.read_status.queue.size", worker.getQueue(), java.util.Collection::size) + .register(meterRegistry); + } + + public void schedule(List messageIds, String userId) { + if (messageIds == null || messageIds.isEmpty() || userId == null) { + return; + } + List snapshot = List.copyOf(messageIds); + scheduler.schedule( + () -> worker.execute(() -> { + try { + readStatusService.updateReadStatus(snapshot, userId); + } catch (Exception e) { + log.error("Deferred read-status update failed for user {}", userId, e); + } + }), + delayMs, + TimeUnit.MILLISECONDS); + } + + private static ThreadFactory namedThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + @PreDestroy + void shutdown() { + scheduler.shutdown(); + worker.shutdown(); + } +} 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..7f49c123 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,25 +1,17 @@ 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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; import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.*; @@ -34,14 +26,10 @@ @RequiredArgsConstructor public class RoomJoinHandler { - private final SocketIOServer socketIOServer; - private final MessageRepository messageRepository; - private final RoomRepository roomRepository; - private final UserRepository userRepository; + private final MongoTemplate mongoTemplate; private final UserRooms userRooms; - private final MessageLoader messageLoader; - private final MessageResponseMapper messageResponseMapper; - private final RoomLeaveHandler roomLeaveHandler; + private final RoomPostJoinDispatcher postJoinDispatcher; + private final JoinAckDispatcher joinAckDispatcher; @OnEvent(JOIN_ROOM) public void handleJoinRoom(SocketIOClient client, String roomId) { @@ -54,16 +42,6 @@ public void handleJoinRoom(SocketIOClient client, String roomId) { return; } - if (userRepository.findById(userId).isEmpty()) { - client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "User not found")); - return; - } - - if (roomRepository.findById(roomId).isEmpty()) { - client.sendEvent(JOIN_ROOM_ERROR, Map.of("message", "채팅방을 찾을 수 없습니다.")); - return; - } - // 이미 해당 방에 참여 중인지 확인 if (userRooms.isInRoom(userId, roomId)) { log.debug("User {} already in room {}", userId, roomId); @@ -72,65 +50,19 @@ public void handleJoinRoom(SocketIOClient client, String roomId) { return; } - roomRepository.addParticipant(roomId, userId); - - // Join socket room and add to user's room set - client.joinRoom(roomId); - userRooms.add(userId, 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()) { + if (!addParticipant(roomId, userId)) { 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)); + // Join socket room and add to user's room set + client.joinRoom(roomId); + userRooms.add(userId, roomId); - // 참가자 목록 업데이트 브로드캐스트 - socketIOServer.getRoomOperations(roomId) - .sendEvent(PARTICIPANTS_UPDATE, participants); + joinAckDispatcher.request(client, roomId, userId, userName); + postJoinDispatcher.scheduleParticipantsUpdate(roomId); - log.info("User {} joined room {} successfully. Message count: {}, hasMore: {}", - userName, roomId, messageLoadResult.getMessages().size(), messageLoadResult.isHasMore()); + log.info("User {} joined room {} successfully", userName, roomId); } catch (Exception e) { log.error("Error handling joinRoom", e); @@ -139,6 +71,12 @@ public void handleJoinRoom(SocketIOClient client, String roomId) { )); } } + + private boolean addParticipant(String roomId, String userId) { + Query query = Query.query(Criteria.where("_id").is(roomId)); + return mongoTemplate.updateFirst( + query, new Update().addToSet("participantIds", userId), "rooms").getMatchedCount() > 0; + } private SocketUser getUser(SocketIOClient client) { return client.get("user"); 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..02863a2a 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 @@ -1,24 +1,14 @@ 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.MessageResponse; -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.model.User; -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.ArrayList; -import java.util.HashMap; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -36,12 +26,10 @@ @RequiredArgsConstructor public class RoomLeaveHandler { - private final SocketIOServer socketIOServer; - private final MessageRepository messageRepository; private final RoomRepository roomRepository; private final UserRepository userRepository; private final UserRooms userRooms; - private final MessageResponseMapper messageResponseMapper; + private final RoomPostJoinDispatcher postJoinDispatcher; @OnEvent(LEAVE_ROOM) public void handleLeaveRoom(SocketIOClient client, String roomId) { @@ -76,8 +64,8 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { log.debug("Leave room cleanup - roomId: {}, userId: {}", roomId, userId); - sendSystemMessage(roomId, userName + "님이 퇴장하였습니다."); - broadcastParticipantList(roomId); + postJoinDispatcher.scheduleSystemMessage(roomId, userName + "님이 퇴장하였습니다."); + postJoinDispatcher.scheduleParticipantsUpdate(roomId); } catch (Exception e) { log.error("Error handling leaveRoom", e); @@ -85,52 +73,6 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { } } - private void sendSystemMessage(String roomId, String content) { - try { - Message systemMessage = new Message(); - systemMessage.setRoomId(roomId); - systemMessage.setContent(content); - systemMessage.setType(MessageType.system); - systemMessage.setTimestamp(LocalDateTime.now()); - systemMessage.setMentions(new ArrayList<>()); - systemMessage.setReactions(new HashMap<>()); - systemMessage.setReaders(new ArrayList<>()); - systemMessage.setMetadata(new HashMap<>()); - - Message savedMessage = messageRepository.save(systemMessage); - MessageResponse response = messageResponseMapper.mapToMessageResponse(savedMessage, null); - - socketIOServer.getRoomOperations(roomId) - .sendEvent(MESSAGE, response); - - } catch (Exception e) { - log.error("Error sending system message", e); - } - } - - private void broadcastParticipantList(String roomId) { - Optional roomOpt = roomRepository.findById(roomId); - if (roomOpt.isEmpty()) { - return; - } - - var participantList = roomOpt.get() - .getParticipantIds() - .stream() - .map(userRepository::findById) - .filter(Optional::isPresent) - .map(Optional::get) - .map(UserResponse::from) - .toList(); - - if (participantList.isEmpty()) { - return; - } - - socketIOServer.getRoomOperations(roomId) - .sendEvent(PARTICIPANTS_UPDATE, participantList); - } - private SocketUser getUserDto(SocketIOClient client) { return client.get("user"); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcher.java new file mode 100644 index 00000000..bac613d8 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcher.java @@ -0,0 +1,237 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.MESSAGE; +import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.PARTICIPANTS_UPDATE; + +import com.corundumstudio.socketio.SocketIOServer; +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.model.User; +import com.ktb.chatapp.repository.MessageRepository; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import jakarta.annotation.PreDestroy; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class RoomPostJoinDispatcher { + + private final SocketIOServer socketIOServer; + private final MessageRepository messageRepository; + private final MongoTemplate mongoTemplate; + private final MessageResponseMapper messageResponseMapper; + private final ThreadPoolExecutor[] stripes; + private final ScheduledExecutorService scheduler; + private final Map> pendingParticipantUpdates = new ConcurrentHashMap<>(); + private final Map pendingSystemMessages = new ConcurrentHashMap<>(); + private final long participantDebounceMs; + private final long systemMessageDebounceMs; + private final Timer queueDelayTimer; + private final Timer participantSnapshotTimer; + + public RoomPostJoinDispatcher( + SocketIOServer socketIOServer, + MessageRepository messageRepository, + MongoTemplate mongoTemplate, + MessageResponseMapper messageResponseMapper, + MeterRegistry meterRegistry, + @Value("${socketio.post-join.stripes:16}") int stripeCount, + @Value("${socketio.post-join.queue-capacity:1000}") int queueCapacity, + @Value("${socketio.participants.debounce-ms:250}") long participantDebounceMs, + @Value("${socketio.system-messages.debounce-ms:1000}") long systemMessageDebounceMs) { + this.socketIOServer = socketIOServer; + this.messageRepository = messageRepository; + this.mongoTemplate = mongoTemplate; + this.messageResponseMapper = messageResponseMapper; + this.participantDebounceMs = participantDebounceMs; + this.systemMessageDebounceMs = systemMessageDebounceMs; + this.queueDelayTimer = meterRegistry.timer("socketio.room.post_join.queue.delay"); + this.participantSnapshotTimer = meterRegistry.timer("socketio.room.participants.snapshot.time"); + this.stripes = new ThreadPoolExecutor[Math.max(1, stripeCount)]; + for (int i = 0; i < stripes.length; i++) { + stripes[i] = new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(1, queueCapacity)), + namedThreadFactory("room-post-join-" + i + "-"), + new ThreadPoolExecutor.CallerRunsPolicy()); + } + this.scheduler = Executors.newSingleThreadScheduledExecutor( + namedThreadFactory("room-participants-debounce-")); + Gauge.builder("socketio.room.post_join.queue.size", this, RoomPostJoinDispatcher::queuedTasks) + .register(meterRegistry); + Gauge.builder("socketio.room.participants.pending", pendingParticipantUpdates, Map::size) + .register(meterRegistry); + } + + public void publishSystemMessage(String roomId, String content) { + execute(roomId, () -> { + try { + Message message = Message.builder() + .roomId(roomId) + .content(content) + .type(MessageType.system) + .timestamp(LocalDateTime.now()) + .mentions(new ArrayList<>()) + .reactions(new HashMap<>()) + .readers(new ArrayList<>()) + .metadata(new HashMap<>()) + .build(); + Message saved = messageRepository.save(message); + socketIOServer.getRoomOperations(roomId) + .sendEvent(MESSAGE, messageResponseMapper.mapToMessageResponse(saved, null)); + } catch (Exception e) { + log.error("Failed to publish system message for room {}", roomId, e); + } + }); + } + + public void scheduleSystemMessage(String roomId, String content) { + pendingSystemMessages.compute(roomId, (key, existing) -> { + SystemMessageBatch batch = existing != null ? existing : new SystemMessageBatch(); + batch.add(content); + batch.reschedule(scheduler.schedule( + () -> flushSystemMessages(roomId, batch), + systemMessageDebounceMs, + TimeUnit.MILLISECONDS)); + return batch; + }); + } + + private void flushSystemMessages(String roomId, SystemMessageBatch batch) { + if (!pendingSystemMessages.remove(roomId, batch)) { + return; + } + for (String content : batch.contents()) { + publishSystemMessage(roomId, content); + } + } + + public void scheduleParticipantsUpdate(String roomId) { + pendingParticipantUpdates.compute(roomId, (key, previous) -> { + if (previous != null) { + previous.cancel(false); + } + return scheduler.schedule( + () -> { + pendingParticipantUpdates.remove(roomId); + execute(roomId, () -> broadcastParticipantSnapshot(roomId)); + }, + participantDebounceMs, + TimeUnit.MILLISECONDS); + }); + } + + private void broadcastParticipantSnapshot(String roomId) { + participantSnapshotTimer.record(() -> { + try { + Query roomQuery = Query.query(Criteria.where("_id").is(roomId)); + roomQuery.fields().include("participantIds"); + Room room = mongoTemplate.findOne(roomQuery, Room.class); + if (room == null || room.getParticipantIds() == null) { + return; + } + + Query usersQuery = Query.query(Criteria.where("_id").in(room.getParticipantIds())); + usersQuery.fields().include("_id").include("name").include("email").include("profileImage"); + Map usersById = mongoTemplate.find(usersQuery, User.class).stream() + .filter(user -> user.getId() != null) + .collect(Collectors.toMap(User::getId, user -> user)); + List participants = room.getParticipantIds().stream() + .map(usersById::get) + .filter(Objects::nonNull) + .map(UserResponse::from) + .toList(); + socketIOServer.getRoomOperations(roomId).sendEvent(PARTICIPANTS_UPDATE, participants); + } catch (Exception e) { + log.error("Failed to broadcast participant snapshot for room {}", roomId, e); + } + }); + } + + private void execute(String roomId, Runnable task) { + long queuedAt = System.nanoTime(); + stripe(roomId).execute(() -> { + queueDelayTimer.record(System.nanoTime() - queuedAt, TimeUnit.NANOSECONDS); + task.run(); + }); + } + + private ThreadPoolExecutor stripe(String roomId) { + return stripes[Math.floorMod(roomId.hashCode(), stripes.length)]; + } + + private double queuedTasks() { + int queued = 0; + for (ThreadPoolExecutor stripe : stripes) { + queued += stripe.getQueue().size(); + } + return queued; + } + + private static ThreadFactory namedThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + @PreDestroy + void shutdown() { + scheduler.shutdown(); + for (ThreadPoolExecutor stripe : stripes) { + stripe.shutdown(); + } + } + + private static final class SystemMessageBatch { + private final List contents = new ArrayList<>(); + private ScheduledFuture future; + + private synchronized void add(String content) { + contents.add(content); + } + + private synchronized void reschedule(ScheduledFuture next) { + if (future != null) { + future.cancel(false); + } + future = next; + } + + private synchronized List contents() { + return List.copyOf(contents); + } + } +} diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index da6d0e5f..61d49719 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -4,7 +4,8 @@ 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 +server.tomcat.max-connections=150 +server.tomcat.mbeanregistry.enabled=true # Database Configuration # Spring Boot 4: connection settings moved from spring.data.mongodb.* to spring.mongodb.* @@ -17,9 +18,13 @@ spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.password=${REDIS_PASSWORD:} # File Upload Configuration -# Storage type: local or gridfs (default: local) +# Original files stay in local storage. Optional mirror PUTs use the AWS default credentials chain. file.storage.type=${FILE_STORAGE_TYPE:local} -file.upload-dir=./uploads +aws.s3.bucket=${S3_BUCKET:} +aws.s3.region=${AWS_REGION:ap-northeast-2} +aws.s3.endpoint=${S3_ENDPOINT:} +aws.s3.path-style-access=${S3_PATH_STYLE_ACCESS:false} +# Bucket CORS/lifecycle must be provisioned before rollout. See docs/S3_DIRECT_UPLOAD.md. # Multipart upload limits spring.servlet.multipart.max-file-size=5MB @@ -41,6 +46,19 @@ 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.post-join.stripes=${SOCKETIO_POST_JOIN_STRIPES:16} +socketio.post-join.queue-capacity=${SOCKETIO_POST_JOIN_QUEUE_CAPACITY:1000} +socketio.participants.debounce-ms=${SOCKETIO_PARTICIPANTS_DEBOUNCE_MS:250} +socketio.system-messages.debounce-ms=${SOCKETIO_SYSTEM_MESSAGES_DEBOUNCE_MS:1000} +socketio.read-status.workers=${SOCKETIO_READ_STATUS_WORKERS:8} +socketio.read-status.queue-capacity=${SOCKETIO_READ_STATUS_QUEUE_CAPACITY:1000} +socketio.read-status.delay-ms=${SOCKETIO_READ_STATUS_DELAY_MS:2000} +socketio.message-fetch.workers=${SOCKETIO_MESSAGE_FETCH_WORKERS:8} +socketio.message-fetch.queue-capacity=${SOCKETIO_MESSAGE_FETCH_QUEUE_CAPACITY:1000} +socketio.message-fetch.debounce-ms=${SOCKETIO_MESSAGE_FETCH_DEBOUNCE_MS:100} +socketio.join-ack.workers=${SOCKETIO_JOIN_ACK_WORKERS:8} +socketio.join-ack.queue-capacity=${SOCKETIO_JOIN_ACK_QUEUE_CAPACITY:1000} +socketio.join-ack.debounce-ms=${SOCKETIO_JOIN_ACK_DEBOUNCE_MS:100} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/MongoConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/MongoConfigTest.java new file mode 100644 index 00000000..6667537c --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/MongoConfigTest.java @@ -0,0 +1,24 @@ +package com.ktb.chatapp.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ktb.chatapp.model.MessageType; +import org.junit.jupiter.api.Test; + +class MongoConfigTest { + + private final MongoConfig.MessageTypeReadConverter converter = + new MongoConfig.MessageTypeReadConverter(); + + @Test + void readsLegacyUppercaseMessageType() { + assertEquals(MessageType.text, converter.convert("TEXT")); + assertEquals(MessageType.system, converter.convert("SYSTEM")); + } + + @Test + void keepsCurrentLowercaseMessageTypeCompatible() { + assertEquals(MessageType.ai, converter.convert("ai")); + assertEquals(MessageType.file, converter.convert("file")); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/RoomPaginationIndexMigrationIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/RoomPaginationIndexMigrationIntegrationTest.java new file mode 100644 index 00000000..8a3a84c4 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/RoomPaginationIndexMigrationIntegrationTest.java @@ -0,0 +1,108 @@ +package com.ktb.chatapp.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +@Testcontainers(disabledWithoutDocker = true) +class RoomPaginationIndexMigrationIntegrationTest { + + @Container + private static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8.3.4"); + + private static MongoClient mongoClient; + private static MongoTemplate mongoTemplate; + + @BeforeAll + static void setUpClient() { + mongoClient = MongoClients.create(MONGO.getConnectionString()); + mongoTemplate = new MongoTemplate(mongoClient, "room-pagination-migration-test"); + } + + @AfterAll + static void closeClient() { + mongoClient.close(); + } + + @BeforeEach + void clearRooms() { + mongoTemplate.dropCollection(RoomPaginationIndexMigration.COLLECTION); + } + + @Test + void backfillsCreatedAtAndCreatesIndexIdempotently() { + ObjectId id = new ObjectId(); + rooms().insertOne(new Document("_id", id).append("name", "room")); + RoomPaginationIndexMigration migration = new RoomPaginationIndexMigration(mongoTemplate); + + migration.run(null); + assertDoesNotThrow(() -> migration.run(null)); + + assertNotNull(rooms().find(new Document("_id", id)).first().getDate("createdAt")); + assertEquals(RoomPaginationIndexMigration.INDEX_NAME, paginationIndex().getString("name")); + } + + @Test + void rejectsInvalidIdsBeforeChangingAnyRoom() { + rooms().insertMany(List.of( + new Document("_id", new ObjectId()).append("name", "valid"), + new Document("_id", "invalid-id").append("name", "invalid") + )); + + assertThrows(IllegalStateException.class, + () -> new RoomPaginationIndexMigration(mongoTemplate).run(null)); + + assertEquals(0, rooms().countDocuments(new Document("createdAt", new Document("$type", "date")))); + } + + @Test + void paginationSortUsesTheCompoundIndex() { + List roomDocuments = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + roomDocuments.add(new Document("_id", new ObjectId()).append("createdAt", new Date())); + } + rooms().insertMany(roomDocuments); + new RoomPaginationIndexMigration(mongoTemplate).run(null); + + Document find = new Document("find", RoomPaginationIndexMigration.COLLECTION) + .append("filter", new Document()) + .append("sort", new Document("createdAt", -1).append("_id", -1)) + .append("limit", 31); + Document explain = mongoTemplate.getDb().runCommand( + new Document("explain", find).append("verbosity", "executionStats") + ); + + assertTrue(explain.toJson().contains("IXSCAN")); + assertTrue(explain.get("executionStats", Document.class).getInteger("totalDocsExamined") <= 31); + } + + private static MongoCollection rooms() { + return mongoTemplate.getCollection(RoomPaginationIndexMigration.COLLECTION); + } + + private static Document paginationIndex() { + return rooms().listIndexes().into(new ArrayList<>()).stream() + .filter(index -> RoomPaginationIndexMigration.INDEX_NAME.equals(index.getString("name"))) + .findFirst() + .orElseThrow(); + } +} 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..bf991a7d 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 @@ -32,7 +32,10 @@ */ @WebMvcTest(controllers = {ProfileImageController.class, UploadsResourceAccessTest.NoopController.class}) @Import({SecurityConfig.class, WebMvcConfig.class, LocalStorage.class}) -@TestPropertySource(properties = "file.upload-dir=target/test-classes/test-uploads") +@TestPropertySource(properties = { + "file.storage.type=local", + "file.upload-dir=target/test-classes/test-uploads" +}) class UploadsResourceAccessTest { @Autowired diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/UserEmailIndexMigrationIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/UserEmailIndexMigrationIntegrationTest.java new file mode 100644 index 00000000..e82d2978 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/UserEmailIndexMigrationIntegrationTest.java @@ -0,0 +1,126 @@ +package com.ktb.chatapp.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.IndexOptions; +import java.util.ArrayList; +import java.util.List; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +@Testcontainers(disabledWithoutDocker = true) +class UserEmailIndexMigrationIntegrationTest { + + @Container + private static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8.3.4"); + + private static MongoClient mongoClient; + private static MongoTemplate mongoTemplate; + + @BeforeAll + static void setUpClient() { + mongoClient = MongoClients.create(MONGO.getConnectionString()); + mongoTemplate = new MongoTemplate(mongoClient, "migration-test"); + } + + @AfterAll + static void closeClient() { + mongoClient.close(); + } + + @BeforeEach + void clearUsers() { + mongoTemplate.dropCollection(UserEmailIndexMigration.COLLECTION); + } + + @Test + void createsUniqueIndexForEmptyDatabaseAndIsIdempotent() { + UserEmailIndexMigration migration = new UserEmailIndexMigration(mongoTemplate); + + migration.run(null); + assertDoesNotThrow(() -> migration.run(null)); + + Document index = emailIndex(); + assertEquals(UserEmailIndexMigration.INDEX_NAME, index.getString("name")); + assertTrue(index.getBoolean("unique")); + } + + @Test + void keepsEquivalentUniqueIndexWithDifferentName() { + users().createIndex( + new Document("email", 1), + new IndexOptions().unique(true).name("existing_email_unique") + ); + + new UserEmailIndexMigration(mongoTemplate).run(null); + + assertEquals("existing_email_unique", emailIndex().getString("name")); + } + + @Test + void createsIndexForExistingDatabaseWithoutIndex() { + users().insertMany(List.of( + new Document("_id", "user-1").append("email", "one@test.com"), + new Document("_id", "user-2").append("email", "two@test.com") + )); + + new UserEmailIndexMigration(mongoTemplate).run(null); + + assertTrue(emailIndex().getBoolean("unique")); + } + + @Test + void rejectsDuplicateEmailWithoutDeletingData() { + users().insertMany(List.of( + new Document("_id", "user-1").append("email", "duplicate@test.com"), + new Document("_id", "user-2").append("email", "duplicate@test.com") + )); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new UserEmailIndexMigration(mongoTemplate).run(null) + ); + + assertTrue(exception.getMessage().contains("duplicate@test.com")); + assertTrue(exception.getMessage().contains("user-1")); + assertTrue(exception.getMessage().contains("user-2")); + assertEquals(2, users().countDocuments()); + } + + @Test + void rejectsConflictingNonUniqueEmailIndex() { + users().createIndex(new Document("email", 1), new IndexOptions().name("email_lookup")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new UserEmailIndexMigration(mongoTemplate).run(null) + ); + + assertTrue(exception.getMessage().contains("email_lookup")); + } + + private static MongoCollection users() { + return mongoTemplate.getCollection(UserEmailIndexMigration.COLLECTION); + } + + private static Document emailIndex() { + List indexes = users().listIndexes().into(new ArrayList<>()); + return indexes.stream() + .filter(index -> new Document("email", 1).equals(index.get("key", Document.class))) + .findFirst() + .orElseThrow(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/OpenApiContractTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/OpenApiContractTest.java index e8b83e46..80106f49 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/controller/OpenApiContractTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/OpenApiContractTest.java @@ -50,6 +50,6 @@ void apiDocs_shouldExposeApiContracts() throws Exception { .andExpect(jsonPath("$.servers[*].url", hasItem("http://localhost:5001"))) .andExpect(jsonPath("$.paths['/api/health']").exists()) .andExpect(jsonPath("$.components.securitySchemes['Bearer Authentication']").exists()) - .andExpect(jsonPath("$.components.securitySchemes['Session ID'].name").value("x-session-id")); + .andExpect(jsonPath("$.components.securitySchemes['Session ID']").doesNotExist()); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/ProfileImageControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/ProfileImageControllerTest.java new file mode 100644 index 00000000..b955bd1d --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/ProfileImageControllerTest.java @@ -0,0 +1,39 @@ +package com.ktb.chatapp.controller; + +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.when; + +import com.ktb.chatapp.storage.StoragePort; +import java.net.URI; +import java.util.Optional; +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; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@ExtendWith(MockitoExtension.class) +@DisplayName("프로필 이미지 조회 단위 테스트") +class ProfileImageControllerTest { + + @Mock + private StoragePort storagePort; + + @Test + @DisplayName("프로필 이미지는 S3 서명 URL로 리다이렉트한다") + void redirectsToS3Url() { + URI s3Url = URI.create("https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/avatar.png"); + when(storagePort.offloadUrl(eq("profiles/avatar.png"), any(), any(ContentDisposition.class))) + .thenReturn(Optional.of(s3Url)); + + ResponseEntity response = new ProfileImageController(storagePort).getProfileImage("avatar.png"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); + assertThat(response.getHeaders().getLocation()).isEqualTo(s3Url); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerPaginationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerPaginationTest.java new file mode 100644 index 00000000..a498f693 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerPaginationTest.java @@ -0,0 +1,69 @@ +package com.ktb.chatapp.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.dto.RoomsResponse; +import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.service.RecentMessageCounter; +import com.ktb.chatapp.service.RoomService; +import com.ktb.chatapp.exception.InvalidRoomCursorException; +import java.security.Principal; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +@ExtendWith(MockitoExtension.class) +class RoomControllerPaginationTest { + + @Mock private UserRepository userRepository; + @Mock private RecentMessageCounter recentMessageCounter; + @Mock private RoomService roomService; + + private RoomController controller; + + @BeforeEach + void setUp() { + controller = new RoomController(userRepository, recentMessageCounter, roomService); + } + + @Test + void forwardsValidatedPageRequest() { + RoomsResponse rooms = RoomsResponse.builder().success(true).data(List.of()).build(); + when(roomService.getAllRooms("viewer@test.com", 30, "cursor")).thenReturn(rooms); + + ResponseEntity response = controller.getAllRooms(principal(), "30", "cursor"); + + assertEquals(200, response.getStatusCode().value()); + verify(roomService).getAllRooms("viewer@test.com", 30, "cursor"); + } + + @Test + void rejectsOutOfRangePageSize() { + ResponseEntity response = controller.getAllRooms(principal(), "101", null); + + assertEquals(400, response.getStatusCode().value()); + assertEquals("INVALID_PAGE_SIZE", ((StandardResponse) response.getBody()).getCode()); + } + + @Test + void mapsMalformedCursorToBadRequest() { + when(roomService.getAllRooms("viewer@test.com", 30, "broken")) + .thenThrow(new InvalidRoomCursorException()); + + ResponseEntity response = controller.getAllRooms(principal(), "30", "broken"); + + assertEquals(400, response.getStatusCode().value()); + assertEquals("INVALID_CURSOR", ((StandardResponse) response.getBody()).getCode()); + } + + private static Principal principal() { + return () -> "viewer@test.com"; + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerRoomDetailTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerRoomDetailTest.java new file mode 100644 index 00000000..cee0bb94 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerRoomDetailTest.java @@ -0,0 +1,135 @@ +package com.ktb.chatapp.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.JoinRoomRequest; +import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.service.RecentMessageCounter; +import com.ktb.chatapp.service.RoomService; +import java.security.Principal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +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; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +@ExtendWith(MockitoExtension.class) +class RoomControllerRoomDetailTest { + + @Mock private UserRepository userRepository; + @Mock private RecentMessageCounter recentMessageCounter; + @Mock private RoomService roomService; + + private RoomController roomController; + + @BeforeEach + void setUp() { + roomController = new RoomController(userRepository, recentMessageCounter, roomService); + } + + @Test + void getRoomById_batchesCreatorAndParticipantsAndPreservesOrder() { + Room room = Room.builder() + .id("room-1") + .name("Room") + .creator("creator-1") + .createdAt(LocalDateTime.of(2026, 8, 11, 10, 0)) + .participantIds(new LinkedHashSet<>(List.of( + "participant-2", "missing-user", "participant-1"))) + .build(); + User creator = user("creator-1", "Creator"); + User participant1 = user("participant-1", "Participant 1"); + User participant2 = user("participant-2", "Participant 2"); + when(roomService.findRoomById("room-1")).thenReturn(Optional.of(room)); + when(userRepository.findSummariesByIdIn(anySet())) + .thenReturn(List.of(participant1, creator, participant2)); + when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(7); + + ResponseEntity response = roomController.getRoomById("room-1", principal("viewer@test.com")); + + assertEquals(200, response.getStatusCode().value()); + RoomResponse data = (RoomResponse) ((Map) response.getBody()).get("data"); + assertEquals("creator-1", data.getCreator().getId()); + assertEquals(List.of("participant-2", "participant-1"), data.getParticipants().stream() + .map(participant -> participant.getId()).toList()); + assertEquals(7, data.getRecentMessageCount()); + assertTrue((Boolean) ((Map) response.getBody()).get("success")); + verify(userRepository).findSummariesByIdIn(anySet()); + verify(userRepository, never()).findById(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void getRoomById_returnsServerErrorWhenCreatorIsMissing() { + Room room = Room.builder() + .id("room-1") + .creator("missing-creator") + .participantIds(new LinkedHashSet<>()) + .build(); + when(roomService.findRoomById("room-1")).thenReturn(Optional.of(room)); + when(userRepository.findSummariesByIdIn(anySet())).thenReturn(List.of()); + + ResponseEntity response = roomController.getRoomById("room-1", principal("viewer@test.com")); + + assertEquals(500, response.getStatusCode().value()); + verify(userRepository).findSummariesByIdIn(anySet()); + } + + @Test + void joinRoom_passesJwtUserIdAndReturnsServiceResponseWithoutRemapping() { + RoomResponse roomResponse = RoomResponse.builder() + .id("room-1") + .participants(List.of()) + .createdAtDateTime(LocalDateTime.now()) + .build(); + when(roomService.joinRoom("room-1", "secret", "user-1")).thenReturn(roomResponse); + + ResponseEntity response = roomController.joinRoom( + "room-1", + JoinRoomRequest.builder().password("secret").build(), + authentication("user-1", "user@test.com") + ); + + assertEquals(200, response.getStatusCode().value()); + assertSame(roomResponse, ((Map) response.getBody()).get("data")); + verify(userRepository, never()).findSummariesByIdIn(anySet()); + verify(recentMessageCounter, never()).countRecentMessages(org.mockito.ArgumentMatchers.anyString()); + } + + private static Principal principal(String name) { + return () -> name; + } + + private static JwtAuthenticationToken authentication(String userId, String email) { + Instant now = Instant.now(); + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject(email) + .claim("userId", userId) + .issuedAt(now) + .expiresAt(now.plusSeconds(60)) + .build(); + return new JwtAuthenticationToken(jwt, List.of(), email); + } + + private static User user(String id, String name) { + return User.builder().id(id).name(name).email(id + "@test.com").build(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryImplIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryImplIntegrationTest.java new file mode 100644 index 00000000..29954b47 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryImplIntegrationTest.java @@ -0,0 +1,140 @@ +package com.ktb.chatapp.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ktb.chatapp.model.Room; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +@Testcontainers(disabledWithoutDocker = true) +class RoomRepositoryImplIntegrationTest { + + @Container + private static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8.3.4"); + + private static MongoClient mongoClient; + private static MongoTemplate mongoTemplate; + private RoomRepositoryImpl repository; + + @BeforeAll + static void setUpClient() { + mongoClient = MongoClients.create(MONGO.getConnectionString()); + mongoTemplate = new MongoTemplate(mongoClient, "room-repository-test"); + } + + @AfterAll + static void closeClient() { + mongoClient.close(); + } + + @BeforeEach + void setUp() { + mongoTemplate.dropCollection(Room.class); + repository = new RoomRepositoryImpl(mongoTemplate); + mongoTemplate.save(Room.builder() + .id("room-1") + .creator("creator-1") + .participantIds(new HashSet<>(Set.of("creator-1"))) + .build()); + } + + @Test + void addParticipantAndReturnReturnsUpdatedRoom() { + Room updated = repository.addParticipantAndReturn("room-1", "user-1"); + + assertNotNull(updated); + assertEquals(Set.of("creator-1", "user-1"), updated.getParticipantIds()); + } + + @Test + void addParticipantAndReturnReturnsNullWhenParticipantAlreadyExists() { + assertNotNull(repository.addParticipantAndReturn("room-1", "user-1")); + + Room duplicate = repository.addParticipantAndReturn("room-1", "user-1"); + + assertNull(duplicate); + Room stored = mongoTemplate.findById("room-1", Room.class); + assertNotNull(stored); + assertEquals(Set.of("creator-1", "user-1"), stored.getParticipantIds()); + } + + @Test + void concurrentJoinsDoNotLoseOrDuplicateParticipants() throws Exception { + int participantCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(participantCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int index = 0; index < participantCount; index++) { + String userId = "user-" + index; + futures.add(executor.submit(() -> { + start.await(); + return repository.addParticipantAndReturn("room-1", userId); + })); + } + start.countDown(); + for (Future future : futures) { + assertNotNull(future.get()); + } + } finally { + executor.shutdownNow(); + } + + Room stored = mongoTemplate.findById("room-1", Room.class); + assertNotNull(stored); + assertEquals(participantCount + 1, stored.getParticipantIds().size()); + assertTrue(stored.getParticipantIds().contains("creator-1")); + for (int index = 0; index < participantCount; index++) { + assertTrue(stored.getParticipantIds().contains("user-" + index)); + } + } + + @Test + void findPageUsesCreatedAtAndIdAsStableCursor() { + mongoTemplate.dropCollection(Room.class); + LocalDateTime timestamp = LocalDateTime.of(2026, 8, 12, 10, 0); + Room newest = room("507f1f77bcf86cd799439013", timestamp.plusMinutes(1)); + Room tiedHigherId = room("507f1f77bcf86cd799439012", timestamp); + Room tiedLowerId = room("507f1f77bcf86cd799439011", timestamp); + Room oldest = room("507f1f77bcf86cd799439010", timestamp.minusMinutes(1)); + List.of(oldest, tiedLowerId, newest, tiedHigherId).forEach(mongoTemplate::save); + + List firstPage = repository.findPage(null, null, 2); + List secondPage = repository.findPage( + firstPage.get(1).getCreatedAt(), firstPage.get(1).getId(), 2); + + assertEquals(List.of(newest.getId(), tiedHigherId.getId()), + firstPage.stream().map(Room::getId).toList()); + assertEquals(List.of(tiedLowerId.getId(), oldest.getId()), + secondPage.stream().map(Room::getId).toList()); + } + + private static Room room(String id, LocalDateTime createdAt) { + return Room.builder() + .id(id) + .creator("creator-1") + .createdAt(createdAt) + .participantIds(Set.of("creator-1")) + .build(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/MessageReadStatusServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/MessageReadStatusServiceTest.java new file mode 100644 index 00000000..2b791014 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/MessageReadStatusServiceTest.java @@ -0,0 +1,40 @@ +package com.ktb.chatapp.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.ktb.chatapp.model.Message; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +@ExtendWith(MockitoExtension.class) +class MessageReadStatusServiceTest { + + @Mock private MongoTemplate mongoTemplate; + + @Test + void updateReadStatusUsesOneBulkUpdateForDistinctMessages() { + MessageReadStatusService service = new MessageReadStatusService(mongoTemplate); + + service.updateReadStatus(List.of("message-1", "message-1", "message-2"), "user-1"); + + verify(mongoTemplate).updateMulti(any(Query.class), any(Update.class), eq(Message.class)); + } + + @Test + void updateReadStatusSkipsEmptyInput() { + MessageReadStatusService service = new MessageReadStatusService(mongoTemplate); + + service.updateReadStatus(List.of(), "user-1"); + + verify(mongoTemplate, never()).updateMulti(any(Query.class), any(Update.class), eq(Message.class)); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterIntegrationTest.java new file mode 100644 index 00000000..466a6964 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterIntegrationTest.java @@ -0,0 +1,66 @@ +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.model.Message; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +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.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = "socketio.enabled=false") +class RecentMessageCounterIntegrationTest { + + private static final List ROOM_IDS = List.of("batch-room-1", "batch-room-2"); + + @Autowired private RecentMessageCounter recentMessageCounter; + @Autowired private MongoTemplate mongoTemplate; + + @AfterEach + void tearDown() { + mongoTemplate.remove( + Query.query(Criteria.where("room").in(ROOM_IDS)), + Message.class + ); + } + + @Test + void countRecentMessages_groupsRoomsAndExcludesBeforeBoundary() { + LocalDateTime since = LocalDateTime.of(2026, 8, 10, 12, 0); + mongoTemplate.insert(List.of( + message("batch-1", "batch-room-1", since.minusNanos(1)), + message("batch-2", "batch-room-1", since), + message("batch-3", "batch-room-1", since.plusMinutes(1)), + message("batch-4", "batch-room-2", since.plusMinutes(2)) + ), Message.class); + + Map counts = + recentMessageCounter.countRecentMessages(ROOM_IDS, since); + + assertThat(counts).containsExactlyInAnyOrderEntriesOf(Map.of( + "batch-room-1", 2, + "batch-room-2", 1 + )); + } + + private static Message message(String id, String roomId, LocalDateTime timestamp) { + return Message.builder() + .id(id) + .roomId(roomId) + .content("message") + .timestamp(timestamp) + .build(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomCursorCodecTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomCursorCodecTest.java new file mode 100644 index 00000000..9dc8ecf2 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomCursorCodecTest.java @@ -0,0 +1,29 @@ +package com.ktb.chatapp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import com.ktb.chatapp.exception.InvalidRoomCursorException; +import org.junit.jupiter.api.Test; + +class RoomCursorCodecTest { + + @Test + void roundTripsVersionedCursor() { + LocalDateTime createdAt = LocalDateTime.of(2026, 8, 12, 10, 30, 15, 123_000_000); + String id = "507f1f77bcf86cd799439011"; + + RoomCursorCodec.Cursor decoded = RoomCursorCodec.decode(RoomCursorCodec.encode(createdAt, id)); + + assertEquals(createdAt, decoded.createdAt()); + assertEquals(id, decoded.id()); + } + + @Test + void rejectsInvalidCursorPayload() { + assertThrows(InvalidRoomCursorException.class, () -> RoomCursorCodec.decode("broken")); + assertThrows(InvalidRoomCursorException.class, + () -> RoomCursorCodec.encode(LocalDateTime.now(), "not-an-object-id")); + } +} 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..e7007b12 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -0,0 +1,234 @@ +package com.ktb.chatapp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomsResponse; +import com.ktb.chatapp.event.RoomUpdatedEvent; +import com.ktb.chatapp.exception.InvalidRoomCursorException; +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.Set; +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 org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +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 + void getAllRooms_emptyList_avoidsBatchLookups() { + when(roomRepository.findPage(null, null, 31)).thenReturn(List.of()); + + RoomsResponse response = roomService.getAllRooms("viewer@test.com", 30, null); + + assertTrue(response.isSuccess()); + assertTrue(response.getData().isEmpty()); + verify(userRepository, never()).findSummariesByIdIn(anySet()); + verify(recentMessageCounter, never()).countRecentMessages(anySet()); + } + + @Test + void getAllRooms_batchesUsersAndMessageCountsOnceAndKeepsContract() throws Exception { + LocalDateTime older = LocalDateTime.of(2026, 8, 10, 10, 0); + LocalDateTime newer = older.plusMinutes(1); + Room oldRoom = room("room-old", "Old", "creator-1", older, + Set.of("creator-1", "participant-1", "missing-user")); + Room newRoom = room("room-new", null, "missing-creator", newer, + Set.of("participant-1")); + User creator = user("creator-1", "Creator", "creator@test.com"); + User participant = user("participant-1", null, null); + + when(roomRepository.findPage(null, null, 31)).thenReturn(List.of(newRoom, oldRoom)); + when(userRepository.findSummariesByIdIn(anySet())).thenReturn(List.of(creator, participant)); + when(recentMessageCounter.countRecentMessages(anySet())) + .thenReturn(Map.of("room-old", 7)); + + RoomsResponse response = roomService.getAllRooms("viewer@test.com", 30, null); + + assertTrue(response.isSuccess()); + assertEquals(List.of("room-new", "room-old"), response.getData().stream() + .map(RoomResponse::getId).toList()); + RoomResponse first = response.getData().get(0); + RoomResponse second = response.getData().get(1); + assertEquals("제목 없음", first.getName()); + assertNull(first.getCreator()); + assertEquals(0, first.getRecentMessageCount()); + assertEquals(1, first.getParticipantsCount()); + assertEquals("알 수 없음", first.getParticipants().get(0).getName()); + assertEquals("", first.getParticipants().get(0).getEmail()); + assertEquals(7, second.getRecentMessageCount()); + assertEquals(2, second.getParticipantsCount()); + verify(userRepository).findSummariesByIdIn(anySet()); + verify(recentMessageCounter).countRecentMessages(anySet()); + + JsonNode json = new ObjectMapper().findAndRegisterModules().valueToTree(second); + assertTrue(json.has("_id")); + assertTrue(json.has("name")); + assertTrue(json.has("hasPassword")); + assertTrue(json.has("creator")); + assertTrue(json.has("participants")); + assertTrue(json.has("participantsCount")); + assertTrue(json.has("createdAt")); + assertTrue(json.has("recentMessageCount")); + assertFalse(json.has("isCreator")); + assertFalse(json.has("createdAtDateTime")); + } + + @Test + void getAllRooms_returnsOpaqueCursorFromLookaheadItem() { + LocalDateTime newest = LocalDateTime.of(2026, 8, 12, 10, 0); + Room first = room("507f1f77bcf86cd799439011", "First", null, newest, Set.of()); + Room second = room("507f1f77bcf86cd799439010", "Second", null, newest.minusMinutes(1), Set.of()); + when(roomRepository.findPage(null, null, 2)).thenReturn(List.of(first, second)); + + RoomsResponse response = roomService.getAllRooms("viewer@test.com", 1, null); + + assertEquals(List.of(first.getId()), response.getData().stream().map(RoomResponse::getId).toList()); + assertTrue(response.getMetadata().isHasMore()); + assertEquals(1, response.getMetadata().getCurrentCount()); + assertTrue(response.getMetadata().getNextCursor() != null); + } + + @Test + void getAllRooms_rejectsMalformedCursorBeforeQueryingRepository() { + assertThrows(InvalidRoomCursorException.class, + () -> roomService.getAllRooms("viewer@test.com", 30, "not-a-cursor")); + verify(roomRepository, never()).findPage(any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void getAllRooms_propagatesRepositoryFailure() { + when(roomRepository.findPage(null, null, 31)).thenThrow(new IllegalStateException("mongo unavailable")); + + assertThrows(IllegalStateException.class, + () -> roomService.getAllRooms("viewer@test.com", 30, null)); + } + + @Test + void joinRoom_usesUserIdAtomicUpdateAndSharesOneResponseWithEvent() { + Room before = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.LinkedHashSet<>(List.of("creator-1"))); + Room after = room("room-1", "Room", "creator-1", before.getCreatedAt(), + new java.util.LinkedHashSet<>(List.of("creator-1", "user-1"))); + User creator = user("creator-1", "Creator", "creator@test.com"); + User participant = user("user-1", "Participant", "participant@test.com"); + when(roomRepository.findById("room-1")).thenReturn(java.util.Optional.of(before)); + when(roomRepository.addParticipantAndReturn("room-1", "user-1")).thenReturn(after); + when(userRepository.findSummariesByIdIn(anySet())).thenReturn(List.of(participant, creator)); + when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(4); + + RoomResponse response = roomService.joinRoom("room-1", null, "user-1"); + + assertEquals(List.of("creator-1", "user-1"), response.getParticipants().stream() + .map(participantResponse -> participantResponse.getId()).toList()); + assertEquals(false, ReflectionTestUtils.getField(response, "isCreator")); + assertEquals(4, response.getRecentMessageCount()); + verify(userRepository, never()).findByEmail(any()); + verify(roomRepository, never()).save(any()); + verify(userRepository).findSummariesByIdIn(anySet()); + verify(recentMessageCounter).countRecentMessages("room-1"); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(RoomUpdatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertSame(response, eventCaptor.getValue().getRoomResponse()); + } + + @Test + void joinRoom_keepsExistingParticipantWithoutMutationAndUsesIdForIsCreator() { + Room room = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.LinkedHashSet<>(List.of("creator-1"))); + User creator = user("creator-1", "Creator", "creator@test.com"); + when(roomRepository.findById("room-1")).thenReturn(java.util.Optional.of(room)); + when(userRepository.findSummariesByIdIn(anySet())).thenReturn(List.of(creator)); + + RoomResponse response = roomService.joinRoom("room-1", null, "creator-1"); + + assertEquals(true, ReflectionTestUtils.getField(response, "isCreator")); + verify(roomRepository, never()).addParticipantAndReturn(any(), any()); + verify(roomRepository, never()).save(any()); + verify(userRepository, never()).findByEmail(any()); + verify(eventPublisher, never()).publishEvent(any(RoomUpdatedEvent.class)); + } + + @Test + void joinRoom_reloadsRoomWithoutPublishingWhenConcurrentRequestAlreadyAddedParticipant() { + Room before = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.LinkedHashSet<>(List.of("creator-1"))); + Room latest = room("room-1", "Room", "creator-1", before.getCreatedAt(), + new java.util.LinkedHashSet<>(List.of("creator-1", "user-1"))); + User creator = user("creator-1", "Creator", "creator@test.com"); + User participant = user("user-1", "Participant", "participant@test.com"); + when(roomRepository.findById("room-1")) + .thenReturn(java.util.Optional.of(before), java.util.Optional.of(latest)); + when(roomRepository.addParticipantAndReturn("room-1", "user-1")).thenReturn(null); + when(userRepository.findSummariesByIdIn(anySet())) + .thenReturn(List.of(creator, participant)); + + RoomResponse response = roomService.joinRoom("room-1", null, "user-1"); + + assertEquals(2, response.getParticipantsCount()); + verify(roomRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(RoomUpdatedEvent.class)); + } + + private static Room room( + String id, + String name, + String creator, + LocalDateTime createdAt, + Set participants) { + return Room.builder() + .id(id) + .name(name) + .creator(creator) + .createdAt(createdAt) + .participantIds(participants) + .build(); + } + + private static User user(String id, String name, String email) { + return User.builder().id(id).name(name).email(email).build(); + } +} 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..b8318696 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,14 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; 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.redis.core.StringRedisTemplate; import org.springframework.test.context.TestPropertySource; import static org.assertj.core.api.Assertions.assertThat; @@ -18,7 +20,7 @@ * 백엔드 저장소(Redis/MongoDB)가 변경되어도 테스트 코드 수정이 불필요 */ @SpringBootTest -@Import(MongoTestContainer.class) +@Import({MongoTestContainer.class, RedisTestContainer.class}) @TestPropertySource(properties = { "socketio.enabled=false" }) @@ -28,6 +30,9 @@ class SessionServiceTest { @Autowired private SessionService sessionService; + @Autowired + private StringRedisTemplate redisTemplate; + 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"; @@ -67,6 +72,9 @@ void createSession_Success() { assertNotNull(result.getSessionData()); assertEquals(TEST_USER_ID, result.getSessionData().getUserId()); assertEquals(result.getSessionId(), result.getSessionData().getSessionId()); + String redisKey = "auth:session:" + TEST_USER_ID; + assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))); + assertThat(redisTemplate.getExpire(redisKey)).isPositive(); } @Test @@ -167,8 +175,8 @@ void validateSession_NonExistentUser_Failure() { } @Test - @DisplayName("세션 검증 - lastActivity 업데이트") - void validateSession_UpdatesLastActivity() throws InterruptedException { + @DisplayName("짧은 간격의 세션 검증은 lastActivity를 다시 저장하지 않는다") + void validateSession_DoesNotUpdateLastActivityWithinThrottleWindow() throws InterruptedException { // Given SessionMetadata metadata = createTestMetadata(); SessionCreationResult created = sessionService.createSession(TEST_USER_ID, metadata); @@ -181,42 +189,7 @@ void validateSession_UpdatesLastActivity() throws InterruptedException { // Then assertTrue(result.isValid()); - assertThat(result.getSession().getLastActivity()).isGreaterThan(initialLastActivity); - } - - // ============ 세션 활동 업데이트 테스트 ============ - - @Test - @DisplayName("lastActivity 업데이트 성공") - void updateLastActivity_Success() throws InterruptedException { - // Given - SessionMetadata metadata = createTestMetadata(); - SessionCreationResult created = sessionService.createSession(TEST_USER_ID, metadata); - long initialLastActivity = created.getSessionData().getLastActivity(); - - Thread.sleep(100); - - // When - sessionService.updateLastActivity(TEST_USER_ID); - - // Then - 세션 데이터를 다시 가져와서 확인 - SessionData activeSession = sessionService.getActiveSession(TEST_USER_ID); - assertNotNull(activeSession); - assertThat(activeSession.getLastActivity()).isGreaterThan(initialLastActivity); - } - - @Test - @DisplayName("lastActivity 업데이트 - null userId 처리") - void updateLastActivity_NullUserId_NoException() { - // When & Then - 예외 발생하지 않아야 함 - assertDoesNotThrow(() -> sessionService.updateLastActivity(null)); - } - - @Test - @DisplayName("lastActivity 업데이트 - 존재하지 않는 세션") - void updateLastActivity_NonExistentSession_NoException() { - // When & Then - assertDoesNotThrow(() -> sessionService.updateLastActivity("non-existent-user")); + assertThat(result.getSession().getLastActivity()).isEqualTo(initialLastActivity); } // ============ 세션 제거 테스트 ============ 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..f6f6c666 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 @@ -15,7 +15,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +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,8 +37,8 @@ class SessionServiceUnitTest { private SessionService sessionService; @Test - @DisplayName("세션 생성은 기존 사용자 세션을 제거한 뒤 새 세션을 저장한다") - void createSession_RemovesExistingSessionsBeforeSave() { + @DisplayName("세션 생성은 단일 사용자 키에 새 세션을 저장한다") + void createSession_SavesSessionUsingSingleUserKey() { ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(Session.class); when(sessionStore.save(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -44,7 +46,7 @@ void createSession_RemovesExistingSessionsBeforeSave() { USER_ID, new SessionMetadata("agent", "127.0.0.1", "device")); - verify(sessionStore).deleteAll(USER_ID); + verify(sessionStore, never()).deleteAll(USER_ID); verify(sessionStore).save(sessionCaptor.capture()); Session savedSession = sessionCaptor.getValue(); assertThat(result.getSessionId()).isEqualTo(savedSession.getSessionId()); @@ -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).save(any(Session.class)); RuntimeException exception = assertThrows( RuntimeException.class, @@ -64,7 +66,7 @@ void createSession_StoreFailure_ThrowsRuntimeException() { assertThat(exception).hasMessage("세션 생성 중 오류가 발생했습니다."); assertThat(exception).hasRootCauseInstanceOf(IllegalStateException.class); - verify(sessionStore, never()).save(any(Session.class)); + verify(sessionStore).save(any(Session.class)); } @Test @@ -123,6 +125,44 @@ void validateSession_StoreFailure_ReturnsValidationError() { assertThat(result.getError()).isEqualTo("VALIDATION_ERROR"); } + @Test + @DisplayName("오래된 활성 세션은 ID가 일치할 때만 원자적으로 갱신한다") + void validateSession_OldActiveSession_TouchesMatchingSession() { + Session oldSession = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .createdAt(Instant.now().minusSeconds(600).toEpochMilli()) + .lastActivity(Instant.now().minusSeconds(600).toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(1200)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(oldSession)); + when(sessionStore.touch(eq(USER_ID), eq(SESSION_ID), anyLong())).thenReturn(true); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isTrue(); + verify(sessionStore).touch(eq(USER_ID), eq(SESSION_ID), anyLong()); + } + + @Test + @DisplayName("활동 갱신 중 교체된 세션은 무효 처리한다") + void validateSession_ReplacedDuringTouch_ReturnsInvalidSession() { + Session oldSession = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .createdAt(Instant.now().minusSeconds(600).toEpochMilli()) + .lastActivity(Instant.now().minusSeconds(600).toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(1200)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(oldSession)); + when(sessionStore.touch(eq(USER_ID), eq(SESSION_ID), anyLong())).thenReturn(false); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isFalse(); + assertThat(result.getError()).isEqualTo("INVALID_SESSION"); + } + @Test @DisplayName("활성 세션 조회 중 저장소 실패는 null로 반환된다") void getActiveSession_StoreFailure_ReturnsNull() { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/UploadIntentServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/UploadIntentServiceTest.java new file mode 100644 index 00000000..890d14af --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/UploadIntentServiceTest.java @@ -0,0 +1,77 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.dto.UploadIntentRequest; +import com.ktb.chatapp.dto.MirrorUploadResultRequest; +import com.ktb.chatapp.model.PendingUpload; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.PendingUploadRepository; +import com.ktb.chatapp.repository.UserRepository; +import com.ktb.chatapp.storage.PresignedUpload; +import com.ktb.chatapp.storage.MirrorUploadPresigner; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.net.URI; +import java.time.Instant; +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; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UploadIntentServiceTest { + @Mock PendingUploadRepository pendingRepository; + @Mock UserRepository userRepository; + @Mock MirrorUploadPresigner presigner; + + private UploadIntentService service; + private User user; + private SimpleMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + service = new UploadIntentService( + pendingRepository, userRepository, presigner, new UploadPolicy(), meterRegistry); + user = User.builder().id("user-1").email("user@example.com").build(); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + } + + @Test + void createReturnsFiveMinutePresignedPutForOwnedPendingObject() { + when(presigner.presign(any(), any(), anyLong(), any())).thenReturn( + new PresignedUpload(URI.create("https://s3.test/pending"), + Map.of("Content-Type", "image/png"), Instant.now().plusSeconds(300))); + + var response = service.create("USER@example.com", + new UploadIntentRequest("image.png", "image/png", 100), PendingUpload.Purpose.CHAT); + + assertThat(response.method()).isEqualTo("PUT"); + assertThat(response.uploadUrl()).isEqualTo("https://s3.test/pending"); + assertThat(response.key()).startsWith("pending/mirror/chat/"); + verify(pendingRepository).save(any(PendingUpload.class)); + } + + @Test + void recordResultClosesPendingIntentAndRecordsPutMetric() { + PendingUpload pending = PendingUpload.builder().id("upload-1").userId("user-1") + .purpose(PendingUpload.Purpose.CHAT).expectedSize(100).contentType("image/png") + .status(PendingUpload.Status.PENDING).build(); + when(pendingRepository.findByIdAndUserIdAndPurpose( + "upload-1", "user-1", PendingUpload.Purpose.CHAT)).thenReturn(Optional.of(pending)); + + service.recordResult("user@example.com", + new MirrorUploadResultRequest("upload-1", true, 200, 42), PendingUpload.Purpose.CHAT); + + assertThat(pending.getStatus()).isEqualTo(PendingUpload.Status.COMPLETED); + assertThat(meterRegistry.get("mirror_put_success").counter().count()).isEqualTo(1); + verify(pendingRepository).save(pending); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/UploadPolicyTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/UploadPolicyTest.java new file mode 100644 index 00000000..2ae9166f --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/UploadPolicyTest.java @@ -0,0 +1,32 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class UploadPolicyTest { + private final UploadPolicy policy = new UploadPolicy(); + + @Test + void acceptsEveryDocumentTypeAtFiveMegabytes() { + assertThatNoException().isThrownBy(() -> policy.validate("report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + UploadPolicy.MAX_SIZE, false)); + } + + @Test + void rejectsOversizeAndMimeExtensionMismatch() { + assertThatThrownBy(() -> policy.validate("photo.jpg", "image/jpeg", UploadPolicy.MAX_SIZE + 1, false)) + .isInstanceOf(UploadIntentException.class); + assertThatIllegalArgumentException().isThrownBy(() -> + policy.validate("photo.pdf", "image/jpeg", 10, false)); + } + + @Test + void profilePurposeOnlyAcceptsImages() { + assertThatIllegalArgumentException().isThrownBy(() -> + policy.validate("clip.mp4", "video/mp4", 10, true)); + } +} 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..5ec5da1a 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 @@ -48,7 +48,7 @@ class UserServiceTest { */ @BeforeEach void setUp() { - userService = new UserService(userRepository, fileService, new LocalStorage(uploadDir.toString())); + userService = new UserService(userRepository, fileService, new LocalStorage(uploadDir.toString()), new UploadPolicy()); ReflectionTestUtils.setField(userService, "maxProfileImageSize", 5242880L); } 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..f74ce09f --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java @@ -0,0 +1,54 @@ +package com.ktb.chatapp.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayInputStream; +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 software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@ExtendWith(MockitoExtension.class) +@DisplayName("S3Storage 단위 테스트") +class S3StorageTest { + + @Mock + private S3Client s3Client; + + @Mock + private S3Presigner s3Presigner; + + private S3Storage storage; + + @BeforeEach + void setUp() { + storage = new S3Storage(s3Client, s3Presigner); + ReflectionTestUtils.setField(storage, "bucket", "test-bucket"); + } + + @Test + @DisplayName("이미지 바이트를 지정한 S3 key와 Content-Type으로 저장한다") + void putStoresObjectInS3() { + byte[] content = "image-bytes".getBytes(); + + StoredObject result = storage.put( + new ByteArrayInputStream(content), "profiles/avatar.png", "image/png", content.length); + + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(request.capture(), any(RequestBody.class)); + assertThat(request.getValue().bucket()).isEqualTo("test-bucket"); + assertThat(request.getValue().key()).isEqualTo("profiles/avatar.png"); + assertThat(request.getValue().contentType()).isEqualTo("image/png"); + assertThat(result).isEqualTo(new StoredObject("profiles/avatar.png", content.length)); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/StoragePortSelectionTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/StoragePortSelectionTest.java index d57dd341..1335eb69 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/storage/StoragePortSelectionTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/StoragePortSelectionTest.java @@ -1,36 +1,25 @@ package com.ktb.chatapp.storage; -import java.nio.file.Path; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("file.storage.type 스위치 단위 테스트") +@DisplayName("로컬 스토리지 선택 단위 테스트") class StoragePortSelectionTest { - @TempDir - private Path uploadDir; - - private final ApplicationContextRunner contextRunner = - new ApplicationContextRunner().withUserConfiguration(LocalStorage.class); - - @Test - @DisplayName("프로퍼티 미설정 시 LocalStorage 빈이 등록된다") - void localStorageIsRegisteredWhenPropertyMissing() { - contextRunner - .withPropertyValues("file.upload-dir=" + uploadDir) - .run(context -> assertThat(context).hasSingleBean(LocalStorage.class)); - } - @Test - @DisplayName("file.storage.type=local이면 LocalStorage 빈이 등록된다") - void localStorageIsRegisteredWhenPropertyIsLocal() { - contextRunner - .withPropertyValues("file.storage.type=local", "file.upload-dir=" + uploadDir) - .run(context -> assertThat(context).hasSingleBean(LocalStorage.class)); + @DisplayName("local 설정에서만 LocalStorage가 빈으로 등록된다") + void localStorageIsConditionalApplicationComponent() { + assertThat(LocalStorage.class).hasAnnotation(Component.class); + + ConditionalOnProperty condition = LocalStorage.class + .getAnnotation(ConditionalOnProperty.class); + assertThat(condition).isNotNull(); + assertThat(condition.name()).containsExactly("file.storage.type"); + assertThat(condition.havingValue()).isEqualTo("local"); + assertThat(condition.matchIfMissing()).isFalse(); } - } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/util/BannedWordCheckerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/util/BannedWordCheckerTest.java index 05c4a4f6..aed2e365 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/util/BannedWordCheckerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/util/BannedWordCheckerTest.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -47,6 +49,13 @@ void containsBannedWord_detectsWordEmbeddedInMessage() { assertTrue(checker.containsBannedWord(message)); } + @ParameterizedTest + @ValueSource(strings = {"b3sig78jv", "9c0hej6x", "lbl276sz", "p4e84", "hy8m", "ikqy2y"}) + void containsBannedWord_detectsLoadTestWords(String word) { + BannedWordChecker checker = new BannedWordChecker(BANNED_WORDS); + assertTrue(checker.containsBannedWord(word)); + } + @Test void containsBannedWord_returnsFalseForCleanOrEmptyInput() { BannedWordChecker checker = new BannedWordChecker(BANNED_WORDS); 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..bb09942d 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 @@ -1,12 +1,14 @@ package com.ktb.chatapp.websocket.socketio; import com.corundumstudio.socketio.BroadcastOperations; +import com.corundumstudio.socketio.SocketIOClient; import com.corundumstudio.socketio.SocketIOServer; import com.ktb.chatapp.dto.RoomResponse; import com.ktb.chatapp.event.RoomActivityEvent; import com.ktb.chatapp.event.RoomUpdatedEvent; import com.ktb.chatapp.event.SessionEndedEvent; import java.util.Map; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +32,7 @@ class SocketIOEventListenerTest { @Mock private SocketIOServer socketIOServer; @Mock private BroadcastOperations userOperations; @Mock private BroadcastOperations roomListOperations; + @Mock private SocketIOClient userClient; private SocketIOEventListener listener; @@ -43,6 +46,7 @@ void handleSessionEndedEvent_sendsSessionEndedToUserRoom() { SessionEndedEvent event = new SessionEndedEvent(this, "user-1", "duplicate_login", "ended"); when(socketIOServer.getRoomOperations("user:user-1")).thenReturn(userOperations); + when(userOperations.getClients()).thenReturn(List.of(userClient)); listener.handleSessionEndedEvent(event); @@ -52,6 +56,7 @@ void handleSessionEndedEvent_sendsSessionEndedToUserRoom() { Map payload = (Map) payloadCaptor.getValue(); assertEquals("duplicate_login", payload.get("reason")); assertEquals("ended", payload.get("message")); + verify(userClient).disconnect(); } @Test 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..eef128b9 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 @@ -16,8 +16,6 @@ import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RoomActivityNotifier; -import com.ktb.chatapp.service.SessionService; -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.ai.AiService; @@ -49,7 +47,6 @@ class ChatMessageHandlerTest { @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; @@ -67,7 +64,6 @@ void setUp() { userRepository, fileRepository, aiService, - sessionService, roomActivityNotifier, bannedWordChecker, rateLimitService, @@ -80,10 +76,6 @@ void handleChatMessage_blocksMessagesContainingBannedWords() { SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); when(client.get("user")).thenReturn(socketUser); - SessionValidationResult validResult = SessionValidationResult.valid(null); - when(sessionService.validateSession(socketUser.id(), socketUser.authSessionId())) - .thenReturn(validResult); - RateLimitCheckResult allowedResult = RateLimitCheckResult.allowed(10000, 9999, 60, System.currentTimeMillis() / 1000 + 60, 60); when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) .thenReturn(allowedResult); @@ -123,8 +115,6 @@ void handleChatMessage_echoesSavedMessageToSenderSocket() { 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)); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcherTest.java new file mode 100644 index 00000000..056c8bcd --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/JoinAckDispatcherTest.java @@ -0,0 +1,70 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.corundumstudio.socketio.SocketIOClient; +import com.ktb.chatapp.dto.FetchMessagesRequest; +import com.ktb.chatapp.dto.FetchMessagesResponse; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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) +class JoinAckDispatcherTest { + + @Mock private MessageLoader messageLoader; + @Mock private ReadStatusDispatcher readStatusDispatcher; + @Mock private RoomPostJoinDispatcher postJoinDispatcher; + @Mock private SocketIOClient firstClient; + @Mock private SocketIOClient secondClient; + + private JoinAckDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new JoinAckDispatcher( + messageLoader, + readStatusDispatcher, + postJoinDispatcher, + new SimpleMeterRegistry(), + 1, + 10, + 25); + } + + @AfterEach + void tearDown() { + dispatcher.shutdown(); + } + + @Test + void coalescesHistoryAndAcknowledgesEveryWaitingClient() { + FetchMessagesResponse history = FetchMessagesResponse.builder() + .messages(List.of()) + .hasMore(false) + .build(); + when(messageLoader.loadMessages(any(FetchMessagesRequest.class), eq(null))).thenReturn(history); + + dispatcher.request(firstClient, "room-1", "user-1", "first"); + dispatcher.request(secondClient, "room-1", "user-2", "second"); + + verify(firstClient, org.mockito.Mockito.timeout(1_000)) + .sendEvent(eq("joinRoomSuccess"), any()); + verify(secondClient, org.mockito.Mockito.timeout(1_000)) + .sendEvent(eq("joinRoomSuccess"), any()); + verify(messageLoader, times(1)).loadMessages(any(FetchMessagesRequest.class), eq(null)); + verify(postJoinDispatcher, org.mockito.Mockito.timeout(1_000)) + .scheduleSystemMessage("room-1", "first님이 입장하였습니다."); + verify(postJoinDispatcher, org.mockito.Mockito.timeout(1_000)) + .scheduleSystemMessage("room-1", "second님이 입장하였습니다."); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcherTest.java new file mode 100644 index 00000000..33940697 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageFetchDispatcherTest.java @@ -0,0 +1,60 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.corundumstudio.socketio.SocketIOClient; +import com.ktb.chatapp.dto.FetchMessagesRequest; +import com.ktb.chatapp.dto.FetchMessagesResponse; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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) +class MessageFetchDispatcherTest { + + @Mock private MessageLoader messageLoader; + @Mock private ReadStatusDispatcher readStatusDispatcher; + @Mock private SocketIOClient firstClient; + @Mock private SocketIOClient secondClient; + + private MessageFetchDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new MessageFetchDispatcher( + messageLoader, readStatusDispatcher, new SimpleMeterRegistry(), 1, 10, 25); + } + + @AfterEach + void tearDown() { + dispatcher.shutdown(); + } + + @Test + void coalescesEquivalentConcurrentFetchesIntoOneQuery() { + FetchMessagesRequest request = new FetchMessagesRequest("room-1", 30, null); + FetchMessagesResponse response = FetchMessagesResponse.builder() + .messages(List.of()) + .hasMore(false) + .build(); + when(messageLoader.loadMessages(request, null)).thenReturn(response); + + dispatcher.request(firstClient, request, "user-1"); + dispatcher.request(secondClient, request, "user-2"); + + verify(firstClient, org.mockito.Mockito.timeout(1_000)) + .sendEvent(eq("previousMessagesLoaded"), eq(response)); + verify(secondClient, org.mockito.Mockito.timeout(1_000)) + .sendEvent(eq("previousMessagesLoaded"), eq(response)); + verify(messageLoader, times(1)).loadMessages(request, null); + } +} 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..2cdb310f 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 com.ktb.chatapp.websocket.socketio.UserRooms; 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; @@ -26,15 +23,15 @@ @ExtendWith(MockitoExtension.class) class MessageFetchHandlerTest { - @Mock private RoomRepository roomRepository; - @Mock private MessageLoader messageLoader; + @Mock private UserRooms userRooms; + @Mock private MessageFetchDispatcher messageFetchDispatcher; @Mock private SocketIOClient client; private MessageFetchHandler handler; @BeforeEach void setUp() { - handler = new MessageFetchHandler(roomRepository, messageLoader); + handler = new MessageFetchHandler(userRooms, messageFetchDispatcher); } @Test @@ -45,26 +42,19 @@ void handleFetchMessages_rejectsUnauthorizedClient() { handler.handleFetchMessages(client, request); verify(client).sendEvent(eq(ERROR), any()); - verify(messageLoader, never()).loadMessages(any(), any()); + verify(messageFetchDispatcher, never()).request(any(), any(), any()); } @Test void handleFetchMessages_loadsMessagesForParticipant() { FetchMessagesRequest request = new FetchMessagesRequest("room-1", 30, null); - FetchMessagesResponse response = FetchMessagesResponse.builder() - .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(messageLoader.loadMessages(request, "user-1")).thenReturn(response); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); handler.handleFetchMessages(client, request); verify(client, never()).sendEvent(eq("messageLoadStart")); - verify(client).sendEvent(PREVIOUS_MESSAGES_LOADED, response); + verify(messageFetchDispatcher).request(client, request, "user-1"); } } 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..e5ad79b2 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 @@ -67,8 +67,7 @@ void setUp() { messageLoader = new MessageLoader( messageRepository, userRepository, - new MessageResponseMapper(fileRepository), - messageReadStatusService + new MessageResponseMapper(fileRepository) ); // 테스트 사용자 생성 및 저장 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..07744df2 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 @@ -60,8 +60,7 @@ void setUp() { messageLoader = new MessageLoader( messageRepository, userRepository, - new MessageResponseMapper(fileRepository), - messageReadStatusService + new MessageResponseMapper(fileRepository) ); var testUser = User.builder() @@ -81,7 +80,6 @@ void setUp() { lenient().when(userRepository.findAllById(anySet())) .thenReturn(List.of(testUser)); - lenient().doNothing().when(messageReadStatusService).updateReadStatus(anyList(), anyString()); } private Message createMessage(String id, LocalDateTime timestamp) { @@ -115,17 +113,19 @@ void loadMessages_shouldReturnAscendingOrderAfterReversing() { // Then: 결과는 오름차순으로 정렬되어야 함 assertThat(result.getMessages()).hasSize(30); assertThat(result.isHasMore()).isTrue(); + verify(userRepository).findAllById(anySet()); + verify(userRepository, never()).findById(anyString()); // 시간순 정렬 확인 (오름차순: 오래된 것 → 최신 것) // [50시간 전, 49시간 전, ..., 21시간 전] verifyAscending(result); } - private static @NotNull Page getMessagePage(List first30Messages) { + private static @NotNull Slice getMessagePage(List first30Messages) { List messages = new ArrayList<>(first30Messages.reversed()); Pageable pageable = PageRequest.of(0, 30, Sort.by("timestamp").descending()); - Page messagePage = new PageImpl<>(messages, pageable, 50); + Slice messagePage = new SliceImpl<>(messages, pageable, true); return messagePage; } @@ -137,7 +137,7 @@ void loadInitialMessages_shouldReturnAscendingOrderAfterReversing() { // DB는 DESC 정렬로 반환 (최신 것부터) // [1시간 전, 2시간 전, ..., 30시간 전] - Page messagePage = getMessagePage(last30Messages); + Slice messagePage = getMessagePage(last30Messages); when(messageRepository.findByRoomIdAndTimestampBefore( eq(roomId), any(LocalDateTime.class), any(Pageable.class))) diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ReadStatusDispatcherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ReadStatusDispatcherTest.java new file mode 100644 index 00000000..29d5d75e --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ReadStatusDispatcherTest.java @@ -0,0 +1,38 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static org.mockito.Mockito.verify; + +import com.ktb.chatapp.service.MessageReadStatusService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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) +class ReadStatusDispatcherTest { + + @Mock private MessageReadStatusService readStatusService; + private ReadStatusDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new ReadStatusDispatcher(readStatusService, new SimpleMeterRegistry(), 1, 10, 10); + } + + @AfterEach + void tearDown() { + dispatcher.shutdown(); + } + + @Test + void schedulesReadStatusOutsideTheRequestThread() { + dispatcher.schedule(List.of("message-1"), "user-1"); + + verify(readStatusService, org.mockito.Mockito.timeout(1_000)) + .updateReadStatus(List.of("message-1"), "user-1"); + } +} 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..003d5e90 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,40 @@ 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.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; +import com.mongodb.client.result.UpdateResult; 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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; 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 MongoTemplate mongoTemplate; @Mock private UserRooms userRooms; - @Mock private MessageLoader messageLoader; - @Mock private MessageResponseMapper messageResponseMapper; - @Mock private RoomLeaveHandler roomLeaveHandler; + @Mock private RoomPostJoinDispatcher postJoinDispatcher; + @Mock private JoinAckDispatcher joinAckDispatcher; @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(mongoTemplate, userRooms, postJoinDispatcher, joinAckDispatcher); } @Test @@ -73,45 +47,35 @@ void handleJoinRoom_rejectsUnauthorizedClient() { } @Test - void handleJoinRoom_addsParticipantLoadsMessagesAndBroadcasts() { + void handleJoinRoomAcknowledgesBeforeDispatchingPostJoinWork() { 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); + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq("rooms"))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); handler.handleJoinRoom(client, "room-1"); - verify(roomRepository).addParticipant("room-1", "user-1"); + verify(mongoTemplate).updateFirst(any(Query.class), any(Update.class), eq("rooms")); 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()); + verify(joinAckDispatcher).request(client, "room-1", "user-1", "tester"); + verify(postJoinDispatcher).scheduleParticipantsUpdate("room-1"); + } + + @Test + void handleJoinRoom_missingRoomStopsBeforeJoiningSocket() { + SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); + when(client.get("user")).thenReturn(socketUser); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq("rooms"))) + .thenReturn(UpdateResult.acknowledged(0, 0L, null)); + + handler.handleJoinRoom(client, "room-1"); + + verify(client).sendEvent(eq(JOIN_ROOM_ERROR), any()); + verify(client, never()).joinRoom(any()); + verify(joinAckDispatcher, never()).request(any(), any(), any(), any()); } } 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..f64d3ef1 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 @@ -1,34 +1,21 @@ 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.MessageResponse; -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.model.User; -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.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; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.ERROR; -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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; @@ -38,26 +25,17 @@ @ExtendWith(MockitoExtension.class) class RoomLeaveHandlerTest { - @Mock private SocketIOServer socketIOServer; - @Mock private MessageRepository messageRepository; @Mock private RoomRepository roomRepository; @Mock private UserRepository userRepository; @Mock private UserRooms userRooms; - @Mock private MessageResponseMapper messageResponseMapper; + @Mock private RoomPostJoinDispatcher postJoinDispatcher; @Mock private SocketIOClient client; - @Mock private BroadcastOperations roomOperations; private RoomLeaveHandler handler; @BeforeEach void setUp() { - handler = new RoomLeaveHandler( - socketIOServer, - messageRepository, - roomRepository, - userRepository, - userRooms, - messageResponseMapper); + handler = new RoomLeaveHandler(roomRepository, userRepository, userRooms, postJoinDispatcher); } @Test @@ -74,52 +52,22 @@ void handleLeaveRoom_rejectsUnauthorizedClient() { void handleLeaveRoom_removesParticipantAndBroadcasts() { 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(); - User remainingUser = User.builder().id("user-2").name("remaining").email("remaining@example.com").build(); Room roomBeforeLeave = Room.builder() .id("room-1") .name("room") .participantIds(Set.of("user-1", "user-2")) .build(); - Room roomAfterLeave = Room.builder() - .id("room-1") - .name("room") - .participantIds(Set.of("user-2")) - .build(); - MessageResponse leaveMessageResponse = MessageResponse.builder() - .id("message-1") - .roomId("room-1") - .content("tester님이 퇴장하였습니다.") - .type(MessageType.system) - .timestamp(1L) - .build(); - when(client.get("user")).thenReturn(socketUser); when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - when(userRepository.findById("user-2")).thenReturn(Optional.of(remainingUser)); - when(roomRepository.findById("room-1")) - .thenReturn(Optional.of(roomBeforeLeave), Optional.of(roomAfterLeave)); - when(messageRepository.save(any(Message.class))).thenAnswer(invocation -> { - Message message = invocation.getArgument(0); - message.setId("message-1"); - message.setTimestamp(LocalDateTime.now()); - return message; - }); - when(messageResponseMapper.mapToMessageResponse(any(Message.class), eq(null))) - .thenReturn(leaveMessageResponse); - when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); + when(roomRepository.findById("room-1")).thenReturn(Optional.of(roomBeforeLeave)); handler.handleLeaveRoom(client, "room-1"); verify(roomRepository).removeParticipant("room-1", "user-1"); verify(client).leaveRoom("room-1"); verify(userRooms).remove("user-1", "room-1"); - verify(roomOperations).sendEvent(MESSAGE, leaveMessageResponse); - ArgumentCaptor participantsCaptor = ArgumentCaptor.forClass(Object.class); - verify(roomOperations).sendEvent(eq(PARTICIPANTS_UPDATE), participantsCaptor.capture()); - @SuppressWarnings("unchecked") - var participants = (java.util.List) participantsCaptor.getValue(); - assertEquals(List.of("user-2"), participants.stream().map(UserResponse::getId).toList()); - verify(roomOperations, never()).sendEvent(eq("userLeft"), any()); + verify(postJoinDispatcher).scheduleSystemMessage("room-1", "tester님이 퇴장하였습니다."); + verify(postJoinDispatcher).scheduleParticipantsUpdate("room-1"); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcherTest.java new file mode 100644 index 00000000..63d42b7f --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomPostJoinDispatcherTest.java @@ -0,0 +1,72 @@ +package com.ktb.chatapp.websocket.socketio.handler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.corundumstudio.socketio.BroadcastOperations; +import com.corundumstudio.socketio.SocketIOServer; +import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.MessageRepository; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +@ExtendWith(MockitoExtension.class) +class RoomPostJoinDispatcherTest { + + @Mock private SocketIOServer socketIOServer; + @Mock private MessageRepository messageRepository; + @Mock private MongoTemplate mongoTemplate; + @Mock private MessageResponseMapper messageResponseMapper; + @Mock private BroadcastOperations roomOperations; + + private RoomPostJoinDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new RoomPostJoinDispatcher( + socketIOServer, + messageRepository, + mongoTemplate, + messageResponseMapper, + new SimpleMeterRegistry(), + 1, + 10, + 25, + 25); + } + + @AfterEach + void tearDown() { + dispatcher.shutdown(); + } + + @Test + void repeatedParticipantUpdatesAreCoalescedIntoOneSnapshot() { + 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(mongoTemplate.findOne(any(Query.class), eq(Room.class))).thenReturn(room); + when(mongoTemplate.find(any(Query.class), eq(User.class))).thenReturn(List.of(user)); + when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); + + dispatcher.scheduleParticipantsUpdate("room-1"); + dispatcher.scheduleParticipantsUpdate("room-1"); + dispatcher.scheduleParticipantsUpdate("room-1"); + + verify(roomOperations, org.mockito.Mockito.timeout(1_000).times(1)) + .sendEvent(eq("participantsUpdate"), any()); + verify(mongoTemplate, times(1)).findOne(any(Query.class), eq(Room.class)); + } +} diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index 862048be..6a38e3a8 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -12,8 +12,11 @@ springdoc.swagger-ui.path=/api/swagger-ui.html spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=5MB server.tomcat.max-http-form-post-size=5MB +aws.s3.bucket=test-bucket +aws.s3.region=ap-northeast-2 +aws.s3.endpoint= +aws.s3.path-style-access=false 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..68580a95 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -1,3 +1,4 @@ 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_FILE_UPLOAD_MODE=server diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index f50a3b6c..531ba7dc 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -2,6 +2,9 @@ FROM node:22-alpine AS builder WORKDIR /repo +ARG NEXT_PUBLIC_FILE_UPLOAD_MODE=server +ENV NEXT_PUBLIC_FILE_UPLOAD_MODE=$NEXT_PUBLIC_FILE_UPLOAD_MODE + ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 47bfccf8..a3379269 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -37,6 +37,7 @@ cp .env.example .env.local PORT=3000 NEXT_PUBLIC_API_URL=http://localhost:5001 NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 +NEXT_PUBLIC_FILE_UPLOAD_MODE=server ``` > `.env.local`은 프로덕션 빌드(`build:production`)에서도 `.env.production`보다 @@ -45,6 +46,7 @@ NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 **환경 변수 설명:** - `NEXT_PUBLIC_API_URL`: 백엔드 REST API 서버 주소 - `NEXT_PUBLIC_SOCKET_URL`: Socket.IO 서버 주소 +- `NEXT_PUBLIC_FILE_UPLOAD_MODE`: `server`(기본 multipart) 또는 `mirror`(multipart 성공 후 S3 보조 복제) 서버환경에서 실행시 Route 53 에 등록한 도메인을 입력하세요. 예: `https://chat.goorm-ktb-[번호].goorm.team` @@ -116,6 +118,9 @@ make deploy DEPLOY_PATH=/opt/ktb-chat-frontend ```bash docker build -t chat-app-frontend . + +# 운영 S3 직접 업로드 이미지 +docker build --build-arg NEXT_PUBLIC_FILE_UPLOAD_MODE=mirror -t chat-app-frontend . ``` > **참고**: `NEXT_PUBLIC_*` 환경 변수는 빌드 시점에 코드에 인라인됩니다. diff --git a/apps/frontend/app/login/page.js b/apps/frontend/app/login/page.js deleted file mode 100644 index 925c8c89..00000000 --- a/apps/frontend/app/login/page.js +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; - -const LoginRedirectPage = () => { - const router = useRouter(); - - useEffect(() => { - const queryString = window.location.search; - router.replace(queryString ? `/${queryString}` : '/'); - }, [router]); - - return null; -}; - -export default LoginRedirectPage; diff --git a/apps/frontend/components/ChatHeader.js b/apps/frontend/components/ChatHeader.js index 8dc55672..f6c0a6e0 100644 --- a/apps/frontend/components/ChatHeader.js +++ b/apps/frontend/components/ChatHeader.js @@ -31,6 +31,7 @@ const ChatHeader = () => { Chat App Logo diff --git a/apps/frontend/components/ChatInput.js b/apps/frontend/components/ChatInput.js index 63603ac1..0911b1db 100644 --- a/apps/frontend/components/ChatInput.js +++ b/apps/frontend/components/ChatInput.js @@ -46,6 +46,7 @@ const ChatInput = forwardRef(({ const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); + const [submissionStatus, setSubmissionStatus] = useState('idle'); const [isDragging, setIsDragging] = useState(false); const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 }); @@ -109,7 +110,10 @@ const ChatInput = forwardRef(({ throw new Error('파일이 선택되지 않았습니다.'); } - onSubmit({ + setUploading(true); + setSubmissionStatus('uploading-and-waiting-for-message'); + setUploadError(null); + await onSubmit({ type: 'file', content: message.trim(), fileData: file @@ -119,19 +123,29 @@ const ChatInput = forwardRef(({ setShowEmojiPicker(false); setShowMentionList(false); setFiles([]); + setSubmissionStatus('complete'); } catch (error) { console.error('File submit error:', error); setUploadError(error.message); + setSubmissionStatus('failed'); + } finally { + setUploading(false); } } else if (message.trim()) { - onSubmit({ - type: 'text', - content: message.trim() - }); - setMessage(''); - setShowEmojiPicker(false); - setShowMentionList(false); + try { + setSubmissionStatus('waiting-for-message'); + await onSubmit({ + type: 'text', + content: message.trim() + }); + setMessage(''); + setShowEmojiPicker(false); + setShowMentionList(false); + setSubmissionStatus('complete'); + } catch (error) { + setSubmissionStatus('failed'); + } } }, [files, message, onSubmit, setMessage, setShowEmojiPicker, setShowMentionList]); @@ -403,6 +417,9 @@ const ChatInput = forwardRef(({ )} + + {submissionStatus} +