Skip to content

[Feat] 관리자 기능 추가 #103

Merged
kangcheolung merged 3 commits intodevelopfrom
feature/102
Feb 9, 2026
Merged

[Feat] 관리자 기능 추가 #103
kangcheolung merged 3 commits intodevelopfrom
feature/102

Conversation

@kangcheolung
Copy link
Copy Markdown
Member

@kangcheolung kangcheolung commented Feb 9, 2026

🔍️ 작업 내용

  • Closes #
    관리자 기능 추가

✨ 상세 설명

관리자 페이지는 없지만 프론트의 편의를 위해서 기능 추가

  • 브랜드 수정/삭제 API 추가
  • 카테고리 수정/삭제 API 추가
  • 상품 수정 API 추가
  • 상품옵션 수정/삭제 API 추가

🛠️ 추후 리팩토링 및 고도화 계획

📸 스크린샷 (선택)

💬 리뷰 요구사항

Summary by CodeRabbit

새로운 기능

  • 관리자가 브랜드, 카테고리, 상품의 세부 정보를 부분 수정(PATCH)할 수 있는 API 추가
  • 관리자가 상품 옵션(사이즈, 색상, 재고)을 수정할 수 있는 기능 추가
  • 관리자가 브랜드, 카테고리, 및 상품 옵션을 삭제(DELETE)할 수 있는 기능 추가
  • 관리용 요청/응답 형식 및 관련 검증·에러 항목이 확장되어 관리 작업 시 상세한 오류 안내 제공

kangcheolung and others added 2 commits February 9, 2026 15:50
- 브랜드, 카테고리, 상품, 상품옵션 수정 기능 추가
- 브랜드, 카테고리, 상품, 상품옵션 삭제 기능 추가
- 각 엔티티에 updateXXX 메서드 추가
- ProductOptionRepository에 deleteByProduct 메서드 추가
- Admin API 엔드포인트 추가 (PATCH, DELETE)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- 주문 이력 보존을 위해 상품 물리 삭제 기능 제거
- 대신 상품 수정 API를 통해 onSale=false로 판매 중단 처리 가능

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 9, 2026

Walkthrough

관리자용 브랜드·카테고리·상품·상품 옵션의 PATCH(수정) 및 DELETE(삭제) 엔드포인트와 이를 처리하는 서비스/엔티티/저장소 메서드 및 관련 UpdateRequest DTO들이 추가되었습니다.

Changes

Cohort / File(s) Summary
Admin 컨트롤러 엔드포인트
src/main/java/com/ongil/backend/domain/admin/controller/AdminController.java
브랜드, 카테고리, 상품, 상품 옵션에 대한 PATCH 업데이트 및 DELETE 삭제 엔드포인트 7개 추가. 요청은 UpdateRequest DTO를 받아 AdminService로 위임하고 DataResponse로 래핑하여 반환.
Admin UpdateRequest DTO
src/main/java/com/ongil/backend/domain/admin/dto/request/AdminBrandUpdateRequest.java, .../AdminCategoryUpdateRequest.java, .../AdminProductUpdateRequest.java, .../AdminProductOptionUpdateRequest.java
브랜드/카테고리/상품/상품옵션용 4개의 UpdateRequest DTO 추가(필드, Lombok @Getter/@NoArgsConstructor, Swagger @Schema).
Admin 서비스 메서드
src/main/java/com/ongil/backend/domain/admin/service/AdminService.java
브랜드·카테고리·상품·상품옵션에 대한 update/delete 메서드 7개 추가. 존재 검증, 연관 검사(브랜드/카테고리에 상품 존재 여부 등) 및 엔티티 업데이트/삭제 처리.
엔티티 업데이트 메서드
src/main/java/com/ongil/backend/domain/brand/entity/Brand.java, .../category/entity/Category.java, .../product/entity/Product.java, .../product/entity/ProductOption.java
각 엔티티에 null-safe 업데이트 메서드 추가(입력값이 null이 아닐 때만 필드 변경). Product는 가격/할인율 변경 시 discountPrice 재계산 로직 포함. Category는 자기참조 방지 검사 추가. ProductOption의 음수 재고 검사 추가.
Repository 메서드 및 쿼리 검사
src/main/java/com/ongil/backend/domain/product/repository/ProductOptionRepository.java, .../product/repository/ProductRepository.java, .../category/repository/CategoryRepository.java
ProductOptionRepository에 deleteByProduct(Product) 추가. ProductRepository에 existsByBrandId, existsByCategoryId 추가. CategoryRepository에 existsByParentCategoryId 추가 — 삭제 전 연관 존재 확인용.
공통 예외 코드 추가
src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java
재고 유효성, 브랜드/카테고리 삭제 제약, 카테고리 자기참조 관련 새 ErrorCode 항목들 추가.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Controller as AdminController
    participant Service as AdminService
    participant Repo as Repository/DB

    Client->>Controller: PATCH /admin/brands/{id} + AdminBrandUpdateRequest
    Controller->>Service: updateBrand(id, request)
    Service->>Repo: find Brand by id
    Repo-->>Service: Brand
    Service->>Service: brand.updateBrand(...)
    Service->>Repo: save/update Brand
    Repo-->>Service: updated Brand
    Service-->>Controller: BrandResponse
    Controller-->>Client: 200 OK (DataResponse<BrandResponse>)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✨ Feature

