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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

spring-boot-starter-data-jpa 의존성이 중복 선언되어 있습니다.

31번 라인에 이미 동일한 의존성이 선언되어 있으므로, 37번 라인의 중복 항목을 제거해 주세요. 또한 31번 라인의 들여쓰기가 탭이 아닌 스페이스로 되어 있어 파일 내 다른 라인들과 일관성이 맞지 않습니다.

🔧 수정 제안
     implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
 	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
 	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
 	implementation 'org.springframework.boot:spring-boot-starter-validation'
 	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
 	implementation 'me.paulschwarz:spring-dotenv:4.0.0'
-	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

31번 라인의 들여쓰기도 스페이스에서 탭으로 수정하세요:

-    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
🤖 Prompt for AI Agents
In `@build.gradle` at line 37, Remove the duplicate dependency declaration of
'org.springframework.boot:spring-boot-starter-data-jpa' (keep only one
occurrence) and fix the inconsistent indentation on the remaining declaration by
converting the leading spaces to a tab so it matches the file's existing
indentation style; locate the duplicate by searching for the exact dependency
string and update the earlier occurrence's indentation accordingly.


compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/web/SearchWeb/config/ApiResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.web.SearchWeb.config;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class ApiResponse<T> {
private final boolean success;
private final T data;
private final ErrorResponse error; // 여기서 ErrorResponse는 JSON 포장지 역할

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}

// Enum을 인자로 받음으로써 타입 안정성 확보
public static ApiResponse<Void> fail(ErrorCode errorCode) {
return new ApiResponse<>(
false,
null,
new ErrorResponse(errorCode) // Enum을 생성자로 넘겨서 변환
);
}

