Skip to content
Open
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
@@ -0,0 +1,121 @@
package io.github.fnzl54.library.book.presentation.get

import io.github.fnzl54.library.book.service.ReadBookListService
import io.github.fnzl54.library.config.swagger.ApiErrorCode
import io.github.fnzl54.library.core.domain.entity.BookItem
import io.github.fnzl54.library.core.exception.error.GlobalErrorCode
import io.github.fnzl54.library.core.presentation.SuccessResponse
import io.github.fnzl54.library.core.presentation.pagination.Pagination
import io.github.fnzl54.library.core.presentation.pagination.PaginationRequest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springdoc.core.annotations.ParameterObject
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@Tag(name = "Book", description = "책")
class ReadBookListController(
private val readBookListService: ReadBookListService,
) {
@Operation(
summary = "도서 검색 API",
description =
"키워드(제목 / 저자 부분 검색)로 도서를 페이지 단위로 조회합니다.\n\n" +
"키워드를 입력하지 않으면 전체 도서를 조회합니다.\n\n" +
"응답의 items는 해당 도서의 소장본 목록(청구기호, 상태)입니다.",
operationId = "readBookList",
)
@ApiResponse(
responseCode = "200",
content = [Content(schema = Schema(implementation = ReadBookListResponse::class))],
)
@ApiErrorCode(errorCodes = [GlobalErrorCode::class])
@GetMapping("/books")
fun readBookList(
@Valid @ParameterObject request: ReadBookListRequest,
): ResponseEntity<ReadBookListResponse> {
val serviceRequest =
ReadBookListService.Request(
keyword = request.keyword?.trim(),
pageable = request.toPageRequest(),
)

val serviceResponse = readBookListService.execute(serviceRequest)
return ResponseEntity.ok(toResponse(serviceResponse.result))
}

private fun toResponse(pageResult: ReadBookListService.PageResult): ReadBookListResponse =
ReadBookListResponse(
statusCode = HttpStatus.OK.value(),
result =
ReadBookListResponse.Result(
pagination = pageResult.pagination,
books =
pageResult.books.map { book ->
ReadBookListResponse.BookDetail(
bookId = book.bookId,
isbn = book.isbn,
title = book.title,
author = book.author,
publisher = book.publisher,
items =
book.items.map { item ->
ReadBookListResponse.BookItemDetail(
callNumber = item.callNumber,
status = item.status,
)
},
)
},
),
)

data class ReadBookListRequest(
@Schema(description = "검색 키워드 (제목/저자 부분 검색)", example = "릴케")
val keyword: String? = null,
Comment on lines +80 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

검색 키워드(keyword)에 대한 길이 제한 검증이 누락되어 있습니다. 사용자가 악의적으로 매우 긴 문자열을 입력할 경우 데이터베이스 쿼리 성능 저하나 예기치 못한 오류가 발생할 수 있으므로, @field:Size(max = 100) 등을 통해 적절한 길이 제한 검증을 추가하는 것을 권장합니다.

Suggested change
data class ReadBookListRequest(
@Schema(description = "검색 키워드 (제목/저자 부분 검색)", example = "릴케")
val keyword: String? = null,
data class ReadBookListRequest(
@Schema(description = "검색 키워드 (제목/저자 부분 검색)", example = "릴케")
@field:Size(max = 100, message = "검색어는 최대 100자까지 입력 가능합니다.")
val keyword: String? = null,
References
  1. 각 아키텍처 레이어에서 유효성 검증을 수행해야 합니다. 컨트롤러 레벨에서는 @Valid와 같은 프레임워크 기능을 활용하여 검증을 구현합니다.

@Schema(description = "페이지 번호 (1부터 시작, 기본값: 1)", example = "1")
override val page: Int = 1,
@Schema(description = "페이지 크기 (기본값: 10, 최대: 100)", example = "10")
override val size: Int = 10,
) : PaginationRequest(page, size)
Comment on lines +83 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

요청 객체에서 부모 클래스의 pagesize 프로퍼티를 재정의할 때는, 올바른 API 문서화를 위해 재정의된 프로퍼티에도 @Schema 어노테이션을 명시적으로 적용해야 합니다. 또한, 재정의 시 부모 클래스의 Bean Validation 어노테이션이 상속되지 않으므로 @field:Min 등의 검증 어노테이션도 함께 추가해주어야 합니다.

        @Schema(description = "페이지 번호 (1부터 시작, 기본값: 1)", example = "1")
        @field:Min(1)
        override val page: Int = 1,
        @Schema(description = "페이지 크기 (기본값: 10, 최대: 100)", example = "10")
        @field:Min(1) @field:Max(100)
        override val size: Int = 10,
    ) : PaginationRequest(page, size)
References
  1. 요청 객체에서 부모 클래스의 프로퍼티(예: page, size)를 재정의할 때는 올바른 API 문서화를 위해 재정의된 프로퍼티에도 @Schema 어노테이션을 명시적으로 적용해야 합니다.


class ReadBookListResponse(
@Schema(description = "HTTP 상태 코드", example = "200")
override val statusCode: Int,
result: Result,
) : SuccessResponse<ReadBookListResponse.Result>(statusCode = statusCode, result = result) {
data class Result(
val pagination: Pagination,
val books: List<BookDetail>,
)

data class BookDetail(
@Schema(description = "도서 ID", example = "1")
val bookId: Long,
@Schema(description = "ISBN", example = "9788937425370", nullable = true)
val isbn: String?,
@Schema(description = "도서 제목", example = "말테의 수기")
val title: String,
@Schema(description = "저자", example = "라이너 마리아 릴케")
val author: String,
@Schema(description = "출판사", example = "민음사", nullable = true)
val publisher: String?,
@Schema(description = "소장본 목록")
val items: List<BookItemDetail>,
)

data class BookItemDetail(
@Schema(description = "청구기호", example = "A-001")
val callNumber: String,
@Schema(description = "대출 상태", example = "AVAILABLE")
val status: BookItem.Status,
)
Comment on lines +114 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

특정 도메인 엔티티를 나타내는 API 엔드포인트에서는 연관된 엔티티의 물리적 상태(예: BookItem.Status) 대신 해당 도메인 엔티티의 논리적 상태를 응답으로 반환하여 의미적 명확성을 확보해야 합니다. 현재 BookItemDetail에서 BookItem.Status를 직접 반환하는 대신, 도메인의 논리적 상태를 반환하도록 변경하는 것을 권장합니다.

References
  1. 특정 도메인 엔티티를 나타내는 API 엔드포인트에서는 연관된 엔티티의 물리적 상태 대신 해당 도메인 엔티티의 논리적 상태를 반환하여 의미적 명확성을 확보해야 합니다.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.github.fnzl54.library.book.service

import com.fasterxml.jackson.annotation.JsonIgnore
import io.github.fnzl54.library.core.application.BaseRequest
import io.github.fnzl54.library.core.application.BaseResponse
import io.github.fnzl54.library.core.application.BaseService
import io.github.fnzl54.library.core.domain.entity.BookItem
import io.github.fnzl54.library.core.domain.repository.BookQueryRepository
import io.github.fnzl54.library.core.presentation.pagination.Pagination
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class ReadBookListService(
private val bookQueryRepository: BookQueryRepository,
) : BaseService<ReadBookListService.Request, ReadBookListService.Response>() {
override fun doExecute(request: Request): Response {
val searchRequest = BookQueryRepository.BookSearchRequest(keyword = request.keyword)
val bookPage = bookQueryRepository.findBooksByKeyword(searchRequest, request.pageable)

val bookIds = bookPage.content.map { it.bookId }
val itemsByBookId =
bookQueryRepository
.findBookItemsByBookIds(bookIds)
.groupBy { it.bookId }

val books =
bookPage.content.map { summary ->
BookDetail(
bookId = summary.bookId,
isbn = summary.isbn,
title = summary.title,
author = summary.author,
publisher = summary.publisher,
items =
itemsByBookId[summary.bookId].orEmpty().map { item ->
BookItemDetail(
callNumber = item.callNumber,
status = item.status,
)
},
)
}

return Response(
PageResult(
pagination = Pagination.from(bookPage),
books = books,
),
)
}

data class Request(
val keyword: String?,
val pageable: Pageable,
) : BaseRequest {
@get:JsonIgnore
override val isValid: Boolean
get() = true
}

data class PageResult(
val pagination: Pagination,
val books: List<BookDetail>,
)

data class BookDetail(
val bookId: Long,
val isbn: String?,
val title: String,
val author: String,
val publisher: String?,
val items: List<BookItemDetail>,
)

data class BookItemDetail(
val callNumber: String,
val status: BookItem.Status,
)

class Response(pageResult: PageResult) : BaseResponse<PageResult>(result = pageResult)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package io.github.fnzl54.library.core.domain.repository

import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import io.github.fnzl54.library.core.domain.entity.BookItem
import io.github.fnzl54.library.core.domain.entity.QBook.book
import io.github.fnzl54.library.core.domain.entity.QBookItem.bookItem
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.support.PageableExecutionUtils
import org.springframework.stereotype.Repository

@Repository
class BookQueryRepository(
private val jpaQueryFactory: JPAQueryFactory,
) {
data class BookSearchRequest(
val keyword: String?,
)

data class BookSummary(
val bookId: Long,
val isbn: String?,
val title: String,
val author: String,
val publisher: String?,
)

data class BookItemSummary(
val bookId: Long,
val callNumber: String,
val status: BookItem.Status,
)

fun findBooksByKeyword(
request: BookSearchRequest,
pageable: Pageable,
): Page<BookSummary> {
val predicates =
arrayOf(
book.deleted.isFalse,
keywordContains(request.keyword),
)
Comment on lines +40 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

현재 predicates 배열에 keywordContains(request.keyword)의 결과로 null이 포함될 수 있습니다. Querydsl의 where 메서드는 null 파라미터를 무시하도록 설계되어 있지만, 명시적으로 null을 배제한 논리 조건 배열을 전달하는 것이 더 안전하고 직관적입니다.

Kotlin의 listOfNotNull을 사용하면 null이 아닌 조건들만 깔끔하게 필터링하여 안전하게 전달할 수 있습니다.

Suggested change
val predicates =
arrayOf(
book.deleted.isFalse,
keywordContains(request.keyword),
)
val predicates =
listOfNotNull(
book.deleted.isFalse,
keywordContains(request.keyword),
).toTypedArray()
References
  1. Kotlin의 Null Safety를 적절히 활용하고 있는지 확인하고, Java 스타일의 처리 대신 Kotlin의 장점을 살린 코드를 제안합니다. (link)


val content =
jpaQueryFactory
.select(
Projections.constructor(
BookSummary::class.java,
book.id,
book.isbn,
book.title,
book.author,
book.publisher,
),
).from(book)
.where(*predicates)
.orderBy(book.title.asc(), book.id.asc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()

val countQuery =
jpaQueryFactory
.select(book.count())
.from(book)
.where(*predicates)

return PageableExecutionUtils.getPage(content, pageable) { countQuery.fetchOne() ?: 0L }
}

fun findBookItemsByBookIds(bookIds: Collection<Long>): List<BookItemSummary> {
if (bookIds.isEmpty()) return emptyList()

return jpaQueryFactory
.select(
Projections.constructor(
BookItemSummary::class.java,
bookItem.book.id,
bookItem.callNumber,
bookItem.status,
),
).from(bookItem)
.where(
bookItem.deleted.isFalse,
bookItem.book.id.`in`(bookIds),
)
.orderBy(bookItem.callNumber.asc())
.fetch()
}

private fun keywordContains(keyword: String?): BooleanExpression? =
keyword?.takeIf { it.isNotBlank() }?.let { kw ->
book.title.containsIgnoreCase(kw)
.or(book.author.containsIgnoreCase(kw))
}
}