[JPA] 연관관계 매핑

RID·2024년 6월 21일
0

JPA 이해하기

목록 보기
2/4

배경


이번 포스팅에서는 지난번 영속성 컨텍스트에 이어서 Entity 사이의 연관관계를 어떻게 구현하는지, 그리고 연관관계를 가지는 Entity 사이의 영속성 전이에 대해서 살펴보도록 하자.

연관관계


데이터베이스의 테이블에는 Foreign key(FK) 를 활용해서 두 테이블 사이에 관계를 설정한다. 그리고, 연관되어 있는 테이블의 값이 모두 필요할 때는 join을 통해서 두 테이블을 합쳐서 정보를 확인한다.

이때 만약 A, B 두 테이블이 있고, B에 A의 FK가 존재하는 상황이라고 하자. B에만 FK가 존재하더라도 join을 통해 A->B, B->A 모든 참조가 가능하다. 즉, RDB에서는 연관된 두 테이블 사이에 뱡향이 존재하지 않는다!

하지만 객체에서는 다르다. 객체 사이에 연관관계는 결국 특정 객체가 다른 객체의 존재를 알아야지만 참조가 가능해진다. 따라서 A만 B에 대한 정보를 가지고 있다면, B는 A에 대해서 절대 알 수가 없다.

결국 실제 RDB에는 방향이 없이 존재하는 두 연관된 테이블에 대해서, 객체를 다룰 때는 어떤 방향으로 방향성을 설정할지에 대한 내용이 연관관계 매핑이다.

이때 연관된 두 객체가 서로에 대해서 알고 참조가 가능하다면 양방향 연관관계라고하며, 한쪽 객체에서만 참조가 가능한 경우 단방향 연관관계라고 한다.

단방향 연관관계


실제 데이터베이스의 관계에는 방향성이 없다고 했다. 그렇다면 객체에서 이 관계를 설정할 때 어떤 방향성을 선택해야 하는지에 대한 고민을 하게된다. 이에 대한 정답은 없다! 하지만 각각의 방향성이 가지는 특징을 알고, 상황에 따라 적합한 방향성을 설정하면 된다.

  1. @OneToOne
  • 테이블간의 record가 1대1로 대응이 된다는 뜻은 사실 이 테이블이 서로 합쳐질 수 있음을 의미한다. 그렇기 때문에 DB의 구조에 대해 한 번더 생각해보는 것도 좋다.
  1. @OneToMany

    먼저 해당 연관관계에 대해서 얘기하기 전에 1:n 관계를 가진 경우 데이터베이스 테이블을 어떻게 구성하는지 잠깐 생각해보자.

    특정 Team에 여러 Member가 존재하는 경우 1:n관계를 가진다. 이때 우리는 Team의 column에 Member 리스트를 넣는 것이 아니라, Member 쪽에 FK로 team_id를 넣어서 관리하게 된다.

    즉, DB입장에서는 join을 통해 양쪽에서 서로 참조가 가능하다고 했지만, 실제 연관관계의 주인은 member라고 할 수 있다.

    @OneToMany 연관관계는 1:n 관계에서 1쪽에 있는 Entity에 리스트 형태로 n쪽 테이블 정보를 가지고 있겠다는 것이다. 실제로 아래와 같은 이유들 때문에 OneToMany 연관관계 사용을 지양하라고 얘기하기도 한다.

  • 실제 관계의 주인이 아닌 쪽에서 참조를 하게 되므로 FK의 위치와 객체 참조의 위치가 달라 헷갈릴 여지가 있다.

  • Insert시 불필요한 Update 쿼리가 발생한다. (DB의 데이터가 저장되는 과정도 함께 고민해보자! )
    - 실제 외래키는 1:n에서 n쪽에 해당하는 부분에 존재한다. 그렇기 때문에 먼저 insert를 진행하고, 외래키 설정을 위한 update를 한번 더 진행하게 된다.

    위와 같은 단점이 존재하기 하지만 사실 유용한 경우도 있다! 예를 들어 특정 야구팀의 선수를 조회하는 요청이 특정 선수의 팀을 조회하는 요청보다 빈번하게 발생한다면 @OneToMany의 연관관계 방향이 더 자연스러운 방향이 되는 것이다.

    그렇기 때문에 이런 문제가 있다는 것만 인지하고 필요에 따라 방향을 고려해보도록 하자.

  1. @ManyToOne

    해당 연관관계 방향은 사실 크게 얘기할 부분이 없다. 실제 FK가 존재하는 쪽에서 참조를 진행하겠다고 하는 것이니 데이터베이스와 비교했을 때 굉장히 자연스럽게 느껴진다.

    사실 그래서 처음 프로젝트를 생성하고 연관관계를 설정할 때 동일한 Aggregate 내부의 도메인 엔티티에 대해서는 ERD에서 설계한 것을 토대로 @ManyToOne 으로 설정해두는 편이다.

    그리고 필요에 따라서 양방향 연관관계를 설정하거나, OneToMany로 변경한다.

  2. @ManytoMany

    위의 상황 역시 데이터베이스 관점에서 먼저 살펴보자. M:N 관계를 가지는 테이블 사이의 관계를 정의하기 위해서는 별도의 join 테이블이 필요하게 된다. 두 테이블 각각과 조인테이블의 경우 1:n 관계를 가지기 때문에 join 테이블에 대한 엔티티를 생성한 후 @OneToMany 혹은 ManyToOne 연관관계를 맺을 수도 있다.

    반면,@ManyToMany를 사용해서 join 테이블에 대한 엔티티를 생성하지 않고 두 객체간의 참조를 이루어 낼 수 있다.

    하지만 이 경우 만약 join 테이블에 다른 정보가 존재하는 경우 이를 참조할 방법이 없고, JPA가 내부적으로 쿼리를 날리기 때문에 예측하기 어려운 쿼리가 날아간다는 문제가 있다고 한다.
    (사실 직접 엔티티를 생성하는 첫 번째 방식을 주로 사용해서 직접 경험하지는 못했다.)

