짧다면 짧고, 길다면 길 수 있는 약 2주간의 강의(AWS, Docker, 스프링 심화)를 들은 뒤, 곧바로 실전 프로젝트에 들어가게 되었다.
실전 프로젝트가 끝나면 바로 최종 프로젝트로 이어지기 때문에, 강의 시간에 배운 내용들을 최대한 활용해보자는 마음가짐으로 진행했다.
이번 프로젝트를 마지막 학습 기회라 생각하고, 최대한 스스로 설계하고 구현해보는 데 집중했다.
📚 도서 관리 앱
와이어프레임
ERD
각자 도메인별로 기능을 맡으며 진행하였고, 나는 Book 도메인 기능을 맡게되었다.
Book Entity
@Entity
@Table(name = "book")
@NoArgsConstructor
@Getter
public class Book extends TimeStamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String authorName;
private String publisher;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@Enumerated(EnumType.STRING)
private BookStatus bookStatus = BookStatus.AVAILABLE;
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Like> likes = new ArrayList<>();
private boolean isDeleted = false;
public Book(String title, String authorName, String publisher, BookStatus bookStatus) {
this.title = title;
this.authorName = authorName;
this.publisher = publisher;
this.bookStatus = bookStatus;
}
public static Book createInfo(AdminBookRequestDto dto) {
return new Book(
dto.getTitle(),
dto.getAuthorName(),
dto.getPublisher(),
BookStatus.AVAILABLE
);
}
public static void updateInfo(Book book, AdminBookUpdateRequestDto dto) {
book.update(
dto.getTitle(),
dto.getAuthorName(),
dto.getPublisher(),
dto.getBookStatus()
);
}
public void update(String title, String authorName, String publisher, BookStatus bookStatus) {
this.title = title;
this.authorName = authorName;
this.publisher = publisher;
this.bookStatus = bookStatus;
}
public void updateStatus(BookStatus newStatus) {
this.bookStatus = newStatus;
}
public void assignUser(User user) {
this.user = user;
}
public void clearUser() {
this.user = null;
}
public void delete() {
this.isDeleted = true;
}
public void returnBookByForce() {
this.bookStatus = BookStatus.AVAILABLE;
this.clearUser();
}
}
- 도서의 기본 정보와 대여 상태(AVAILABLE, RENTED)를 이넘으로 포함했다.
서비스 로직
BookController
// /books/{bookId} - 도서 단건 조회
@GetMapping("/books/{bookId}")
public ResponseEntity<ApiResponse<BookResponseDto>> getBook(@PathVariable Long bookId) {
BookResponseDto responseDto = bookService.getBook(bookId);
return ApiResponse.onSuccess(SuccessStatus.BOOK_READ_SUCCESS, responseDto);
}
// /books - 키워드 검색 (페이징)
@GetMapping("/books")
public ResponseEntity<ApiResponse<Page<BookResponseDto>>> searchByKeyword(
@RequestParam(required = false) String keyword,
@PageableDefault(page = 0, size = 10) Pageable pageable
) {
Page<BookResponseDto> response = bookService.searchByTitle(keyword, pageable);
return ApiResponse.onSuccess(SuccessStatus.BOOK_READ_SUCCESS, response);
}
// /books/{bookId}/rent - 도서 대여
@PostMapping("/books/{bookId}/rent")
public ResponseEntity<ApiResponse<RentalResponseDto>> rentBook(
@AuthenticationPrincipal CustomUserPrincipal authUser,
@PathVariable Long bookId,
@RequestBody @Valid BookRentRequestDto request
) {
RentalResponseDto response = bookService.rentBook(bookId, request.getBookStatus(), authUser.getId());
return ApiResponse.onSuccess(SuccessStatus.BOOK_RENT_SUCCESS, response);
}
// /books/{bookId}/return - 도서 반납
@PostMapping("/books/{bookId}/return")
public ResponseEntity<ApiResponse<RentalResponseDto>> returnBook(
@AuthenticationPrincipal CustomUserPrincipal authUser,
@PathVariable Long bookId,
@RequestBody @Valid BookRentRequestDto request
) {
RentalResponseDto responseDto = bookService.returnBook(bookId, request.getBookStatus(), authUser.getId());
return ApiResponse.onSuccess(SuccessStatus.BOOK_RETURN_SUCCESS, responseDto);
}
BookService
@Service
@RequiredArgsConstructor
public class BookService {
private final BookRepository bookRepository;
private final KeywordRepository keywordRepository;
private final UserRepository userRepository;
/**
* 도서 단 건 조회
*/
public BookResponseDto getBook(Long bookId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new ApiException(ErrorStatus.BOOK_NOT_FOUND));
return BookResponseDto.from(book);
}
/**
* 도서 키워드 검색(페이징 처리)
*/
@Transactional
public Page<BookResponseDto> searchByTitle(String keyword, Pageable pageable) {
Page<Book> searchedBooks = bookRepository.findByTitle(keyword, pageable);
keywordRepository.save(Keyword.of(keyword));
return BookResponseDto.fromEntityPage(searchedBooks);
}
/**
* 도서 대여
*/
@Transactional
public RentalResponseDto rentBook(Long bookId, String rentStatus, Long userId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new ApiException(ErrorStatus.BOOK_NOT_FOUND));
if (book.getBookStatus() == BookStatus.RENTED) {
throw new ApiException(ErrorStatus.BOOK_ALREADY_RENTED);
}
BookStatus status = BookStatus.of(rentStatus);
if (status != BookStatus.RENTED) {
throw new ApiException(ErrorStatus.INVALID_BOOK_STATUS);
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new ApiException(ErrorStatus.USER_NOT_FOUND));
book.updateStatus(status);
book.assignUser(user); // 대여자 지정
return RentalResponseDto.from(book);
}
/**
* 도서 반납
*/
@Transactional
public RentalResponseDto returnBook(Long bookId, String rentStatus, Long userId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new ApiException(ErrorStatus.BOOK_NOT_FOUND));
if (!book.getUser().getId().equals(userId)) {
throw new ApiException(ErrorStatus.BOOK_RETURN_FORBIDDEN);
}
if (book.getBookStatus() == BookStatus.AVAILABLE) {
throw new ApiException(ErrorStatus.BOOK_ALREADY_AVAILABLE);
}
BookStatus status = BookStatus.of(rentStatus);
if (status != BookStatus.AVAILABLE) {
throw new ApiException(ErrorStatus.INVALID_BOOK_STATUS);
}
book.updateStatus(status);
book.clearUser(); // 유저 정보 제거
return RentalResponseDto.from(book);
}
}
도서 단건 조회
@GetMapping("/books/{bookId}")
public ResponseEntity<ApiResponse<BookResponseDto>> getBook(@PathVariable Long bookId) {
BookResponseDto responseDto = bookService.getBook(bookId);
return ApiResponse.onSuccess(SuccessStatus.BOOK_READ_SUCCESS, responseDto);
}
공통 응답으로 처리 하였고, @PathVariable로 도서 ID를 이용하여 하나의 도서를 상세 조회하도록 구현했다.
BookService.getBook()을 호출해 도서를 찾고 DTO로 반환했다.
✅ Postman API 요청
도서 키워드 검색 API (페이징)
@GetMapping("/books")
public ResponseEntity<ApiResponse<Page<BookResponseDto>>> searchByKeyword(
@RequestParam(required = false) String keyword,
@PageableDefault(page = 0, size = 10) Pageable pageable
)
제목에 포함된 키워드를 기준으로 도서를 검색하고 DTO를 페이지로 처리하여 리스트를 반환하도록 했다.
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.title LIKE %?1%")
Page<Book> findByTitle(String keyword, Pageable pageable);
}
도서의 title에 대해 부분 문자열과 LIKE %?1% 조건을 주면서 키워드를 포함하도록하여 검색이 되도록 구현했다.
✅ Postman API 요청
도서 대여,반납 API
@PostMapping("/books/{bookId}/rent")
public ResponseEntity<ApiResponse<RentalResponseDto>> rentBook(
@AuthenticationPrincipal CustomUserPrincipal authUser,
@PathVariable Long bookId,
@RequestBody @Valid BookRentRequestDto request) {
RentalResponseDto response = bookService.rentBook(bookId, request.getBookStatus(),
authUser.getId());
return ApiResponse.onSuccess(SuccessStatus.BOOK_RENT_SUCCESS, response);
}
@PostMapping("/books/{bookId}/return")
public ResponseEntity<ApiResponse<RentalResponseDto>> returnBook(
@AuthenticationPrincipal CustomUserPrincipal authUser,
@PathVariable Long bookId,
@RequestBody @Valid BookRentRequestDto request) {
RentalResponseDto responseDto = bookService.returnBook(bookId, request.getBookStatus(),
authUser.getId());
return ApiResponse.onSuccess(SuccessStatus.BOOK_RETURN_SUCCESS, responseDto);
}
Book상태(enum)를 변경하여 도서 반납,대여 기능들을 구현했다.
대여 시, 해당 도서가 이미 대여 중이면 예외를 발생시키고, 요청된 상태가 RENTED인지 확인한 뒤, 유저를 조회해 도서에 대여자로 지정하고 상태를 변경하도록 했다.
✅ Postman API 요청
반납 시에는 요청자가 실제 대여자인지 확인하고, 도서가 이미 반납된 상태(AVAILABLE)인지 검사한 뒤, 상태를 AVAILABLE로 바꾸고 대여자 유저 정보를 null로 변환하도록 했다.
✅ Postman API 요청
Book 도메인의 간단한 CRUD 기능 후 다음 목표로 검색 기능을 테스트 코드를 활용한 동시성 제어 및 캐싱 처리
등의 부가 기능들을 추가해나갈 계획이다.
'Spring' 카테고리의 다른 글
15만권 도서 검색 서비스에 캐시 적용하기 (4) | 2025.06.09 |
---|---|
EC2 인바운드 규칙 미설정으로 인한 접속 불가 문제 (3) | 2025.05.27 |
Spring AOP란? (1) | 2025.05.15 |
QueryDSL 의존성 설정 및 활용 (1) | 2025.05.13 |
DB 동시성 제어(비관적, 낙관적, 분산 락) (1) | 2025.05.02 |