본문 바로가기
Framework and Tool/JPA

JPA - 고급맵핑 - 복합키와 식별 관계 맵핑

by ocwokocw 2021. 7. 11.

- 참조: 자바 ORM 표준 JPA 프로그래밍

- 참조: https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/JoinColumns.html

- 식별 vs 비식별 관계

이전에도 언급했듯이 DB 에서는 외래키(FK)를 기본키(PK)에 포함하는지 여부에 따라 식별과 비식별 관계로 구분한다.

위의 ER Diagram 에서 CHILD 테이블을 보면 PARENT 테이블의 PK 인 PARENT_ID 를 PK 이자 FK 로 사용하고 있다. 외래키를 기본키에 포함하고 있으므로 식별관계이다.

위 다이어그램은 CHILD 테이블에서 PARENT_ID 를 FK 로만 참조하고 있고, CHILD_ID 를 PK 로 사용하고 있다. FK 가 PK 에 포함되지 않으므로 비식별 관계이다. 비식별 관계도 FK 에 NULL 허용여부에 따라 필수적 비식별과 선택적 비식별로 나눈다.


- 복합 키: 비식별 관계 맵핑

앞에서 이미 @IdClass 와 식별자 클래스를 이용하여 복합키를 맵핑해본적이 있다. 다시한번 생각해보는 의미에서 코드를 작성해보자. 만약 앞의 복합키에서 식별자 클래스 사용법을 몰랐다면 위의 ER Diagram 을 JPA 코드로 작성할 때 아래와 같이 작성할것이다.

 

@Entity
public class Parent{

	@Id
	@Column(name = "PARENT_ID")
	private String id;

........

@Entity
public class Child{

	@Id
	@Column(name = "PARENT_ID")
	private String parentId;

	@Id
	@Column(name = "CHILD_ID")
	private String childId;

 

실행해보면 알겠지만 위의 코드는 오류가 난다. JPA 는 영속성 컨텍스트에 보관할 때 식별자를 키로 사용한다고 했다. 식별자 구분시에는 equals 와 hashCode 를 사용하므로 식별자 필드가 2 개 이상이면 별도의 식별자 클래스를 만들어서 equals 와 hashCode 를 구현해야 한다.

 

JPA 에는 복합 키 지원을 위해 @IdClass 와 @EmbeddedId 를 지원한다.


- @IdClass

위 ER Diagram 은 PARENT 는 PK 가 2 개인 복합 키 이고, CHILD 에서는 이 키들을 외래키로만 사용한 비식별 관계를 나타낸것이다.

 

@Entity
@IdClass(ParentId.class)
public class Parent{

	@Id
	@Column(name = "PARENT_ID1")
	private String parentId1;
	
	@Id
	@Column(name = "PARENT_ID2")
	private String parentId2;

..............

public class ParentId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	private String parentId1;
	private String parentId2;

	setter/getter.......

	@Override
	public int hashCode() {
		.............
	}

	@Override
	public boolean equals(Object obj) {
		.............
	}
}

 

복합키 사용시 @IdClass 를 이용하여 식별자 클래스로 맵핑하였다. 식별자 클래스의 특징을 다시한번 살펴보자면 Serializable 을 구현해야 하고, hashCode 와 equals 를 오버라이드 해야한다. 또한 식별자 클래스의 속성명과 엔티티의 속성명이 같아야 한다.

 

Child 클래스는 아래와 같이 작성한다.

 

Entity
public class Child{

	@Id
	@Column(name = "CHILD_ID")
	private String childId;

