catsridingCATSRIDING|OCEANWAVES
Dev

JPA Entity 복합키 매핑하기

jynn@catsriding.com
Oct 29, 2023
Published byJynn
999
JPA Entity 복합키 매핑하기

Map Composite Primary keys in JPA

오래된 Legacy DB를 마주하게 되면 의도조차 불분명한 수없이 많은 복합 키와 컬럼들이 등장합니다. 이런 Legacy DB에 JPA를 적용하는 과정에서 가장 걸림돌이 되는 부분이 복합 키 매핑이었습니다.

이번 포스팅에서는 @EmbeddedId를 통해 DB 테이블의 복합 키를 JPA Entity에 매핑하여 사용하는 방법에 대해 알아보겠습니다.

MySQL 데이터베이스를 기반으로 하고 있으며, 코드 가독성과 빠른 테스트를 위해 Lombok 라이브러리를 적극적으로 활용합니다.

Prerequisite

데이터베이스에 복합 키로 구성된 테이블을 생성합니다. Key 타입은 Legacy DB에서 자주 보이는 varchar 타입으로 지정하고 테이블의 관계는 식별 관계로 설정하였습니다.

map-composite-primary-keys-in-jpa-1

--  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 테이블의 엔티티를 매핑합니다. GRANDPARENTSPARENTS 테이블은 현재 식별 관계에 있기 때문에 외래 키도 함께 매핑합니다.

//  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