연관관계 매핑의 종류는 다음과 같다.
엔티티 간 참조 방향은 단방향과 양방향이 있을 수 있다.
연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 일반적으로 외래키를 가진 테이블이 그 관계의 주인이 되며, 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행할 수 있다.
Member 엔티티와 MemberDetail 엔티티를 일대일 매핑을 만들어 보자.
9.3.1 일대일 단방향 매핑
일단 MemberDetail 엔티티를 아래와 같이 구성한다.
// MemberDetail 엔티티
@Entity
@Table(name = "member_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = True)
public class MemberDetail extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
@OneToOne
@JoinColumn(name = "product_number")
private Member member;
}
위 코드를 보면 Member 엔티티와 일대일 관계를 설정하기 위해 @OneToOne 어노테이션을 지정하였다. @JoinColumn 어노테이션을 사용해 매핑할 외래키를 지정한다. 만약 @JoinColumn을 선언하지 않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않다. @JoinColumn 의 속성은 아래와 같다.
위와 같이 MemberDetail 엔티티를 생성하고 아래와 같이 레포지토리를 생성한다.
public interface MemberDetailRepository extends JpaRepository<MemberDetail, Long> {
}
일대일 매핑 관계를 설정한 엔티티와 레포지토리를 구성하면 아래와 같이 MemberDetailRepository에서 MemberDetail 객체를 조회한 후 연관 매핑된 Member 객체를 조회할 수 있다.
Member member = memberDetailRepository.findById(memberDetail.getId()).get().getMember());
@OneToOne 어노테이션은 기본 fetch 전략으로 EAGER(즉시 로딩 전략)를 채택한다. 그리고 optional() 메서드는 기본값으로 true가 설정되어 있다. 기본값이 true이면 매핑되는 값이 nullable 함을 의미한다. @OneToOne 어노테이션을 지정할 때 '@OneToOne(optional = false)'와 같이 지정하여 null 값을 허용하지 않을 수 있다.
9.3.2 일대일 양방향 매핑
객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다.
즉, 위와 같이 MemberDetail에 @OneToOne 어노테이션을 지정하고, 다른 엔티티인 Member 엔티티에도 @OneToOne 어노테이션을 지정하여 MemberDetail을 매핑하도록 한다.
양방향 관계를 설정하면 양쪽에서 외래키를 가지고 있어 쿼리문에서 left outer join이 두번이나 실행되기 때문에 효율성이 떨어진다. 따라서 엔티티는 양방향으로 매핑하되 한쪽 엔티티만 외래키를 주는 것이 좋다. 이 때 사용되는 속성 값이 mappedBy이다. mappedBy는 어떤 객체가 주인인지 표시하는 속성이다. mappedBy로 지정된 객체가 주인 객체의 상대 객체이며, 이 객체는 외래키를 가질 수 없다.
// mappedBy 속성을 추가한 Member 엔티티 클래스
@OneToOne(mappedBy = "member")
@ToString.Exclude
private MemberDetail memberDetail;
단방향으로 연관관계를 설정하거나 양방향 설정이 필요한 경우에 종종 ToString을 사용할 때 순환참조가 발생하여 StackOverflowError가 발생하는 경우가 있다. 이러한 에러를 방지하기 위해 위와 같이 exclude를 사용해 ToString에서 제외 설정을 하는 것이 좋다.
점주는 한 명이고 점주가 관리하는 점포가 많은 경우에는 점주 테이블의 입장에서 보면 일대다 관계, 점포 입장에서 보면 다대일 관계가 성립한다. 이런 경우를 살펴보자.
9.4.1 다대일 단방향 매핑
점주(Manager)와 점포(Store) 엔티티가 구성되었다고 하자. 다대일 단방향 매핑을 위해 점포(Store) 엔티티에 아래와 같이 설정해야 한다.
@ManyToOne
@JoinColumn(name = "manager_id")
@ToString.Exclude
private Manager manager;
보통 외래키를 갖는 쪽이 주인의 역할을 수행하기 때문에 위의 경우 Store 엔티티가 Manager 엔티티의 주인이다. Store 엔티티와 Manager 엔티티의 레포지토리가 생성되어 있다면 다음과 같이 StoreRepository에서 Store객체를 조회하여, 이를 통해 연관관계가 설정된 manager를 조회할 수 있다.
storeRepository.findById(1L)
.orElseThrow(RuntimeException::new).getManager();
9.4.2 다대일 양방향 매핑
JPA에서는 양쪽에서 단방향으로 매핑하는 것이 양방향 매핑 방식이다. 따라서 Manager 엔티티에 아래와 같이 설정한다.
@OneToMany(mappedBy = "manager", fetch = FetchType.EAGER)
@ToString.Exclude
private List<Store> storeList = new ArrayList();
일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션 형식으로 필드를 생성한다. @OneToMany의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정했다. 만약 @OneToMany가 붙은 쪽에서 @JoinColumn 어노테이션을 사용하면 상대 엔티티에 외래키가 설정된다.
위와 같이 설정하면 아래와 같은 코드로 Manager객체를 조회를 통해 manager가 가지고 있는 store를 조회할 수 있다.
List<Store> stores = managerRepository.findById(manager.getId()).get().getStoreList();
주의할 점은 현재 Store 객체가 주인관계이기 때문에 위와 같이 조회를 하려면 Store객체에 manager객체를 저장해야 한다. 만약 Manager 엔티티에 정의한 storeList 필드에 Store 엔티티를 추가하는 방식으로 데이터베이스에 레코드를 저장하게 되면 Manager 엔티티 클래스는 연관관계의 주인이 아니기 때문에 해당 데이터는 데이터베이스에 반영되지 않는다.
// 주인이 아닌 엔티티에서 연관관계를 설정한 잘못된 예
manager.getStoreList().add(store1);
9.4.3 일대다 단방향 매핑
@OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없다. 점포(Store) 테이블과 점포 분류(StoreCategory) 테이블을 생각해보자. 하나의 점포 분류(StoreCategory)에는 여러 개의 점포(Store)를 가질 수 있다. StoreCategory 엔티티에 아래와 같이 일대다 매핑관계를 설정한다.
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "store_category_id")
private List<Store> stores = new ArrayList();
이와 같은 일대다 단방향 관계의 단점은 매핑의 주체가 아닌 반대 테이블에 왜래키가 추가된다는 점이다. 이 방식은 다대일 구조와 다르게 외래키를 설정하기 위해 다른 테이블에 update 쿼리를 발생시킨다. 이 같은 문제를 해결하기 위해서는 일대다 양방향 연관관계를 사용하기 보다는 다대일 연관관계를 사용하는 것이 좋다.
현재 Store 엔티티가 주인이기 때문에 아래와 같이 StoreCategory에서 생성한 리스트 객체에 Store 객체를 추가해서 연관관계를 설정할 수 있다. 또한, StoreCategoryRepository에서 조회한 StoreCategory 객체를 통해 store를 조회할 수 있다.
storeCategory.getStores().add(store);
List<Store> stores = storeCategoryRepository.findById(1L).get().getStores();
다대다 연관관계는 실무에서 거의 사용되지 않는 구성이다. 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가진는 구조가 만들어진다. 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해소한다.
다대다 관계를 보기 위해서 직원(Employee)와 점포(Store) 관계를 살펴보자. 직원은 여러 점포에서 근무할 수 있고, 한 점포에서 여러 직원들이 근무할 수 있다.
9.5.1 다대다 단방향 매핑
직원(Employee) 엔티티에 아래와 같이 설정하여 다대다 연관관계를 설정하자.
@ManyToMany
@ToString.Exclude
private List<Store> stores = new ArrayList();
public void addStore(Store store){
stores.add(store);
}
리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @JoinColumn은 설정하지 않아도 된다. 아래와 같이 EmployeeRepository를 조회한 Employee객체를 통해 Store 객체를 조회할 수 있다. 이 경우도 employee객체에서 생성한 stores리스트에 store 객체를 추가해서 연관관계를 설정할 수 있다.
employee1.addStore(store1);
employee1.addStore(store2);
employee2.addStore(store2);
employee2.addStore(store3);
employeeRepository.saveAll(Lists.newArrayList(employee1, employee2));
employeeRepository.findById(1L).get().getStores();
위와 같은 경우 리포지토리를 사용하게 되면 매번 트랜잭션이 끊어져 생산업체 엔티티에서 상품 리스트를 가져오는 작업이 불가능하다. 이 같은 문제를 해소하기 위해 위 코드를 포함하는 메서드에 @Transactional 어노테이션을 지정해 트랜잭션이 유지되도록 구성한다.
9.5.2 다대다 양방향 매핑
Store 엔티티에 아래와 같이 작성하면 양방향 매핑 설정이 된다.
@ManyToMany
@ToString.Exclude
private List<Employee> employees = new ArrayList();
public void addEmployee(Employee employee){
employees.add(employee);
}
다대다 연관관계의 한계를 극복하기 위해서는 중간 테이블을 생성하느 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.
영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다.
영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관이 있다. 한 엔티티가 영속상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것이다.
9.6.1 영속성 전이 적용
@OneToMany(mappedBy = "manager", cascade = CascadeType.PERSIST)
@ToString.Exclude
private List<Store> storeList = new ArrayList();
Manager 엔티티에 위와 같이 일대다 관계 설정이 cascade타입이 PERSIST로 되어 있다고 하자.
store1.setManager(manager);
store2.setManager(manager);
store3.setManager(manager);
manager.getStoreList().addAll(Lists.newArrayList(store1, store2, store3));
managerRepository.save(manager);
위 코드의 store1, store2, store3은 따로 영속화 작업(레포지토리 저장)을 수행하지 않고 manager와의 연관관계만 설정하였다. 그러면 manager객체를 managerRepository에 저장할 때 영속성 전이가 수행되어 각 store객체를 저장하는 쿼리도 같이 수행된다.
9.6.2 고아 객체
JPA에서 고아(orphan) 객체란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다. JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다. Employee 엔티티에 아래와 같이 설정하여 사용할 수 있다.
@OneToMany(mappedBy = "employee", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Store> storeList = new ArrayList();
employee에 포함된 storeList에서 store2가 삭제되면, store2는 employee와 연관관계가 끊어진다. 위와 같이 orphanRemoval = true로 설정해 놓았으면, store2는 연관관계가 끊어져 고아 객체가 되어 삭제된다.