-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#3): 도서 CRUD API 구현 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # 사용처 : CreateBookService.execute, UpdateBookService.execute | ||
| # 활성 도서끼리만 ISBN 중복을 금지한다. | ||
| CREATE UNIQUE INDEX ux_books_active_isbn | ||
| ON books ((IF(deleted_at IS NULL, isbn, NULL))); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package org.library.book.application | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema | ||
| import jakarta.validation.constraints.NotBlank | ||
| import org.library.book.domain.Book | ||
| import org.library.book.domain.BookRepository | ||
| import org.library.book.domain.error.BookError | ||
| import org.library.book.dto.BookResponse | ||
| import org.library.core.application.Result | ||
| import org.library.core.application.err | ||
| import org.library.core.application.ok | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| class CreateBookService( | ||
| private val bookRepository: BookRepository, | ||
| ) { | ||
|
|
||
| @Transactional | ||
| fun execute(request: Request): Result<BookResponse, BookError> { | ||
| val isbn = Book.normalizeIsbn(request.isbn) | ||
| if (isbn != null && bookRepository.findByIsbnAndDeletedAtIsNull(isbn) != null) { | ||
| return BookError.DUPLICATE_ISBN.err() | ||
| } | ||
| val book = bookRepository.save( | ||
| Book(title = request.title, author = request.author, isbn = isbn), | ||
| ) | ||
| return BookResponse.from(book).ok() | ||
| } | ||
|
|
||
| data class Request( | ||
| @field:NotBlank | ||
| @field:Schema(description = "도서 제목", example = "클린 아키텍처") | ||
| val title: String, | ||
| @field:NotBlank | ||
| @field:Schema(description = "저자", example = "로버트 마틴") | ||
| val author: String, | ||
| @field:Schema(description = "ISBN (미입력 가능)", example = "9788966262472", nullable = true) | ||
| val isbn: String? = null, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package org.library.book.application | ||
|
|
||
| import org.library.book.domain.BookRepository | ||
| import org.library.book.domain.error.BookError | ||
| import org.library.core.application.Result | ||
| import org.library.core.application.err | ||
| import org.library.core.application.ok | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| class DeleteBookService( | ||
| private val bookRepository: BookRepository, | ||
| ) { | ||
|
|
||
| @Transactional | ||
| fun execute(id: Long): Result<Unit, BookError> { | ||
| val book = bookRepository.findByIdAndDeletedAtIsNull(id) | ||
| ?: return BookError.NOT_FOUND.err() | ||
| book.softDelete() | ||
| return Unit.ok() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package org.library.book.application | ||
|
|
||
| import org.library.book.domain.BookRepository | ||
| import org.library.book.domain.error.BookError | ||
| import org.library.book.dto.BookResponse | ||
| import org.library.core.application.Result | ||
| import org.library.core.application.err | ||
| import org.library.core.application.ok | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| @Transactional(readOnly = true) | ||
| class GetBookService( | ||
| private val bookRepository: BookRepository, | ||
| ) { | ||
|
|
||
| fun execute(id: Long): Result<BookResponse, BookError> { | ||
| val book = bookRepository.findByIdAndDeletedAtIsNull(id) | ||
| ?: return BookError.NOT_FOUND.err() | ||
| return BookResponse.from(book).ok() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package org.library.book.application | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema | ||
| import jakarta.validation.constraints.NotBlank | ||
| import org.library.book.domain.Book | ||
| import org.library.book.domain.BookRepository | ||
| import org.library.book.domain.error.BookError | ||
| import org.library.book.dto.BookResponse | ||
| import org.library.core.application.Result | ||
| import org.library.core.application.err | ||
| import org.library.core.application.ok | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| class UpdateBookService( | ||
| private val bookRepository: BookRepository, | ||
| ) { | ||
|
|
||
| @Transactional | ||
| fun execute(id: Long, request: Request): Result<BookResponse, BookError> { | ||
| val book = bookRepository.findByIdAndDeletedAtIsNull(id) | ||
| ?: return BookError.NOT_FOUND.err() | ||
|
|
||
| val isbn = Book.normalizeIsbn(request.isbn) | ||
| if (isbn != null) { | ||
| val owner = bookRepository.findByIsbnAndDeletedAtIsNull(isbn) | ||
| if (owner != null && owner.id != id) { | ||
| return BookError.DUPLICATE_ISBN.err() | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] 동일한 ISBN 레이스 컨디션이 수정 API에도 존재 Problem: Evidence: 동일 패턴에 대한 근본 원인과 수정 방향은 Fix direction:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수정 완료 (상단 리뷰 참고) |
||
|
|
||
| book.update(title = request.title, author = request.author, isbn = isbn) | ||
| return BookResponse.from(book).ok() | ||
| } | ||
|
|
||
| data class Request( | ||
| @field:NotBlank | ||
| @field:Schema(description = "도서 제목", example = "클린 아키텍처") | ||
| val title: String, | ||
| @field:NotBlank | ||
| @field:Schema(description = "저자", example = "로버트 마틴") | ||
| val author: String, | ||
| @field:Schema(description = "ISBN (미입력 시 기존 값이 지워진다)", example = "9788966262472", nullable = true) | ||
| val isbn: String? = null, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package org.library.book.controller | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation | ||
| import io.swagger.v3.oas.annotations.Parameter | ||
| 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.library.book.application.CreateBookService | ||
| import org.library.book.application.DeleteBookService | ||
| import org.library.book.application.GetBookService | ||
| import org.library.book.application.UpdateBookService | ||
| import org.library.book.domain.error.BookError | ||
| import org.library.book.dto.BookResponse | ||
| import org.library.core.application.getOrThrow | ||
| import org.library.core.swagger.ApiErrorCode | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.http.ResponseEntity | ||
| import org.springframework.web.bind.annotation.DeleteMapping | ||
| import org.springframework.web.bind.annotation.GetMapping | ||
| import org.springframework.web.bind.annotation.PatchMapping | ||
| import org.springframework.web.bind.annotation.PathVariable | ||
| import org.springframework.web.bind.annotation.PostMapping | ||
| import org.springframework.web.bind.annotation.RequestBody | ||
| import org.springframework.web.bind.annotation.RequestMapping | ||
| import org.springframework.web.bind.annotation.RestController | ||
|
|
||
| @Tag(name = "Book", description = "도서 관리 API") | ||
| @RestController | ||
| @RequestMapping("/books") | ||
| class BookController( | ||
| private val createBookService: CreateBookService, | ||
| private val getBookService: GetBookService, | ||
| private val updateBookService: UpdateBookService, | ||
| private val deleteBookService: DeleteBookService, | ||
| ) { | ||
|
|
||
| @Operation( | ||
| summary = "도서 등록", | ||
| description = "새 도서를 등록한다. ISBN은 미입력할 수 있고, 값이 있으면 중복될 수 없다.", | ||
| ) | ||
| @ApiResponse( | ||
| responseCode = "201", | ||
| description = "도서 등록 성공", | ||
| content = [Content(schema = Schema(implementation = BookResponse::class))], | ||
| ) | ||
| @ApiErrorCode(errorCodes = [BookError::class], only = ["DUPLICATE_ISBN"]) | ||
| @PostMapping | ||
| fun create(@Valid @RequestBody request: CreateBookService.Request): ResponseEntity<BookResponse> { | ||
| val book = createBookService.execute(request).getOrThrow() | ||
| return ResponseEntity.status(HttpStatus.CREATED).body(book) | ||
| } | ||
|
|
||
| @Operation(summary = "도서 단건 조회", description = "ID로 도서 한 건을 조회한다.") | ||
| @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND"]) | ||
| @GetMapping("/{id}") | ||
| fun get(@Parameter(description = "도서 ID") @PathVariable id: Long): BookResponse = | ||
| getBookService.execute(id).getOrThrow() | ||
|
|
||
| @Operation( | ||
| summary = "도서 수정", | ||
| description = "도서의 제목·저자·ISBN을 수정한다. 요청 본문이 곧 최종 상태이며, ISBN을 생략하면 기존 값이 지워진다.", | ||
| ) | ||
| @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND", "DUPLICATE_ISBN"]) | ||
| @PatchMapping("/{id}") | ||
| fun update( | ||
| @Parameter(description = "도서 ID") @PathVariable id: Long, | ||
| @Valid @RequestBody request: UpdateBookService.Request, | ||
| ): BookResponse = | ||
| updateBookService.execute(id, request).getOrThrow() | ||
|
|
||
| @Operation(summary = "도서 삭제", description = "도서를 소프트 삭제한다.") | ||
| @ApiResponse(responseCode = "204", description = "도서 삭제 성공") | ||
| @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND"]) | ||
| @DeleteMapping("/{id}") | ||
| fun delete(@Parameter(description = "도서 ID") @PathVariable id: Long): ResponseEntity<Unit> { | ||
| deleteBookService.execute(id).getOrThrow() | ||
| return ResponseEntity.noContent().build() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package org.library.book.domain.error | ||
|
|
||
| import org.library.core.exception.ErrorCode | ||
| import org.springframework.http.HttpStatus | ||
|
|
||
| enum class BookError( | ||
| override val status: HttpStatus, | ||
| override val message: String, | ||
| ) : ErrorCode { | ||
| NOT_FOUND(HttpStatus.NOT_FOUND, "도서를 찾을 수 없습니다."), | ||
| DUPLICATE_ISBN(HttpStatus.CONFLICT, "이미 등록된 ISBN입니다."), | ||
| ; | ||
|
|
||
| override val code: String get() = name | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package org.library.book.dto | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema | ||
| import org.library.book.domain.Book | ||
| import java.time.LocalDateTime | ||
|
|
||
| @Schema(description = "도서 응답") | ||
| data class BookResponse( | ||
| @field:Schema(description = "도서 ID", example = "1") | ||
| val id: Long, | ||
| @field:Schema(description = "도서 제목", example = "클린 아키텍처") | ||
| val title: String, | ||
| @field:Schema(description = "저자", example = "로버트 마틴") | ||
| val author: String, | ||
| @field:Schema(description = "ISBN (미입력 가능)", example = "9788966262472", nullable = true) | ||
| val isbn: String?, | ||
| @field:Schema(description = "등록 일시") | ||
| val createdAt: LocalDateTime, | ||
| ) { | ||
| companion object { | ||
| fun from(book: Book): BookResponse = BookResponse( | ||
| id = book.id, | ||
| title = book.title, | ||
| author = book.author, | ||
| isbn = book.isbn, | ||
| createdAt = book.createdAt, | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MEDIUM] ISBN 중복 검사가 check-then-act라 동시 요청 시 레이스 컨디션 발생
Problem:
findByIsbnAndDeletedAtIsNull조회와save사이에 DB 락이나 유니크 제약이 없다. 두 요청이 같은 ISBN으로 거의 동시에 들어오면 둘 다 중복 검사를 통과한 뒤 각자 커밋되어,DUPLICATE_ISBN이 막으려는 불변식이 깨진 채로 활성 도서 2건이 동일 ISBN을 가지게 된다.Evidence:
Book.kt#L24의isbn컬럼에unique = true가 없고, 레포지토리 전체를 확인해도 마이그레이션/스키마 파일이 없어 DB 레벨 유니크 제약이 전혀 없다. 같은 패턴이UpdateBookService.kt#L25-L31에도 있다.Fix direction: 소프트 삭제 특성상 단순 컬럼 유니크 제약은 부적합하므로(삭제된 도서의 ISBN은 재사용 가능해야 함),
deleted_at IS NULL조건의 부분 유니크 인덱스를 DB에 추가하고, 저장 시 발생하는 제약 위반 예외를 잡아DUPLICATE_ISBN으로 변환하는 방식을 권장한다.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
수정 완료
인덱스
deleted_at IS NULL조건의 부분 인덱스는 PostgreSQL 기능으로 MySQL에서 사용이 불가능하여함수 기반 유니크 인덱스를 통해 인덱스를 추가하여 활성 도서끼리만 ISBN 중복을 금지
예외 처리
예외를 전파하여 핸들러에서
ConstraintKind.UNIQUE만 409로 반환하고 있습니다.