	@ManyToOne
	@JoinColumns({
		@JoinColumn(name = "PARENT_ID1"),
		@JoinColumn(name = "PARENT_ID2")
	})
	private Parent parent;

 

Child 엔티티는 Parent 엔티티를 외래키로 참조한다. 다중성은 Parent : Child = 1 : N 이므로 @ManyToOne 을 사용한다. 그리고 조인 컬럼이 2 개 이상이기 때문에 @JoinColumn 이 아닌 @JoinColumns 를 사용한다.

 

public static void save(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Parent parent = new Parent();
	parent.setParentId1("PARENT#1");
	parent.setParentId2("PARENT#2");
	em.persist(parent);
	
	tx.commit();
	em.close();
}

................

public static void find(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	ParentId parentId = new ParentId("PARENT#1", "PARENT#2");
	Parent parent = em.find(Parent.class, parentId);
	System.out.println("parent: " + parent);
	
	tx.commit();
	em.close();
}

 

복합키 엔티티를 저장할 때에는 식별자 클래스를 이용하여 저장하지 않아도 된다. 영속성 컨텍스트에 등록하기 전 내부에서 ParentId 를 생성하여 키로 사용한다.

 

조회코드에서는 식별자 클래스인 ParentId 를 이용하여 조회한다. 이때 복합키를 생성자인자로 받는 생성자를 별도로 생성했는데, 필요 생성자를 별도로 구현시 기본생성자도 구현해주어야 한다.


- @EmbeddedId

@EmbeddedId 를 사용하면 좀 더 객체지향적인 코드를 작성할 수 있다.

 

@Entity
public class Parent{

	@EmbeddedId
	private ParentId parentId;
	
	private String name;

...........

@Embeddable
public class ParentId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	@Column(name = "PARENT_ID1")
	private String parentId1;
	
	@Column(name = "PARENT_ID2")
	private String parentId2;

 

@IdClass 로 식별자 클래스 사용시에는 복합키의 속성들을 모두 열거했었다. 하지만 @EmbeddedId 를 이용하면 엔티티에 식별자 클래스형만 명시하면 된다.

 

식별자 클래스에는 @Embeddable 어노테이션을 붙여준다. 어노테이션을 붙여줘야 하는것을 제외하고 @IdClass 의 식별자 클래스 조건들(Serializable, equals, hashCode, public 접근제어자, 기본 생성자)을 모두 만족해야 한다.

 

public static void save(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Parent parent = new Parent();
	
	ParentId parentId = new ParentId("PARENT#1", "PARENT#2");
	parent.setParentId(parentId);
	parent.setName("NAME#1");
	em.persist(parent);
	
	tx.commit();
	em.close();
}

 

조회하는 코드는 달라지지 않는다. 저장시 코드가 달라지는데 좀 더 객체지향적인 코드를 작성할 수 있다고 한 점이 드러난다. 기존에는 Parent 객체의 parentId1, parentId2 를 set 하면 영속화시킬 때 내부적으로 ParentId 를 이용한다고 했었다. @EmbeddedId 를 이용하면 저장시 ParentId 를 명시적으로 이용한다.


- 식별자 클래스가 만족하는 조건들

식별자 클래스에서 만족해야 하는 조건들을을 계속 언급했는데 조금 번거로운점이 있다. 하지만 Java 의 기본특성과 연관지어서 큰 줄기에서 생각해보면 왜 해당조건들을 만족시켜줘야 하는지 알 수 있다.

- Serializable

Serializable 은 직역하면 직렬화를 한다는 의미이다. 직렬화는 메모리를 파일저장이나 네트워크 통신시 사용하기 위한 형식으로 변환하는일이다. 만약 Java 의 기본타입형들만 사용한다면 직렬화를 이용하지 않아도 된다. (기본타입형들을 직렬화 할 수 있다는 의미이지 JPA 의 식별자 클래스 사용시 기본형들만 사용하면 Serializable 을 이용하지 않아도 된다는 의미가 아니다.)

 

<Serializable 을 구현한 String 형>

문제는 객체 참조형에서 발생한다. Java 에서 객체를 참조할 때에는 주소값을 참조하고 있다. 만약 이를 DB 에 저장한다고 가정해보자. 어플리케이션을 재시작하면 이 주소값은 아무런 의미가 없어진다. 하지만 직렬화는 값들을 끌어모아서 Java 의 기본형 타입으로 변환해준다. 따라서 Serializable 이 필요하고 JPA 에서는 식별자 클래스의 직렬화 구현여부는 validation 하고 있으므로 식별자 클래스에서 사용해주어야 한다.

- equals, hashCode

equals 와 hashCode 는 여러번 언급했다. 엔티티를 영속시킬 때 기본 PK 값을 이용하여 구분한다. equals 는 Object 의 기본구현에서 인스턴스의 주소를 비교한다. 만약 PK 값이 똑같은 식별자 클래스인 인스턴스가 2 개 있다고 가정한다면 JPA 에서는 이 둘을 같다고 판단해야 해당 식별자 클래스로 검색한 엔티티가 같은 엔티티임을 보장할 수 있다. 그래서 equals 를 동일성 비교가 아니라 동등성 비교로 재정의해야하고 같은 맥락에서 hashCode 도 재정의 해야하는것이다. 

이 둘과 관련해서 effective java 의 equals 와 hashCode 대목을 읽어보기를 바란다.


- 복합 키: 식별관계 맵핑

위 다이어그램에서 PK 들은 다른 테이블의 FK 이면서 PK 가 되는 식별자 테이블이다. 이런 식별관계에서 기본키를 포함한 복합 키를 구현할때에도 @IdClass 나 @EmbeddedId 로 구현해야 한다.


- @IdClass

위의 다이어그램을 @IdClass 를 이용하여 JPA 코드를 작성해보자.

 

@Entity
public class Parent{