@Getter
public static class ErrorResponse {
private final String code;
private final String message;

// Enum에서 필요한 정보만 추출하여 세팅
private ErrorResponse(ErrorCode errorCode) {
this.code = errorCode.getCode();
this.message = errorCode.getMessage();
}
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/web/SearchWeb/config/BusinessException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.web.SearchWeb.config;

import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/web/SearchWeb/config/CommonErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.web.SearchWeb.config;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode{
// 500 Internal Server Error: 서버 내부 오류
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C006", "서버에 오류가 발생했습니다.");
Comment on lines +9 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

에러 코드 "C006" 번호 체계 확인

현재 유일한 상수인 INTERNAL_SERVER_ERROR의 코드가 "C006"입니다. C001~C005에 해당하는 다른 공통 에러 코드가 계획되어 있는지, 아니면 번호 체계에 의도가 있는지 확인이 필요합니다. 향후 유지보수를 위해 코드 번호 규칙을 문서화하는 것을 권장합니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/web/SearchWeb/config/CommonErrorCode.java` around lines 9 -
11, The enum CommonErrorCode currently defines INTERNAL_SERVER_ERROR with code
"C006"; verify the intended numbering scheme and either adjust the code value on
INTERNAL_SERVER_ERROR to the correct sequence (e.g., "C001" if it should be
first) or add/document the missing placeholders for C001–C005 and a comment
explaining the numbering convention; update the enum constant
(INTERNAL_SERVER_ERROR) and associated documentation/comments to reflect the
chosen, consistent code scheme.


private final HttpStatus status;
private final String code;
private final String message;

}
9 changes: 9 additions & 0 deletions src/main/java/com/web/SearchWeb/config/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.web.SearchWeb.config;

import org.springframework.http.HttpStatus;

public interface ErrorCode {
HttpStatus getStatus();
String getCode();
String getMessage();
}
34 changes: 34 additions & 0 deletions src/main/java/com/web/SearchWeb/config/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.web.SearchWeb.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

// FolderException, MemberException 등 모든 비즈니스 예외가 이 메서드 하나로 들어옵니다.
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();

// 로그에는 어떤 도메인에서 예외가 났는지 클래스명과 메시지를 찍어줍니다.
log.warn("BusinessException [{}]: {}", e.getClass().getSimpleName(), errorCode.getMessage());

return ResponseEntity
.status(errorCode.getStatus())
.body(ApiResponse.fail(errorCode));
}

// 그 외 예상치 못한 서버 에러(500) 처리
@ExceptionHandler(Exception.class)
protected ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Internal Server Error", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(CommonErrorCode.INTERNAL_SERVER_ERROR));
}
Comment on lines +26 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

IllegalArgumentException이 500으로 처리되는 버그가 있습니다.

MemberFolderServiceImpl.validateFolderName()에서 IllegalArgumentException을 던지는데, 이 예외는 BusinessException이 아니므로 handleException(Exception e)에 잡혀 500 Internal Server Error로 응답됩니다. 클라이언트 입력 검증 실패는 400 Bad Request로 응답해야 합니다.

🐛 IllegalArgumentException 핸들러 추가 제안
+    `@ExceptionHandler`(IllegalArgumentException.class)
+    protected ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
+        log.warn("IllegalArgumentException: {}", e.getMessage());
+        return ResponseEntity
+            .status(HttpStatus.BAD_REQUEST)
+            .body(ApiResponse.fail(CommonErrorCode.INVALID_INPUT));
+    }
+
     // 그 외 예상치 못한 서버 에러(500) 처리
     `@ExceptionHandler`(Exception.class)

또는 validateFolderName에서 BusinessException 계열 예외를 던지도록 서비스 레이어를 수정하는 것도 방법입니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/web/SearchWeb/config/GlobalExceptionHandler.java` around
lines 26 - 33, Add a specific handler for IllegalArgumentException in
GlobalExceptionHandler so client validation errors from
MemberFolderServiceImpl.validateFolderName() return 400 instead of being caught
by the generic handleException(Exception). Implement a new method (e.g.,
handleIllegalArgumentException) annotated with
`@ExceptionHandler`(IllegalArgumentException.class) that logs at WARN/INFO and
returns
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.fail(...)) using
an appropriate client-error CommonErrorCode (or add one if missing);
alternatively, change MemberFolderServiceImpl.validateFolderName() to throw a
BusinessException subclass so the existing BusinessException handler handles it.

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.web.SearchWeb.folder.controller;

import com.web.SearchWeb.config.ApiResponse;
import com.web.SearchWeb.folder.controller.dto.MemberFolderRequests;
import com.web.SearchWeb.folder.controller.dto.MemberFolderResponses;
import com.web.SearchWeb.folder.domain.MemberFolder;
import com.web.SearchWeb.folder.service.MemberFolderService;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/folders")
public class MemberFolderController {

private final MemberFolderService memberFolderService;

// 생성 (201 Created 응답)
@PostMapping
public ResponseEntity<ApiResponse<Long>> create(@RequestBody MemberFolderRequests.Create req) {
Long folderId = memberFolderService.create(
req.ownerMemberId,
req.parentFolderId,
req.folderName,
req.description
);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success(folderId));
}
Comment on lines +31 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

요청 DTO에 유효성 검증이 없습니다.

@RequestBody@Valid가 없고, MemberFolderRequests.Create의 필드(ownerMemberId, folderName 등)에 @NotNull, @NotBlank 같은 Bean Validation 어노테이션이 없습니다. ownerMemberIdnull로 들어오면 JPA 레이어에서 예상치 못한 오류가 발생할 수 있습니다.

컨트롤러 파라미터에 @Valid를 추가하고, DTO 필드에 적절한 검증 어노테이션을 적용하세요.

🛡️ 컨트롤러 수정 예시
-    public ResponseEntity<ApiResponse<Long>> create(`@RequestBody` MemberFolderRequests.Create req) {
+    public ResponseEntity<ApiResponse<Long>> create(`@Valid` `@RequestBody` MemberFolderRequests.Create req) {

DTO 수정 예시 (MemberFolderRequests.java):

public static class Create {
    `@NotNull`
    public Long ownerMemberId;
    public Long parentFolderId; // null이면 루트
    `@NotBlank`
    public String folderName;
    public String description;
}

GlobalExceptionHandlerMethodArgumentNotValidException 핸들러도 추가해야 합니다.

🤖 Prompt for AI Agents
In
`@src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java`
around lines 31 - 41, Add Bean Validation to the create endpoint by annotating
the controller parameter in MemberFolderController.create with `@Valid` and
applying appropriate validation annotations to fields in
MemberFolderRequests.Create (e.g., `@NotNull` on ownerMemberId, `@NotBlank` on
folderName, leave parentFolderId nullable). Also add or update a handler in
GlobalExceptionHandler to catch MethodArgumentNotValidException and return a
clear ApiResponse error so validation failures produce controlled HTTP 400
responses.


// 단건 조회
@GetMapping("/{folderId}")
public ResponseEntity<ApiResponse<MemberFolderResponses>> get(@PathVariable Long folderId) {
MemberFolder folder = memberFolderService.get(folderId);
return ResponseEntity.ok(ApiResponse.success(MemberFolderResponses.from(folder)));
}

// 루트 폴더 조회
@GetMapping("/owners/{ownerMemberId}/root")
public ResponseEntity<ApiResponse<List<MemberFolderResponses>>> listRoot(@PathVariable Long ownerMemberId) {
List<MemberFolderResponses> responses = memberFolderService.listRootFolders(ownerMemberId)
.stream()
.map(MemberFolderResponses::from)
.collect(Collectors.toList());

return ResponseEntity.ok(ApiResponse.success(responses));
}

// 하위 폴더 조회
@GetMapping("/owners/{ownerMemberId}/children/{parentFolderId}")
public ResponseEntity<ApiResponse<List<MemberFolderResponses>>> listChildren(
@PathVariable Long ownerMemberId,
@PathVariable Long parentFolderId
) {
List<MemberFolderResponses> responses = memberFolderService.listChildren(ownerMemberId, parentFolderId)
.stream()
.map(MemberFolderResponses::from)
.collect(Collectors.toList());

return ResponseEntity.ok(ApiResponse.success(responses));
}

// 수정 (200 OK)
@PutMapping("/{folderId}")
public ResponseEntity<ApiResponse<Void>> update(@PathVariable Long folderId, @RequestBody MemberFolderRequests.Update req) {
memberFolderService.update(folderId, req.folderName, req.description);
return ResponseEntity.ok(ApiResponse.success(null));
}

// 이동(부모 변경)
@PutMapping("/{folderId}/move")
public ResponseEntity<ApiResponse<Void>> move(@PathVariable Long folderId, @RequestBody MemberFolderRequests.Move req) {
memberFolderService.move(folderId, req.newParentFolderId);
return ResponseEntity.ok(ApiResponse.success(null));
}

// 삭제
@DeleteMapping("/{folderId}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long folderId) {
memberFolderService.delete(folderId);
return ResponseEntity.ok(ApiResponse.success(null));
}
Comment on lines +89 to +94
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

삭제 응답 코드로 204 No Content가 더 적절합니다.

RESTful 관례상 삭제 성공 시 204 No Content를 반환하는 것이 일반적입니다.

♻️ 수정 제안
     `@DeleteMapping`("/{folderId}")
-    public ResponseEntity<ApiResponse<Void>> delete(`@PathVariable` Long folderId) {
+    public ResponseEntity<Void> delete(`@PathVariable` Long folderId) {
         memberFolderService.delete(folderId);
-        return ResponseEntity.ok(ApiResponse.success(null));
+        return ResponseEntity.noContent().build();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 삭제
@DeleteMapping("/{folderId}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long folderId) {
memberFolderService.delete(folderId);
return ResponseEntity.ok(ApiResponse.success(null));
}
// 삭제
`@DeleteMapping`("/{folderId}")
public ResponseEntity<Void> delete(`@PathVariable` Long folderId) {
memberFolderService.delete(folderId);
return ResponseEntity.noContent().build();
}
🤖 Prompt for AI Agents
In
`@src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java`
around lines 89 - 94, Change the DELETE handler to return HTTP 204 No Content
instead of a 200 with a body: update the MemberFolderController.delete method
signature to return ResponseEntity<Void> (remove the ApiResponse wrapper), call
memberFolderService.delete(folderId) as before, and return
ResponseEntity.noContent().build(); ensure imports and any callers are updated
to expect no body. This references MemberFolderController.delete and
memberFolderService.delete.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.web.SearchWeb.folder.controller.dto;

public class MemberFolderRequests {

/**
* 폴더 생성 요청
*/
public static class Create {
public Long ownerMemberId;
public Long parentFolderId; // null이면 루트
public String folderName;
public String description;
}
Comment on lines +8 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

보안 문제: ownerMemberId를 요청 본문에서 받으면 안 됩니다

Create DTO에서 ownerMemberId를 클라이언트 요청 본문으로 받고 있습니다. 악의적인 사용자가 다른 사용자의 ID를 지정하여 해당 사용자 계정에 폴더를 생성할 수 있는 IDOR(Insecure Direct Object Reference) 취약점입니다.

ownerMemberId는 인증된 세션/토큰(예: SecurityContextHolder, @AuthenticationPrincipal)에서 추출해야 합니다.

🔒 Create DTO에서 ownerMemberId 제거 제안
 public static class Create {
-    public Long ownerMemberId;
     public Long parentFolderId; // null이면 루트
     public String folderName;
     public String description;
 }

컨트롤러에서 인증 정보로부터 ownerMemberId를 주입하는 방식으로 변경하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static class Create {
public Long ownerMemberId;
public Long parentFolderId; // null이면 루트
public String folderName;
public String description;
}
public static class Create {
public Long parentFolderId; // null이면 루트
public String folderName;
public String description;
}
🤖 Prompt for AI Agents
In
`@src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderRequests.java`
around lines 8 - 13, Remove the ownerMemberId field from the
MemberFolderRequests.Create DTO (so it is no longer accepted from the request
body) and update the folder creation flow to derive the owner ID from the
authenticated principal instead (e.g., obtain memberId from
SecurityContextHolder or an `@AuthenticationPrincipal` argument in the controller
method that handles Create). Ensure the controller no longer reads
Create.ownerMemberId but passes the principal-derived memberId into the
service/repository create method (keeping parentFolderId, folderName,
description on Create unchanged) so owner assignment is done server-side and
IDOR is eliminated.


/**
* 폴더 수정 요청
*/
public static class Update {
public String folderName;
public String description;
}

/**
* 폴더 이동 요청
*/
public static class Move {
public Long newParentFolderId; // null이면 루트로 이동
}
Comment on lines +3 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

요청 DTO에 Bean Validation 어노테이션 누락

모든 inner class 필드가 public으로 선언되어 있고, @NotBlank, @NotNull, @Size 등의 Bean Validation 어노테이션이 없습니다. 도메인 엔티티의 changeInfo에서 folderName null/blank 검증을 하고 있지만, 컨트롤러 진입 시점에서 조기 실패(fail-fast)하는 것이 올바른 패턴입니다.

♻️ Validation 적용 및 캡슐화 개선 제안
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
 public class MemberFolderRequests {
 
     public static class Create {
-        public Long parentFolderId;
-        public String folderName;
-        public String description;
+        `@Getter` `@Setter`
+        private Long parentFolderId;
+
+        `@NotBlank`
+        `@Size`(max = 100)
+        `@Getter` `@Setter`
+        private String folderName;
+
+        `@Size`(max = 500)
+        `@Getter` `@Setter`
+        private String description;
     }

컨트롤러의 @RequestBody 앞에 @Valid를 추가해야 합니다.

🤖 Prompt for AI Agents
In
`@src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderRequests.java`
around lines 3 - 28, The DTOs in MemberFolderRequests (inner classes Create,
Update, Move) lack Bean Validation and use public mutable fields; change each
DTO to encapsulate fields (make them private and provide getters/setters or
apply Lombok annotations like `@Getter/`@Setter/@NoArgsConstructor) and add
appropriate javax.validation annotations: for Create.folderName use `@NotBlank`
and `@Size`(max=...) (and `@NotNull` for ownerMemberId), for Update.folderName use
`@NotBlank/`@Size as needed, and for Move.newParentFolderId use `@NotNull` if moving
is required (or omit if null allowed); finally ensure controller methods that
accept these DTOs annotate the `@RequestBody` parameters with `@Valid` so validation
runs on entry.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.web.SearchWeb.folder.controller.dto;

import com.web.SearchWeb.folder.domain.MemberFolder;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class MemberFolderResponses {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

클래스명이 복수형으로 혼동 가능

MemberFolderResponses는 단일 폴더 응답을 표현하는데 복수형 이름이 사용되고 있습니다. 일반적인 컨벤션에 따라 MemberFolderResponse(단수형)로 변경하는 것이 의미적으로 명확합니다. 컬렉션 응답이 필요하다면 List<MemberFolderResponse>로 사용하는 것이 자연스럽습니다.

🤖 Prompt for AI Agents
In
`@src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java`
at line 9, The DTO class MemberFolderResponses is named in plural but represents
a single folder response; rename the class to MemberFolderResponse to match
semantics and update all references/usages (imports, constructors, builders,
JSON serializers, controller/service method return types, and any tests) to use
MemberFolderResponse instead of MemberFolderResponses; if any code expects a
collection, convert those signatures to use List<MemberFolderResponse> or
Collection<MemberFolderResponse> accordingly and run/refactor compilation fixes
and imports.

private final Long memberFolderId;
private final Long ownerMemberId;
private final Long parentFolderId;
private final String folderName;
private final String description;

// Entity -> DTO 변환을 위한 정적 팩토리 메서드
public static MemberFolderResponses from(MemberFolder folder) {
return MemberFolderResponses.builder()
.memberFolderId(folder.getMemberFolderId())
.ownerMemberId(folder.getOwnerMemberId())
.parentFolderId(folder.getParentFolderId())
.folderName(folder.getFolderName())
.description(folder.getDescription())
.build();
}
}
Loading