단방향 연관관계에 대해서 설명하면서 위의 4가지 매핑 방식을 설명해서 착각할 수 있지만, 위 4개의 매핑 방식은 단방향, 양방향과 전혀 관계가 없다. 단지 관계의 방향이 어느쪽으로 설정되어 있는가에 대한 내용일 뿐이다. 두 엔티티가 서로에 대해서 방향을 가지는 경우 양방향이라고 부르는 것이고, 한쪽에서만 참조가 가능한 경우 단방향 연관관계라고 부르는 것이다.

양방향 연관관계


데이터베이스를 생각해보면 한쪽 테이블에만 외래키를 두더라도 관계는 완성된 것이다. 객체에서도 마찬가지이다. 단방향 연관관계를 설정했다면 두 엔티티 사이의 연관관계는 형성된 것이고, 잘 활용하면 필요한 데이터 정보는 모두 가져올 수 있다.

그럼에도 해당 방향이 아닌 반대 방향의 조회가 필요하다고 판단되는 경우 반대쪽에서도 연관관계를 설정해주면서 양방향 연관관계를 맺을 수 있다. 하지만 양방향 연관관계의 경우 주의해야 할 부분이 존재한다.

바로 mappedBy를 사용해서 누가 연관관계의 주인인지 설정해줄 필요가 있다. 이는 두 엔티티가 서로에 대해 참조가 가능해지기 때문에, 두 방향 모두에서 각 객체를 수정할 수 있게 되면 문제가 발생하는 상황이 생길 수 있기 때문이다. 따라서 연관관계의 주인을 중심으로 동작하도록 JPA에게 알려주는 것이다.

그래서 양방향 연관관계에서 관계의 주인이 아닌쪽, 즉 mappedBy옵션이 걸려있는 엔티티의 경우 조회 권한만을 가지게 된다.

그렇기 때문에 양방향 연관관계에서는 반드시 데이터를 추가/삭제/변경할 때 연관관계의 주인을 변경해야 한다!

아래는 양방향 연관관계로 발생한 문제상황에 대한 내용과 관련된 유튜브 영상이다.

https://youtu.be/brE0tYOV9jQ?si=wHr0vpOr1VM14f96

위 문제 상황에 대해서 한번 살펴보자.

