6장. 다양한 연관관계 매핑

배유연·2023년 10월 24일

1. 다대일

🔍 다대일 단방향(N:1)


회원은 Member.team으로 팀 엔티티를 참조할 수 있지만, 반대로 팀에는 회원을 참조하는 필드가 없다. 따라서 다대일 단방향 연관관계이다.

@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;

@JoinColumn(name = "TEAM_ID") 을 통해 TEAM_ID 외래키와 매핑하였다.

🔍 다대일 양방향(N:1)

  • 객체 연관관계에서 실선이 연관관계의 주인이고 점선은 연관관계의 주인이 아니다.
  • 양방향 관계에서 연관관계의 주인은 항상 다쪽이다.(외래키를 갖고있는 쪽)
  • 주인이 아닌 Team.members 는 조회를 위한 JPQL이나 객체 그래프 탐색을 할 때 사용한다.

2. 일대다

🔍 일대다 단방향(1:N)

  • 일대다 단방향 관계는 JPA 2.0부터 지원한다.

  • 팀 엔티티에서만 Member를 관리하는 참조필드가 있다.
@Entity
public class Team{
  ...
  @OneToMany
  @JoinColumn(name= "TEAM_ID") // MEMBER 테이블의 TEAM_ID
  private List<Member> members = new ArrayList<Member>();
  ...
}

일대다 단방향 관계를 매핑할 때는 @JoinColumn 을 명시해야한다. 그렇지 않으면 JPA는 연관관계를 관리하는 조인테이블 전략을 기본으로 사용해서 매핑한다.

일대다 단방향 매핑의 단점
단점은, 매핑한 객체가 관리하는 외래키가 다른 테이블에 있다는 것이다. 이렇게 되면, INSERT 할 때 연관관계를 처리하기 위한 UPDATE 문을 추가로 실행해야한다.

private static void testSave(EntityManager em) {


     Member member1 = new Member("member1", "회원1");
     Member member2 = new Member("member2", "회원2");

     Team team1 = new Team("team1", "팀1");
     team1.getMembers().add(member1);
     team1.getMembers().add(member2);
     
     em.persist(member1); //INSERT member1
     em.persist(member2); //INSERT member2
     em.persist(team1);	//INSERT-team1, UPDATE-member1.fk
     					//UPDATE-member2.fk
}

그러므로 일대다 단방향 보다는 다대일 양방향 매핑을 사용하자.

🔍 일대다 양방향(1:N,N:1)

일대다 양방향 매핑은 존재하지 않는다. 대신 다대일 양방향 매핑을 사용해야한다.
(방법이 있긴한데.. Member에 읽기 전용으로 다대일 단방향 매핑을 추가하면 된다.)

@Entity
public class Member{
	...
    @ManyToOne
    @JoinColumn(name="TEAM_ID", insertable = false, updatable = false)
    private Team team;
    ...
}

양쪽 다 같은 키를 관리하면 문제가 발생하므로 읽기만 가능하도록 설정해두었다.
얘도 마찬가지로 일대다 단방향의 단점을 그대로 가지고 있다. 다대일 양방향 매핑을 사용하자!


3. 일대일

일대일 관계는 주 테이블이나 대상 테이블 둘 중 어느곳이나 외래키를 가질 수 있다.

주 테이블에 외래키
주 테이블에 외래키를 두고 대상테이블을 참조한다. 외래 키를 객체 참조와 비슷하게 사용할 수 있어서 객체지향 개발자들이 선호한다.

대상 테이블에 외래키
전통적인 데이터베이스 개발자들이선호하는 방식이다. 장점은, 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다.

🔍 주 테이블에 외래 키

단방향

회원과 사물함의 일대일 단방향 관계를 나태낸 그림이다. MEMBER가 주테이블이고 LOCKER 는 대상 테이블 이다.

@Entity
public class Member2 {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
	...
}
@Entity
public class Locker {

    @Id
    @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;

    private String name;
	...
}

객체 매핑에 @OneToOne을 사용했고, 데이터베이스는 LOCKER_ID 외래 키에 유니크 제약조건을 추가했다.

양방향

@Entity
public class Member2 {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
	...
}
@Entity
public class Locker {

   @Id
   @GeneratedValue
   @Column(name = "LOCKER_ID")
   private Long id;

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

Member가 외래키를 갖고 있으므로 연관관계의 주인이다. 따라서 Lcoker.member 에 mappedBy 를 선언해 연관관계의 주인이 아니라고 설정했다.

🔍 대상 테이블에 외래 키

단방향

일대일 관계 중 대상테이블에 외래키가 있는 단방향관계는 JPA에서 지원하지 않는다.

그림으로 봐도 매핑할 수 있는 방법이 없다.

양방향

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne(mappedBy = "member")
    private Locker locker;
	...
}
@Entity
public class Locker {