	@Id
	@Column(name = "PARENT_ID")
	private String id;
	
	private String name;

......

@Entity
@IdClass(ChildId.class)
public class Child{

	@Id
	@Column(name = "CHILD_ID")
	private String id;

	@Id
	@ManyToOne
	@JoinColumn(name = "PARENT_ID")
	private Parent parent;

..........

public class ChildId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	private String id;
	private Parent parent;

..........

@Entity
@IdClass(GrandchildId.class)
public class Grandchild{

	@Id
	@Column(name = "GRANDCHILD_ID")
	private String id;

	@Id
	@ManyToOne
	@JoinColumns({
		@JoinColumn(name = "PARENT_ID"),
		@JoinColumn(name = "CHILD_ID")
	})
	private Child child;

............

public class GrandchildId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	private String id;
	private Child child;

 

비식별 관계에서 외래키에는 다중성 어노테이션과 JoinColumn 만 사용했지만, 식별관계에서는 FK 이자 동시에 PK 여야 하므로 @Id 와 @ManyToOne, @JoinColumn 어노테이션을 같이 사용해주었다. 그 외에 @IdClass 를 이용하여 식별자 클래스와 속성명을 일치시키는 부분은 동일하다.

 

그럼 저장 테스트를 해보자. 아래와 같이 작성하고 실행시켜보자.

 

public static void save(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Parent parent = new Parent();
	parent.setName("PARRENT_NAME#1");
	parent.setId("PARENT#1");
	em.persist(parent);
	
	Child child = new Child();
	child.setId("CHILD#1");
	child.setParent(parent);
	em.persist(child);
	
	Grandchild grandchild = new Grandchild();
	grandchild.setId("GRANDCHILD#1");
	grandchild.setChild(child);
	em.persist(grandchild);
	
	tx.commit();
	em.close();
}

 

H2 console 에서 조회해보면 아래와 같이 나온다.

그런데 결과가 좀 이상하다. GRANDCHILD 테이블에서 PARENT_ID 와 CHILD_ID 의 데이터가 뒤바뀌었다. @JoinColumns 사용시 순서에 영향을 받기 때문에 referencedColumnName 속성을 지정해주는것이 좋다. 아래와 같이 참조 컬럼명을 지정해주자. (https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/JoinColumns.html)

 

@Id
@ManyToOne
@JoinColumns({
	@JoinColumn(name = "PARENT_ID", referencedColumnName = "PARENT_ID"),
	@JoinColumn(name = "CHILD_ID", referencedColumnName = "CHILD_ID")
})
private Child child;

 

<데이터가 정상적으로 맵핑된 모습>


- @EmbeddedId

@EmbeddedId 로 식별관계 구성시에는 @MapsId 라는 어노테이션을 사용한다.

 

@Embeddable
public class ChildId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	@Column(name = "CHILD_ID")
	private String id;
	
