Search

5. 책 요구사항 구현하기

책 생성 API 개발하기

책 요구사항 구현 하기

1. 책 생성, 대출, 반납 API를 온전히 개발하며 지금까지 다루었던 모든 개념을 실습해본다.
2. 객체지향적으로 설계하기 위한 연관관계를 이해하고, 연관관계의 다양한 옵션에 대해 이해한다.
3. JPA에서 연관관계를 매핑하는 방법을 이해하고, 연관관계를 사용해 개발할 때와 사용하지 않고 개발할 때의 차이점을 이해한다.
지금까지 배웠던 개념들을 총동원해서 책 생성 API를 개발하자!

요구사항

도서관에 책을 등록할 수 있다.

API 스펙

할 일

book 테이블

create table book( id bigint auto_increment, name varchar(255), primary key (id) );
SQL
복사
1) @Column 의 length 기본값이 255
2) 문자열 필드는 최적화를 해야 하는 경우가 아닐때 조금 여유롭게 설정하는 것이 좋다!

Book 객체

package com.group.libraryapp.domain.book; @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; @Column(nullable = false) private String name; }
Java
복사

BookRepository

package com.group.libraryapp.domain.book; public interface BookRepository extends JpaRepository<Book, Long> { }
Java
복사

중간 점검

DTO를 만들어 주자!

package com.group.libraryapp.dto.book; public class BookCreateRequest { private String name; public String getName() { return name; } }
Java
복사

다음으로는 Controller와 Service를 만들자!

컨트롤러
package com.group.libraryapp.controller.book; @RestController public class BookController { private final BookService bookService; public BookController(BookService bookService) { this.bookService = bookService; } @PostMapping("/book") public void saveBook(@RequestBody BookCreateRequest request){ bookService.saveBook(request); } }
Java
복사
서비스
package com.group.libraryapp.service.book; import com.group.libraryapp.domain.book.BookRepository; @Service public class BookService { private final BookRepository bookRepository; public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository; } @Transactional public void saveBook(BookCreateRequest request){ bookRepository.save(new Book(request.getName())); } }
Java
복사
Book
public Book(String name) { if (name == null || name.isBlank()){ throw new IllegalArgumentException(String.format("잘못된 name(%s)이 들어왔습니다.", name)); } this.name = name; }
Java
복사
id는 자동생성이니까 name 만 받는 생성자를 추가해주자
그리고 jpa는 기본 생성자가 필요해서 기본생성자 추가
protected Book() {}
Java
복사

다 완성했다면, 웹 UI와 DB를 통해 확인

select * from book;
SQL
복사

대출 기능 개발하기

요구사항

사용자가 책을 빌릴 수 있다. 다른 사람이 그 책을 진작 빌렸다면, 빌릴 수 없다.

API 스펙 확인

요구사항을 보니 지금 테이블로는 충분하지 않다!

유저의 대출 기록을 저장하는 새로운 테이블이 필요하다!

user_loan_history 테이블 추가

create table user_loan_history( id bigint auto_increment, user_id bigint, book_name varchar(255), is_return tinyint(1), primary key (id) );
SQL
복사
어떤 유저가 책을 빌렸는지 알 수 있는 유저의 id
어떤 책을 빌렸는지 확인하기 위한 책 이름
현재 대출 중인지, 반납 완료했는지 확인
0이면 대출 중 / 1이면 반납한 것이다.

예를 하나 들어보자!

2번 유저는 2권의 책을 빌렸다. 클린 코드는 반납했고, 테스트 주도 개발은 대출중이다.

이제 UserLoanHistory 객체를 만들어 주자!

package com.group.libraryapp.domain.user.loanhistory; @Entity public class UserLoanHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private Long userId; private String bookName; private boolean isReturn; }
Java
복사
boolean으로 처리하면, tinyint에 잘 매핑된다!
0이면 false 1이면 true
레파지토리
package com.group.libraryapp.domain.user.loanhistory; import org.springframework.data.jpa.repository.JpaRepository; public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> { }
Java
복사

다음은 똑같다, DTO / Controller / Service 구현!

