From 04fed990ee006cbb54458100526ba12b27117f85 Mon Sep 17 00:00:00 2001 From: jeongminju Date: Mon, 10 Aug 2026 14:09:43 +0900 Subject: [PATCH 1/3] 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 2/3] =?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 63e9bf75aa47c18c2a3b3c9a9c6cce32e64cea53 Mon Sep 17 00:00:00 2001 From: rkdwlss2 Date: Tue, 11 Aug 2026 16:37:56 +0900 Subject: [PATCH 3/3] 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(); + } +}