   @Id
   @GeneratedValue
   @Column(name = "LOCKER_ID")
   private Long id;

   private String name;
   
   @OneToOne
   @JoinColumn(name="MEMBER_ID")
   private Member member;
	...
}

주 엔티티인 Member 엔티티 대신에 대상 엔티티인 Locker를 연관관계의 주인으로 만들어서 LOCKER 테이블의 외래키를 관리하도록 했다.


4. 다대다(N:N)

관계형 데이터베이스는 다대다 관계를 2개의 테이블로 표현할 수 없다. 그래서 이것을 풀어내는 연결테이블을 사용한다.
객체는 테이블과 다르게 객체 2개로 다대다 관계를 만들 수 있다.

객체 연관관계

테이블 연관관계

🔍 N:N 단방향

회원과 상품 엔티티 코드를 보자.

@Entity
public class Member{
	@Id @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT"
    			, joinColumns = @JoinColumn(name="MEMBER_ID")
                , inverseJoinColumns = @JoinColumn(name="PRODUCT_ID""))
    private List<Product> products = new ArrayList<Product>();
    ...
}  

@Entity
public class Product{
	@Id @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    ...
}

@JoinTable을 통해서 연결 테이블을 바로 매핑하였다.

@JoinTable 속성

  • @JoinTable.name : 연결테이블을 지정한다.
  • @JoinTable.joinColumns : 현재 방향인 회원과 매핑할 조인 컬럼 정보를 지정한다.
  • @JoinTable.inverseJoinColumns : 반대 방향인 상품과 매핑할 조인 컬럼 정보를 지정한다.

다대다 단방향 저장 처리

    public static void save(EntityManager em) {
        Product product = new Product();
        product.setName("상품A");
        em.persist(product);

        Member4 member4 = new Member4();
        member4.setUsername("회원4");

        //1번째 방법
        member4.getProducts().add(product);
        
        em.persist(member4);
    }

위 로직을 실행하면

INSERT INTO PRODUCT...
INSERT INTO MEMBER...
INSERT INTO MEMBER_PRODUCT...

위 쿼리가 실행된다.

다대다 단방향 조회 처리

	public static void find() {
        Member findMember = em.find(Member.class, memberId);
        
        //객체 그래프 탐색
        List<Product> findProducts = findMember.getProducts();
        for (Product item : findProducts) {
            System.out.println("itemName : " + item.getName());
        }
    }

member.getProducts()를 호출해서 상품 이름을 출력하면 다음 SQL이 실행된다.