BookLoanRequest
package com.group.libraryapp.dto.book.request; public class BookLoanRequest { private String userName; private String bookName; public String getUserName() { return userName; } public String getBookName() { return bookName; } }
Java
복사
BookController
@PostMapping("/book/loan") public void loanBook(@RequestBody BookLoanRequest request){ bookService.loanBook(request); }
Java
복사
BookService
@Transactional public void loanBook(BookLoanRequest request) { //1. 책 정보를 가지고 온다 //2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. }
Java
복사
@Transactional public void loanBook(BookLoanRequest request) { //1. 책 정보를 가지고 온다 Book book = bookRepository.findByName(request.getBookName()) .orElseThrow(IllegalArgumentException::new); //2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. }
Java
복사
BookRepository
public interface BookRepository extends JpaRepository<Book, Long> { Optional<Book> findByName(String name); }
Java
복사
BookService
package com.group.libraryapp.service.book; @Service public class BookService { private final BookRepository bookRepository; private final UserLoanHistoryRepository userLoanHistoryRepository; public BookService(BookRepository bookRepository, UserLoanHistoryRepository userLoanHistoryRepository) { this.bookRepository = bookRepository; this.userLoanHistoryRepository = userLoanHistoryRepository; } @Transactional public void saveBook(BookCreateRequest request){ bookRepository.save(new Book(request.getName())); } @Transactional public void loanBook(BookLoanRequest request) { //1. 책 정보를 가지고 온다 Book book = bookRepository.findByName(request.getBookName()) .orElseThrow(IllegalArgumentException::new); //2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. } }
Java
복사
UserLoanHistoryRepository
package com.group.libraryapp.domain.user.loanhistory; public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> { //select * from user_loan_history where book_name => and is_return =? boolean existsByBookNameAndIsReturn(String name, boolean isReturn); }
Java
복사
Book
package com.group.libraryapp.domain.book; @Entity public class Book { . . . public String getName() { return name; } }
Java
복사
getter 추가
BookService
//2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)){ throw new IllegalArgumentException("진짜 대출되어 있는 책입니다."); }
Java
복사
이제 유저 정보를 가지고와서 유저 정보와 책 정보를 기반으로 저장해시켜줘야 한다
UserLoanHistory 에 생성자 추가
public UserLoanHistory(Long userId, String bookName) { this.userId = userId; this.bookName = bookName; this.isReturn = false; } protected UserLoanHistory() {}
Java
복사
BookService
@Transactional public void loanBook(BookLoanRequest request) { //1. 책 정보를 가지고 온다 Book book = bookRepository.findByName(request.getBookName()) .orElseThrow(IllegalArgumentException::new); //2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)){ throw new IllegalArgumentException("진짜 대출되어 있는 책입니다."); } //4. 유저 정보를 가져온다. User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); //5. 유저 정보와 책 정보를 기반으로 UserLoanHistory를 저장 userLoanHistoryRepository.save(new UserLoanHistory(user.getId(), book.getName())); }
Java
복사

이제 테스트해보자

select * from user_loan_history;
SQL
복사
이제 다른 유저가 클린 코드책을 빌릴려고하면

반납 기능 개발하기

요구사항 확인!

사용자가 책을 반납할 수 있다.

API 스펙 확인!

API 스펙, HTTP Body 완전히 동일하다!

이런 경우, DTO를 새로 만드는게 좋을까? 아니면 재활용하는게 좋을까?
(개인적으로) 새로 만드는 것을 선호합니다!
그래야 두 기능 중 한 기능에 변화가 생겼을 때 유연하고 side-effect 없이 대처할 수 있기 때문.

구현해보자

package com.group.libraryapp.dto.book.request; public class BookReturnRequest { private String userName; private String bookName; public String getUserName() { return userName; } public String getBookName() { return bookName; } }
Java
복사
BookController
@PutMapping("/book/return") public void returnBook(@RequestBody BookReturnRequest request){ bookService.returnBook(request); }
Java
복사
BookService
@Transactional public void returnBook(BookReturnRequest request) { //유저 정보를 찾는다 User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); // 유저 id를 가지고 대출 기록을 찾는다 UserLoanHistory history = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName()) .orElseThrow(IllegalArgumentException::new); }
Java
복사
UserLoanHistoryRepository
package com.group.libraryapp.domain.user.loanhistory; public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> { . . Optional<UserLoanHistory> findByUserIdAndBookName(long usrId, String bookName); }
Java
복사
이제 대출 기록을 반납처리해주자
UserLoanHistory
package com.group.libraryapp.domain.user.loanhistory; @Entity public class UserLoanHistory { . . public void doReturn(){ this.isReturn = true; } }
Java
복사
대출기록 반납했다는 값을 처리하는 함수를 추가
@Transactional public void returnBook(BookReturnRequest request) { //유저 정보를 찾는다 User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); // 유저 id를 가지고 대출 기록을 찾는다 UserLoanHistory history = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName()) .orElseThrow(IllegalArgumentException::new); //대출 기록을 반납해준다 history.doReturn(); }
Java
복사

이번에도 마찬가지로 웹 UI와 DB를 열어 확인

select * from user_loan_history;
SQL
복사

자 그런데, 한 가지 고민할만한 내용이 있습니다!

이제 직접 SQL로 접근하는게 아니라 테이블과 맵핑된 객체를 이용했다(ORM)
SQL 대신 ORM을 사용하게 된 이유 중 하나 “DB 테이블과 객체는 패러다임이 다르기 때문”

DB 테이블과 객체는 패러다임이 다르다!

데이터의 영속성을 부여하기 위해서
DB 테이블에 데이터를 저장하는 것은 필수이다!
하지만 Java 언어는 객체지향형 언어이고,
대규모 웹 애플리케이션을 다룰 때에도 절차지향적인 설계보다 객체지향적인 설계가 좋다!
<20강. 스프링 컨테이너를 왜 사용할까?> 에서 살펴보았던 이유 역시 보다 객체지향적인 설계를 하기 위한 맥락에서 출발했다.

지금 코드를 조금 더 객체지향적으로 만들 수 없을까?

User와 UserLoanHistory가 직접 협업할 수 있게 처리할 수 없을까?

조금 더 객체지향적으로 개발할 수 없을까?

우선 현재의 대출 기능 관계를 살펴보자

다음으로 현재의 반납 기능 관계를 살펴보자.

대출 기능은 이렇게 개선할 수 있다!

User만 갖고와서 처리해주도록 할거다

반납 기능은 이렇게 개선할 수 있다!

현재 이부분을
User만 갖고와서 처리하도록 해줄거다.

선행 조건

User와 UserLoanHistory가 서로를 알아야 한다.

코드 변경

이 부분을
private User user;
Java
복사
그러면 User 밑에 빨간줄이 나오는데 JPA에 User라는 객체를 어디에 맵핑하라는거냐! 라는 오류다.
이때 사용하는 어노테이션이
@ManyToOne
@ManyToOne private User user;
Java
복사
내가 다수고 너가 1개
즉 대출 기록은 여러개고 대출 기록을 소유하고 있는 당사자는 한명이다.
N : 1

N : 1 관계란?

학생 여러명이 교실에 들어갈 수 있다!
학생 N : 교실 1
JPA에서는 @ManyToOne 라는 어노테이션을 써서 표현한다
User
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사
유저 입장에서는 대출기록은 여러개다.
1 : N 관계다. 그 때 사용하는 어노테이션이 @OneToMany
@OneToMany private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사

연관관계의 주인

Table을 보았을 때 누가 관계의 주도권을 가지고 있는가

이렇게 두 테이블이 있을 때 주도권이 누구한테 있는지 확인해줘야 한다.
주도권은 user_loan_history 한테 있다.
두 테이블을 봤을 때 누가 더 상대방을 보고 있는지 보고 결정난다.
지금 user_id가 없다면 둘다 연관되어 있는지도 모른다.
user_loan_history가 user_id가 있어서 주도권이 있다.
연관관계의 주인이 아닌 쪽에 mappedBy 옵션을 달아 주어야 한다.
User
@OneToMany(mappedBy = "user") private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사
연관관계의 주인의 값이 설정되어야만 진정한 데이터가 저장된다.
이제 UserLoanHistory에 오류나는 부분을 수정해줄려면 생성자부분을 수정해주면 된다.
BookService
해당 부분을 user.getId()에서 user로 바꿔준다
UserLoanHistory
public UserLoanHistory(User user, String bookName) { this.user = user; this.bookName = bookName; this.isReturn = false; }
Java
복사
근데 User랑 UserLoanHistory가 연관관계를 맺고 있지만 막상 BookService에서 서로 협력하고 있진 않다
각자 따로 놀고 있음.

JPA 연관관계에 대한 추가적인 기능들

1 : 1 관계

한 사람은 한 개의 실거주 주소만을 가지고 있다!
사람과 주소는 1 : 1 관계

이를 객체로 표현해보자!

여기서 서로 연관 관계인게 Person도 Address를 갖고 있고
Address도 Person을 갖고 있다.

Person과 Address를 이제 Table로 변경하자

잠깐! person 테이블이 address 테이블의 id를 가질 수도 있고,
address 테이블이 person 테이블의 id를 가질 수도 있다!
예를 들어 테이블을 다음과 같이 구성했다고 해보자!
create table person ( id bigint auto_increment, name varchar(255), address_id bigint, primary key (id) ); create table address ( id bigint auto_increment, city varchar(255), street varchar(255), primary key (id) );
SQL
복사
여기서 연관관계의 주인이 등장한다!

Person과 Address를 이제 Table로 변경하자

person이 주도권을 가지고 있다!
즉, Person이 연관관계의 주인이다!
package com.group.libraryapp.temp; @Entity public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String name; private Address address; }
Java
복사
package com.group.libraryapp.temp; @Entity public class Address { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String city; private String street; private Person person; }
Java
복사

1 : 1 관계의 @OneToOne 사용

package com.group.libraryapp.temp; @Entity public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String name; @OneToOne private Address address; }
Java
복사
package com.group.libraryapp.temp; @Entity public class Address { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String city; private String street; @OneToOne private Person person; }
Java
복사
여기서 1 : 1 관계를 나태내주는 @OneToOne를 추가해주면 된다

연관관계 주인을 JPA에게 알려줘야 한다.

연관관계의 주인이 아닌 쪽에 mappedBy 사용
@Entity public class Address { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String city; private String street; @OneToOne(mappedBy = "address") private Person person; }
Java
복사

연관관계의 주인 효과

객체가 연결되는 기준이 된다!
PersonService
package com.group.libraryapp.temp; @Service public class PersonService { private final AddressRepository addressRepository; private final PersonRepository personRepository; public PersonService(AddressRepository addressRepository, PersonRepository personRepository) { this.addressRepository = addressRepository; this.personRepository = personRepository; } @Transactional public void savePerson(){ //연결되지 않은 테이블이 생성 Person person = personRepository.save(new Person()); Address address = addressRepository.save(new Address()); //Person에 있는 address를 저장해준다. 그러면 DB에 연결된 채로 저장이 된다 person.setAddress(address); } }
Java
복사
Person
public void setAddress(Address address) { this.address = address; }
Java
복사
PersonController
package com.group.libraryapp.temp; @RestController public class PersonController { private final PersonService personService; public PersonController(PersonService personService) { this.personService = personService; } @GetMapping("/test/person") private void testPerson(){ personService.savePerson(); } }
Java
복사
이 코드를 실행하면 DB에서 정상으로 테이블이 연결된다!
이 코드를 실행하면 DB에서 정상으로 테이블이 연결된다! 연관관계의 주인(Person)을 통해 객체가 연결 되었기 때문!
AddressRepository
package com.group.libraryapp.temp; public interface AddressRepository extends JpaRepository<Address, Long> { }
Java
복사
PersonRepository
package com.group.libraryapp.temp; public interface PersonRepository extends JpaRepository<Person, Long> { }
Java
복사
포스트맨에서 날리면
select * from person;
SQL
복사
select * from address;
SQL
복사
만약 연관관계의 주인이 아닌 Address에서 setter를 써보자
public void setPerson(Person person) { this.person = person; }
Java
복사
PersonService
@Transactional public void savePerson(){ //연결되지 않은 테이블이 생성 Person person = personRepository.save(new Person()); Address address = addressRepository.save(new Address()); //Person에 있는 address를 저장해준다. 그러면 DB에 연결된 채로 저장이 된다 // person.setAddress(address); address.setPerson(person); }
Java
복사
그렇다면 이 코드는 어떻게 될까? 실행시키면, 테이블 간의 연결은 되어 있지 않다!
address의 생성자로 사용하고 포스트맨을 다시 날려주고 데이터를 보면
2번애 연결이 안된걸 볼 수 있다
어떤 객체의 setter를 쓸건지 한줄만 바꿔줬는데 연결이 안됨.
이게 바로 연관관계의 효과다.

연관관계의 주인 효과

1) 상대 테이블을 참조하고 있으면 연관관계의 주인
2) 연관관계의 주인이 아니면 mappedBy를 사용
3) 연관관계의 주인의 setter가 사용되어야만 테이블 연결

연관관계의 사용시 한 가지 주의해야 할 점

트랜잭션이 끝나지 않았을 때, 한 쪽만 연결해두면 반대 쪽은 알 수 없다!

해결책 : setter 한 번에 둘을 같이 이어주자!

연관관계 주인쪽에서 setter를 호출
@Transactional public void savePerson(){ //연결되지 않은 테이블이 생성 Person person = personRepository.save(new Person()); Address address = addressRepository.save(new Address()); //Person에 있는 address를 저장해준다. 그러면 DB에 연결된 채로 저장이 된다 person.setAddress(address); }
Java
복사
근데 아직 객체끼리는 연결되지 않음 트랜잭션이 끝나면 Person과 Address는 연관관계 주인으로 인해 연결이 됐지만
트랜잭션이 끝나기 전에는 객체끼리는 연결되지 않음
그래서 address.getPerson(); 하면 null이 나옴
Person
public void setAddress(Address address) { this.address = address; this.address.setPerson(this); }
Java
복사
이렇게 주고
Address
public Person getPerson() { return this.person; }
Java
복사
PersonService
address.getPerson();
Java
복사
이렇게 주면 이제 null이 아니다.
setter 할때 객체를 한번에 셋팅해줘서

N : 1 관계 - @ManyToOne과 @OneToMany

User는 여러개의 UserLoanHistory를 갖고 있기 때문에 연관관계의 주인이 될 수 없다.
테이블 상 컬럼이 무수히 많이 들어갈거라서 불가능.
그래서 연관관계 주인은 UserLoanHistory이여야 하고 이쪽에 user_id가 있어야한다.
무조건 숫자가 많은 N 쪽이 연관관계 주인이 되어야 한다.

@ManyToOne을 단방향으로만 사용할 수도 있다!

User에
@OneToMany(mappedBy = "user") private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사
이 부분을 지워도 UserLoanHistory가 User를 가리키게 @ManyToOne 하나만 쓸 수 있다.

@JoinColumn

연관관계의 주인이 활용할 수 있는 어노테이션. 필드의 이름이나 null 여부, 유일성 여부, 업데이트 여부 등을 지정
UserLoanHistory
@JoinColumn(nullable = false) @ManyToOne private User user;
Java
복사

N : M 관계 - @ManyToMany

학생과 동아리라고 생각하면 된다.
한 학생은 여러 동아리로 가입할 수 있고, 한 동아리에 여러 학생이 있다.
구조가 복잡하고, 테이블이 직관적으로 매핑되지 않아 사용하지 않는 것을 추천!!

cascade 옵션

cascade : 폭포처럼 흐르다
한 객체가 저장되거나 삭제될 때, 그 변경이 폭포처럼 흘러 연결되어 있는 객체도 함께 저장되거나 삭제되는 기능
ABC 유저를 삭제하면 DB에서는 어떤 데이터가 삭제될까?!
유저가 빌린 책 기록은 그대로 남아 있는데 유저만 쏙 사라지는게 매우 이상하다!!
그래서 이 현상이 이상해서 같이 사라지게 설정해주는 cascade가 생겼다
User
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사
User가 저장되거나 삭제될때 UserLoanHistory도 같이 저장하거나 삭제하고 싶으면 설정해주면 된다.
cascade 옵션을 붙이고 다시 실행해보자!
User가 삭제될 때 User와 연결된 UserLoanHistory까지 한 번에 사라지게 된다!

orphanRemoval 옵션

책1에 대한걸 지우는 로직을 짰다. 그러면 책2만 연결되어 있을거다
이때 DB는 어떻게 변할까?!
아무런 변화가 없다. cascade를 붙여도 이건 내가(User) 저장되거나 삭제될때다. 지금 User는 그대로기 때문.
만약 진짜 책1을 빌린 기록을 지우고 싶으면 UserLoanHistory에 대한 레파지토리를 갖고와서 직접 delete 해줘야 한다….
이럴 때 바로 orphanRemoval 옵션을 쓸 수 있다!
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
Java
복사
책1에 대한 연결이 끊어지면 책1을 빌린 데이터도 삭제할수있다.
객체간의 관계가 끊어진 데이터를 자동으로 제거하는 옵션
관계가 끊어진 데이터 = orphan (고아)
제거 = removal

정리

1. 상대 테이블을 가리키는 테이블이 연관관계의 주인이다.
연관관계의 주인이 아닌 객체는 mappedBy를 통해 주인에게 매여 있음을 표시해 주어야 한다.
2. 양쪽 모두 연관관계를 갖고 있을 때는 양쪽 모두 한 번에 맺어주는게 좋다.
3. cascade 옵션을 활용하면,
저장이나 삭제를 할 때 연관관계에 놓인 테이블까지 함께 저장 또는 삭제가 이루어진다
4. orphanRemoval 옵션을 활용하면, 연관관계가 끊어진 데이터를 자동으로 제거해준다.

책 대출/반납 기능 리팩토링과 지연 로딩

대출기능을 리팩토링해보자

User
public void loanBook(String bookName){ this.userLoanHistories.add(new UserLoanHistory(this, bookName)); }
Java
복사
BookService
@Transactional public void loanBook(BookLoanRequest request) { //1. 책 정보를 가지고 온다 Book book = bookRepository.findByName(request.getBookName()) .orElseThrow(IllegalArgumentException::new); //2. 대출 기록 정보를 확인해서 대출중인지 확인합니다. //3. 만약에 확인했는데 대출 중이라면 예외를 발생시킨다. if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)){ throw new IllegalArgumentException("진짜 대출되어 있는 책입니다."); } //4. 유저 정보를 가져온다. User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); //5. 유저 정보와 책 정보를 기반으로 UserLoanHistory를 저장 userLoanHistoryRepository.save(new UserLoanHistory(user, book.getName())); }
Java
복사
이 함수의
userLoanHistoryRepository.save(new UserLoanHistory(user, book.getName()));
Java
복사
이부분을 이렇게 수정할 수 있다. 기존에는 UserLoanHistory를 직접 만들어서 userLoanHistoryRepository를 통해서 save를 직접 해줬다.
이걸
user.loanBook(book.getName());
Java
복사
이렇게 수정하면 cascade 옵션을 통해서 User와 User가 갖고있는 새로운 UserLoanHistory가 들어간다
이게 트랜잭션이 끝날때 User안에 새로운 연관관게가 맺어지게 체크되서 UserLoanHistory까지 save 된다.
그래서 loanBook 함수에서는 UserLoanHistory를 직접 사용하지 않게 됐다!
지금 과정을 도메인 계층에 비즈니스 로직이 들었가다고 말한다.

반납기능을 리팩토링해보자

UserLoanHistory
public String getBookName() { return this.bookName; }
Java
복사
User
public void returnBook(String bookName){ UserLoanHistory targetHistory = this.userLoanHistories.stream() .filter(history -> history.getBookName().equals(bookName)) //파라매터로 받은 bookName하고 같은지 보고 .findFirst() //첫번째로 걸리는걸 찾아서 .orElseThrow(IllegalArgumentException::new); //혹시 갖고있는 유저가 없으면 익셉션 처리 targetHistory.doReturn(); }
Java
복사
기존 BookService의 returnBook 함수를 수정해주면
@Transactional public void returnBook(BookReturnRequest request) { //유저 정보를 찾는다 User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); // 유저 id를 가지고 대출 기록을 찾는다 UserLoanHistory history = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName()) .orElseThrow(IllegalArgumentException::new); //대출 기록을 반납해준다 history.doReturn(); }
Java
복사
@Transactional public void returnBook(BookReturnRequest request) { //유저 정보를 찾는다 User user = userRepository.findByName(request.getUserName()) .orElseThrow(IllegalArgumentException::new); user.returnBook(request.getBookName()); }
Java
복사
이렇게 소스가 간결해진다.
이렇게 되면 UserLoanHistoryRepository에 있는
Optional<UserLoanHistory> findByUserIdAndBookName(long usrId, String bookName);
Java
복사
이 부분도 사용하지 않게 되니까 지워도 된다

반납 기능 로직

함수형 프로그래밍을 할 수 있게, stream을 시작한다.
들어오는 객체들 중에 다음 조건을 충족하는 것만 필터링 한다.
UserLoanHistory 중 책 이름이 bookName과 같은 것!
첫 번째로 해당하는 UserLoanHistory를 찾는다.
Optional을 제거하기 위해 없으면 예외를 던진다.
그렇게 찾은 UserLoanHistory를 반납처리 한다.

도메인 계층에 비즈니스 로직이 들어갔다.

영속성 컨텍스트의 4번째 능력!

User를 가져오는 부분과 도메인 로직 실행 중간에 출력을 해보자!
책을 반납해주자
User 먼저 찾는게 조회되고
hello 출력
User와 연결된 UserLoanHistory가 조회
User를 가장 먼저 가져오고 Hello 출력이 된다음 UserLoanHistory를 가져온다!
꼭 필요한 순간에 데이터를 로딩한다!!
즉, User만 갖고오다가 정말 필요한 순간에 UserLoanHistory 까지 갖고온다.
이걸 지연 로딩 (Lazy Loading)라고 함

지연 로딩

@OneToMany의 fetch 옵션
지연 로딩을 사용하게 되면, 연결되어 있는 객체를 꼭 필요한 순간에만 가져온다
만약
@OneToMany(fetch = FetchType.EAGER)
Java
복사
이런식으로 주면 첨부터 다 조회해서 갖고온다

두 가지 추가적으로 생각해볼거리

[1] 연관관계를 사용하면 무엇이 좋을까?

1) 각자의 역할에 집중하게 된다! (= 응집성)
2) 새로운 개발자가 코드를 읽을 때 이해하기 쉬워진다.
3) 테스트 코드 작성이 쉬워진다.

[2] 연관관계를 사용하는 것이 항상 좋을까?

그렇지는 않다!
지나치게 사용하면, 성능상의 문제가 생길 수도 있고 도메인 간의 복잡한 연결로 인해 시스템을 파악하기 어려워질 수 있다.
또한 너무 얽혀 있으면, A를 수정했을 때 B C D 까지 영향이 퍼지게 된다.
비즈니스 요구사항, 기술적인 요구사항, 도메인 아키텍처 등
여러 부분을 고민해서 연관관계 사용을 선택해야 한다.

정리. 다음으로!

책 요구사항 구현하기

1. 책 생성, 대출 반납 API를 온전히 개발하며 지금까지 다루었던 모든 개념을 실습해 본다.
2. 객체지향적으로 설계하기 위한 연관관계를 이해하고, 연관관계의 다양한 옵션에 대해 이해한다.
3. JPA에서 연관관계를 매핑하기 위한 방법을 이해하고, 연관관계를 사용해 개발할 때와 사용하지 않고 개발할 때의 차이점을 이해한다.

*참고