본문 바로가기

WEB/Spring

[JPA] 7장 복합 키와 식별 관계 매핑

복합 키와 식별 관계 매핑

데이터베이스 테이블 사이 관계는 외래 키가 기본 키에 포함되는지 여부에 따라 식별 관계와 비식별 관계로 구분한다.

식별 관계 (Identifyng Relationship)

부모 테이블의 기본 키를 내려받아서 자식 테이블의 기본 키 + 외래 키로 사용하는 관계

비식별 관계(Non-Identifyng Relationship)

부모 테이블의 기본 키를 받아서 자식 테이블의 외래 키로만 사용하는 관계

그리고 외래 키에 NULL 을 허용하는지에 따라 필수적 비식별 관계와 선택적 비식별 관계로 나눈다.

  • 필수적 비식별 관계(Mandatory)
  • 외래 키에 NULL을 허용하지 않는다. 연관관계를 필수적으로 맺어야 한다.
  • 선택적 비식별 관계(Optional)
  • 외래 키에 NULL을 허용한다. 연관관계를 맺을지 말지 선택할 수 있다.

데이터베이스 테이블 설계 시 식별 관계나 비식별 관계 중 하나를 선택해야 한다. 최근에는 비식별 관계를 주로 사용하고, 꼭 필요한 곳에만 식별 관계를 사용하는 추세다. JPA는 두 관계를 모두 지원한다.

  • 식별 관계는 부모 테이블의 기본키를 자식 테이블로 전파하면서 자식 테이블의 기본키 컬럼이 점점 늘어난다. 예를 들어 부모 테이블은 기본 키 컬럼이 하나 였지만 자식 컬럼은 2개, 손자는 3개 결국 쿼리는 복잡해지고 기본 키 인덱스가 불필요하게 늘어날 수 있다.
  • 식별 관계는 2개 이상의 컬럼을 합해서 복합 기본키로 만들어야 하는 경우가 많다.
  • 식별 관계를 사용할 때 기본키로 비지니스 의미가 있는 자연 키 컬럼을 조합하는 경우가 많다. 반면에 비식별 관계의 기본키는 비즈니스와 전혀 관계없는 대리키를 주로 이용한다. 비즈니스 요구사항은 시간이 지남에 따라 언젠가 변하기에 식별 관계의 자연 키 컬럼들은 변경이 어려워진다.
  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블의 기본 키로 사용하므로 비식별 관계보다 테이블 구조가 유연하지 못하다.
  • 일대일 관계를 제외하고 식별 관계는 2개 이상의 컬럼을 묶은 복합 기본키를 사용한다. JPA 에서 복합 기본키는 별도의 복합키 클래스를 만들어 관리해야 한다. 따라서 컬럼이 하나인 기본키를 매핑하는 것보다 많은 노력이 필요하다.

비식별 관계 매핑

  1. 기본키를 구성하는 컬럼 1개

그냥 @Id private String id;

  1. 기본키를 2개 이상

별도의 식별자 클래스를 만들어야 한다.