Suggested reviewers

  • neibler
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive PR 제목은 관리자 기능 추가라는 일반적인 내용을 담고 있으나, 변경 사항의 구체적인 핵심(브랜드/카테고리/상품/상품옵션의 수정 및 삭제 API 추가)을 명확히 나타내지 못하고 있습니다. 제목을 더 구체적으로 개선하여 추가된 주요 기능(예: '[Feat] 관리자 CRUD API 추가 - 브랜드, 카테고리, 상품, 옵션')을 명시하거나, 핵심 변경을 더 명확히 드러내는 표현으로 수정하면 좋겠습니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/102

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In
`@src/main/java/com/ongil/backend/domain/admin/controller/AdminController.java`:
- Around line 77-82: The delete path currently risks a DB FK violation when
removing Brand/Category; update the deletion flow to perform a pre-check in
adminService.deleteBrand (and the analogous category delete method) to query for
existing Product(s) (by brandId/categoryId) and/or subCategories before
deleting, and if any exist throw a ValidationException (or your app's
DomainValidationException) with message "연관된 상품/카테고리가 있어 삭제할 수 없습니다";
alternatively, if you prefer cascade deletes, add cascade = CascadeType.REMOVE
(or CascadeType.DELETE) on the Brand/Category `@OneToMany` mappings to Product and
subCategories respectively, or implement a GlobalExceptionHandler method that
handles DataIntegrityViolationException and returns the same friendly
message—choose one approach and apply consistently to Brand, Category, Product
entity mappings and adminService.deleteBrand/deleteCategory call sites.

In
`@src/main/java/com/ongil/backend/domain/admin/dto/request/AdminProductUpdateRequest.java`:
- Around line 9-45: The AdminProductUpdateRequest DTO is missing the onSale
field required to toggle product sale status; add a private Boolean onSale field
to the AdminProductUpdateRequest class with an appropriate `@Schema`(description =
"판매 여부", example = "false") annotation so the API can accept onSale updates (the
class already uses `@Getter/`@NoArgsConstructor so no extra accessor code is
needed).

In `@src/main/java/com/ongil/backend/domain/admin/service/AdminService.java`:
- Around line 186-223: The updateProduct method is not passing the onSale flag
from AdminProductUpdateRequest into product.updateProduct; add an onSale Boolean
to AdminProductUpdateRequest (with getter) if missing, update the call site in
AdminService.updateProduct to pass request.getOnSale() into
product.updateProduct, and ensure the Product.updateProduct signature (and any
usages) is updated to accept and apply the onSale value so the Product's onSale
state is persisted; keep other validations unchanged and return
productConverter.toSimpleResponse(product) as before.
- Around line 162-166: The current logic in AdminService (handling
request.getParentCategoryId()) treats a null parentCategoryId as "do not change"
because Category.updateCategory only updates when parentCategory != null; to
allow explicit removal of a parent, add a clear signal in the request (e.g.,
treat parentCategoryId == 0L or add a boolean like clearParent) and branch: if
request.getParentCategoryId() == 0L (or clearParent==true) set parentCategory =
null and mark that parent should be cleared, else if parentCategoryId != null
load parent via categoryRepository.findById(...); then call
Category.updateCategory with a parameter or overload that can distinguish
"no-op", "set to X", and "clear parent" (or pass an Optional/flag) so
updateCategory can explicitly remove the parent when requested.