	private String parent;

..............

@Entity
public class Child{

	@EmbeddedId
	private ChildId id;
	
	@MapsId("parent")
	@ManyToOne
	@JoinColumn(name = "PARENT_ID", referencedColumnName = "PARENT_ID")
	private Parent parent;

...........

@Embeddable
public class GrandchildId implements Serializable{

	/**
	 * 
	 */
	private static final long serialVersionUID = -2929789292155268166L;

	@Column(name = "GRANDCHILD_ID")
	private String id;
	
	private ChildId child;

..........

@Entity
public class Grandchild{

	@EmbeddedId
	private GrandchildId id;

	@MapsId("child")
	@ManyToOne
	@JoinColumns({
		@JoinColumn(name = "PARENT_ID", referencedColumnName = "PARENT_ID"),
		@JoinColumn(name = "CHILD_ID", referencedColumnName = "CHILD_ID")
	})
	private Child child;

 

@EmbeddedId 는 다른 테이블의 PK 를 이어받아 식별 관계로 사용할 연관관계 속성에 @MapsId 어노테이션을 사용하면 된다. 식별자 클래스와 식별자 클래스를 사용하는 엔티티 클래스로 나누어서 맵핑하는법을 살펴보자.

 

@Embeddable 어노테이션을 붙여주는 식별자 클래스인 ChildId 를 살펴보자. ChildId 에서는 2 개의 컬럼이 있는데 PK 역할만 하는 id 속성(컬럼명 CHILD_ID) 과 FK 이면서 PK 역할을 하는 parent 속성(컬럼명 PARENT_ID) 가 있다. PK 역할만 하는 id 속성에는 @Column 으로 원하는 컬럼명을 맵핑해준다. 반면 FK 이면서 PK 역할을 하는 식별 관계로 사용할 연관관계에는 아무것도 붙여주지 않는다.

 

이 식별자 클래스를 사용하는 Child 엔티티를 살펴보자. @EmbeddedId 어노테이션으로 식별자 클래스를 Id 로 사용한다고 맵핑해준다. 이 엔티티에서 식별 관계로 사용하는 연관관계인 parent 에는 @MapsId 어노테이션을 붙여준다. 어노테이션 value 는 식별자 클래스에서 맵핑되는 필드명을 적어준다.

 

제대로 작성했는지 테스트 코드를 실행해보자.

 

public static void save(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Parent parent = new Parent();
	parent.setName("PARRENT_NAME#1");
	parent.setId("PARENT#1");
	em.persist(parent);
	
	ChildId childId = new ChildId();
	
	childId.setParent(parent.getId());
	childId.setId("CHILD#1");

	Child child = new Child();
	
	child.setId(childId);
	child.setParent(parent);
	em.persist(child);

	GrandchildId grandchildId = new GrandchildId();
	
	grandchildId.setId("GRANDCHILD#1");
	grandchildId.setChild(childId);
	
	Grandchild grandchild = new Grandchild();
	
	grandchild.setId(grandchildId);
	grandchild.setChild(child);
	em.persist(grandchild);
	
	tx.commit();
	em.close();
}

 

위의 코드는 Parent, Child, Grandchild 엔티티를 1건씩 저장한다. 아래 H2 console 은 이 코드를 실행한 결과이다.


- 비식별 관계로의 변환

방금 예제를 비식별관계로 변경해보자.

 

@Entity
public class Parent{

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

......

@Entity
public class Child{

	@Id @GeneratedValue
	@Column(name = "CHILD_ID")
	private Long id;
	
	@ManyToOne
	@JoinColumn(name = "PARENT_ID", referencedColumnName = "PARENT_ID")
	private Parent parent;

.......

@Entity
public class Grandchild{

	@Id @GeneratedValue
	@Column(name = "GRANDCHILD_ID")
	private Long id;
	