@Entity
class Book(
	var name : String,
    
    @ManyToOne
    @JoinColumn(name = "bookstore_id")
    var bookStore: BookStore? = null
){
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id : Long? = null
    
}

@Entity
class BookStore(
	var name: String,
    
    @OneToMany(mappedBy = "Book")
    val books : MutableList<Book> = mutableListOf()
){
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id : Long? = null
    
	fun addBook(book: Book) {
    	books.add(book)
    }
}

BookBookStore가 위와 같이 양방향 연관관계를 맺고 있는 상황이다. 실제 연관관계의 주인은 Book이다. 이때 Serivce에서 다음과 같은 함수를 실행했다고 생각해보자.

fun test(){
	val bookStore = BookStore("책장 1")
    bookStoreRepository.save(bookStore)
    
    val book = Book("책1")
    
    bookStore.addBook(book)
  
    bookRepository.save(book)

}

위의 함수를 실행하고 실제 데이터베이스를 보면 Book 쪽에 BookStore에 대한 외래키가 설정되지 않고 저장이 된다. 왜냐하면 양방향 연관관계에서 연관관계의 주인인 Book에게 연관된BookStore가 누구인지 설정해주지 않았기 때문이다.

BookStoreBook리스트에 새로 Book을 추가했기 때문에 자연스레 외래키가 지정되길 바라겠지만, 위에서 얘기했듯이 연관관계의 주인이 아닌쪽에서는 일어나는 변경이 반영되지 않는다.

위의 문제는 아래와 같이 간단히 해결하면 된다. 연관관계의 주인쪽에서 변경을 진행하면 된다.

fun test(){
	val bookStore = BookStore("책장 1")
    bookStoreRepository.save(bookStore)
    
    val book = Book("책1", bookStore)
    bookStore.addBook(book)
    bookRepository.save(book)

}

이렇게 하면 연관관계의 주인쪽에서 변경 사항이 생겼고, 데이터베이스에 올바르게 반영될 것이다.

사실 위의 문제 상황은 Kotlin을 사용하고 있다면 만나기 어려울 것이다. Book 엔티티에 있는BookStore를 nullable하지 않게 설정하기만 하면 새로 Book을 생성할 때 무조건 BookStore를 생성자에 넣어주어야 하기 때문이다.

하지만 그럼에도 양방향 연관관계에서는 반드시 연관관계의 주인을 통해 변경해야 한다는 사실을 잊지말자.

양방향 연관관계는 그럼 안쓰는게 좋은거네?

지금까지 설명한 바에 따르면 사실 장점보다는 단점이 많아 보이기도 한다. 이미 단방향 연관관계로도 관계 설정은 완료가 된 것이고, 반대 방향의 조회를 위해서 감수해야할 리스크가 커보이기도 한다.

하지만 꼭 나쁜것만은 아니다. 위에서 @OneToMany 연관관계에서 insert시 불필요한 update 쿼리가 발생한다고 얘기했었다.

@Transactional
fun main() {
	// BookStore -> Book 으로 @OnetoMany 단방향 연관관계 설정
	val book = Book("책1")
	val bookStore = BookStore("책장1", mutableListOf(book))
	bookStoreRepository.save(bookStore)
}
INSERT INTO bookstore(bookstore_id, name) VALUES (default, ?)
INSERT INTO book(book_id, name) VALUES (default, ?)
UPDATE book SET bookstore_id = ? WHERE book_id = ?

위의 상황을 보면 Book에 대해서 insert를 먼저 진행하고 이후에 외래키를 설정하기 위해 update 쿼리를 날리게 된다. 위 상황은 양방향 연관관계를 설정할 시 오히려 해결된다!

연관관계의 주인에 대한 변경을 의식하면서 연관관계를 양방향으로 설정하게 되면 3번째에 등장했던 update 쿼리가 사라지게 된다. (mappedBy에 의해 JPA가 연관관계의 주인을 중심으로 동작하기 때문이다!)

하지만 무조건 양방향으로 이 문제를 해결하려고 하기 이전에 @OneToMany가 반드시 필요한지도 한 번 고민해볼 필요가 있다!

0개의 댓글