Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -71,4 +73,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 @@ -141,4 +141,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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ class BookUseCase(
return userBookService.findUserBooksByDynamicConditionWithStatusCounts(userId, status, sort, title, pageable)
}

@Transactional
fun deleteBookFromMyLibrary(
userId: UUID,
userBookId: UUID
) {
userService.validateUserExists(userId)
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 @@ -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,13 @@ 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,
private val readingRecordRepository: ReadingRecordRepository
) {
Copy link
Member

Choose a reason for hiding this comment

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

사실 이 방법이 간편하긴 한데, 다른 도메인의 코드도 삭제를 할 수 있어서 지양하는 방향이 개인적으로 좋아보여요!!
Usecase에 ReadingRecordService를 의존시키고 해당 비즈니스 메서드를 이용해 삭제하는 방향성은 어떨지 제안드려봅니다!

fun upsertUserBook(
userId: UUID,
Expand Down Expand Up @@ -77,6 +79,11 @@ class UserBookDomainService(
return userBookRepository.existsByIdAndUserId(userBookId, userId)
}

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

Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

부분 삭제 방지를 위한 트랜잭션 강제화 제안

현재는 UseCase에서 트랜잭션이 보장되지만, 도메인 계층의 변경 메서드가 트랜잭션 밖에서 호출되는 실수를 방지하려면 MANDATORY 전파를 적용해 트랜잭션 존재를 강제하는 것을 권장합니다.

다음과 같이 주입형 트랜잭션 강제화를 추가할 수 있습니다:

+    @Transactional(propagation = Propagation.MANDATORY)
     fun deleteById(userBookId: UUID) {
         readingRecordRepository.deleteAllByUserBookId(userBookId)
         userBookRepository.deleteById(userBookId)
     }

해당 어노테이션을 사용하려면 파일 상단에 아래 import가 필요합니다:

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

또한 대량 삭제 성능을 위해 reading_record.user_book_id(가칭) FK 컬럼에 인덱스 존재 여부를 확인해 주세요. 인덱스가 없으면 deleteAllByUserBookId 시 테이블 풀스캔이 발생할 수 있습니다.

🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt
around lines 82 to 86, the domain deleteById method can be called outside a
transactional boundary by mistake; mark the method with
@Transactional(propagation = Propagation.MANDATORY) and add the required imports
(org.springframework.transaction.annotation.Propagation and
org.springframework.transaction.annotation.Transactional) at the top of the file
to enforce a transaction is present; additionally, verify that the
reading_record.user_book_id FK column has an index (add one if missing) to avoid
full table scans during deleteAllByUserBookId.

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

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