JPA는 복합 키를 지원 → 2가지 방법 :

  • @IdClass = 관계형 데이터베이스에 가까운 방법
    @Entity
    @IdClass(ParentId.class)
    public class Parent{
        @Id
        @Column(name = "PARENT_ID1")
        private String id1; // ParentId.id1과 연결
        @Id
        @Column(name = "PARENT_ID2")
        private String id2; // ParentId.id2와 연결
        private String name;
        ...
    }
    
    • 조건
      1. 식별자 클래스의 속성명과 엔티티에서 사용하는 식별자의 속성명이 같아야 한다.
      • Parent.id1 과 ParentId.id1, Parent.id2와 ParentId.id2 가 같다.
      2. Serializable 인터페이스를 구현해야 한다.영속성 컨텍스트에서 엔티티를 보관할 때 식별자를 통해 구분할 때 이를 통해 동등성 비교를 한다.5. 식별자 클래스는 public이어야 한다.
    • 4. 기본 생성자가 있어야 한다.
    • 3. equals, hashCode를 구현해야 한다.
    // 엔티티 저장
    Parent parent = new Parent();
    parent.setId1("myId1"); // 식별자
    parent.setId2("myId2"); // 식별자
    parent.setName("parentName");
    em.persist(parent);
    // 엔티티 조회
    ParentId parentId = new ParentId("myId1", "myId2");
    Parent parent = em.find(Parent.class, parentId);
    
    • ParentId가 보이지 않음 → em.persist() 를 호출하면 영속성 컨텍스트에 엔티티를 등록하기 직전에 내부에서 parent.Id1, parent.Id2를 가지고 ParentId를 생성하고 영속성 컨텍스트의 키로 사용함
  • public class ParentId implements Serializable { private String id1; // Parent.id1 매핑 private String id2; // Parent.id2 매핑 public ParentId(){ } public ParentId(String id1, String id2){ this.id1 = id1; this.id2 = id2; } @Override public boolean equals(Object o) {...} @Override public int hashCode() {...} }
  • @EmbeddedId = 객체지향에 가까운 방법
    • 기본 키를 직접 매핑한다.
    @Embeddable
    public class ParentId implements Serializable {
        @Column(name = "PARENT_ID1")
        private String id1;
        @Column(name = "PARENT_ID2")
        private String id2;
        // equals and hashCode 구현
        ...
    }
    
    @Entity
    public class Parent {
        @EmbeddedId
        private ParentId id;
        private String name;
        ...
    }
    
    • 조건2. Serializable 인터페이스를 구현해야 한다.4. 기본 생성자가 있어야 한다.
    • 5. 식별자 클래스는 public이어야 한다.
    • 3. equals, hashCode를 구현해야 한다.
    • 1. @Embeddable 어노테이션을 붙여줘야 한다.
    • 사용
    • // 엔티티 저장 Parent parent =new Parent(); ParentId parentId =new ParentId("myId1", "myId2"); parent.setId(parentId); parent.setName("parentName"); em.persist(parent); // 엔티티 조회 ParentId parentId =new ParentId("myId1", "myId2"); Parent parent = em.find(Parent.class, parentId);

복합 키와 equals(), hashCode()

“복합 키는 equals() 와 hashCode()를 필수로 구현해야 한다.”

좀 더 자세히,,,

일단, 서로 다른 new를 통하여 생성한 식별자 클래스 인스턴스라도 식별자 클래스 인스턴스 내부의 값들이 모두 같으면 같은 키이다.

하지만 equals() 메소드와 hashCode()메소드를 재정의하지 않으면 같은 키이지만 다르다고 판단하게 된다. 차근차근 살펴보자.

먼저, [equals]에 대해서 살펴보자

  • 모든 클래스는 기본으로 Object 클래스를 상속 받는데 Object의 equals의 경우 단순히 자기 자신과 같은 지를 비교 → 물리적으로 다른 인스턴스이지만 논리적 동치성(같은 값)일 경우 false를 반환
public boolean equals(Object obj) {
    return (this == obj); //참조값 비교 (동일성 비교)
}
    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2); // 논리적으로 같은 값

        System.out.println(p1.equals(p2)); // false -> 분명 같은 키이인데 다른 것으로 판단함
        System.out.println(p2.equals(p1)); // false
    }
  • 이를 “재정의”를 통해 true로 반환할 수 있도록 함(같은 키를 가진 것은 같은 것이다 라는 것을 적용함)
static class Point {
    public final int x;
    public final int y;

      public Point(int x, int y) {
          this.x = x;
          this.y = y;
      }

      @Override
      public boolean equals(Object o) {
          if (o == this) return true; // 1
          if (!(o instanceof Point)) return false; // 2
          Point p = (Point) o; // 3

          return p.x == this.x && p.y == this.y; 
      }
}

⇒ eqauls는 두 객체의 논리적 동치성을 비교하기 위해 사용가능해짐

  • 영속성 컨텍스트는 엔티티의 식별자를 키로 사용해서 엔티티를 관리한다.
    • Map 객체로 저장 : 엔티티를 식별자 값(@Id 맵핑)으로 구분한다. Key-value로 관리하는데 이때 key 값이 @Id 값이 된다.
  • 식별자를 비교할 때 equals()와 hashCode()를 사용한다. → equals를 제대로 재정의 했다면 반드시 hashCode도 재정의 해야 한다.

