JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야한다. 이 경우, 식별 관계를 매핑하기 위해 실수했던 부분과 직접 ID값을 할당할 때 발생할 수 있는 문제점에 대해 기록하려고 한다.
🔑 복합키 : 비식별 관계 매핑
- @EmbeddedId
둘 이상의 컬럼으로 구성된 복합 기본 키를 매핑하기 위해서는 별도의 식별자 클래스를 생성해야한다.
다음의 RECRUIT_CATEGORY 테이블은 CATEGORY 테이블의 PK인 category_id와 RECRUITMENT 테이블의 PK인 recruiment_id 를 FK로 받아, 복합 키로 사용한다.
JPA에서는 @IdClass 와 @EmbeddedId 2가지 방법으로 복합키를 매핑할 수 있다. @EmbeddedId 가 조금 더 객체지향에 가까운 방법이다. 여기서는 @EmbeddedId 를 사용하는 방법만 알아보겠다.
우선, 다음과 같이 식별자 클래스를 생성해야한다.
// 식별자 클래스
@Embeddable
@NoArgsConstructor
@EqualsAndHashCode
public class RecruitCategoryId implements Serializable {
@Column(name = "RECRUIMENT_ID")
private Long recruitId;
@Column(name = "CATEGORY_ID")
private Long categoryId;
public RecruitCategoryId(Long recruitId, Long categoryId) {
this.recruitId = recruitId;
this.categoryId = categoryId;
}
}
@Entity
public class RecruitCategory {
@EmbeddedId
private RecruitCategoryId id;
...
}
- 식별자 클래스에 직접 복합키에 사용될 컬럼을 매핑해주고 Entity에 @EmbeddedId로 식별자 클래스를 정의해주면 된다.
- @EmbeddedId를 적용한 식별자 클래스는 다음의 조건을 만족해야한다.
- @Embeddable 적용
- Serializable 인터페이스 구현
- equals, hashCode 구현 - 식별자로 사용하기 위함
- 기본 생성자 필수
- 클래스 접근 제어자 범위 public
🔑 식별 관계 구성
- @MapsId
@EmbeddedId 로 기본 키에 매핑한 외래 키를 연관관계에 사용하려면 @MapsId 어노테이션을 사용해야한다.
그렇지 않으면 다음과 같이 MappingException이 발생한다. ('Column 'category_id' is duplicated in mapping for entity ~~ ')
예외 메시지를 잘 읽어보면, entity 에서 컬럼이 중복되었다고 한다. 당연하게도, 복합키로 이미 사용되고 있는 외래 키를 객체 그래프 탐색을 위해 필드에 한번 더 정의하였기 때문에 두개의 중복된 필드가 있는 것으로 판단한 것이다.
@Entity
public class RecruitCategory {
@EmbeddedId
private RecruitCategoryId id;
// 식별자 클래스의 ID와 매핑
@MapsId("categoryId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CATEGORY_ID")
private Category category;
@MapsId("recruitId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "RECRUIMENT_ID")
private Recruitment recruitment;
}
// 식별자 클래스
@Embeddable
@NoArgsConstructor
@EqualsAndHashCode
public class RecruitCategoryId implements Serializable {
@Column(name = "RECRUIMENT_ID")
private Long recruitId;
@Column(name = "CATEGORY_ID")
private Long categoryId;
public RecruitCategoryId(Long recruitId, Long categoryId) {
this.recruitId = recruitId;
this.categoryId = categoryId;
}
}
다음과 같이 @MapsId를 사용하여 category_id와 recruiment_id를 외래 키와 기본 키 둘 다 매핑할 수 있다.
매핑하는 방법은 @MapsId 어노테이션에 기본 키로 매핑한 필드명을 적어주면 된다.
🔑 Id를 직접 매핑할 때 주의해야할 점 (merge)
- isNew() 재정의
JPA에서 새로운 엔티티를 판단하는 기본 전략은 다음과 같다.
- 식별자가 객체일 때 null 로 판단
- 식별자가 자바 기본 타입일 때 0 으로 판단
따라서, @GeneratedValue 를 사용하지 않고 기본 키를 직접 할당하여 사용하면 Entity가 영속되기 전에 이미 ID값을 가지고 있으므로 새로운 엔티티이더라도 이를 판단할 수 없다.
Spring Data JPA에서 데이터를 저장하는 save()는 새로운 엔티티라고 판단되면 persist()를 실행하고, 그렇지 않다면 merge()를 호출한다.
merge() 는 우선 DB를 호출해서 값을 확인하고 (SELECT 쿼리 발생), DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이다. 따라서, 다음과 같이 복합키를 구성하여 ID 값을 직접 넣어줄 때 데이터를 save() 메서드를 통해 데이터를 insert하면 다음과 같이 select 쿼리도 함께 발생하게 된다. (불필요한 쿼리 발생💥)
따라서 사용해서 새로운 엔티티 확인 여부를 직접 구현하는 것이 효과적이다. 이는 Persistable 을 구현하여 isNew() 메서드를 재정의해주면 된다.
참고로 등록시간( @CreatedDate )을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. (@CreatedDate에 값이 없으면 새로운 엔티티로 판단)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "RECRUIT_CATEGORY")
@Entity
public class RecruitCategory implements Persistable<RecruitCategoryId> {
@EmbeddedId
private RecruitCategoryId id;
@MapsId("categoryId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CATEGORY_ID")
private Category category;
@MapsId("recruitId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "RECRUIMENT_ID")
private Recruitment recruitment;
@CreatedDate
private LocalDate created;
@Override
public RecruitCategoryId getId() {
return id;
}
// 새로운 엔티티 판단 전략 재정의
@Override
public boolean isNew() {
return created == null;
}
}
- Persistable<기본키 클래스> 를 implements 하여 isNew()와 getId()를 재정의해준다.
- getId() : 식별 값을 확인한다. 식별자 클래스나 Id 값을 반환한다.
- isNew() : 새로운 엔티티를 판단하는 메서드이다.
- 보통 createdDate 는 기본적으로 사용하는 값이므로 createdDate 값이 null이면 새로운 Entity로 판단할 수 있다.
참고
- 자바 표준 ORM JPA 프로그래밍 - 김영한 저