	@ManyToOne
	@JoinColumns({
		@JoinColumn(name = "PARENT_ID", referencedColumnName = "PARENT_ID"),
		@JoinColumn(name = "CHILD_ID", referencedColumnName = "CHILD_ID")
	})
	private Child child;

 

식별 관계의 복합키가 없어지므로 식별자 클래스를 만들지 않아도 되고 맵핑도 쉬우며 코드도 단순해졌다. 저장을 하는 save 로직에서도 훨씬 간단명료해졌음을 알 수 있다.

 

public static void save(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Parent parent = new Parent();
	
	parent.setName("PARRENT_NAME#1");
	em.persist(parent);
	
	Child child = new Child();
	
	child.setParent(parent);
	em.persist(child);


	Grandchild grandchild = new Grandchild();
	
	grandchild.setChild(child);
	em.persist(grandchild);
	
	tx.commit();
	em.close();
}

- 1 : 1 식별관계

1 : 1 식별관계는 조금 독특하다. 아래 다이어그램을 보자.

1 : 1 식별관계에서는 부모 테이블이 복합 키가 아니면 자식 테이블의 PK 도 복합 키로 구성하지 않아도 된다.

 

@Entity
public class Board {

	@Id @GeneratedValue
	@Column(name = "BOARD_ID")
	private Long id;
	
	private String title;

	@OneToOne(mappedBy = "board")
	private BoardDetail boardDetail;

..........

@Entity
public class BoardDetail {

	@Id
	private Long boardId;
	
	private String content;

	@MapsId
	@OneToOne
	@JoinColumn(name = "BOARD_ID", referencedColumnName = "BOARD_ID")
	private Board board;

 

Board 와 BoardDetail 을 여태까지 배운내용으로 학습하면 어렵지 않게 작성할 수 있다. 둘다 @OneToOne 어노테이션을 사용하고 외래키는 BOARD_DETAIL 에서 관리하므로 mappedBy 나 JoinColumn 도 어렵지 않게 맵핑할 수 있다.

 

그런데 @MapsId 가 난데없이 등장한다. @MapsId 의 정의를 다시한번 생각해보자. 외래키와 맵핑한 연관관계(@JoinColumn 으로 맵핑한 Board) 를 기본키(boardId) 에도 맵핑한다고 했다. 1 : 1 식별관계에서는 BOARD_ID 가 FK 이면서 PK 가 되므로 위와 같이 외래키로 참조하는 Board 에 @MapsId 어노테이션을 맵핑해준다.

 

@MapsId 는 꼭 @EmbeddedId 에서만 사용하는것은 아니다. OneToOne 이나 ManyToOne 에서 부모 엔티티의 단일 PK 를 참조할때에 된다.

 

위 예제에서는 @MapsId 의 값을 할당하지 않았는데, 엔티티의 기본키가 연관관계에서 참조하는 엔티티의 PK 와 동일한 Java 타입이면 속성을 지정하지 않아도 된다. 자세한 사항은 다음 문서를 참조한다. (https://docs.jboss.org/hibernate/jpa/2.1/api/index.html?javax/persistence/package-summary.html)


- 식별 vs 비식별

식별과 비식별은 모두 장단점이 있다. 식별은 부모의 PK 들이 자식으로 전파되면 될수록 점점 컬럼수가 늘어난다. 이렇게 되면 복합 키를 작성해야 하며 유연성이 떨어진다. 현업에서도 보통 비식별을 이용하는 추세다.

 

그렇다고 해서 식별이 장점이 아예 없는것은 아닌다. PARENT_ID, CHILD_ID 와 같은 복합 키를 갖게 되면 PK 는 자동으로 인덱스가 설정되므로, 자식테이블에서 PARENT_ID 조건으로 조회시 인덱스를 이용할 수 있다. (CHILD_ID 조건으로만 조회하면 인덱스를 사용하지 못한다.)

 

비식별 중에서도 필수적과 선택적을 고려해볼 수 있다. FK 에 NULL 을 허용할지 말지 여부인데, 비즈니스 제약사항이 없다면 FK 에는 NULL 을 허용하지 않는것이 좋다. FK 에 NULL 이 존재한다는것은 OUTER JOIN 을 이용해야 자식테이블의 값이 존재하지 않을때에도 부모 테이블을 조회할 수 있기 때문이다.

댓글