Backend/JPA

[JPA] 연관관계 매핑

findmypiece 2021. 12. 17. 16:35
728x90

Entity 연관관계를 매핑할 때 특별한 경우가 아니라면 단방향으로 설계가 권장된다는 말을 많이한다. 그런데 DB 테이블 설계시에는 조인을 고려하고 설계하다보니 기본적으로 양방향이 가능한 구조인데 객체만 단방향으로 설계하기란 쉽지 않다.

 

예를 들어 게시판을 만든다고 할 때 DB테이블은 게시글을 의미하는 Contents 테이블과 댓글을 의미하는 Reply 테이블을 생성할 것이고 외래키는 N에 해당하는 Reply 에 둘 것이다.(실무에서는 외래키 역할을 하는 칼럼을 추가하긴 하지만 제약조건까지 걸어서 사용하는 경우는 드물다)

 

DB 입장에서 보면 join 을 통해 Contents 를 조회할 때 Reply 정보를 함께 가져올수 있고 Reply 조회시 Contents 정보를 함께 가져올 수도 있다. 어느쪽에서 출발하더라도 상대방의 정보를 연결할 수 있고 이를 양방향 연관관계라 한다. DB에서는 일단 매핑이 되면 기본이 양방뱡 연관관계이다.

 

이러한 관계를 Entity 로 만들면 어떻게 될까? 객체끼리 이러한 연관관계를 맺기 위해서는 기본적으로 참조변수가 있어야 한다. 예를 들어 Spring 개발을 한다고 했을 때 Controller 에서 Service 의 메소드를 사용하려면 Service 타입의 참조변수를 선언해야 한다. 기본적으로 이러한 상태를 단방향 연관관계라 한다.

 

그럴 일은 없겠지만 만약 Service에서도 Controller 를 참조하게 하고 싶다면 Controller 타입의 참조변수를 선언해야 한다. 이를 통해 양방향 연관관계가 성립된다. 즉, 객체에서 양방향 연관관계라는 것은 결국 단방향 연관관계가 2개인 상태를 말하는 것이다.

 

그렇다면 JPA에서 이러한 양방향 연관관계 지양하는 이유는 뭘까? 결론부터 말하면 관리 포인트가 늘어나기 때문인데 다르게 말하면 관리만 잘하면 문제될 건 없다. 

 

