우당탕탕 개발일지

[SpringBoot] 6. 데이터베이스의 객체와 연관관계 (Repository 계층) 본문

Server

[SpringBoot] 6. 데이터베이스의 객체와 연관관계 (Repository 계층)

devchop 2025. 1. 8. 16:13

실습 library db에는 user테이블과 book테이블, 그리고 어떤 유저가 어떤 책을 빌렸는지 정보를 저장하는 user_loan_history 테이블이 있다. user_loan_history 테이블은 아래처럼 생겼다.

create table user_loan_history(
    id bigint auto_increment,
    user_id bigint,
    book_id bigint,
    is_return tinyint(1),
)

 

SQL대신 ORM을 사용하게 된 이유 중 하나는, DB테이블과 객체의 패러다임이 다르기 떄문이었다. Java는 객체지향형 언어이고, 대규모 웹 어플리케이션을 다룰 때에도 객체지향적인 방법이 어울린다. 현재 코드를 조금 더 객체지향적으로 업그레이드 하는 방법을 알아보자.

user와 user-loan-history는 밀접하게 연관이 되어있다. user와 userloanhistory가 Repository 딴에서 협업하게끔 할 수 없을까??

현재는 userService에서 userRepository, bookRepository 에 각각 접근하여 원하는 데이터를 가져오고있다. 즉, userRepository 와 bookRepository는 서로 협업하지 못하고 있다. userRepository에서 userLoanHistoryRepository에 접근하여, userService에서는 userRepository를 통해서 바로 데이터를 가져올 수 있으면 더 좋을 것 같다.

 

1. 연관관계의 주인 파악하기

Repository 끼리 서로 연결을 짓기 위해서는 먼저 연관관계의 주인을 설정해야한다. table을 보았을때 관계의 주도권을 누가 가지고 있는지를 확인한다. 테이블상에서 userLoanTable이 user_id를 보유하고 있기 때문에 userLoanHistory테이블이 주도권을 가지고 있다고 판단한다.

 

2. mappedBy
연관관계의 주인이 아닌 쪽mappedBy 옵션을 추가한다. (주인에게 매여있다는 의미로 생각하면 쉽겠다.)실제 디비에는 없지만, userloanHistory의 user 에 매핑된다는 것을 지정해준다.

1대1관계 표현하기

1인당 하나의 주소를 가지고 있다고 하자. Java상에서 Person class에는 Address변수를, Address class 에서는 Persion 변수를 가지고있다. 실제 테이블에선 둘중 하나만 상대값을 가지고 있다. 만약 person테이블에 address_id를 보유하고 있게 설계한다면, person이 연관관계의 주인이다. 연관관계 주인(Person)은 객체가 연결되는 기준이 된다.

public class Person{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    private String name;

    @OneToOne 
    private Address adress;
}
public class Adress{
    @Id
    @GeneratedByValue(strategy = GenerationType.IDENTITY)
    private Long id =null;
    private String city ;
    private String street;

    //연관관계의 주인이 아니기 때문에 mappedBy가 필요하다. 실제 db에는 없는 부분, 매핑을 통해 관리한다.
    @OneToOne(mappedBy = "address") 
    private Person person;
}

 

아래와같은 함수를 작성했다고 하자. 연관관계의 주인이 자신의 필드를 수정하고 있다. 만약 address 객체. 즉 연관관계의 주인이 아닌 객체가 person을 설정하는 SerPersion() 을 호출했을 경우, Person에 Address가 연결되기를 기대하지만 그렇게 되지 않는다. 연관관계의 주인이 값을 설정해야만 실제 db에 데이터가 정상적으로 반영된다. 실제 db에 상대id를 가지는 것이 person이므로, 어찌보면 당연한 것이다

@Transactional
public void savePerson(){
    Person person = personRepository.save(new Person());
    Address address = addressRepository.save(new Address());
    person.SetAddress(address);

    system.out.println(address.getPerson()); //null!
}

 

주의할점
savePerson() 에서, 트랜잭션이 아직 끝나지 않았을때 address에서 person을 조회하려 하면 정상적으로 조회되지않는다 (null) 테이블끼리는 연결되었으나, 객체끼리 연결되지 않은 상태이기 때문이다. 이를 해결하기 위해서는 adress 쪽에도 person을 연결한다.

public class Person(){
    //...
    public void SetAddress(Address address){
        this.address = address;
        this.address.SetPerson(this);
    }
}

N:1 관계 표현하기

연관관계 주인은 무조건 N쪽이다. 한명의유저가(1) : 여러개의 대출기록(N) 을 보유하고 있을 경우, 대출기록이 연관관계의 주인이다.

학생 여러명이 한 교실에 들어갈때 학생N: 교실1 이라고 할 수 있다. 유저의 대출정보의 경우, 한 유저가 여러 대출정보를 가지고 있으므로 대출정보 N : 유저1이다. User는 대출정보를 보유하고있고, UserLoan은 userId가 아니라 user객체를 보유하고 있게 된다.

//user.java
package com.group.library_app.domain.user;

@Entity
public class User {
    @Id //primary key 로 간주
    @GeneratedValue(strategy= GenerationType.IDENTITY) //자동생성. identity == auto_increment
    private Long id ;