In `@src/main/java/com/ongil/backend/domain/category/entity/Category.java`:
- Around line 48-63: The updateCategory method in Category allows setting
parentCategory only when non-null and lacks self-reference protection; change
updateCategory in class Category so it accepts null to unset the parent
(allowing promotion to top-level) and explicitly prevent cycles by rejecting
when parentCategory == this (e.g., throw IllegalArgumentException); keep the
other field updates unchanged and reference updateCategory and parentCategory
when making the fix.

In `@src/main/java/com/ongil/backend/domain/product/entity/Product.java`:
- Around line 141-187: Add an onSale boolean to the admin update flow: extend
AdminProductUpdateRequest with a nullable Boolean onSale, add a corresponding
Boolean onSale parameter to Product.updateProduct(...) and, inside
updateProduct, if (onSale != null) set this.onSale = onSale; then update
AdminService.updateProduct (and any callers) to pass the DTO.getOnSale() into
Product.updateProduct; ensure the symbol names referenced are
AdminProductUpdateRequest, Product.updateProduct, and AdminService.updateProduct
so the new onSale value flows from the DTO to the entity.

In `@src/main/java/com/ongil/backend/domain/product/entity/ProductOption.java`:
- Around line 48-59: The updateProductOption method allows negative stock values
which can violate domain invariants and mislead getStockStatus; change
updateProductOption in ProductOption to validate the incoming stock (e.g.,
require stock >= 0) and either clamp negative values to 0 or throw an
IllegalArgumentException when stock < 0, and also update getStockStatus (if
present) to treat any stock <= 0 as OUT_OF_STOCK to be defensive against
negative values.
🧹 Nitpick comments (6)
src/main/java/com/ongil/backend/domain/product/repository/ProductOptionRepository.java (1)

28-29: 파생 삭제 쿼리(derived delete)는 N+1 DELETE 문을 발생시킵니다

Spring Data JPA의 deleteByProduct는 내부적으로 먼저 SELECT로 엔티티를 모두 조회한 후, 건별로 DELETE를 실행합니다. 옵션이 많은 상품의 경우 성능 이슈가 발생할 수 있습니다. 벌크 삭제 쿼리 사용을 권장합니다.

♻️ 벌크 삭제 쿼리 제안
-	// 특정 상품의 모든 옵션 삭제
-	void deleteByProduct(Product product);
+	// 특정 상품의 모든 옵션 삭제
+	`@Modifying`
+	`@Query`("DELETE FROM ProductOption po WHERE po.product = :product")
+	void deleteByProduct(`@Param`("product") Product product);

@Modifying@Query import가 필요합니다:

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
src/main/java/com/ongil/backend/domain/admin/dto/request/AdminProductUpdateRequest.java (1)

20-21: price에 대한 음수/0 값 검증 부재

관리자 실수로 음수 또는 0 가격이 저장될 수 있습니다. @Positive 등 Bean Validation 어노테이션 추가를 권장합니다. discountRate(Line 36)도 0~100 범위 검증이 필요합니다.

🛡️ 검증 어노테이션 추가 제안
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Positive;
+
 	`@Schema`(description = "가격", example = "29000")
+	`@Positive`
 	private Integer price;
 ...
 	`@Schema`(description = "할인율 (%)", example = "10")
+	`@Min`(0) `@Max`(100)
 	private Integer discountRate;
src/main/java/com/ongil/backend/domain/product/entity/Product.java (1)

150-156: pricediscountRate가 동시에 전달될 때 할인가가 두 번 계산됩니다.

price 블록(Line 150-155)에서 기존 discountRatediscountPrice를 먼저 계산하고, 이후 discountRate 블록(Line 169-176)에서 다시 계산합니다. 최종 결과는 올바르지만 불필요한 중간 계산이 발생합니다.

할인가 재계산 로직을 메서드 하단에 한 번만 수행하도록 분리하면 가독성과 유지보수성이 향상됩니다.

Also applies to: 169-177

src/main/java/com/ongil/backend/domain/admin/controller/AdminController.java (1)

70-72: PATCH 엔드포인트에 @Valid 어노테이션이 누락되어 있습니다.