	select
        products0_.MEMBER_ID as MEMBER_I1_25_0_,
        products0_.PRODUCT_ID as PRODUCT_2_25_0_,
        product1_.PRODUCT_ID as PRODUCT_1_34_1_,
        product1_.name as name2_34_1_ 
    from
        MEMBER_PRODUCT products0_ 
    inner join
        Product product1_ 
            on products0_.PRODUCT_ID=product1_.PRODUCT_ID 
    where
        products0_.MEMBER_ID=?
        ```

🔍 N:N 양방향

N:N 양방향도 마찬가지로 @ManyToMany 를 사용하면 된다. 그리고 연관관계의 주인이 아닌 곳에 mappedBy 를 지정한다.
코드를 보자.

@Entity
public class Member{
	@Id @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT"
    			, joinColumns = @JoinColumn(name="MEMBER_ID")
                , inverseJoinColumns = @JoinColumn(name="PRODUCT_ID""))
    private List<Product> products = new ArrayList<Product>();
    ...
}  

@Entity
public class Product{
	@Id @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    
    @ManyToMany(mappedBy = "products") // 역방향 추가
    private List<Member> members;
    ...
}

Member 필드는 바꿀게 없고, Product.members 를 추가하여 양방향 관계를 설정한다.
그리고 편의를 위해 Member엔티티에 연관관계 편의 메소드를 추가하자.

public void addProduct(Product product)
	...
    products.add(product);
    product.getMembers().add(this);
}    

양방향 연관관계를 설정하였으므로 product.getMember()를 사용해서 역방향으로 객체그래프를 탐색할 수 있다.

public static void findInverse(EntityManager em) {
        Product product = em.find(Product.class, 1L);
        List<Member4> members = product.getMembers();
        for (Member4 entityMember : members) {
            System.out.println("entityMember = " + entityMember.getUsername());
        }
    }

🔍 N:N 매핑의 한계와 극복, 연결 엔티티 사용

위에서 사용했던 @ManyToMany 는 실무에서 사용하기에 한계가 있다. 왜냐하면, 보통은 연결테이블에 추가 컬럼이 필요하다.(예를들어서, 주문날짜/주문수량 같은 것...)

엔티티를 수정해보자.

1. Member 엔티티 수정

@Entity
public class Member{
	@Id @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @OneToMany
    private List<MemberProduct> memberProducts;
    ...
}

이제 Member는 연관관계의 주인이 아니다.

2. Product 엔티티 수정

@Entity
public class Product{
	@Id @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
	...    
}

Product(상품코드) 엔티티에서 회원상품 엔티티로 객체그래프 탐색이 필요하지 않아 연관관계를 만들지 않았다.

3. MemberProduct 엔티티 추가

@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {

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

    @Id
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private int orderAmount;

    ...
}

public class MemberProductId implements Serializable {

    private String member;
    private String product;
    
    //@Override equals, hashcode...
    ...
}

MemberProduct (회원상품)엔티티를 보면 기본 키를 매핑하는 @Id와 외래키를 매핑하는 @JoinColumn 을 동시에 사용해서 기본키+외래키를 한번에 매핑했다.

복합 기본 키

JPA에서 복합키를 사용하려면 별도의 식별자 클래스를 만들어야 한다.
그리고 엔티티에 @IdClass 를 사용해서 식별자 클래스를 지정하면 된다.

복합키를 위한 식별자 클래스의 특징

  • 복합 키는 별도의 식별자 클래스로 만들어야한다.
  • Seializable을 구현해야한다.
  • equals 와 hashCode 메소드를 구현해야한다.
  • 기본 생성자가 있어야한다.
  • 식별자 클래스는 public 이어야한다.
  • @IdClass 를 사용하는 방법 외에 @EmbeddedId를 사용하는 방법도 있다.

식별 관계

부모테이블의 기본키를 받아서 자신의 기본키+외래키로 사용하는 것을 식별관계라고 한다.
위 예에서는 회웡상품에서 회원(부모)의 기본키를 받아서 자신의 기본키로 사용함과 동시에 외래키로 사용한다. 상품테이블과도 마찬가지다.
따라서 식별관계라고 할 수 있다.

저장 코드

public static void save(EntityManager em) {
	// 회원 저장
    Member643 member = new Member643();
    member.setId("member1");
    member.setUsername("회원1");
    em.persist(member);

    //상품 저장
    Product643 product = new Product643();
    product.setId("productA");
    product.setName("상품A");
    em.persist(product);

    //회원 상품 저장
    MemberProduct memberProduct = new MemberProduct();
    memberProduct.setMember(member);
    memberProduct.setProduct(product);
    memberProduct.setOrderAmount(10000);

    em.persist(memberProduct);
}

회원상품 엔티티는 연관된 회원의 식별자와 상품의 식별자를 가져와서 자신의 기본키 값으로 사용한다.

조회코드

public static void findByMemberProductId(EntityManager em) {
	// 기본 키 값 생성
    MemberProductId memberProductId = new MemberProductId();
    memberProductId.setMember("member1");
    memberProductId.setProduct("productA");

    MemberProduct memberProduct = em.find(MemberProduct.class, memberProductId);

    Member643 member = memberProduct.getMember();
    Product643 product = memberProduct.getProduct();

    System.out.println("member = " + member.getUsername());
    System.out.println("product = " + product.getName());
    System.out.println("order amount = " + memberProduct.getOrderAmount());
}

복합키는 항상 식별자 클래스를 만들고, 생성한 식별자 클래스로 엔티티를 조회한다.
이렇게 복합키를 사용하는 방식은 복잡하다.
별도의 식별자 클래스생성과 @IdClass 또는 @EmbeddedId도 사용해야한다. 또한 equals, hashCode 도 구현해야한다.

🔍 N:N 새로운 기본키 사용

N:N관계에서 추천하는 기본키 생성전략은 데이터베이스에서 자동으로 생성해주는 대리키를 Long값으로 사용하는 것이다.
장점은? 영구히 쓸 수 있으며, 비즈니스에 의존하지 않는다.

테이블 명을 MEMBER_PRODUCT 에서 ORDER로 변경하였다.

@Entity
@Table(name = "Orders")
public class Order {

    @Id
    @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

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

    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product644 product;

    private int orderAmount;
    ...
}

ORDER_ID라는 새로운 기본키를 하나 만들고 MEMBER_ID, PRODUCT_ID 는 외래키로만 사용한다.

저장시에는?

	...
    Order order = new Order();
    order.setMember(member);
    order.setProduct(product);
    order.setOrderAmount(10000);

    em.persist(order);

조회시에는?

	...
	Long orderId = 1L;

	Order order = em.find(Order.class, orderId);
    Member644 member = order.getMember();
	Product644 product = order.getProduct();

얼마나 간단한가!
이처럼 새로운 기본키를 사용해서 다대다 관계를 풀어내는 것도 좋은 방법이다.

식별 관계 : 받아온 식별자 기본키+외래키
비식별관계 : 받아온 식별자는 외래키로만 사용하고, 새로운 식별자를 추가한다.

profile
기초를 중요시하는 백엔드개발자입니당

0개의 댓글