서비스를 만들기 위해 데이터 베이스를 설계하다보면, PK를 하나로만 지정하지 않고 여러개를 지정하거나, N:M관계를 풀어내기 위해서 중간 테이블을 만든 경우에는 양쪽 테이블의 두 PK 값을 중간테이블의 복합키로 사용하기도 합니다.
이럴 때에는 JPA에서 간단하게 Id 어노테이션만 붙여서 매핑을 할 수가 없는데요, 이런 상황에서 복합키를 매핑하는 방법에 대해서 알아보겠습니다.
매핑 방법을 알아내기 위해 Hibernate 공식문서를 참고했습니다.
💡하이버네이트 공식문서
(https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#identifiers)
JPA를 구현한 Hibernate의 공식문서에서는 다음과 같이 Identifier의 조건을 설명하고 있습니다.
Identifiers model the primary key of an entity. They are used to uniquely identify each specific entity.
Identifier는 엔티티의 고유키로 사용되며, 그들은 고유한 엔티티를 식별해 낼 수 있습니다.
엔티티 Identifier의 특징으로는 3가지가 존재합니다.
UNIQUE
The values must uniquely identify each row.
특정한 행에서만 고유하게 identifier의 값이 존재해야합니다.
NOT NULL
The values cannot be null. For composite ids, no part can be null.
Null값이 와서는 안됩니다. 만약 복합키라면 내부의 어떤 값도 null이 되서는 안됩니다.
IMMUTABLE
The values, once inserted, can never be changed. In cases where the values for the PK you have chosen will be updated, Hibernate recommends mapping the mutable value as a natural id, and use a surrogate id for the PK. See Natural Ids.
identifier의 값이 한번 삽입되었으면 해당 값은 절대 바뀌어서는 안됩니다. 만약 선택한 PK값이 업데이트 되는 경우 , 하이버네이트는 Hibernate에서는 변환 가능한 값을 natural ID로 매핑하고 PK에 대해 surrogate ID를 사용하는 것이 좋습니다.
하이버네이트에서는 PK를 복합키로 정의할 때, 하나의 클래스로 복합키를 나타내 주어야 합니다. 이러한 방식에는 @IdClass 를 이용하는 방식과 @Embeddable 어노테이션을 이용하는 방식 2가지가 존재합니다.
하이버네이트 공식문서
Modeling a composite identifier using an EmbeddedId simply means defining an embeddable to be a composition for the attributes making up the identifier, and then exposing an attribute of that embeddable type on the entity.
아래는 하이버네이트 공식문서에서 복합키 설정과 동시에 식별관계로 SystemUser가 Subsystem을 다 대 일 관계로 매핑 되어 있는 상황을 예시로 보여주고 있는 예제 코드입니다.
@Entity(name = "SystemUser")
public static class SystemUser {
@EmbeddedId
private PK pk;
private String name;
//Getters and setters are omitted for brevity
}
@Entity(name = "Subsystem")
public static class Subsystem {
@Id
private String id;
private String description;
//Getters and setters are omitted for brevity
}
@Embeddable
public static class PK implements Serializable {
@ManyToOne(fetch = FetchType.LAZY)
private Subsystem subsystem;
private String username;
public PK(Subsystem subsystem, String username) {
this.subsystem = subsystem;
this.username = username;
}
private PK() {
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PK pk = (PK) o;
return Objects.equals(subsystem, pk.subsystem) &&
Objects.equals(username, pk.username);
}
@Override
public int hashCode() {
return Objects.hash(subsystem, username);
}
}
복합키 설정을 위해서는 다음과 같은 조건을 만족시키면서 엔티티 작성을 해야합니다.
The composite identifier must be represented by a "primary key class". The primary key class may be defined using the jakarta.persistence.EmbeddedId annotation (see Composite identifiers with @EmbeddedId), or defined using the jakarta.persistence.IdClass annotation (see Composite identifiers with @IdClass).
복합키 identifier는 primary key class로 대표되어야 하며 (composite identifier 클래스는 pk를 위한 클래스이어야 하며) 해당 클래스를 엔티티에서 사용할 때에는 @EmbeddedId 어노테이션을 사용하거나 @Idclass 어노테이션을 사용해서 명시해주어야 합니다.
The primary key class must be public and must have a public no-arg constructor.
PK용 클래스에서는 무조건 public 으로 인자가 없는 생성자가 존재해야 합니다.
The primary key class must be serializable.
PK용 클래스는 무조건 serializable 해야합니다.
💡정확히 무슨 뜻인지는 모르겠지만, serializable 인터페이스혹은 해당 인터페이스를 상속받은 클래스를 상속 받으면 해결이 됩니다.
The primary key class must define equals and hashCode methods, consistent with equality for the underlying database types to which the primary key is mapped.
PK용 클래스에서는 equals 와 hashCode 메서드가 꼭 정의되어 있어야 합니다. 또한 단일키가 동일성을 가지고 판단하면서 매핑되듯이, 해당 메서드 들은 단일 PK원칙과 동일한 동일성을 가져야 합니다.
영속성 컨텍스트에서 동일성을 판단할 때, equals와 hashCode가 쓰이기 때문에 해당 메서드는 PK 인스턴스의 유일성을 증명해낼 수 있어야 합니다.
💡 식별관계 매핑
위의 코드와 같이 1~4 복합키 조건에 맞춘 엔티티와 PK클래스를 작성했다면, PK클래스 내부에도 DB 모델링용 어노테이션(ex. @ManyToOne)을 사용할 수 있습니다.