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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
Expand Down Expand Up @@ -70,4 +72,13 @@ class BookController(
return ResponseEntity.ok(response)
}

@DeleteMapping("/my-library/{userBookId}")
override fun deleteBookFromMyLibrary(
@AuthenticationPrincipal userId: UUID,
@PathVariable userBookId: UUID,
): ResponseEntity<Unit> {
Copy link
Member

Choose a reason for hiding this comment

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

굿굿!

bookUseCase.deleteBookFromMyLibrary(userId, userBookId)
return ResponseEntity.noContent().build()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,24 @@ interface BookControllerApi {
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC)
pageable: Pageable
): ResponseEntity<UserBookPageResponse>

@Operation(summary = "내 서재에 저장한 도서 삭제", description = "내 서재에 저장한 도서를 삭제합니다.")
@ApiResponses(
value = [
ApiResponse(
responseCode = "204",
description = "성공적으로 도서를 삭제했습니다."
),
ApiResponse(
responseCode = "404",
description = "해당하는 도서를 찾을 수 없습니다.",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@DeleteMapping("/my-library/{userBookId}")
fun deleteBookFromMyLibrary(
@AuthenticationPrincipal userId: UUID,
@Parameter(description = "삭제할 도서 ID") @PathVariable userBookId: UUID
): ResponseEntity<Unit>
Comment on lines +144 to +162
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

PathVariable 이름 명시 및 문서(401/403) 보완 제안

  • 안정성을 위해 @PathVariable("userBookId")처럼 이름을 명시하는 것을 권장합니다. 빌드 옵션/리플렉션 설정에 따라 파라미터 이름 손실 시 매핑 이슈를 방지합니다.
  • OpenAPI 문서에 인증 실패/권한 부족(401/403) 응답 스키마를 추가하면 API 소비자에게 더 명확합니다.

적용 예시(diff):

-    fun deleteBookFromMyLibrary(
-        @AuthenticationPrincipal userId: UUID,
-        @Parameter(description = "삭제할 도서 ID") @PathVariable userBookId: UUID
-    ): ResponseEntity<Unit>
+    fun deleteBookFromMyLibrary(
+        @AuthenticationPrincipal userId: UUID,
+        @Parameter(description = "삭제할 도서 ID") @PathVariable("userBookId") userBookId: UUID
+    ): ResponseEntity<Unit>

문서(선택, 동일 블록 내 추가):

         @ApiResponses(
             value = [
                 ApiResponse(
                     responseCode = "204",
                     description = "성공적으로 도서를 삭제했습니다."
                 ),
+                ApiResponse(
+                    responseCode = "401",
+                    description = "인증 실패",
+                    content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+                ),
+                ApiResponse(
+                    responseCode = "403",
+                    description = "권한 없음",
+                    content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+                ),
                 ApiResponse(
                     responseCode = "404",
                     description = "해당하는 도서를 찾을 수 없습니다.",
                     content = [Content(schema = Schema(implementation = ErrorResponse::class))]
                 )
             ]
         )

삭제 흐름에서 존재하지 않는 도서/타인 소유 도서에 대한 404 처리가 실제로 보장되는지 아래 스크립트로 확인해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 컨트롤러/유스케이스 삭제 진입점
rg -n -A 15 -B 5 $'deleteBookFromMyLibrary\\('

# 서비스 레이어 삭제 구현
rg -n -A 20 -B 5 $'deleteUserBook\\('

# 소유자/존재 검증
rg -n -A 3 -B 3 $'existsByIdAndUserId\\('

# 연관 독서 기록 선삭제 호출
rg -n -A 5 -B 3 $'deleteAllByUserBookId\\('

Length of output: 16419


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- find validateUserBookExists occurrences ----"
rg -n "validateUserBookExists" || true

echo
echo "---- show UserBookService.kt ----"
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt || true

echo
echo "---- show UserBookDomainService.kt ----"
sed -n '1,240p' domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt || true

echo
echo "---- show UserBookRepository.kt ----"
sed -n '1,240p' domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt || true

echo
echo "---- show infra UserBookRepositoryImpl.kt ----"
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt || true

echo
echo "---- search for NotFound/NotFoundException/EntityNotFound/ResourceNotFound ----"
rg -n "NotFound|NotFoundException|EntityNotFound|ResourceNotFound" || true

echo
echo "---- search for exception handler (@ControllerAdvice) ----"
rg -n "@ControllerAdvice" || true
rg -n "ExceptionHandler\\(" || true

Length of output: 19149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- locate files ----"
rg -n "class UserBookException" || true
rg -n "enum class UserBookErrorCode" || true
rg -n "class CommonException" || true
rg -n "class ErrorResponse" || true
rg -n "GlobalExceptionHandler.kt" || true

echo
echo "---- show apis UserBookException.kt ----"
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookException.kt || true

echo
echo "---- show apis UserBookErrorCode.kt ----"
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt || true

echo
echo "---- show global CommonException (if exists) ----"
sed -n '1,240p' global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonException.kt || true
sed -n '1,240p' global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalException.kt || true || true

echo
echo "---- show GlobalExceptionHandler.kt ----"
sed -n '1,240p' global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalExceptionHandler.kt || true

echo
echo "---- show ErrorResponse.kt ----"
sed -n '1,240p' global-utils/src/main/kotlin/org/yapp/globalutils/response/ErrorResponse.kt || true

Length of output: 9752


@PathVariable 이름 명시 + OpenAPI(401/403) 문서 추가 권고 — 삭제(404) 처리와 연관 삭제는 이미 구현되어 있습니다

검증 결과: 존재하지 않거나 타인 소유 도서에 대해 404는 UserBookService.validateUserBookExists → UserBookException(USER_BOOK_NOT_FOUND)로 보장되며, UserBookDomainService.deleteById에서 readingRecordRepository.deleteAllByUserBookId(...) 후 userBookRepository.deleteById(...)로 연관 독서기록을 선삭제합니다.

수정 권고(적용 위치)

  • apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
  • apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt

참고(검증 근거)

  • apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt :: validateUserBookExists(...) throws UserBookException(USER_BOOK_NOT_FOUND)
  • domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt :: deleteById(...) calls deleteAllByUserBookId(...) then deleteById(...)
  • infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt :: deleteAllByUserBookId(...) 존재

권장 적용 예시(diff)

--- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
@@
     @ApiResponses(
         value = [
             ApiResponse(
                 responseCode = "204",
                 description = "성공적으로 도서를 삭제했습니다."
             ),
+            ApiResponse(
+                responseCode = "401",
+                description = "인증 실패",
+                content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+            ),
+            ApiResponse(
+                responseCode = "403",
+                description = "권한 없음",
+                content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+            ),
             ApiResponse(
                 responseCode = "404",
                 description = "해당하는 도서를 찾을 수 없습니다.",
                 content = [Content(schema = Schema(implementation = ErrorResponse::class))]
             )
         ]
     )
     @DeleteMapping("/my-library/{userBookId}")
     fun deleteBookFromMyLibrary(
         @AuthenticationPrincipal userId: UUID,
-        @Parameter(description = "삭제할 도서 ID") @PathVariable userBookId: UUID
+        @Parameter(description = "삭제할 도서 ID") @PathVariable("userBookId") userBookId: UUID
     ): ResponseEntity<Unit>
--- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt
@@
     @DeleteMapping("/my-library/{userBookId}")
     override fun deleteBookFromMyLibrary(
         @AuthenticationPrincipal userId: UUID,
-        @PathVariable userBookId: UUID,
+        @PathVariable("userBookId") userBookId: UUID,
     ): ResponseEntity<Unit> {
         bookUseCase.deleteBookFromMyLibrary(userId, userBookId)
         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
@Operation(summary = "내 서재에 저장한 도서 삭제", description = "내 서재에 저장한 도서를 삭제합니다.")
@ApiResponses(
value = [
ApiResponse(
responseCode = "204",
description = "성공적으로 도서를 삭제했습니다."
),
ApiResponse(
responseCode = "404",
description = "해당하는 도서를 찾을 수 없습니다.",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@DeleteMapping("/my-library/{userBookId}")
fun deleteBookFromMyLibrary(
@AuthenticationPrincipal userId: UUID,
@Parameter(description = "삭제할 도서 ID") @PathVariable userBookId: UUID
): ResponseEntity<Unit>
@Operation(summary = "내 서재에 저장한 도서 삭제", description = "내 서재에 저장한 도서를 삭제합니다.")
@ApiResponses(
value = [
ApiResponse(
responseCode = "204",
description = "성공적으로 도서를 삭제했습니다."
),
ApiResponse(
responseCode = "401",
description = "인증 실패",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "403",
description = "권한 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "404",
description = "해당하는 도서를 찾을 수 없습니다.",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@DeleteMapping("/my-library/{userBookId}")
fun deleteBookFromMyLibrary(
@AuthenticationPrincipal userId: UUID,
@Parameter(description = "삭제할 도서 ID") @PathVariable("userBookId") userBookId: UUID
): ResponseEntity<Unit>
🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
around lines 145-163, the @PathVariable is not explicitly named and the OpenAPI
responses omit 401/403; update the signature to use @PathVariable("userBookId")
on the userBookId parameter and add ApiResponse entries for responseCode "401"
and "403" (with appropriate descriptions and ErrorResponse schema) to the
ApiResponses list; also apply the same @PathVariable("userBookId") annotation to
the corresponding method in
apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt so
parameter binding is explicit and docs include authentication/authorization
responses.

}
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,9 @@ class UserBookService(
completedCount = userBookStatusCountsVO.completedCount
)
}

fun deleteUserBook(userBookId: UUID, userId: UUID) {
validateUserBookExists(userBookId, userId)
userBookDomainService.deleteById(userBookId)
}
}
14 changes: 13 additions & 1 deletion apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.yapp.apis.book.dto.response.UserBookResponse
import org.yapp.apis.book.service.BookManagementService
import org.yapp.apis.book.service.BookQueryService
import org.yapp.apis.book.service.UserBookService
import org.yapp.apis.readingrecord.service.ReadingRecordService
import org.yapp.apis.user.service.UserService
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBookSortType
Expand All @@ -26,7 +27,8 @@ class BookUseCase(
private val bookQueryService: BookQueryService,
private val userService: UserService,
private val userBookService: UserBookService,
private val bookManagementService: BookManagementService
private val bookManagementService: BookManagementService,
private val readingRecordService: ReadingRecordService
) {
fun searchBooks(
request: BookSearchRequest
Expand Down Expand Up @@ -84,6 +86,16 @@ class BookUseCase(
return userBookService.findUserBooksByDynamicConditionWithStatusCounts(userId, status, sort, title, pageable)
}

@Transactional
fun deleteBookFromMyLibrary(
userId: UUID,
userBookId: UUID
) {
userService.validateUserExists(userId)
readingRecordService.deleteAllByUserBookId(userBookId)
userBookService.deleteUserBook(userBookId, userId)
}

private fun mergeWithUserBookStatus(
searchedBooks: List<BookSummary>,
userId: UUID?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class ReadingRecordService(
return page.map { ReadingRecordResponse.from(it) }
}

fun deleteAllByUserBookId(userBookId: UUID) {
readingRecordDomainService.deleteAllByUserBookId(userBookId)
}
Comment on lines +50 to +52
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

읽기 기록 일괄 삭제는 트랜잭션 경계 내에서만 호출되도록 강제하세요

이 메서드는 대량 삭제를 수행하므로 상위 경계(UseCase)의 트랜잭션 안에서만 실행되도록 MANDATORY 전파를 권장합니다. 현재 BookUseCase.deleteBookFromMyLibrary가 @transactional로 감싸고 있어 호환됩니다.

+import org.springframework.transaction.annotation.Propagation
+import org.springframework.transaction.annotation.Transactional
-    fun deleteAllByUserBookId(userBookId: UUID) {
+    @Transactional(propagation = Propagation.MANDATORY)
+    fun deleteAllByUserBookId(userBookId: UUID) {
         readingRecordDomainService.deleteAllByUserBookId(userBookId)
     }
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
around lines 49-51, the deleteAllByUserBookId method performs bulk deletions and
must be invoked only within an existing transaction; update the method to
require a transaction by annotating it with @Transactional(propagation =
Propagation.MANDATORY) and add the necessary Spring imports
(org.springframework.transaction.annotation.Transactional and
org.springframework.transaction.annotation.Propagation); ensure the method
visibility remains appropriate and that any callers (e.g.,
BookUseCase.deleteBookFromMyLibrary) already run inside a transaction.

fun updateReadingRecord(
readingRecordId: UUID,
request: UpdateReadingRecordRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.yapp.apis.readingrecord.usecase

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.transaction.annotation.Transactional
import org.yapp.apis.book.service.UserBookService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ class ReadingRecordDomainService(
return buildReadingRecordInfoVO(savedReadingRecord)
}

fun deleteAllByUserBookId(userBookId: UUID) {
readingRecordRepository.deleteAllByUserBookId(userBookId)
}
Comment on lines +149 to +151
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

연관 엔티티(태그 매핑) 무결성 보장 및 트랜잭션 강제 필요

  • 트랜잭션: 대량 삭제이므로 트랜잭션 경계 강제(MANDATORY)를 권장합니다.
  • 무결성: reading_record_tag 같은 조인 테이블이 FK로 reading_record를 참조한다면, DB/JPA에서 ON DELETE CASCADE 또는 orphanRemoval/cascade 설정이 필요합니다. 설정이 없다면 현재 구현은 제약 위반으로 실패하거나 고아 레코드를 남길 수 있습니다.

제안:

+import org.springframework.transaction.annotation.Propagation
+import org.springframework.transaction.annotation.Transactional
-    fun deleteAllByUserBookId(userBookId: UUID) {
+    @Transactional(propagation = Propagation.MANDATORY)
+    fun deleteAllByUserBookId(userBookId: UUID) {
         readingRecordRepository.deleteAllByUserBookId(userBookId)
     }

검증 항목:

  • DB 마이그레이션 혹은 JPA 엔티티 설정에 다음 중 하나가 존재하는지 확인
    • reading_record_tag(reading_record_id) → reading_record(id)에 ON DELETE CASCADE
    • JPA에서 @onDelete(action = OnDeleteAction.CASCADE) 또는 cascade = REMOVE / orphanRemoval = true

필요하다면, readingRecordTagRepository에 “레코드 ID 목록 기반 삭제”를 추가한 뒤(예: deleteAllByReadingRecordIdIn), 태그 매핑 → 읽기 기록 순으로 삭제하는 안전한 경로도 제공합니다. 원하시면 해당 레포지토리/도메인 변경 패치도 함께 드리겠습니다.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for entities, repositories, migrations and cascade/orphan settings related to ReadingRecord..."

# Find Kotlin classes and usages
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "class\s+ReadingRecord\b" --type kotlin || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "class\s+ReadingRecordTag\b" --type kotlin || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "interface\s+ReadingRecordRepository\b" --type kotlin || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "deleteAllByUserBookId" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "deleteAllByReadingRecordId" || true

# Search for JPA cascade/on-delete/orphan settings
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "orphanRemoval\s*=\s*true" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "cascade\s*=\s*CascadeType" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "@OnDelete" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "OnDeleteAction" || true

# Search DB migration / SQL files for foreign keys referencing reading_record or reading_record_tag
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "reading_record_tag" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "reading_record" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "ON DELETE CASCADE" || true
rg -n -C2 --hidden --no-ignore-vcs --glob '!**/build/**' --glob '!**/.gradle/**' "REFERENCES .*reading_record" -P || true

echo "Done."

Length of output: 10351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== ReadingRecordEntity ==="
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt || true

echo
echo "=== ReadingRecordTagEntity ==="
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecordtag/entity/ReadingRecordTagEntity.kt || true

echo
echo "=== JpaReadingRecordRepository ==="
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt || true

echo
echo "=== ReadingRecordRepositoryImpl ==="
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt || true

echo
echo "=== JpaReadingRecordTagRepository (if any) ==="
rg -n --hidden --no-ignore-vcs --glob '!**/build/**' 'readingrecordtag/repository|ReadingRecordTagRepository' || true
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecordtag/repository/JpaReadingRecordTagRepository.kt || true

echo
echo "=== Domain service & API service locations ==="
sed -n '1,240p' domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt || true
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt || true
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt || true

echo
echo "=== Search for DB migrations (sql/flyway/liquibase) mentioning reading_records or reading_record_tags ==="
rg -n --hidden --no-ignore-vcs --glob '!**/build/**' '(reading_records|reading_record_tags|ON DELETE CASCADE|REFERENCES .*reading_records|REFERENCES .*reading_record_tags)' -S || true

Length of output: 24422


연관 엔티티(태그 매핑) 무결성 보장 필요 — 수정 필요

확인 결과: reading_records와 reading_record_tags는 JPA 연관 매핑이 없고 DB 마이그레이션에서도 ON DELETE CASCADE를 찾을 수 없습니다. 또한 JpaReadingRecordRepository의 deleteAllByUserBookId는 파생 삭제(벌크 DELETE)로 동작할 가능성이 높아 엔티티 라이프사이클(@SQLDelete)을 우회하고, 그 결과 reading_record_tags가 고아 레코드로 남을 위험이 있습니다.

검증 근거(주요 파일)

  • infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt — @SQLDelete 적용(soft-delete), 태그 연관 필드 없음
  • infra/src/main/kotlin/org/yapp/infra/readingrecordtag/entity/ReadingRecordTagEntity.kt — readingRecordId: UUID 컬럼(연관 @manytoone 없음)
  • infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt — fun deleteAllByUserBookId(userBookId: UUID)
  • infra/src/main/kotlin/org/yapp/infra/readingrecordtag/repository/JpaReadingRecordTagRepository.kt — findByReadingRecordId만 존재(삭제 메서드 없음)
  • 리포지토리/마이그레이션 검색 결과: ON DELETE CASCADE 관련 스크립트 없음

권장 수정 (안전한 삭제 흐름)

  • 트랜잭션 보장: Domain 경계에서 명시적으로 트랜잭션 요구
    • domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt 에서 deleteAllByUserBookId를 MANDATORY로 변경
  • 태그 매핑 먼저 삭제 후 읽기 기록 삭제 (원자성 보장)
    • 도메인/인프라에 “readingRecordId 목록 기반 삭제” API 추가(deleteAllByReadingRecordIdIn)하고, 이걸 먼저 호출한 뒤 readingRecord 삭제 실행
  • 대안: DB 스키마에 FK(REFERENCES reading_records(id)) + ON DELETE CASCADE 추가(데이터 모델/soft-delete 정책과 충돌 가능성 검토 필요)

예시 패치(간단 참고)

추가 import:

  • import org.springframework.transaction.annotation.Propagation
  • import org.springframework.transaction.annotation.Transactional

domain/src/.../ReadingRecordDomainService.kt 변경 예:

-    fun deleteAllByUserBookId(userBookId: UUID) {
-        readingRecordRepository.deleteAllByUserBookId(userBookId)
-    }
+    @Transactional(propagation = Propagation.MANDATORY)
+    fun deleteAllByUserBookId(userBookId: UUID) {
+        val records = readingRecordRepository.findAllByUserBookId(userBookId)
+        val recordIds = records.map { it.id.value }
+        if (recordIds.isNotEmpty()) {
+            readingRecordTagRepository.deleteAllByReadingRecordIdIn(recordIds)
+        }
+        readingRecordRepository.deleteAllByUserBookId(userBookId)
+    }

infra/src/.../readingrecordtag/repository/JpaReadingRecordTagRepository.kt 예(간단):

 interface JpaReadingRecordTagRepository : JpaRepository<ReadingRecordTagEntity, UUID>, JpaReadingRecordTagQuerydslRepository {
     fun findByReadingRecordId(readingRecordId: UUID): List<ReadingRecordTagEntity>
+    fun deleteAllByReadingRecordIdIn(readingRecordIds: List<UUID>)
 }

요약: 현재 구현은 태그 매핑 고아화 또는 제약 위반 가능성이 있으므로, 위 중 하나(애플리케이션 레벨 선삭제 → 레코드 삭제 OR DB FK+ON DELETE CASCADE)를 적용하고, 도메인 서비스에 트랜잭션 경계(MANDATORY) 적용을 권장합니다. 필요한 경우 readingRecordTagRepository/impl 패치와 테스트용 코드 패치를 제가 직접 제공해드리겠습니다.

Committable suggestion skipped: line range outside the PR's diff.

fun deleteReadingRecord(readingRecordId: UUID) {
val readingRecord = readingRecordRepository.findById(readingRecordId)
?: throw ReadingRecordNotFoundException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ReadingRecordRepository {

fun save(readingRecord: ReadingRecord): ReadingRecord

fun deleteAllByUserBookId(userBookId: UUID)
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

반환값 설계(옵션): 삭제 건수 반환 고려

Line [12]의 deleteAllByUserBookId는 Unit을 반환합니다. 운영/검증(로깅, 모니터링, E2E 테스트) 관점에선 삭제된 행 수(Long)를 반환하면 유용합니다. 필수는 아니나, “정말로 연관 독서 기록이 모두 제거되었는지”를 상위에서 검증할 수 있습니다. 인프라 레이어(JPA)도 동일 시그니처로 맞추면 일관성이 좋아집니다.

🤖 Prompt for AI Agents
In
domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt
around line 12, change the deleteAllByUserBookId signature to return the
deleted-row count (Long) instead of Unit; update the repository interface method
to return Long, adjust any concrete implementations (including the
JPA/infrastructure repository method) to return and propagate the deletion
count, and update callers/tests to expect and, where useful, assert the returned
Long so callers can verify that related reading records were actually removed.


fun findById(id: UUID): ReadingRecord?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import org.yapp.domain.userbook.vo.HomeBookVO
import org.yapp.domain.userbook.vo.UserBookInfoVO
import org.yapp.domain.userbook.vo.UserBookStatusCountsVO
import org.yapp.globalutils.annotation.DomainService
import org.yapp.domain.readingrecord.ReadingRecordRepository
import java.util.*

@DomainService
class UserBookDomainService(
private val userBookRepository: UserBookRepository
private val userBookRepository: UserBookRepository,
) {
fun upsertUserBook(
userId: UUID,
Expand Down Expand Up @@ -77,6 +78,10 @@ class UserBookDomainService(
return userBookRepository.existsByIdAndUserId(userBookId, userId)
}

fun deleteById(userBookId: UUID) {
userBookRepository.deleteById(userBookId)
}

fun findBooksWithRecordsOrderByLatest(userId: UUID): List<HomeBookVO> {
val resultTriples = userBookRepository.findRecordedBooksSortedByRecency(userId)

Expand All @@ -103,7 +108,8 @@ class UserBookDomainService(
return userBooks.map { userBook ->
HomeBookVO.newInstance(
userBook = userBook,
lastRecordedAt = userBook.updatedAt ?: throw IllegalStateException("UserBook의 updatedAt이 null입니다: ${userBook.id}"),
lastRecordedAt = userBook.updatedAt
?: throw IllegalStateException("UserBook의 updatedAt이 null입니다: ${userBook.id}"),
recordCount = 0
Comment on lines +111 to 113
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

NPE 방지 의도는 좋으나 requireNotNull이 더 간결하고 의도가 명확합니다

Kotlin 표준 함수로 의도를 명확히 드러내고 중괄호 중첩을 줄일 수 있습니다.

-                lastRecordedAt = userBook.updatedAt
-                    ?: throw IllegalStateException("UserBook의 updatedAt이 null입니다: ${userBook.id}"),
+                lastRecordedAt = requireNotNull(userBook.updatedAt) {
+                    "UserBook의 updatedAt이 null입니다: ${userBook.id}"
+                },
📝 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
lastRecordedAt = userBook.updatedAt
?: throw IllegalStateException("UserBook의 updatedAt이 null입니다: ${userBook.id}"),
recordCount = 0
lastRecordedAt = requireNotNull(userBook.updatedAt) {
"UserBook의 updatedAt이 null입니다: ${userBook.id}"
},
recordCount = 0
🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt
around lines 111 to 113, replace the explicit null-check with an idiomatic
requireNotNull call to make the intent clearer and reduce nesting: use
requireNotNull on userBook.updatedAt with the same error message so
lastRecordedAt is assigned the non-null value or throws the specified
IllegalStateException message if null.

)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface UserBookRepository {
fun existsByIdAndUserId(id: UUID, userId: UUID): Boolean
fun findById(id: UUID): UserBook?
fun save(userBook: UserBook): UserBook
fun deleteById(id: UUID)
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

deleteById 계약(존재하지 않는 ID 처리) 명확화 및 경쟁 상태 방지 제안

Line [15]의 deleteById 추가 자체는 타당합니다. 다만 SimpleJpaRepository의 deleteById는 대상이 없을 경우 EmptyResultDataAccessException을 던집니다. 상위 계층에서 404를 보장하려면 다음을 명확히 해두는 것이 안전합니다.

  • 유스케이스 레벨에서 반드시 existsByIdAndUserId로 선검증 후 호출(현재 그렇게 사용 중인지 확인 필요).
  • 또는 도메인 계약을 “존재하지 않는 ID면 예외”로 문서화하여 일관성 유지.
  • 추가 개선(선택): 레이스 컨디션을 줄이기 위해 한 번의 쿼리로 삭제를 보장하는 deleteByIdAndUserId(id, userId): Long 같은 메서드를 제공하고, 영향 행 수를 기반으로 NotFound를 판단.

현재 PR 범위에서는 계약 문서화 + 상위 선검증 확인만으로 충분해 보입니다.

아래 스크립트로 상위 흐름에서 선검증을 수행하는지 확인해 주세요(예: existsByIdAndUserId 사용 및 404 변환).


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# delete 유스케이스/컨트롤러 구현 확인
rg -n -A 15 -B 5 $'fun\\s+deleteBookFromMyLibrary\\('

# 서비스 계층의 삭제 진입점 확인
rg -n -A 20 -B 5 $'deleteUserBook\\('

# 선검증 여부(존재/소유자 확인) 검색
rg -n -A 3 -B 3 $'existsByIdAndUserId\\('

# 도메인에서 연관 레코드 선삭제 흐름 확인
rg -n -A 5 -B 3 $'deleteAllByUserBookId\\('

Length of output: 16425


deleteById 계약 확인 — 상위선검증 존재, 문서화 또는 레이스 대비 권고

검증 결과 삭제 흐름에서 호출 전 존재/소유자 선검증이 수행되고 있음을 확인했습니다. 다만 SimpleJpaRepository#deleteById는 대상이 없을 경우 EmptyResultDataAccessException을 던지므로 계약을 명시하거나 레이스 컨디션 대비 처리를 권장합니다.

주의할 위치:

  • apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt (deleteBookFromMyLibrary → userBookService.deleteUserBook 호출)
  • apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt (deleteUserBook — validateUserBookExists 호출 후 userBookDomainService.deleteById 호출)
  • domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt (deleteById: readingRecordRepository.deleteAllByUserBookId → userBookRepository.deleteById)
  • domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt (현재 선언: fun deleteById(id: UUID))
  • infra/.../JpaUserBookRepository.kt, impl/UserBookRepositoryImpl.kt (existsByIdAndUserId 구현/위임 존재)

권장 조치 (간단):

  • 필수(권장 강하게): domain/.../UserBookRepository.kt의 deleteById에 KDoc로 동작 계약 명시(존재하지 않으면 예외 발생 또는 호출 전 상위에서 검증 필요).
  • 선택(안전성 강화): 레이스 조건을 줄이려면 repository에 deleteByIdAndUserId(id, userId): Long 같은 영향행수 반환 메서드 추가하고 UserBookDomainService에서 해당 메서드로 교체하여 영향행수로 NotFound 판단(또는 delete 시 EmptyResultDataAccessException을 잡아 NotFound로 변환).

현재 PR 범위에서는 계약 문서화 + 상위 선검증 유지 확인으로 충분해 보입니다. 추가 안정화는 별도 개선으로 권장합니다.

🤖 Prompt for AI Agents
domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt around
line 15: 현재 선언된 fun deleteById(id: UUID)에 대해 동작 계약을 명시하세요 — KDoc을 추가해 이 메서드가 대상이
없으면 SimpleJpaRepository#deleteById가 EmptyResultDataAccessException을 던진다는 점(혹은 호출
전 상위에서 존재/소유자 검증이 필수임)을 분명히 적어주고, 호출자들이 선검증을 수행해야 함을 문서화하세요; 추가로 안전성 강화를 원하면 별도
개선으로 repository에 deleteByIdAndUserId(id, userId): Long 같은 영향행수 반환 메서드 추가 및
UserBookDomainService에서 이를 사용해 영향행수 기준으로 NotFound 처리하도록 교체하는 옵션을 제안하세요.

fun findAllByUserId(userId: UUID): List<UserBook>
fun findAllByUserIdAndBookIsbn13In(userId: UUID, bookIsbn13s: List<String>): List<UserBook>
fun findUserBooksByDynamicCondition(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface JpaReadingRecordRepository : JpaRepository<ReadingRecordEntity, UUID>,

fun findAllByUserBookId(userBookId: UUID): List<ReadingRecordEntity>

fun deleteAllByUserBookId(userBookId: UUID)

Comment on lines +14 to +15
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

대량 삭제 시 영속성 컨텍스트 정합성 확보(@Modifying clearAutomatically 권장)

deleteAllByUserBookId는 파생 bulk delete 쿼리입니다. bulk 연산은 영속성 컨텍스트를 우회하므로 같은 트랜잭션에서 이미 로드된 엔티티가 있다면 스냅샷 불일치가 생길 수 있습니다. @Modifying(clearAutomatically = true) 추가를 권장합니다. 필요 시 메서드 수준 @Transactional도 명시해 주세요(관례적으로 서비스 레이어에서 트랜잭션을 갖고 있으면 생략 가능).

적용 예시(diff):

+    @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true)
     fun deleteAllByUserBookId(userBookId: UUID)

파일 상단에 import 추가(예시):

import org.springframework.data.jpa.repository.Modifying

또한, 삭제 성능을 위해 reading_record.user_book_id에 인덱스가 존재하는지 확인해 주세요(없다면 추가 권장).

인덱스 및 어노테이션 여부를 빠르게 점검:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# @Modifying 사용 여부
rg -n $'@Modifying\\(' infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt || true

# 엔티티에 userBookId 컬럼 및 인덱스 힌트 존재 여부(엔티티/DDL 힌트 탐색)
rg -n -A 3 -B 3 $'userBookId' infra | sed -n '1,200p'
rg -n -A 5 -B 2 $'@Table\\(|@Index\\(' infra | sed -n '1,200p' || true

Length of output: 25821


영속성 컨텍스트 정합성 확보: @Modifying(clearAutomatically = true) 추가 필요

검증 결과: JpaReadingRecordRepository의 deleteAllByUserBookId에 @Modifying이 없고, ReadingRecordEntity(reading_records)에는 user_book_id 인덱스 정의가 없습니다. 아래 수정 권장합니다.

수정 대상(예시)

  • infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt
    • fun deleteAllByUserBookId(userBookId: UUID) — @Modifying 미사용
  • infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
    • userBookId 컬럼 존재(@column name = "user_book_id") — @table(indexes=...) 없음(인덱스 미존재)
  • infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt
    • jpaReadingRecordRepository.deleteAllByUserBookId(userBookId) 호출 — 서비스 레이어에 트랜잭션이 없다면 메서드 수준 @transactional 고려

권장 적용 예시(diff):

+import org.springframework.data.jpa.repository.Modifying
+import org.springframework.transaction.annotation.Transactional // 선택사항
 interface JpaReadingRecordRepository : JpaRepository<ReadingRecordEntity, UUID>, JpaReadingRecordQuerydslRepository {
     fun findAllByUserBookId(userBookId: UUID): List<ReadingRecordEntity>
 
+    @Modifying(clearAutomatically = true)
+    // @Transactional // 서비스에서 트랜잭션을 보장하면 생략 가능
     fun deleteAllByUserBookId(userBookId: UUID)

인덱스 권장(마이그레이션으로 추가):

ALTER TABLE reading_records
  ADD INDEX idx_reading_records_user_book_id (user_book_id);

요약: @Modifying(clearAutomatically = true) 추가 및 필요 시 메서드 수준 @transactional 적용, 그리고 reading_records.user_book_id에 DB 인덱스 추가를 통해 성능·정합성 확보하세요.

🤖 Prompt for AI Agents
In
infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt
around lines 14-15, the deleteAllByUserBookId method lacks
@Modifying(clearAutomatically = true) which can break persistence-context
consistency; annotate the repository method with @Modifying(clearAutomatically =
true) and ensure it's called within a @Transactional boundary (add
@Transactional at service/method level if not present). Also update
infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
to declare an index on user_book_id via @Table(indexes = ...) to match DB
indexing, and add a DB migration that creates an index (e.g., ALTER TABLE
reading_records ADD INDEX idx_reading_records_user_book_id (user_book_id));
ensure ReadingRecordRepositoryImpl calls the repository within a transactional
context if the service layer is not already transactional.

fun findAllByUserBookId(userBookId: UUID, pageable: Pageable): Page<ReadingRecordEntity>

fun findAllByUserBookIdIn(userBookIds: List<UUID>): List<ReadingRecordEntity>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class ReadingRecordRepositoryImpl(
return savedEntity.toDomain()
}

override fun deleteAllByUserBookId(userBookId: UUID) {
jpaReadingRecordRepository.deleteAllByUserBookId(userBookId)
}

override fun findById(id: UUID): ReadingRecord? {
return jpaReadingRecordRepository.findByIdOrNull(id)?.toDomain()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class UserBookRepositoryImpl(
return savedEntity.toDomain()
}

override fun deleteById(id: UUID) {
jpaUserBookRepository.deleteById(id)
}

Comment on lines +41 to +44
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

deleteById의 예외 전파/404 매핑을 명확히 해주세요

jpaUserBookRepository.deleteById(id)는 해당 ID가 없으면 EmptyResultDataAccessException을 던질 수 있습니다. 상위에서 존재/소유자 검증을 선행한다면 대부분 안전하지만, 경쟁 상태(동시 삭제) 시 예외가 발생할 여지가 있습니다.

권장:

  • 글로벌 예외 처리에서 위 예외를 404로 매핑하거나,
  • 서비스 레이어에서 예외를 Domain NotFound로 변환하여 일관된 응답을 보장.

현재 구현은 위임 자체는 적절하므로, 예외 처리 정책만 합의/문서화되면 됩니다.


deleteById 예외 전파/404 매핑 필요 — 수정 필요

간단 요약: jpaUserBookRepository.deleteById(id)는 ID가 없을 경우 EmptyResultDataAccessException을 던질 수 있습니다(경합 상황에서 발생 가능). 현재 구현은 예외를 처리하지 않고 위임하고 있어 예외 전파 정책을 명확히 해야 합니다.

권장(선택 중 하나):

  • 글로벌 예외 핸들러에서 EmptyResultDataAccessException → 404로 매핑, 또는
  • 서비스/유스케이스 레이어에서 EmptyResultDataAccessException을 도메인 NotFound 예외(또는 ResponseStatusException(HttpStatus.NOT_FOUND))로 변환

주의할 파일 위치:

  • infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt (라인 41–44)

예시 대체 코드(참고):
override fun deleteById(id: UUID) {
try {
jpaUserBookRepository.deleteById(id)
} catch (ex: EmptyResultDataAccessException) {
throw DomainNotFoundException("UserBook", id) // 또는 ResponseStatusException(HttpStatus.NOT_FOUND)
}
}

현재 위임 자체는 적절하므로, 예외 처리 정책(글로벌 매핑 또는 변환)을 합의하고 코드/문서에 반영해 주세요.

🤖 Prompt for AI Agents
infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt
around lines 41-44: jpaUserBookRepository.deleteById(id) can throw
EmptyResultDataAccessException when the id does not exist; wrap the call in a
try/catch that catches EmptyResultDataAccessException and convert it to the
agreed-upon application-level response (either throw a
DomainNotFoundException("UserBook", id) or throw
ResponseStatusException(HttpStatus.NOT_FOUND)), or alternatively document and
ensure a global exception handler maps EmptyResultDataAccessException → 404;
implement the chosen policy here so the repository does not leak framework
exceptions directly.

override fun findAllByUserId(userId: UUID): List<UserBook> {
return jpaUserBookRepository.findAllByUserId(userId).map { it.toDomain() }
}
Expand Down