Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
Binary file added .github/.DS_Store
Binary file not shown.
118 changes: 118 additions & 0 deletions k6-folder-closure-only.js
Original file line number Diff line number Diff line change
@@ -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));
}
122 changes: 122 additions & 0 deletions k6-recursive-only.js
Original file line number Diff line number Diff line change
@@ -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));
}
Binary file added src/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ public ResponseEntity<CustomResponse<NoteResponseDto.Delete>> 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<CustomResponse<NoteResponseDto.UpdateTitleResponse>> 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 = "노트 수동 저장",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,11 @@ public static SaveResponse failure(String message) {
}
}

@Schema(description = "노트 제목 수정 응답 DTO")
public record UpdateTitleResponse(
Long id,
String title,
LocalDateTime lastModifiedAt
) {}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ public interface NoteRepository extends JpaRepository<Note, Long> {

/**
* 워크스페이스의 삭제되지 않은 노트 목록 조회 (페이징)
* 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<Note> 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<Note> findAllByWorkspaceId(@Param("workspaceId") Long workspaceId);

Expand All @@ -41,9 +41,9 @@ public interface NoteRepository extends JpaRepository<Note, Long> {

/**
* 워크스페이스와 노트 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<Note> findByIdAndWorkspaceId(@Param("noteId") Long noteId, @Param("workspaceId") Long workspaceId);

Expand All @@ -61,9 +61,9 @@ public interface NoteRepository extends JpaRepository<Note, Long> {

/**
* 제목으로 노트 검색 (워크스페이스 내)
* 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<Note> searchByTitle(@Param("workspaceId") Long workspaceId, @Param("keyword") String keyword, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
* 워크스페이스 멤버 권한 확인
*/
Expand Down
Loading