본문 바로가기
  • 개발하는 곰돌이
Development/JPA

[JPA] 양방향 연관 관계에서 연관 관계의 주인 설정과 주의 사항

by 개발하는 곰돌이 2023. 3. 22.

목차

    개요

    JPA에서는 두 엔티티 사이의 연관 관계를 정의할 때 기본적으로 단방향으로 정의한다. 이로 인해 DB 테이블에서 외래키를 기준으로 하여 조인하는 것으로 두 테이블 간의 연관된 데이터를 조회할 수 있는 것과 달리 JPA에서 단방향 연관 관계가 정의된 상태에서는 한 쪽 엔티티 객체는 연관 관계인 엔티티 객체를 조회할 수 있으나, 반대쪽 엔티티 객체는 어떤 엔티티 객체와 연관 관계를 갖는지 알 수 없다.

     

    이로 인해 JPA에서는 필요에 따라 양방향 연관 관계를 정의하게 되는데, 이 과정에서 주의해야할 중요한 점이 있어서 어느정도 정리해보려고 한다.

    JPA의 양방향 연관 관계

    앞서 이야기한 바와 같이 필요에 따라선 연관된 두 엔티티 사이에서 서로 연관된 엔티티 객체를 조회해야 할 수 있다. 예를 들면 아래와 같은 평범한 게시판 구조에서의 관계를 들 수 있다.

    이 구조에서 DB 상으로 게시글은 작성자가 누구인지에 대해 user의 id를 외래키로 갖게 된다. 이 user_id를 통해 게시글의 작성자에 대한 정보를 조회할 수 있고, 반대로 사용자가 작성한 게시글에 대한 정보를 조회할 수도 있다.

     

    JPA에서는 이렇게 서로 연관된 엔티티 양쪽에서 연관되는 객체의 정보를 얻기 위해선 양방향 연관 관계를 정의해야 한다.  외래키를 갖는 엔티티에서만 연관 관계를 정의해도 DB 상에서 외래키가 생성되긴 하지만, 부모 테이블에 매핑되는 엔티티에서 자식 테이블에 매핑되는 엔티티에 대한 연관 관계를 정의하지 않으면 자식 엔티티 객체에서는 부모 엔티티 객체의 정보를 얻을 수 있지만 부모 엔티티 객체에서는 자식 엔티티 객체에 대한 정보를 얻으려면 자식 엔티티를 전부 조회한 다음 부모 엔티티 객체와 연관된 객체들만 필터링하는 방식을 사용해야 해서 매우 비효율적이다.

     

    따라서, 위와 같은 테이블 구조에서 User 객체에 해당하는 Board 객체 리스트를 얻으려면 각 엔티티를 다음과 같이 정의해야 한다.

     

    Board

    @Entity
    class Board(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val seq: Long?,
        @Column(nullable = false, length = 100)
        var title: String,
        @Column(nullable = false, length = Int.MAX_VALUE)
        var content: String,
        val createTime: LocalDate = LocalDate.now(),
        var updateTime: LocalDate = LocalDate.now(),
        @ManyToOne
        @JoinColumn(name = "user_id")
        val user: User?
    ) {
        ...
    }

     

    User

    @Entity
    class User(
        @Id
        @GeneratedValue(strategy = GenerationType.UUID)
        val id: UUID?,
        @Column(nullable = false)
        var name: String,
        var age: Int?,
        var phone: String?,
        var email: String?,
        @OneToMany(mappedBy = "user")
        val boards: MutableList<Board> = mutableListOf()
    ) {
        ...
    }

    여기서 @OneToManymappedBy에는 자식 엔티티 클래스에서 연관 관계로 지정한 부모 엔티티 객체의 식별자를 적어주면 된다.

     

    일단 이렇게 해주면 User 객체에 해당하는 게시글 목록을 JPA에서도 확인할 수 있게 된다.

    양방향 연관 관계의 규칙

    JPA의 양방향 연관 관계를 설정할 때는 아래의 규칙에 따라 연관된 엔티티를 매핑해야 한다.

    • 연관 관계를 갖는 두 객체 중 하나를 연관 관계의 주인으로 지정
    • 연관 관계의 주인만이 외래키를 관리(삽입, 수정, 삭제)
    • 주인이 아닌 쪽은 읽기만 가능
    • 주인은 mappedBy 속성을 사용하지 않음
    • 주인이 아닌 쪽은 mappedBy 속성을 사용하여 주인 지정

    연관 관계의 주인

    연관 관계의 주인을 정할 땐 외래키를 갖는 테이블에 매핑되는 엔티티의 객체를 주인으로 지정해야 한다. 즉, 자식 엔티티의 외래키에 해당하는 필드(N:1 관계에서 N에 해당하는 쪽)가 연관 관계의 주인이 되어야 한다.

     

    만약 외래키가 없는 반대쪽을 주인으로 지정하게 되면 엔티티 객체의 속성을 변경할 때 해당 엔티티와 매핑되는 테이블이 아닌, 다른 테이블의 외래키를 관리하게 된다. 예를 들어, 위의 Board - User 관계에서 User의 boards가 연관 관계의 주인이 된다면 사용자가 글을 작성했을 때 아래와 같은 로직이 발생할 것이다.

    1. 새로운 글에 해당하는 Board 객체 생성하고 저장. → 이 시점에는 작성자에 해당하는 user가 null.
    2. 글의 작성자에 해당하는 User 객체를 조회.
    3. 2에서 조회한 User 객체의 boards에 새로운 글에 해당하는 Board 객체 추가. 이 과정에서 새로운 글에 해당하는 Board 객체의 작성자에 해당하는 user가 갱신되면서 외래키 갱신

    이 경우는 양방향 연관 관계의 규칙에 따라 Board 쪽에서는 외래키를 관리하지 못하기 때문에 작성자에 해당하는 User 객체의 boards에 Board 객체를 추가하면서 Board에 존재하는 외래키를 관리하게 된다. 아래는 이러한 경우에 대한 예시이다.

    위 코드를 실행하면 발생하는 쿼리는 아래와 같다.

    외래키가 없는 쪽을 주인으로 지정하면 board를 insert할 때 외래키인 user_id를 설정할 수 없어서 이후에 별도로 user_id를 update한다.

    분명히 Board 객체는 처음에 저장만 하고 수정한게 없고, User 객체의 boards에 Board 객체를 추가했을 뿐인데 Board의 user_id가 UPDATE되고 있다. 이렇게 수정하는 객체와 실제로 UPDATE되는 테이블 사이의 괴리가 발생하여 개발자에게 혼동을 줄 여지가 있다.

     

    또한, 자식 엔티티가 주인일 경우에는 자식 엔티티가 자신이 가진 외래키를 직접 관리하기 때문에 예시의 Board - User 관계에서 새로운 글을 작성하려면 Board의 외래키인 user_id를 지정하여 INSERT 쿼리 한 번만 실행하면 된다. 하지만 위의 경우는 3개의 쿼리를 실행하는 것에 비하면 매우 큰 차이라고 볼 수 있다.

    외래키가 있는 쪽을 주인으로 지정하면 board를 insert할 때 외래키인 user_id를 바로 설정하여 추가한다.

     

    이러한 이유로 양방향 연관 관계에서는 외래키가 존재하는 쪽을 주인으로 지정해야 한다. 또한 외래키가 존재하는 쪽은 N:1 관계에서 N에 해당하는 쪽이기 때문에 @ManyToOne에는 주인을 지정하는 mappedBy 속성이 존재하지 않는다. 

    주인이 아닌 쪽은 읽기만 가능

    연관 관계의 주인을 지정하게 되면 두 엔티티 사이의 외래키는 연관 관계의 주인만 관리할 수 있고, 주인이 아닌 쪽은 연관된 객체를 읽는 것만 가능하다. 주인이 아닌 쪽에서 연관 관계 설정을 위해 갖는 객체는 단순히 이 객체가 이러한 자식들을 갖고 있다는 것을 보여줄 뿐이고, 실제 DB 상의 데이터와 매핑되진 않는다. 예시의 Board - User 구조에서 User 객체의 boards에 있는 Board 객체를 수정하거나 삭제해도 DB에 있는 Board 테이블에는 아무일도 일어나지 않는다. 예를 들어 아래와 같은 테스트 코드를 실행해도 실제 Board는 삭제되지 않기 때문에 테스트가 정상적으로 통과하는 것을 볼 수 있다.

    User 객체의 boards 요소를 모두 삭제해도 DB에는 아무일도 일어나지 않는다.

    양방향 연관 관계 설정 시 주의 사항

    이러한 양방향 연관 관계를 설정할 때는 아래와 같이 주의할 점이 있다.

    • 객체의 필드 값을 설정할 땐 항상 양쪽에 값을 설정해야 한다.
    • 양방향 연관 관계 설정 시 무한 루프를 주의해야 한다.

    객체의 필드 값을 설정할 땐 항상 양쪽에 값을 설정해야 한다

    주인이 되는 쪽은 값을 설정하지 않으면 DB에 외래키 값으로 null이 삽입되기 때문에 당연히 값을 설정해야 한다. 그런데 그 반대의 경우인 주인이 아닌 쪽에도 값을 설정해줘야 하는 점은 조금 의아할 수 있는데, 이 부분은 영속성 컨텍스트와 관련있다고 한다.

     

    처음에 JPA를 통해 객체를 추가하면 바로 DB에 INSERT되는 것이 아니라 영속성 컨텍스트의 1차 캐시에 저장되는데, 자식 엔티티 객체를 추가한 후에 이를 반영하지 않은 상태에서 부모 엔티티 객체를 조회하게 되면 추가했던 자식 엔티티 객체는 아직 DB에 추가되지 않았기 때문에 부모 엔티티 객체는 새로 추가한 자식 엔티티 객체를 제외한 값들만 갖게 된다.

     

    이를 위해 JPA를 통해 자식 객체를 추가할 때 자식 객체가 연관 관계로 갖는 부모 객체의 속성에도 값을 추가해야 하는데, 객체를 새로 생성할 때마다 하나하나 설정하기에는 매우 번거롭기 때문에 아래와 같이 별도의 메소드를 작성하거나 생성자나 정적 팩토리 메소드로 객체를 생성하는 과정에서 설정해주면 수월하다.

    @Entity
    class Board(
        @Column(nullable = false, length = 100)
        var title: String,
        @Column(nullable = false, length = Int.MAX_VALUE)
        var content: String,
        @ManyToOne
        @JoinColumn(name = "user_id")
        val user: Users? = null,
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val seq: Long? = null,
        val createTime: LocalDate = LocalDate.now(),
        var updateTime: LocalDate = LocalDate.now()
    ) {
        init {
            user?.boards?.add(this)
        }
        ...
    }
    
    // 또는
    
    @Entity
    class Board(
        @Column(nullable = false, length = 100)
        var title: String,
        @Column(nullable = false, length = Int.MAX_VALUE)
        var content: String,
        @ManyToOne
        @JoinColumn(name = "user_id")
        var user: Users? = null,
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val seq: Long? = null,
        val createTime: LocalDate = LocalDate.now(),
        var updateTime: LocalDate = LocalDate.now()
    ) {
        fun addUser(user: Users) {
            this.user = user
            user.boards.add(this)
        }
    }

    무한 루프를 주의해야 한다

    toString()을 사용하거나 컨트롤러에서 JSON 형태로 응답을 반환하는 과정에서 발생할 수 있는 무한 루프를 주의해야 한다. 기본적으로 양방향 연관 관계는 두 엔티티가 서로를 참조하는 형태가 된다.

     

    첫번째로 예시의 Board - User 구조에서 각 엔티티 객체의 모든 필드 값을 문자열로 반환하도록 toString()을 작성한 경우를 예로 들어 보자. Board 객체에 toString()을 호출하면 Board가 참조하는 User의 toString()을 호출하고, User에서는 User가 참조하는 Board의 toString()을 호출하게 된다. 결국 서로가 서로의 toString()을 무한히 호출하게 되므로 StackOverflow가 발생하게 된다. 이를 방지하기 위해서는 toString()을 오버라이드하지 않거나, 오버라이드하더라도 서로 연관 관계로 참조하는 객체는 제외하거나 한쪽에서만 참조하는 객체를 사용해야 할 것이다.

     

    두번째로 컨트롤러에서 JSON 형태로 응답을 반환할 때는 엔티티를 직접 반환하지 않고 별도의 DTO로 변환하여 반환하는 방법을 사용할 수 있다. 사실 컨트롤러의 응답으로 엔티티를 직접 반환하는 것은 두 가지 문제가 있다.

    1. 테이블의 구조를 노출시킬 수 있다. 엔티티는 DB의 테이블에 대응되는 클래스이기 때문에 엔티티의 구조가 곧 테이블의 구조가 되거나, 그게 아니더라도 매우 유사한 구조를 갖게 된다.
    2. 엔티티의 구조가 변경되면 API의 사양까지 바뀐다. API에서 응답으로 필요한 필드는 그대로인데 엔티티의 구조가 변경되면 응답으로 반환하는 JSON의 구조까지 바뀌게 된다. 정작 응답에서 필요한 필드는 바뀐게 없는데도 말이다.

    이러한 이유로 컨트롤러에서 JSON 형태로 응답을 반환할 때는 엔티티를 별도의 DTO로 변환하여 반환하는 것이 좋다.

    하지만 양방향 연관 관계는 잘 안쓴다

    사실 양방향 연관 관계를 실제로 사용할 일은 많지 않다고 한다. 양방향 연관 관계는 고려해야할 사항이 있는데다가, 부모 엔티티 객체에 해당하는 자식 엔티티 객체들을 확인하고 싶을 땐 굳이 양방향 연관 관계를 사용하지 않고 자식 테이블에서 외래키를 기준으로 조회하면 된다. 이 글에서도 사용자가 작성한 글의 목록을 조회하는 경우를 위해 양방향 연관 관계를 설정했지만 사실 그냥 Board 테이블에서 외래키인 user_id를 기준으로 조회하면 된다.

     

    그럼에도 불구하고 양방향 연관 관계를 사용하게 되는 경우는 부모 엔티티 객체를 조회할 때 해당 객체와 연관된 자식 엔티티 객체까지 함께 조회하고 싶을 때 등이 있다. 실제로는 단방향 연관 관계로도 충분히 가능한 일이지만 조금 더 객체지향적인 개발을 하고싶을 때 양방향 연관 관계를 사용할 수 있다고 한다. 다만, 이러한 경우에도 양방향 연관 관계를 사용하기보다는 둘 사이의 연계성을 끊고 별개로 사용하는 것을 권장한다고 한다.

    Reference

     

    자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

    JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., - 강의 소개 | 인프런

    www.inflearn.com

    댓글