연관관계 설정

goose_bumps·2024년 8월 15일

기존의 BookService의 대출/반납 기능은 User, Book, UserLoanHistory에 전부 접근해서 작동했었다.

  • 대출 : Book 객체 가져옴 -> UserLoanHistoryRepository에서 대출 여부 검증 -> User 객체 가져옴 -> UserLoanHistory에 User,Book 정보 저장

  • 반납 : User 객체 가져옴 -> UserLoanHistory 가져와 반납처리

이 구조를 BookService <- User <-> UserLoanHistory 로 바꾸고자 한다. 즉, BookService에서 User를 가져와 바로 대출, 반납 처리가 가능한 것이다.

이를 구현하려면 연관관계를 설정해야 한다.

1. 연관관계 설정

1) 1:N 관계

사용자인 User는 여러 권의 책을 대출할 수 있다. 만약 userId가 1인 User가 3권의 책을 빌린다면 UserLoanHistory에는 user_Id가 1인 테이블이 3개가 생길 것이다.
즉, 1명의 User는 여러 개의 UserLoanHistory를 가질 수 있다는 것이다.
그러_면, User가 1, UserLoanHistory가 N으로 관계를 정리할 수 있다.
왜 이 관계를 정리했냐면 @OneToMany, @ManyToOne 을 정해야 하기 때문이다.

User가 1쪽이니 @OneToMany, UserLoanHistory는 N쪽이니 @ManyToOne을 추가하면 된다.
그렇다면 User는 여러 개의 UserLoanHistory를 가져야 하므로 다음 필드를 추가해준다.

List<UserLoanHistory> userLoanHistories = new ArrayList<>();

반대로 UserLoanHistory는 기존의 user_id 필드를 user로 바꾸어 주어야 하는데, 문제는 변경 시 컴파일 에러가 발생한다.
왜냐? user_loan_history의 테이블에는 user라는 필드가 없어 매핑이 안되기 때문이다.
이제 에너테이션을 추가해줄 차례인 것이다.

    //User
   
    @OneToMany(mappedBy = "user")
    private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
    //UserLoanHistory
    
    @ManyToOne
    private User user;
    
    //생성자 매개변수도 바꾸어 주어야 함
    public UserLoanHistory(User user, String bookName) {
    this.user = user;
    this.bookName = bookName;
    this.isReturn = false;
    }

기존에 있던 생성자는 user_Id를 매개변수로 받았지만 필드를 user로 변경했기 때문에 매개변수도 user로 바꾸어 주어야 한다.
여기서 User 클래스 쪽에만 mappedBy라는 옵션이 추가되어 있는데 이는 주도권을 설정하려고 추가한 것이다.

user 테이블과 user_loan_history 테이블을 확인해보자.
user 테이블에는 id, name, age
user_loan_history 테이블에는 id, user_id, book_name, is_return 이 있는데 user_loan_history에만 상대방에 대한 정보를 가지고 있다.

즉, User를 가리키고 있는 것이다. 여기서 알 수 있는 것은 user_loan_history가 주도권을 가진다는 것이다.

mappedBy에 "user"로 입력해두면 UserLoanHistory의 user 필드를 연관관계의 주도권을 가지고 있다고 인식하게 된다.

정리하자면, 1:N 관계에서는 N쪽이 주도권을 가지게 되고, 주도권을 가지지 않은 쪽은 mappedBy를 추가하여 주도권을 가지고 있는 필드명을 입력해주어야 한다.

2) 주도권의 필요성

그렇다면 주도권을 가지게 되면 어떤 것이 이점이 될까?
객체가 연결되는 기준이 되는 점이다.

만약, 주도권 설정을 해주지 않는다면 두 테이블이 연결되지 않는다. User 정보를 변경한 것이 데이터베이스에 반영되지만, 반대로 UserLoanHistory의 정보 변경은 반영되지 않는다는 것이다.

이는 1:N 관계뿐만 아니라 1:1관계에서도 마찬가지이다.

1:1 관계일 경우 둘 다 @OneToOne을 추가하면 되지만 주도권을 가지지 않은 쪽에는 반드시 mappedBy를 해주어야 한다는 것이다.

3) @joinColumn

연관관계에 주도권을 가진 쪽에 추가할 수 있는 에너테이션이다. 가지고 있는 다른 테이블을 가리키는 필드 이름이나 null 여부, 유일성 여부, 업데이트 가능 여부 등을 설정할 수 있다.

    @JoinColumn(nullable = false)
    @ManyToOne
    private User user;

4) cascade 옵션

cascade 옵션은 한 객체의 저장/삭제가 연결되어 있는 다른 객체의 저장/삭제로 이어지게 하는 기능이다.

