JPA Entity 복합키 매핑하기
Map Composite Primary keys in JPA
오래된 Legacy DB를 마주하게 되면 의도조차 불분명한 수없이 많은 복합 키와 컬럼들이 등장합니다. 이런 Legacy DB에 JPA를 적용하는 과정에서 가장 걸림돌이 되는 부분이 복합 키 매핑이었습니다.
이번 포스팅에서는 @EmbeddedId
를 통해 DB 테이블의 복합 키를 JPA Entity에 매핑하여 사용하는 방법에 대해 알아보겠습니다.
MySQL 데이터베이스를 기반으로 하고 있으며, 코드 가독성과 빠른 테스트를 위해 Lombok 라이브러리를 적극적으로 활용합니다.
Prerequisite
데이터베이스에 복합 키로 구성된 테이블을 생성합니다. Key 타입은 Legacy DB에서 자주 보이는 varchar
타입으로 지정하고 테이블의 관계는 식별 관계로 설정하였습니다.
-- SQL Schema
create table GRANDPARENTS
(
grandparent_id varchar(255) not null
primary key,
name varchar(255) null
);
create table PARENTS
(
parent_id varchar(255) not null,
grandparent_id varchar(255) not null,
name varchar(255) null,
primary key (grandparent_id, parent_id),
constraint PARENTS_GRANDPARENTS_grandparent_id_fk
foreign key (grandparent_id) references GRANDPARENTS(grandparent_id)
);
create table CHILDREN
(
child_id varchar(255) not null,
parent_id varchar(255) not null,
grandparent_id varchar(255) not null,
name varchar(255) null,
primary key (grandparent_id, child_id, parent_id),
constraint CHILDREN_PARENTS_grandparent_id_parent_id_fk
foreign key (grandparent_id, parent_id) references PARENTS(grandparent_id, parent_id)
);
데이터베이스가 준비되었다면, Spring Boot 이니셜라이저를 통해 새로운 프로젝트를 생성합니다.
- Java 17
- Spring Boot 3.1.5
- Spring Data JPA
- Gradle 8
- MySQL
- Lombok
프로젝트 초기화가 완료되면 application.yml
에 데이터베이스 커넥션 정보 및 JPA 관련 설정을 구성합니다.
# application.yml
spring:
profiles:
active: local
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/jpa
username: catsriding
password: catsriding
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQLDialect
open-in-view: false
generate-ddl: false
properties:
hibernate:
default_batch_fetch_size: 1000
format_sql: true
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
ddl-auto: validate
logging:
level:
org:
hibernate:
SQL: debug
type:
descriptor:
sql: trace
애플리케이션을 실행하여 정상적으로 구동되는 지 확인합니다.
Implement a Composite Key
DB 테이블과 JPA 엔티티를 하나씩 매핑해보겠습니다. 가장 먼저, 단일 기본 키를 가진 GRANDPARENTS
테이블을 매핑합니다.
// Grandparent.java
@Slf4j
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "GRANDPARENTS")
public class Grandparent {
@Id
@Column(name = "grandparent_id", nullable = false)
private String grandparentId;
@Column(name = "name")
private String name;
}
그리고 다음은 부모 테이블의 기본 키를 포함한 복합 키를 가진 PARENTS
테이블을 Entity로 매핑합니다.
복합 키를 매핑하기 위해서는 식별자 클래스가 별도로 필요합니다. Entity에서 식별자 클래스를 임베드 할 수 있도록 @Embeddable
어노테이션을 선언하고, 테이블의 복합 키와 매칭되는 필드에 각각 @EmbeddedId
어노테이션을 추가합니다.
// ParentId.java
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class ParentId implements Serializable {
private static final long serialVersionUID = -5915679009448533476L;
@Column(name = "parent_id", nullable = false)
private String parentId;
@Column(name = "grandparent_id", nullable = false)
private String grandparentId;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
ParentId entity = (ParentId) o;
return Objects.equals(this.grandparentId, entity.grandparentId) &&
Objects.equals(this.parentId, entity.parentId);
}
@Override
public int hashCode() {
return Objects.hash(grandparentId, parentId);
}
}
@EmbeddedId
를 활용한 복합 키 매핑시 식별자 클래스는 반드시 다음 조건을 만족해야 합니다.
@Embeddable
어노테이션 선언Serializable
인터페이스 구현equals
&hashCode
구현- 기본 생성자 추가
public
클래스
이 ParentId.class
를 기본 키로 하여 PARENTS
테이블의 엔티티를 매핑합니다. GRANDPARENTS
와 PARENTS
테이블은 현재 식별 관계에 있기 때문에 외래 키도 함께 매핑합니다.
// Parent.java
@Slf4j
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "PARENTS")
public class Parent {
@EmbeddedId
private ParentId id;
@MapsId("grandparentId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "grandparent_id", nullable = false)
private Grandparent grandparent;
@Column(name = "name")
private String name;
}
복합 키를 매핑하면서 사용한 주요 JPA 어노테이션은 다음과 같습니다.
@Embeddable
- 이 어노테이션이 선언된 클래스는 다른 엔티티에 내장될 수 있습니다.
@Embeddable
클래스를 임베드 하는 엔티티는@Embedded
를 명시해야 합니다.
@EmbeddedId
@Embeddable
로 선언한 클래스를 엔티티의 기본 키로 사용한다는 의미입니다.@Id
어노테이션과 함께 사용될 수 없습니다.
@MapsId
: 외래 키로 매핑한 연관관계를 기본 키에도 매핑하겠다는 의미입니다.
마지막 부모 테이블의 복합 키를 포함한 총 세 개의 컬럼으로 구성된 복합 키를 지닌 CHILDREN
테이블입니다. PARENTS
테이블과 마찬가지로 식별자 클래스가 필요합니다.
// ChildId.java
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class ChildId implements Serializable {
private static final long serialVersionUID = 3871010042844477354L;
@Column(name = "child_id", nullable = false)
private String childId;
@Column(name = "parent_id", nullable = false)
private String parentId;
@Column(name = "grandparent_id", nullable = false)
private String grandparentId;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
ChildId entity = (ChildId) o;
return Objects.equals(this.grandparentId, entity.grandparentId) &&
Objects.equals(this.childId, entity.childId) &&
Objects.equals(this.parentId, entity.parentId);
}
@Override
public int hashCode() {
return Objects.hash(grandparentId, childId, parentId);
}
}
부모 테이블의 기본 키가 복합 키여서, 하위 테이블의 외래 키도 복합 키입니다. 외래 키 매핑 시 컬럼이 여러 개인 경우 @JoinColumns
어노테이션 안에 @JoinColumn
으로 컬럼을 매핑하면 됩니다.
// Child.java
@Slf4j
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "CHILDREN")
public class Child {
@EmbeddedId
private ChildId id;
@MapsId("id")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumns({
@JoinColumn(name = "parent_id", referencedColumnName = "grandparent_id", nullable = false),
@JoinColumn(name = "grandparent_id", referencedColumnName = "parent_id", nullable = false)
})
private Parent parents;
@Column(name = "name")
private String name;
}
JpaRepository
복합 키를 지닌 JPA 엔티티의 Spring Data JPA 확장은 단일 기본 키를 가진 엔티티와 동일합니다. ID
에 식별자 클래스의 타입을 지정합니다.
// GrandparentRepository.java
public interface GrandparentRepository extends JpaRepository<Grandparent, String> {
}
// ParentRepository.java
public interface ParentRepository extends JpaRepository<Parent, ParentId> {
}
// ChildRepository.java
public interface ChildRepository extends JpaRepository<Child, ChildId> {
}
Playgrounds
간단한 테스트 코드를 통해 복합 키를 활용하는 방법에 대해 살펴보겠습니다.
Save a New Record
복합 키를 지닌 테이블에 새로운 레코드를 추가할 때는 식별자 클래스의 인스턴스를 생성한 다음 이것을 기본 키로 하여 엔티티를 만들어서 데이터베이스에 저장합니다.
// CompositeKeysTest.java
@Autowired
GrandparentRepository grandparentRepository;
@Autowired
ParentRepository parentRepository;
@Autowired
ChildRepository childRepository;
@Test
@DisplayName("save new failmy")
void shouldSaveNewFailmy() throws Exception {
Grandparent newGrandparent = new Grandparent("grandparent_id-1", "grandparent-A");
Grandparent grandparent = grandparentRepository.save(newGrandparent);
ParentId parentId = createParentId("parent_id-1", grandparent.getGrandparentId());
Parent newParent = new Parent(parentId, grandparent, "parent-A");
Parent parent = parentRepository.save(newParent);
ChildId childId = createChildId("child_id-1", parent);
Child newChild = new Child(childId, parent, "child-A");
childRepository.save(newChild);
}
테스트를 실행해 보면 총 세 번의 insert
쿼리가 수행됩니다.
-- console log
insert
into
GRANDPARENTS
(name,grandparent_id)
values
(?,?)
insert
into
PARENTS
(name,grandparent_id,parent_id)
values
(?,?,?)
insert
into
CHILDREN
(name,child_id,grandparent_id,parent_id)
values
(?,?,?,?)
Retrieve a Record
기본 키를 기반으로 레코드를 조회할 때도 마찬가지로 식별 클래스의 인스턴스를 생성한 다음 이것을 where
절의 조건으로 대입합니다.
@Test
@DisplayName("retrieve")
void shouldRetrieve() throws Exception {
// Given
Grandparent newGrandparent = new Grandparent("grandparent_id-11", "grandparent-A");
Grandparent grandparent = grandparentRepository.save(newGrandparent);
ParentId parentId = createParentId("parent_id-11", grandparent.getGrandparentId());
Parent newParent = new Parent(parentId, grandparent, "parent-A");
Parent parent = parentRepository.save(newParent);
ChildId childId = createChildId("child_id-11", parent);
Child newChild = new Child(childId, parent, "child-A");
childRepository.save(newChild);
// When
Child child = childRepository.findById(childId).orElseThrow();
// Then
assertThat(child.getName()).isEqualTo(newChild.getName());
assertThat(child.getId().getParentId()).isEqualTo(newParent.getId().getParentId());
assertThat(child.getId().getGrandparentId()).isEqualTo(newGrandparent.getGrandparentId());
}
테스트를 실행하면 where
절에 복합 키가 조건으로 대입되는 것을 확인할 수 있습니다.
-- console log
select
c1_0.child_id,
c1_0.grandparent_id,
c1_0.parent_id,
c1_0.name
from
CHILDREN c1_0
where
(
c1_0.child_id,c1_0.grandparent_id,c1_0.parent_id
) in ((?,?,?))
- Spring
- JPA
- Database