diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a541d84 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000..07da754 Binary files /dev/null and b/.github/.DS_Store differ diff --git a/k6-folder-closure-only.js b/k6-folder-closure-only.js new file mode 100644 index 0000000..009c06e --- /dev/null +++ b/k6-folder-closure-only.js @@ -0,0 +1,118 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +// 커스텀 메트릭 정의 +const errorRate = new Rate('errors'); + +// FolderClosure 패턴만 테스트하는 시나리오 +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '2m', target: 100 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'https://api.syncly-io.com'; +const LOGIN_EMAIL = __ENV.LOGIN_EMAIL || '1026hzz2@gmail.com'; +const LOGIN_PASSWORD = __ENV.LOGIN_PASSWORD || 'Khj86284803!'; + +const WORKSPACE_ID = 48; +const FOLDER_ID = 60; + +function login() { + const loginPayload = JSON.stringify({ + email: LOGIN_EMAIL, + password: LOGIN_PASSWORD, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, loginParams); + + check(loginRes, { + '로그인 성공': (r) => r.status === 200, + }); + + if (loginRes.status !== 200) { + console.error(`로그인 실패: ${loginRes.status} - ${loginRes.body}`); + errorRate.add(1); + return null; + } + + const token = loginRes.json('accessToken') || loginRes.json('token'); + + if (!token) { + console.error('토큰을 찾을 수 없습니다.'); + errorRate.add(1); + return null; + } + + return token; +} + +export default function () { + const token = login(); + + if (!token) { + sleep(1); + return; + } + + const params = { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + + const res = http.get( + `${BASE_URL}/api/workspaces/${WORKSPACE_ID}/folders/${FOLDER_ID}/path`, + params + ); + + const success = check(res, { + '상태 200': (r) => r.status === 200, + '응답 시간 < 500ms': (r) => r.timings.duration < 500, + '응답 시간 < 200ms': (r) => r.timings.duration < 200, + 'path 존재': (r) => { + try { + const body = JSON.parse(r.body); + return body.result && body.result.path && Array.isArray(body.result.path); + } catch (e) { + return false; + } + }, + }); + + if (!success) { + errorRate.add(1); + } + + sleep(Math.random() * 1 + 0.5); +} + +export function setup() { + console.log('='.repeat(60)); + console.log('FolderClosure 패턴 단독 성능 테스트'); + console.log(`BASE_URL: ${BASE_URL}`); + console.log(`WORKSPACE_ID: ${WORKSPACE_ID}`); + console.log(`FOLDER_ID: ${FOLDER_ID}`); + console.log('='.repeat(60)); +} + +export function teardown(data) { + console.log('='.repeat(60)); + console.log('FolderClosure 패턴 테스트 종료'); + console.log('='.repeat(60)); +} diff --git a/k6-recursive-only.js b/k6-recursive-only.js new file mode 100644 index 0000000..a368ba4 --- /dev/null +++ b/k6-recursive-only.js @@ -0,0 +1,122 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +// 커스텀 메트릭 정의 +const errorRate = new Rate('errors'); + +// 재귀 방식만 테스트하는 시나리오 +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '2m', target: 100 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'https://api.syncly-io.com'; +const LOGIN_EMAIL = __ENV.LOGIN_EMAIL || '1026hzz2@gmail.com'; +const LOGIN_PASSWORD = __ENV.LOGIN_PASSWORD || 'Khj86284803!'; + +const WORKSPACE_ID = 48; +const FOLDER_ID = 60; + +function login() { + const loginPayload = JSON.stringify({ + email: LOGIN_EMAIL, + password: LOGIN_PASSWORD, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, loginParams); + + check(loginRes, { + '로그인 성공': (r) => r.status === 200, + }); + + if (loginRes.status !== 200) { + console.error(`로그인 실패: ${loginRes.status} - ${loginRes.body}`); + errorRate.add(1); + return null; + } + + // ✅ 실제 응답 구조에 맞게 수정 + const token = + loginRes.json('result') || + loginRes.json('accessToken') || + loginRes.json('token'); + + if (!token) { + console.error('토큰을 찾을 수 없습니다.'); + errorRate.add(1); + return null; + } + + return token; +} + +export default function () { + const token = login(); + + if (!token) { + sleep(1); + return; + } + + const params = { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + + const res = http.get( + `${BASE_URL}/api/workspaces/${WORKSPACE_ID}/folders/${FOLDER_ID}/path-recursive`, + params + ); + + const success = check(res, { + '상태 200': (r) => r.status === 200, + '응답 시간 < 500ms': (r) => r.timings.duration < 500, + '응답 시간 < 200ms': (r) => r.timings.duration < 200, + 'path 존재': (r) => { + try { + const body = JSON.parse(r.body); + return body.result && body.result.path && Array.isArray(body.result.path); + } catch (e) { + return false; + } + }, + }); + + if (!success) { + errorRate.add(1); + } + + sleep(Math.random() * 1 + 0.5); +} + +export function setup() { + console.log('='.repeat(60)); + console.log('재귀 방식 단독 성능 테스트'); + console.log(`BASE_URL: ${BASE_URL}`); + console.log(`WORKSPACE_ID: ${WORKSPACE_ID}`); + console.log(`FOLDER_ID: ${FOLDER_ID}`); + console.log('='.repeat(60)); +} + +export function teardown(data) { + console.log('='.repeat(60)); + console.log('재귀 방식 테스트 종료'); + console.log('='.repeat(60)); +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..891b8a5 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/java/com/project/syncly/domain/note/controller/NoteController.java b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java index 76d117e..f74e564 100644 --- a/src/main/java/com/project/syncly/domain/note/controller/NoteController.java +++ b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java @@ -144,6 +144,30 @@ public ResponseEntity> deleteNote( return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); } + @PatchMapping("/{noteId}/title") + @Operation( + summary = "노트 제목 수정", + description = "노트의 제목을 수정합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "제목 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (제목 누락 등)"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "노트를 찾을 수 없음") + }) + public ResponseEntity> updateNoteTitle( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Parameter(description = "노트 ID") @PathVariable Long noteId, + @Valid @RequestBody NoteRequestDto.UpdateTitle requestDto, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + NoteResponseDto.UpdateTitleResponse response = noteService.updateNoteTitle(workspaceId, noteId, requestDto, memberId); + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); + } + @PostMapping("/{noteId}/save") @Operation( summary = "노트 수동 저장", diff --git a/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java b/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java index 24b1d97..504ad32 100644 --- a/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java +++ b/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java @@ -105,4 +105,15 @@ public static NoteResponseDto.Delete toDeleteResponse(Note note) { "노트가 삭제되었습니다." ); } + + /** + * 노트 제목 수정 응답 DTO 생성 + */ + public static NoteResponseDto.UpdateTitleResponse toUpdateTitleResponse(Note note) { + return new NoteResponseDto.UpdateTitleResponse( + note.getId(), + note.getTitle(), + note.getLastModifiedAt() + ); + } } diff --git a/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java index 1a9dcba..c494535 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java +++ b/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java @@ -12,4 +12,11 @@ public record Create( @Size(max = 200, message = "노트 제목은 최대 200자까지 입력 가능합니다.") String title ) {} + + @Schema(description = "노트 제목 수정 요청 DTO") + public record UpdateTitle( + @NotBlank(message = "노트 제목은 필수입니다.") + @Size(max = 200, message = "노트 제목은 최대 200자까지 입력 가능합니다.") + String title + ) {} } diff --git a/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java index 62f1b03..12eb325 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java +++ b/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java @@ -88,4 +88,11 @@ public static SaveResponse failure(String message) { } } + @Schema(description = "노트 제목 수정 응답 DTO") + public record UpdateTitleResponse( + Long id, + String title, + LocalDateTime lastModifiedAt + ) {} + } diff --git a/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java b/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java index dd6f818..366a343 100644 --- a/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java +++ b/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java @@ -17,17 +17,17 @@ public interface NoteRepository extends JpaRepository { /** * 워크스페이스의 삭제되지 않은 노트 목록 조회 (페이징) - * EntityGraph를 사용하여 Creator를 eager loading + * EntityGraph를 사용하여 Creator와 Workspace를 eager loading */ - @EntityGraph(attributePaths = {"creator"}) + @EntityGraph(attributePaths = {"creator", "workspace"}) @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.isDeleted = false") Page findByWorkspaceId(@Param("workspaceId") Long workspaceId, Pageable pageable); /** * 워크스페이스의 삭제되지 않은 노트 목록 조회 (전체) - * EntityGraph를 사용하여 Creator를 eager loading + * EntityGraph를 사용하여 Creator와 Workspace를 eager loading */ - @EntityGraph(attributePaths = {"creator"}) + @EntityGraph(attributePaths = {"creator", "workspace"}) @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.isDeleted = false") List findAllByWorkspaceId(@Param("workspaceId") Long workspaceId); @@ -41,9 +41,9 @@ public interface NoteRepository extends JpaRepository { /** * 워크스페이스와 노트 ID로 조회 (권한 검증용) - * EntityGraph를 사용하여 Creator를 eager loading + * EntityGraph를 사용하여 Creator와 Workspace를 eager loading */ - @EntityGraph(attributePaths = {"creator"}) + @EntityGraph(attributePaths = {"creator", "workspace"}) @Query("SELECT n FROM Note n WHERE n.id = :noteId AND n.workspace.id = :workspaceId AND n.isDeleted = false") Optional findByIdAndWorkspaceId(@Param("noteId") Long noteId, @Param("workspaceId") Long workspaceId); @@ -61,9 +61,9 @@ public interface NoteRepository extends JpaRepository { /** * 제목으로 노트 검색 (워크스페이스 내) - * EntityGraph를 사용하여 Creator를 eager loading + * EntityGraph를 사용하여 Creator와 Workspace를 eager loading */ - @EntityGraph(attributePaths = {"creator"}) + @EntityGraph(attributePaths = {"creator", "workspace"}) @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.title LIKE %:keyword% AND n.isDeleted = false") Page searchByTitle(@Param("workspaceId") Long workspaceId, @Param("keyword") String keyword, Pageable pageable); } diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteService.java b/src/main/java/com/project/syncly/domain/note/service/NoteService.java index ccede8c..c18f982 100644 --- a/src/main/java/com/project/syncly/domain/note/service/NoteService.java +++ b/src/main/java/com/project/syncly/domain/note/service/NoteService.java @@ -26,6 +26,11 @@ public interface NoteService { */ NoteResponseDto.Delete deleteNote(Long workspaceId, Long noteId, Long memberId); + /** + * 노트 제목 수정 + */ + NoteResponseDto.UpdateTitleResponse updateNoteTitle(Long workspaceId, Long noteId, NoteRequestDto.UpdateTitle requestDto, Long memberId); + /** * 워크스페이스 멤버 권한 확인 */ diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java b/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java index b18c694..d1d03e9 100644 --- a/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java +++ b/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java @@ -138,6 +138,30 @@ public NoteResponseDto.Delete deleteNote(Long workspaceId, Long noteId, Long mem return NoteConverter.toDeleteResponse(note); } + @Override + @Transactional + public NoteResponseDto.UpdateTitleResponse updateNoteTitle(Long workspaceId, Long noteId, NoteRequestDto.UpdateTitle requestDto, Long memberId) { + log.info("Updating note title: workspaceId={}, noteId={}, memberId={}", workspaceId, noteId, memberId); + + // 워크스페이스 멤버십 검증 + validateWorkspaceMembership(workspaceId, memberId); + + // 노트 조회 및 워크스페이스 일치 확인 + Note note = noteRepository.findByIdAndWorkspaceId(noteId, workspaceId) + .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND)); + + // 제목 업데이트 + note.updateTitle(requestDto.title()); + noteRepository.save(note); + + log.info("Note title updated successfully: noteId={}, newTitle={}", noteId, requestDto.title()); + + // WebSocket을 통해 모든 워크스페이스 멤버에게 노트 제목 변경 알림 + broadcastNoteTitleUpdate(note, workspaceId); + + return NoteConverter.toUpdateTitleResponse(note); + } + @Override public void validateWorkspaceMember(Long workspaceId, Long memberId) { validateWorkspaceMembership(workspaceId, memberId); @@ -201,6 +225,32 @@ private void broadcastNoteCreation(Note savedNote, Long workspaceId) { } } + /** + * WebSocket을 통해 노트 제목 변경을 모든 워크스페이스 멤버에게 브로드캐스트합니다. + */ + private void broadcastNoteTitleUpdate(Note note, Long workspaceId) { + try { + Map message = new HashMap<>(); + message.put("type", "NOTE_TITLE_UPDATED"); + + Map payload = new HashMap<>(); + payload.put("noteId", note.getId()); + payload.put("title", note.getTitle()); + payload.put("workspaceId", workspaceId); + payload.put("lastModifiedAt", note.getLastModifiedAt()); + + message.put("payload", payload); + + String destination = "/topic/workspace/" + workspaceId + "/notes/list"; + messagingTemplate.convertAndSend(destination, message); + + log.info("Note title update broadcasted: noteId={}, newTitle={}, destination={}", note.getId(), note.getTitle(), destination); + } catch (Exception e) { + log.error("Failed to broadcast note title update: noteId={}", note.getId(), e); + // 브로드캐스트 실패는 제목 수정에 영향을 주지 않음 + } + } + /** * 워크스페이스 멤버십 검증 */