user_id가 3인 User가 "정보처리기사"라는 이름의 책을 대출하였다고 가정해보자.
그러면 user_loan_history 테이블에 user_id는 3, book_name은 "정보처리기사"로 저장될 것이다. 이때 delete user 기능을 사용하여 해당 유저를 삭제하면 어떻게 될까?

결과는 데이터베이스에 그대로 남는다. User 데이터만 사라지고 User가 대출하였던 기록은 그대로 남는 것이다.

이때 cascade 옵션을 사용하게 되면 User 데이터도 사라지고 user_loan_history에도 user_id가 3인 행 자체가 전부 사라지게 되는 것이다.

비유하자면, 사라진 사람의 연관 기록이 전부 사라진다고 보면 된다.

User 데이터 삭제 시 연관된 UserLoanHistory를 삭제해야 하기 때문에 User 클래스의 userLoanHistories에 cascade 옵션을 설정해주면 된다.

    @OneToMany(mappedBy = "user",cascade = CascadeType.ALL)
    private List<UserLoanHistory> userLoanHistories = new ArrayList<>();

cascade는 "작은 폭포"라는 사전적 의미를 가지는 데 폭포처럼 데이터의 저장/삭제가 연결된 데이터로 흘러가 이어진다고 생각하면 좋을 것 같다.

5) orphanRemoval 옵션

반대로, 어떠한 User가 자신의 대출기록 중 대출기록3을 삭제하고 싶을 경우 userLoanHistoried에 있는 UserLoanHistory를 삭제하면 될까?

정답은 orphanRemoval 옵션을 설정해야 하거나 UserLoanHistoryRepository.delete()를 직접 사용해주어야 한다는 것이다.

orphanRemoval을 설정해주지 않으면 List에서 제거하여도 반영되지 않는다.

    @OneToMany(mappedBy = "user",cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserLoanHistory> userLoanHistories = new ArrayList<>();

2. 코드 리펙토링

이제, 연관관계 설정이 끝났으니 앞에서 설명했듯이 서비스 -> 도메인 -> 레포지토리 순으로 코드를 리펙토링 해보자.

1) 대출 기능


//User class
    public void loanBook(String bookName){
        this.userLoanHistories.add(new UserLoanHistory(this,bookName));
    }

도메인 계층인 User 클래스에 loanBook 메서드를 추가해준다.
OneToMany 관계를 형성하기 위해 userLoanHistories라는 List를 만들었었는데 그 List에 UserLoanHistory 객체를 추가하는 기능이다.

그러면 User와 bookName 정보를 파라미터로 받은 UserLoanHistory 객체가 List에 추가가 되고 테이블에 반영이 되는 것이다.

이제 BookService를 수정해보자.

    
  //BookService class
  @Transactional
    public void loanBook(UserLoanHistoryRequest request){
        Book book = bookRepository.findByName(request.getBookName())
                .orElseThrow(IllegalArgumentException::new);
        if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)){
            throw new IllegalArgumentException("대출중인 책입니다");}

        User user = userRepository.findByName(request.getUserName());
        if(user == null){
            throw new IllegalArgumentException();
        }
  
          /*
        User user = userRepository.findByName(request.getUserName());
        if(user == null){throw new IllegalArgumentException();}
        userLoanHistoryRepository.save(new UserLoanHistory(user.getId(),book.getName()));
        */
  
        user.loanBook(book.getName());

기존에 있던 코드는 주석 처리하였고 이제 도메인에서 레포지토리로 접근하는 방식으로 변경되었기 때문에 서비스 계층에서는 레포지토리로 접근하는 코드가 있으면 안된다.

2) 반납 기능

반납 기능도 대출 기능과 메커니즘은 동일하다.

  
//User class
public void returnBook(String bookName){
      UserLoanHistory targetHistory = this.userLoanHistories.stream()
              .filter(history -> history.getBookName().equals(bookName))
              .findFirst()
              .orElseThrow(IllegalArgumentException::new);
      targetHistory.doReturn();
  }

User 클래스에 returnBook 이라는 메서드를 추가해주고 마지막에 doReturn을 호출해줘야 데이터베이스에 있는 is_return이 반납 후 true로 변경 처리가 된다.

자, 이제 서비스 계층으로 돌아가 대출 기능과 마찬가지로 서비스 계층에서 레포지토리에 접근하려는 코드는 없애줘야 한다.

  
//BookService
 @Transactional
  public void returnBook(UserReturnHistoryRequest request){
      User user = userRepository.findByName(request.getUserName());
      if(user == null){
          throw new IllegalArgumentException();
      }
/* UserLoanHistory userLoanHistory = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName())
.orElseThrow(IllegalArgumentException::new);
      userLoanHistory.doReturn();*/

      user.returnBook(request.getBookName());
  }

여기까지 마무리하면 이제 개발 단계는 완성한 것이다.
나머지 남은 작업은 배포인데 다음 포스팅에서 다루겠다.

0개의 댓글