그렇다면 늘어나는 관리포인트는 뭐가 있을까?

  1. 두개의 Entity 중 하나를 연관관계의 주인으로 지정해야 한다.
    단방향 연관관계에서는 관계를 설정한 쪽이 주인이 되고 참조변수도 그곳에서 관리한다. 그리고 주인 Entity 에서만 참조변수에 값 할당을 통해 DB 외래키 등록이 가능하고 주인이 아닌 Entity 에서는 읽기만 가능하다.

    그런데 양방향 연관관계에서는 각각의 Entity 에서 참조변수를 가지고 있기 때문에 어느쪽을 주인 Entity로 할지 지정을 해야 한다. 일반적으로 N에 해당하는 쪽이 주인이 되고 일반적으로 아래와 같이 설정한다.
    //주인 Entity
    ...
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "PUSH_REQ_INFO_SEQ")
    var purPushReqInfo: PurPushReqInfo
    ...
        
    //주인이 아닌 Entity
    ...
    @OneToMany(mappedBy = "purPushReqInfo", fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    var purPushTrgtKey: MutableList<PurPushTrgtKey> = mutableListOf()
    ...​
    코드에 대한 설명을 조금 보태자면 주인이 아닌 Entity 에서는 mappedBy 를 통해 주인 Entity의 외래키(참조변수)를 지정한다. 그리고 주인 Entity 에서는 조인칼럼의 이름을 지정한다(이는 경우에 따라 안해줘도 되지만 명시적으로 해주는 것을 추천한다고 한다) 이 과정을 통해 주인 Entity 가 선택된다. 

    그리고 각 Entity는 fetch = FetchType.LAZY 에 의해 어느 방향에서 하든 기본은 지연로딩이 적용된다. 다만 필요에 의해 JPQL 또는 QueryDsl을 이용해서 즉시로딩(fetch join)을 적용할 수 있으며 이 때 기본은 outer join 이 수행되지만 optional = false 을 통해 상대방 무조건 null 아님을 명시할 경우 inner join 이 수행되도록 할 수 있다. cascade = [CascadeType.ALL] 는 뒤에서 설명한다.

  2. DB에 외래키가 정상적으로 입력되려면 반드시 주인 Entity 의 참조변수 값을 입력해야 한다.
    참조변수가 각 Entity 존재할텐데 영속성 객체 DB에 적용될 때 외래키가 정상적으로 등록되려면 반드시 주인 Entity의 참조변수에 값을 입력해야 한다.

    주인 Entity가 아닌 쪽의 참조변수에 값을 입력해도 DB에 데이터는 입력되겠지만 외래키는 등록되지 않는다. 이는 연관관계의 주인 Entity만 외래키의 값을 변경할 수 있고 상대방 Entity는 조회만 가능한 제약이 있기 때문이다.

  3. 주인이 아닌 Entity에도 참조변수에 값을 할당해줘야 한다.
    양방향 연관관계라도 주인 Entity는 하나를 선택해야 한다고 했다. 그리고 주인 Entity의 참조변수 에만 값을 할당하면 DB상 저장되는 데이터는 아무런 문제가 없다.

    다만 객체 입장에서는 주인 Entity 는 이미 생성되었고 양방향 임에도 주인이 아닌 Entity 의 참조변수 값은 null 인 모순된 상황에 놓이게 된다.

    이에 DB 입력에는 문제가 없지만 객체까지 고려한다면 save 단계에서 주인이 아닌 Entity 의 참조변수에도 값을 할당해줘야 안전하다.


  4. 객체 값 할당은 편의 메소드를 만들어서 관리하는게 좋다.
    양방향 연관관계에서는 2번 처럼 양쪽 객체의 참조변수에 모두 값을 입력해줘야 하는데 2개의 메소드를 호출해야 한다면 실수가 발생할 수 있기 때문에 일반적으로 위와 같은 기능을 합쳐놓은 편의 메소드를 주인 Entity에 만들어서 사용한다.

    그리고 영속성 컨텍스트가 아직 살아있는 상태에서 관계를 변경할 경우 변경된 연결됐던 Entity가 조회될 수 있으므로 위에서 만드는 메소드에는 기존 관계를 제거 로직이 포함되어 있어야 한다.

3, 4번은 연관관계를 최초로 등록할 때와 수정할 때 취해야 하는 방법이 다르므로 예시를 좀 더 추가한다. 먼저 3번 내용을 생각해보자.

 

연관관계를 최초 등록할 때는 주인이 아닌 엔티티를 먼저 생성하고 이후 단계는 3번 내용을 준수하기 위해 두 가지 방법을 선택적으로 취할 수 있다. 

주인 Entity를 별도로 save 해서 영속상태로 만들고 영속상태인 주인이 아닌 Entity 참조변수에 추가하는 방법과 주인 Entity는 일반 객체로만 만들고 영속상태인 주인이 아닌 Entity 참조변수에 추가하는 방법이다.

일반적으로 후자 방식을 사용하는데 이 때 주인 Entity 가 영속상태가 아니기 때문에 save가 실패하게 된다. 이때 주인이 아닌 Entity 에 cascade = [CascadeType.ALL] 옵션을 지정할 경우 연관된 Entity에도 영속성이 전이되어 함께 영속상태로 만들어주기 때문에 정상적으로 처리된다.

 

4번의 경우 구현을 해보면 알겠지만 주인이 아닌 Entity가 필수참여 관계라면 연관관계를 최초 등록할 때는 딱히 필요가 없다. 어차피 주인 Entity 생성자에 주인이 아닌 Entity 가 포함될 것이기 때문에 별도 메소드를 통해 관계를 연결할 일이 없기 때문이다.

다만 이미 존재하는 연관관계를 수정하는 경우에는 필요하다. 나도 최초 연관관계를 생성하는 과정에서는 딱히 필요없을 거 같다고 생각했다가 연관관계를 수정하는 경우에는 필요성을 느껴 구현해서 사용중이다.

 

이처럼 양방향 연관관계는 단방향 연관관계와 비교해서 신경쓸 것도 많고 복잡하기 때문에 연관관계 매핑 시에는 아래와 같은 순서를 따르는 것이 좋다.

  • 우선 단방향 연관관계 매핑을 사용한다.
  • 반대 방향으로 객체 그래프 탐색이 필요한 경우에만 양방향 연관관계 매핑을 고려하도록 한다.
  • 양방향 연관관계 매핑을 한다면 객체에서 양쪽 방향을 모두 괸리하고 동기화 하기 위한 노력이 필요하다.

다만 복잡한 코드는 추후 유지보수나 확장에도 문제가 되기 때문에 그냥 단방향 연관관계를 사용하는 게 낫다. 그렇다면 단방향 연관관계를 만들어 놓았는데 반대방향으로 참조가 필요한 경우는 어떻게 하나? 

 

예를 들어 맴버 엔티티와 팀 엔티티가 있다고 할 때 일반적으로 맴버->팀 방향을 가지는 단방향 연관관계를 만들 것이다. 그런데 개발을 하다보니 팀->맴버의 경우도 필요해진 것이다. 이 때는 기존에 정의된 엔티티말고 새로운 엔티티를 정의해서 읽어오면 된다. 어차피 querydsl 에서는 연관관계 없이도 조인이 가능하고 조회결과는 Projections 활용해서 받아오도록 한다.

 

하지만 이런 경우가 많지는 않을 것이다. 에초에 테이블 설계시 부모/자식 테이블이 있겠지만 그 중 주테이블은 설계단계에서 정해지고 그렇다는 것은 대부분 주테이블을 시작으로 하는 단방향 연관관계만 사용됨을 의미한다.

 

예를 들어 팀, 맴버 테이블이라면 대부분의 업무에서 맴버테이블이 주테이블이고 주문, 주문목록 테이블이라면 대부분의 업무에서 주문이 주테이블에 속한다. 이 때 이미 방향은 맴버->팀, 주문->주문목록으로 정해진다는 말이다.

 

여기에서 말하고자 하는 것은 연관관계를 매핑할때 무조건 외래키가 있는 테이블을 주테이블로 결정하지 말고 업무적 특성에 따라 주테이블을 선택해야 한다는 점이다. 대부분 자식 테이블이 주테이블 이겠지만 주문, 주문목록 테이블 처럼 부모테이블이 주테이블인 경우도 적지 않기 때문이다.

 

추가로 @OneToOne 양방향 관계에서는 지연로딩이 동작하지 않는 경우가 있다. 주인 Entity를 조회할 때는 지연로딩이 잘 동작하지만 상대  Entity를 조회할 때는 지연로딩 설정을 했더라도 즉시로딩으로 처리된다. 

 

이유는 연관관계 주인이 아닌 쪽에서는 외래키를 가지고 있지 않기 때문에 주인이 null 인지 아닌지 확인할 길이 없고 프록시는 null을 감쌀 수 없기 때문에 참조하고 있는 객체가 null인지 null이 아닌지 확인하는 쿼리를 우선 실행해야 하기 때문이다.

 

이를 해결하기 위해서는 구조를 단방향으로 변경하던지 OneToMany 관계로 변경해야 한다. 이렇게 되면 즉시로딩할게 없기 때문에 비효율도 없어진다. 또는 꼭 이 구조를 유지해야 한다면 select 를 두번 날리게 하기 보다 아예 fetch join 으로 읽어오게 하는 것도 방법이다.

 

참고로 fetch join 사용을 위해선 @Query 애너테이션을 이용해 JPQL를 사용하던지 QueryDsl 을 사용해야 한다.

 

https://velog.io/@conatuseus/연관관계-매핑-기초-1-i3k0xuve9i
https://velog.io/@conatuseus/연관관계-매핑-기초-2-양방향-연관관계와-연관관계의-주인
https://kapentaz.github.io/jpa/hibernate/@ManyToOne의-N+1-문제-원인-및-해결/#
https://coco-log.tistory.com/128
https://multifrontgarden.tistory.com/268
https://victorydntmd.tistory.com/208
https://ict-nroo.tistory.com/122
https://ict-nroo.tistory.com/126
https://wave1994.tistory.com/156
https://jyami.tistory.com/20
https://1-7171771.tistory.com/143
https://ict-nroo.tistory.com/125
https://velog.io/@devsh/JPA-연관-관계-매핑-OneToMany-ManyToOne-OneToOne-ManyToMany
https://junghyun100.github.io/JPA-양방향-매핑/
https://bloowhale.tistory.com/65
http://wonwoo.ml/index.php/post/1002

 

728x90