→ 왜?

hash 값을 사용하는 Collection(HashMap, HashSet, HashTable)은 객체가 논리적으로 같은지 비교할 때 아래 그림과 같은 과정을 거친다.

hashCode 메서드의 리턴 값이 우선 일치하고 equals 메서드의 리턴 값이 true여야 논리적으로 같은 객체라고 판단한다.

Object의 hashCode() 메소드는 객체의 메모리 번지를 이용해서 해시코드를 만들어 리턴하기 때문에 객체 마다 다른 값을 가지고 있다.

hashcode()를 재정의 하지 않으면 같은 값 객체라도 해시값이 다를 수 있다. 따라서 HashTable에서 해당 객체가 저장된 버킷을 찾을 수 없다.

반대로 equals()를 재정의하지 않으면 hashcode()가 만든 해시값을 이용해 객체가 저장된 버킷을 찾을 수는 있지만 해당 객체가 자신과 같은 객체인지 값을 비교할 수 없기 때문에 null을 리턴하게 된다. 따라서 역시 원하는 객체를 찾을 수 없다.

이러한 이유로 객체의 정확한 동등 비교를 위해서는 Object의 equals() 메소드만 재정의하지 말고 hashCode()메소드도 재정의해서 논리적 동등 객체일경우 동일한 해시코드가 리턴되도록 해야한다.

재정의했다고 해도  Map의 key로 두 인스턴스를 사용했을 때 서로 다르다는 결과가 나온다.

    public static void main(String[] args) {
        Map<Point, String> map = new HashMap<>();

        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);
        System.out.println(p1.equals(p2));

        map.put(p1, "test");
        System.out.println(map.get(p2)); // null 이 출력됨, 기대한 것은 test임 
    }

hashCode를 재정의하고 map을 조회하면 동일한 key값으로 판정되어 값을 받아올 수 있다.

    static class Point {
        public final int x;
        public final int y;

          public Point(int x, int y) {
              this.x = x;
              this.y = y;
          }

          @Override
          public boolean equals(Object o) {
              if (o == this) return true;
              if (!(o instanceof Point)) return false;
              Point p = (Point) o;

              return p.x == this.x && p.y == this.y;
          }

          @Override
          public int hashCode() {
              return Objects.hash(x, y); //간단하게 재정의
          }

    }
  • 그럼 복합키가 아닐경우는 ?
    • “복합 키가 아닌 Long 같은 일반 타입들은 내부에 equals, hashCode가 이미 구현되어서 값이 같으면 같음을 보장합니다.”

@IdClass vs @EmbeddedId

각각 장단점이 있으므로 본인의 취향에 맞는 것을 일관성 있게 사용하면 된다.

@EmbeddedId 가 @IdClass와 비교해서 더 객체지향적이고 중복도 없어서 좋아보이긴 하지만 특정 상황에 JPQL이 조금 더 길어질 수 있다.

em.createQuery("select p.id.id1, p.id.id2 from Parent p"); // @EmbeddedId
em.createQuery("select p.id1, p.id2 from Parent p"); // @IdClass

복합 키에는 @GeneratedValue 를 사용할 수 없다. 복합 키를 구성하는 여러 컬럼 중 하나에도 사용할 수 없다.


 


레퍼런스

https://velog.io/@dhk22/Java-Effective-Java-Equals-And-HashCode-제대로-알고-쓰자

'WEB > Spring' 카테고리의 다른 글

[JPA] 2장 JPA 시작  (0) 2023.05.22
[Spring Security] 스프링 시큐리티란?  (0) 2023.03.08
[JPA] JPA와 JPA의 필요성  (0) 2023.02.26
빈 생명주기 콜백  (1) 2022.09.11
같은 타입인 여러 개의 빈들을 조회하고 싶을 때  (1) 2022.09.10