노트 정리/자바 ORM 표준 JPA 프로그래밍

[JPA이론] 6. 다양한 연관관계 매핑

DuL2 2022. 10. 13. 03:25

 

다양한 연관관계에 대한 정리.

다중성

  • @OneToOne
  • @OneToMany
  • @ManyToOne
  • @ManyToMany - 실무에서는 쓰지 않는게 좋다.

단방향, 양방향

테이블

  • 외래 키 하나로 양쪽 조인 가능
  • 사실 방향이라는 개념이 없음

객체

  • 참조용 필드가 있는 쪽으로만 참조 가능
  • 한쪽만 참조하면 단방향
  • 양쪽이 서로 참조하면 양방향 -> 단방향이 2개 있는 개념

연관관계의 주인

테이블은 외래 키 하나로 두 테이블이 연관관계를 맺는 반면 객체의 경우에는 서로가 연관관계를 맺어야함.

객체는 양방향 관계에서 단방향이 2개인 형상이고 이 둘 중 하나의 객체에서 주인(외래 키를 관리할 곳)을 정해야한다.

오로지 연관관계의 주인 만이 데이터에 대한 영향을 줄 수 있으며 주인의 반대편은 단순 조회만 가능하다.

 

다대일 - @ManyToOne

가장 많이 사용하는 연관관계로 양방향 관계라면 `@ManyToOne`를 사용하는 쪽이 보통 연관관계의 주인이 된다.

또한, `@JoinColumn` 어노테이션으로 테이블의 컬럼명을 연관관계 매핑한다.

 

cascade 옵션을 줄 수 있으며 EntityManager에서 persist시 cascade 옵션이 걸려있는 객체는 모두 전파된다.

 

일대다 단방향 - @OneToMany

일대다 단방향에서 일(1)인 쪽이 연관관계의 주인인 경우이다. 권장하지 않는 방법이지만 가능하다.

 

Team이 연관관계의 주인을 가져가면 Team.members의 값을 변경할 때 Member 테이블에 대한 쿼리가 나가야한다. 이는 테이블의 경우에는 다쪽이 무조건 외래키를 가져가기 때문이다. 

 

코드로 나타내면 다음과 같다.

 

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();

 

 만약, TEAM 객체의 memberList에 값을 add하면 테이블에서는 객체와 달리 Member 테이블에 대한 Update 쿼리가 하나 더 나가야한다. 이 부분에서 주인이 다대일에서 일대다 부분으로 바뀌었을 때 차이점이 생기게 된다.

 

 일대다를 주인으로 잡을 경우 코드 상으로는 Team에 대해 작성하고 있는 것 같지만 Member 테이블에 영향이 가므로 헷갈릴 수 있다. 특히 수십 개의 테이블을 사용하는 복잡한 실무에서는 더욱 확인이 어려울 수 있다.

 

 객체적으로 보았을 때 일대다에 주인이 있을 수 있지만 유지보수가 어려울 수 있으므로 다대일 일대다 양방향을 사용하여 좀 더 관계형 DB에 맞게 설계하는 것이 개발의 효율을 높일 수 있으므로 설계시 이 부분을 고려하는 것이 좋다.

 

주의 - @JoinColumn 사용하지 않으면 테이블 - 테이블 사이에 중간 테이블을 만들어 조인 테이블 방식(@JoinTable)을 사용하게 된다.

 

일대다 양방향 - @OneToMany

공식적인 방법은 아니고 설정을 통한 편법이다.

Team에서는 똑같이 작성하고 Member의 Team에 다음과 같이 어노테이션을 붙여주면 된다.

    @ManyToOne
    @JoinColumn(name = "TEAM_ID",insertable = false,updatable = false)
    private Team team;

다대일 양방향에서 일인 쪽이 읽기만 가능한 것처럼 만들어 주는 것이다.

 

-> 읽기 전용으로 만들어주는 것은 종종 필요한 경우가 존재한다.

 

일대일 - @OneToOne

일대일 방향에서는 외래 키의 위치가 자유롭다. 원하는 주 테이블에 외래 키를 놓고 테이블에서는 유니크 제약조건을 사용하면 된다.

 

양방향 매핑
@Entity
public class Member {
...
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
...
}
@Entity
public class Locker {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToOne(mappedBy = "locker")
    private Member member;
}

 

반대로 Locker가 주인을 가져갈 수도 있다.

 이 때 생각해봐야할 이슈가 생기는 데 만약 Member가 주인을 가지고 있다면 객체를 다루는 입장에서는 Member가 Locker를 가지고 있기 때문에 성능상의 이점과 개발 효율이 증가할 수 있는 반면 나중에 비즈니스가 여러 Member가 한개의 Locker를 사용할 수 있게끔 변경된다면 여러 코드를 변경해야하고 DBA 입장에서는 난감할 수 있는 상황이 발생한다.

 

 물론, 반대의 입장도 똑같이 득과 실이 존재하기 때문에 생각을 해보는 것이 좋다.

 

 Locker에 두는 경우에 JPA의 단점이 생기는데 프록시 기능의 한계로 LAZY로 설계하더라도 항상 EAGER 로딩이 된다. 풀어 설명하자면 프록시 객체를 만들기 위해서는 객체의 필드가 null인지 값이 있는지 알아야하는데 양방향일 경우 Member를 조회할 때 Locker의 값을 알기 위해서는 즉시 로딩을 통해 DB에 확인해야하는 문제점을 가지고 있다.

 

 

다대다 - @ManyToMany

관계형 데이터베이스는 다대다 관계를 정규화된 2개의 테이블로 구현이 불가능하다.

그러므로 중간에 연결 테이블을 만들어 `다대일 - 일대다 : 다대일 - 일대다` 구조로 해결해야한다.

 

 

하지만, 객체는 컬렉션을 사용하여 다대다가 가능하다.

 

단방향일 때는 Member에만 만들어주면 되고 양방향일 때는 똑같이 mappedBy 옵션을 사용해주면 된다.

양방향 매핑
@Entity
public class Member {
...
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT")
    private List<Product> products = new ArrayList<>();
...
}
@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;
    private String name;
   
    @ManyToMany(mappedBy = "products")
    private List<Member> members = new ArrayList<>();
}

 

 다대다 매핑은 편리해보이지만 치명적인 한계를 가지고 있는데 다대다 매핑으로 인해 만들어지는 중간 테이블(여기서는 MEMBER_PRODUCT)에 매핑을 위한 데이터 말고는 다른 컬럼 사용이 불가능하다. 예를 들어 주문 시간이나 수량과 같은 정보를 추가할 수 없다는 것이다. 또한 중간 테이블이 숨겨져 있기 때문에 생각지 못한 쿼리가 나갈 가능성이 농후하다.

 

 다대다를 해결하기 위해서 결국 다대일 : 일대다 - 다대일 : 일대다로 풀어 내면 되는데 중간 연결 테이블(MEMBER_PRODUCT)을 Entity로 승격시켜 문제를 해결할 수 있다.

 

다대일 : 일대다 - 다대일 : 일대다
@Entity
public class Member {
...
    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
...
}
@Entity
public class MemberProduct {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
}
@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;
    private String name;
   
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

 이렇게 되면 연결 테이블에서는 불가능했던 정보들을 MemberProduct 엔티티에 담아 처리할 수 있다. 또한 MemberProduct라는 이름은 다른 이름 예를 들어 Orders 등의 의미있는 테이블 이름으로 변경하여 사용하게 될 것이다.