JPA를 사용하는 프로젝트를 진행하면서 다대다 관계를 풀어내야할 일이 생겼다.
객체 지향언어에서는 2개의 컬렉션 객체로 다대다 관계를 표현 할 수 있지만, 관계형 데이터베이스는 정규화된 2개의 테이블로 다대다 관계를 표현할 수 없다. 이와 같은 한계를 극복하기 위해 중간 테이블을 Entity로 만들어, 일대다 - 다대일 관계로 풀어내야한다.
프로젝트에서 정의한 도메인 관계는 다음과 같다. (관계형 데이터베이스에서 다대다 관계는 없다.)
- RECRUITMENT : 모집글 테이블
- CATEGORY : 카테고리 (태그) 테이블
하나의 모집글은 여러개의 카테고리 (태그)를 가질 수 있고 하나의 카테고리는 여러 모집글에 할당될 수 있기 때문에 다대다 관계이다.
이를 관계형 데이터베이스에서 풀어내려면 중간 테이블을 생성해야한다.
RECRUITMENT 테이블과 CATEGORY 사이에 RECRUIT_CATEGORY 이름의 중간 테이블을 넣고,
다대다 관계를 일대다 - 다대일 관계로 풀어내면 된다.
🧩다대다 매핑 방법 #1 - @ManyToMany
위와 같이 중간 테이블로 다대다 관계를 풀어내기 위한 첫 번째 방법으로는 @ManyToMany 어노테이션을 사용하는 것이다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Recruitment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "RECRUIMENT_ID")
private Long id;
@Embedded
private Post post; //title, content
@Enumerated(EnumType.STRING)
private RecruitStatus status;
// 다대다 매핑
@ManyToMany
@JoinTable(
name = "RECRUIT_CATEGORY",
joinColumns = @JoinColumn(name = "RECRUIMENT_ID"),
inverseJoinColumns = @JoinColumn(name = "CATEGORY_ID")
)
private List<RecruitCategory> categories = new ArrayList<>();
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Category {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "CATEGORY_ID")
private Long id;
private String name;
// 양방향 매핑시 사용
@ManyToMany(mappedBy = "categories")
private List<Recruitment> recruitments;
}
- @ManyToMany 와 @JoinTable을 사용해서 연결 테이블을 바로 매핑할 수 있다.
- @JoinTable 속성
- name : 연결 테이블이름을 지정한다.
- joinColumns : 현재 방향인 모집글과 매핑할 조인 컬럼 정보를 지정한다.
- inverseJoinColumns : 반대 방향인 카테고리와 매핑할 조인 컬럼 정보를 지정한다.
- @JoinTable 속성
- 양방향 매핑시 반대편 객체에 @ManyToMany 어노테이션을 사용하여 컬렉션을 정의하고 mappedBy 속성으로 연관관계의 주인을 설정하면 된다.
장점
- @ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주므로 도메인 모델이 단순해지고 편리하다.
단점
- JPA에서 다대다 관계를 설정하면 내부적으로 중간 테이블이 존재하기때문에 예상치 못한 쿼리가 발생한다.
- 연결 테이블에 컬럼을 추가할 수 없다.
🧩다대다 매핑 방법 #2 - 연결 Entity 사용 (중간 테이블 직접 생성)
앞선 @ManyToMany 어노테이션을 사용하는 방법은 연결 테이블을 개발자가 직접 관리하는 방식이 아니다.
결국, 중간 테이블을 Entity로 만들어 일대다 - 다대일 관계를 매핑해주는 방식이 더욱 선호된다.
다대다를 풀어낸 테이블을 보면, 연결 테이블을 기준으로 RECRUITMENT, CATEGORY 테이블이 다대일 관계를 가지고 있다. 이처럼 중간 테이블 Entity를 생성하고 각각의 연관관계를 설정해주면 된다.
필자는 RECRUITMENT에서 CATEGORY 테이블에 접근해야할 일이 많고 중간 연결 테이블은 RECRUITMENT 테이블에 완전히 종속된 형태이므로 orphanremoval과 cascade 옵션을 추가하여 테이블의 생명주기를 RECRUITMENT와 같게 하였다.
🔑 1. 중간 테이블 - 식별 관계 (복합 키)
처음엔 위 ERD처럼 별도의 기본키 컬럼을 사용하지 않고 다른 테이블의 2개의 FK를 조합하여 복합 기본 키로 사용하고자 하였다.
(category_id + recrument_id) [pk]
이처럼 부모 테이블의 기본키를 내려받아서 자식 테이블의 기본 키 + 외래 키로 사용하는 관계를 식별 관계라고 한다.
(실무에서는 외래키는 외래키로만 사용하고 기본 키는 따로 사용하는 비식별 관계를 권장한다.)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Recruitment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "RECRUIMENT_ID")
private Long id;
@Embedded
private Post post; //title, content
@Enumerated(EnumType.STRING)
private RecruitStatus status;
// 양방향 매핑
@OneToMany(mappedBy = "recruitment", orphanRemoval = true, cascade = CascadeType.ALL)
private List<RecruitCategory> category = new ArrayList<>();
// 연관관계 편의 메서드
public void setCategory(List<RecruitCategory> list) {
for (RecruitCategory recruitCategory : list) {
if (!this.category.contains(recruitCategory)) {
this.category.add(recruitCategory);
recruitCategory.setParent(this);
}
}
}
}
// 중간 테이블
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "RECRUIT_CATEGORY")
@Entity
public class RecruitCategory implements Persistable<RecruitCategoryId> {
// 복합키 매핑
@EmbeddedId
private RecruitCategoryId id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CATEGORY_ID")
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "RECRUIMENT_ID")
private Recruitment recruitment;
@CreatedDate
private LocalDate created;
public void setParent(Recruitment recruitment) {
this.recruitment = recruitment;
this.id = new RecruitCategoryId(recruitment.getId(), category.getId());
}
@Override
public RecruitCategoryId getId() {
return id;
}
@Override
public boolean isNew() {
return created == null;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Category {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "CATEGORY_ID")
private Long id;
private String name;
}
- 연관 관계의 주인은 중간 테이블에 있기 때문에 Recruitment 의 setCategory() 메서드를 통해 연관 관계의 주인에 값을 할당한다.
- 테이블의 식별자로 복합 기본키를 사용하였다. 복합키를 위한 별도의 식별자 클래스를 생성하고 insert시 merge() 방식의 동작을 막기 위해 isNew() 메서드를 재정의 하였다.
복합키 매핑 방법에 대해서는 다음의 포스팅을 참고해주세요
2023.03.19 - [JPA/Spring Data JPA] - [JPA] 복합키 매핑하기 (@EmbeddedId, @MapsId, isNew())
[JPA] 복합키 매핑하기 (@EmbeddedId, @MapsId, isNew())
JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야한다. 이 경우, 식별 관계를 매핑하기 위해 실수했던 부분과 직접 ID값을 할당할 때 발생할 수 있는 문제점에 대해 기록하
rachel0115.tistory.com
🔑 2. 중간 테이블 - 비식별 관계
외래 키는 외래 키로만 사용하고 기본 키는 따로 사용하는 관계를 비식별 관계라고 한다. (recrument_category_id) [pk]
외래 키에 NULL이 허용되는지 여부에 따라, 필수적 비식별 관계 (NOT NULL), 선택적 비식별 관계(NULL)로 구분할 수 있다.
// 중간 테이블
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "RECRUIT_CATEGORY")
@Entity
public class RecruitCategory {
// 기본키 매핑
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "RECRUIT_CATEGORY_ID")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CATEGORY_ID")
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "RECRUIMENT_ID")
private Recruitment recruitment;
@CreatedDate
private LocalDate created;
public void setParent(Recruitment recruitment) {
this.recruitment = recruitment;
this.id = new RecruitCategoryId(recruitment.getId(), category.getId());
}
}
- 다음과 같이 기본 키를 따로 생성하여 식별자로 사용한다.
참고
- 자바 표준 ORM JPA 프로그래밍 - 김영한 저