    @Column(nullable= false, length=25,name="name")
    private String name;

    @Column(nullable= true,name="age")
    private Integer age;

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

    protected User(){} //기본생성자가 필요함.
    public User(String name, Integer age){
        if(name == null || name.isBlank()){
            throw new IllegalArgumentException("invalid name : "+name);
        }
        this.name = name;
        this.age = age;
    }

    public void LoanBook(long bookId){
        loanHistories.add(new UserLoanHistory(this,bookId));
    }
    public void returnBook(long bookId){
        UserLoanHistory history = this.loanHistories.stream()
        .filter(book-> book.getId()== bookId)
        .findFirst()
        .orElseThrow(()->new IllegalArgumentException("loan history not exist"));
         
        history.doReturn();
    }
    public void UpdateName(String _name){
        name= _name;
    }
    public Long getId(){return id;}
    public String getName(){return name;}
    public Integer getAge(){return age;}

}
//userLoanHistroy.java
package com.group.library_app.domain.user.loanHistory;
@Entity
public class UserLoanHistory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

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

    @Column(nullable = false, name="book_id")
    private long bookId;
    @Column(nullable = false, name="is_return")
    private boolean isReturn;

    protected UserLoanHistory(){}
    public UserLoanHistory(User user, long bookId) {
        this.user = user;
        this.bookId = bookId;
        isReturn =false;
    }
    public void doReturn(){this.isReturn =true;}

    public long getId(){return id;}
    public User getUser(){return user;}
    public long getBookId(){return bookId;}
    public boolean getIsReturn(){return isReturn;}
}

User에 있는 userLoanHistory에는 @oneToMany 어노테이션을, userLoanHistory 에 있는 User에는 @ManyToOne 어노테이션을 추가하여 일대다 관계임을 명시해준다.

N:M 관계 표현하기

@ManyToMany 이것은 사용하지 않는 것을 추천한다. 테이블을 새로 하나 만들어서, 1:N관계가 2개 나오도록 풀어서 만들어야한다.

여러가지 옵션

@JoinColumn
연관관계의 주인이 활용할 수 있는 어노테이션이다. 필드의 이름이나 null여부, 유일성 여부, 업데이트 여부 등을 지정한다. Column 어노테이션 대신 사용한다.

 

cascade 옵션
'폭포처럼 흐르다' 라는 의미를 보유하고있는데, 한 객체가 저장되거나 삭제될때 그 변경이 폭포처럼 흘러서, 연결되어있는 객체도 함께 저장,삭제되는 기능이다.

A라는 유저가 책 2권을 빌렸다고 가정하자. 만약 유저가 삭제되어서 userRepository.delete(user) 함수가 호출되었을 경우, 유저만 삭제되고 유저가 책을 빌렸던 userLoanHsitory는 남아있게 된다. 유령회원이된다. 이런 문제를 해결하기 위해 cascade 옵션이 등장했다. User테이블에서 볼 수 있듯이, 유저가 삭제될 때 연관된 history를 모두 제거하고싶다면 @OneToMany 쪽에 cacade 옵션을 추가한다.

 

orphanRemoval 옵션
A라는 유저가 책 2권(책1, 책2)을 빌렸다고 가정하자. 대출기로에서, 책1만 제거하고 싶다고 하자. 이때 user에 있는 userLoanHistory에서 책1을 제거할 경우, 실제로는 변화가 없다. 왜냐면 User는 연관관계의 주인이 아니기 때문이다. 지우고싶다면 userLoanHistory에서 값을 찾아서 지워야 한다.

만약 user에 있는 loanHistory를 지우는 것만으로도 실제 db에 데이터가 제거되게 하고싶을 경우 orphanRemoval 옵션을 사용한다. 즉, 객체간의 관계가 끊어진 데이터를 자동으로 제거하는 옵션이다.

마무리하며

이전에 영속성 컨텍스트에 대해 공부를 했는데, 영속성 컨텍스트의 또다른 능력이 있다. 바로 지연로딩 이다. 

다음 3개 작업을 순서대로 수행한다고 가정하자.

  1. User의 정보를 가져온다.
  2. 해당 User의 대출기록을 가져온다.
  3. 그중 A라는 책의 기록을 삭제한다.

이 때, 유저와 대출기록 정보를 한번에 가져오는것(EAGER) 이 아니라, 유저를 가져온 다음 user의 대출기록을 가져온다.(LAZY). 즉, 실제로 값이 필요한 순간에 데이터를 가져온다. 이는 트랜젝션(영속성 컨테이너) 안에서만 가능하다.

 

연관관계를 사용하면 각자의 역할에 집중하게된다. 새로운 개발자가 코드를 읽을 때 이해하기 쉬워진다. 도메인 코드로 로직이 존재하기 때문에 계층이 분리되어 있고, 도메인 각각의 역할파악이 쉬워진다.

그렇다고 해서 연관관계를 사용하는 것이 무조건 옳다는 것은 아니다. 지나치게 사용할 경우 성능상의 문제가 있고, 도메인끼리 너무 얽혀있을 경우 오히려 시스템으 파악하기 어려워질 수 있다. 

따라서 비지니스 요구사항, 기술적 요구사항, 아키텍처들을 고려해서 설계하는 것이 좋다.