From 04fed990ee006cbb54458100526ba12b27117f85 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Mon, 10 Aug 2026 14:09:43 +0900 Subject: [PATCH 01/32] docs: add agent coding guidelines --- AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 AGENTS.md 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. From c5f14562f719893788dc335cb143739423d6bf5c Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 09:03:56 +0900 Subject: [PATCH 02/32] =?UTF-8?q?refactor:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B8=EC=A6=9D=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../com/ktb/chatapp/config/OpenApiConfig.java | 16 +- .../ktb/chatapp/config/SecurityConfig.java | 6 +- .../chatapp/controller/AuthController.java | 101 +++++------- .../com/ktb/chatapp/dto/LoginResponse.java | 1 - .../ktb/chatapp/dto/TokenRefreshResponse.java | 3 +- .../java/com/ktb/chatapp/model/Session.java | 13 -- .../chatapp/repository/SessionRepository.java | 13 -- .../security/AuthenticatedUserPrincipal.java | 31 ++++ .../security/CustomBearerTokenResolver.java | 1 - .../ktb/chatapp/service/SessionService.java | 45 ++---- .../service/UserDetailsServiceImpl.java | 10 +- .../service/session/SessionMongoStore.java | 41 ----- .../service/session/SessionRedisStore.java | 148 ++++++++++++++++++ .../chatapp/service/session/SessionStore.java | 7 + .../socketio/AuthTokenListenerImpl.java | 10 +- .../socketio/SocketIOEventListener.java | 11 +- .../socketio/handler/ChatMessageHandler.java | 17 -- .../controller/OpenApiContractTest.java | 2 +- .../chatapp/service/SessionServiceTest.java | 51 ++---- .../service/SessionServiceUnitTest.java | 50 +++++- .../socketio/SocketIOEventListenerTest.java | 5 + .../handler/ChatMessageHandlerTest.java | 10 -- apps/frontend/components/FileMessage.js | 16 +- apps/frontend/contexts/AuthContext.js | 8 +- .../files/__tests__/useChatFileUpload.test.js | 4 +- .../features/chat/files/useChatFileUpload.js | 3 +- .../room/__tests__/useMessageHandling.test.js | 1 - .../room/__tests__/useRoomHandling.test.js | 1 - .../features/chat/room/useFileHandling.js | 3 +- .../features/chat/room/useRoomHandling.js | 5 +- .../rooms/__tests__/ChatRoomsView.test.js | 1 - .../rooms/__tests__/useRoomsSocket.test.js | 1 - .../features/chat/rooms/useRoomsSocket.js | 1 - .../frontend/lib/api/__tests__/client.test.js | 12 +- apps/frontend/lib/api/client.js | 3 +- apps/frontend/lib/socket/SocketProvider.js | 5 +- .../socket/__tests__/SocketProvider.test.js | 2 - apps/frontend/pages/profile.js | 3 +- apps/frontend/services/authService.js | 15 +- apps/frontend/services/fileService.js | 9 +- loadtest/load-test.js | 6 +- loadtest/ramp-up-test.js | 35 ++--- 43 files changed, 369 insertions(+), 359 deletions(-) create mode 100644 .gitignore delete mode 100644 apps/backend/src/main/java/com/ktb/chatapp/repository/SessionRepository.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/security/AuthenticatedUserPrincipal.java delete mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/frontend/components/FileMessage.js b/apps/frontend/components/FileMessage.js index 3af1edc9..9b75b5a2 100644 --- a/apps/frontend/components/FileMessage.js +++ b/apps/frontend/components/FileMessage.js @@ -29,14 +29,14 @@ const FileMessage = ({ const messageDomRef = useRef(null); useEffect(() => { if (msg?.file) { - const url = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); + const url = fileService.getPreviewUrl(msg.file, user?.token, true); setPreviewUrl(url); console.debug('Preview URL generated:', { filename: msg.file.filename, url }); } - }, [msg?.file, user?.token, user?.sessionId]); + }, [msg?.file, user?.token]); if (!msg?.file) { console.error('File data is missing:', msg); @@ -105,12 +105,12 @@ const FileMessage = ({ throw new Error('파일 정보가 없습니다.'); } - if (!user?.token || !user?.sessionId) { + if (!user?.token) { throw new Error('인증 정보가 없습니다.'); } const baseUrl = fileService.getFileUrl(msg.file.filename, false); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}&download=true`; + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&download=true`; const iframe = document.createElement('iframe'); iframe.style.display = 'none'; @@ -137,12 +137,12 @@ const FileMessage = ({ throw new Error('파일 정보가 없습니다.'); } - if (!user?.token || !user?.sessionId) { + if (!user?.token) { throw new Error('인증 정보가 없습니다.'); } const baseUrl = fileService.getFileUrl(msg.file.filename, true); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}`; + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}`; const newWindow = window.open(authenticatedUrl, '_blank'); if (!newWindow) { @@ -165,11 +165,11 @@ const FileMessage = ({ ); } - if (!user?.token || !user?.sessionId) { + if (!user?.token) { throw new Error('인증 정보가 없습니다.'); } - const previewUrl = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); + const previewUrl = fileService.getPreviewUrl(msg.file, user?.token, true); return (
diff --git a/apps/frontend/contexts/AuthContext.js b/apps/frontend/contexts/AuthContext.js index 8d8c0d5c..1622f1d7 100644 --- a/apps/frontend/contexts/AuthContext.js +++ b/apps/frontend/contexts/AuthContext.js @@ -109,7 +109,7 @@ export const AuthProviderWithRouter = ({ children, router }) => { const logout = useCallback(async () => { try { // authService를 통해 로그아웃 API 호출 - await authService.logout(user?.token, user?.sessionId); + await authService.logout(user?.token); } catch (error) { console.error('Logout error:', error); } finally { @@ -136,15 +136,13 @@ export const AuthProviderWithRouter = ({ children, router }) => { const updatedUserData = await authService.updateProfile( updates, - user.token, - user.sessionId + user.token ); const updatedUser = { ...user, ...updatedUserData, token: user.token, - sessionId: user.sessionId, lastActivity: Date.now() }; @@ -171,7 +169,7 @@ export const AuthProviderWithRouter = ({ children, router }) => { // 토큰 검증 const verifyToken = useCallback(async () => { try { - if (!user?.token || !user?.sessionId) { + if (!user?.token) { throw new Error('No authentication data found'); } diff --git a/apps/frontend/features/chat/files/__tests__/useChatFileUpload.test.js b/apps/frontend/features/chat/files/__tests__/useChatFileUpload.test.js index f32fcb85..f3bc5833 100644 --- a/apps/frontend/features/chat/files/__tests__/useChatFileUpload.test.js +++ b/apps/frontend/features/chat/files/__tests__/useChatFileUpload.test.js @@ -29,7 +29,7 @@ describe('useChatFileUpload', () => { await act(async () => { response = await result.current.uploadChatFile( { name: 'sample.pdf' }, - { token: 'token-1', sessionId: 'session-1' } + { token: 'token-1' } ); }); @@ -51,7 +51,7 @@ describe('useChatFileUpload', () => { try { await result.current.uploadChatFile( { name: 'sample.pdf' }, - { token: 'token-1', sessionId: 'session-1' } + { token: 'token-1' } ); } catch (error) { uploadError = error; diff --git a/apps/frontend/features/chat/files/useChatFileUpload.js b/apps/frontend/features/chat/files/useChatFileUpload.js index 3dc96318..0a47397e 100644 --- a/apps/frontend/features/chat/files/useChatFileUpload.js +++ b/apps/frontend/features/chat/files/useChatFileUpload.js @@ -22,8 +22,7 @@ export const useChatFileUpload = () => { const uploadResponse = await fileService.uploadFile( file, (progress) => setUploadProgress(progress), - currentUser.token, - currentUser.sessionId + currentUser.token ); if (!uploadResponse.success) { diff --git a/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js b/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js index 447be2a3..ab6434b0 100644 --- a/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js +++ b/apps/frontend/features/chat/room/__tests__/useMessageHandling.test.js @@ -30,7 +30,6 @@ const roomId = 'room-1'; const currentUser = { token: 'token-1', - sessionId: 'session-1', }; describe('useMessageHandling', () => { diff --git a/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js b/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js index 14601bdf..78a2e6d3 100644 --- a/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js +++ b/apps/frontend/features/chat/room/__tests__/useRoomHandling.test.js @@ -9,7 +9,6 @@ const authMocks = vi.hoisted(() => ({ user: { id: 'user-1', token: 'token-1', - sessionId: 'session-1', name: 'Tester', email: 'tester@example.com', }, diff --git a/apps/frontend/features/chat/room/useFileHandling.js b/apps/frontend/features/chat/room/useFileHandling.js index 9c0b52de..0e62f8fa 100644 --- a/apps/frontend/features/chat/room/useFileHandling.js +++ b/apps/frontend/features/chat/room/useFileHandling.js @@ -29,8 +29,7 @@ export const useFileHandling = (currentUser, roomId, handleSessionError) => { const uploadResponse = await fileService.uploadFile( file, (progress) => setUploadProgress(progress), - currentUser.token, - currentUser.sessionId + currentUser.token ); if (!uploadResponse.success) { diff --git a/apps/frontend/features/chat/room/useRoomHandling.js b/apps/frontend/features/chat/room/useRoomHandling.js index 1c42c1c8..76ec17df 100644 --- a/apps/frontend/features/chat/room/useRoomHandling.js +++ b/apps/frontend/features/chat/room/useRoomHandling.js @@ -135,7 +135,7 @@ export const useRoomHandling = ({ const setupSocket = useCallback(async () => { try { - if (!user?.token || !user?.sessionId) { + if (!user?.token) { throw new Error('Invalid authentication state'); } @@ -167,7 +167,6 @@ export const useRoomHandling = ({ const socket = await socketClient.connect({ auth: { token: user.token, - sessionId: user.sessionId, }, transports: ['websocket', 'polling'], reconnection: true, @@ -193,7 +192,7 @@ export const useRoomHandling = ({ const fetchRoomData = useCallback( async (roomId) => { try { - if (!user?.token || !user?.sessionId) { + if (!user?.token) { await handleSessionError(); throw new Error('인증 정보가 유효하지 않습니다.'); } diff --git a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js index 5fec65b9..204be521 100644 --- a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js @@ -17,7 +17,6 @@ vi.mock('@/contexts/AuthContext', () => ({ user: { id: 'user-1', token: 'token-1', - sessionId: 'session-1', }, }), })); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js index c8106e6f..a8aeabd3 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js @@ -11,7 +11,6 @@ vi.mock('@/lib/socket/socketClient', () => ({ const currentUser = { token: 'token-1', - sessionId: 'session-1', }; const renderRoomsSocket = (socket, overrides = {}) => { diff --git a/apps/frontend/features/chat/rooms/useRoomsSocket.js b/apps/frontend/features/chat/rooms/useRoomsSocket.js index 6290d3c2..1cf1a997 100644 --- a/apps/frontend/features/chat/rooms/useRoomsSocket.js +++ b/apps/frontend/features/chat/rooms/useRoomsSocket.js @@ -25,7 +25,6 @@ export const useRoomsSocket = ({ .connect({ auth: { token: currentUser.token, - sessionId: currentUser.sessionId, }, }) .catch((err) => { diff --git a/apps/frontend/lib/api/__tests__/client.test.js b/apps/frontend/lib/api/__tests__/client.test.js index 983eacb7..78b18e93 100644 --- a/apps/frontend/lib/api/__tests__/client.test.js +++ b/apps/frontend/lib/api/__tests__/client.test.js @@ -16,11 +16,9 @@ describe('api client', () => { expect( getAuthHeaders({ token: 'token-1', - sessionId: 'session-1', }) ).toEqual({ - 'x-auth-token': 'token-1', - 'x-session-id': 'session-1', + Authorization: 'Bearer token-1', }); }); @@ -29,7 +27,6 @@ describe('api client', () => { baseURL: 'http://api.test', getSession: () => ({ token: 'token-1', - sessionId: 'session-1', }), }); @@ -43,8 +40,7 @@ describe('api client', () => { const response = await client.get('/api/rooms'); - expect(readHeader(response.config.headers, 'x-auth-token')).toBe('token-1'); - expect(readHeader(response.config.headers, 'x-session-id')).toBe('session-1'); + expect(readHeader(response.config.headers, 'Authorization')).toBe('Bearer token-1'); }); it('respects skipAuth requests', async () => { @@ -52,7 +48,6 @@ describe('api client', () => { baseURL: 'http://api.test', getSession: () => ({ token: 'token-1', - sessionId: 'session-1', }), }); @@ -66,8 +61,7 @@ describe('api client', () => { const response = await client.post('/api/auth/login', {}, { skipAuth: true }); - expect(readHeader(response.config.headers, 'x-auth-token')).toBeUndefined(); - expect(readHeader(response.config.headers, 'x-session-id')).toBeUndefined(); + expect(readHeader(response.config.headers, 'Authorization')).toBeUndefined(); }); it('clears stored users on auth expiration', async () => { diff --git a/apps/frontend/lib/api/client.js b/apps/frontend/lib/api/client.js index 7e0f7c42..362e895e 100644 --- a/apps/frontend/lib/api/client.js +++ b/apps/frontend/lib/api/client.js @@ -23,8 +23,7 @@ export const getAuthHeaders = (session = loadStoredUser()) => { } return { - 'x-auth-token': session.token, - ...(session.sessionId ? { 'x-session-id': session.sessionId } : {}), + Authorization: `Bearer ${session.token}`, }; }; diff --git a/apps/frontend/lib/socket/SocketProvider.js b/apps/frontend/lib/socket/SocketProvider.js index 69966a24..0214f542 100644 --- a/apps/frontend/lib/socket/SocketProvider.js +++ b/apps/frontend/lib/socket/SocketProvider.js @@ -8,11 +8,10 @@ export const SocketProvider = ({ children, client = socketClient, session = null useEffect(() => { const handleOnline = () => { - if (!client.isConnected() && session?.token && session?.sessionId) { + if (!client.isConnected() && session?.token) { client.connect({ auth: { token: session.token, - sessionId: session.sessionId, }, }).catch(() => {}); } @@ -29,7 +28,7 @@ export const SocketProvider = ({ children, client = socketClient, session = null window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; - }, [client, session?.sessionId, session?.token]); + }, [client, session?.token]); return ( diff --git a/apps/frontend/lib/socket/__tests__/SocketProvider.test.js b/apps/frontend/lib/socket/__tests__/SocketProvider.test.js index fc855e42..8cf6f3b7 100644 --- a/apps/frontend/lib/socket/__tests__/SocketProvider.test.js +++ b/apps/frontend/lib/socket/__tests__/SocketProvider.test.js @@ -36,7 +36,6 @@ describe('SocketProvider', () => { renderProvider(client, { token: 'token-1', - sessionId: 'session-1', }); act(() => { @@ -46,7 +45,6 @@ describe('SocketProvider', () => { expect(client.connect).toHaveBeenCalledWith({ auth: { token: 'token-1', - sessionId: 'session-1', }, }); }); diff --git a/apps/frontend/pages/profile.js b/apps/frontend/pages/profile.js index 46e3d842..d85ef900 100644 --- a/apps/frontend/pages/profile.js +++ b/apps/frontend/pages/profile.js @@ -56,8 +56,7 @@ const Profile = () => { await authService.changePassword( '', formData.newPassword, - user.token, - user.sessionId + user.token ); } diff --git a/apps/frontend/services/authService.js b/apps/frontend/services/authService.js index d8a4bb9b..e9ec5004 100644 --- a/apps/frontend/services/authService.js +++ b/apps/frontend/services/authService.js @@ -51,8 +51,7 @@ class AuthService { name: response.data.user.name, email: response.data.user.email, profileImage: response.data.user.profileImage, - token: response.data.token, - sessionId: response.data.sessionId + token: response.data.token }; return userData; @@ -83,11 +82,11 @@ class AuthService { * 로그아웃 API 호출 * 상태 관리와 리다이렉션은 AuthContext에서 처리 */ - async logout(token, sessionId) { + async logout(token) { try { if (token) { await api.post('/api/auth/logout', null, { - headers: getAuthHeaders({ token, sessionId }) + headers: getAuthHeaders({ token }) }); } } catch (error) { @@ -117,7 +116,7 @@ class AuthService { * 프로필 업데이트 API 호출 * 상태 업데이트는 AuthContext에서 처리 */ - async updateProfile(data, token, sessionId) { + async updateProfile(data, token) { try { if (!token) { throw new Error('인증 정보가 없습니다.'); @@ -127,7 +126,7 @@ class AuthService { '/api/users/profile', data, { - headers: getAuthHeaders({ token, sessionId }) + headers: getAuthHeaders({ token }) } ); @@ -149,7 +148,7 @@ class AuthService { /** * 비밀번호 변경 API 호출 */ - async changePassword(currentPassword, newPassword, token, sessionId) { + async changePassword(currentPassword, newPassword, token) { try { if (!token) { throw new Error('인증 정보가 없습니다.'); @@ -162,7 +161,7 @@ class AuthService { newPassword }, { - headers: getAuthHeaders({ token, sessionId }) + headers: getAuthHeaders({ token }) } ); diff --git a/apps/frontend/services/fileService.js b/apps/frontend/services/fileService.js index 97d48c45..f77a1d11 100644 --- a/apps/frontend/services/fileService.js +++ b/apps/frontend/services/fileService.js @@ -74,7 +74,7 @@ class FileService { return { success: true }; } - async uploadFile(file, onProgress, token, sessionId) { + async uploadFile(file, onProgress, token) { const validationResult = await this.validateFile(file); if (!validationResult.success) { return validationResult; @@ -91,7 +91,7 @@ class FileService { `${this.baseUrl}/api/files/upload` : '/api/files/upload'; - // token과 sessionId는 axios 인터셉터에서 자동으로 추가되므로 + // token은 axios 인터셉터에서 자동으로 추가되므로 // 여기서는 명시적으로 전달하지 않아도 됩니다 const response = await axiosInstance.post(uploadUrl, formData, { headers: { @@ -157,19 +157,18 @@ class FileService { return `${baseUrl}/api/files/${endpoint}/${filename}`; } - getPreviewUrl(file, token, sessionId, withAuth = true) { + getPreviewUrl(file, token, withAuth = true) { if (!file?.filename) return ''; const baseUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/files/view/${file.filename}`; if (!withAuth) return baseUrl; - if (!token || !sessionId) return baseUrl; + if (!token) return baseUrl; // URL 객체 생성 전 프로토콜 확인 const url = new URL(baseUrl); url.searchParams.append('token', encodeURIComponent(token)); - url.searchParams.append('sessionId', encodeURIComponent(sessionId)); return url.toString(); } diff --git a/loadtest/load-test.js b/loadtest/load-test.js index 4ba6795e..0c729d76 100644 --- a/loadtest/load-test.js +++ b/loadtest/load-test.js @@ -124,7 +124,7 @@ class LoadTester { // If login fails, try to register if (loginError.response?.status === 401 || loginError.response?.status === 404) { this.log('info', `Registering new user: ${email}`); - // register 응답은 token/sessionId가 null이라 소켓 인증에 쓸 수 없다 — 재로그인 필수 + // register 응답은 token이 없어 소켓 인증에 쓸 수 없다 — 재로그인 필수 await axios.post( `${this.config.apiUrl}/api/auth/register`, { email, password, name }, @@ -203,11 +203,11 @@ class LoadTester { return; } - const { token, sessionId, user } = authData; + const { token, user } = authData; // 3. Connect to Socket.IO const socket = io(this.config.socketUrl, { - auth: { token, sessionId }, + auth: { token }, transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: 3, diff --git a/loadtest/ramp-up-test.js b/loadtest/ramp-up-test.js index a3ba0dbf..9fff80c2 100644 --- a/loadtest/ramp-up-test.js +++ b/loadtest/ramp-up-test.js @@ -37,7 +37,7 @@ * a) 회원가입 (POST /api/auth/register) 또는 로그인 (POST /api/auth/login) * b) [REST API] 방 입장 (POST /api/rooms/:roomId/join) * c) [REST API] 방 정보 확인 (GET /api/rooms/:roomId) - * d) Socket.IO 연결 (with JWT token & sessionId) + * d) Socket.IO 연결 (with JWT token) * e) [WebSocket] 채팅방 참여 (joinRoom event) * f) [WebSocket] 이전 메시지 가져오기 (fetchPreviousMessages event → previousMessagesLoaded 수신) * g) 랜덤 간격으로 메시지 전송 (chatMessage event → message 브로드캐스트 수신) @@ -391,14 +391,13 @@ class RampUpLoadTester { return `${randomAdj} ${randomNoun} ${randomNum}`; } - async getUserProfile(token, sessionId, userId) { + async getUserProfile(token, userId) { try { const response = await axios.get( `${this.config.apiUrl}/api/users/profile`, { headers: { - 'x-auth-token': token, - 'x-session-id': sessionId + Authorization: `Bearer ${token}` }, timeout: 5000 } @@ -412,7 +411,7 @@ class RampUpLoadTester { } } - async updateUserProfile(token, sessionId, userId, newName) { + async updateUserProfile(token, userId, newName) { try { this.metrics.profileUpdatesAttempted++; const response = await axios.put( @@ -420,8 +419,7 @@ class RampUpLoadTester { { name: newName }, { headers: { - 'x-auth-token': token, - 'x-session-id': sessionId, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 5000 @@ -437,7 +435,7 @@ class RampUpLoadTester { } } - async uploadFileToChat(socket, token, sessionId, userId, roomId) { + async uploadFileToChat(socket, token, userId, roomId) { try { // Create form data with the image file const formData = new FormData(); @@ -452,8 +450,7 @@ class RampUpLoadTester { formData, { headers: { - 'x-auth-token': token, - 'x-session-id': sessionId, + Authorization: `Bearer ${token}`, ...formData.getHeaders() }, timeout: 10000, @@ -534,7 +531,7 @@ class RampUpLoadTester { return; } - const { token, sessionId, user } = authData; + const { token, user } = authData; // auth(login/register) 단계 완료 시점 (bcrypt CPU 힌트) onboardingStages.auth = Date.now() - onboardingStart; @@ -546,8 +543,7 @@ class RampUpLoadTester { {}, { headers: { - 'x-auth-token': token, - 'x-session-id': sessionId, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 5000 @@ -572,8 +568,7 @@ class RampUpLoadTester { `${this.config.apiUrl}/api/rooms/${roomId}`, { headers: { - 'x-auth-token': token, - 'x-session-id': sessionId, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 5000 @@ -592,7 +587,7 @@ class RampUpLoadTester { // Step 3: WebSocket Connection const socket = io(this.config.socketUrl, { - auth: { token, sessionId }, + auth: { token }, transports: ['websocket'], reconnection: false, forceNew: true, @@ -633,7 +628,7 @@ class RampUpLoadTester { }); // Start continuous message sending - this.startContinuousMessaging(socket, userId, roomId, token, sessionId); + this.startContinuousMessaging(socket, userId, roomId, token); }); socket.on(SERVER_EMIT.JOIN_ROOM_ERROR, (error) => { @@ -785,7 +780,7 @@ class RampUpLoadTester { } } - startContinuousMessaging(socket, userId, roomId, token, sessionId) { + startContinuousMessaging(socket, userId, roomId, token) { let messageCount = 0; const sendMessage = async () => { @@ -810,13 +805,13 @@ class RampUpLoadTester { if (messageCount % 10 === 0) { this.log('info', `User ${userId} reached ${messageCount} messages, updating profile...`); const newName = this.generateRandomName(); - await this.updateUserProfile(token, sessionId, userId, newName); + await this.updateUserProfile(token, userId, newName); } // Upload image to chat every 17 messages if (messageCount % 17 === 0) { this.log('info', `User ${userId} reached ${messageCount} messages, uploading image...`); - await this.uploadFileToChat(socket, token, sessionId, userId, roomId); + await this.uploadFileToChat(socket, token, userId, roomId); } } catch (error) { From 173f94c7d0beea43b0125bff61d5b69ee71ff75a Mon Sep 17 00:00:00 2001 From: jeongminju Date: Mon, 10 Aug 2026 23:13:27 +0900 Subject: [PATCH 03/32] perf(load): add rooms API k6 scenario --- performance/rooms-api/README.md | 20 +++++ performance/rooms-api/rooms-list.js | 134 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 performance/rooms-api/README.md create mode 100644 performance/rooms-api/rooms-list.js diff --git a/performance/rooms-api/README.md b/performance/rooms-api/README.md new file mode 100644 index 00000000..024320aa --- /dev/null +++ b/performance/rooms-api/README.md @@ -0,0 +1,20 @@ +# Rooms list HTTP-only load test + +This k6 scenario is deliberately separate from the Socket.IO scripts in `loadtest/`. +It logs in fixed accounts during `setup`, then measures only `GET /api/rooms` plus +the configured think time. It never creates users during a measured run. + +Prepare `loadtest-0@test.com` through `loadtest-29@test.com` once with password +`Test1234!`, then run: + +```sh +API_BASE_URL=http://localhost:5001 \ +TEST_NAME=rooms-before-1 \ +FIXTURE_ID=rooms321-users559-messages4781-sessions529 \ +VUS=10 DURATION=3m THINK_TIME_MS=1000 \ +k6 run performance/rooms-api/rooms-list.js +``` + +Required experiment metadata is printed as JSON at the end, including UTC/KST +timestamps, p95/p99, error rate, timeout count, and completed requests. Record the +matching Prometheus range for MongoDB pool checked-out and wait queue alongside it. diff --git a/performance/rooms-api/rooms-list.js b/performance/rooms-api/rooms-list.js new file mode 100644 index 00000000..5b9b6cac --- /dev/null +++ b/performance/rooms-api/rooms-list.js @@ -0,0 +1,134 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +const API_BASE_URL = __ENV.API_BASE_URL || 'http://localhost:5001'; +const TEST_NAME = __ENV.TEST_NAME || 'rooms-list-local'; +const FIXTURE_ID = __ENV.FIXTURE_ID || 'unspecified'; +const VUS = Number(__ENV.VUS || 10); +const DURATION = __ENV.DURATION || '3m'; +const THINK_TIME_MS = Number(__ENV.THINK_TIME_MS || 1000); +const ACCOUNT_START = Number(__ENV.ACCOUNT_START || 0); +const ACCOUNT_COUNT = Number(__ENV.ACCOUNT_COUNT || 30); +const PASSWORD = __ENV.LOAD_TEST_PASSWORD || 'Test1234!'; + +const roomDuration = new Trend('rooms_list_duration', true); +const roomErrors = new Rate('rooms_list_errors'); +const roomCompleted = new Counter('rooms_list_completed'); +const roomTimeouts = new Counter('rooms_list_timeouts'); + +export const options = { + vus: VUS, + duration: DURATION, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], + thresholds: { + rooms_list_errors: ['rate<=0.01'], + rooms_list_timeouts: ['count==0'], + }, +}; + +function account(index) { + return { + email: `loadtest-${ACCOUNT_START + index}@test.com`, + password: PASSWORD, + }; +} + +export function setup() { + if (VUS > ACCOUNT_COUNT) { + throw new Error(`VUS (${VUS}) must not exceed ACCOUNT_COUNT (${ACCOUNT_COUNT})`); + } + + const tokens = []; + for (let index = 0; index < ACCOUNT_COUNT; index += 1) { + const response = http.post( + `${API_BASE_URL}/api/auth/login`, + JSON.stringify(account(index)), + { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + tags: { phase: 'setup', endpoint: 'login' }, + }, + ); + + if (response.status !== 200) { + throw new Error( + `Fixed account ${account(index).email} is unavailable (HTTP ${response.status}). ` + + 'Prepare accounts once before the measured run.', + ); + } + + const token = response.json('token'); + if (!token) { + throw new Error(`Login response for ${account(index).email} did not contain a token`); + } + tokens.push(token); + } + + return { tokens }; +} + +export default function (data) { + const token = data.tokens[(__VU - 1) % data.tokens.length]; + const response = http.get(`${API_BASE_URL}/api/rooms`, { + headers: { Authorization: `Bearer ${token}` }, + timeout: '30s', + tags: { phase: 'measurement', endpoint: 'rooms-list' }, + }); + + const success = check(response, { + 'rooms list returned HTTP 200': (res) => res.status === 200, + 'rooms list preserved success contract': (res) => res.json('success') === true, + 'rooms list preserved data contract': (res) => Array.isArray(res.json('data')), + }); + + roomDuration.add(response.timings.duration); + roomErrors.add(!success); + roomCompleted.add(1); + if (response.error_code === 1050 || response.timings.duration >= 30000) { + roomTimeouts.add(1); + } + + sleep(THINK_TIME_MS / 1000); +} + +function metricValue(data, metric, field, fallback = 0) { + return data.metrics[metric] && data.metrics[metric].values[field] !== undefined + ? data.metrics[metric].values[field] + : fallback; +} + +function formatKst(date) { + const kst = new Date(date.getTime() + (9 * 60 * 60 * 1000)); + return kst.toISOString().replace('T', ' ').replace('Z', ' KST'); +} + +export function handleSummary(data) { + const endedAt = new Date(); + const durationMs = metricValue(data, 'iteration_duration', 'avg') + ? data.state.testRunDurationMs + : 0; + const startedAt = new Date(endedAt.getTime() - durationMs); + const summary = { + testName: TEST_NAME, + fixtureId: FIXTURE_ID, + apiBaseUrl: API_BASE_URL, + model: 'HTTP-only rooms list', + vus: VUS, + duration: DURATION, + thinkTimeMs: THINK_TIME_MS, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + startedAtKst: formatKst(startedAt), + endedAtKst: formatKst(endedAt), + completed: metricValue(data, 'rooms_list_completed', 'count'), + errorRate: metricValue(data, 'rooms_list_errors', 'rate'), + timeouts: metricValue(data, 'rooms_list_timeouts', 'count'), + p95Ms: metricValue(data, 'rooms_list_duration', 'p(95)'), + p99Ms: metricValue(data, 'rooms_list_duration', 'p(99)'), + }; + + return { + stdout: `${JSON.stringify(summary, null, 2)}\n`, + }; +} From 8a3a5433ec7d1273d7e06127be1686ccd07579d7 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Mon, 10 Aug 2026 23:16:05 +0900 Subject: [PATCH 04/32] perf(rooms): batch room list lookups --- .../chatapp/service/RecentMessageCounter.java | 41 ++++++ .../com/ktb/chatapp/service/RoomService.java | 64 ++++++++- .../RecentMessageCounterIntegrationTest.java | 66 +++++++++ .../ktb/chatapp/service/RoomServiceTest.java | 131 ++++++++++++++++++ 4 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterIntegrationTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java 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/RoomService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java index 5f5ecf40..61889789 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 @@ -10,9 +10,12 @@ 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; @@ -34,9 +37,39 @@ public class RoomService { public RoomsResponse getAllRooms(String name) { try { + List rooms = roomRepository.findAll(); + 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.findAllById(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); + // 전체 방을 조회해 최신순으로 정렬한다 - List roomResponses = roomRepository.findAll().stream() - .map(room -> mapToRoomResponse(room, name)) + List roomResponses = rooms.stream() + .map(room -> mapToRoomResponse( + room, + name, + usersById, + recentMessageCounts.getOrDefault(room.getId(), 0))) .sorted(Comparator.comparing( RoomResponse::getCreatedAtDateTime, Comparator.nullsLast(Comparator.reverseOrder()))) @@ -194,6 +227,33 @@ private RoomResponse mapToRoomResponse(Room room, String name) { 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/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/RoomServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java new file mode 100644 index 00000000..05a697c7 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -0,0 +1,131 @@ +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.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.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.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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; + +@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.findAll()).thenReturn(List.of()); + + RoomsResponse response = roomService.getAllRooms("viewer@test.com"); + + assertTrue(response.isSuccess()); + assertTrue(response.getData().isEmpty()); + verify(userRepository, never()).findAllById(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.findAll()).thenReturn(List.of(oldRoom, newRoom)); + when(userRepository.findAllById(anySet())).thenReturn(List.of(creator, participant)); + when(recentMessageCounter.countRecentMessages(anySet())) + .thenReturn(Map.of("room-old", 7)); + + RoomsResponse response = roomService.getAllRooms("viewer@test.com"); + + 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).findAllById(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")); + } + + 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(); + } +} From 46d9df878e824dbfd7988fe4ad4f1d7eb7c70559 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 11:17:16 +0900 Subject: [PATCH 05/32] perf(observe): add API bottleneck diagnostics --- .../dashboards/ktb-chat-monitoring.json | 70 +++++ .../monitoring/prometheus/prometheus.dev.yml | 17 +- .../config/MongoPoolMetricsConfig.java | 21 ++ e2e/artillery/README.md | 28 +- e2e/artillery/aggregate-observations.js | 65 +++++ e2e/artillery/all-scenarios.js | 18 +- e2e/artillery/observation.js | 229 +++++++++++++++ e2e/artillery/observation.test.js | 31 ++ e2e/artillery/report-observations.js | 33 +++ ...i-priority-20260811-profiler-evidence.json | 69 +++++ e2e/artillery/scenarios/auth.scenario.js | 18 +- e2e/artillery/scenarios/chat.scenario.js | 83 +++--- e2e/artillery/scenarios/profile.scenario.js | 25 +- loadtest/LOAD_TEST_PLAN.md | 273 ++++++++++++++++++ loadtest/load-test.js | 38 +++ performance/room-join/README.md | 15 + .../room-join/results/room-join-before-1.json | 16 + .../results/room-join-before-observed-1.json | 16 + .../results/room-join-pool-smoke.json | 16 + performance/room-join/room-join.js | 91 ++++++ 20 files changed, 1097 insertions(+), 75 deletions(-) create mode 100644 apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-monitoring.json create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/MongoPoolMetricsConfig.java create mode 100644 e2e/artillery/aggregate-observations.js create mode 100644 e2e/artillery/observation.js create mode 100644 e2e/artillery/observation.test.js create mode 100644 e2e/artillery/report-observations.js create mode 100644 e2e/artillery/results/e2e-api-priority-20260811-profiler-evidence.json create mode 100644 loadtest/LOAD_TEST_PLAN.md create mode 100644 performance/room-join/README.md create mode 100644 performance/room-join/results/room-join-before-1.json create mode 100644 performance/room-join/results/room-join-before-observed-1.json create mode 100644 performance/room-join/results/room-join-pool-smoke.json create mode 100644 performance/room-join/room-join.js 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/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/e2e/artillery/README.md b/e2e/artillery/README.md index b2bf11b1..230102a3 100644 --- a/e2e/artillery/README.md +++ b/e2e/artillery/README.md @@ -18,6 +18,32 @@ PHASE1_ARRIVAL_COUNT=10 PHASE1_DURATION=30 make artillery ``` +## Stage 0 관측 실행 + +브라우저 흐름을 부하 테스트로 올리기 전에 1 VU로 실제 호출 경로를 진단합니다. 이 실행은 +사용자 행동을 바꾸지 않고 HTTP API, Socket.IO 요청-완료 쌍, 화면 행동 완료시간을 각각 +수집합니다. + +```bash +BASE_URL=http://localhost:3000 \ +PHASE1_ARRIVAL_COUNT=1 PHASE1_DURATION=5 \ +OBSERVATION_RUN_ID=stage0-run1 \ +pnpm --filter e2e exec artillery run artillery/artillery-config.yaml + +node e2e/artillery/report-observations.js \ + e2e/artillery/results/stage0-run1 +``` + +각 VU의 원시 JSON과 합산 `summary.json`이 지정한 실행 디렉터리에 저장됩니다. 표는 +호출 수, 성공/실패, 평균, p95, p99, 누적시간 및 같은 계층 내 누적시간 비중을 보여 줍니다. +HTTP의 4xx/5xx는 실패로 집계되므로 의도된 로그인 실패 시나리오의 401은 행동 성공 여부와 +별도로 해석해야 합니다. Socket 표에는 연결, 방 입장, 과거 메시지 조회, 메시지 전송 완료가 +서로 섞이지 않습니다. 해당 실행이 `fetchPreviousMessages`를 보내지 않았다면 과거 메시지 행은 +생성되지 않습니다. + +`PHASE1_ARRIVAL_COUNT`는 지속 동시 VU가 아니라 phase 동안 생성할 총 사용자 수입니다. +Stage 0을 3회 완료하고 Mongo profiler/실행계획 근거까지 확보하기 전에는 VU를 올리지 않습니다. + ## 환경 변수 Artillery 실행 시 다음 환경 변수로 커스터마이징할 수 있습니다: @@ -215,4 +241,4 @@ - https://www.artillery.io/docs - https://playwright.dev/docs/intro - - https://www.artillery.io/docs/reference/engines/playwright \ No newline at end of file + - https://www.artillery.io/docs/reference/engines/playwright diff --git a/e2e/artillery/aggregate-observations.js b/e2e/artillery/aggregate-observations.js new file mode 100644 index 00000000..3188f00a --- /dev/null +++ b/e2e/artillery/aggregate-observations.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { summarizeSamples } = require('./observation'); + +const runDirectories = process.argv.slice(2).map((value) => path.resolve(value)); +if (runDirectories.length === 0) { + throw new Error('Usage: node aggregate-observations.js ...'); +} + +const reports = runDirectories.flatMap((runDirectory) => fs.readdirSync(runDirectory) + .filter((file) => file.startsWith('vu-') && file.endsWith('.json')) + .map((file) => JSON.parse(fs.readFileSync(path.join(runDirectory, file), 'utf8')))); + +const samples = { actions: [], http: [], socket: [] }; +for (const report of reports) { + samples.actions.push(...report.samples.actions); + samples.http.push(...report.samples.http); + samples.socket.push(...report.samples.socket); +} + +const timestamps = Object.values(samples).flat() + .flatMap((sample) => [sample.startedAt, sample.endedAt]) + .filter(Boolean) + .sort(); +const rankedHttp = summarizeSamples(samples.http.filter((sample) => + sample.name !== 'GET /api/health' && + !(sample.name === 'POST /api/auth/login' && sample.status === 401))); + +const aggregate = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + runIds: reports.map((report) => report.runId), + virtualUsers: reports.length, + startedAt: timestamps.at(0), + endedAt: timestamps.at(-1), + validity: { + completed: reports.filter((report) => report.samples.actions.length > 0 && + report.samples.actions.every((sample) => sample.success)).length, + failed: reports.filter((report) => report.samples.actions.some((sample) => !sample.success)).length, + allSamplesHaveWindowsAndNames: Object.values(samples).flat().every((sample) => + Boolean(sample.name && sample.startedAt && sample.endedAt)), + }, + summary: { + actions: summarizeSamples(samples.actions), + httpDiagnostic: summarizeSamples(samples.http), + httpRanked: rankedHttp, + socket: summarizeSamples(samples.socket), + }, + samples, +}; + +const outputPath = process.env.OBSERVATION_AGGREGATE_PATH + ? path.resolve(process.env.OBSERVATION_AGGREGATE_PATH) + : path.join(path.dirname(runDirectories[0]), 'aggregate.json'); +fs.writeFileSync(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`); +console.log(outputPath); +console.log(JSON.stringify({ + runIds: aggregate.runIds, + startedAt: aggregate.startedAt, + endedAt: aggregate.endedAt, + validity: aggregate.validity, + httpRanked: aggregate.summary.httpRanked, + socket: aggregate.summary.socket, +}, null, 2)); diff --git a/e2e/artillery/all-scenarios.js b/e2e/artillery/all-scenarios.js index c0c77e26..961c8b3e 100644 --- a/e2e/artillery/all-scenarios.js +++ b/e2e/artillery/all-scenarios.js @@ -8,6 +8,7 @@ const { const { fullProfileUpdateScenario, } = require('./scenarios/profile.scenario.js'); +const { createObservation } = require('./observation.js'); function generateUserSchema() { const timestamp = Date.now(); @@ -53,14 +54,19 @@ const allScenariosFlat = [ async function allScenarios(page, vuContext) { const testUser = generateUserSchema(); vuContext.vars.testUser = testUser; + vuContext.vars.observation = createObservation(page, vuContext); - for (const scenario of allScenariosFlat) { - try { - await scenario(page, vuContext); - } catch (err) { - err.message = `[${scenario.name}] ${err.message}`; - throw err; + try { + for (const scenario of allScenariosFlat) { + try { + await scenario(page, vuContext); + } catch (err) { + err.message = `[${scenario.name}] ${err.message}`; + throw err; + } } + } finally { + await vuContext.vars.observation.finish(); } } diff --git a/e2e/artillery/observation.js b/e2e/artillery/observation.js new file mode 100644 index 00000000..de15b9a3 --- /dev/null +++ b/e2e/artillery/observation.js @@ -0,0 +1,229 @@ +const fs = require('fs'); +const path = require('path'); +const { randomUUID } = require('crypto'); + +const OBJECT_ID = /^[a-f\d]{24}$/i; +const UUID = /^[a-f\d]{8}-[a-f\d]{4}-[1-5][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i; +const INTEGER = /^\d+$/; + +const SOCKET_PAIRS = { + joinRoom: { response: 'joinRoomSuccess', metric: 'room_join' }, + fetchPreviousMessages: { response: 'previousMessagesLoaded', metric: 'message_history' }, + chatMessage: { response: 'message', metric: 'message_send' }, +}; + +function percentile(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.ceil(ratio * sorted.length) - 1]; +} + +function normalizeApiPath(rawUrl) { + const url = new URL(rawUrl, 'http://localhost'); + const parts = url.pathname.split('/').filter(Boolean); + if (parts[0] !== 'api') return null; + + return `/${parts.map((part, index) => { + const previous = parts[index - 1]; + if ((previous === 'view' || previous === 'profiles') && parts[index - 2] === 'files') { + return '{filename}'; + } + if (OBJECT_ID.test(part) || UUID.test(part) || INTEGER.test(part)) { + if (previous === 'rooms') return '{roomId}'; + if (previous === 'users') return '{userId}'; + if (previous === 'messages') return '{messageId}'; + return '{id}'; + } + return part; + }).join('/')}`; +} + +function parseSocketEvent(payload) { + const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload); + const arrayStart = text.indexOf('['); + if (arrayStart === -1 || !/^4\d/.test(text)) return null; + try { + const packet = JSON.parse(text.slice(arrayStart)); + return Array.isArray(packet) && typeof packet[0] === 'string' ? packet[0] : null; + } catch { + return null; + } +} + +function summarizeSamples(samples) { + const groups = new Map(); + for (const sample of samples) { + const key = sample.name; + const group = groups.get(key) || { name: key, count: 0, success: 0, failure: 0, durations: [] }; + group.count += 1; + group[sample.success === false ? 'failure' : 'success'] += 1; + group.durations.push(sample.durationMs); + groups.set(key, group); + } + const grandTotalMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0); + return [...groups.values()].map((group) => { + const totalDurationMs = group.durations.reduce((sum, value) => sum + value, 0); + return { + name: group.name, + count: group.count, + success: group.success, + failure: group.failure, + averageMs: Number((totalDurationMs / group.count).toFixed(1)), + p95Ms: Number(percentile(group.durations, 0.95).toFixed(1)), + p99Ms: Number(percentile(group.durations, 0.99).toFixed(1)), + totalDurationMs: Number(totalDurationMs.toFixed(1)), + contributionPct: grandTotalMs === 0 ? 0 : Number(((totalDurationMs / grandTotalMs) * 100).toFixed(1)), + }; + }).sort((a, b) => b.totalDurationMs - a.totalDurationMs); +} + +function createObservation(page, vuContext) { + const startedRequests = new Map(); + const pendingSocketEvents = new Map(); + const samples = { http: [], socket: [], actions: [] }; + let currentAction = null; + let socketSequence = 0; + + const recordHttp = async (request, success) => { + const started = startedRequests.get(request); + startedRequests.delete(request); + if (!started) return; + const response = await request.response().catch(() => null); + samples.http.push({ + name: `${request.method()} ${started.path}`, + action: started.action, + durationMs: performance.now() - started.at, + startedAt: started.wallStartedAt, + endedAt: new Date().toISOString(), + success: success && response !== null && response.status() < 400, + status: response?.status() ?? null, + }); + }; + + const onRequest = (request) => { + const apiPath = normalizeApiPath(request.url()); + if (apiPath) startedRequests.set(request, { + at: performance.now(), wallStartedAt: new Date().toISOString(), path: apiPath, action: currentAction, + }); + }; + const onRequestFinished = (request) => { void recordHttp(request, true); }; + const onRequestFailed = (request) => { void recordHttp(request, false); }; + const onWebSocket = (webSocket) => { + const connectionKey = `connection:${socketSequence += 1}`; + pendingSocketEvents.set(connectionKey, { + at: performance.now(), wallStartedAt: new Date().toISOString(), action: currentAction, + }); + + webSocket.on('framesent', ({ payload }) => { + const event = parseSocketEvent(payload); + const pair = SOCKET_PAIRS[event]; + if (!pair) return; + const queue = pendingSocketEvents.get(pair.response) || []; + queue.push({ + at: performance.now(), wallStartedAt: new Date().toISOString(), action: currentAction, metric: pair.metric, + }); + pendingSocketEvents.set(pair.response, queue); + }); + webSocket.on('framereceived', ({ payload }) => { + const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload); + if (text.startsWith('40')) { + const pending = pendingSocketEvents.get(connectionKey); + if (pending) { + samples.socket.push({ + name: 'connection', action: pending.action, durationMs: performance.now() - pending.at, + startedAt: pending.wallStartedAt, endedAt: new Date().toISOString(), success: true, + }); + pendingSocketEvents.delete(connectionKey); + } + } + const event = parseSocketEvent(payload); + const queue = pendingSocketEvents.get(event); + const pending = Array.isArray(queue) ? queue.shift() : null; + if (pending) { + samples.socket.push({ + name: pending.metric, action: pending.action, durationMs: performance.now() - pending.at, + startedAt: pending.wallStartedAt, endedAt: new Date().toISOString(), success: true, + }); + } + }); + }; + + page.on('request', onRequest); + page.on('requestfinished', onRequestFinished); + page.on('requestfailed', onRequestFailed); + page.on('websocket', onWebSocket); + + async function action(name, callback) { + const previousAction = currentAction; + currentAction = name; + const startedAt = performance.now(); + const wallStartedAt = new Date().toISOString(); + try { + const result = await callback(); + samples.actions.push({ + name, durationMs: performance.now() - startedAt, startedAt: wallStartedAt, + endedAt: new Date().toISOString(), success: true, + }); + return result; + } catch (error) { + samples.actions.push({ + name, durationMs: performance.now() - startedAt, startedAt: wallStartedAt, + endedAt: new Date().toISOString(), success: false, + }); + throw error; + } finally { + currentAction = previousAction; + } + } + + async function finish() { + await new Promise((resolve) => setImmediate(resolve)); + page.off('request', onRequest); + page.off('requestfinished', onRequestFinished); + page.off('requestfailed', onRequestFailed); + page.off('websocket', onWebSocket); + + const runId = process.env.OBSERVATION_RUN_ID || new Date().toISOString().replace(/[:.]/g, '-'); + const outputRoot = process.env.OBSERVATION_OUTPUT_DIR || path.resolve(__dirname, 'results'); + const runDirectory = path.join(outputRoot, runId); + fs.mkdirSync(runDirectory, { recursive: true }); + const report = { + schemaVersion: 1, + runId, + vuId: vuContext?._uid || vuContext?.vars?.$uuid || randomUUID(), + generatedAt: new Date().toISOString(), + samples, + summary: { + actions: summarizeSamples(samples.actions), + http: summarizeSamples(samples.http), + socket: summarizeSamples(samples.socket), + }, + }; + const filename = `vu-${process.pid}-${randomUUID()}.json`; + fs.writeFileSync(path.join(runDirectory, filename), `${JSON.stringify(report, null, 2)}\n`); + printSummary(report.summary); + return report; + } + + return { action, finish }; +} + +function printTable(title, rows) { + console.log(`\n[E2E observation] ${title}`); + console.table(rows.map(({ name, count, success, failure, averageMs, p95Ms, p99Ms, totalDurationMs, contributionPct }) => ({ + name, count, success, failure, averageMs, p95Ms, p99Ms, totalDurationMs, contributionPct, + }))); +} + +function printSummary(summary) { + printTable('User actions', summary.actions); + printTable('HTTP API (ranked by cumulative response time)', summary.http); + printTable('Socket.IO actions', summary.socket); +} + +module.exports = { + createObservation, + normalizeApiPath, + parseSocketEvent, + summarizeSamples, +}; diff --git a/e2e/artillery/observation.test.js b/e2e/artillery/observation.test.js new file mode 100644 index 00000000..320ecdd9 --- /dev/null +++ b/e2e/artillery/observation.test.js @@ -0,0 +1,31 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeApiPath, parseSocketEvent, summarizeSamples } = require('./observation'); + +test('normalizes dynamic API identifiers without changing static routes', () => { + assert.equal(normalizeApiPath('http://localhost:5001/api/rooms'), '/api/rooms'); + assert.equal(normalizeApiPath('http://localhost:5001/api/rooms/507f1f77bcf86cd799439011?x=1'), '/api/rooms/{roomId}'); + assert.equal(normalizeApiPath('http://localhost:5001/api/rooms/507f1f77bcf86cd799439011/join'), '/api/rooms/{roomId}/join'); + assert.equal(normalizeApiPath('http://localhost:5001/api/files/view/123_random.jpg'), '/api/files/view/{filename}'); + assert.equal(normalizeApiPath('http://localhost:3000/_next/static/a.js'), null); +}); + +test('extracts Socket.IO event names from websocket frames', () => { + assert.equal(parseSocketEvent('42["joinRoom","room-1"]'), 'joinRoom'); + assert.equal(parseSocketEvent('451-["message",{"content":"hello"}]'), 'message'); + assert.equal(parseSocketEvent('40{"sid":"abc"}'), null); + assert.equal(parseSocketEvent('2'), null); +}); + +test('summarizes and ranks samples by cumulative duration', () => { + const result = summarizeSamples([ + { name: 'fast-frequent', durationMs: 20, success: true }, + { name: 'fast-frequent', durationMs: 20, success: false }, + { name: 'slow-once', durationMs: 30, success: true }, + ]); + assert.equal(result[0].name, 'fast-frequent'); + assert.deepEqual(result[0], { + name: 'fast-frequent', count: 2, success: 1, failure: 1, + averageMs: 20, p95Ms: 20, p99Ms: 20, totalDurationMs: 40, contributionPct: 57.1, + }); +}); diff --git a/e2e/artillery/report-observations.js b/e2e/artillery/report-observations.js new file mode 100644 index 00000000..f3c1b652 --- /dev/null +++ b/e2e/artillery/report-observations.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { summarizeSamples } = require('./observation'); + +const runDirectory = path.resolve(process.argv[2] || 'artillery/results'); +const files = fs.readdirSync(runDirectory).filter((file) => file.startsWith('vu-') && file.endsWith('.json')); +if (files.length === 0) throw new Error(`No observation JSON files found in ${runDirectory}`); + +const reports = files.map((file) => JSON.parse(fs.readFileSync(path.join(runDirectory, file), 'utf8'))); +const samples = { actions: [], http: [], socket: [] }; +for (const report of reports) { + samples.actions.push(...report.samples.actions); + samples.http.push(...report.samples.http); + samples.socket.push(...report.samples.socket); +} + +const aggregate = { + schemaVersion: 1, + runId: reports[0].runId, + generatedAt: new Date().toISOString(), + virtualUsers: reports.length, + summary: { + actions: summarizeSamples(samples.actions), + http: summarizeSamples(samples.http), + socket: summarizeSamples(samples.socket), + }, +}; +fs.writeFileSync(path.join(runDirectory, 'summary.json'), `${JSON.stringify(aggregate, null, 2)}\n`); +for (const [name, rows] of Object.entries(aggregate.summary)) { + console.log(`\n[E2E observation aggregate] ${name}`); + console.table(rows); +} diff --git a/e2e/artillery/results/e2e-api-priority-20260811-profiler-evidence.json b/e2e/artillery/results/e2e-api-priority-20260811-profiler-evidence.json new file mode 100644 index 00000000..299eeda7 --- /dev/null +++ b/e2e/artillery/results/e2e-api-priority-20260811-profiler-evidence.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "sourceRunId": "e2e-api-priority-20260811-profiler-confirm", + "purpose": "Profiler confirmation for the HTTP cumulative-time winner; excluded from the three-run HTTP ranking", + "profilingLevel": 2, + "profileCollectionMaxBytes": 67108864, + "target": "POST /api/rooms/{roomId}/join", + "windows": [ + { + "action": "room_create", + "startedAt": "2026-08-11T01:56:08.266Z", + "endedAt": "2026-08-11T01:56:08.316Z", + "mongoCommandCount": 10, + "messageCollscanCount": 2, + "messageDocsExamined": 16222, + "sessionCollscanCount": 1, + "sessionDocsExamined": 536, + "userEmailCollscanCount": 1, + "userEmailDocsExamined": 566, + "userIdLookupCount": 4 + }, + { + "action": "room_join", + "startedAt": "2026-08-11T01:56:10.188Z", + "endedAt": "2026-08-11T01:56:10.444Z", + "mongoCommandCount": 11, + "messageCollscanCount": 2, + "messageDocsExamined": 16228, + "sessionCollscanCount": 1, + "sessionDocsExamined": 536, + "userEmailCollscanCount": 1, + "userEmailDocsExamined": 566, + "userIdLookupCount": 4 + }, + { + "action": "room_join_for_file", + "startedAt": "2026-08-11T01:56:15.043Z", + "endedAt": "2026-08-11T01:56:15.280Z", + "mongoCommandCount": 11, + "messageCollscanCount": 2, + "messageDocsExamined": 16252, + "sessionCollscanCount": 1, + "sessionDocsExamined": 536, + "userEmailCollscanCount": 1, + "userEmailDocsExamined": 566, + "userIdLookupCount": 4 + }, + { + "action": "room_join_for_forbidden_word", + "startedAt": "2026-08-11T01:56:17.725Z", + "endedAt": "2026-08-11T01:56:17.965Z", + "mongoCommandCount": 11, + "messageCollscanCount": 2, + "messageDocsExamined": 16258, + "sessionCollscanCount": 1, + "sessionDocsExamined": 536, + "userEmailCollscanCount": 1, + "userEmailDocsExamined": 566, + "userIdLookupCount": 4 + } + ], + "codeCorrelation": { + "duplicateResponseMapping": [ + "RoomService.joinRoom maps the joined room for RoomUpdatedEvent", + "RoomController.joinRoom maps the same room again for the HTTP response" + ], + "effectPerRequest": "Two recent-message counts and repeated creator/participant lookups" + } +} diff --git a/e2e/artillery/scenarios/auth.scenario.js b/e2e/artillery/scenarios/auth.scenario.js index 5b51a288..577eb006 100644 --- a/e2e/artillery/scenarios/auth.scenario.js +++ b/e2e/artillery/scenarios/auth.scenario.js @@ -13,22 +13,23 @@ const ACTION_TIMEOUT_SHORT = parseInt(process.env.ACTION_TIMEOUT_SHORT || '500', */ async function loginScenario(page, vuContext) { let testUser = vuContext.vars.testUser; + const observe = vuContext.vars.observation.action; try { // 1. 회원가입 - await registerAction(page, testUser); + await observe('register', () => registerAction(page, testUser)); await page.waitForTimeout(ACTION_TIMEOUT); // 2. 로그인 - await loginAction(page, testUser); + await observe('login', () => loginAction(page, testUser)); await expect(page).toHaveURL(`${BASE_URL}/chat`); // URL 만으로는 완주를 판정할 수 없다. 방 목록이 실제로 그려져야 // 로그인이 쓸모 있는 상태로 끝난 것이다(목록이 비어 있는 경우 포함). - await expect( - page.getByTestId('join-chat-room-button').first() - .or(page.getByTestId('rooms-empty')) - ).toBeVisible(); + await observe('room_list_display', () => expect( + page.getByTestId('join-chat-room-button').first() + .or(page.getByTestId('rooms-empty')) + ).toBeVisible()); // 컨텍스트에 사용자 정보 저장 (다른 시나리오에서 사용 가능) vuContext.vars.testUser = testUser; @@ -43,11 +44,12 @@ async function loginScenario(page, vuContext) { * 잘못된 로그인 시도 부하 테스트 */ async function failedLoginScenario(page, vuContext) { + const observe = vuContext.vars.observation.action; try { - await loginAction(page, { + await observe('failed_login', () => loginAction(page, { email: 'nonexistent@example.com', password: 'WrongPassword123!', - }, false); + }, false)); await page.waitForTimeout(500); const errorElement = page.getByTestId('login-error-message'); diff --git a/e2e/artillery/scenarios/chat.scenario.js b/e2e/artillery/scenarios/chat.scenario.js index 76be9abf..f78a1664 100644 --- a/e2e/artillery/scenarios/chat.scenario.js +++ b/e2e/artillery/scenarios/chat.scenario.js @@ -27,27 +27,29 @@ async function gotoChatPage(page, vuContext) { * Artillery 채팅방 생성 및 메시지 전송 시나리오 */ async function chatRoomCreationScenario(page, vuContext) { + const observe = vuContext.vars.observation.action; try { // 1. 채팅방 확인 await expect(page).toHaveURL(`${BASE_URL}/chat`); // 2. 채팅방 생성 const roomName = `부하테스트_${randomUUID()}`; - await createChatRoomAction(page, roomName); - await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await observe('room_create', async () => { + await createChatRoomAction(page, roomName); + await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); - // URL 이 바뀌어도 소켓이 안 붙으면 방은 로딩 스피너에 머문다. - // 입력창이 떠야 방에 들어온 것이고, 여기서 끊어야 다음 단계가 - // "입력창을 못 찾음"으로 30s 를 태우지 않는다. - await expect(page.getByTestId('chat-message-input')).toBeVisible(); + // URL 이 바뀌어도 소켓이 안 붙으면 방은 로딩 스피너에 머문다. + await expect(page.getByTestId('chat-message-input')).toBeVisible(); + }); // 3. 메시지 전송 const message = `테스트 메시지 ${bannedWordSafeText(Date.now())}`; - await sendMessageAction(page, message); - await page.waitForTimeout(ACTION_TIMEOUT_SHORT); - - const messageElement = page.getByTestId('message-content').filter({ hasText: message }); - await expect(messageElement).toBeVisible(); + await observe('message_send', async () => { + await sendMessageAction(page, message); + await page.waitForTimeout(ACTION_TIMEOUT_SHORT); + const messageElement = page.getByTestId('message-content').filter({ hasText: message }); + await expect(messageElement).toBeVisible(); + }); vuContext.vars.chatRoomUrl = page.url(); } catch (error) { @@ -60,14 +62,21 @@ async function chatRoomCreationScenario(page, vuContext) { * Artillery 메시지 대량 전송 시나리오 */ async function massMessageScenario(page, vuContext) { + const observe = vuContext.vars.observation.action; try { // 1. 랜덤 채팅방 입장 - await joinRandomChatRoomAction(page); - await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await observe('room_join', async () => { + await joinRandomChatRoomAction(page); + await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await expect(page.getByTestId('chat-message-input')).toBeVisible(); + }); // 2. 여러 메시지 연속 전송 (10개) console.log(`Sending ${MASS_MESSAGE_COUNT} messages...`); - await sendMultipleMessagesAction(page, MASS_MESSAGE_COUNT); + await observe('mass_message_send', async () => { + const messages = await sendMultipleMessagesAction(page, MASS_MESSAGE_COUNT); + await expect(page.getByTestId('message-content').filter({ hasText: messages.at(-1) })).toBeVisible(); + }); } catch (error) { console.error('Mass message scenario failed:', error.message); throw error; @@ -78,10 +87,14 @@ async function massMessageScenario(page, vuContext) { * Artillery 파일 업로드 시나리오 */ async function fileUploadScenario(page, vuContext) { + const observe = vuContext.vars.observation.action; try { // 1. 랜덤 채팅방 입장 - await joinRandomChatRoomAction(page); - await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await observe('room_join_for_file', async () => { + await joinRandomChatRoomAction(page); + await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await expect(page.getByTestId('chat-message-input')).toBeVisible(); + }); // 2. 이미지 파일 업로드 const filePath = path.resolve(__dirname, '../../fixtures/images/profile.jpg'); @@ -92,13 +105,13 @@ async function fileUploadScenario(page, vuContext) { { timeout: 15000 } ); - await uploadFileAction(page, filePath, message); - await uploadPromise; - - await page.waitForTimeout(ACTION_TIMEOUT); - - const fileMessageContainer = page.getByTestId('file-message-container').filter({ hasText: message }); - await expect(fileMessageContainer).toBeVisible({ timeout: 10000 }); + await observe('file_upload', async () => { + await uploadFileAction(page, filePath, message); + await uploadPromise; + await page.waitForTimeout(ACTION_TIMEOUT); + const fileMessageContainer = page.getByTestId('file-message-container').filter({ hasText: message }); + await expect(fileMessageContainer).toBeVisible({ timeout: 10000 }); + }); } catch (error) { console.error('File upload scenario failed:', error.message); throw error; @@ -110,6 +123,7 @@ async function fileUploadScenario(page, vuContext) { */ async function forbiddenWordScenario(page, vuContext) { const testUser = vuContext.vars.testUser; + const observe = vuContext.vars.observation.action; // NOTE: 환경변수에서 금칙어 목록을 가져오거나 기본값 사용 const FORBIDDEN_WORDS = process.env.FORBIDDEN_WORDS ? process.env.FORBIDDEN_WORDS @@ -120,20 +134,21 @@ async function forbiddenWordScenario(page, vuContext) { try { // 1. 랜덤 채팅방 입장 - await joinRandomChatRoomAction(page); - await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await observe('room_join_for_forbidden_word', async () => { + await joinRandomChatRoomAction(page); + await expect(page).toHaveURL(new RegExp(`${BASE_URL}/chat/\\w+`)); + await expect(page.getByTestId('chat-message-input')).toBeVisible(); + }); // 2. 금칙어 메시지 전송 시도 const forbiddenWord = FORBIDDEN_WORDS[Math.floor(Math.random() * FORBIDDEN_WORDS.length)]; - await sendMessageAction(page, forbiddenWord); - - // 3. 에러 토스트 확인 - const errorToast = page.getByTestId('toast-error'); - await expect(errorToast).toBeVisible({ timeout: 5000 }); - - // 4. 메시지가 전송되지 않았는지 확인 - const sentMessage = page.getByTestId('message-content').filter({ hasText: forbiddenWord }); - await expect(sentMessage).not.toBeVisible(); + await observe('forbidden_message_send', async () => { + await sendMessageAction(page, forbiddenWord); + const errorToast = page.getByTestId('toast-error'); + await expect(errorToast).toBeVisible({ timeout: 5000 }); + const sentMessage = page.getByTestId('message-content').filter({ hasText: forbiddenWord }); + await expect(sentMessage).not.toBeVisible(); + }); vuContext.vars.testUser = testUser; } catch (error) { diff --git a/e2e/artillery/scenarios/profile.scenario.js b/e2e/artillery/scenarios/profile.scenario.js index 5d33b676..758053ae 100644 --- a/e2e/artillery/scenarios/profile.scenario.js +++ b/e2e/artillery/scenarios/profile.scenario.js @@ -16,17 +16,20 @@ const ACTION_TIMEOUT_SHORT = parseInt(process.env.ACTION_TIMEOUT_SHORT || '500', * 이름 변경 + 이미지 업로드 */ async function fullProfileUpdateScenario(page, vuContext) { + const observe = vuContext.vars.observation.action; try { // 1. 프로필 페이지 이동 - await goToProfileAction(page); + await observe('profile_open', () => goToProfileAction(page)); await page.waitForTimeout(ACTION_TIMEOUT_SHORT); // 2. 이미지 업로드 - await page.getByTestId('profile-image-file-input').setInputFiles(PROFILE_IMAGE_PATH); - await page.waitForTimeout(ACTION_TIMEOUT); + await observe('profile_image_change', async () => { + await page.getByTestId('profile-image-file-input').setInputFiles(PROFILE_IMAGE_PATH); + await page.waitForTimeout(ACTION_TIMEOUT); + await expect(page.getByTestId('toast-success')).toBeVisible(); + }); // 2-1. 이미지 업로드 검증 - await expect(page.getByTestId('toast-success')).toBeVisible(); await page.waitForTimeout(ACTION_TIMEOUT_SHORT); // 3. 이름 변경 @@ -35,13 +38,13 @@ async function fullProfileUpdateScenario(page, vuContext) { await page.waitForTimeout(ACTION_TIMEOUT_SHORT); // 4. 저장 - await page.getByTestId('profile-save-button').click(); - await page.waitForTimeout(ACTION_TIMEOUT); - - // 5. 성공 확인 - await expect(page.getByTestId('profile-success-message')).toBeVisible(); - await expect(page.getByTestId('profile-name-input')).toHaveValue(newName); - await expect(page.getByTestId('profile-image-avatar')).toBeVisible(); + await observe('profile_name_change', async () => { + await page.getByTestId('profile-save-button').click(); + await page.waitForTimeout(ACTION_TIMEOUT); + await expect(page.getByTestId('profile-success-message')).toBeVisible(); + await expect(page.getByTestId('profile-name-input')).toHaveValue(newName); + await expect(page.getByTestId('profile-image-avatar')).toBeVisible(); + }); } catch (error) { console.error('Full profile update scenario failed:', error.message); throw error; diff --git a/loadtest/LOAD_TEST_PLAN.md b/loadtest/LOAD_TEST_PLAN.md new file mode 100644 index 00000000..be434434 --- /dev/null +++ b/loadtest/LOAD_TEST_PLAN.md @@ -0,0 +1,273 @@ +# KTB Bootcamp Chat 48시간 활성 사용자 수 최적화 계획서 + +## 1. 목표 + +48시간 안에 KTB Bootcamp Chat이 실제 채팅 행동을 수행하며 안정적으로 유지할 수 있는 **동시 활성 사용자 수**를 최대화한다. + +전체 API를 차례로 측정하지 않는다. 동일한 혼합 Ramp-up에서 최초 포화점을 찾고, 그 순간의 지연·오류·자원 지표로 가장 유력한 병목 하나를 선택해 다음 루프를 반복한다. + +```text +15분 이내 Ramp-up +→ 최초 포화 신호 확인 +→ 병목 하나 선택 +→ 최소 변경 +→ 같은 Ramp-up 재실행 +→ 개선되면 새 베이스라인, 아니면 변경 폐기 +``` + +로컬 결과는 운영 SLA나 절대 수용량으로 해석하지 않는다. 이 문서의 수용량은 같은 장비와 같은 워크로드에서 최적화 전후를 비교하기 위한 값이다. + +## 2. 활성 사용자와 수용량 정의 + +### 활성 사용자 + +다음 조건을 모두 만족하는 고유 VUser를 활성 사용자로 계산한다. + +- 고유 계정과 세션으로 인증됨 +- Socket.IO 연결이 유지됨 +- 하나의 채팅방에 입장함 +- 2~5초 간격으로 메시지를 보내고 수신함 +- 수신 메시지에 읽음 처리를 수행함 +- 일부 메시지에 리액션을 수행함 +- 간헐적으로 이전 메시지를 조회함 + +### 안정 수용량 + +아래 조건을 60초 연속 만족한 가장 높은 활성 사용자 수를 해당 회차의 안정 수용량으로 기록한다. + +- 연결 성공률 99% 이상 +- 예상하지 못한 오류율 1% 이하 +- 비정상 disconnect 1% 이하 +- 30초 이상 연속 timeout 없음 +- Socket 메시지 p95가 저부하 기준의 2배 미만 +- VUser 증가율 대비 처리량 증가율이 50% 이상 +- CPU 90%, heap 85%, Tomcat thread 90%, MongoDB connection pool 90%가 60초 이상 지속되지 않음 + +첫 위반이 3개의 연속된 5초 window에서 재현되면 포화 후보로 기록한다. 같은 조건의 3회 중 2회 이상 비슷한 사용자 수에서 재현되면 확정 포화점으로 인정한다. + +## 3. 고정 워크로드 + +모든 비교 실행에서 다음 조건을 고정한다. + +- 초당 10명의 사용자를 추가한다. +- 새 사용자 10명마다 전용 방 하나를 만들고 여러 방에 분산한다. +- 사용자마다 2~5초 간격으로 1KB text 메시지를 보낸다. +- 행동 비율은 메시지 60%, 읽음 20%, 이전 메시지 조회 10%, 리액션 5%, 방 관련 REST 5%로 유지한다. +- VU마다 고유 계정, JWT와 `x-session-id`를 사용한다. +- 애플리케이션 커밋, JVM 옵션, MongoDB·Redis 설정, 컨테이너 자원과 부하기 장비를 고정한다. +- 시작 seed의 계정 수, 방 수, 참가자 수와 누적 메시지 수를 기록한다. + +기본 `max-users`는 500이다. 포화 없이 도달하면 다음 실행에서 `1000 → 2000`처럼 두 배로 높인다. 포화점이 확인된 이후에는 직전 안정 수용량의 80%에서 시작해 주변 구간을 다시 측정한다. + +한 방 집중 부하는 전체 분산 수용량과 섞지 않는다. 최종 후보 검증 단계에서 별도의 회귀 시나리오로만 실행한다. + +## 4. 빠른 Ramp-up 실행 단위 + +각 실행은 최대 15분 안에 끝낸다. + +```text +Smoke 확인 +→ 60초 저부하 웜업 +→ 부하기 window와 counter 초기화 +→ measurementStart 기록 +→ 초당 10명 Ramp-up +→ 포화 확인 후 30초 관찰 또는 max-users 도달 후 60초 유지 +→ measurementEnd 기록 +→ 결과 저장과 병목 선정 +``` + +### 웜업 + +- 30 VUser로 60초간 본 측정과 같은 행동을 수행한다. +- JVM, connection pool과 주요 코드 경로를 준비하되 결과에는 포함하지 않는다. +- Ramp-up 시작 직전에 부하기 latency sample, 요청·오류 수와 처리량 window를 초기화한다. +- Prometheus는 `measurementStart`부터 `measurementEnd`까지만 결과 계산에 사용한다. +- 웜업 중 계약 실패나 예상하지 못한 오류가 발생하면 Ramp-up을 시작하지 않는다. + +### 자동 종료 + +- 포화가 확인되면 30초만 추가 관찰하고 종료한다. +- `max-users`에 도달하면 60초 유지 후 종료한다. +- 부하기 자체 오류, fixture 오염 또는 필수 관측 누락은 즉시 무효 처리한다. +- 오류 폭증으로 서버나 다음 실험의 복구가 어려워질 가능성이 있으면 즉시 연결을 정리한다. + +### 실행 시간 예산 + +| 단계 | 상한 | +|---|---:| +| 웜업 | 1분 | +| Ramp-up과 포화 후 관찰 | 6분 | +| 결과 수집과 병목 선정 | 3분 | +| 복구·재실행 여유 | 5분 | +| **합계** | **15분** | + +15분을 넘기는 원인은 기록한다. 장시간 안정성 검증이 아닌 탐색 실행에서 시간을 맞추기 위해 불필요하게 부하를 유지하지 않는다. + +## 5. 실행 전 부하기 준비 + +준비 작업은 48시간의 첫 3시간을 넘기지 않는다. + +### 현재 확인된 결손 + +- `ramp-up-test.js`가 요구하는 `breakpoint/windowed-metrics`와 `breakpoint/breakpoint-detector` 모듈이 현재 `loadtest` 디렉터리에 없다. +- `loadtest` 의존성이 설치되지 않은 상태에서는 `socket.io-client`를 찾지 못해 실행되지 않는다. +- 현재 지표는 REST와 Socket을 크게 묶어 보여 주므로 최초 포화 원인을 작업별로 구분하기 어렵다. + +### 최소 준비 범위 + +- 누락된 breakpoint 모듈과 의존성을 복구하고 기존 테스트를 통과시킨다. +- 웜업 60초 후 측정 window를 초기화하는 phase를 추가한다. +- `measurementStart`, `measurementEnd`, 활성 사용자 수와 포화 신호를 결과에 기록한다. +- 로그인, 방 REST, Socket 연결, `joinRoom`, 메시지, 읽음, 과거 조회, 리액션의 count·오류·p95를 분리한다. +- 5초 window timeline과 `breaking-point.json`을 항상 별도 실행 디렉터리에 저장한다. +- Prometheus target과 필수 지표의 수집 여부를 Smoke에서 확인한다. + +3시간 안에 세부 지표 분리가 끝나지 않으면 부하기 복구, 활성 사용자·Socket RTT·오류·disconnect·서버 자원 측정만으로 첫 Ramp-up을 시작한다. 부하기 완성도를 높이느라 최적화 시간을 소진하지 않는다. + +## 6. 최초 베이스라인 + +부하기가 실행되면 다음 순서로 3시간 안에 최초 확정 포화점을 만든다. + +1. 1~5 VUser Smoke로 REST와 Socket 계약을 확인한다. +2. `max-users=500`에서 표준 Ramp-up을 실행한다. +3. 포화가 없으면 `max-users`를 두 배로 높여 즉시 다시 실행한다. +4. 포화 후보가 발견되면 같은 설정으로 총 3회 실행한다. +5. 3회 중 2회 이상 비슷한 지점에서 재현된 가장 낮은 값을 최초 확정 포화점으로 기록한다. +6. 포화 시각 전후 60초의 부하기 timeline과 Prometheus 지표를 맞춰 첫 병목 가설을 하나만 선택한다. + +Smoke는 HTTP 상태뿐 아니라 응답 body, ID, 세션과 Socket payload를 검증한다. 준비 트래픽과 회원가입은 측정 결과에서 제외한다. + +## 7. 병목 선택 기준 + +포화 직전과 포화 시점의 변화를 다음 순서로 확인한다. + +1. 연결 실패, ping timeout, server disconnect +2. 메시지 RTT p95·p99와 초당 메시지 처리량 +3. `joinRoom`과 전체 onboarding p95 +4. 오류가 증가한 REST·Socket 작업 +5. CPU, heap·GC, thread·FD +6. MongoDB 명령 지연·connection pool +7. Redis 명령 지연·연결·메모리 +8. network와 disk I/O + +가장 먼저 악화된 신호와 코드 경로가 직접 연결되는 가설 하나만 다음 변경 대상으로 선택한다. 여러 최적화를 한 번에 적용하지 않는다. + +예시: + +- CPU와 로그인 p95가 먼저 상승하면 인증·BCrypt 경로를 조사한다. +- MongoDB 지연과 `GET /api/rooms`가 함께 상승하면 query 수와 실행 계획을 조사한다. +- 메시지 처리량이 정체되고 Socket RTT만 상승하면 저장·broadcast·읽음 갱신 경로를 조사한다. +- 연결 실패와 FD가 함께 상승하면 Socket·OS 연결 제한을 조사한다. +- heap·GC가 먼저 상승하면 메시지 fan-out 객체와 보관 구조를 조사한다. + +상관관계만으로 원인을 확정하지 않는다. 로그, profiler, MongoDB `explain` 등 가장 작은 진단으로 가설을 확인한 뒤 코드를 변경한다. + +## 8. 최적화 반복 루프 + +각 최적화는 다음 절차를 따른다. + +1. 확정 베이스라인 결과에서 병목 가설 하나를 선택한다. +2. 가설을 확인하는 읽기 전용 진단을 수행한다. +3. 병목에 직접 필요한 최소 변경만 구현한다. +4. 관련 단위·통합 테스트를 실행한다. +5. 동일한 seed, 증가율, 메시지 간격과 `max-users`로 표준 Ramp-up을 실행한다. +6. 안정 수용량이 증가하고 다른 핵심 지표가 회귀하지 않으면 채택한다. +7. 개선되지 않으면 변경을 폐기하고 다음 가설로 이동한다. +8. 채택된 결과를 새로운 베이스라인으로 삼는다. + +### 빠른 채택 기준 + +- 안정 수용량이 10% 이상 증가하거나, 같은 수용량에서 포화 신호가 사라짐 +- 오류율과 비정상 disconnect가 증가하지 않음 +- 메시지 p95·p99가 악화되지 않음 +- CPU, heap·GC, MongoDB·Redis 중 다른 계층으로 심각한 포화가 이동하지 않음 + +수용량 변화가 10% 미만이면 1회 추가 실행한다. 반복 결과가 측정 변동 범위 안이면 채택하지 않는다. + +## 9. 48시간 배분 + +| 경과 시간 | 작업 | 산출물 | +|---|---|---| +| 0~3시간 | 부하기 복구, 웜업 phase, Smoke | 실행 가능한 표준 Ramp-up | +| 3~6시간 | 베이스라인 탐색·3회 재현 | 최초 확정 포화점과 병목 가설 | +| 6~36시간 | 진단→한 가지 변경→15분 Ramp-up 반복 | 가능한 많은 검증된 수용량 개선 | +| 36~42시간 | 최종 후보 3회 재현, 한 방 집중·E2E 회귀 | 최종 안정 수용량 | +| 42~46시간 | 변경 전후 지표 정리, 테스트와 코드 정리 | 재현 가능한 결과와 최종 변경 | +| 46~48시간 | 실패·환경 복구·재측정 버퍼 | 누락 없는 최종 보고 | + +6~36시간에는 개선 횟수를 미리 정하지 않는다. 작고 근거가 강한 변경부터 처리하며, 진단이나 구현이 2시간 이상 막히면 다음 병목 가설로 전환한다. + +## 10. 최종 검증 + +마지막으로 채택된 코드에서 다음을 실행한다. + +- 표준 분산 Ramp-up 3회 +- 확정 안정 수용량에서 10분 E2E Constant 1회 +- 같은 방에 사용자가 집중되는 hot-room Ramp-up 1회 +- 안정 수용량의 2배를 30초 안에 투입하는 Spike 1회 + +최종 안정 수용량은 분산 Ramp-up 3회의 중앙값이 아니라, 안정 조건을 충족한 세 회차 중 가장 낮은 값으로 보수적으로 기록한다. E2E Constant 또는 회귀 테스트가 실패하면 마지막 최적화를 채택하지 않는다. + +## 11. 측정 지표와 결과 + +### 부하기 + +- active users, 연결 성공률, disconnect 사유 +- 초당 메시지 송신·고유 수신 수 +- 작업별 count, 오류율, 평균, p50, p95, p99, 최대 +- 최초 포화 사용자 수, 시각과 신호 + +### 서버와 자원 + +- 프로세스 CPU 평균·최대 +- heap/non-heap, GC 횟수·중단시간 +- thread, FD, 노출되는 경우 Tomcat active/max thread +- HTTP URI·method·status별 count와 timer +- MongoDB 명령 수·지연, connection pool과 wait queue +- Redis 명령 수·지연, 연결, 메모리와 hit/miss +- 수집 가능한 network와 disk I/O + +누적 counter는 측정 구간 증가량을 사용한다. timer 평균은 `sum 증가량 / count 증가량`, gauge는 구간 평균과 최대를 사용한다. + +### 회차 결과표 + +| 커밋 | 회차 | max-users | 안정 수용량 | 최초 포화 신호 | msg/s | Socket p95/p99 | 오류율 | 비정상 해제 | CPU 최대 | heap 최대 | Mongo/Redis 포화 | +|---|---:|---:|---:|---|---:|---|---:|---:|---:|---:|---| +| | | | | | | | | | | | | + +### 최적화 기록 + +| 순서 | 병목 근거 | 변경 한 가지 | 변경 전 수용량 | 변경 후 수용량 | 변화율 | 회귀 | 채택 여부 | +|---:|---|---|---:|---:|---:|---|---| +| | | | | | | | | + +각 결과에는 애플리케이션·부하기 커밋, JVM과 DB 설정, seed, 시작·종료 시각, 무효·중단 이유를 함께 저장한다. + +## 12. 실행 체크리스트 + +### 준비 + +- [ ] 부하기 의존성과 breakpoint 모듈 복구 +- [ ] Smoke와 계약 테스트 통과 +- [ ] 웜업 표본이 결과에서 제외됨 +- [ ] 작업별 지연·오류와 measurement 시각 기록 +- [ ] Prometheus target과 필수 지표 확인 +- [ ] 고유 계정과 재사용 가능한 seed 확보 + +### 반복 루프 + +- [ ] 이전 회차와 workload·환경 동일 +- [ ] 최초 포화 신호와 시각 확인 +- [ ] 병목 가설 하나만 선택 +- [ ] 변경도 한 가지로 제한 +- [ ] 동일 Ramp-up으로 즉시 재측정 +- [ ] 개선 없으면 변경 폐기 +- [ ] 개선되면 새 베이스라인 기록 + +### 종료 + +- [ ] 최종 분산 Ramp-up 3회 완료 +- [ ] 10분 E2E Constant 통과 +- [ ] hot-room과 Spike 회귀 결과 기록 +- [ ] 최종 안정 수용량과 개선율 기록 +- [ ] 측정하지 못한 지표와 환경 제약 명시 diff --git a/loadtest/load-test.js b/loadtest/load-test.js index 4ba6795e..cc95503f 100644 --- a/loadtest/load-test.js +++ b/loadtest/load-test.js @@ -85,6 +85,7 @@ class LoadTester { errorsMessage: 0, latencies: [], connectionTimes: [], + operations: {}, startTime: Date.now() }; this.sockets = []; @@ -106,6 +107,33 @@ class LoadTester { // Don't output to console directly - it will be handled by printMetrics } + recordOperation(name, elapsedMs = 0, failed = false) { + const operation = this.metrics.operations[name] || { count: 0, errors: 0, latencies: [] }; + operation.count++; + if (failed) operation.errors++; + if (elapsedMs > 0) operation.latencies.push(elapsedMs); + this.metrics.operations[name] = operation; + } + + printOperationRanking() { + const rows = Object.entries(this.metrics.operations) + .map(([name, operation]) => { + const avg = operation.latencies.length + ? operation.latencies.reduce((sum, value) => sum + value, 0) / operation.latencies.length + : 0; + const errorRate = operation.errors / operation.count; + const impact = operation.count * Math.max(avg, 1) * (1 + errorRate * 10); + return { name, count: operation.count, avg, errorRate, impact }; + }) + .sort((a, b) => b.impact - a.impact) + .slice(0, 5); + + console.log(chalk.bold.magenta('\n=== Operation Impact Ranking (first-pass) ===')); + rows.forEach((row, index) => { + console.log(`${index + 1}. ${row.name}: impact=${row.impact.toFixed(1)}, count=${row.count}, avg=${row.avg.toFixed(1)}ms, errors=${(row.errorRate * 100).toFixed(1)}%`); + }); + } + async createTestUser(userId) { try { const email = `loadtest-${userId}@test.com`; @@ -224,13 +252,18 @@ class LoadTester { this.log('success', `User ${userId} (${user.name}) connected in ${connectionTime}ms`); // Join room + const joinStart = Date.now(); socket.emit(CLIENT_EMIT.JOIN_ROOM, roomId); + + socket.__loadtestJoinStart = joinStart; }); socket.on(SERVER_EMIT.JOIN_ROOM_SUCCESS, (data) => { + this.recordOperation('joinRoom', Date.now() - (socket.__loadtestJoinStart || Date.now())); this.log('info', `User ${userId} joined room ${roomId} with ${data.participants?.length || 0} participants`); // Fetch previous messages before starting to send + socket.__loadtestPreviousStart = Date.now(); socket.emit(CLIENT_EMIT.FETCH_PREVIOUS_MESSAGES, { roomId: roomId, limit: 30 }); // Start sending messages @@ -238,6 +271,7 @@ class LoadTester { }); socket.on(SERVER_EMIT.PREVIOUS_MESSAGES_LOADED, (data) => { + this.recordOperation('fetchPreviousMessages', Date.now() - (socket.__loadtestPreviousStart || Date.now())); this.metrics.previousMessagesFetched++; if (data.messages?.length) { this.metrics.messagesReceived += data.messages.length; @@ -245,6 +279,7 @@ class LoadTester { }); socket.on(SERVER_EMIT.JOIN_ROOM_ERROR, (error) => { + this.recordOperation('joinRoom', Date.now() - (socket.__loadtestJoinStart || Date.now()), true); this.metrics.errorsConnection++; this.log('error', `User ${userId} failed to join room:`, error.message || JSON.stringify(error)); socket.close(); @@ -259,6 +294,7 @@ class LoadTester { socket.emit(CLIENT_EMIT.MARK_MESSAGES_AS_READ, { messageIds: [data._id] }); + this.recordOperation('markMessagesAsRead'); this.metrics.messagesRead++; // Randomly react to ~10% of messages @@ -268,6 +304,7 @@ class LoadTester { reaction: '👍', type: 'add' }); + this.recordOperation('messageReaction'); this.metrics.reactionsSent++; } } @@ -505,6 +542,7 @@ class LoadTester { // Stop metrics reporting and print final report clearInterval(this.metricsInterval); this.printMetrics(); + this.printOperationRanking(); console.log(chalk.bold.green('\n✓ Load test completed!\n')); process.exit(0); diff --git a/performance/room-join/README.md b/performance/room-join/README.md new file mode 100644 index 00000000..2307ce8b --- /dev/null +++ b/performance/room-join/README.md @@ -0,0 +1,15 @@ +# Room join HTTP-only load test + +This scenario measures only `POST /api/rooms/{roomId}/join`. Account login and room selection +run once in `setup` and are excluded from the custom metrics. + +```sh +API_BASE_URL=http://localhost:5001 \ +TEST_NAME=room-join-before-1 FIXTURE_ID=rooms324-users562-messages8051 \ +VUS=10 DURATION=3m THINK_TIME_MS=1000 \ +k6 run performance/room-join/room-join.js +``` + +Run the same fixture and settings three times before and after one code change. Adoption requires +error rate at most 1%, zero timeouts, Mongo wait queue zero, p95 improvement of at least 10%, and +no p99 regression. Result JSON is written under `results/`. diff --git a/performance/room-join/results/room-join-before-1.json b/performance/room-join/results/room-join-before-1.json new file mode 100644 index 00000000..fdaec4fe --- /dev/null +++ b/performance/room-join/results/room-join-before-1.json @@ -0,0 +1,16 @@ +{ + "testName": "room-join-before-1", + "fixtureId": "rooms324-users562-messages8051", + "apiBaseUrl": "http://localhost:5001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "3m", + "thinkTimeMs": 1000, + "endedAt": "2026-08-11T01:11:27.059Z", + "p95Ms": 137.09819999999993, + "p99Ms": 245.29899999999998, + "averageMs": 93.7216435944142, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 1647 +} diff --git a/performance/room-join/results/room-join-before-observed-1.json b/performance/room-join/results/room-join-before-observed-1.json new file mode 100644 index 00000000..9bb9af8a --- /dev/null +++ b/performance/room-join/results/room-join-before-observed-1.json @@ -0,0 +1,16 @@ +{ + "testName": "room-join-before-observed-1", + "fixtureId": "rooms324-users562-messages8051", + "apiBaseUrl": "http://localhost:5001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "3m", + "thinkTimeMs": 1000, + "endedAt": "2026-08-11T01:31:10.699Z", + "p95Ms": 140.53889999999998, + "p99Ms": 249.15699999999998, + "averageMs": 85.71070464135015, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 1659 +} diff --git a/performance/room-join/results/room-join-pool-smoke.json b/performance/room-join/results/room-join-pool-smoke.json new file mode 100644 index 00000000..1823258a --- /dev/null +++ b/performance/room-join/results/room-join-pool-smoke.json @@ -0,0 +1,16 @@ +{ + "testName": "room-join-pool-smoke", + "fixtureId": "rooms324-users562-messages8051", + "apiBaseUrl": "http://localhost:5001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "30s", + "thinkTimeMs": 0, + "endedAt": "2026-08-11T01:25:44.936Z", + "p95Ms": 168.76619999999997, + "p99Ms": 321.811, + "averageMs": 102.96696698762054, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 2908 +} diff --git a/performance/room-join/room-join.js b/performance/room-join/room-join.js new file mode 100644 index 00000000..d77f3069 --- /dev/null +++ b/performance/room-join/room-join.js @@ -0,0 +1,91 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +const API_BASE_URL = __ENV.API_BASE_URL || 'http://localhost:5001'; +const TEST_NAME = __ENV.TEST_NAME || 'room-join-local'; +const FIXTURE_ID = __ENV.FIXTURE_ID || 'unspecified'; +const VUS = Number(__ENV.VUS || 10); +const DURATION = __ENV.DURATION || '3m'; +const THINK_TIME_MS = Number(__ENV.THINK_TIME_MS || 1000); +const ACCOUNT_COUNT = Number(__ENV.ACCOUNT_COUNT || 30); +const PASSWORD = __ENV.LOAD_TEST_PASSWORD || 'Test1234!'; + +const duration = new Trend('room_join_duration', true); +const errors = new Rate('room_join_errors'); +const completed = new Counter('room_join_completed'); +const timeouts = new Counter('room_join_timeouts'); + +export const options = { + vus: VUS, + duration: DURATION, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], + thresholds: { + room_join_errors: ['rate<=0.01'], + room_join_timeouts: ['count==0'], + }, +}; + +function account(index) { + return { email: `loadtest-${index}@test.com`, password: PASSWORD }; +} + +export function setup() { + if (VUS > ACCOUNT_COUNT) throw new Error(`VUS (${VUS}) exceeds ACCOUNT_COUNT (${ACCOUNT_COUNT})`); + const tokens = []; + for (let index = 0; index < ACCOUNT_COUNT; index += 1) { + const response = http.post(`${API_BASE_URL}/api/auth/login`, JSON.stringify(account(index)), { + headers: { 'Content-Type': 'application/json' }, timeout: '10s', tags: { phase: 'setup' }, + }); + if (response.status !== 200 || !response.json('token')) { + throw new Error(`Fixed account ${account(index).email} unavailable (HTTP ${response.status})`); + } + tokens.push(response.json('token')); + } + const rooms = http.get(`${API_BASE_URL}/api/rooms`, { + headers: { Authorization: `Bearer ${tokens[0]}` }, timeout: '30s', tags: { phase: 'setup' }, + }); + const roomId = rooms.json('data.0._id') || rooms.json('data.0.id'); + if (rooms.status !== 200 || !roomId) throw new Error('No room is available for room-join measurement'); + return { tokens, roomId }; +} + +export default function (data) { + const token = data.tokens[(__VU - 1) % data.tokens.length]; + const response = http.post(`${API_BASE_URL}/api/rooms/${data.roomId}/join`, '{}', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + timeout: '30s', tags: { phase: 'measurement', endpoint: 'room-join' }, + }); + const success = check(response, { + 'room join returned HTTP 200': (res) => res.status === 200, + 'room join preserved success contract': (res) => res.json('success') === true, + 'room join returned a room': (res) => Boolean(res.json('data._id') || res.json('data.id')), + }); + duration.add(response.timings.duration); + errors.add(!success); + completed.add(1); + if (response.error_code === 1050 || response.timings.duration >= 30000) timeouts.add(1); + sleep(THINK_TIME_MS / 1000); +} + +function value(data, metric, field, fallback = 0) { + return data.metrics[metric]?.values[field] ?? fallback; +} + +export function handleSummary(data) { + const summary = { + testName: TEST_NAME, fixtureId: FIXTURE_ID, apiBaseUrl: API_BASE_URL, + model: 'HTTP-only room join', vus: VUS, duration: DURATION, thinkTimeMs: THINK_TIME_MS, + endedAt: new Date().toISOString(), + p95Ms: value(data, 'room_join_duration', 'p(95)'), + p99Ms: value(data, 'room_join_duration', 'p(99)'), + averageMs: value(data, 'room_join_duration', 'avg'), + errorRate: value(data, 'room_join_errors', 'rate'), + timeoutCount: value(data, 'room_join_timeouts', 'count'), + completedRequests: value(data, 'room_join_completed', 'count'), + }; + return { + stdout: `${JSON.stringify(summary, null, 2)}\n`, + [`performance/room-join/results/${TEST_NAME}.json`]: `${JSON.stringify(summary, null, 2)}\n`, + }; +} From 7ba1bc012b9069d26690ce9e984bd6585c7f132b Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 11:18:46 +0900 Subject: [PATCH 06/32] docs(pr): add metric-focused template --- .github/pull_request_template.md | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..83cc8f1b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,43 @@ +## 문제와 원인 + + + +## 구현 + +| 항목 | 변경 전 | 변경 후 | +|---|---|---| +| 동작 또는 구조 | | | +| 데이터·요청 흐름 | | | + + + +## 측정 방법 + +| 조건 | 값 | +|---|---| +| 대상 흐름 | | +| 데이터 규모 | | +| 사용자 수·시간·횟수 | | +| 확인 지표 | | + + + +## 결과 + +| 지표 | 변경 전 | 변경 후 | 변화 | +|---|---:|---:|---:| +| p95 | | | | +| p99 | | | | +| 오류율 | | | | +| 병목 지표 | | | | + + + +## 검증 + +- [ ] 관련 단위 테스트 +- [ ] 관련 통합 테스트 +- [ ] 기존 응답·이벤트·화면 동작 유지 +- [ ] 동일 조건 전후 비교 + + From 5372bc5dc999d5eda97571af54736112b245a0ab Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 11:22:58 +0900 Subject: [PATCH 07/32] docs(pr): clarify review evidence flow --- .github/pull_request_template.md | 73 ++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 83cc8f1b..13271504 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,43 +1,60 @@ -## 문제와 원인 +## 한눈에 보기 - +| 구분 | 내용 | +|---|---| +| 문제 | | +| 확인된 원인 | | +| 해결 방법 | | +| 사용자·개발자 영향 | | + + + +## 확인 과정 -## 구현 +| 단계 | 확인한 내용 | 판단 근거 | +|---:|---|---| +| 1. 재현 | | | +| 2. 원인 확인 | | | +| 3. 해결안 선택 | | | -| 항목 | 변경 전 | 변경 후 | + + +## 구현 내용 + +| 영역 | 변경 내용 | 변경 이유 | |---|---|---| -| 동작 또는 구조 | | | -| 데이터·요청 흐름 | | | +| | | | - + -## 측정 방법 +## 검증 조건 -| 조건 | 값 | +| 항목 | 조건 | |---|---| -| 대상 흐름 | | -| 데이터 규모 | | -| 사용자 수·시간·횟수 | | -| 확인 지표 | | +| 테스트 대상 | | +| 데이터·환경 | | +| 실행 횟수·부하 | | +| 통과 기준 | | + + - +## 검증 결과 -## 결과 +| 확인 항목 | 변경 전 | 변경 후 | 판정 | +|---|---:|---:|---| +| | | | | -| 지표 | 변경 전 | 변경 후 | 변화 | -|---|---:|---:|---:| -| p95 | | | | -| p99 | | | | -| 오류율 | | | | -| 병목 지표 | | | | + - +## 영향 범위와 후속 작업 -## 검증 +- 영향받는 기능: +- 이번 PR에서 제외한 범위: +- 다음 작업: -- [ ] 관련 단위 테스트 -- [ ] 관련 통합 테스트 -- [ ] 기존 응답·이벤트·화면 동작 유지 -- [ ] 동일 조건 전후 비교 +## 체크리스트 - +- [ ] 문제를 재현하고 원인을 확인했습니다. +- [ ] 변경에 직접 관련된 테스트를 통과했습니다. +- [ ] 기존 API·이벤트·화면 동작의 유지 여부를 확인했습니다. +- [ ] 실행하지 못한 검증이나 남은 위험을 작성했습니다. From 6f729b2e4bcfe1040ff513c12c98ae3af3d53b9b Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 11:25:54 +0900 Subject: [PATCH 08/32] docs(pr): add result metric table --- .github/pull_request_template.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 13271504..87333ce9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -44,7 +44,14 @@ |---|---:|---:|---| | | | | | - +| 지표 | 변경 전 | 변경 후 | 변화 | +|---|---:|---:|---:| +| p95 | | | | +| p99 | | | | +| 오류율 | | | | +| 병목 지표 | | | | + + ## 영향 범위와 후속 작업 From 9f8bfead8bd1bc4ad1d60186871378a37267ff64 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 11:27:34 +0900 Subject: [PATCH 09/32] docs(pr): allow change-specific metrics --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 87333ce9..28bf822c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -51,7 +51,7 @@ | 오류율 | | | | | 병목 지표 | | | | - + ## 영향 범위와 후속 작업 From c31df8aab1a7a5c85090b373cbfb6d2207d2ec1e Mon Sep 17 00:00:00 2001 From: rkdwlss2 Date: Tue, 11 Aug 2026 14:42:36 +0900 Subject: [PATCH 10/32] =?UTF-8?q?s3=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/.gitignore | 1 + apps/backend/DEPLOY.md | 7 +++++ apps/backend/README.md | 5 ++++ apps/backend/pom.xml | 12 ++++++++ .../controller/ProfileImageController.java | 18 ++++++++--- .../com/ktb/chatapp/storage/LocalStorage.java | 5 +--- .../src/main/resources/application.properties | 9 ++++-- .../storage/StoragePortSelectionTest.java | 30 ++++--------------- .../chatapp/util/BannedWordCheckerTest.java | 9 ++++++ .../src/test/resources/application.properties | 5 +++- .../chat/rooms/__tests__/useRoomList.test.js | 1 + 11 files changed, 66 insertions(+), 36 deletions(-) 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..28913277 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 + # 파일 저장소 (AWS 자격 증명은 IAM Role 또는 AWS 기본 환경 변수 사용) + FILE_STORAGE_TYPE=s3 + 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..e09767b5 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` | ❌ | `s3` | 파일 저장 방식. 현재 `s3`만 지원 | +| `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/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/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/storage/LocalStorage.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/LocalStorage.java index 1454c5c6..4ccc08a1 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 @@ -11,13 +11,10 @@ import java.nio.file.StandardCopyOption; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; -import org.springframework.stereotype.Component; -@Component -@ConditionalOnProperty(name = "file.storage.type", havingValue = "local", matchIfMissing = true) +/** 테스트 전용 로컬 구현. 애플리케이션 빈으로 등록하지 않아 운영 코드에서는 사용할 수 없다. */ public class LocalStorage implements StoragePort { private final Path rootLocation; diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index da6d0e5f..b87bd54f 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -17,9 +17,12 @@ spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.password=${REDIS_PASSWORD:} # File Upload Configuration -# Storage type: local or gridfs (default: local) -file.storage.type=${FILE_STORAGE_TYPE:local} -file.upload-dir=./uploads +# Files are stored only in S3. Credentials use the AWS default credentials chain. +file.storage.type=${FILE_STORAGE_TYPE:s3} +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} # Multipart upload limits spring.servlet.multipart.max-file-size=5MB 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..8e47383a 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,18 @@ 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.stereotype.Component; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("file.storage.type 스위치 단위 테스트") +@DisplayName("S3 전용 스토리지 선택 단위 테스트") 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("S3Storage만 애플리케이션 빈으로 등록된다") + void onlyS3StorageIsApplicationComponent() { + assertThat(S3Storage.class).hasAnnotation(Component.class); + assertThat(LocalStorage.class.getAnnotation(Component.class)).isNull(); } - } 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/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/features/chat/rooms/__tests__/useRoomList.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js index 9be9d1b4..fd959b17 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js @@ -102,4 +102,5 @@ describe('useRoomList', () => { expect(result.current.error).toBeNull(); expect(result.current.rooms).toEqual([{ _id: 'room-1' }]); }); + }); From a81291be1761d24d920e7db39cde8dfa0dfb102d Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 16:10:00 +0900 Subject: [PATCH 11/32] perf: add room timestamp message index --- .../backend/src/main/java/com/ktb/chatapp/model/Message.java | 5 +++++ 1 file changed, 5 insertions(+) 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..dba4283c 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,7 @@ 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 java.time.LocalDateTime; import java.util.ArrayList; @@ -26,6 +27,10 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "messages") +@CompoundIndex( + name = "room_timestamp_idx", + def = "{'room': 1, 'timestamp': 1}" +) public class Message { @Id From 441479a4636df9e2f5b45cb88cdbfc266edd15ff Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 16:11:06 +0900 Subject: [PATCH 12/32] perf: add room detail load test --- performance/room-detail/README.md | 24 ++++ performance/room-detail/fixture.js | 112 ++++++++++++++++++ performance/room-detail/results/.gitkeep | 1 + .../results/baseline-p10-m100k-vu1.json | 21 ++++ .../results/baseline-p10-m100k-vu10.json | 21 ++++ .../results/baseline-p10-m10k-vu1.json | 21 ++++ .../results/baseline-p10-m10k-vu10.json | 21 ++++ .../results/baseline-p10-m1k-vu1.json | 21 ++++ .../results/baseline-p10-m1k-vu10.json | 21 ++++ .../results/baseline-p10-m1k-vu30.json | 21 ++++ .../results/baseline-p100-m100k-vu1.json | 21 ++++ .../results/baseline-p100-m100k-vu10.json | 21 ++++ .../results/baseline-p100-m10k-vu1.json | 21 ++++ .../results/baseline-p100-m10k-vu10.json | 21 ++++ .../results/baseline-p100-m1k-vu1.json | 21 ++++ .../results/baseline-p100-m1k-vu10.json | 21 ++++ .../results/baseline-p500-m100k-vu1.json | 21 ++++ .../results/baseline-p500-m100k-vu10.json | 21 ++++ .../results/baseline-p500-m10k-vu1.json | 21 ++++ .../results/baseline-p500-m10k-vu10.json | 21 ++++ .../results/baseline-p500-m1k-vu1.json | 21 ++++ .../results/baseline-p500-m1k-vu10.json | 21 ++++ .../results/exp1-after-p10-m100k-explain.json | 1 + .../results/exp1-after-p10-m100k-vu1.json | 21 ++++ .../results/exp1-after-p10-m100k-vu10.json | 21 ++++ .../results/exp1-after-p10-m10k-explain.json | 1 + .../results/exp1-after-p10-m10k-vu1.json | 21 ++++ .../results/exp1-after-p10-m10k-vu10.json | 21 ++++ .../results/exp1-after-p10-m1k-explain.json | 1 + .../results/exp1-after-p10-m1k-vu1.json | 21 ++++ .../results/exp1-after-p10-m1k-vu10.json | 21 ++++ .../results/exp1-after-p10-m1k-vu30.json | 21 ++++ .../exp1-before-p10-m100k-explain.json | 1 + .../results/exp1-before-p10-m100k-vu1.json | 21 ++++ .../results/exp1-before-p10-m100k-vu10.json | 21 ++++ .../results/exp1-before-p10-m10k-explain.json | 1 + .../results/exp1-before-p10-m10k-vu1.json | 21 ++++ .../results/exp1-before-p10-m10k-vu10.json | 21 ++++ .../results/exp1-before-p10-m1k-explain.json | 1 + .../results/exp1-before-p10-m1k-vu1.json | 21 ++++ .../results/exp1-before-p10-m1k-vu10.json | 21 ++++ .../results/exp1-before-p10-m1k-vu30.json | 21 ++++ .../results/room-detail-baseline-report.md | 83 +++++++++++++ performance/room-detail/room-detail.js | 108 +++++++++++++++++ 44 files changed, 1027 insertions(+) create mode 100644 performance/room-detail/README.md create mode 100644 performance/room-detail/fixture.js create mode 100644 performance/room-detail/results/.gitkeep create mode 100644 performance/room-detail/results/baseline-p10-m100k-vu1.json create mode 100644 performance/room-detail/results/baseline-p10-m100k-vu10.json create mode 100644 performance/room-detail/results/baseline-p10-m10k-vu1.json create mode 100644 performance/room-detail/results/baseline-p10-m10k-vu10.json create mode 100644 performance/room-detail/results/baseline-p10-m1k-vu1.json create mode 100644 performance/room-detail/results/baseline-p10-m1k-vu10.json create mode 100644 performance/room-detail/results/baseline-p10-m1k-vu30.json create mode 100644 performance/room-detail/results/baseline-p100-m100k-vu1.json create mode 100644 performance/room-detail/results/baseline-p100-m100k-vu10.json create mode 100644 performance/room-detail/results/baseline-p100-m10k-vu1.json create mode 100644 performance/room-detail/results/baseline-p100-m10k-vu10.json create mode 100644 performance/room-detail/results/baseline-p100-m1k-vu1.json create mode 100644 performance/room-detail/results/baseline-p100-m1k-vu10.json create mode 100644 performance/room-detail/results/baseline-p500-m100k-vu1.json create mode 100644 performance/room-detail/results/baseline-p500-m100k-vu10.json create mode 100644 performance/room-detail/results/baseline-p500-m10k-vu1.json create mode 100644 performance/room-detail/results/baseline-p500-m10k-vu10.json create mode 100644 performance/room-detail/results/baseline-p500-m1k-vu1.json create mode 100644 performance/room-detail/results/baseline-p500-m1k-vu10.json create mode 100644 performance/room-detail/results/exp1-after-p10-m100k-explain.json create mode 100644 performance/room-detail/results/exp1-after-p10-m100k-vu1.json create mode 100644 performance/room-detail/results/exp1-after-p10-m100k-vu10.json create mode 100644 performance/room-detail/results/exp1-after-p10-m10k-explain.json create mode 100644 performance/room-detail/results/exp1-after-p10-m10k-vu1.json create mode 100644 performance/room-detail/results/exp1-after-p10-m10k-vu10.json create mode 100644 performance/room-detail/results/exp1-after-p10-m1k-explain.json create mode 100644 performance/room-detail/results/exp1-after-p10-m1k-vu1.json create mode 100644 performance/room-detail/results/exp1-after-p10-m1k-vu10.json create mode 100644 performance/room-detail/results/exp1-after-p10-m1k-vu30.json create mode 100644 performance/room-detail/results/exp1-before-p10-m100k-explain.json create mode 100644 performance/room-detail/results/exp1-before-p10-m100k-vu1.json create mode 100644 performance/room-detail/results/exp1-before-p10-m100k-vu10.json create mode 100644 performance/room-detail/results/exp1-before-p10-m10k-explain.json create mode 100644 performance/room-detail/results/exp1-before-p10-m10k-vu1.json create mode 100644 performance/room-detail/results/exp1-before-p10-m10k-vu10.json create mode 100644 performance/room-detail/results/exp1-before-p10-m1k-explain.json create mode 100644 performance/room-detail/results/exp1-before-p10-m1k-vu1.json create mode 100644 performance/room-detail/results/exp1-before-p10-m1k-vu10.json create mode 100644 performance/room-detail/results/exp1-before-p10-m1k-vu30.json create mode 100644 performance/room-detail/results/room-detail-baseline-report.md create mode 100644 performance/room-detail/room-detail.js diff --git a/performance/room-detail/README.md b/performance/room-detail/README.md new file mode 100644 index 00000000..fd8a3dbe --- /dev/null +++ b/performance/room-detail/README.md @@ -0,0 +1,24 @@ +# Room Detail HTTP-only bottleneck test + +This track measures only `GET /api/rooms/{roomId}`. Its fixture documents use a +unique `FIXTURE_ID` prefix and cleanup deletes only those documents plus the one +dedicated login account. + +The nine fixture conditions are the full combination of 10, 100, and 500 +participants with 1k, 10k, and 100k messages. Run 1 VU for 30 seconds, then 10, +30, 50, and 100 VUs for one minute each. Stop increasing a condition after +errors exceed 1%, p95 exceeds twice its smoke value, a timeout occurs, or the +MongoDB wait queue appears. + +Required environment variables are `FIXTURE_ID`, `ROOM_ID`, +`EXPECTED_PARTICIPANTS`, `EXPECTED_RECENT_MESSAGES`, `LOAD_TEST_EMAIL`, and +`LOAD_TEST_PASSWORD`. Example: + +```sh +k6 run -e FIXTURE_ID=room-detail-20260811 \ + -e ROOM_ID=room-detail-20260811:room:p10-m1k \ + -e EXPECTED_PARTICIPANTS=10 -e EXPECTED_RECENT_MESSAGES=1000 \ + -e LOAD_TEST_EMAIL=room-detail-20260811@fixture.invalid \ + -e LOAD_TEST_PASSWORD='RoomDetail1234!' -e VUS=1 -e DURATION=30s \ + performance/room-detail/room-detail.js +``` diff --git a/performance/room-detail/fixture.js b/performance/room-detail/fixture.js new file mode 100644 index 00000000..65f7f676 --- /dev/null +++ b/performance/room-detail/fixture.js @@ -0,0 +1,112 @@ +const database = db.getSiblingDB('bootcamp-chat'); +const fixtureId = process.env.FIXTURE_ID; +const action = process.env.ACTION || 'create'; +const email = process.env.LOAD_TEST_EMAIL; +const prefix = `${fixtureId}:`; + +if (!fixtureId) throw new Error('FIXTURE_ID is required'); + +const conditions = [ + { key: 'p10-m1k', participants: 10, messages: 1000 }, + { key: 'p10-m10k', participants: 10, messages: 10000 }, + { key: 'p10-m100k', participants: 10, messages: 100000 }, + { key: 'p100-m1k', participants: 100, messages: 1000 }, + { key: 'p100-m10k', participants: 100, messages: 10000 }, + { key: 'p100-m100k', participants: 100, messages: 100000 }, + { key: 'p500-m1k', participants: 500, messages: 1000 }, + { key: 'p500-m10k', participants: 500, messages: 10000 }, + { key: 'p500-m100k', participants: 500, messages: 100000 }, +]; + +function cleanup(removeOwner = true) { + const roomIds = conditions.map((condition) => `${prefix}room:${condition.key}`); + const removed = { + messages: database.messages.deleteMany({ room: { $in: roomIds } }).deletedCount, + rooms: database.rooms.deleteMany({ _id: { $regex: `^${prefix}` } }).deletedCount, + users: database.users.deleteMany({ + $or: [ + { _id: { $regex: `^${prefix}` } }, + ...(removeOwner && email ? [{ email }] : []), + ], + }).deletedCount, + }; + printjson({ action: 'cleanup', fixtureId, removed }); +} + +function create() { + cleanup(false); + const owner = database.users.findOne({ email }); + if (!owner) throw new Error(`Load-test owner not found: ${email}`); + const selected = conditions.find((condition) => condition.key === process.env.CONDITION); + if (!selected) throw new Error(`Unknown CONDITION: ${process.env.CONDITION}`); + const now = new Date(); + const users = []; + for (let index = 0; index < selected.participants; index += 1) { + users.push({ + _id: `${prefix}user:${index}`, + name: `Room detail participant ${index}`, + email: `${fixtureId}-${index}@fixture.invalid`, + password: 'not-used', + createdAt: now, + updatedAt: now, + isOnline: false, + }); + } + database.users.insertMany(users, { ordered: false }); + + for (const condition of [selected]) { + const roomId = `${prefix}room:${condition.key}`; + const participantIds = users.slice(0, condition.participants).map((user) => user._id); + database.rooms.insertOne({ + _id: roomId, + name: `Room detail ${condition.key}`, + creator: owner._id, + hasPassword: true, + password: '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', + participantIds, + createdAt: now, + }); + for (let offset = 0; offset < condition.messages; offset += 1000) { + const batchSize = Math.min(1000, condition.messages - offset); + const messages = Array.from({ length: batchSize }, (_, index) => ({ + _id: `${prefix}message:${condition.key}:${offset + index}`, + room: roomId, + sender: participantIds[(offset + index) % participantIds.length], + content: `fixture message ${offset + index}`, + type: 'TEXT', + timestamp: now, + mentions: [], + reactions: {}, + readers: [], + metadata: {}, + })); + database.messages.insertMany(messages, { ordered: false }); + } + } + printjson({ action: 'create', fixtureId, ownerId: owner._id, condition: selected }); +} + +function refresh() { + const roomId = `${prefix}room:${process.env.CONDITION}`; + const result = database.messages.updateMany({ room: roomId }, { $set: { timestamp: new Date() } }); + printjson({ action: 'refresh', roomId, matched: result.matchedCount, modified: result.modifiedCount }); +} + +function explain() { + const roomId = `${prefix}room:${process.env.CONDITION}`; + const since = new Date(Date.now() - (30 * 60 * 1000)); + const result = database.messages.find({ room: roomId, timestamp: { $gte: since } }) + .explain('executionStats'); + printjson({ + action: 'explain', + roomId, + winningPlan: result.queryPlanner.winningPlan, + executionStats: result.executionStats, + }); +} + +if (action === 'create') create(); +else if (action === 'cleanup') cleanup(); +else if (action === 'refresh') refresh(); +else if (action === 'explain') explain(); +else throw new Error(`Unknown ACTION: ${action}`); diff --git a/performance/room-detail/results/.gitkeep b/performance/room-detail/results/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/performance/room-detail/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/performance/room-detail/results/baseline-p10-m100k-vu1.json b/performance/room-detail/results/baseline-p10-m100k-vu1.json new file mode 100644 index 00000000..456a6e59 --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m100k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m100k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:02:47.070Z", + "endedAt": "2026-08-11T05:03:17.099Z", + "requests": 837, + "rps": 27.873168359031983, + "averageMs": 35.63207168458783, + "p95Ms": 40.6684, + "p99Ms": 51.24756, + "maxMs": 73.953, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1757 +} diff --git a/performance/room-detail/results/baseline-p10-m100k-vu10.json b/performance/room-detail/results/baseline-p10-m100k-vu10.json new file mode 100644 index 00000000..e07dba97 --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m100k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m100k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:03:17.388Z", + "endedAt": "2026-08-11T05:04:17.807Z", + "requests": 899, + "rps": 14.879654135266913, + "averageMs": 670.9273403781976, + "p95Ms": 742.3532, + "p99Ms": 776.24166, + "maxMs": 840.21, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1757 +} diff --git a/performance/room-detail/results/baseline-p10-m10k-vu1.json b/performance/room-detail/results/baseline-p10-m10k-vu1.json new file mode 100644 index 00000000..5ae3601e --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m10k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m10k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:01:13.235Z", + "endedAt": "2026-08-11T05:01:43.269Z", + "requests": 2779, + "rps": 92.53072293031848, + "averageMs": 10.63920223101833, + "p95Ms": 13.470699999999997, + "p99Ms": 18.631439999999984, + "maxMs": 48.182, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1754 +} diff --git a/performance/room-detail/results/baseline-p10-m10k-vu10.json b/performance/room-detail/results/baseline-p10-m10k-vu10.json new file mode 100644 index 00000000..8adf517d --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m10k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m10k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:01:43.621Z", + "endedAt": "2026-08-11T05:02:43.686Z", + "requests": 8777, + "rps": 146.12603596192213, + "averageMs": 68.2146575139568, + "p95Ms": 84.42759999999998, + "p99Ms": 99.21299999999998, + "maxMs": 155.429, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1754 +} diff --git a/performance/room-detail/results/baseline-p10-m1k-vu1.json b/performance/room-detail/results/baseline-p10-m1k-vu1.json new file mode 100644 index 00000000..bcb872b0 --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m1k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m1k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T04:56:31.186Z", + "endedAt": "2026-08-11T04:57:01.523Z", + "requests": 2994, + "rps": 98.69382968747976, + "averageMs": 9.854931863727451, + "p95Ms": 15.836449999999996, + "p99Ms": 27.931419999999974, + "maxMs": 265.6, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1751 +} diff --git a/performance/room-detail/results/baseline-p10-m1k-vu10.json b/performance/room-detail/results/baseline-p10-m1k-vu10.json new file mode 100644 index 00000000..198f466c --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m1k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m1k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T04:57:59.327Z", + "endedAt": "2026-08-11T04:58:59.409Z", + "requests": 32515, + "rps": 541.1808231014955, + "averageMs": 18.294671136398666, + "p95Ms": 25.610999999999972, + "p99Ms": 34.9073, + "maxMs": 89.777, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1751 +} diff --git a/performance/room-detail/results/baseline-p10-m1k-vu30.json b/performance/room-detail/results/baseline-p10-m1k-vu30.json new file mode 100644 index 00000000..1c43fc82 --- /dev/null +++ b/performance/room-detail/results/baseline-p10-m1k-vu30.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p10-m1k-vu30", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 30, + "duration": "1m", + "startedAt": "2026-08-11T04:59:09.965Z", + "endedAt": "2026-08-11T05:00:10.048Z", + "requests": 30617, + "rps": 509.5823009619, + "averageMs": 58.60356700525837, + "p95Ms": 77.7178, + "p99Ms": 117.23071999999999, + "maxMs": 614.039, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1751 +} diff --git a/performance/room-detail/results/baseline-p100-m100k-vu1.json b/performance/room-detail/results/baseline-p100-m100k-vu1.json new file mode 100644 index 00000000..d718a81f --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m100k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m100k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 100000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:07:25.316Z", + "endedAt": "2026-08-11T05:07:55.361Z", + "requests": 427, + "rps": 14.212083426194488, + "averageMs": 69.94050117096018, + "p95Ms": 89.3402, + "p99Ms": 152.26188, + "maxMs": 287.814, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14540 +} diff --git a/performance/room-detail/results/baseline-p100-m100k-vu10.json b/performance/room-detail/results/baseline-p100-m100k-vu10.json new file mode 100644 index 00000000..13e25ae4 --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m100k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m100k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 100000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:07:55.677Z", + "endedAt": "2026-08-11T05:08:56.093Z", + "requests": 921, + "rps": 15.244485799686991, + "averageMs": 654.5924049945718, + "p95Ms": 762.449, + "p99Ms": 877.1468, + "maxMs": 986.078, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14540 +} diff --git a/performance/room-detail/results/baseline-p100-m10k-vu1.json b/performance/room-detail/results/baseline-p100-m10k-vu1.json new file mode 100644 index 00000000..8a4b3613 --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m10k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m10k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 10000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:05:51.398Z", + "endedAt": "2026-08-11T05:06:21.425Z", + "requests": 743, + "rps": 24.745043622714352, + "averageMs": 40.12956527590845, + "p95Ms": 45.5228, + "p99Ms": 61.77095999999999, + "maxMs": 114.009, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14537 +} diff --git a/performance/room-detail/results/baseline-p100-m10k-vu10.json b/performance/room-detail/results/baseline-p100-m10k-vu10.json new file mode 100644 index 00000000..97f3c669 --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m10k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m10k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 10000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:06:21.831Z", + "endedAt": "2026-08-11T05:07:21.956Z", + "requests": 5027, + "rps": 83.60933812023822, + "averageMs": 119.19171295006947, + "p95Ms": 188.81189999999992, + "p99Ms": 285.80604, + "maxMs": 429.393, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14537 +} diff --git a/performance/room-detail/results/baseline-p100-m1k-vu1.json b/performance/room-detail/results/baseline-p100-m1k-vu1.json new file mode 100644 index 00000000..7634bd5b --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m1k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m1k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 1000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:04:19.578Z", + "endedAt": "2026-08-11T05:04:49.616Z", + "requests": 722, + "rps": 24.036586481124637, + "averageMs": 41.29804155124656, + "p95Ms": 54.043499999999995, + "p99Ms": 78.64497999999996, + "maxMs": 122.365, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14534 +} diff --git a/performance/room-detail/results/baseline-p100-m1k-vu10.json b/performance/room-detail/results/baseline-p100-m1k-vu10.json new file mode 100644 index 00000000..6be71efc --- /dev/null +++ b/performance/room-detail/results/baseline-p100-m1k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p100-m1k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p100-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 100, + "recentMessages": 1000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:04:49.981Z", + "endedAt": "2026-08-11T05:05:50.081Z", + "requests": 5290, + "rps": 88.02107247769918, + "averageMs": 113.22039565217383, + "p95Ms": 172.1158, + "p99Ms": 325.1329999999997, + "maxMs": 701.469, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 14534 +} diff --git a/performance/room-detail/results/baseline-p500-m100k-vu1.json b/performance/room-detail/results/baseline-p500-m100k-vu1.json new file mode 100644 index 00000000..f0bbe100 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m100k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m100k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 100000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:18:30.297Z", + "endedAt": "2026-08-11T05:19:00.364Z", + "requests": 150, + "rps": 4.989006889618954, + "averageMs": 199.67049999999998, + "p95Ms": 240.24599999999998, + "p99Ms": 306.53140999999994, + "maxMs": 350.945, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72539 +} diff --git a/performance/room-detail/results/baseline-p500-m100k-vu10.json b/performance/room-detail/results/baseline-p500-m100k-vu10.json new file mode 100644 index 00000000..ed235ef5 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m100k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m100k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 100000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:19:00.720Z", + "endedAt": "2026-08-11T05:20:01.463Z", + "requests": 690, + "rps": 11.359497779810853, + "averageMs": 876.7446028985515, + "p95Ms": 1305.57985, + "p99Ms": 1536.10014, + "maxMs": 1655.465, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72539 +} diff --git a/performance/room-detail/results/baseline-p500-m10k-vu1.json b/performance/room-detail/results/baseline-p500-m10k-vu1.json new file mode 100644 index 00000000..9c2851a8 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m10k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m10k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 10000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:16:57.813Z", + "endedAt": "2026-08-11T05:17:27.919Z", + "requests": 154, + "rps": 5.11531310831536, + "averageMs": 194.74034415584404, + "p95Ms": 267.19849999999997, + "p99Ms": 401.59994999999986, + "maxMs": 505.595, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72536 +} diff --git a/performance/room-detail/results/baseline-p500-m10k-vu10.json b/performance/room-detail/results/baseline-p500-m10k-vu10.json new file mode 100644 index 00000000..0769b505 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m10k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m10k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 10000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:17:28.190Z", + "endedAt": "2026-08-11T05:18:28.453Z", + "requests": 1362, + "rps": 22.601155353803826, + "averageMs": 440.7788186490455, + "p95Ms": 556.43305, + "p99Ms": 993.3990299999999, + "maxMs": 1459.474, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72536 +} diff --git a/performance/room-detail/results/baseline-p500-m1k-vu1.json b/performance/room-detail/results/baseline-p500-m1k-vu1.json new file mode 100644 index 00000000..8e197e02 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m1k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m1k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 1000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T05:15:25.386Z", + "endedAt": "2026-08-11T05:15:55.574Z", + "requests": 155, + "rps": 5.134552947559684, + "averageMs": 193.94834193548382, + "p95Ms": 306.3727, + "p99Ms": 368.31213999999994, + "maxMs": 498.821, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72533 +} diff --git a/performance/room-detail/results/baseline-p500-m1k-vu10.json b/performance/room-detail/results/baseline-p500-m1k-vu10.json new file mode 100644 index 00000000..0333ad32 --- /dev/null +++ b/performance/room-detail/results/baseline-p500-m1k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "baseline-p500-m1k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p500-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 500, + "recentMessages": 1000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T05:15:55.978Z", + "endedAt": "2026-08-11T05:16:56.231Z", + "requests": 1210, + "rps": 20.082214594927045, + "averageMs": 496.469249586777, + "p95Ms": 765.5410499999999, + "p99Ms": 859.55152, + "maxMs": 923.614, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 72533 +} diff --git a/performance/room-detail/results/exp1-after-p10-m100k-explain.json b/performance/room-detail/results/exp1-after-p10-m100k-explain.json new file mode 100644 index 00000000..1754b06a --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m100k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m100k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m100k"}},{"timestamp":{"$gte":"2026-08-11T05:54:58.493Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"3FD813D9","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"FETCH","nss":"bootcamp-chat.messages","inputStage":{"stage":"IXSCAN","nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m100k\", \"room-detail-20260811:room:p10-m100k\"]"],"timestamp":["[new Date(1786427698493), new Date(9223372036854775807)]"]}}},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":100000,"executionTimeMillis":43,"totalKeysExamined":100000,"totalDocsExamined":100000,"executionStages":{"isCached":false,"stage":"FETCH","nReturned":100000,"executionTimeMillisEstimate":43,"works":100001,"advanced":100000,"needTime":0,"needYield":0,"saveState":3,"restoreState":3,"isEOF":1,"nss":"bootcamp-chat.messages","docsExamined":100000,"alreadyHasObj":0,"inputStage":{"stage":"IXSCAN","nReturned":100000,"executionTimeMillisEstimate":22,"works":100001,"advanced":100000,"needTime":0,"needYield":0,"saveState":3,"restoreState":3,"isEOF":1,"nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m100k\", \"room-detail-20260811:room:p10-m100k\"]"],"timestamp":["[new Date(1786427698493), new Date(9223372036854775807)]"]},"keysExamined":100000,"seeks":1,"dupsTested":0,"dupsDropped":0,"peakTrackedMemBytes":0}}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m100k","timestamp":{"$gte":"2026-08-11T05:54:58.493Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-after-p10-m100k-vu1.json b/performance/room-detail/results/exp1-after-p10-m100k-vu1.json new file mode 100644 index 00000000..e133e8aa --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m100k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m100k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:24:58.606Z", + "endedAt": "2026-08-11T06:25:28.630Z", + "requests": 2446, + "rps": 81.46861195237797, + "averageMs": 12.008766557645137, + "p95Ms": 12.992249999999999, + "p99Ms": 16.83015, + "maxMs": 40.196, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1756 +} diff --git a/performance/room-detail/results/exp1-after-p10-m100k-vu10.json b/performance/room-detail/results/exp1-after-p10-m100k-vu10.json new file mode 100644 index 00000000..7eceb71a --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m100k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m100k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:25:28.916Z", + "endedAt": "2026-08-11T06:26:28.952Z", + "requests": 29170, + "rps": 485.8754491183135, + "averageMs": 20.367840589647003, + "p95Ms": 30.16755, + "p99Ms": 47.95641, + "maxMs": 243.454, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1756 +} diff --git a/performance/room-detail/results/exp1-after-p10-m10k-explain.json b/performance/room-detail/results/exp1-after-p10-m10k-explain.json new file mode 100644 index 00000000..39e8962e --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m10k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m10k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m10k"}},{"timestamp":{"$gte":"2026-08-11T05:53:25.773Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"3FD813D9","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"FETCH","nss":"bootcamp-chat.messages","inputStage":{"stage":"IXSCAN","nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m10k\", \"room-detail-20260811:room:p10-m10k\"]"],"timestamp":["[new Date(1786427605773), new Date(9223372036854775807)]"]}}},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":10000,"executionTimeMillis":4,"totalKeysExamined":10000,"totalDocsExamined":10000,"executionStages":{"isCached":false,"stage":"FETCH","nReturned":10000,"executionTimeMillisEstimate":0,"works":10001,"advanced":10000,"needTime":0,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","docsExamined":10000,"alreadyHasObj":0,"inputStage":{"stage":"IXSCAN","nReturned":10000,"executionTimeMillisEstimate":0,"works":10001,"advanced":10000,"needTime":0,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m10k\", \"room-detail-20260811:room:p10-m10k\"]"],"timestamp":["[new Date(1786427605773), new Date(9223372036854775807)]"]},"keysExamined":10000,"seeks":1,"dupsTested":0,"dupsDropped":0,"peakTrackedMemBytes":0}}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m10k","timestamp":{"$gte":"2026-08-11T05:53:25.773Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-after-p10-m10k-vu1.json b/performance/room-detail/results/exp1-after-p10-m10k-vu1.json new file mode 100644 index 00000000..a5858d9a --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m10k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m10k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:23:25.848Z", + "endedAt": "2026-08-11T06:23:55.877Z", + "requests": 8590, + "rps": 286.0661761314442, + "averageMs": 3.3012306169965147, + "p95Ms": 4.272099999999999, + "p99Ms": 8.2501, + "maxMs": 37.523, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1753 +} diff --git a/performance/room-detail/results/exp1-after-p10-m10k-vu10.json b/performance/room-detail/results/exp1-after-p10-m10k-vu10.json new file mode 100644 index 00000000..48bbacef --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m10k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m10k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:23:56.339Z", + "endedAt": "2026-08-11T06:24:56.370Z", + "requests": 108425, + "rps": 1806.1569554308928, + "averageMs": 5.421342697717238, + "p95Ms": 7.949799999999995, + "p99Ms": 10.452519999999998, + "maxMs": 60.814, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1753 +} diff --git a/performance/room-detail/results/exp1-after-p10-m1k-explain.json b/performance/room-detail/results/exp1-after-p10-m1k-explain.json new file mode 100644 index 00000000..8972128f --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m1k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m1k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m1k"}},{"timestamp":{"$gte":"2026-08-11T05:50:52.940Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"3FD813D9","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"FETCH","nss":"bootcamp-chat.messages","inputStage":{"stage":"IXSCAN","nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m1k\", \"room-detail-20260811:room:p10-m1k\"]"],"timestamp":["[new Date(1786427452940), new Date(9223372036854775807)]"]}}},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":1000,"executionTimeMillis":2,"totalKeysExamined":1000,"totalDocsExamined":1000,"executionStages":{"isCached":false,"stage":"FETCH","nReturned":1000,"executionTimeMillisEstimate":0,"works":1001,"advanced":1000,"needTime":0,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","docsExamined":1000,"alreadyHasObj":0,"inputStage":{"stage":"IXSCAN","nReturned":1000,"executionTimeMillisEstimate":0,"works":1001,"advanced":1000,"needTime":0,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","keyPattern":{"room":1,"timestamp":1},"indexName":"room_timestamp_room_detail_exp1","isMultiKey":false,"multiKeyPaths":{"room":[],"timestamp":[]},"isUnique":false,"isSparse":false,"isPartial":false,"indexVersion":2,"direction":"forward","indexBounds":{"room":["[\"room-detail-20260811:room:p10-m1k\", \"room-detail-20260811:room:p10-m1k\"]"],"timestamp":["[new Date(1786427452940), new Date(9223372036854775807)]"]},"keysExamined":1000,"seeks":1,"dupsTested":0,"dupsDropped":0,"peakTrackedMemBytes":0}}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m1k","timestamp":{"$gte":"2026-08-11T05:50:52.940Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-after-p10-m1k-vu1.json b/performance/room-detail/results/exp1-after-p10-m1k-vu1.json new file mode 100644 index 00000000..87592606 --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m1k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m1k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:20:53.268Z", + "endedAt": "2026-08-11T06:21:23.331Z", + "requests": 9928, + "rps": 330.2433229343942, + "averageMs": 2.8966973207091002, + "p95Ms": 4.519899999999997, + "p99Ms": 8.78899999999999, + "maxMs": 59.002, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/exp1-after-p10-m1k-vu10.json b/performance/room-detail/results/exp1-after-p10-m1k-vu10.json new file mode 100644 index 00000000..eb7fe183 --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m1k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m1k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:21:23.882Z", + "endedAt": "2026-08-11T06:22:23.920Z", + "requests": 134991, + "rps": 2248.443935584882, + "averageMs": 4.345446970538775, + "p95Ms": 6.761, + "p99Ms": 10.400399999999996, + "maxMs": 65.617, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/exp1-after-p10-m1k-vu30.json b/performance/room-detail/results/exp1-after-p10-m1k-vu30.json new file mode 100644 index 00000000..5ce766ae --- /dev/null +++ b/performance/room-detail/results/exp1-after-p10-m1k-vu30.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-after-p10-m1k-vu30", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 30, + "duration": "1m", + "startedAt": "2026-08-11T06:22:24.458Z", + "endedAt": "2026-08-11T06:23:24.493Z", + "requests": 141849, + "rps": 2362.7738417554333, + "averageMs": 12.59192297443058, + "p95Ms": 17.484599999999993, + "p99Ms": 28.44879999999998, + "maxMs": 168.525, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/exp1-before-p10-m100k-explain.json b/performance/room-detail/results/exp1-before-p10-m100k-explain.json new file mode 100644 index 00000000..4a7c03fb --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m100k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m100k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m100k"}},{"timestamp":{"$gte":"2026-08-11T05:45:51.180Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"5DF97F34","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m100k"}},{"timestamp":{"$gte":"2026-08-11T05:45:51.180Z"}}]},"nss":"bootcamp-chat.messages","direction":"forward"},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":100000,"executionTimeMillis":29,"totalKeysExamined":0,"totalDocsExamined":100055,"executionStages":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m100k"}},{"timestamp":{"$gte":"2026-08-11T05:45:51.180Z"}}]},"nReturned":100000,"executionTimeMillisEstimate":18,"works":100056,"advanced":100000,"needTime":55,"needYield":0,"saveState":2,"restoreState":2,"isEOF":1,"nss":"bootcamp-chat.messages","direction":"forward","docsExamined":100055}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m100k","timestamp":{"$gte":"2026-08-11T05:45:51.180Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-before-p10-m100k-vu1.json b/performance/room-detail/results/exp1-before-p10-m100k-vu1.json new file mode 100644 index 00000000..8e268e33 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m100k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m100k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:15:51.278Z", + "endedAt": "2026-08-11T06:16:21.316Z", + "requests": 1131, + "rps": 37.65307422869126, + "averageMs": 26.239787798408464, + "p95Ms": 33.4595, + "p99Ms": 37.98539999999999, + "maxMs": 60.81, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1756 +} diff --git a/performance/room-detail/results/exp1-before-p10-m100k-vu10.json b/performance/room-detail/results/exp1-before-p10-m100k-vu10.json new file mode 100644 index 00000000..4a097481 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m100k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m100k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m100k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 100000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:16:21.606Z", + "endedAt": "2026-08-11T06:17:21.673Z", + "requests": 15017, + "rps": 250.00807444337119, + "averageMs": 39.74697849104343, + "p95Ms": 57.202, + "p99Ms": 78.83115999999997, + "maxMs": 214.399, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1756 +} diff --git a/performance/room-detail/results/exp1-before-p10-m10k-explain.json b/performance/room-detail/results/exp1-before-p10-m10k-explain.json new file mode 100644 index 00000000..f9d3b0eb --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m10k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m10k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m10k"}},{"timestamp":{"$gte":"2026-08-11T05:44:17.993Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"5DF97F34","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m10k"}},{"timestamp":{"$gte":"2026-08-11T05:44:17.993Z"}}]},"nss":"bootcamp-chat.messages","direction":"forward"},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":10000,"executionTimeMillis":3,"totalKeysExamined":0,"totalDocsExamined":10055,"executionStages":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m10k"}},{"timestamp":{"$gte":"2026-08-11T05:44:17.993Z"}}]},"nReturned":10000,"executionTimeMillisEstimate":1,"works":10056,"advanced":10000,"needTime":55,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","direction":"forward","docsExamined":10055}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m10k","timestamp":{"$gte":"2026-08-11T05:44:17.993Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-before-p10-m10k-vu1.json b/performance/room-detail/results/exp1-before-p10-m10k-vu1.json new file mode 100644 index 00000000..838c9724 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m10k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m10k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:14:18.109Z", + "endedAt": "2026-08-11T06:14:48.151Z", + "requests": 5760, + "rps": 191.7349264641633, + "averageMs": 5.047486805555543, + "p95Ms": 6.470299999999998, + "p99Ms": 13.29164, + "maxMs": 220.326, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1753 +} diff --git a/performance/room-detail/results/exp1-before-p10-m10k-vu10.json b/performance/room-detail/results/exp1-before-p10-m10k-vu10.json new file mode 100644 index 00000000..de2397eb --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m10k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m10k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m10k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 10000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:14:48.763Z", + "endedAt": "2026-08-11T06:15:48.806Z", + "requests": 75693, + "rps": 1260.6585253238172, + "averageMs": 7.765354510985207, + "p95Ms": 11.71, + "p99Ms": 17.03308, + "maxMs": 115.512, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1753 +} diff --git a/performance/room-detail/results/exp1-before-p10-m1k-explain.json b/performance/room-detail/results/exp1-before-p10-m1k-explain.json new file mode 100644 index 00000000..e55e7ce0 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m1k-explain.json @@ -0,0 +1 @@ +{"roomId":"room-detail-20260811:room:p10-m1k","result":{"explainVersion":"1","queryPlanner":{"namespace":"bootcamp-chat.messages","parsedQuery":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m1k"}},{"timestamp":{"$gte":"2026-08-11T05:41:19.572Z"}}]},"indexFilterSet":false,"queryHash":"333F96BB","planCacheShapeHash":"333F96BB","planCacheKey":"5DF97F34","optimizationTimeMillis":0,"maxIndexedOrSolutionsReached":false,"maxIndexedAndSolutionsReached":false,"maxScansToExplodeReached":false,"prunedSimilarIndexes":false,"winningPlan":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m1k"}},{"timestamp":{"$gte":"2026-08-11T05:41:19.572Z"}}]},"nss":"bootcamp-chat.messages","direction":"forward"},"rejectedPlans":[]},"executionStats":{"executionSuccess":true,"nReturned":1000,"executionTimeMillis":4,"totalKeysExamined":0,"totalDocsExamined":1055,"executionStages":{"isCached":false,"stage":"COLLSCAN","filter":{"$and":[{"room":{"$eq":"room-detail-20260811:room:p10-m1k"}},{"timestamp":{"$gte":"2026-08-11T05:41:19.572Z"}}]},"nReturned":1000,"executionTimeMillisEstimate":0,"works":1056,"advanced":1000,"needTime":55,"needYield":0,"saveState":0,"restoreState":0,"isEOF":1,"nss":"bootcamp-chat.messages","direction":"forward","docsExamined":1055}},"queryShapeHash":"B8001078FD5D895872A94C0C4367B62C92BA0E7F04C12F8927AA604EC9A6A303","command":{"find":"messages","filter":{"room":"room-detail-20260811:room:p10-m1k","timestamp":{"$gte":"2026-08-11T05:41:19.572Z"}},"$db":"bootcamp-chat"},"serverInfo":{"host":"e70b4f942162","port":27017,"version":"8.3.4","gitVersion":"4b03e7daaa316c78b9bf433046dba81637d581c0"},"serverParameters":{"internalQueryFacetBufferSizeBytes":104857600,"internalDocumentSourceGroupMaxMemoryBytes":104857600,"internalQueryMaxBlockingSortMemoryUsageBytes":104857600,"internalDocumentSourceSetWindowFieldsMaxMemoryBytes":104857600,"internalQueryFacetMaxOutputDocSizeBytes":104857600,"internalLookupStageIntermediateDocumentMaxSizeBytes":104857600,"internalQueryProhibitBlockingMergeOnMongoS":0,"internalQueryMaxAddToSetBytes":104857600,"internalQueryFrameworkControl":"trySbeRestricted","internalQueryPlannerIgnoreIndexWithCollationForRegex":1},"ok":1}} diff --git a/performance/room-detail/results/exp1-before-p10-m1k-vu1.json b/performance/room-detail/results/exp1-before-p10-m1k-vu1.json new file mode 100644 index 00000000..b5372e66 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m1k-vu1.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m1k-vu1", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "30s", + "startedAt": "2026-08-11T06:11:19.691Z", + "endedAt": "2026-08-11T06:11:49.728Z", + "requests": 13002, + "rps": 432.87415890090585, + "averageMs": 2.1988337178895487, + "p95Ms": 2.964949999999999, + "p99Ms": 4.416909999999999, + "maxMs": 26.398, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/exp1-before-p10-m1k-vu10.json b/performance/room-detail/results/exp1-before-p10-m1k-vu10.json new file mode 100644 index 00000000..023a7542 --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m1k-vu10.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m1k-vu10", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "1m", + "startedAt": "2026-08-11T06:11:50.157Z", + "endedAt": "2026-08-11T06:12:50.181Z", + "requests": 134609, + "rps": 2242.619625761465, + "averageMs": 4.359470867475355, + "p95Ms": 6.219, + "p99Ms": 8.251839999999998, + "maxMs": 114.882, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/exp1-before-p10-m1k-vu30.json b/performance/room-detail/results/exp1-before-p10-m1k-vu30.json new file mode 100644 index 00000000..2b39022b --- /dev/null +++ b/performance/room-detail/results/exp1-before-p10-m1k-vu30.json @@ -0,0 +1,21 @@ +{ + "testName": "exp1-before-p10-m1k-vu30", + "fixtureId": "room-detail-20260811", + "roomId": "room-detail-20260811:room:p10-m1k", + "commit": "ce107855630f5ca543228d08ba6330a6fe96ad95", + "participants": 10, + "recentMessages": 1000, + "vus": 30, + "duration": "1m", + "startedAt": "2026-08-11T06:12:50.744Z", + "endedAt": "2026-08-11T06:13:50.773Z", + "requests": 138767, + "rps": 2311.6940630908052, + "averageMs": 12.874351315514495, + "p95Ms": 16.219, + "p99Ms": 22.523679999999995, + "maxMs": 3214.091, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1750 +} diff --git a/performance/room-detail/results/room-detail-baseline-report.md b/performance/room-detail/results/room-detail-baseline-report.md new file mode 100644 index 00000000..9d325b6b --- /dev/null +++ b/performance/room-detail/results/room-detail-baseline-report.md @@ -0,0 +1,83 @@ +# Room Detail API standalone bottleneck result + +## Run metadata + +- Test ID: `room-detail-20260811` +- Target: local-only `http://localhost:15001`; MongoDB, Redis, Prometheus, and Grafana ran in local Docker +- Commit: `ce107855630f5ca543228d08ba6330a6fe96ad95` (`upstream/develop` at test start) +- Measured endpoint: `GET /api/rooms/{roomId}` only; login was performed in k6 `setup` and excluded from custom metrics +- Measurement window: 2026-08-11 13:56:31–14:21:20 KST +- Fixture matrix: full 3 × 3 combination of 10/100/500 participants and 1k/10k/100k recent messages +- Contract checks: HTTP 200, `success=true`, exact room ID, exact participant count/list size, and exact recent-message count +- Saturation: error rate over 1%, p95 over twice Smoke, timeout, or Mongo wait queue; later VU stages were skipped after saturation + +## Load results + +| Participants | Messages | Smoke p95 / RPS | Saturation VU | Saturation avg / p95 / p99 / max (ms) | RPS | p95 ÷ Smoke | Error / timeout | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 10 | 1k | 15.84 / 98.69 | 30 | 58.60 / 77.72 / 117.23 / 614.04 | 509.58 | 4.91× | 0% / 0 | +| 10 | 10k | 13.47 / 92.53 | 10 | 68.21 / 84.43 / 99.21 / 155.43 | 146.13 | 6.27× | 0% / 0 | +| 10 | 100k | 40.67 / 27.87 | 10 | 670.93 / 742.35 / 776.24 / 840.21 | 14.88 | 18.25× | 0% / 0 | +| 100 | 1k | 54.04 / 24.04 | 10 | 113.22 / 172.12 / 325.13 / 701.47 | 88.02 | 3.18× | 0% / 0 | +| 100 | 10k | 45.52 / 24.75 | 10 | 119.19 / 188.81 / 285.81 / 429.39 | 83.61 | 4.15× | 0% / 0 | +| 100 | 100k | 89.34 / 14.21 | 10 | 654.59 / 762.45 / 877.15 / 986.08 | 15.24 | 8.53× | 0% / 0 | +| 500 | 1k | 306.37 / 5.13 | 10 | 496.47 / 765.54 / 859.55 / 923.61 | 20.08 | 2.50× | 0% / 0 | +| 500 | 10k | 267.20 / 5.12 | 10 | 440.78 / 556.43 / 993.40 / 1459.47 | 22.60 | 2.08× | 0% / 0 | +| 500 | 100k | 240.25 / 4.99 | 10 | 876.74 / 1305.58 / 1536.10 / 1655.47 | 11.36 | 5.43× | 0% / 0 | + +The 10-participant/1k condition passed 10 VU (p95 25.61 ms, 541.18 RPS) and saturated at 30 VU. Every other condition saturated at 10 VU, so no disallowed later stages were run. + +## Query and application diagnosis + +1. Recent-message count is a collection scan. The only message index was `_id_`. Every explain used `COLLSCAN`, with `totalKeysExamined=0`. `nReturned` was exactly 1k/10k/100k and `totalDocsExamined` was 1,033–1,053 / 10,033–10,053 / 100,033–100,053; the small excess is unrelated pre-existing/local-worker data. Explain execution time rose from about 2 ms to 27–30 ms at 100k. +2. Participant mapping is an N+1 query. Each detail request calls one creator `findById` plus one `findById` per participant: 11, 101, and 501 user finds per request. Prometheus range estimates were about 11.1, 103.2, and 513.0 because counter `increase()` extrapolates across scrape boundaries, while source inspection confirms the exact 11/101/501 pattern. The message aggregation remained approximately one command per request. +3. Response/serialization cost grows with participants. Mean response size was about 1.75 KB, 14.54 KB, and 72.54 KB for 10, 100, and 500 participants. At 500 participants this cost and N+1 dominate enough that Smoke p95 variance masks a monotonic message-size trend. +4. Request-path logging is a separate fixed application bottleneck. Wildcard CORS configuration logs a multi-line warning for every request. The isolated process emitted about 369 MB of console output during the run, adding synchronous formatting/output pressure unrelated to fixture axes. + +## Representative observability + +For the worst fixture at 10 VU (500 participants, 100k messages): + +- URI timer: average 867.49 ms, max 1,655.14 ms. +- Process CPU max: 24.9%; live threads max: 37. +- Heap peak: 131,452,400 bytes (about 125.4 MiB); GC pause sum: about 0.152 s over the stage. +- Mongo user-find average/max: 0.870/187.32 ms. +- Mongo message-aggregate average/max: 405.00/939.82 ms. +- Mongo checked-out connections max: 9; wait queue max: 0; checkout failures: 0. +- A wait queue max of 1 was observed at 100 participants/1k messages and 100 participants/10k messages at 10 VU; checkout failures and timeouts remained zero. + +## Conclusion and next experiments + +There are two independent scale-sensitive bottlenecks plus one fixed request-path overhead: + +- Message axis: missing `{room: 1, timestamp: 1}` index causes linear document scanning and dominates the 100k-message conditions. +- Participant axis: participant-by-participant `findById` causes linear Mongo command amplification and dominates 100/500-participant conditions. +- Constant overhead: repeated CORS warning logging materially reduces local throughput and should be moved to startup/configuration time. + +No production optimization was implemented. Following the one-change-per-experiment rule, validate these separately: (A) compound message index, (B) batch creator/participants with `findAllById`, and (C) log the wildcard warning once. Re-run the worst fixture at the same VU and apply the stated 10% p95 improvement/non-regression adoption criteria. + +## Experiment 1: compound message index + +The index-only comparison ran on 2026-08-11 using the same local backend commit and the p10-m1k, p10-m10k, and p10-m100k fixtures. The temporary index was `room_timestamp_room_detail_exp1`; it was dropped after measurement. + +| Fixture | Before p95 (Smoke / 10 VU) | After p95 (Smoke / 10 VU) | p95 change at 10 VU | Explain before → after | +|---|---:|---:|---:|---| +| p10-m1k | 2.96 / 6.22 ms | 4.52 / 6.76 ms | +8.7% | COLLSCAN → FETCH/IXSCAN | +| p10-m10k | 6.47 / 11.71 ms | 4.27 / 7.95 ms | -32.1% | COLLSCAN → FETCH/IXSCAN | +| p10-m100k | 33.46 / 57.20 ms | 12.99 / 30.17 ms | -47.3% | COLLSCAN → FETCH/IXSCAN | + +Explain executionStats changed as follows: + +- p10-m1k: `docs 1,055 → 1,000`, `keys 0 → 1,000`, `nReturned 1,000 → 1,000`. +- p10-m10k: `docs 10,055 → 10,000`, `keys 0 → 10,000`, `nReturned 10,000 → 10,000`. +- p10-m100k: `docs 100,055 → 100,000`, `keys 0 → 100,000`, `nReturned 100,000 → 100,000`. + +All valid runs returned HTTP 200 with `success=true`, the expected room and participant count, the expected recent-message count, 0% contract errors, and 0 timeouts. The 10k and 100k conditions meet the 10% p95 improvement target. The 1k condition does not show a meaningful benefit, so this is a scale-dependent optimization rather than a universal latency improvement. + +For p10-m100k at 10 VU, Prometheus showed messages aggregate cumulative time decreasing from about 493.4s to 411.8s over the sampled windows; process CPU and heap were not treated as final regression results because the before/after request counts differed. A wait queue maximum of 1 appeared after the index run and must be checked in repetition. + +This is a first-pass result, not final adoption. Repeat the before/after worst fixture three times and use medians before making a permanent index decision. No application code or permanent database migration was implemented. + +## Isolation and cleanup + +An initially public 500-participant fixture was joined by another local load worker, causing participant-count check failures. Those contaminated runs were discarded; fixtures were made private and the three 500-participant conditions were re-run with 0% contract errors. Cleanup removed the dedicated room, messages, participant users, and login account; post-cleanup fixture counts were all zero. Mongo profiler and Prometheus target configuration were restored. Concurrent local workers added 22 unrelated messages during the test; they were not modified or deleted. diff --git a/performance/room-detail/room-detail.js b/performance/room-detail/room-detail.js new file mode 100644 index 00000000..fa569bcc --- /dev/null +++ b/performance/room-detail/room-detail.js @@ -0,0 +1,108 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +const API_BASE_URL = __ENV.API_BASE_URL || 'http://localhost:5001'; +const TEST_NAME = __ENV.TEST_NAME || 'room-detail-local'; +const FIXTURE_ID = __ENV.FIXTURE_ID; +const ROOM_ID = __ENV.ROOM_ID; +const EXPECTED_PARTICIPANTS = Number(__ENV.EXPECTED_PARTICIPANTS); +const EXPECTED_RECENT_MESSAGES = Number(__ENV.EXPECTED_RECENT_MESSAGES); +const VUS = Number(__ENV.VUS || 1); +const DURATION = __ENV.DURATION || '30s'; +const EMAIL = __ENV.LOAD_TEST_EMAIL; +const PASSWORD = __ENV.LOAD_TEST_PASSWORD; + +const duration = new Trend('room_detail_duration', true); +const errors = new Rate('room_detail_errors'); +const completed = new Counter('room_detail_completed'); +const timeouts = new Counter('room_detail_timeouts'); +const responseBytes = new Trend('room_detail_response_bytes'); + +export const options = { + vus: VUS, + duration: DURATION, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], + thresholds: { + room_detail_errors: ['rate<=0.01'], + room_detail_timeouts: ['count==0'], + }, +}; + +export function setup() { + if (!FIXTURE_ID || !ROOM_ID || !EMAIL || !PASSWORD) { + throw new Error('FIXTURE_ID, ROOM_ID, LOAD_TEST_EMAIL and LOAD_TEST_PASSWORD are required'); + } + const response = http.post(`${API_BASE_URL}/api/auth/login`, JSON.stringify({ + email: EMAIL, + password: PASSWORD, + }), { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + tags: { phase: 'setup', endpoint: 'login' }, + }); + const token = response.json('token'); + if (response.status !== 200 || !token) { + throw new Error(`Fixture login failed (HTTP ${response.status})`); + } + return { token }; +} + +export default function (data) { + const response = http.get(`${API_BASE_URL}/api/rooms/${ROOM_ID}`, { + headers: { Authorization: `Bearer ${data.token}` }, + timeout: '30s', + tags: { phase: 'measurement', endpoint: 'room-detail', fixture: FIXTURE_ID }, + }); + const success = check(response, { + 'room detail returned HTTP 200': (res) => res.status === 200, + 'room detail preserved success contract': (res) => res.json('success') === true, + 'room detail returned expected room': (res) => res.json('data._id') === ROOM_ID, + 'room detail returned expected participants': (res) => + res.json('data.participantsCount') === EXPECTED_PARTICIPANTS && + res.json('data.participants').length === EXPECTED_PARTICIPANTS, + 'room detail returned expected recent messages': (res) => + res.json('data.recentMessageCount') === EXPECTED_RECENT_MESSAGES, + }); + duration.add(response.timings.duration); + errors.add(!success); + completed.add(1); + responseBytes.add(response.body ? response.body.length : 0); + if (response.error_code === 1050 || response.timings.duration >= 30000) timeouts.add(1); +} + +function value(data, metric, field, fallback = 0) { + return data.metrics[metric]?.values[field] ?? fallback; +} + +export function handleSummary(data) { + const elapsedSeconds = data.state.testRunDurationMs / 1000; + const requests = value(data, 'room_detail_completed', 'count'); + const endedAt = new Date(); + const startedAt = new Date(endedAt.getTime() - data.state.testRunDurationMs); + const summary = { + testName: TEST_NAME, + fixtureId: FIXTURE_ID, + roomId: ROOM_ID, + commit: __ENV.COMMIT || 'unknown', + participants: EXPECTED_PARTICIPANTS, + recentMessages: EXPECTED_RECENT_MESSAGES, + vus: VUS, + duration: DURATION, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + requests, + rps: elapsedSeconds > 0 ? requests / elapsedSeconds : 0, + averageMs: value(data, 'room_detail_duration', 'avg'), + p95Ms: value(data, 'room_detail_duration', 'p(95)'), + p99Ms: value(data, 'room_detail_duration', 'p(99)'), + maxMs: value(data, 'room_detail_duration', 'max'), + errorRate: value(data, 'room_detail_errors', 'rate'), + timeoutCount: value(data, 'room_detail_timeouts', 'count'), + averageResponseBytes: value(data, 'room_detail_response_bytes', 'avg'), + }; + return { + stdout: `${JSON.stringify(summary, null, 2)}\n`, + [`performance/room-detail/results/${TEST_NAME}.json`]: `${JSON.stringify(summary, null, 2)}\n`, + }; +} From 4d2f9de2bef0898256923db938f2873faf436f15 Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 16:12:19 +0900 Subject: [PATCH 13/32] test: wait for registration outcome --- e2e/actions/auth.actions.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/actions/auth.actions.js b/e2e/actions/auth.actions.js index fd5f0a88..074754c2 100644 --- a/e2e/actions/auth.actions.js +++ b/e2e/actions/auth.actions.js @@ -29,7 +29,12 @@ async function registerAction(page, userData) { await page.getByTestId('register-password-confirm-input').fill(userData.passwordConfirm); await page.getByTestId('register-name-input').fill(userData.name); await page.getByTestId('register-submit-button').click(); - await page.waitForTimeout(1000); // 잠시 대기 + // 성공 시 앱이 1000ms 뒤 router.push('/login')로 이동하므로, 그 내비게이션이 끝난 뒤에 + // 다음 goto가 실행되어야 경합(net::ERR_ABORTED)이 없다. 실패 시엔 에러 메시지 표시까지 대기. + await Promise.race([ + page.waitForURL(`${BASE_URL}/login`, { timeout: 10000 }).catch(() => {}), + page.getByTestId('register-error-message').waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}), + ]); } /** From 64b9e637712d2f55837fa34f9e8fae8b786ec8d6 Mon Sep 17 00:00:00 2001 From: rkdwlss2 Date: Tue, 11 Aug 2026 16:37:56 +0900 Subject: [PATCH 14/32] s3config --- .../java/com/ktb/chatapp/config/S3Config.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java 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..16d5eb25 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java @@ -0,0 +1,38 @@ +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.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) { + var builder = S3Presigner.builder().region(Region.of(region)); + if (!endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } +} From 63e9bf75aa47c18c2a3b3c9a9c6cce32e64cea53 Mon Sep 17 00:00:00 2001 From: rkdwlss2 Date: Tue, 11 Aug 2026 16:37:56 +0900 Subject: [PATCH 15/32] s3config --- .../java/com/ktb/chatapp/config/S3Config.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java 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..16d5eb25 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/S3Config.java @@ -0,0 +1,38 @@ +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.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) { + var builder = S3Presigner.builder().region(Region.of(region)); + if (!endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } +} From 9f797b8fbff41dd56069cbe03950027536fdc7b9 Mon Sep 17 00:00:00 2001 From: rkdwlss2 Date: Tue, 11 Aug 2026 16:47:41 +0900 Subject: [PATCH 16/32] s3config --- .../com/ktb/chatapp/storage/S3Storage.java | 77 +++++++++++++++++++ .../ProfileImageControllerTest.java | 39 ++++++++++ .../ktb/chatapp/storage/S3StorageTest.java | 54 +++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/controller/ProfileImageControllerTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java 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/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/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)); + } +} From 3ecaa8ad0f32efd6b12018e0bce6efafa10bedae Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 18:10:10 +0900 Subject: [PATCH 17/32] fix encoded chat room IDs --- .../hooks/__tests__/useRoomId.test.js | 43 +++++++++++++++++++ apps/frontend/hooks/useRoomId.js | 13 +++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/hooks/__tests__/useRoomId.test.js diff --git a/apps/frontend/hooks/__tests__/useRoomId.test.js b/apps/frontend/hooks/__tests__/useRoomId.test.js new file mode 100644 index 00000000..ad977ee0 --- /dev/null +++ b/apps/frontend/hooks/__tests__/useRoomId.test.js @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useRoomId } from '../useRoomId'; + +const navigationMocks = vi.hoisted(() => ({ + useParams: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useParams: navigationMocks.useParams, +})); + +describe('useRoomId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('decodes an encoded room id from the route', () => { + navigationMocks.useParams.mockReturnValue({ + room: 'room-join-exp2-20260811%3Aroom%3A1%3A0', + }); + + const { result } = renderHook(() => useRoomId()); + + expect(result.current).toBe('room-join-exp2-20260811:room:1:0'); + }); + + it('keeps an unencoded room id unchanged', () => { + navigationMocks.useParams.mockReturnValue({ room: 'room-1' }); + + const { result } = renderHook(() => useRoomId()); + + expect(result.current).toBe('room-1'); + }); + + it('keeps malformed route encoding unchanged', () => { + navigationMocks.useParams.mockReturnValue({ room: 'room%invalid' }); + + const { result } = renderHook(() => useRoomId()); + + expect(result.current).toBe('room%invalid'); + }); +}); diff --git a/apps/frontend/hooks/useRoomId.js b/apps/frontend/hooks/useRoomId.js index 30c38c4d..137769a3 100644 --- a/apps/frontend/hooks/useRoomId.js +++ b/apps/frontend/hooks/useRoomId.js @@ -1,7 +1,18 @@ import { useParams } from 'next/navigation'; export const useRoomId = () => { - return useParams()?.room; + const roomId = useParams()?.room; + + if (typeof roomId !== 'string') { + return roomId; + } + + try { + return decodeURIComponent(roomId); + } catch { + // 잘못 인코딩된 경로는 원본을 유지해 기존 오류 처리 흐름에 맡긴다. + return roomId; + } }; export default useRoomId; From 907cfbb671eccff7cfe024c809a17b878cb0d55a Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 19:07:25 +0900 Subject: [PATCH 18/32] perf(room): make joins atomic Avoid lost participant updates and suppress duplicate room events. Batch projected participant reads and add staged load diagnostics. --- .../chatapp/controller/RoomController.java | 11 +- .../com/ktb/chatapp/service/RoomService.java | 116 +++++++++-- .../com/ktb/chatapp/storage/LocalStorage.java | 6 +- .../src/main/resources/application.properties | 3 +- .../ktb/chatapp/service/RoomServiceTest.java | 110 +++++++++- .../storage/StoragePortSelectionTest.java | 17 +- loadtest/__tests__/room-join-tools.test.js | 22 ++ loadtest/package.json | 3 +- performance/room-join/README.md | 82 +++++++- performance/room-join/compare.js | 50 +++++ performance/room-join/docker-compose.yaml | 74 +++++++ performance/room-join/fixture.js | 132 ++++++++++++ performance/room-join/lib.js | 66 ++++++ .../room-join/prometheus.performance.yml | 13 ++ performance/room-join/room-join.js | 190 +++++++++++++----- performance/room-join/run-matrix.js | 129 ++++++++++++ performance/room-join/stage0.js | 63 ++++++ 17 files changed, 991 insertions(+), 96 deletions(-) create mode 100644 loadtest/__tests__/room-join-tools.test.js create mode 100644 performance/room-join/compare.js create mode 100644 performance/room-join/docker-compose.yaml create mode 100644 performance/room-join/fixture.js create mode 100644 performance/room-join/lib.js create mode 100644 performance/room-join/prometheus.performance.yml create mode 100644 performance/room-join/run-matrix.js create mode 100644 performance/room-join/stage0.js 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..3b9e54ac 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 @@ -29,6 +29,7 @@ import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @Tag(name = "채팅방 (Rooms)", description = "채팅방 생성 및 관리 API - 채팅방 목록 조회, 생성, 참여, 헬스체크") @@ -232,16 +233,16 @@ public ResponseEntity getRoomById(@Parameter(description = "채팅방 ID", ex public ResponseEntity joinRoom( @Parameter(description = "채팅방 ID", example = "60d5ec49f1b2c8b9e8c4f2a1") @PathVariable String roomId, @RequestBody JoinRoomRequest joinRoomRequest, - Principal principal) { + Authentication authentication) { try { - Room joinedRoom = roomService.joinRoom(roomId, joinRoomRequest.getPassword(), principal.getName()); + String userId = ((Map) authentication.getDetails()).get("userId").toString(); + RoomResponse roomResponse = roomService.joinRoomResponse( + roomId, joinRoomRequest.getPassword(), authentication.getName(), 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( 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 61889789..5c6664af 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 @@ -20,6 +20,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +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.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -33,6 +38,7 @@ public class RoomService { private final RecentMessageCounter recentMessageCounter; private final PasswordEncoder passwordEncoder; private final ApplicationEventPublisher eventPublisher; + private final MongoTemplate mongoTemplate; public RoomsResponse getAllRooms(String name) { @@ -177,15 +183,23 @@ public Optional findRoomById(String roomId) { } public Room joinRoom(String roomId, String password, String name) { + User user = userRepository.findByEmail(name) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다: " + name)); + JoinRoomResult result = joinRoomInternal(roomId, password, user.getId()); + if (result == null) { + return null; + } + publishRoomUpdatedIfParticipantAdded(roomId, name, result); + return result.room(); + } + + private JoinRoomResult joinRoomInternal(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()) { if (password == null || !passwordEncoder.matches(password, room.getPassword())) { @@ -193,37 +207,93 @@ public Room joinRoom(String roomId, String password, String name) { } } - // 이미 참여중인지 확인 - if (!room.getParticipantIds().contains(user.getId())) { - // 채팅방 참여 - room.getParticipantIds().add(user.getId()); - room = roomRepository.save(room); + if (room.getParticipantIds() != null && room.getParticipantIds().contains(userId)) { + return new JoinRoomResult(room, false); + } + + Query addParticipant = Query.query(Criteria.where("_id").is(roomId) + .and("participantIds").ne(userId)); + Room updatedRoom = mongoTemplate.findAndModify( + addParticipant, + new Update().addToSet("participantIds", userId), + FindAndModifyOptions.options().returnNew(true), + Room.class); + if (updatedRoom != null) { + return new JoinRoomResult(updatedRoom, true); + } + + // The room was deleted, or another concurrent request added the same participant first. + return roomRepository.findById(roomId) + .map(latestRoom -> new JoinRoomResult(latestRoom, false)) + .orElse(null); + } + + public RoomResponse joinRoomResponse(String roomId, String password, String name) { + User user = userRepository.findByEmail(name) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다: " + name)); + return joinRoomResponse(roomId, password, name, user.getId()); + } + + public RoomResponse joinRoomResponse( + String roomId, String password, String name, String userId) { + JoinRoomResult result = joinRoomInternal(roomId, password, userId); + if (result == null) { + return null; + } + + RoomResponse response = mapToRoomResponse(result.room(), name); + publishRoomUpdatedIfParticipantAdded(roomId, result, response); + return response; + } + + private void publishRoomUpdatedIfParticipantAdded( + String roomId, String name, JoinRoomResult result) { + if (!result.participantAdded()) { + return; + } + publishRoomUpdatedIfParticipantAdded(roomId, result, mapToRoomResponse(result.room(), name)); + } + + private void publishRoomUpdatedIfParticipantAdded( + String roomId, JoinRoomResult result, RoomResponse response) { + if (!result.participantAdded()) { + return; } - - // Publish event for room updated try { - RoomResponse roomResponse = mapToRoomResponse(room, name); - eventPublisher.publishEvent(new RoomUpdatedEvent(this, roomId, roomResponse)); + eventPublisher.publishEvent(new RoomUpdatedEvent(this, roomId, response)); } catch (Exception e) { log.error("roomUpdate 이벤트 발행 실패", e); } - - return room; } 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()); + } + Query usersQuery = Query.query(Criteria.where("_id").in(userIds)); + usersQuery.fields() + .include("_id") + .include("name") + .include("email") + .include("profileImage"); + Map usersById = userIds.isEmpty() + ? Map.of() + : mongoTemplate.find(usersQuery, User.class).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; + 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()); @@ -276,4 +346,6 @@ private RoomResponse buildRoomResponse( .recentMessageCount(recentMessageCount) .build(); } + + private record JoinRoomResult(Room room, boolean participantAdded) {} } 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 4ccc08a1..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 @@ -11,10 +11,14 @@ import java.nio.file.StandardCopyOption; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Component; -/** 테스트 전용 로컬 구현. 애플리케이션 빈으로 등록하지 않아 운영 코드에서는 사용할 수 없다. */ +/** 로컬 파일 스토리지 구현. file.storage.type=local인 환경에서만 등록된다. */ +@Component +@ConditionalOnProperty(name = "file.storage.type", havingValue = "local") public class LocalStorage implements StoragePort { private final Path rootLocation; diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index b87bd54f..c6b493d0 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.* 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 index 05a697c7..77989f1b 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -3,9 +3,13 @@ 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.anySet; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -13,6 +17,7 @@ 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.model.Room; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.RoomRepository; @@ -20,13 +25,19 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) @@ -37,6 +48,7 @@ class RoomServiceTest { @Mock private RecentMessageCounter recentMessageCounter; @Mock private PasswordEncoder passwordEncoder; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private MongoTemplate mongoTemplate; private RoomService roomService; @@ -47,7 +59,8 @@ void setUp() { userRepository, recentMessageCounter, passwordEncoder, - eventPublisher + eventPublisher, + mongoTemplate ); } @@ -110,6 +123,101 @@ void getAllRooms_batchesUsersAndMessageCountsOnceAndKeepsContract() throws Excep assertFalse(json.has("createdAtDateTime")); } + @Test + void joinRoom_addsNewParticipantAndReturnsIt() { + Room before = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.HashSet<>(Set.of("creator-1"))); + Room after = room("room-1", "Room", "creator-1", before.getCreatedAt(), + new java.util.HashSet<>(Set.of("creator-1", "participant-1"))); + User joining = user("participant-1", "Participant", "participant@test.com"); + User creator = user("creator-1", "Creator", "creator@test.com"); + when(roomRepository.findById("room-1")).thenReturn(Optional.of(before)); + when(mongoTemplate.findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), any(Class.class))).thenReturn(after); + when(mongoTemplate.find(any(Query.class), org.mockito.ArgumentMatchers.eq(User.class))) + .thenReturn(List.of(creator, joining)); + + RoomResponse response = roomService.joinRoomResponse( + "room-1", null, joining.getEmail(), joining.getId()); + + assertTrue(response.getParticipants().stream().anyMatch(p -> p.getId().equals(joining.getId()))); + verify(roomRepository, never()).save(any(Room.class)); + ArgumentCaptor event = ArgumentCaptor.forClass(RoomUpdatedEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertSame(response, event.getValue().getRoomResponse()); + verify(recentMessageCounter).countRecentMessages("room-1"); + verify(userRepository, never()).findByEmail(joining.getEmail()); + ArgumentCaptor usersQuery = ArgumentCaptor.forClass(Query.class); + verify(mongoTemplate).find(usersQuery.capture(), org.mockito.ArgumentMatchers.eq(User.class)); + assertEquals( + org.bson.Document.parse("{'_id':1,'name':1,'email':1,'profileImage':1}"), + usersQuery.getValue().getFieldsObject()); + } + + @Test + void joinRoom_isIdempotentForExistingParticipant() { + User joining = user("participant-1", "Participant", "participant@test.com"); + Room room = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.HashSet<>(Set.of("creator-1", joining.getId()))); + when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(mongoTemplate.find(any(Query.class), org.mockito.ArgumentMatchers.eq(User.class))) + .thenReturn(List.of(joining)); + + roomService.joinRoomResponse("room-1", null, joining.getEmail(), joining.getId()); + roomService.joinRoomResponse("room-1", null, joining.getEmail(), joining.getId()); + + assertEquals(2, room.getParticipantIds().size()); + verify(roomRepository, never()).save(any(Room.class)); + verify(eventPublisher, never()).publishEvent(any(RoomUpdatedEvent.class)); + verify(mongoTemplate, never()).findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), any(Class.class)); + } + + @Test + void joinRoom_rejectsWrongPassword() { + Room room = room("room-1", "Private", "creator-1", LocalDateTime.now(), + new java.util.HashSet<>(Set.of("creator-1"))); + room.setHasPassword(true); + room.setPassword("encoded"); + User joining = user("participant-1", "Participant", "participant@test.com"); + when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(userRepository.findByEmail(joining.getEmail())).thenReturn(Optional.of(joining)); + when(passwordEncoder.matches("wrong", "encoded")).thenReturn(false); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> roomService.joinRoom("room-1", "wrong", joining.getEmail())); + + assertTrue(error.getMessage().contains("\uBE44\uBC00\uBC88\uD638")); + verify(mongoTemplate, never()).findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), any(Class.class)); + } + + @Test + void joinRoom_returnsNullForMissingRoom() { + when(roomRepository.findById("missing")).thenReturn(Optional.empty()); + + assertNull(roomService.joinRoomResponse( + "missing", null, "participant@test.com", "participant-1")); + verify(userRepository, never()).findByEmail("participant@test.com"); + } + + @Test + void joinRoom_returnsNullWhenRoomDisappearsBeforeAtomicUpdate() { + Room room = room("room-1", "Room", "creator-1", LocalDateTime.now(), + new java.util.HashSet<>(Set.of("creator-1"))); + User joining = user("participant-1", "Participant", "participant@test.com"); + when(roomRepository.findById("room-1")) + .thenReturn(Optional.of(room), Optional.empty()); + when(mongoTemplate.findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), any(Class.class))).thenReturn(null); + + assertNull(roomService.joinRoomResponse( + "room-1", null, joining.getEmail(), joining.getId())); + + verify(roomRepository, times(2)).findById("room-1"); + verify(eventPublisher, never()).publishEvent(any(RoomUpdatedEvent.class)); + } + private static Room room( String id, String name, 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 8e47383a..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 @@ -2,17 +2,24 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("S3 전용 스토리지 선택 단위 테스트") +@DisplayName("로컬 스토리지 선택 단위 테스트") class StoragePortSelectionTest { @Test - @DisplayName("S3Storage만 애플리케이션 빈으로 등록된다") - void onlyS3StorageIsApplicationComponent() { - assertThat(S3Storage.class).hasAnnotation(Component.class); - assertThat(LocalStorage.class.getAnnotation(Component.class)).isNull(); + @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/loadtest/__tests__/room-join-tools.test.js b/loadtest/__tests__/room-join-tools.test.js new file mode 100644 index 00000000..a4ba2a49 --- /dev/null +++ b/loadtest/__tests__/room-join-tools.test.js @@ -0,0 +1,22 @@ +'use strict'; + +const { median, decideOptimization, parseArgs } = require('../../performance/room-join/lib'); + +describe('room join performance helpers', () => { + test('median supports three-run adoption comparisons', () => { + expect(median([30, 10, 20])).toBe(20); + expect(median([10, 20])).toBe(15); + }); + + test.each([ + [{ messagesScaleWithLatency: true, planStage: 'COLLSCAN' }, '{ room: 1, timestamp: 1 } index'], + [{ participantsScaleWithCommands: true }, 'batch participant lookup and remove duplicate response assembly'], + [{ newParticipantOnlyRegression: true }, '$addToSet atomic participant update'], + ])('selects the first optimization from evidence', (input, expected) => { + expect(decideOptimization(input)).toBe(expected); + }); + + test('parses both inline and separate CLI values', () => { + expect(parseArgs(['--mode=idempotent', '--vus', '10'])).toEqual({ mode: 'idempotent', vus: '10' }); + }); +}); diff --git a/loadtest/package.json b/loadtest/package.json index 8e50bcde..ee2950be 100644 --- a/loadtest/package.json +++ b/loadtest/package.json @@ -13,7 +13,8 @@ "test:rampup": "node ramp-up-test.js", "test:rampup:small": "node ramp-up-test.js --max-users=200 --sustain-duration=120", "test:rampup:large": "node ramp-up-test.js --max-users=1000 --min-users-per-second=10 --max-users-per-second=20 --sustain-duration=300", - "create-users": "node create-test-users.js" + "create-users": "node create-test-users.js", + "test:room-join-tools": "jest __tests__/room-join-tools.test.js" }, "keywords": [ "load-test", diff --git a/performance/room-join/README.md b/performance/room-join/README.md index 2307ce8b..ec3ca53d 100644 --- a/performance/room-join/README.md +++ b/performance/room-join/README.md @@ -1,15 +1,77 @@ -# Room join HTTP-only load test +# Room Join API performance diagnosis -This scenario measures only `POST /api/rooms/{roomId}/join`. Account login and room selection -run once in `setup` and are excluded from the custom metrics. +`POST /api/rooms/{roomId}/join`만 측정한다. Room Detail, Socket.IO, 브라우저 E2E는 이 흐름에 포함하지 않는다. 기존 데이터와 기존 `results/` 파일은 생성·정리 대상이 아니다. + +## 1. 격리 fixture + +fixture 사용자는 API 로그인이 가능해야 하므로 현재 애플리케이션과 같은 평문 비밀번호의 BCrypt hash를 전달한다. 로컬 기존 테스트 사용자 hash를 재사용하는 예시는 다음과 같다. + +```sh +TEST_ID=20260811-a +PASSWORD_HASH="$(docker exec mongo-ktb mongosh --quiet bootcamp-chat --eval \ + 'print(db.users.findOne({email:"loadtest-0@test.com"}).password)')" + +TEST_ID="$TEST_ID" PASSWORD_HASH="$PASSWORD_HASH" ACTION=create \ + mongosh --quiet mongodb://localhost:27017/bootcamp-chat performance/room-join/fixture.js \ + > "performance/room-join/results/$TEST_ID-fixture.json" +``` + +기본 매트릭스는 메시지 축 `p10 × m1000/10000/100000`, 참가자 축 `p10/100/500 × m1000`이다. 중복인 `p10-m1000`은 한 번만 만든다. 각 방과 문서에는 `perfJoinTestId`가 기록되고, 계정은 `perf-join--*` prefix를 쓴다. + +개수와 격리를 재검증하거나 정리한다. + +```sh +TEST_ID="$TEST_ID" ACTION=verify mongosh --quiet mongodb://localhost:27017/bootcamp-chat \ + performance/room-join/fixture.js + +TEST_ID="$TEST_ID" ACTION=delete mongosh --quiet mongodb://localhost:27017/bootcamp-chat \ + performance/room-join/fixture.js +``` + +삭제는 동일 `perfJoinTestId`의 방/메시지와, 동일 test ID 및 prefix를 모두 만족하는 사용자만 대상으로 한다. fixture가 이미 있으면 create는 실패하며 암묵적으로 덮어쓰지 않는다. + +## 2. Stage 0 — 단일 요청 진단 + +fixture JSON의 대상 방 ID로 profiler를 켜고 정확히 Join 한 건을 보낸다. 완료 후 기존 profiling level을 복구하고, 같은 시간창 명령 수와 메시지 쿼리 `executionStats`를 저장한다. + +```sh +node performance/room-join/stage0.js \ + --fixture perf-join-20260811-a-p10-m100000 \ + --room-id ROOM_OBJECT_ID --mode idempotent --participants 10 --messages 100000 +``` + +participant 수에 따라 사용자 조회 명령 수가 선형 증가하는지, 메시지 수 증가 시 `COLLSCAN` 및 `totalDocsExamined ≫ nReturned`인지 여기서 먼저 판정한다. + +## 3. Stage 1 — HTTP-only 매트릭스 + +create 출력 JSON을 manifest로 사용한다. 각 fixture에 대해 `idempotent`와 `new-participant`를 분리하고 `1 VU × 30초` smoke 후 `10→30→50→100 VU × 1분`을 실행한다. +`new-participant`는 각 단계 직전에 해당 test ID의 `new` 사용자 ID만 대상 방에서 `$pull`한 뒤 VU당 Join을 정확히 한 번 보내므로, 모든 단계가 최초 동시 참여를 측정한다. `idempotent`는 방의 기존 참가자 계정을 VU들이 순환 사용한다. + +```sh +node performance/room-join/run-matrix.js \ + --manifest performance/room-join/results/20260811-a-fixture.json \ + --test-id 20260811-before-run1 +``` + +다음 조건이면 해당 fixture/mode의 다음 단계를 실행하지 않는다. + +- 오류율 1% 초과 +- timeout 1건 이상 +- 단계 p95가 smoke p95의 2배 초과 +- Mongo wait queue 또는 checkout failure 발생 +- k6 응답 계약 threshold 실패 + +각 결과에는 test/commit/fixture/mode/VU/시간창, 요청·RPS, avg/p95/p99/max, 오류·timeout 및 동일 시간창 Prometheus 관측치가 기록된다. Prometheus 쿼리 오류도 결과에 명시적으로 남긴다. + +## 4. 전후 3회 중앙값 비교 + +최악 fixture의 동일 조건만 담은 aggregate 두 개를 비교한다. ```sh -API_BASE_URL=http://localhost:5001 \ -TEST_NAME=room-join-before-1 FIXTURE_ID=rooms324-users562-messages8051 \ -VUS=10 DURATION=3m THINK_TIME_MS=1000 \ -k6 run performance/room-join/room-join.js +node performance/room-join/compare.js \ + --before performance/room-join/results/before-worst-aggregate.json \ + --after performance/room-join/results/after-worst-aggregate.json \ + --observation performance/room-join/results/stage0-observation.json ``` -Run the same fixture and settings three times before and after one code change. Adoption requires -error rate at most 1%, zero timeouts, Mongo wait queue zero, p95 improvement of at least 10%, and -no p99 regression. Result JSON is written under `results/`. +최종 채택은 양쪽 3회 이상, p95 중앙값 10% 이상 개선, 오류율 1% 이하, timeout 0, p99와 Mongo pool 비회귀가 모두 필요하다. 생성된 JSON의 요약 수치를 기존 Notion 성능 병목 페이지의 Join API 전용 표로 옮긴다. 이 도구는 외부 문서를 자동 수정하지 않는다. diff --git a/performance/room-join/compare.js b/performance/room-join/compare.js new file mode 100644 index 00000000..5cee39fc --- /dev/null +++ b/performance/room-join/compare.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const { parseArgs, median, decideOptimization } = require('./lib'); + +const args = parseArgs(process.argv.slice(2)); +const before = loadRuns(required('before')); +const after = loadRuns(required('after')); +const observation = args.observation ? JSON.parse(fs.readFileSync(args.observation, 'utf8')) : {}; +const beforeP95 = median(before.map((run) => run.latencyMs.p95)); +const afterP95 = median(after.map((run) => run.latencyMs.p95)); +const beforeP99 = median(before.map((run) => run.latencyMs.p99)); +const afterP99 = median(after.map((run) => run.latencyMs.p99)); +const improvement = beforeP95 > 0 ? (beforeP95 - afterP95) / beforeP95 : 0; +const resourceMetrics = ['processCpu', 'heapUsed', 'gcPauseRate', 'mongoCommandMax', 'mongoCheckedOut']; +const resources = Object.fromEntries(resourceMetrics.map((name) => { + const beforeValue = median(before.map((run) => observationMaximum(run, name))); + const afterValue = median(after.map((run) => observationMaximum(run, name))); + return [name, { before: beforeValue, after: afterValue, regressed: beforeValue !== null && afterValue !== null && afterValue > beforeValue }]; +})); +const resourcesRegressed = Object.values(resources).some((metric) => metric.regressed); +const resourcesComplete = Object.values(resources).every((metric) => metric.before !== null && metric.after !== null); +const adopted = before.length >= 3 && after.length >= 3 && improvement >= 0.10 + && after.every((run) => run.errorRate <= 0.01 && run.timeoutCount === 0) + && afterP99 <= beforeP99 + && !resourcesRegressed + && resourcesComplete + && after.every((run) => !run.waitQueueDetected && !run.checkoutFailureDetected); + +console.log(JSON.stringify({ + beforeRuns: before.length, afterRuns: after.length, + median: { beforeP95Ms: beforeP95, afterP95Ms: afterP95, p95Improvement: improvement, beforeP99Ms: beforeP99, afterP99Ms: afterP99 }, + resources: { complete: resourcesComplete, metrics: resources }, + temporaryAdoptionEligible: improvement >= 0.10 && after.every((run) => run.errorRate <= 0.01 && run.timeoutCount === 0) && afterP99 <= beforeP99 && resourcesComplete && !resourcesRegressed, + finalAdoption: adopted, + selectedFirstOptimization: decideOptimization(observation), +}, null, 2)); + +function loadRuns(file) { + const payload = JSON.parse(fs.readFileSync(file, 'utf8')); + return Array.isArray(payload) ? payload : payload.runs || [payload]; +} +function observationMaximum(run, name) { + const series = run.observability?.[name]; + if (!Array.isArray(series)) return null; + const values = series.flatMap((item) => item.values || []).map((pair) => pair[1]).filter(Number.isFinite); + return values.length ? Math.max(...values) : null; +} +function required(name) { if (!args[name]) throw new Error(`--${name} is required`); return args[name]; } diff --git a/performance/room-join/docker-compose.yaml b/performance/room-join/docker-compose.yaml new file mode 100644 index 00000000..8be68292 --- /dev/null +++ b/performance/room-join/docker-compose.yaml @@ -0,0 +1,74 @@ +services: + mongo: + image: mongo:8.3.4 + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: bootcamp-chat + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.runCommand({ ping: 1 }).ok ? 0 : 2)"] + interval: 2s + timeout: 2s + retries: 30 + + redis: + image: redis:8.8.0 + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 30 + + mongodb-exporter: + image: percona/mongodb_exporter:0.51.0 + command: ["--mongodb.uri=mongodb://mongo:27017", "--collect-all"] + depends_on: + mongo: + condition: service_healthy + + app: + image: ktb-chat-backend:join-optimized-8fde958 + ports: + - "5001:5001" + - "5002:5002" + env_file: + - ../../apps/backend/.env + environment: + MONGO_URI: mongodb://mongo:27017/bootcamp-chat + REDIS_HOST: redis + REDIS_PORT: 6379 + FILE_STORAGE_TYPE: local + SPRING_DATA_MONGODB_AUTO_INDEX_CREATION: "true" + SWAGGER_ENABLED: "false" + SERVER_TOMCAT_THREADS_MAX: ${TOMCAT_THREADS_MAX:-10} + SERVER_TOMCAT_MAX_CONNECTIONS: ${TOMCAT_MAX_CONNECTIONS:-50} + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + + prometheus: + image: prom/prometheus:v3.13.0 + ports: + - "9090:9090" + volumes: + - ./prometheus.performance.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + depends_on: + - app + - mongodb-exporter + +volumes: + mongo_data: + redis_data: + prometheus_data: diff --git a/performance/room-join/fixture.js b/performance/room-join/fixture.js new file mode 100644 index 00000000..111ec77d --- /dev/null +++ b/performance/room-join/fixture.js @@ -0,0 +1,132 @@ +/* global db, print, quit, ObjectId */ + +// Run with mongosh: TEST_ID=... ACTION=create mongosh bootcamp-chat fixture.js +const action = process.env.ACTION || 'create'; +const testId = process.env.TEST_ID; +const prefix = `perf-join-${testId}`; +const passwordHash = process.env.PASSWORD_HASH; +const fixedMessages = integerEnv('FIXED_MESSAGES', 1000); +const messageAxis = intList(process.env.MESSAGE_AXIS || '1000,10000,100000'); +const participantAxis = intList(process.env.PARTICIPANT_AXIS || '10,100,500'); +const maxVus = integerEnv('MAX_VUS', 100); + +if (!testId || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(testId)) fail('invalid or missing TEST_ID'); +if (action === 'create' && !passwordHash) fail('PASSWORD_HASH is required for create'); +if (!['create', 'verify', 'delete'].includes(action)) fail(`unsupported ACTION=${action}`); + +const fixtureRooms = [ + ...messageAxis.map((messages) => ({ axis: 'messages', participants: 10, messages })), + ...participantAxis.map((participants) => ({ axis: 'participants', participants, messages: fixedMessages })), +].filter((fixture, index, all) => all.findIndex((candidate) => + candidate.participants === fixture.participants && candidate.messages === fixture.messages) === index); + +if (action === 'delete') { + const roomIds = db.rooms.find({ perfJoinTestId: testId }, { _id: 1 }).toArray().map((room) => room._id); + const messages = db.messages.deleteMany({ $or: [{ perfJoinTestId: testId }, { room: { $in: roomIds } }] }); + const rooms = db.rooms.deleteMany({ perfJoinTestId: testId }); + const users = db.users.deleteMany({ perfJoinTestId: testId, email: { $regex: `^${escapeRegex(prefix)}-` } }); + print(EJSON.stringify({ action, testId, deleted: { users: users.deletedCount, rooms: rooms.deletedCount, messages: messages.deletedCount } })); + quit(0); +} + +if (action === 'create') { + if (db.rooms.countDocuments({ perfJoinTestId: testId }) > 0 + || db.users.countDocuments({ perfJoinTestId: testId }) > 0 + || db.messages.countDocuments({ perfJoinTestId: testId }) > 0) { + fail(`fixture ${testId} already exists; delete it explicitly before recreating`); + } + const maximumParticipants = Math.max(...fixtureRooms.map((fixture) => fixture.participants)); + const memberCount = Math.max(maximumParticipants, maxVus); + const users = []; + for (let index = 0; index < memberCount; index += 1) users.push(user('member', index)); + for (let index = 0; index < maxVus; index += 1) users.push(user('new', index)); + db.users.insertMany(users, { ordered: false }); + + const memberIds = users.filter((item) => item.perfJoinRole === 'member').map((item) => item._id); + fixtureRooms.forEach((fixture) => { + const slug = `p${fixture.participants}-m${fixture.messages}`; + const roomId = new ObjectId(); + db.rooms.insertOne({ + _id: roomId, + name: `${prefix}-${slug}`, + creator: memberIds[0].toString(), + hasPassword: false, + createdAt: new Date(), + participantIds: memberIds.slice(0, fixture.participants).map(String), + perfJoinTestId: testId, + perfJoinFixture: slug, + perfJoinAxis: fixture.axis, + }); + insertMessages(roomId.toString(), fixture.messages, memberIds); + }); +} + +const verification = verify(); +print(EJSON.stringify({ action, testId, prefix, fixtures: verification })); +if (verification.some((fixture) => !fixture.valid)) quit(2); + +function user(role, index) { + return { + _id: new ObjectId(), + name: `${prefix}-${role}-${index}`, + email: `${prefix}-${role}-${index}@perf-join.test`, + password: passwordHash, + createdAt: new Date(), + updatedAt: new Date(), + isOnline: false, + perfJoinTestId: testId, + perfJoinRole: role, + }; +} + +function insertMessages(roomId, count, memberIds) { + const batchSize = 1000; + for (let offset = 0; offset < count; offset += batchSize) { + const batch = []; + const limit = Math.min(count, offset + batchSize); + for (let index = offset; index < limit; index += 1) { + batch.push({ + room: roomId, + sender: memberIds[index % memberIds.length].toString(), + content: `${prefix}-message-${index}`, + type: 'TEXT', + timestamp: new Date(Date.now() - (count - index) * 1000), + mentions: [], reactions: {}, readers: [], metadata: {}, + perfJoinTestId: testId, + }); + } + db.messages.insertMany(batch, { ordered: false }); + } +} + +function verify() { + return fixtureRooms.map((expected) => { + const slug = `p${expected.participants}-m${expected.messages}`; + const room = db.rooms.findOne({ perfJoinTestId: testId, perfJoinFixture: slug }); + const actualMessages = room ? db.messages.countDocuments({ perfJoinTestId: testId, room: room._id.toString() }) : 0; + const actualParticipants = room?.participantIds?.length || 0; + return { + id: `${prefix}-${slug}`, + roomId: room ? room._id.toString() : null, + axis: room?.perfJoinAxis || expected.axis, + participants: actualParticipants, + messages: actualMessages, + valid: Boolean(room) && actualParticipants === expected.participants && actualMessages === expected.messages, + }; + }); +} + +function intList(value) { + const values = value.split(',').map((item) => Number(item.trim())); + if (values.some((item) => !Number.isInteger(item) || item <= 0)) fail(`invalid integer list: ${value}`); + return values; +} + +function integerEnv(name, fallback) { + const value = Number(process.env[name] || fallback); + if (!Number.isInteger(value) || value <= 0) fail(`${name} must be a positive integer`); + return value; +} + +function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function fail(message) { print(`ERROR: ${message}`); quit(1); } diff --git a/performance/room-join/lib.js b/performance/room-join/lib.js new file mode 100644 index 00000000..800d02fd --- /dev/null +++ b/performance/room-join/lib.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const PROMETHEUS_QUERIES = { + processCpu: 'process_cpu_usage', + heapUsed: 'jvm_memory_used_bytes{area="heap"}', + gcPauseRate: 'rate(jvm_gc_pause_seconds_sum[1m])', + httpAverage: 'sum(rate(http_server_requests_seconds_sum{uri="/api/rooms/{roomId}/join"}[1m]))/sum(rate(http_server_requests_seconds_count{uri="/api/rooms/{roomId}/join"}[1m]))', + httpMax: 'max(http_server_requests_seconds_max{uri="/api/rooms/{roomId}/join"})', + mongoCommandRate: 'sum(rate(mongodb_driver_commands_seconds_count[1m]))', + mongoCommandMax: 'max(mongodb_driver_commands_seconds_max)', + mongoCheckedOut: 'sum(mongodb_driver_pool_checkedout)', + mongoWaitQueue: 'sum(mongodb_driver_pool_waitqueuesize)', + mongoCheckoutFailures: 'sum(increase(mongodb_driver_pool_checkoutfailed_total[1m]))', + tomcatBusyThreads: 'sum(tomcat_threads_busy_threads)', + tomcatCurrentThreads: 'sum(tomcat_threads_current_threads)', + tomcatMaxThreads: 'sum(tomcat_threads_config_max_threads)', + tomcatBusyRatio: 'sum(tomcat_threads_busy_threads)/sum(tomcat_threads_config_max_threads)', +}; + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith('--')) throw new Error(`unexpected argument: ${argument}`); + const [key, inline] = argument.slice(2).split('=', 2); + const value = inline === undefined ? argv[++index] : inline; + if (value === undefined || value.startsWith('--')) throw new Error(`missing value for --${key}`); + values[key] = value; + } + return values; +} + +function median(values) { + if (!values.length) return null; + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +function decideOptimization(observation) { + if (observation.messagesScaleWithLatency && observation.planStage === 'COLLSCAN') { + return '{ room: 1, timestamp: 1 } index'; + } + if (observation.participantsScaleWithCommands) { + return 'batch participant lookup and remove duplicate response assembly'; + } + if (observation.newParticipantOnlyRegression || observation.missingParticipant) { + return '$addToSet atomic participant update'; + } + return 'no optimization selected; collect more Stage 0 evidence'; +} + +function ensureResultPath(root, requested) { + const resultRoot = path.resolve(root, 'results'); + const resolved = path.resolve(root, requested); + if (resolved !== resultRoot && !resolved.startsWith(`${resultRoot}${path.sep}`)) { + throw new Error(`result path must stay under ${resultRoot}`); + } + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + return resolved; +} + +module.exports = { PROMETHEUS_QUERIES, parseArgs, median, decideOptimization, ensureResultPath }; diff --git a/performance/room-join/prometheus.performance.yml b/performance/room-join/prometheus.performance.yml new file mode 100644 index 00000000..551b357c --- /dev/null +++ b/performance/room-join/prometheus.performance.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 1s + scrape_timeout: 1s + +scrape_configs: + - job_name: spring-boot-app + metrics_path: /actuator/prometheus + static_configs: + - targets: ["app:5001"] + + - job_name: mongodb + static_configs: + - targets: ["mongodb-exporter:9216"] diff --git a/performance/room-join/room-join.js b/performance/room-join/room-join.js index d77f3069..386f3d03 100644 --- a/performance/room-join/room-join.js +++ b/performance/room-join/room-join.js @@ -1,91 +1,181 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Counter, Rate, Trend } from 'k6/metrics'; +import exec from 'k6/execution'; const API_BASE_URL = __ENV.API_BASE_URL || 'http://localhost:5001'; -const TEST_NAME = __ENV.TEST_NAME || 'room-join-local'; -const FIXTURE_ID = __ENV.FIXTURE_ID || 'unspecified'; -const VUS = Number(__ENV.VUS || 10); -const DURATION = __ENV.DURATION || '3m'; -const THINK_TIME_MS = Number(__ENV.THINK_TIME_MS || 1000); -const ACCOUNT_COUNT = Number(__ENV.ACCOUNT_COUNT || 30); -const PASSWORD = __ENV.LOAD_TEST_PASSWORD || 'Test1234!'; - -const duration = new Trend('room_join_duration', true); -const errors = new Rate('room_join_errors'); +const TEST_ID = __ENV.TEST_ID || 'room-join-local'; +const FIXTURE_ID = required('FIXTURE_ID'); +const ROOM_ID = required('ROOM_ID'); +const MODE = __ENV.MODE || 'idempotent'; +const VUS = positiveInteger('VUS', 1); +const DURATION = __ENV.DURATION || '30s'; +const THINK_TIME_MS = nonNegativeNumber('THINK_TIME_MS', 0); +const PASSWORD = __ENV.PERF_PASSWORD || 'Test1234!'; +const EMAIL_DOMAIN = __ENV.EMAIL_DOMAIN || 'perf-join.test'; +const TIMEOUT_MS = positiveInteger('TIMEOUT_MS', 30000); +const SMOKE_P95_MS = Number(__ENV.SMOKE_P95_MS || 0); +const RESULT_PATH = __ENV.RESULT_PATH || `performance/room-join/results/${TEST_ID}.json`; +const ITERATIONS = Number(__ENV.ITERATIONS || 0); +const PARTICIPANTS = positiveInteger('PARTICIPANTS', 1); +const ACCOUNT_PREFIX = __ENV.ACCOUNT_PREFIX || FIXTURE_ID.replace(/-p\d+-m\d+$/, ''); + +if (!['idempotent', 'new-participant'].includes(MODE)) { + throw new Error(`MODE must be idempotent or new-participant, got ${MODE}`); +} + +const joinDuration = new Trend('room_join_duration', true); +const joinErrors = new Rate('room_join_errors'); const completed = new Counter('room_join_completed'); const timeouts = new Counter('room_join_timeouts'); +const thresholds = { + room_join_errors: ['rate<=0.01'], + room_join_timeouts: ['count==0'], +}; +if (SMOKE_P95_MS > 0) thresholds.room_join_duration = [`p(95)<=${SMOKE_P95_MS * 2}`]; + export const options = { - vus: VUS, - duration: DURATION, + ...(ITERATIONS > 0 + ? { scenarios: { join: { executor: 'shared-iterations', vus: VUS, iterations: ITERATIONS, maxDuration: DURATION } } } + : { vus: VUS, duration: DURATION }), + setupTimeout: '5m', summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], - thresholds: { - room_join_errors: ['rate<=0.01'], - room_join_timeouts: ['count==0'], - }, + thresholds, }; +function required(name) { + const value = __ENV[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +function positiveInteger(name, fallback) { + const value = Number(__ENV[name] || fallback); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +function nonNegativeNumber(name, fallback) { + const value = Number(__ENV[name] || fallback); + if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be non-negative`); + return value; +} + function account(index) { - return { email: `loadtest-${index}@test.com`, password: PASSWORD }; + const role = MODE === 'idempotent' ? 'member' : 'new'; + return { email: `${ACCOUNT_PREFIX}-${role}-${index}@${EMAIL_DOMAIN}`, password: PASSWORD }; } +let newParticipantJoined = false; + export function setup() { - if (VUS > ACCOUNT_COUNT) throw new Error(`VUS (${VUS}) exceeds ACCOUNT_COUNT (${ACCOUNT_COUNT})`); const tokens = []; - for (let index = 0; index < ACCOUNT_COUNT; index += 1) { + const userIds = []; + const accountCount = MODE === 'idempotent' ? Math.min(VUS, PARTICIPANTS) : VUS; + for (let index = 0; index < accountCount; index += 1) { const response = http.post(`${API_BASE_URL}/api/auth/login`, JSON.stringify(account(index)), { - headers: { 'Content-Type': 'application/json' }, timeout: '10s', tags: { phase: 'setup' }, + headers: { 'Content-Type': 'application/json' }, + timeout: `${TIMEOUT_MS}ms`, + tags: { phase: 'setup' }, }); - if (response.status !== 200 || !response.json('token')) { - throw new Error(`Fixed account ${account(index).email} unavailable (HTTP ${response.status})`); + const token = response.json('token'); + const userId = response.json('user._id') || response.json('user.id'); + if (response.status !== 200 || !token || !userId) { + throw new Error(`Fixture account unavailable: ${account(index).email} (HTTP ${response.status})`); } - tokens.push(response.json('token')); + tokens.push(token); + userIds.push(userId); } - const rooms = http.get(`${API_BASE_URL}/api/rooms`, { - headers: { Authorization: `Bearer ${tokens[0]}` }, timeout: '30s', tags: { phase: 'setup' }, - }); - const roomId = rooms.json('data.0._id') || rooms.json('data.0.id'); - if (rooms.status !== 200 || !roomId) throw new Error('No room is available for room-join measurement'); - return { tokens, roomId }; + return { tokens, userIds }; } export default function (data) { - const token = data.tokens[(__VU - 1) % data.tokens.length]; - const response = http.post(`${API_BASE_URL}/api/rooms/${data.roomId}/join`, '{}', { - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - timeout: '30s', tags: { phase: 'measurement', endpoint: 'room-join' }, + const fixedIterations = ITERATIONS > 0; + if (MODE === 'new-participant' && !fixedIterations && newParticipantJoined) { + sleep(1); + return; + } + const index = MODE === 'new-participant' && fixedIterations + ? exec.scenario.iterationInTest % data.tokens.length + : (__VU - 1) % data.tokens.length; + const expectedUserId = data.userIds[index]; + const response = http.post(`${API_BASE_URL}/api/rooms/${ROOM_ID}/join`, '{}', { + headers: { + Authorization: `Bearer ${data.tokens[index]}`, + 'Content-Type': 'application/json', + }, + timeout: `${TIMEOUT_MS}ms`, + tags: { phase: 'measurement', endpoint: 'room-join', mode: MODE, fixture: FIXTURE_ID }, }); + let payload = null; + if (response.body) { + try { + payload = response.json(); + } catch (_) { + payload = null; + } + } + const success = check(response, { 'room join returned HTTP 200': (res) => res.status === 200, - 'room join preserved success contract': (res) => res.json('success') === true, - 'room join returned a room': (res) => Boolean(res.json('data._id') || res.json('data.id')), + 'room join preserved success contract': () => payload?.success === true, + 'room join returned expected room': (res) => + (payload?.data?._id || payload?.data?.id) === ROOM_ID, + 'room join contains participant': () => { + const participants = payload?.data?.participants; + return Array.isArray(participants) + && participants.some((participant) => (participant._id || participant.id) === expectedUserId); + }, }); - duration.add(response.timings.duration); - errors.add(!success); + + joinDuration.add(response.timings.duration); + joinErrors.add(!success); completed.add(1); - if (response.error_code === 1050 || response.timings.duration >= 30000) timeouts.add(1); - sleep(THINK_TIME_MS / 1000); + if (response.error_code === 1050 || response.timings.duration >= TIMEOUT_MS) timeouts.add(1); + if (MODE === 'new-participant' && !fixedIterations) newParticipantJoined = true; + if (THINK_TIME_MS > 0) sleep(THINK_TIME_MS / 1000); } -function value(data, metric, field, fallback = 0) { - return data.metrics[metric]?.values[field] ?? fallback; +function metric(data, name, field, fallback = 0) { + return data.metrics[name]?.values[field] ?? fallback; } export function handleSummary(data) { + const runDurationMs = data.state?.testRunDurationMs || metric(data, 'iteration_duration', 'max', 0); + const startedAt = new Date(Date.now() - runDurationMs).toISOString(); + const requests = metric(data, 'room_join_completed', 'count'); + const durationSeconds = runDurationMs / 1000; const summary = { - testName: TEST_NAME, fixtureId: FIXTURE_ID, apiBaseUrl: API_BASE_URL, - model: 'HTTP-only room join', vus: VUS, duration: DURATION, thinkTimeMs: THINK_TIME_MS, + schemaVersion: 1, + testId: TEST_ID, + commit: __ENV.GIT_COMMIT || 'unknown', + fixture: { + id: FIXTURE_ID, + roomId: ROOM_ID, + participants: Number(__ENV.PARTICIPANTS || 0), + messages: Number(__ENV.MESSAGES || 0), + }, + mode: MODE, + vus: VUS, + configuredDuration: DURATION, + startedAt, endedAt: new Date().toISOString(), - p95Ms: value(data, 'room_join_duration', 'p(95)'), - p99Ms: value(data, 'room_join_duration', 'p(99)'), - averageMs: value(data, 'room_join_duration', 'avg'), - errorRate: value(data, 'room_join_errors', 'rate'), - timeoutCount: value(data, 'room_join_timeouts', 'count'), - completedRequests: value(data, 'room_join_completed', 'count'), + requests, + rps: durationSeconds > 0 ? requests / durationSeconds : 0, + latencyMs: { + average: metric(data, 'room_join_duration', 'avg'), + p95: metric(data, 'room_join_duration', 'p(95)'), + p99: metric(data, 'room_join_duration', 'p(99)'), + max: metric(data, 'room_join_duration', 'max'), + }, + errorRate: metric(data, 'room_join_errors', 'rate'), + timeoutCount: metric(data, 'room_join_timeouts', 'count'), + thresholdsPassed: !Object.values(data.metrics).some((item) => item.thresholds + && Object.values(item.thresholds).some((threshold) => threshold.ok === false)), }; return { stdout: `${JSON.stringify(summary, null, 2)}\n`, - [`performance/room-join/results/${TEST_NAME}.json`]: `${JSON.stringify(summary, null, 2)}\n`, + [RESULT_PATH]: `${JSON.stringify(summary, null, 2)}\n`, }; } diff --git a/performance/room-join/run-matrix.js b/performance/room-join/run-matrix.js new file mode 100644 index 00000000..f23ee896 --- /dev/null +++ b/performance/room-join/run-matrix.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { PROMETHEUS_QUERIES, parseArgs, ensureResultPath } = require('./lib'); + +const ROOT = __dirname; +const args = parseArgs(process.argv.slice(2)); +const manifestPath = required('manifest'); +const testId = args['test-id'] || `room-join-${new Date().toISOString().replace(/[:.]/g, '-')}`; +const apiUrl = args['api-url'] || 'http://localhost:5001'; +const prometheusUrl = args['prometheus-url'] || 'http://localhost:9090'; +const mongoUri = args['mongo-uri'] || 'mongodb://localhost:27017/bootcamp-chat'; +const modes = (args.modes || 'idempotent,new-participant').split(','); +const stages = parseStages(args.stages || '1:30s,10:1m,30:1m,50:1m,100:1m'); +const fixtures = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).fixtures; +const commit = command('git', ['rev-parse', 'HEAD']).stdout.trim(); +const aggregate = { schemaVersion: 1, testId, commit, startedAt: new Date().toISOString(), runs: [], stopped: [] }; + +for (const fixture of fixtures) { + if (!fixture.valid) throw new Error(`invalid fixture: ${fixture.id}`); + for (const mode of modes) { + let smokeP95 = 0; + for (const [vus, duration] of stages) { + if (mode === 'new-participant') resetNewParticipants(fixture.roomId); + const runId = `${testId}-${fixture.id}-${mode}-${vus}vu`; + const relativeResult = `results/${runId}.json`; + const resultPath = ensureResultPath(ROOT, relativeResult); + const startedAt = new Date().toISOString(); + const k6Environment = { + API_BASE_URL: apiUrl, TEST_ID: runId, FIXTURE_ID: fixture.id, + ROOM_ID: fixture.roomId, PARTICIPANTS: String(fixture.participants), MESSAGES: String(fixture.messages), + MODE: mode, VUS: String(vus), DURATION: duration, SMOKE_P95_MS: String(smokeP95), + RESULT_PATH: resultPath, GIT_COMMIT: commit, + }; + if (process.env.PERF_PASSWORD) k6Environment.PERF_PASSWORD = process.env.PERF_PASSWORD; + const k6Process = command('k6', ['run', ...k6EnvArgs(k6Environment), path.join(ROOT, 'room-join.js')], undefined, false); + if (!fs.existsSync(resultPath)) throw new Error(`k6 did not write ${resultPath}: ${k6Process.stderr}`); + const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); + result.observability = collectPrometheus(prometheusUrl, startedAt, result.endedAt); + result.waitQueueDetected = maximum(result.observability.mongoWaitQueue) > 0; + result.checkoutFailureDetected = maximum(result.observability.mongoCheckoutFailures) > 0; + fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`); + aggregate.runs.push(result); + if (vus === 1) smokeP95 = result.latencyMs.p95; + const reason = stopReason(result, smokeP95, k6Process.status); + if (reason) { + aggregate.stopped.push({ fixture: fixture.id, mode, afterVus: vus, reason }); + break; + } + } + } +} + +function resetNewParticipants(roomId) { + const escapedTestId = JSON.stringify(fixturesTestId()); + const escapedRoomId = JSON.stringify(roomId); + const script = `const ids=db.users.find({perfJoinTestId:${escapedTestId},perfJoinRole:'new'},{_id:1}).toArray().map(u=>u._id.toString()); db.rooms.updateOne({_id:ObjectId(${escapedRoomId}),perfJoinTestId:${escapedTestId}},{$pull:{participantIds:{$in:ids}}}); print('reset='+ids.length);`; + command('mongosh', ['--quiet', mongoUri, '--eval', script]); +} + +function fixturesTestId() { + const ids = new Set(fixtures.map((fixture) => fixture.id.match(/^perf-join-(.+)-p\d+-m\d+$/)?.[1])); + if (ids.size !== 1 || ids.has(undefined)) throw new Error('manifest fixture IDs do not share one valid test ID'); + return [...ids][0]; +} + +aggregate.endedAt = new Date().toISOString(); +const aggregatePath = ensureResultPath(ROOT, `results/${testId}-aggregate.json`); +fs.writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`); +console.log(aggregatePath); + +function collectPrometheus(baseUrl, start, end) { + const observations = {}; + for (const [name, query] of Object.entries(PROMETHEUS_QUERIES)) { + const url = `${baseUrl}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&step=5s`; + try { + const response = command('curl', ['-fsS', url], undefined, false); + const payload = JSON.parse(response.stdout); + observations[name] = (payload.data?.result || []).map((series) => ({ + metric: series.metric, + values: series.values.map(([timestamp, value]) => [timestamp, Number(value)]), + })); + } catch (error) { + observations[name] = { error: error.message }; + } + } + return observations; +} + +function maximum(series) { + if (!Array.isArray(series)) return 0; + return Math.max(0, ...series.flatMap((item) => item.values.map((pair) => pair[1])).filter(Number.isFinite)); +} + +function stopReason(result, smokeP95, exitCode) { + if (exitCode !== 0 || !result.thresholdsPassed) return 'k6 threshold failed'; + if (result.errorRate > 0.01) return 'error rate exceeded 1%'; + if (result.timeoutCount > 0) return 'timeout detected'; + if (smokeP95 > 0 && result.latencyMs.p95 > smokeP95 * 2) return 'p95 exceeded 2x smoke p95'; + if (result.waitQueueDetected) return 'Mongo wait queue detected'; + if (result.checkoutFailureDetected) return 'Mongo checkout failure detected'; + return null; +} + +function parseStages(value) { + return value.split(',').map((stage) => { + const [vus, duration] = stage.split(':'); + if (!/^\d+$/.test(vus) || Number(vus) <= 0 || !/^\d+(ms|s|m|h)$/.test(duration || '')) { + throw new Error(`invalid stage: ${stage}`); + } + return [Number(vus), duration]; + }); +} + +function command(executable, commandArgs, env, throwOnFailure = true) { + const result = spawnSync(executable, commandArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env }); + if (result.error || (throwOnFailure && result.status !== 0)) { + throw new Error(`${executable} failed: ${result.error?.message || result.stderr}`); + } + return result; +} + +function k6EnvArgs(environment) { + return Object.entries(environment).flatMap(([name, value]) => ['-e', `${name}=${value}`]); +} +function required(name) { if (!args[name]) throw new Error(`--${name} is required`); return args[name]; } diff --git a/performance/room-join/stage0.js b/performance/room-join/stage0.js new file mode 100644 index 00000000..83b6008f --- /dev/null +++ b/performance/room-join/stage0.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { parseArgs, ensureResultPath } = require('./lib'); + +const args = parseArgs(process.argv.slice(2)); +const fixture = required('fixture'); +const roomId = required('room-id'); +const mode = args.mode || 'idempotent'; +const testId = args['test-id'] || `stage0-${fixture}-${mode}`; +const mongoUri = args['mongo-uri'] || 'mongodb://localhost:27017/bootcamp-chat'; +const apiUrl = args['api-url'] || 'http://localhost:5001'; +const outputPath = ensureResultPath(__dirname, `results/${testId}-diagnosis.json`); +const start = new Date(); +const previousLevel = Number(mongoEval('db.getProfilingStatus().was')); + +try { + mongoEval('db.setProfilingLevel(2, { slowms: 0 }); print("enabled")'); + const k6Result = path.join(__dirname, 'results', `${testId}-request.json`); + const k6Environment = { + API_BASE_URL: apiUrl, TEST_ID: testId, FIXTURE_ID: fixture, ROOM_ID: roomId, + MODE: mode, VUS: '1', ITERATIONS: '1', DURATION: '30s', RESULT_PATH: k6Result, + PARTICIPANTS: args.participants || '1', MESSAGES: args.messages || '0', + }; + if (process.env.PERF_PASSWORD) k6Environment.PERF_PASSWORD = process.env.PERF_PASSWORD; + run('k6', ['run', ...k6EnvArgs(k6Environment), path.join(__dirname, 'room-join.js')]); + const profiler = JSON.parse(mongoEval(` + const since = ISODate('${start.toISOString()}'); + const rows = db.system.profile.find({ ts: { $gte: since } }).toArray(); + const commands = {}; + rows.forEach(r => { const key = r.ns + ':' + (r.op || r.commandName || 'command'); commands[key] = (commands[key] || 0) + 1; }); + print(EJSON.stringify({ commandCount: rows.length, commands, rows: rows.map(r => ({ ts:r.ts, op:r.op, ns:r.ns, millis:r.millis, docsExamined:r.docsExamined, keysExamined:r.keysExamined, nreturned:r.nreturned, command:r.command })) })); + `)); + const explain = JSON.parse(mongoEval(` + const history = db.messages.find({room:'${roomId}'}).sort({timestamp:-1}).limit(30).explain('executionStats'); + const recent = db.messages.find({room:'${roomId}',timestamp:{$gte:new Date(Date.now()-600000)}}).explain('executionStats'); + function summary(x) { const e=x.executionStats; return { winningPlan:x.queryPlanner.winningPlan, nReturned:e.nReturned, totalDocsExamined:e.totalDocsExamined, totalKeysExamined:e.totalKeysExamined, executionTimeMillis:e.executionTimeMillis }; } + print(EJSON.stringify({ history:summary(history), recentCount:summary(recent) })); + `)); + fs.writeFileSync(outputPath, `${JSON.stringify({ + schemaVersion: 1, testId, fixture, roomId, mode, + startedAt: start.toISOString(), endedAt: new Date().toISOString(), profiler, explain, + }, null, 2)}\n`); + console.log(outputPath); +} finally { + mongoEval(`db.setProfilingLevel(${Number.isInteger(previousLevel) ? previousLevel : 0}); print('restored')`); +} + +function mongoEval(script) { + return run('mongosh', ['--quiet', mongoUri, '--eval', script]).stdout.trim().split('\n').at(-1); +} +function run(executable, commandArgs, env) { + const result = spawnSync(executable, commandArgs, { encoding: 'utf8', env }); + if (result.error || result.status !== 0) throw new Error(`${executable} failed: ${result.error?.message || result.stderr}`); + return result; +} +function k6EnvArgs(environment) { + return Object.entries(environment).flatMap(([name, value]) => ['-e', `${name}=${value}`]); +} +function required(name) { if (!args[name]) throw new Error(`--${name} is required`); return args[name]; } From 4bdede9cd8234c262237028769fcc95cfa6d21ff Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 19:18:41 +0900 Subject: [PATCH 19/32] perf: optimize room detail and REST join queries --- .../chatapp/controller/RoomController.java | 37 +++-- .../chatapp/repository/RoomRepository.java | 2 +- .../repository/RoomRepositoryCustom.java | 8 ++ .../repository/RoomRepositoryImpl.java | 27 ++++ .../com/ktb/chatapp/service/RoomService.java | 48 ++++--- .../RoomControllerRoomDetailTest.java | 135 ++++++++++++++++++ .../RoomRepositoryImplIntegrationTest.java | 97 +++++++++++++ .../ktb/chatapp/service/RoomServiceTest.java | 49 +++++++ 8 files changed, 373 insertions(+), 30 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryCustom.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryImpl.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/controller/RoomControllerRoomDetailTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryImplIntegrationTest.java 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..108bf773 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java @@ -20,15 +20,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 - 채팅방 목록 조회, 생성, 참여, 헬스체크") @@ -232,16 +237,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 +275,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.findAllById(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/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..67653f69 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.ktb.chatapp.repository; + +import com.ktb.chatapp.model.Room; + +public interface RoomRepositoryCustom { + + Room addParticipantAndReturn(String roomId, String userId); +} 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..061e1b30 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.ktb.chatapp.repository; + +import com.ktb.chatapp.model.Room; +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; + +@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)); + Update update = new Update().addToSet("participantIds", userId); + return mongoTemplate.findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true), + Room.class + ); + } +} 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 61889789..5422abf6 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 @@ -176,15 +176,13 @@ 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()) { @@ -194,36 +192,50 @@ public Room joinRoom(String roomId, String password, String name) { } // 이미 참여중인지 확인 - if (!room.getParticipantIds().contains(user.getId())) { - // 채팅방 참여 - room.getParticipantIds().add(user.getId()); - room = roomRepository.save(room); + if (!room.getParticipantIds().contains(userId)) { + room = roomRepository.addParticipantAndReturn(roomId, userId); + if (room == null) { + return null; + } } - + + RoomResponse roomResponse = mapToRoomResponse(room, userId); + // 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); } - 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.findAllById(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()); 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..c1f0cdc9 --- /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.findAllById(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).findAllById(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.findAllById(anySet())).thenReturn(List.of()); + + ResponseEntity response = roomController.getRoomById("room-1", principal("viewer@test.com")); + + assertEquals(500, response.getStatusCode().value()); + verify(userRepository).findAllById(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()).findAllById(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..d0df70f5 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryImplIntegrationTest.java @@ -0,0 +1,97 @@ +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.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.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 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)); + } + } +} 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 index 05a697c7..858fabae 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -3,7 +3,9 @@ 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.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; @@ -13,6 +15,7 @@ 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.model.Room; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.RoomRepository; @@ -24,10 +27,12 @@ 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 { @@ -110,6 +115,50 @@ void getAllRooms_batchesUsersAndMessageCountsOnceAndKeepsContract() throws Excep assertFalse(json.has("createdAtDateTime")); } + @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.findAllById(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).findAllById(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.findAllById(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()); + } + private static Room room( String id, String name, From d4456cc7d418afa1c22845fed5c89a171b5c591c Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 19:27:02 +0900 Subject: [PATCH 20/32] feat: migrate unique users email index --- .../config/UserEmailIndexMigration.java | 62 +++++++++ .../main/java/com/ktb/chatapp/model/User.java | 4 +- ...serEmailIndexMigrationIntegrationTest.java | 126 ++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/UserEmailIndexMigration.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/config/UserEmailIndexMigrationIntegrationTest.java 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/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/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(); + } +} From 69e00742286a56a2b24382e7ce2d24cb0bef0eff Mon Sep 17 00:00:00 2001 From: jaksfj Date: Tue, 11 Aug 2026 19:27:30 +0900 Subject: [PATCH 21/32] perf: record room detail and join experiments --- .../results/exp2-after-p10-m1k-smoke-1.json | 22 ++++ .../results/exp2-after-p10-m1k-smoke-2.json | 22 ++++ .../results/exp2-after-p10-m1k-smoke-3.json | 22 ++++ .../results/exp2-after-p10-m1k-vu10-1.json | 22 ++++ .../results/exp2-after-p10-m1k-vu10-2.json | 22 ++++ .../results/exp2-after-p10-m1k-vu10-3.json | 22 ++++ .../results/exp2-after-p100-m1k-smoke-1.json | 22 ++++ .../results/exp2-after-p100-m1k-smoke-2.json | 22 ++++ .../results/exp2-after-p100-m1k-smoke-3.json | 22 ++++ .../results/exp2-after-p100-m1k-vu10-1.json | 22 ++++ .../results/exp2-after-p100-m1k-vu10-2.json | 22 ++++ .../results/exp2-after-p100-m1k-vu10-3.json | 22 ++++ .../results/exp2-after-p500-m1k-smoke-1.json | 22 ++++ .../results/exp2-after-p500-m1k-smoke-2.json | 22 ++++ .../results/exp2-after-p500-m1k-smoke-3.json | 22 ++++ .../results/exp2-after-p500-m1k-vu10-1.json | 22 ++++ .../results/exp2-after-p500-m1k-vu10-2.json | 22 ++++ .../results/exp2-after-p500-m1k-vu10-3.json | 22 ++++ .../room-detail/room-detail-exp2-report.md | 36 +++++++ performance/room-detail/room-detail.js | 1 + performance/room-join/README.md | 18 ++++ performance/room-join/fixture.js | 50 +++++++++ .../results/join-final-mutation-vu10-1.json | 19 ++++ .../results/join-final-mutation-vu10-2.json | 19 ++++ .../results/join-final-mutation-vu10-3.json | 19 ++++ .../results/join-final-repeated-vu10-1.json | 19 ++++ .../results/join-final-repeated-vu10-2.json | 19 ++++ .../results/join-final-repeated-vu10-3.json | 19 ++++ performance/room-join/room-join-mutation.js | 100 ++++++++++++++++++ .../room-join-optimization-report.md | 56 ++++++++++ performance/room-join/room-join.js | 22 ++-- 31 files changed, 786 insertions(+), 7 deletions(-) create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-smoke-1.json create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-smoke-2.json create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-smoke-3.json create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-vu10-1.json create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-vu10-2.json create mode 100644 performance/room-detail/results/exp2-after-p10-m1k-vu10-3.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-smoke-1.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-smoke-2.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-smoke-3.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-vu10-1.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-vu10-2.json create mode 100644 performance/room-detail/results/exp2-after-p100-m1k-vu10-3.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-smoke-1.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-smoke-2.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-smoke-3.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-vu10-1.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-vu10-2.json create mode 100644 performance/room-detail/results/exp2-after-p500-m1k-vu10-3.json create mode 100644 performance/room-detail/room-detail-exp2-report.md create mode 100644 performance/room-join/fixture.js create mode 100644 performance/room-join/results/join-final-mutation-vu10-1.json create mode 100644 performance/room-join/results/join-final-mutation-vu10-2.json create mode 100644 performance/room-join/results/join-final-mutation-vu10-3.json create mode 100644 performance/room-join/results/join-final-repeated-vu10-1.json create mode 100644 performance/room-join/results/join-final-repeated-vu10-2.json create mode 100644 performance/room-join/results/join-final-repeated-vu10-3.json create mode 100644 performance/room-join/room-join-mutation.js create mode 100644 performance/room-join/room-join-optimization-report.md diff --git a/performance/room-detail/results/exp2-after-p10-m1k-smoke-1.json b/performance/room-detail/results/exp2-after-p10-m1k-smoke-1.json new file mode 100644 index 00000000..3380cb38 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-smoke-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-smoke-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:35:48.254Z", + "endedAt": "2026-08-11T08:35:58.584Z", + "requests": 3164, + "rps": 306.3175279362263, + "averageMs": 3.067639380530983, + "p95Ms": 5.3027, + "p99Ms": 7.539099999999999, + "maxMs": 50.862, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p10-m1k-smoke-2.json b/performance/room-detail/results/exp2-after-p10-m1k-smoke-2.json new file mode 100644 index 00000000..a43239fd --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-smoke-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-smoke-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:35:59.532Z", + "endedAt": "2026-08-11T08:36:09.556Z", + "requests": 3494, + "rps": 348.5782268302477, + "averageMs": 2.7766694333142516, + "p95Ms": 3.96235, + "p99Ms": 5.480699999999996, + "maxMs": 26.171, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p10-m1k-smoke-3.json b/performance/room-detail/results/exp2-after-p10-m1k-smoke-3.json new file mode 100644 index 00000000..711adbcd --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-smoke-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-smoke-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:36:10.027Z", + "endedAt": "2026-08-11T08:36:20.044Z", + "requests": 2926, + "rps": 292.12340067180395, + "averageMs": 3.319289131920709, + "p95Ms": 5.782749999999999, + "p99Ms": 11.635999999999996, + "maxMs": 38.461, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p10-m1k-vu10-1.json b/performance/room-detail/results/exp2-after-p10-m1k-vu10-1.json new file mode 100644 index 00000000..085a5a08 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-vu10-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-vu10-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:36:20.486Z", + "endedAt": "2026-08-11T08:36:30.507Z", + "requests": 14723, + "rps": 1469.238987493456, + "averageMs": 6.646223188208913, + "p95Ms": 11.088499999999996, + "p99Ms": 19.041559999999993, + "maxMs": 80.3, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p10-m1k-vu10-2.json b/performance/room-detail/results/exp2-after-p10-m1k-vu10-2.json new file mode 100644 index 00000000..d7fa406b --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-vu10-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-vu10-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:36:30.969Z", + "endedAt": "2026-08-11T08:36:40.991Z", + "requests": 15395, + "rps": 1536.1478182310561, + "averageMs": 6.353281584930152, + "p95Ms": 11.227899999999998, + "p99Ms": 20.358739999999994, + "maxMs": 66.791, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p10-m1k-vu10-3.json b/performance/room-detail/results/exp2-after-p10-m1k-vu10-3.json new file mode 100644 index 00000000..d61e4213 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p10-m1k-vu10-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p10-m1k-vu10-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p10-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 10, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:36:41.416Z", + "endedAt": "2026-08-11T08:36:51.443Z", + "requests": 17081, + "rps": 1703.6074168809434, + "averageMs": 5.720088753585877, + "p95Ms": 8.393, + "p99Ms": 15.023, + "maxMs": 124.176, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 1855 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-smoke-1.json b/performance/room-detail/results/exp2-after-p100-m1k-smoke-1.json new file mode 100644 index 00000000..dabdd9b1 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-smoke-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-smoke-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:40:36.562Z", + "endedAt": "2026-08-11T08:40:46.624Z", + "requests": 1769, + "rps": 175.8175790744283, + "averageMs": 5.435735443753533, + "p95Ms": 7.898599999999998, + "p99Ms": 12.136759999999997, + "maxMs": 39.629, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-smoke-2.json b/performance/room-detail/results/exp2-after-p100-m1k-smoke-2.json new file mode 100644 index 00000000..18983f12 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-smoke-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-smoke-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:40:46.885Z", + "endedAt": "2026-08-11T08:40:56.904Z", + "requests": 1896, + "rps": 189.25795409955586, + "averageMs": 5.073205168776373, + "p95Ms": 6.37425, + "p99Ms": 10.521199999999997, + "maxMs": 59.529, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-smoke-3.json b/performance/room-detail/results/exp2-after-p100-m1k-smoke-3.json new file mode 100644 index 00000000..c5f2e013 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-smoke-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-smoke-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:40:57.191Z", + "endedAt": "2026-08-11T08:41:07.208Z", + "requests": 1421, + "rps": 141.87158674534, + "averageMs": 6.789782547501754, + "p95Ms": 12.404, + "p99Ms": 20.290599999999998, + "maxMs": 43.52, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-vu10-1.json b/performance/room-detail/results/exp2-after-p100-m1k-vu10-1.json new file mode 100644 index 00000000..b8fb61bd --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-vu10-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-vu10-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:41:07.475Z", + "endedAt": "2026-08-11T08:41:17.506Z", + "requests": 6367, + "rps": 634.7464408789069, + "averageMs": 15.410950683210281, + "p95Ms": 33.222699999999975, + "p99Ms": 78.18676, + "maxMs": 204.632, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-vu10-2.json b/performance/room-detail/results/exp2-after-p100-m1k-vu10-2.json new file mode 100644 index 00000000..efb04381 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-vu10-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-vu10-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:41:17.946Z", + "endedAt": "2026-08-11T08:41:27.997Z", + "requests": 2762, + "rps": 274.81843277475474, + "averageMs": 35.82023714699495, + "p95Ms": 91.45304999999995, + "p99Ms": 145.62409999999997, + "maxMs": 343.752, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p100-m1k-vu10-3.json b/performance/room-detail/results/exp2-after-p100-m1k-vu10-3.json new file mode 100644 index 00000000..53d1ca1f --- /dev/null +++ b/performance/room-detail/results/exp2-after-p100-m1k-vu10-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p100-m1k-vu10-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p100-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 100, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:41:28.965Z", + "endedAt": "2026-08-11T08:41:39.097Z", + "requests": 2687, + "rps": 265.217168147882, + "averageMs": 36.81774060290287, + "p95Ms": 89.50489999999996, + "p99Ms": 137.22372, + "maxMs": 254.646, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 15538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-smoke-1.json b/performance/room-detail/results/exp2-after-p500-m1k-smoke-1.json new file mode 100644 index 00000000..8d108ecd --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-smoke-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-smoke-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:42:11.896Z", + "endedAt": "2026-08-11T08:42:21.960Z", + "requests": 690, + "rps": 68.5663725467399, + "averageMs": 13.845437681159432, + "p95Ms": 19.4805, + "p99Ms": 43.09459999999999, + "maxMs": 95.292, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-smoke-2.json b/performance/room-detail/results/exp2-after-p500-m1k-smoke-2.json new file mode 100644 index 00000000..96e38d22 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-smoke-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-smoke-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:42:22.271Z", + "endedAt": "2026-08-11T08:42:32.304Z", + "requests": 668, + "rps": 66.58674927676623, + "averageMs": 14.32484281437125, + "p95Ms": 22.045399999999987, + "p99Ms": 43.93642, + "maxMs": 152.592, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-smoke-3.json b/performance/room-detail/results/exp2-after-p500-m1k-smoke-3.json new file mode 100644 index 00000000..ca75aca3 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-smoke-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-smoke-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 1, + "duration": "10s", + "startedAt": "2026-08-11T08:42:32.632Z", + "endedAt": "2026-08-11T08:42:42.663Z", + "requests": 780, + "rps": 77.75924183542915, + "averageMs": 12.211232051282042, + "p95Ms": 15.3067, + "p99Ms": 18.95093, + "maxMs": 31.664, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-vu10-1.json b/performance/room-detail/results/exp2-after-p500-m1k-vu10-1.json new file mode 100644 index 00000000..5f9ec843 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-vu10-1.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-vu10-1", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:42:42.919Z", + "endedAt": "2026-08-11T08:42:52.971Z", + "requests": 3988, + "rps": 396.77316364813623, + "averageMs": 24.162855065195547, + "p95Ms": 33.0296, + "p99Ms": 40.84985999999999, + "maxMs": 78.84, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-vu10-2.json b/performance/room-detail/results/exp2-after-p500-m1k-vu10-2.json new file mode 100644 index 00000000..a579dddb --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-vu10-2.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-vu10-2", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:42:53.830Z", + "endedAt": "2026-08-11T08:43:03.882Z", + "requests": 3535, + "rps": 351.70622789648235, + "averageMs": 27.33135190947665, + "p95Ms": 42.06889999999999, + "p99Ms": 72.57297999999997, + "maxMs": 196.539, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/results/exp2-after-p500-m1k-vu10-3.json b/performance/room-detail/results/exp2-after-p500-m1k-vu10-3.json new file mode 100644 index 00000000..8a338e06 --- /dev/null +++ b/performance/room-detail/results/exp2-after-p500-m1k-vu10-3.json @@ -0,0 +1,22 @@ +{ + "testName": "exp2-after-p500-m1k-vu10-3", + "fixtureId": "room-detail-exp2-20260811", + "roomId": "room-detail-exp2-20260811:room:p500-m1k", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "participants": 500, + "recentMessages": 1000, + "vus": 10, + "duration": "10s", + "startedAt": "2026-08-11T08:43:04.272Z", + "endedAt": "2026-08-11T08:43:14.316Z", + "requests": 2874, + "rps": 286.1622053806261, + "averageMs": 33.804678496868526, + "p95Ms": 75.44479999999993, + "p99Ms": 166.90414999999996, + "maxMs": 351.347, + "errorRate": 0, + "timeoutCount": 0, + "averageResponseBytes": 77538 +} diff --git a/performance/room-detail/room-detail-exp2-report.md b/performance/room-detail/room-detail-exp2-report.md new file mode 100644 index 00000000..2f86c1bc --- /dev/null +++ b/performance/room-detail/room-detail-exp2-report.md @@ -0,0 +1,36 @@ +# Room Detail experiment 2: batched user lookup + +Date: 2026-08-11 (Asia/Seoul) +Base: `origin/master` (`01b725d`) +Fixture: `room-detail-exp2-20260811`, 1,000 recent messages +Backend: isolated `http://localhost:15001` + +The controller now combines creator and participant IDs and calls `findAllById` once. It then +rebuilds participants in the Room's original iteration order, omits missing participants, and +keeps the existing error when the creator is missing. + +## Results + +Each after value is the median of three 10-second runs. The checked-in master baseline is the +available single 30/60-second run, so latency comparisons are directional rather than a replacement +for the full-duration rerun. + +| Participants | VUs | Baseline p95 (ms) | After median p95 (ms) | Change | Baseline p99 (ms) | After median p99 (ms) | +|---:|---:|---:|---:|---:|---:|---:| +| 10 | 1 | 15.84 | 5.30 | -66.5% | 27.93 | 7.54 | +| 10 | 10 | 25.61 | 11.09 | -56.7% | 34.91 | 19.04 | +| 100 | 1 | 54.04 | 7.90 | -85.4% | 78.64 | 12.14 | +| 100 | 10 | 172.12 | 89.50 | -48.0% | 325.13 | 137.22 | +| 500 | 1 | 306.37 | 19.48 | -93.6% | 368.31 | 43.09 | +| 500 | 10 | 765.54 | 42.07 | -94.5% | 859.55 | 72.57 | + +All 18 after runs had error rate 0 and zero timeouts. Across 86,220 measured requests, the isolated +application reported 86,220 message aggregates and 86,238 User find commands; the extra 18 finds +are the setup logins. This proves one User find per Room Detail request. The 500-participant result +also uses Mongo cursor `getMore`, but still starts only one find command. + +## Decision + +Adopted. Every p95 comparison exceeds the 10% target and every p99 improves. The isolated backend +was not a Prometheus scrape target during these shortened runs, so CPU/heap peak non-regression is +not claimed; the HTTP contract, error, timeout, command-count, and latency checks passed. diff --git a/performance/room-detail/room-detail.js b/performance/room-detail/room-detail.js index fa569bcc..0c18db0f 100644 --- a/performance/room-detail/room-detail.js +++ b/performance/room-detail/room-detail.js @@ -85,6 +85,7 @@ export function handleSummary(data) { fixtureId: FIXTURE_ID, roomId: ROOM_ID, commit: __ENV.COMMIT || 'unknown', + mongoIndexes: __ENV.MONGO_INDEXES || 'unknown', participants: EXPECTED_PARTICIPANTS, recentMessages: EXPECTED_RECENT_MESSAGES, vus: VUS, diff --git a/performance/room-join/README.md b/performance/room-join/README.md index 2307ce8b..0be0d604 100644 --- a/performance/room-join/README.md +++ b/performance/room-join/README.md @@ -13,3 +13,21 @@ k6 run performance/room-join/room-join.js Run the same fixture and settings three times before and after one code change. Adoption requires error rate at most 1%, zero timeouts, Mongo wait queue zero, p95 improvement of at least 10%, and no p99 regression. Result JSON is written under `results/`. + +Set `ROOM_ID` to avoid room-list selection and `ACCOUNT_PREFIX` when using dedicated accounts. + +For a real mutation on every request, create the dedicated fixture and run the mutation scenario: + +```sh +docker compose -f apps/backend/docker-compose.yaml exec -T \ + -e ACTION=create -e FIXTURE_ID=room-join-exp2-20260811 \ + -e ACCOUNT_COUNT=30 -e ACCOUNT_PREFIX=join-exp2-20260811 \ + -e CREATOR_EMAIL=room-detail-exp2-20260811@fixture.invalid \ + -e ROOMS_PER_ACCOUNT=200 mongo mongosh --quiet --file /dev/stdin \ + < performance/room-join/fixture.js + +API_BASE_URL=http://localhost:15001 TEST_NAME=join-mutation-1 \ +FIXTURE_ID=room-join-exp2-20260811 ACCOUNT_PREFIX=join-exp2-20260811 \ +LOAD_TEST_PASSWORD='JoinExp21234!' VUS=10 DURATION=10s THINK_TIME_MS=100 \ +k6 run performance/room-join/room-join-mutation.js +``` diff --git a/performance/room-join/fixture.js b/performance/room-join/fixture.js new file mode 100644 index 00000000..8c8eadfe --- /dev/null +++ b/performance/room-join/fixture.js @@ -0,0 +1,50 @@ +const database = db.getSiblingDB('bootcamp-chat'); +const fixtureId = process.env.FIXTURE_ID; +const action = process.env.ACTION || 'create'; +const accountCount = Number(process.env.ACCOUNT_COUNT || 30); +const accountPrefix = process.env.ACCOUNT_PREFIX || 'loadtest'; +const creatorEmail = process.env.CREATOR_EMAIL; +const roomsPerAccount = Number(process.env.ROOMS_PER_ACCOUNT || 200); +const prefix = `${fixtureId}:room:`; + +if (!fixtureId) throw new Error('FIXTURE_ID is required'); + +function cleanup() { + const removed = database.rooms.deleteMany({ _id: { $regex: `^${prefix}` } }).deletedCount; + printjson({ action: 'cleanup', fixtureId, removedRooms: removed }); +} + +function create() { + cleanup(); + if (!creatorEmail) throw new Error('CREATOR_EMAIL is required'); + const creator = database.users.findOne({ email: creatorEmail }); + if (!creator) throw new Error(`Missing room creator ${creatorEmail}`); + const users = []; + for (let index = 0; index < accountCount; index += 1) { + const email = `${accountPrefix}-${index}@test.com`; + const user = database.users.findOne({ email }); + if (!user) throw new Error(`Missing load-test account ${email}`); + users.push(user); + } + const creatorId = creator._id.toString(); + const now = new Date(); + for (let vu = 1; vu <= accountCount; vu += 1) { + const rooms = []; + for (let iteration = 0; iteration < roomsPerAccount; iteration += 1) { + rooms.push({ + _id: `${prefix}${vu}:${iteration}`, + name: `Room join mutation ${vu}:${iteration}`, + creator: creatorId, + hasPassword: false, + participantIds: [creatorId], + createdAt: now, + }); + } + database.rooms.insertMany(rooms, { ordered: false }); + } + printjson({ action: 'create', fixtureId, accountCount, roomsPerAccount }); +} + +if (action === 'create') create(); +else if (action === 'cleanup') cleanup(); +else throw new Error(`Unknown ACTION: ${action}`); diff --git a/performance/room-join/results/join-final-mutation-vu10-1.json b/performance/room-join/results/join-final-mutation-vu10-1.json new file mode 100644 index 00000000..562f39d6 --- /dev/null +++ b/performance/room-join/results/join-final-mutation-vu10-1.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-mutation-vu10-1", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join, one new participant per request", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "startedAt": "2026-08-11T09:09:47.361Z", + "endedAt": "2026-08-11T09:09:57.529Z", + "p95Ms": 25.91114999999999, + "p99Ms": 41.08063, + "averageMs": 15.689969767441873, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 860 +} diff --git a/performance/room-join/results/join-final-mutation-vu10-2.json b/performance/room-join/results/join-final-mutation-vu10-2.json new file mode 100644 index 00000000..5b10febb --- /dev/null +++ b/performance/room-join/results/join-final-mutation-vu10-2.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-mutation-vu10-2", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join, one new participant per request", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "startedAt": "2026-08-11T09:09:58.631Z", + "endedAt": "2026-08-11T09:10:08.798Z", + "p95Ms": 24.340749999999996, + "p99Ms": 49.80357, + "averageMs": 15.457447674418631, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 860 +} diff --git a/performance/room-join/results/join-final-mutation-vu10-3.json b/performance/room-join/results/join-final-mutation-vu10-3.json new file mode 100644 index 00000000..3ff40726 --- /dev/null +++ b/performance/room-join/results/join-final-mutation-vu10-3.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-mutation-vu10-3", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join, one new participant per request", + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "startedAt": "2026-08-11T09:10:09.861Z", + "endedAt": "2026-08-11T09:10:20.047Z", + "p95Ms": 27.385399999999997, + "p99Ms": 63.7213, + "averageMs": 15.927647674418596, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 860 +} diff --git a/performance/room-join/results/join-final-repeated-vu10-1.json b/performance/room-join/results/join-final-repeated-vu10-1.json new file mode 100644 index 00000000..d2cfff56 --- /dev/null +++ b/performance/room-join/results/join-final-repeated-vu10-1.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-repeated-vu10-1", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "startedAt": "2026-08-11T09:00:27.133Z", + "endedAt": "2026-08-11T09:00:37.411Z", + "p95Ms": 24.095999999999997, + "p99Ms": 43.75119999999989, + "averageMs": 14.266164179104479, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 871 +} diff --git a/performance/room-join/results/join-final-repeated-vu10-2.json b/performance/room-join/results/join-final-repeated-vu10-2.json new file mode 100644 index 00000000..b18f8d78 --- /dev/null +++ b/performance/room-join/results/join-final-repeated-vu10-2.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-repeated-vu10-2", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "startedAt": "2026-08-11T09:00:37.734Z", + "endedAt": "2026-08-11T09:00:47.986Z", + "p95Ms": 28.7301, + "p99Ms": 32.512539999999994, + "averageMs": 14.941232183908037, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 870 +} diff --git a/performance/room-join/results/join-final-repeated-vu10-3.json b/performance/room-join/results/join-final-repeated-vu10-3.json new file mode 100644 index 00000000..49a130f2 --- /dev/null +++ b/performance/room-join/results/join-final-repeated-vu10-3.json @@ -0,0 +1,19 @@ +{ + "testName": "join-final-repeated-vu10-3", + "fixtureId": "room-join-exp2-20260811", + "apiBaseUrl": "http://localhost:15001", + "model": "HTTP-only room join", + "vus": 10, + "duration": "10s", + "thinkTimeMs": 100, + "commit": "4bdede9", + "mongoIndexes": "users.email_unique_messages.room_timestamp_idx", + "startedAt": "2026-08-11T09:00:48.292Z", + "endedAt": "2026-08-11T09:00:58.515Z", + "p95Ms": 24.02685, + "p99Ms": 28.17489, + "averageMs": 16.213308139534902, + "errorRate": 0, + "timeoutCount": 0, + "completedRequests": 860 +} diff --git a/performance/room-join/room-join-mutation.js b/performance/room-join/room-join-mutation.js new file mode 100644 index 00000000..eb225df0 --- /dev/null +++ b/performance/room-join/room-join-mutation.js @@ -0,0 +1,100 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +const API_BASE_URL = __ENV.API_BASE_URL || 'http://localhost:5001'; +const TEST_NAME = __ENV.TEST_NAME || 'room-join-mutation-local'; +const FIXTURE_ID = __ENV.FIXTURE_ID; +const VUS = Number(__ENV.VUS || 10); +const DURATION = __ENV.DURATION || '30s'; +const THINK_TIME_MS = Number(__ENV.THINK_TIME_MS || 1000); +const ACCOUNT_COUNT = Number(__ENV.ACCOUNT_COUNT || 30); +const ACCOUNT_PREFIX = __ENV.ACCOUNT_PREFIX || 'loadtest'; +const PASSWORD = __ENV.LOAD_TEST_PASSWORD || 'Test1234!'; + +const duration = new Trend('room_join_mutation_duration', true); +const errors = new Rate('room_join_mutation_errors'); +const completed = new Counter('room_join_mutation_completed'); +const timeouts = new Counter('room_join_mutation_timeouts'); + +export const options = { + vus: VUS, + duration: DURATION, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], + thresholds: { + room_join_mutation_errors: ['rate<=0.01'], + room_join_mutation_timeouts: ['count==0'], + }, +}; + +function account(index) { + return { email: `${ACCOUNT_PREFIX}-${index}@test.com`, password: PASSWORD }; +} + +export function setup() { + if (!FIXTURE_ID) throw new Error('FIXTURE_ID is required'); + if (VUS > ACCOUNT_COUNT) throw new Error(`VUS (${VUS}) exceeds ACCOUNT_COUNT (${ACCOUNT_COUNT})`); + const tokens = []; + for (let index = 0; index < ACCOUNT_COUNT; index += 1) { + const response = http.post(`${API_BASE_URL}/api/auth/login`, JSON.stringify(account(index)), { + headers: { 'Content-Type': 'application/json' }, timeout: '10s', tags: { phase: 'setup' }, + }); + if (response.status !== 200 || !response.json('token')) { + throw new Error(`Fixed account ${account(index).email} unavailable (HTTP ${response.status})`); + } + tokens.push(response.json('token')); + } + return { tokens }; +} + +export default function (data) { + const roomId = `${FIXTURE_ID}:room:${__VU}:${__ITER}`; + const token = data.tokens[(__VU - 1) % data.tokens.length]; + const response = http.post(`${API_BASE_URL}/api/rooms/${roomId}/join`, '{}', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + timeout: '30s', tags: { phase: 'measurement', endpoint: 'room-join-mutation' }, + }); + const success = check(response, { + 'mutation join returned HTTP 200': (res) => res.status === 200, + 'mutation join preserved success contract': (res) => res.json('success') === true, + 'mutation join returned expected room': (res) => res.json('data._id') === roomId, + 'mutation join added participant': (res) => res.json('data.participantsCount') >= 2, + }); + duration.add(response.timings.duration); + errors.add(!success); + completed.add(1); + if (response.error_code === 1050 || response.timings.duration >= 30000) timeouts.add(1); + sleep(THINK_TIME_MS / 1000); +} + +function value(data, metric, field, fallback = 0) { + return data.metrics[metric]?.values[field] ?? fallback; +} + +export function handleSummary(data) { + const endedAt = new Date(); + const startedAt = new Date(endedAt.getTime() - data.state.testRunDurationMs); + const summary = { + testName: TEST_NAME, + fixtureId: FIXTURE_ID, + apiBaseUrl: API_BASE_URL, + model: 'HTTP-only room join, one new participant per request', + commit: __ENV.COMMIT || 'unknown', + mongoIndexes: __ENV.MONGO_INDEXES || 'unknown', + vus: VUS, + duration: DURATION, + thinkTimeMs: THINK_TIME_MS, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + p95Ms: value(data, 'room_join_mutation_duration', 'p(95)'), + p99Ms: value(data, 'room_join_mutation_duration', 'p(99)'), + averageMs: value(data, 'room_join_mutation_duration', 'avg'), + errorRate: value(data, 'room_join_mutation_errors', 'rate'), + timeoutCount: value(data, 'room_join_mutation_timeouts', 'count'), + completedRequests: value(data, 'room_join_mutation_completed', 'count'), + }; + return { + stdout: `${JSON.stringify(summary, null, 2)}\n`, + [`performance/room-join/results/${TEST_NAME}.json`]: `${JSON.stringify(summary, null, 2)}\n`, + }; +} diff --git a/performance/room-join/room-join-optimization-report.md b/performance/room-join/room-join-optimization-report.md new file mode 100644 index 00000000..4e9256bf --- /dev/null +++ b/performance/room-join/room-join-optimization-report.md @@ -0,0 +1,56 @@ +# REST Room Join sequential optimization report + +Date: 2026-08-11 (Asia/Seoul) +Base: `origin/master` (`01b725d`) +Backend: isolated `http://localhost:15001` +Fixture: `room-join-exp2-20260811` + +## Implemented path + +1. Creator and participants are loaded by one `findAllById` call. +2. The controller passes the session-validated JWT `userId`; Join no longer calls `findByEmail`. +3. New participants use Mongo `findAndModify` with `$addToSet` and `returnNew=true`; existing + participants reuse the initial Room read. +4. RoomService builds one final `RoomResponse` and shares the same object with `RoomUpdatedEvent` + and the HTTP response. + +The repeated scenario uses one password-free room. The mutation scenario recreates 6,000 rooms +before every run and maps each VU/iteration to a unique room, so every measured request is a new +participant mutation. + +## Results + +| Scenario | Runs | Requests | Median p95 (ms) | Median p99 (ms) | Max error | Timeouts | +|---|---:|---:|---:|---:|---:|---:| +| Existing checked-in baseline | 1 | 1,647 | 137.10 | 245.30 | 0 | 0 | +| Final repeated Join | 3 | 2,601 | 24.10 | 32.51 | 0 | 0 | +| Final new-participant mutation | 3 | 2,580 | 25.91 | 49.80 | 0 | 0 | + +The final repeated path improves p95 by 82.4% and p99 by 86.7% against the available baseline. +The new-participant path remains within 7.5% of the repeated-path p95 while performing an actual +atomic write on every request. + +After restarting the backend to reset Micrometer counters, one existing-participant request and one +new-participant request produced: Room find 2, User find 4 (two logins plus two Join mappings), +messages aggregate 2, and Room findAndModify 1. Thus each Join performs one user batch lookup and one +recent-message aggregate, while only a new participant performs the atomic mutation. + +Unit tests verify no `findByEmail` or whole-document `save`, ID-based `isCreator`, participant order, +and that the event and HTTP layers share the service response. Mongo integration tests verify the +returned updated Room and 20 concurrent joins without lost or duplicate participants. + +## Recent-message counter decision + +For the two isolated Join requests, related Mongo command time was 16.319 ms and the two messages +aggregates used 2.919 ms, a 17.89% share. This is below the 20% threshold and the final p95 target is +met, so the exact 30-minute query and `{room: 1, timestamp: 1}` index are retained. No counter code +is introduced. A minute-bucket design remains a follow-up only if a future representative run +crosses the 20% threshold. + +## Scope and limitations + +The after runs are 10 seconds each rather than the README's 3-minute production-profile run, and +the isolated port was not scraped by Prometheus for CPU/heap peaks. The final combined path is +adopted based on contract, command counts, integration tests, zero errors/timeouts, and latency; +the precise contribution of each intermediate Join change was not independently benchmarked. +Socket.IO Join remains a separate follow-up that can reuse `addParticipantAndReturn`. diff --git a/performance/room-join/room-join.js b/performance/room-join/room-join.js index d77f3069..23c757b3 100644 --- a/performance/room-join/room-join.js +++ b/performance/room-join/room-join.js @@ -9,6 +9,8 @@ const VUS = Number(__ENV.VUS || 10); const DURATION = __ENV.DURATION || '3m'; const THINK_TIME_MS = Number(__ENV.THINK_TIME_MS || 1000); const ACCOUNT_COUNT = Number(__ENV.ACCOUNT_COUNT || 30); +const ACCOUNT_PREFIX = __ENV.ACCOUNT_PREFIX || 'loadtest'; +const TARGET_ROOM_ID = __ENV.ROOM_ID; const PASSWORD = __ENV.LOAD_TEST_PASSWORD || 'Test1234!'; const duration = new Trend('room_join_duration', true); @@ -27,7 +29,7 @@ export const options = { }; function account(index) { - return { email: `loadtest-${index}@test.com`, password: PASSWORD }; + return { email: `${ACCOUNT_PREFIX}-${index}@test.com`, password: PASSWORD }; } export function setup() { @@ -42,11 +44,14 @@ export function setup() { } tokens.push(response.json('token')); } - const rooms = http.get(`${API_BASE_URL}/api/rooms`, { - headers: { Authorization: `Bearer ${tokens[0]}` }, timeout: '30s', tags: { phase: 'setup' }, - }); - const roomId = rooms.json('data.0._id') || rooms.json('data.0.id'); - if (rooms.status !== 200 || !roomId) throw new Error('No room is available for room-join measurement'); + let roomId = TARGET_ROOM_ID; + if (!roomId) { + const rooms = http.get(`${API_BASE_URL}/api/rooms`, { + headers: { Authorization: `Bearer ${tokens[0]}` }, timeout: '30s', tags: { phase: 'setup' }, + }); + roomId = rooms.json('data.0._id') || rooms.json('data.0.id'); + if (rooms.status !== 200 || !roomId) throw new Error('No room is available for room-join measurement'); + } return { tokens, roomId }; } @@ -73,10 +78,13 @@ function value(data, metric, field, fallback = 0) { } export function handleSummary(data) { + const endedAt = new Date(); + const startedAt = new Date(endedAt.getTime() - data.state.testRunDurationMs); const summary = { testName: TEST_NAME, fixtureId: FIXTURE_ID, apiBaseUrl: API_BASE_URL, model: 'HTTP-only room join', vus: VUS, duration: DURATION, thinkTimeMs: THINK_TIME_MS, - endedAt: new Date().toISOString(), + commit: __ENV.COMMIT || 'unknown', mongoIndexes: __ENV.MONGO_INDEXES || 'unknown', + startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), p95Ms: value(data, 'room_join_duration', 'p(95)'), p99Ms: value(data, 'room_join_duration', 'p(99)'), averageMs: value(data, 'room_join_duration', 'avg'), From ece990fac1eca4749ff87f09f274fabf594a3cf9 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Tue, 11 Aug 2026 21:26:48 +0900 Subject: [PATCH 22/32] =?UTF-8?q?fix(frontend):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EA=B3=BC=20=EC=B1=84=ED=8C=85=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/components/ChatHeader.js | 1 + apps/frontend/components/ChatInput.js | 33 +++++-- .../components/ConnectionErrorBanner.js | 2 + apps/frontend/components/FileMessage.js | 6 +- apps/frontend/components/FilePreview.js | 6 +- .../components/__tests__/ChatInput.test.js | 28 +++++- .../features/chat/room/ChatRoomView.js | 91 +++++------------- .../chat/room/__tests__/ChatRoomView.test.js | 2 + .../room/__tests__/useMessageHandling.test.js | 4 +- .../features/chat/room/useMessageHandling.js | 7 +- .../features/chat/rooms/ChatRoomsView.js | 6 +- .../rooms/__tests__/ChatRoomsView.test.js | 6 ++ apps/frontend/next.config.js | 11 ++- apps/frontend/pages/chat/new.js | 17 +++- apps/frontend/pages/index.js | 47 +++------- apps/frontend/pages/register.js | 2 +- apps/frontend/test/pages/index.test.js | 30 ++++++ e2e/actions/auth.actions.js | 15 ++- e2e/actions/chat.actions.js | 5 +- e2e/artillery/aggregate-observations.js | 19 +++- e2e/artillery/observation.js | 94 ++++++++++++++++++- e2e/artillery/observation.test.js | 8 +- e2e/artillery/report-observations.js | 9 +- e2e/artillery/scenarios/auth.scenario.js | 8 +- e2e/artillery/scenarios/chat.scenario.js | 9 +- 25 files changed, 326 insertions(+), 140 deletions(-) create mode 100644 apps/frontend/test/pages/index.test.js 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} +