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 @@ -18,10 +18,7 @@ import org.springframework.web.bind.annotation.RestController
import org.yapp.apis.book.dto.request.BookDetailRequest
import org.yapp.apis.book.dto.request.BookSearchRequest
import org.yapp.apis.book.dto.request.UserBookRegisterRequest
import org.yapp.apis.book.dto.response.BookDetailResponse
import org.yapp.apis.book.dto.response.BookSearchResponse
import org.yapp.apis.book.dto.response.UserBookPageResponse
import org.yapp.apis.book.dto.response.UserBookResponse
import org.yapp.apis.book.dto.response.*
import org.yapp.apis.book.usecase.BookUseCase
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBookSortType
Expand All @@ -33,11 +30,20 @@ class BookController(
private val bookUseCase: BookUseCase,
) : BookControllerApi {

@GetMapping("/guest/search")
override fun searchBooksForGuest(
@Valid @ModelAttribute request: BookSearchRequest
): ResponseEntity<GuestBookSearchResponse> {
val response = bookUseCase.searchBooksForGuest(request)
return ResponseEntity.ok(response)
}
Comment on lines +33 to +39
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

게스트 검색 엔드포인트 추가 적절 + 남용 방지 전략 고려

API 분리는 명확합니다. 공개 엔드포인트인 만큼 트래픽 남용(스크래핑, 대량 호출) 대비를 위해 게이트웨이/인프라 레벨에서의 레이트 리밋, 캐시 전략 등을 함께 검토해 주세요.

🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt around
lines 33–39, you added a public guest search endpoint but the review requests
safeguards against abuse; implement rate limiting and caching: add a rate-limit
mechanism (either configure gateway/ingress rules or add a server-side limiter
such as Bucket4j/Resilience4j filter or servlet filter tied to IP/API key) to
throttle requests, apply caching for identical search queries (use @Cacheable on
the use-case method or add appropriate Cache-Control response headers) to reduce
load, validate and enforce sensible request limits (page size, max query length)
in BookSearchRequest, and update API docs/infra config with the chosen
rate-limit and cache settings.


@GetMapping("/search")
override fun searchBooks(
@AuthenticationPrincipal userId: UUID,
@Valid @ModelAttribute request: BookSearchRequest
): ResponseEntity<BookSearchResponse> {
val response = bookUseCase.searchBooks(request)
val response = bookUseCase.searchBooks(request, userId)
return ResponseEntity.ok(response)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ import org.springframework.web.bind.annotation.*
import org.yapp.apis.book.dto.request.BookDetailRequest
import org.yapp.apis.book.dto.request.BookSearchRequest
import org.yapp.apis.book.dto.request.UserBookRegisterRequest
import org.yapp.apis.book.dto.response.BookDetailResponse
import org.yapp.apis.book.dto.response.BookSearchResponse
import org.yapp.apis.book.dto.response.UserBookPageResponse
import org.yapp.apis.book.dto.response.UserBookResponse
import org.yapp.apis.book.dto.response.*
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBookSortType
import org.yapp.globalutils.exception.ErrorResponse
Expand All @@ -32,7 +29,30 @@ import java.util.*
interface BookControllerApi {

@Operation(
summary = "도서 검색", description = "알라딘 API를 통해 키워드로 도서를 검색합니다. \n" +
summary = "비회원 도서 검색", description = "알라딘 API를 통해 키워드로 도서를 검색합니다. \n" +
" 비회원이기에 도서 상태(읽음, 읽는 중 등)은 표시되지 않습니다. "
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "성공적인 검색",
content = [Content(schema = Schema(implementation = GuestBookSearchResponse::class))]
),
ApiResponse(
responseCode = "400",
description = "잘못된 요청 파라미터",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@GetMapping("/guest/search")
fun searchBooksForGuest(
@Valid @Parameter(description = "도서 검색 요청 객체") request: BookSearchRequest
): ResponseEntity<GuestBookSearchResponse>

Comment on lines 31 to +53
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

게스트용 API 스펙 명료화 좋습니다

게스트 응답 DTO를 별도 분리하고 비회원 제약(도서 상태 비노출)을 명확히 기재한 점 좋습니다. 페이지네이션 필드(lastPage, itemsPerPage 등)가 응답에 포함됨을 문서에도 간단히 언급하면 소비자 입장에서 더 명확해집니다.

🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
around lines 31 to 53, the OpenAPI @Operation description for the guest search
endpoint should explicitly mention that the response includes pagination fields
(e.g., lastPage, itemsPerPage, currentPage, totalItems) so consumers know paging
metadata is provided; update the description string to append a short sentence
listing the pagination fields included in GuestBookSearchResponse (or adjust
wording to say "응답에 lastPage, itemsPerPage 등을 포함한 페이지네이션 메타데이터가 포함됩니다") and
ensure the schema annotation remains unchanged.

@Operation(
summary = "회원 도서 검색", description = "알라딘 API를 통해 키워드로 도서를 검색합니다. \n" +
" 유저의 도서 상태(읽음, 읽는 중 등)가 함께 표시됩니다. "
)
@ApiResponses(
Expand All @@ -51,6 +71,7 @@ interface BookControllerApi {
)
@GetMapping("/search")
fun searchBooks(
@AuthenticationPrincipal userId: UUID,
@Valid @Parameter(description = "도서 검색 요청 객체") request: BookSearchRequest
): ResponseEntity<BookSearchResponse>
Comment on lines 72 to 76
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

회원 검색에 대한 보안 스키마 노출(스웨거) 제안

실제 보안은 잘 적용되어 있지만, 스웨거 문서에도 보안 요구 사항을 노출하면 더 명확합니다. springdoc 기준으로 SecurityRequirement를 추가하세요(스키마 이름은 프로젝트 설정에 맞춰 조정 필요).

예시:

@Operation(
    summary = "...",
    security = [SecurityRequirement(name = "bearerAuth")]
)
🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
around lines 72 to 76, the controller method exposes authentication in runtime
but does not declare the security requirement in the generated OpenAPI/Swagger
docs; add a springdoc @Operation annotation to the method including security =
[SecurityRequirement(name = "bearerAuth")] (adjust the schema name to match
project settings), keep or add summary/description as needed, and ensure the
io.swagger.v3.oas.annotations.security.SecurityRequirement import is present so
the generated docs show the bearer auth requirement.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ data class BookSearchResponse private constructor(
}
}

@Schema(name = "BookSummary", description = "검색된 단일 책 요약 정보")
@Schema(name = "BookSummary", description = "회원용 검색된 단일 책 요약 정보")
data class BookSummary private constructor(

@field:Schema(description = "ISBN-13 번호", example = "9781234567890")
Expand All @@ -103,7 +103,7 @@ data class BookSearchResponse private constructor(
description = "알라딘 도서 상세 페이지 링크",
example = "http://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
)
val link: String, // Added link field
val link: String,

@field:Schema(description = "사용자의 책 상태", example = "BEFORE_REGISTRATION")
val userBookStatus: BookStatus
Expand All @@ -120,7 +120,7 @@ data class BookSearchResponse private constructor(
author: String?,
publisher: String?,
coverImageUrl: String,
link: String // Added link
link: String
): BookSummary {
require(!title.isNullOrBlank()) { "Title is required" }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.yapp.apis.book.dto.response

import io.swagger.v3.oas.annotations.media.Schema
import org.yapp.globalutils.validator.BookDataValidator

@Schema(
name = "GuestBookSearchResponse",
description = "게스트용 알라딘 도서 검색 API 응답"
)
data class GuestBookSearchResponse private constructor(
@field:Schema(description = "API 응답 버전", example = "20131101")
val version: String?,

@field:Schema(description = "검색 결과 제목", example = "데미안")
val title: String?,

@field:Schema(description = "출간일", example = "2025-07-30")
val pubDate: String?,

@field:Schema(description = "총 검색 결과 개수", example = "42")
val totalResults: Int?,

@field:Schema(description = "검색 시작 인덱스", example = "1")
val startIndex: Int?,

@field:Schema(description = "한 페이지당 검색 결과 개수", example = "10")
val itemsPerPage: Int?,

@field:Schema(description = "검색 쿼리 문자열", example = "데미안")
val query: String?,

@field:Schema(description = "검색 카테고리 ID", example = "1")
val searchCategoryId: Int?,

@field:Schema(description = "검색 카테고리 이름", example = "소설/시/희곡")
val searchCategoryName: String?,

@field:Schema(description = "마지막 페이지 여부", example = "false")
val lastPage: Boolean,

@field:Schema(description = "검색된 책 목록 (게스트용)")
val books: List<GuestBookSummary>
) {
Comment on lines +38 to +43
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

OpenAPI 스키마에서 필수 필드 표시

게스트 응답의 lastPage, books는 논리적으로 필수입니다. 스키마에 명시하면 클라이언트/문서 일관성이 좋아집니다.

-    @field:Schema(description = "마지막 페이지 여부", example = "false")
+    @field:Schema(description = "마지막 페이지 여부", example = "false", requiredMode = Schema.RequiredMode.REQUIRED)
     val lastPage: Boolean,

-    @field:Schema(description = "검색된 책 목록 (게스트용)")
+    @field:Schema(description = "검색된 책 목록 (게스트용)", requiredMode = Schema.RequiredMode.REQUIRED)
     val books: List<GuestBookSummary>
📝 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
@field:Schema(description = "마지막 페이지 여부", example = "false")
val lastPage: Boolean,
@field:Schema(description = "검색된 책 목록 (게스트용)")
val books: List<GuestBookSummary>
) {
@field:Schema(description = "마지막 페이지 여부", example = "false", requiredMode = Schema.RequiredMode.REQUIRED)
val lastPage: Boolean,
@field:Schema(description = "검색된 책 목록 (게스트용)", requiredMode = Schema.RequiredMode.REQUIRED)
val books: List<GuestBookSummary>
) {
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 38 to 43, the OpenAPI schema doesn't mark the logically required
properties lastPage and books as required; update the @field:Schema annotations
for both properties to include required = true (e.g., @field:Schema(description
= "...", example = "...", required = true)) so the generated OpenAPI spec and
client docs show these fields as mandatory.

companion object {
fun from(response: BookSearchResponse): GuestBookSearchResponse {
return GuestBookSearchResponse(
version = response.version,
title = response.title,
pubDate = response.pubDate,
totalResults = response.totalResults,
startIndex = response.startIndex,
itemsPerPage = response.itemsPerPage,
query = response.query,
searchCategoryId = response.searchCategoryId,
searchCategoryName = response.searchCategoryName,
lastPage = response.lastPage,
books = response.books.map { userBookSummary ->
GuestBookSummary.of(
isbn13 = userBookSummary.isbn13,
title = userBookSummary.title,
author = userBookSummary.author,
publisher = userBookSummary.publisher,
coverImageUrl = userBookSummary.coverImageUrl,
link = userBookSummary.link
Comment on lines +57 to +64
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

변수명 간결화 제안: userBookSummary → book

게스트 DTO 변환 컨텍스트에서 userBookSummary는 혼동 여지가 있습니다. book처럼 일반화된 이름이 가독성에 더 유리합니다. 위의 보강 diff에 반영했습니다.

🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 57 to 64, the lambda parameter name userBookSummary is overly
verbose and should be renamed to book for clarity; update the map {
userBookSummary -> ... } to map { book -> ... } and replace all occurrences of
userBookSummary.isbn13, .title, .author, .publisher, .coverImageUrl, and .link
inside the lambda with book.isbn13, book.title, book.author, book.publisher,
book.coverImageUrl, and book.link respectively to keep naming concise and
consistent.

)
}
)
}
Comment on lines +45 to +68
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

null-안전성과 경계값 보강 필요: lastPage, books 기본값 처리

소스 BookSearchResponselastPage/books가 nullable일 가능성을 고려하면 NPE/스키마 불일치 위험이 있습니다. 게스트 응답은 불변·안전하게 만들어두는 편이 좋습니다.

  • lastPage: nullable이면 기본값을 false 또는 계산값으로 대체
  • books: nullable이면 빈 리스트로 대체
  • 가독성: userBookSummary 변수명은 book 정도로 단순화

다음과 같이 보강을 제안합니다:

         fun from(response: BookSearchResponse): GuestBookSearchResponse {
-            return GuestBookSearchResponse(
+            // nullable 대비: lastPage 계산/기본값
+            val computedLastPage = response.lastPage ?: run {
+                val total = response.totalResults
+                val start = response.startIndex
+                val size = response.itemsPerPage
+                if (total != null && start != null && size != null) {
+                    start + size >= total
+                } else {
+                    false
+                }
+            }
+            return GuestBookSearchResponse(
                 version = response.version,
                 title = response.title,
                 pubDate = response.pubDate,
                 totalResults = response.totalResults,
                 startIndex = response.startIndex,
                 itemsPerPage = response.itemsPerPage,
                 query = response.query,
                 searchCategoryId = response.searchCategoryId,
                 searchCategoryName = response.searchCategoryName,
-                lastPage = response.lastPage,
-                books = response.books.map { userBookSummary ->
+                lastPage = computedLastPage,
+                books = response.books.orEmpty().map { book ->
-                    GuestBookSummary.of(
-                        isbn13 = userBookSummary.isbn13,
-                        title = userBookSummary.title,
-                        author = userBookSummary.author,
-                        publisher = userBookSummary.publisher,
-                        coverImageUrl = userBookSummary.coverImageUrl,
-                        link = userBookSummary.link
+                    GuestBookSummary.of(
+                        isbn13 = book.isbn13,
+                        title = book.title,
+                        author = book.author,
+                        publisher = book.publisher,
+                        coverImageUrl = book.coverImageUrl,
+                        link = book.link
                     )
                 }
             )
         }
📝 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
fun from(response: BookSearchResponse): GuestBookSearchResponse {
return GuestBookSearchResponse(
version = response.version,
title = response.title,
pubDate = response.pubDate,
totalResults = response.totalResults,
startIndex = response.startIndex,
itemsPerPage = response.itemsPerPage,
query = response.query,
searchCategoryId = response.searchCategoryId,
searchCategoryName = response.searchCategoryName,
lastPage = response.lastPage,
books = response.books.map { userBookSummary ->
GuestBookSummary.of(
isbn13 = userBookSummary.isbn13,
title = userBookSummary.title,
author = userBookSummary.author,
publisher = userBookSummary.publisher,
coverImageUrl = userBookSummary.coverImageUrl,
link = userBookSummary.link
)
}
)
}
fun from(response: BookSearchResponse): GuestBookSearchResponse {
// nullable 대비: lastPage 계산/기본값
val computedLastPage = response.lastPage ?: run {
val total = response.totalResults
val start = response.startIndex
val size = response.itemsPerPage
if (total != null && start != null && size != null) {
start + size >= total
} else {
false
}
}
return GuestBookSearchResponse(
version = response.version,
title = response.title,
pubDate = response.pubDate,
totalResults = response.totalResults,
startIndex = response.startIndex,
itemsPerPage = response.itemsPerPage,
query = response.query,
searchCategoryId = response.searchCategoryId,
searchCategoryName = response.searchCategoryName,
lastPage = computedLastPage,
books = response.books.orEmpty().map { book ->
GuestBookSummary.of(
isbn13 = book.isbn13,
title = book.title,
author = book.author,
publisher = book.publisher,
coverImageUrl = book.coverImageUrl,
link = book.link
)
}
)
}
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 45 to 68, the current mapping assumes BookSearchResponse.lastPage
and BookSearchResponse.books are non-null which can cause NPEs and schema
mismatches; update the factory to treat lastPage as non-null (use a safe default
like false or compute from totalResults/itemsPerPage if available) and replace a
nullable books with an empty list, map using a simpler loop variable name (e.g.,
book) when converting to GuestBookSummary, and ensure the returned
GuestBookSearchResponse always contains non-null lastPage and a non-null
immutable books list.

}
Comment on lines +44 to +69
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

단위 테스트 추가 제안: 매핑/정규화 검증

게스트 DTO 팩토리의 핵심 로직이므로, 최소한 다음 케이스에 대한 단위 테스트를 권장합니다:

  • books=null일 때 빈 리스트 매핑
  • lastPage=null일 때 계산/기본값 적용
  • publisher 괄호/공백 정리 확인

필요하시면 테스트 스캐폴딩을 생성해 드리겠습니다.

🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 44 to 69, add null-safe mapping and normalization and add unit
tests: ensure response.books is handled with response.books?.map { ... } ?:
emptyList() so a null books yields an empty list; ensure lastPage is derived or
defaulted when response.lastPage is null (apply the same calculation or default
used elsewhere); normalize publisher by trimming and stripping surrounding
parentheses/extra spaces when mapping each book; then add unit tests covering
books=null, lastPage=null, and publisher containing parentheses/extra whitespace
to verify mapping/normalization behavior.


@Schema(name = "GuestBookSummary", description = "게스트용 검색된 단일 책 요약 정보")
data class GuestBookSummary private constructor(

@field:Schema(description = "ISBN-13 번호", example = "9781234567890")
val isbn13: String,

@field:Schema(description = "책 제목", example = "데미안")
val title: String,

@field:Schema(description = "저자", example = "헤르만 헤세")
val author: String?,

@field:Schema(description = "출판사", example = "민음사")
val publisher: String?,

@field:Schema(
description = "책 표지 이미지 URL",
example = "https://image.aladin.co.kr/product/36801/75/coversum/k692030806_1.jpg"
)
val coverImageUrl: String,

@field:Schema(
description = "알라딘 도서 상세 페이지 링크",
example = "http://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
)
Comment on lines +93 to +95
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

예시 링크 https 권장

문서 예시 값은 가급적 https를 권장합니다.

-            example = "http://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
+            example = "https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
📝 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
description = "알라딘 도서 상세 페이지 링크",
example = "http://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
)
description = "알라딘 도서 상세 페이지 링크",
example = "https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175"
)
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 93 to 95, the example URL uses http; update the example to use an
https link (e.g. https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=3680175) so
the documented example prefers secure scheme—simply replace the example string
with the https variant while keeping the description unchanged.

val link: String
) {
companion object {
fun of(
isbn13: String,
title: String,
author: String?,
publisher: String?,
coverImageUrl: String,
link: String
): GuestBookSummary {
return GuestBookSummary(
isbn13 = isbn13,
title = title,
author = author,
publisher = publisher?.let { BookDataValidator.removeParenthesesFromPublisher(it) },
coverImageUrl = coverImageUrl,
link = link
)
}
}
Comment on lines +98 to +116
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

publisher 정규화 추가 보강: trim/blank 처리

출판사 값이 공백이거나 공백만 있는 경우 null로 정규화하면 응답 품질이 좋아집니다. 현재 괄호 제거만 하므로 최소한의 전처리를 더하는 것을 제안합니다.

-                return GuestBookSummary(
-                    isbn13 = isbn13,
-                    title = title,
-                    author = author,
-                    publisher = publisher?.let { BookDataValidator.removeParenthesesFromPublisher(it) },
-                    coverImageUrl = coverImageUrl,
-                    link = link
-                )
+                val normalizedPublisher = publisher
+                    ?.trim()
+                    ?.takeIf { it.isNotBlank() }
+                    ?.let(BookDataValidator::removeParenthesesFromPublisher)
+                return GuestBookSummary(
+                    isbn13 = isbn13,
+                    title = title,
+                    author = author,
+                    publisher = normalizedPublisher,
+                    coverImageUrl = coverImageUrl,
+                    link = link
+                )
📝 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
companion object {
fun of(
isbn13: String,
title: String,
author: String?,
publisher: String?,
coverImageUrl: String,
link: String
): GuestBookSummary {
return GuestBookSummary(
isbn13 = isbn13,
title = title,
author = author,
publisher = publisher?.let { BookDataValidator.removeParenthesesFromPublisher(it) },
coverImageUrl = coverImageUrl,
link = link
)
}
}
companion object {
fun of(
isbn13: String,
title: String,
author: String?,
publisher: String?,
coverImageUrl: String,
link: String
): GuestBookSummary {
val normalizedPublisher = publisher
?.trim()
?.takeIf { it.isNotBlank() }
?.let(BookDataValidator::removeParenthesesFromPublisher)
return GuestBookSummary(
isbn13 = isbn13,
title = title,
author = author,
publisher = normalizedPublisher,
coverImageUrl = coverImageUrl,
link = link
)
}
}
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/book/dto/response/GuestBookSearchResponse.kt
around lines 98 to 116, the publisher normalization only strips parentheses but
doesn't trim whitespace or convert blank-only strings to null; update the
publisher handling to trim the string after removing parentheses and return null
if the trimmed result is blank (e.g., publisher?.let {
BookDataValidator.removeParenthesesFromPublisher(it).trim().takeIf {
it.isNotBlank() } }) so the GuestBookSummary.publisher is normalized to null for
empty/blank values.

}
}
17 changes: 11 additions & 6 deletions apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.domain.Pageable
import org.springframework.transaction.annotation.Transactional
import org.yapp.apis.book.dto.request.*
import org.yapp.apis.book.dto.response.BookDetailResponse
import org.yapp.apis.book.dto.response.BookSearchResponse
import org.yapp.apis.book.dto.response.*
import org.yapp.apis.book.dto.response.BookSearchResponse.BookSummary
import org.yapp.apis.book.dto.response.UserBookPageResponse
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
Expand All @@ -30,11 +27,19 @@ class BookUseCase(
private val bookManagementService: BookManagementService,
private val readingRecordService: ReadingRecordService
) {
fun searchBooks(
fun searchBooksForGuest(
request: BookSearchRequest
): GuestBookSearchResponse {
val searchResponse = bookQueryService.searchBooks(request)
return GuestBookSearchResponse.from(searchResponse)
}

fun searchBooks(
request: BookSearchRequest,
userId: UUID
): BookSearchResponse {
val searchResponse = bookQueryService.searchBooks(request)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, null)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)

return searchResponse.withUpdatedBooks(booksWithUserStatus)
}
Comment on lines +37 to 45
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

회원 검색에서 사용자 검증/널 처리 일관성 및 단순화 제안

  • getBookDetail 등 다른 경로에서는 userService.validateUserExists(userId)를 호출하는데, 검색에서는 생략되어 있습니다. 의도(성능 최적화 vs 일관된 404 전략)를 문서화하거나 확인 부탁드립니다. 비존재 사용자에 대한 기대 동작을 팀 컨벤션에 맞추는 것이 좋습니다.
  • searchBooks에서 userId는 non-null로 전달되므로, 아래 private 함수들의 userId: UUID? 및 null 처리 분기를 제거해 시그니처를 단순화할 수 있습니다.

아래와 같이 단순화할 수 있습니다.

-    private fun mergeWithUserBookStatus(
-        searchedBooks: List<BookSummary>,
-        userId: UUID?
-    ): List<BookSummary> {
-        if (userId == null || searchedBooks.isEmpty()) {
-            return searchedBooks
-        }
+    private fun mergeWithUserBookStatus(
+        searchedBooks: List<BookSummary>,
+        userId: UUID
+    ): List<BookSummary> {
+        if (searchedBooks.isEmpty()) return searchedBooks
         val isbn13s = searchedBooks.map { it.isbn13 }
         val userBookStatusMap = getUserBookStatusMap(isbn13s, userId)
         return searchedBooks.map { bookSummary ->
             userBookStatusMap[bookSummary.isbn13]
                 ?.let { bookSummary.updateStatus(it) }
                 ?: bookSummary
         }
     }
 
-    private fun getUserBookStatusMap(
-        isbn13s: List<String>,
-        userId: UUID?
-    ): Map<String, BookStatus> {
-        if (userId == null) return emptyMap()
+    private fun getUserBookStatusMap(
+        isbn13s: List<String>,
+        userId: UUID
+    ): Map<String, BookStatus> {
         val userBooksResponse = userBookService.findAllByUserIdAndBookIsbn13In(
             UserBooksByIsbn13sRequest.of(userId, isbn13s)
         )
         return userBooksResponse.associate { userBook ->
             userBook.isbn13 to userBook.status
         }
     }

사용자 존재 검증을 넣을지 여부는 아래와 같이 선택적으로 적용 가능합니다.

fun searchBooks(request: BookSearchRequest, userId: UUID): BookSearchResponse {
    // 정책에 따라 활성화
    // userService.validateUserExists(userId)
    val searchResponse = bookQueryService.searchBooks(request)
    val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)
    return searchResponse.withUpdatedBooks(booksWithUserStatus)
}
🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt around lines
37 to 45, the searchBooks path omits the user existence validation used
elsewhere and passes a non-null userId into private functions typed as nullable;
update to either call userService.validateUserExists(userId) here (or document
why omitted) to match team convention, then simplify the private helper
signatures to accept userId: UUID (remove UUID? and eliminate null branches) and
update all internal usages accordingly so there is no redundant nullable
handling.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class SecurityConfig(
) {
companion object {
private val WHITELIST_URLS = arrayOf(
"/api/v1/books/search",
"/api/v1/books/guest/search",
"/api/v1/auth/refresh",
Comment on lines 24 to 26
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

permitAll 경로에서 잘못된 토큰에 의한 401 가능성 점검

Spring Security에서 BearerTokenAuthenticationFilter는 Authorization 헤더가 존재하면 인증을 시도합니다. 게스트 공개 엔드포인트라도 잘못된 토큰이 실린 요청은 401이 날 수 있으니, UX 정책에 따라 다음 중 하나를 고려해 주세요.

  • 공개 엔드포인트에서는 Authorization 헤더를 무시하도록 필터 추가
  • 또는 문서화/모니터링으로 “잘못된 토큰은 공개 엔드포인트에서도 401” 정책을 명시

필터를 추가하는 예시는 아래와 같습니다.

// 새 필터 추가 예시 (별도 파일로 생성)
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletRequestWrapper
import org.springframework.util.AntPathMatcher
import org.springframework.web.filter.OncePerRequestFilter
import java.util.*

class SkipBearerOnWhitelistFilter(
    private val whitelist: Array<String>
) : OncePerRequestFilter() {

    private val matcher = AntPathMatcher()

    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        return whitelist.none { matcher.match(it, request.requestURI) }
    }

    override fun doFilterInternal(request: HttpServletRequest, response: jakarta.servlet.http.HttpServletResponse, filterChain: jakarta.servlet.FilterChain) {
        val wrapped = object : HttpServletRequestWrapper(request) {
            override fun getHeader(name: String?): String? {
                return if (name.equals("Authorization", ignoreCase = true)) null else super.getHeader(name)
            }
            override fun getHeaders(name: String?): Enumeration<String> {
                if (name.equals("Authorization", ignoreCase = true)) return Collections.emptyEnumeration()
                return super.getHeaders(name)
            }
        }
        filterChain.doFilter(wrapped, response)
    }
}

SecurityConfig 에 필터를 BearerTokenAuthenticationFilter 이전에 추가:

 .authorizeHttpRequests {
     it.requestMatchers(*WHITELIST_URLS).permitAll()
     it.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
     it.anyRequest().authenticated()
 }
+.addFilterBefore(SkipBearerOnWhitelistFilter(WHITELIST_URLS), BearerTokenAuthenticationFilter::class.java)
 .addFilterAfter(mdcLoggingFilter, BearerTokenAuthenticationFilter::class.java)
🤖 Prompt for AI Agents
gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt lines 24-26:
permitAll endpoints may still trigger BearerTokenAuthenticationFilter when an
Authorization header is present, causing unintended 401s; add a
OncePerRequestFilter (e.g., SkipBearerOnWhitelistFilter) that matches requests
against WHITELIST_URLS and, for matches, strips/returns empty for the
Authorization header (override getHeader and getHeaders, handle case-insensitive
"Authorization"), then register this filter in SecurityConfig before
BearerTokenAuthenticationFilter so whitelist endpoints ignore bearer tokens and
avoid unwanted authentication attempts.

"/api/v1/auth/signin",
"/actuator/**",
Expand Down