지금까지 사용자에 관련한 API를 만들어었다.
이번에는 책과 관련된 API를 만들어보자. 책을 등록하고 대출, 반납하는 3가지 API를 만들면 된다.
우선, 설계부터 해보자.
가장 먼저 데이터를 가지고 있는 테이블을 만들어야 한다.
그 후 Domain 패키지에 Book 객체를 만들어 테이블과 매핑시킨다.
Post 방식으로 매핑해야하므로(저장이니까) RequestBody의 객체 클래스도 만들어야 한다.
BookRepository 라는 인터페이스를 만들고 JpaRepository를 상속받는다. 이후 Service 계층을 만들어 해당 레포지토리에 의존성을 주입하고 트랜젝션을 적용시킨다.
마지막으로, BookController를 만들어 Service 계층의 의존성을 주입하면 완성된다.
이 모든 과정은 지금까지 사용자 API를 만드는 과정과 동일하므로 기억이 잘 안나면 다시 복습을 해보자.
내가 사용중인 Intellij는 무료버전이라 테이블 생성이 안되므로 CLI를 사용하겠다.
create table Book(
id int auto_increment,
name varchar(25),
primary key(id)
);
Book 테이블 생성 완료!
다음은 Book 객체를 만들어 테이블과 매핑시켜야 한다.
package com.group.libraryapp.domain.user;
import javax.persistence.*;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, length = 25, name = "name")
private String name;
public Book(String name) {
if(name == null || name.isBlank()){
throw new IllegalArgumentException(String.format("잘못된 입력(%s)입니다",name));
}
this.name = name;
}
protected Book(){}
public long getId() {
return id;
}
public String getName() {
return name;
}
}
User 클래스와 구성은 동일하며 필드 값으로는 id,name만 존재한다.
JPA는 기본 생성자를 필요로 하기 때문에 별도로 Book 클래스의 기본 생성자를 만들어야 한다.
클래스에 @Entity 에너테이션을 추가하고 각 필드마다 @Id, @Column을 추가해주면 Book 테이블과 매핑이 된다.
객체와 테이블 매핑이 완료되었으니 Request를 만들어보자.
책 데이터 저장이므로 SaveBookRequest로 클래스를 만들면 된다.
(사실 이름은 딱히 정해진 것이 없다)
package com.group.libraryapp.dto.book;
public class SaveBookRequest {
private Long id;
private String name;
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
까먹었을가봐 다시 언급하자면 생성자나 setter,getter를 빠르게 생성하고 싶을 경우 Alt + Insert 단축키를 통해 생성이 가능하다.
자, 이제 Repository를 생성해야 한다.
JPA를 통해 SQL쿼리를 대신 날릴 것이므로 JPARepository를 상속받는 BookRepository 인터페이스를 만들어보자.
package com.group.libraryapp.repository;
import com.group.libraryapp.domain.user.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BookRepository extends JpaRepository<Book,Long> {
}
Book 객체와 Id값을 받아야 하므로 타입에 Book,Long을 넣어준다.
이제 절반정도 완료되었다.
이제 Service 계층을 만들고 Repository에 의존성을 주입하자.
package com.group.libraryapp.service;
import com.group.libraryapp.domain.user.Book;
import com.group.libraryapp.dto.book.SaveBookRequest;
import com.group.libraryapp.repository.BookRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BookService {
BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Transactional
public void saveBook(SaveBookRequest request){
bookRepository.save((new Book(request.getName())));
}
}
여기서 간과하면 안되는 점은 서비스 계층에서는 여러 쿼리를 날리기 때문에 트랜잭션 관리를 해주어야 한다.
그리고 Spring Bean으로 등록하기 위해 @Service 에너테이션을 추가한다.
마지막 작업은 Controller를 만드는 것이다.
Controller -> Service -> Repository의 단계를 따라가면 된다.
package com.group.libraryapp.controller.book;
import com.group.libraryapp.dto.book.SaveBookRequest;
import com.group.libraryapp.service.BookService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService){
this.bookService = bookService;
}
@PostMapping("/book")
public void saveBook(@RequestBody SaveBookRequest request){
bookService.saveBook(request);
}
}
지금 만들고 있는 책 등록 API는 데이터를 받아서 저장해야 하므로 Post 방식으로 매핑하고 return 값은 없어도 된다.
복습겸 다시 언급하자면 Post 방식의 매핑은 JSON 형식으로 데이터를 받아 RequestBody 객체로 데이터를 전달한다.
여기까지 만들고 서버를 실행하여 테스트를 해보자.
서버 실행 후 localhost:8080/v1/Index.html 에 접속하면 테스트가 가능하다.
"죄와 벌" 이라는 이름의 책을 등록하였고 데이터베이스에 정상적으로 저장되었다.
다음은 책을 대출하는 기능을 가진 API를 구현해보자.
우선, 대출을 하면 대출 기록을 저장하는 UserLoanHistory라는 도메인이 필요하고 Post 방식이기 때문에 객체로 받을 UserLoanHistoryRequest라는 dto 만들어야 한다.
그리고 Controller -> Service -> Repository 순으로 계층을 구현해야하므로 UserLoanHistoryRepository도 필요하다.
책을 등록하는 경우에는 BookController -> BookService -> BookRepository 순으로 계층을 나눈다면
대출을 하는 경우에는 BookController -> BookService -> UserLoanHistoryRepository 순으로 계층을 나누겠다.
가장 먼저 UserLoanHistory 도메인을 만들어보자.
package com.group.libraryapp.domain.user;
import javax.persistence.*;
@Entity
public class UserLoanHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column
private long userId;
@Column
private String bookName;
@Column
private boolean isReturn;
public UserLoanHistory(long userId, String bookName) {
this.userId = userId;
this.bookName = bookName;
this.isReturn = false;
}
protected UserLoanHistory(){}
}
이 도메인은 데이터베이스에 user_loan_history 라는 새로운 테이블가 매핑되어야 한다.
(테이블 생성 과정은 생략하겠다)
여기서 id값은 대출한 순서대로 부여되며 userId와 bookName은 JSON 형식으로 받는 필드값이다.
생성자에 isReturn 즉, 반납이 되었는가는 매개변수로 넣지 않고 기본값을 false로 설정하였는데, 책을 대출한 순간에는 반납이 안되었기 때문에 이렇게 설정했다.
post 방식이기 때문에 dto 생성은 이제 당연한 과정이다.
package com.group.libraryapp.dto.book;
public class UserLoanHistoryRequest {
private String userName;
private String bookName;
private boolean isUserReturn;
public String getUserName() { return userName;}
public String getBookName() {return bookName;}
public boolean isUserReturn() {return isUserReturn;}
}
여기서 의문이 생길 수 있다. 도메인에는 userId로 필드값을 설정했는데 왜 Request에서는 userName으로 받을까?
대출할 때 사용자 이름을 입력하는 입장에서는 id값을 알 수가 없다. 그래서 입력할 때는 이름으로만 입력을 하고 Service 계층에서 입력받은 userName으로 User 객체를 가져와야 한다.
가장 하위 계층인 Repository를 구성해보자.
UserRepository와 마찬가지로 JPARepository를 구현해야 JPA를 사용하여 쿼리 전달이 가능하다.
package com.group.libraryapp.repository;
import com.group.libraryapp.domain.user.UserLoanHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory,Long> {
}
다음 계층인 Service를 구성해보자.
대출 기능을 추가해하니까 loanBook으로 메서드명을 정하겠다.
@Transactional
public void loanBook(UserLoanHistoryRequest request){
//TODO : 책 정보를 가져온다 -> 대출기록 정보 확인 -> 대출중이라면 예외 발생 -> 유저 정보를 가져온다 -> 유저 정보 저장
}
이 loanBook 메서드는 책 정보를 가져오고 -> 대출기록 정보를 확인 후 -> 만약, 대출중이라면 예외를 발생 -> 유저 정보를 가져온다 -> 유저 정보 저장시키는 순서대로 구현해야 한다.
책 정보를 어떻게 가져올까? 방금 전에 bookName을 Request로 받는다고 했었다. 그렇다면 bookName을 바탕으로 Book 객체를 가져오려면 findByName 이라는 JPA를 사용해야 한다.
@Repository
public interface BookRepository extends JpaRepository<Book,Long> {
Optional<Book> findByName(String name);
}
BookRepository에 findByName을 추가해준다. 여기서 리턴 타입을 Optional로 설정한 이유는 orElseThrow를 사용하기 위함이다.
다시 Service 계층으로 돌아와서 책 정보를 가져오는 기능을 구현해보자.
Book book = bookRepository.findByName(request.getBookName())
.orElseThrow(IllegalArgumentException::new);
orElseThrow는 책 이름을 입력하지 않을 경우 발생할 런타임 에러를 방지하기 위해 추가한 것이다.
자, 이제 다음 순서인 대출기록 정보 확인이다.
도메인에 bookName과 isReturn 필드값이 있었는데 이를 사용하여 대출중인지 여부를 boolean값으로 리턴하는 existsByBookNameAndIsReturn을 UserLoanHistoryRepository에 추가해야 한다.
package com.group.libraryapp.repository;
import com.group.libraryapp.domain.user.UserLoanHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory,Long> {
//select * from user_loan_history where book_name = ? and is_return = ?
boolean existsByBookNameAndIsReturn(String name, boolean isReturn);
}
추가했으니 existsByBookNameAndIsReturn 사용이 가능하다.
if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false))
{throw new IllegalArgumentException("대출중인 책입니다");}
파라미터를 false로 설정했기 때문에 만약 isReturn이 true 즉, 대출중이지 않으면 if문이 실행되지 않는다.
반대로 대출중일 경우 isReturn이 false이기 때문에 if문이 실행되어 예외가 발생한다.
이제, 유저 정보를 가져오고 저장하는 코드를 추가해야한다.
//유저 정보를 가져온다
if(userRepository.findByName(request.getUserName()) == null)
{throw new IllegalArgumentException();}
else
{User user = userRepository.findByName(request.getUserName());
//유저 정보와 책 정보를 기반으로 UserLoanHistory를 저장
userLoanHistoryRepository.save(new UserLoanHistory(user.getId(),book.getName()));
}
유저 정보를 가져왔는데 해당 유저가 없으면 if문이 실행되고 있으면 else가 실행된다.
else가 실행되면 유저 정보를 가져와 user에 저장하고 userId와 아까 저장한 book에서 bookName을 getter을 통해 UserLoanHistory 생성자 파라미터로 입력된다.
그리고 UserLoanHistory 객체가 UserLoanHistoryRepository로 save를 통해 저장된다.
이렇게 해도 되지만 findByName이 2번 호출되기 때문에 코드를 좀 더 간결하게 정리하면 이렇게 만들 수 있다.
User user = userRepository.findByName(request.getUserName());
if(user == null){
throw new IllegalArgumentException();
}
userLoanHistoryRepository.save(new UserLoanHistory(user.getId(),book.getName()));
Service 계층 구현은 끝났고, 마지막 단계인 Controller이다.
@PostMapping("/book/loan")
public void loanBook(@RequestBody UserLoanHistoryRequest request){
bookService.loanBook(request);
}
Post 방식이기 때문에 Request 객체를 전달받아 Service 계층의 loanBook을 호출하면 된다.
생략하긴 했지만 각 계층마다 의존성 주입은 해야한다.
API가 제대로 작동되는지 확인해보자.
사용자 목록 확인하고
"자바의 정석"이라는 책을 등록하고
사용자 이름은 "나루토"이고 대출할 책은 "자바의 정석"으로 입력하면 정상적으로 작동한다!
"kim"이 대출하려고 해도 이미 대출중이기 때문에 예외가 발생한다.
마지막으로 데이터 베이스를 확인해보면 대출한 기록이 테이블에 id, user_id, book_name, is_return 순으로 저장되어 있다.
@Transactional
public void loanBook(UserLoanHistoryRequest request){
//TODO : 책 정보를 가져온다 -> 대출기록 정보 확인 -> 대출중이라면 예외 발생
// 1. 책 정보를 가져와서 book 객체에 정보를 저장
Book book = bookRepository.findByName(request.getBookName())
.orElseThrow(IllegalArgumentException::new);
// 2. 대출기록 정보를 확인하고 대출중이 아닌 책이면 false, 대출중인 경우 true를 반환
// 3. 확인했는데 대출중이라면 예외 발생
if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)){
throw new IllegalArgumentException("대출중인 책입니다");
}
// 4. 유저 정보를 가져온다
User user = userRepository.findByName(request.getUserName());
if(user == null){
throw new IllegalArgumentException();
}
// 5. 유저 정보와 책 정보를 기반으로 UserLoanHistory를 저장
userLoanHistoryRepository.save(new UserLoanHistory(user.getId(),book.getName()));
}
반납은 대출에 비하면 할 것이 별로 없다.
우선 API 스펙을 생각해보자. put 방식으로 데이터를 전달하고 JSON 형식으로 userName, bookName을 입력한다. 리턴값은 필요없다.
대출 API의 스펙과 유사하다. 여기서 의문점이 생긴다. UserLoanHistoryRequest를 그대로 사용할까? 아니면 UserReturnHistoryRequest를 새로 만들까?
내용은 동일하지만 새로 만드는 편이 좋다. 왜냐하면, 만약, API 스펙이 변경될 경우 추가적으로 해주어야 할 작업도 있고 side-effect를 고려해야 하기 때문에 처음부터 깔끔하게 새로 만드는 편이 좋다.
dto 생성 과정은 대출과 동일하므로 생략하겠다.
BookService에 반납 기능을 하는 returnBook 메서드를 생성하고 설계를 해보자.
우선, UserLoanHistory 테이블에 userId, bookName, isReturn이 있기 때문에 userId와 bookName을 파라미터로 객체를 찾아야 한다.
그러면 그 선행작업으로 User 객체를 얻어야 한다. 그 후 User 객체로부터 userId를 얻고 bookName과 같이 파라미터로 입력되어 해당하는 UserLoanHistory 객체를 찾아야 한다.
객체를 찾으면 isReturn 값을 true로 변경하여 반납 처리를 해주면 완성이다!
@Transactional
public void returnBook(UserReturnHistoryRequest request){
if(userRepository.findByName(request.getUserName())==null){
throw new IllegalArgumentException();
}
else{
User user = userRepository.findByName(request.getUserName());
}
}
대출 기능과 마찬가지로 request로부터 userName을 얻고 null일 경우 예외를 던지고 아니라면 User 객체를 얻는다.
User 객체를 얻었으니 이제 userId도 얻을 수 있다.
그렇다면 userId와 bookName으로 UserLoanHistory 객체를 구할 수 있는데 그 전에 UserLoanHistoryRepository에 findByUserIdAndBookName 을 추가해주어야 한다.
@Repository
public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory,Long> {
//select * from user_loan_history where book_name = ? and is_return = ?
boolean existsByBookNameAndIsReturn(String name, boolean isReturn);
Optional<UserLoanHistory> findByUserIdAndBookName(long userId, String bookName);
}
이제 UserLoanHistory 객체를 구해보자.
@Transactional
public void returnBook(UserReturnHistoryRequest request){
if(userRepository.findByName(request.getUserName())==null){
throw new IllegalArgumentException();
}
else{
User user = userRepository.findByName(request.getUserName());
UserLoanHistory userLoanHistory = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName())
.orElseThrow(IllegalArgumentException::new);
}
}
반납하기 위해 입력한 사용자 이름과 책 이름으로 해당하는 UserLoanHistory를 찾았다.
이제 마지막으로 isReturn을 true로 바꾸어주어야 하는데 어떻게 해야할까?
UserLoanHistory 클래스에 메서드를 새로 만들어주면 된다.
public void doReturn(){
this.isReturn = true;
}
doReturn을 호출하면 isReturn이 true로 변경된다.
@Transactional
public void returnBook(UserReturnHistoryRequest request){
if(userRepository.findByName(request.getUserName())==null){
throw new IllegalArgumentException();
}
else{
User user = userRepository.findByName(request.getUserName());
UserLoanHistory userLoanHistory = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName())
.orElseThrow(IllegalArgumentException::new);
userLoanHistory.doReturn();
}
}
여기서 잠깐 복습겸 다시 언급하자면 마지막에
userLoanHistoryRepository.save(userLoanHistory);
이걸 해주어야 할까?
정답은 해주어도 상관은 없지만 transactional의 영속성 컨텍스트 중 변경 감지라는 특성으로 인해 자동으로 변경사항이 저장되므로 안해도 된다.
service 계층 작업이 끝났으니 BookController에 returnBook을 추가해주자.
@PutMapping("/book/return")
public void returnBook(@RequestBody UserReturnHistoryRequest request){
bookService.returnBook(request);
}
제대로 작동하는지 확인해보자.
아까 "나루토"가 대출한 "자바의 정석"을 반납해보겠다.
정상적으로 반납처리 되었다.
데이터베이스에도 반영되었는지 확인해보자.
is_return이 1(true)로 변경되었다!