오늘은 개발하다보면 매우 자주 마주치는 연관 관계 매핑에 대해서 작성해보겠다. DB 설계와 엔티티를 작성하다보면 RDBMS에서 연관 관계들을 매핑해줘야 하는데 스프링에서는 어떻게 매핑하는지 알아보자
만약 상품 테이블이 있고, 상품 정보 테이블이 있다고 가정해보자. 상품마다 각각의 상품 정보가 있으니 상품과 상품 정보는 일대일 관계이다. 따라서 다음과 같이 매핑한다.
상품 정보 엔티티(ProductDetail)
@OneToOne
@JoinColumn(name = "product_number")
private Product product;
@JoinColum
매핑할 외래키를 나타내고, name으로 원하는 컬럼명을 지정할 수 있다.
만약 지정하지 않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어난다.
속성
이렇게 지정 후, ProductDetail 엔티티를 조회하면 매핑된 Product 객체가 함께 조회 되는데, 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 즉시 로딩(Eager)이라고 한다. 스프링은 기본이 즉시로딩(Eager)로 되어 있다.
즉, ProductDetail을 기준으로 left outer join이 실행된다.
SELECT ~ FROM ProductDetail LEFT OUTER JOIN Product
추가로 Product에서도 ProductDetail을 매핑할 수 있다.
@OneToOne
private ProductDetail productDetail;
이렇게 Product에도 연관 관계를 설정하면 조회 시, 불필요하게 LEFT OUTER JOIN이 두번이나 실행되게 된다. 따라서 등장하게 된 개념이 연관 관계의 주인인데, 이는 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이다. 그리고 이때 사용되는 속성 값이 mappedBy
이다.
따라서 다음과 같이 작성할 수 있다.
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
mappedBy로 어떤 객체가 주인인지 설정할 수 있다.
mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이다.
위 예시에서는 ProductDetail 엔티티가 Product 엔티티의 주인이 된다. 즉, mappedBy 값에 해당하는 엔티티가 주인이 된다.
중요한 점은 mappedBy를 지정한 컬럼(private productDetail
)은 DB에 컬럼이 생기지 않는다.
일반적으로 외래키를 가지는 쪽이 연관관계의 주인이다.
거의 대부분 다(1:N에서 N) 쪽이 연관관계의 주인이 된다.
예시로 상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우에는 다대일, 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다. 한번 다대일, 일대다 관계에서는 어떻게 지정하는지 알아보자.
공급업체는 Provider, 상품은 Product라 지정하겠다.
Product
@ManyToOne
@JoinColumn(name = "provider_id")
private Provider provider;
위와 같이 설정하면 Product 입장에서는 다대일이기 때문에 다대일 관계가 매핑된다.
Product 엔티티를 생성 후, Provider를 필드에 지정하면 자동적으로 DB에 Provider의 PK가 지정되어 삽입된다. 또한, Product 엔티티에서 단방향으로 Provider 엔티티 연관관계를 맺고 있기 때문에 ProductRepository만으로 Provider 객체도 조회가 가능하다.
이제 반대로 Provider를 통해 등록된 Product를 조회하기 위한 일대다 관계를 설정해보자.
Provider
@OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
private List<Product> productList = new ArrayList<>();
위와 같이 여러 Product 엔티티가 매핑될 수 있어 Collection 형태(List,Map etc..)로 생성된다.
fetch = FetchType.EAGER
로 지정한 것은 @OneToMany
의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정하였다.
프로젝트를 실행하면 Provider 테이블에는 컬럼이 생성되지 않는데, 이는 위에서도 설명한 mappedBy에 의해서 Product에 외래키 관리를 위임하여 그렇다. 즉, Provider는 외래키를 관리할 수 없다.
Provider는 외래키를 관리할 수 없다 했는데, 예시로 살펴보면 조금 더 이해가 편하다.
provider.getProductList().add(product1); //무시
provider.getProductList().add(product2); //무시
provider.getProductList().add(product3); //무시
위와 같이 productList
에 Product를 추가하게 되면 Provider는 연관관계의 주인이 아니기 때문에 데이터베이스에 반영되지 않는다.
상품(Product)과 상품 분류(Category)는 다대일 관계이다. 왜냐하면 여러개의 상품은 하나의 상품 분류에 들어갈 수 있기 때문이다.
Category
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "category_id")
private List<Product> products = new ArrayList<>();
위와 같이 작성하면, Product 테이블에 외래키로 category_id가 추가된다. 왜냐하면 연관관계는 항상 다(1:N에서 N) 쪽이 주인이기 때문이다.
데이터베이스 입장에서는 '다' 쪽이 외래키를 관리하게 되는데, 일대다 매핑에서는 '일'쪽이 연관관계의 주인이 된다. 따라서 Category를 통한 product 지정 시, 외래키가 Product 테이블에 있기 때문에 Update 쿼리가 발생하게 된다.
이 같은 문제를 해결하기 위해서는 일대다 양방향 연관관계를 사용하기보다 다대일 연관관계를 사용하는 것이 좋다.
다대다는 실무에서 거의 사용하지 않는다. 상품(Product)과 생산업체(Producer)의 예로 들자면 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수 있다.
다대다 연관관계에서는 서로가 리스트를 가지는 구조로 만들어진다. 이런 경우네는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해소한다.
Producer
@ManyToMany
private List<Product> products = new ArrayList<>();
위와 같이 설정하면, 리스트의 형식이라 컬럼이 추가 되지 않고, producer_products라는 중간 테이블이 생성된다. 이 테이블은 Product와 Producer의 PK 필드를 가져와 두개의 외래키가 설정된다.
중간 테이블의 이름을 바꾸고 싶다면, @JoinTable(name = "")
을 추가하여 이름을 지정한다.
Product
@ManyToMany
private List<Producer> producers = new ArrayList<>();
위와 같이 설정하면 양방향 관계가 설정된다.
이렇게 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있다. 하지만 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있다. 따라서 중간 테이블을 생성하는 것 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.
영속성 전이란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성 상태도 변경되는 것을 의미한다.
한번 어떤 종류가 있는지 살펴보자.
ALL
PERSIST
MERGE
REMOVE
REFERSH
DETACH
Provider
@OneToMany(mappedBy = "provider", cascade = CasCadeType.PERSIST)
private List<Product> productList = new ArrayList<>();
위와 같이 설정하고, Provider를 영속성 컨텍스트에 저장하면 코드에 작성돼 있는 Cascade.PERSIST
에 맞춰 Product 엔티티도 함께 저장된다.
하지만 상황과 사용법을 정확히 파악하고 사용해야 한다. REMOVE
와 REMOVE
을 포함하는 ALL
같은 타입을 무분별하게 사용하면 연관된 엔티티가 의도치 않게 모두 삭제될 수가 있기 때문이다.
JPA에서 고아란, 부모 엔티티와 연관관계가 끊어진 엔티티이다. 그리고 JPA에서는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
고아 객체 제거 기능의 사용법은 다음과 같다.
Provider
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Product> productList = new ArrayList<>();
orphanRemoval = true
는 고아 객체를 제거하는 기능이다. 이 기능을 적용하면 연관관계가 끊긴 product
엔티티는 자동적으로 제거된다.