POST 엔드포인트(Line 41, 48, 55, 63)에는 @Valid가 적용되어 있으나 PATCH 엔드포인트에는 빠져 있습니다. 현재 update DTO에 validation 제약 조건이 없어 동작에 영향은 없지만, 향후 DTO에 제약 조건이 추가될 경우를 대비해 일관성 있게 @Valid를 붙여 두는 것이 좋습니다.

 	public DataResponse<BrandResponse> updateBrand(
 		`@PathVariable` Long brandId,
-		`@RequestBody` AdminBrandUpdateRequest request) {
+		`@Valid` `@RequestBody` AdminBrandUpdateRequest request) {

Also applies to: 86-88, 102-104, 111-113

src/main/java/com/ongil/backend/domain/admin/service/AdminService.java (2)

225-243: ProductOptionResponse 수동 빌드가 createProductOption과 중복됩니다 (DRY 위반).

updateProductOption(Line 236-242)과 createProductOption(Line 126-132)에서 동일한 ProductOptionResponse 빌드 로직이 반복됩니다. 다른 엔티티는 Converter를 사용하고 있으므로, ProductConvertertoProductOptionResponse 메서드를 추가하여 일관성을 맞추는 것이 좋습니다.

Also applies to: 114-133


135-147: 읽기 전용 조회에 대한 @Transactional(readOnly = true) 미분리.

클래스 레벨에 @Transactional이 적용되어 있어 모든 메서드가 쓰기 트랜잭션으로 동작합니다. 현재는 모두 쓰기 연산이므로 문제 없지만, 향후 조회 메서드가 추가될 경우 성능에 영향을 줄 수 있으니 참고해 주세요.

Also applies to: 226-243

Comment on lines +9 to +45
@Getter
@NoArgsConstructor
@Schema(description = "상품 수정 요청")
public class AdminProductUpdateRequest {

@Schema(description = "상품명", example = "베이직 코튼 티셔츠")
private String name;

@Schema(description = "상품 설명", example = "편안한 착용감의 코튼 티셔츠입니다.")
private String description;

@Schema(description = "가격", example = "29000")
private Integer price;

@Schema(description = "소재 정보", example = "면 100%")
private String materialOriginal;

@Schema(description = "이미지 URL들 (쉼표로 구분)", example = "https://example.com/img1.jpg,https://example.com/img2.jpg")
private String imageUrls;

@Schema(description = "사이즈들 (쉼표로 구분)", example = "S,M,L,XL")
private String sizes;

@Schema(description = "색상들 (쉼표로 구분)", example = "화이트,블랙,네이비")
private String colors;

@Schema(description = "할인율 (%)", example = "10")
private Integer discountRate;

@Schema(description = "상품 타입", example = "NORMAL")
private ProductType productType;

@Schema(description = "브랜드 ID", example = "1")
private Long brandId;

@Schema(description = "카테고리 ID", example = "1")
private Long categoryId;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

onSale 필드 누락 — PR 목적과 불일치

커밋 메시지에 "주문 이력 보존을 위해 상품의 물리 삭제 기능 제거. 상품 판매 중단은 상품 수정 API를 통해 onSale=false로 처리하도록 변경"이라고 명시되어 있으나, 이 DTO에 onSale (또는 Boolean 타입의 판매 상태) 필드가 없습니다. 현재 상태로는 상품 판매 중단 기능이 동작하지 않습니다.

🐛 onSale 필드 추가 제안
 	`@Schema`(description = "카테고리 ID", example = "1")
 	private Long categoryId;
+
+	`@Schema`(description = "판매 여부", example = "true")
+	private Boolean onSale;
 }

As per coding guidelines, DTO 설계 검증: "누락된 필드 - API 응답에 필요한 필드가 빠져있는지 확인".

📝 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
@Getter
@NoArgsConstructor
@Schema(description = "상품 수정 요청")
public class AdminProductUpdateRequest {
@Schema(description = "상품명", example = "베이직 코튼 티셔츠")
private String name;
@Schema(description = "상품 설명", example = "편안한 착용감의 코튼 티셔츠입니다.")
private String description;
@Schema(description = "가격", example = "29000")
private Integer price;
@Schema(description = "소재 정보", example = "면 100%")
private String materialOriginal;
@Schema(description = "이미지 URL들 (쉼표로 구분)", example = "https://example.com/img1.jpg,https://example.com/img2.jpg")
private String imageUrls;
@Schema(description = "사이즈들 (쉼표로 구분)", example = "S,M,L,XL")
private String sizes;
@Schema(description = "색상들 (쉼표로 구분)", example = "화이트,블랙,네이비")
private String colors;
@Schema(description = "할인율 (%)", example = "10")
private Integer discountRate;
@Schema(description = "상품 타입", example = "NORMAL")
private ProductType productType;
@Schema(description = "브랜드 ID", example = "1")
private Long brandId;
@Schema(description = "카테고리 ID", example = "1")
private Long categoryId;
`@Getter`
`@NoArgsConstructor`
`@Schema`(description = "상품 수정 요청")
public class AdminProductUpdateRequest {
`@Schema`(description = "상품명", example = "베이직 코튼 티셔츠")
private String name;
`@Schema`(description = "상품 설명", example = "편안한 착용감의 코튼 티셔츠입니다.")
private String description;
`@Schema`(description = "가격", example = "29000")
private Integer price;
`@Schema`(description = "소재 정보", example = "면 100%")
private String materialOriginal;
`@Schema`(description = "이미지 URL들 (쉼표로 구분)", example = "https://example.com/img1.jpg,https://example.com/img2.jpg")
private String imageUrls;
`@Schema`(description = "사이즈들 (쉼표로 구분)", example = "S,M,L,XL")
private String sizes;
`@Schema`(description = "색상들 (쉼표로 구분)", example = "화이트,블랙,네이비")
private String colors;
`@Schema`(description = "할인율 (%)", example = "10")
private Integer discountRate;
`@Schema`(description = "상품 타입", example = "NORMAL")
private ProductType productType;
`@Schema`(description = "브랜드 ID", example = "1")
private Long brandId;
`@Schema`(description = "카테고리 ID", example = "1")
private Long categoryId;
`@Schema`(description = "판매 여부", example = "true")
private Boolean onSale;
}
🤖 Prompt for AI Agents
In
`@src/main/java/com/ongil/backend/domain/admin/dto/request/AdminProductUpdateRequest.java`
around lines 9 - 45, The AdminProductUpdateRequest DTO is missing the onSale
field required to toggle product sale status; add a private Boolean onSale field
to the AdminProductUpdateRequest class with an appropriate `@Schema`(description =
"판매 여부", example = "false") annotation so the API can accept onSale updates (the
class already uses `@Getter/`@NoArgsConstructor so no extra accessor code is
needed).

Comment on lines +162 to +166
Category parentCategory = null;
if (request.getParentCategoryId() != null) {
parentCategory = categoryRepository.findById(request.getParentCategoryId())
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.CATEGORY_NOT_FOUND));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# AdminService.java의 updateCategory 메서드 전체 확인
fd "AdminService.java" --type f -x cat {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 9362


🏁 Script executed:

# Category.java 찾기 및 updateCategory 메서드 확인
fd "Category.java" --type f -x cat {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 1924


🏁 Script executed:

# ProductOptionResponse 확인 - converter 패턴 사용 여부
rg "ProductOptionResponse" --type java -A 5 | head -60

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 6463


🏁 Script executed:

# ProductConverter 전체 내용 확인
fd "ProductConverter.java" --type f -x cat {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 4821


parentCategoryIdnull일 때 상위 카테고리를 해제할 수 없습니다.

Category.updateCategory 메서드가 if (parentCategory != null) 조건으로만 업데이트하므로, 요청의 parentCategoryIdnull이면 현재 값이 유지됩니다. 이는 PATCH 시맨틱의 "변경하지 않음" 의미로는 맞지만, 하위 카테고리를 최상위 카테고리로 승격하려는 의도와 구분할 수 없습니다.

명시적인 "해제" 신호(예: 0L 또는 전용 필드)를 도입하여 두 의도를 명확히 구분하는 것이 안전합니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/admin/service/AdminService.java`
around lines 162 - 166, The current logic in AdminService (handling
request.getParentCategoryId()) treats a null parentCategoryId as "do not change"
because Category.updateCategory only updates when parentCategory != null; to
allow explicit removal of a parent, add a clear signal in the request (e.g.,
treat parentCategoryId == 0L or add a boolean like clearParent) and branch: if
request.getParentCategoryId() == 0L (or clearParent==true) set parentCategory =
null and mark that parent should be cleared, else if parentCategoryId != null
load parent via categoryRepository.findById(...); then call
Category.updateCategory with a parameter or overload that can distinguish
"no-op", "set to X", and "clear parent" (or pass an Optional/flag) so
updateCategory can explicitly remove the parent when requested.

Comment on lines +186 to +223
// 상품 수정
public ProductSimpleResponse updateProduct(Long productId, AdminProductUpdateRequest request) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND));

Brand brand = null;
if (request.getBrandId() != null) {
brand = brandRepository.findById(request.getBrandId())
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.BRAND_NOT_FOUND));
}

Category category = null;
if (request.getCategoryId() != null) {
category = categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.CATEGORY_NOT_FOUND));

// 상품은 하위 카테고리에만 등록 가능
if (category.getParentCategory() == null) {
throw new ValidationException(ErrorCode.INVALID_CATEGORY);
}
}

product.updateProduct(
request.getName(),
request.getDescription(),
request.getPrice(),
request.getMaterialOriginal(),
request.getImageUrls(),
request.getSizes(),
request.getColors(),
request.getDiscountRate(),
request.getProductType(),
brand,
category
);

return productConverter.toSimpleResponse(product);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

updateProduct에서 onSale 필드가 전달되지 않습니다.

PR 목표에 명시된 "상품 판매 중단은 상품 수정 API를 통해 onSale=false로 처리"하는 기능이 현재 구현에서 누락되어 있습니다. AdminProductUpdateRequestonSale 필드를 추가하고 product.updateProduct(...) 호출 시 함께 전달해야 합니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/admin/service/AdminService.java`
around lines 186 - 223, The updateProduct method is not passing the onSale flag
from AdminProductUpdateRequest into product.updateProduct; add an onSale Boolean
to AdminProductUpdateRequest (with getter) if missing, update the call site in
AdminService.updateProduct to pass request.getOnSale() into
product.updateProduct, and ensure the Product.updateProduct signature (and any
usages) is updated to accept and apply the onSale value so the Product's onSale
state is persisted; keep other validations unchanged and return
productConverter.toSimpleResponse(product) as before.

Comment on lines +48 to +63

// 카테고리 정보 수정
public void updateCategory(String name, String iconUrl, Integer displayOrder, Category parentCategory) {
if (name != null) {
this.name = name;
}
if (iconUrl != null) {
this.iconUrl = iconUrl;
}
if (displayOrder != null) {
this.displayOrder = displayOrder;
}
if (parentCategory != null) {
this.parentCategory = parentCategory;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

자기 참조(순환 참조) 방지 로직 누락

parentCategory로 자기 자신을 전달하면 순환 참조가 발생하여 카테고리 트리 조회 시 무한 루프에 빠질 수 있습니다. 또한, parentCategorynull로 설정하여 최상위 카테고리로 변경하는 것이 현재 null-check 패턴으로는 불가능합니다.

🛡️ 순환 참조 방어 코드 제안
 if (parentCategory != null) {
+    if (parentCategory.getId().equals(this.id)) {
+        throw new IllegalArgumentException("자기 자신을 상위 카테고리로 설정할 수 없습니다.");
+    }
     this.parentCategory = parentCategory;
 }
🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/category/entity/Category.java` around
lines 48 - 63, The updateCategory method in Category allows setting
parentCategory only when non-null and lacks self-reference protection; change
updateCategory in class Category so it accepts null to unset the parent
(allowing promotion to top-level) and explicitly prevent cycles by rejecting
when parentCategory == this (e.g., throw IllegalArgumentException); keep the
other field updates unchanged and reference updateCategory and parentCategory
when making the fix.

Comment on lines +141 to +187
public void updateProduct(String name, String description, Integer price, String materialOriginal,
String imageUrls, String sizes, String colors, Integer discountRate, ProductType productType,
Brand brand, Category category) {
if (name != null) {
this.name = name;
}
if (description != null) {
this.description = description;
}
if (price != null) {
this.price = price;
// 가격 변경 시 할인가 재계산
if (this.discountRate != null && this.discountRate > 0) {
this.discountPrice = price - (price * this.discountRate / 100);
}
}
if (materialOriginal != null) {
this.materialOriginal = materialOriginal;
}
if (imageUrls != null) {
this.imageUrls = imageUrls;
}
if (sizes != null) {
this.sizes = sizes;
}
if (colors != null) {
this.colors = colors;
}
if (discountRate != null) {
this.discountRate = discountRate;
// 할인율 변경 시 할인가 재계산
if (this.price != null && discountRate > 0) {
this.discountPrice = this.price - (this.price * discountRate / 100);
} else {
this.discountPrice = null;
}
}
if (productType != null) {
this.productType = productType;
}
if (brand != null) {
this.brand = brand;
}
if (category != null) {
this.category = category;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's view the complete Product.java file to see all fields and understand the onSale field
fd "Product.java" --path "*/domain/product/entity/*" --exec wc -l {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 282


🏁 Script executed:

# View the complete Product.java file
fd "Product.java" --path "*/domain/product/entity/*" --exec cat -n {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 282


🏁 Script executed:

# Search for AdminProductUpdateRequest DTO
fd "AdminProductUpdateRequest.java" --exec cat -n {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 1678


🏁 Script executed:

# Search for AdminService and its updateProduct method
fd "AdminService.java" --exec cat -n {}

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 11126


🏁 Script executed:

# Find and view Product.java entity
find . -name "Product.java" -path "*/product/entity/*" -type f

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 135


🏁 Script executed:

# View Product.java - search for field definitions and onSale
fd "Product.java" | head -5 | xargs cat -n | head -250

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 6509


🏁 Script executed:

# Search for onSale field in Product entity
rg "onSale" --type java -B 2 -A 2

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 5940


🏁 Script executed:

# Search for PR objectives or design requirements mentioning onSale
rg "상품.*판매\|판매.*중단\|onSale" --type java --type md -B 2 -A 2

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 49


onSale 필드가 updateProduct 메서드에서 누락되었습니다.

Product 엔티티에 onSale 필드가 정의되어 있고 모든 쿼리에서 onSale = true 조건으로 필터링하고 있으나, AdminProductUpdateRequest DTO와 updateProduct 메서드에 onSale 파라미터가 없어 관리자가 판매 중단을 처리할 수 없습니다. 상품 조회 쿼리들이 onSale = true로 제한되어 있으므로, 이 필드를 통해 상품 가시성을 제어해야 합니다.

수정 필요 항목
  1. AdminProductUpdateRequest DTO에 onSale 필드 추가
  2. Product.updateProduct 메서드에 onSale 파라미터 추가
  3. AdminService.updateProduct 호출 시 onSale 전달
🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/product/entity/Product.java` around
lines 141 - 187, Add an onSale boolean to the admin update flow: extend
AdminProductUpdateRequest with a nullable Boolean onSale, add a corresponding
Boolean onSale parameter to Product.updateProduct(...) and, inside
updateProduct, if (onSale != null) set this.onSale = onSale; then update
AdminService.updateProduct (and any callers) to pass the DTO.getOnSale() into
Product.updateProduct; ensure the symbol names referenced are
AdminProductUpdateRequest, Product.updateProduct, and AdminService.updateProduct
so the new onSale value flows from the DTO to the entity.

- 브랜드 삭제 시 해당 브랜드를 사용하는 상품이 있으면 삭제 불가
- 카테고리 삭제 시 해당 카테고리를 사용하는 상품이 있으면 삭제 불가
- 카테고리 삭제 시 하위 카테고리가 있으면 삭제 불가
- 카테고리 수정 시 자기 참조(순환 참조) 방지
- 상품 옵션 수정 시 재고 음수 값 방지
- 관련 에러 코드 추가 (BRAND-002, CATEGORY-003~005, PRODUCT-004)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/main/java/com/ongil/backend/domain/category/entity/Category.java`:
- Around line 60-68: The current self-reference check in Category only compares
parentCategory.getId() to this.id and misses indirect cycles (A→B→A); update the
Category logic that sets parentCategory (e.g., the constructor or
setParentCategory method) to walk the parent chain via
parentCategory.getParentCategory() (using getParentCategory()/getId()) until
null, and if any ancestor's id equals this.id throw the same ValidationException
with ErrorCode.CATEGORY_SELF_REFERENCE; ensure the traversal stops when parent
is null to avoid infinite loops and consider adding this check at the service
layer if parent relations may be lazily unloaded.
🧹 Nitpick comments (2)
src/main/java/com/ongil/backend/domain/product/entity/ProductOption.java (1)

48-65: 음수 재고 방어 로직 추가 확인 — FQN 대신 import 사용 권장

음수 재고 방어 로직이 잘 추가되었습니다. 다만, ValidationExceptionErrorCode를 완전한 패키지 경로(FQN)로 참조하고 있습니다. 파일 상단에 import를 추가하면 가독성이 향상됩니다.

♻️ import 추가 제안

파일 상단 import 영역에 추가:

import com.ongil.backend.global.common.exception.ValidationException;
import com.ongil.backend.global.common.exception.ErrorCode;

메서드 내부 수정:

 		if (stock < 0) {
-			throw new com.ongil.backend.global.common.exception.ValidationException(
-				com.ongil.backend.global.common.exception.ErrorCode.INVALID_STOCK
-			);
+			throw new ValidationException(ErrorCode.INVALID_STOCK);
 		}
src/main/java/com/ongil/backend/domain/admin/service/AdminService.java (1)

36-47: AdminService의 SRP(단일 책임 원칙) 관련 — 향후 분리 검토 제안

현재 AdminService가 브랜드, 카테고리, 상품, 상품 옵션 4개 도메인의 CRUD를 모두 담당하고 있어 클래스 크기와 책임이 계속 커질 수 있습니다. 향후 리팩토링 시 AdminBrandService, AdminCategoryService, AdminProductService 등으로 분리하면 유지보수성이 향상될 수 있습니다.

PR 설명에 리팩토링 계획이 언급되어 있으므로, 현 단계에서는 참고 사항으로 남깁니다. As per coding guidelines, "SOLID 원칙, 스프링 어노테이션, 의존성 주입 패턴, 예외 처리에 중점을 둔다".

Comment on lines +60 to +68
if (parentCategory != null) {
// 자기 참조(순환 참조) 방지
if (parentCategory.getId().equals(this.id)) {
throw new com.ongil.backend.global.common.exception.ValidationException(
com.ongil.backend.global.common.exception.ErrorCode.CATEGORY_SELF_REFERENCE
);
}
this.parentCategory = parentCategory;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

간접 순환 참조(A→B→A) 감지 누락

현재 자기 참조 방지 로직은 직접적인 자기 참조(parentCategory.id == this.id)만 검사합니다. 카테고리 A의 부모를 B로, B의 부모를 A로 설정하는 간접 순환 참조는 감지하지 못합니다. 카테고리 트리 깊이가 얕다면 당장 문제가 되지 않을 수 있지만, 향후 트리 순회 시 무한 루프 위험이 있습니다.

간접 순환을 방지하려면 부모 체인을 따라 올라가며 현재 카테고리가 이미 존재하는지 확인하는 로직이 필요합니다.

♻️ 순환 참조 방어 코드 예시
 if (parentCategory != null) {
     if (parentCategory.getId().equals(this.id)) {
-        throw new com.ongil.backend.global.common.exception.ValidationException(
-            com.ongil.backend.global.common.exception.ErrorCode.CATEGORY_SELF_REFERENCE
-        );
+        throw new ValidationException(ErrorCode.CATEGORY_SELF_REFERENCE);
+    }
+    // 간접 순환 참조 방지
+    Category ancestor = parentCategory.getParentCategory();
+    while (ancestor != null) {
+        if (ancestor.getId().equals(this.id)) {
+            throw new ValidationException(ErrorCode.CATEGORY_SELF_REFERENCE);
+        }
+        ancestor = ancestor.getParentCategory();
     }
     this.parentCategory = parentCategory;
 }

참고: 이 체크가 Entity 내부에서 동작하려면 부모 체인이 이미 fetch된 상태여야 합니다. Lazy 로딩 환경에서는 AdminService 레벨에서 검증하는 것이 더 안전할 수 있습니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/category/entity/Category.java` around
lines 60 - 68, The current self-reference check in Category only compares
parentCategory.getId() to this.id and misses indirect cycles (A→B→A); update the
Category logic that sets parentCategory (e.g., the constructor or
setParentCategory method) to walk the parent chain via
parentCategory.getParentCategory() (using getParentCategory()/getId()) until
null, and if any ancestor's id equals this.id throw the same ValidationException
with ErrorCode.CATEGORY_SELF_REFERENCE; ensure the traversal stops when parent
is null to avoid infinite loops and consider adding this check at the service
layer if parent relations may be lazily unloaded.

@kangcheolung kangcheolung merged commit 2a84f34 into develop Feb 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant