JPA/Spring Data JPA

[JPA] 복합키 매핑하기 (@EmbeddedId, @MapsId, isNew())

KAispread 2023. 3. 19. 20:10
728x90
반응형

JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야한다. 이 경우, 식별 관계를 매핑하기 위해 실수했던 부분과 직접 ID값을 할당할 때 발생할 수 있는 문제점에 대해 기록하려고 한다.

 


 

🔑 복합키 : 비식별 관계 매핑

- @EmbeddedId

둘 이상의 컬럼으로 구성된 복합 기본 키를 매핑하기 위해서는 별도의 식별자 클래스를 생성해야한다.

다음의 RECRUIT_CATEGORY 테이블은 CATEGORY 테이블의 PK인 category_id와 RECRUITMENT 테이블의 PK인 recruiment_id 를 FK로 받아, 복합 키로 사용한다. 

category_id 와 recruiment_id 를 사용하여 복합 기본키 매핑

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 쿼리도 함께 발생하게 된다. (불필요한 쿼리 발생💥)

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 프로그래밍 - 김영한 저
728x90
반응형