본 게시글은 '스프링 부트 핵심 가이드' 책의 내용을 정리한 것입니다.
저자 : 장정우
출판사 : 위키북스
9.1 연관관계 매핑 종류와 방향
연관관계 매핑 종류
- One To One : 일대일(1:1)
- One To Many : 일대다(1:N)
- Many To One : 다대일(N:1)
- Many To Many :다대다(N:M)
데이터베이스에서는 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성되지만 JPA는 엔티티 간 참조 방향을 설정할 수 있다.
- 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
- 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 외래키를 가진 테이블을 주인이라고 하며, 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행할 수 있다.
9.2 프로젝트 생성
책에서 실습을 위해 구성한 프로젝트 설정은 다음과 같다.
- groupId : com.springboot
- artifactId : relationship
- name : relationship
- Developer Tools : Lombok, Spring Configuration Processor
- Web : Spring Web
- SQL : Spring Data JPA, MariaDB Driver
+ 8장에서 작성한 자바 파일들
9.3 일대일 매핑
9.3.1 일대일 단방향 매핑
일대일 매핑 예시
@OneToOne //(optional=false)
@JoinColumn(name = "product_number")
private Product product;
@OneToOne 어노테이션은 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용한다. 본 예시에서는 ProductDetail 엔티티에 Product 엔티티를 일대일 매핑하기 위해 작성한 코드이다.
@JoinColumn 어노테이션은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만 의도한 이름이 들어가지 않기 때문에 name 속성을 사용해 원하는 칼럼명을 지정하는 것이 좋다.
@JoinColumn 어노테이션에서 사용할 수 있는 속성
- name : 매핑할 외래키의 이름을 설정
- referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정
- foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정(unique, nullable, insertable, updatable 등)
활용 예
package com.springboot.relationship.data.repository;
import com.springboot.relationship.data.entity.Product;
import com.springboot.relationship.data.entity.ProductDetail;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ProductDetailRepositoryTest {
@Autowired
ProductDetailRepository productDetailRepository;
@Autowired
ProductRepository productRepository;
@Test
public void saveAndReadTest1() {
Product product = new Product();
product.setName("스프링 부트 JPA");
product.setPrice(5000);
product.setStock(500);
productRepository.save(product);
ProductDetail productDetail = new ProductDetail();
productDetail.setProduct(product);
productDetail.setDescription("스프링 부트와 JPA를 함께 볼 수 있는 책");
productDetailRepository.save(productDetail);
// 생성한 데이터 조회
System.out.println("savedProduct : " + productDetailRepository.findById(
productDetail.getId()).get().getProduct());
System.out.println("savedProductDetail : " + productDetailRepository.findById(
productDetail.getId()).get());
}
}
ProductDetail 객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 ProductDetailRepository에서 ProductDetail 객체를 조회한 후 연관 매핑된 Product 객체를 조회할 수 있다.(윗부분은 의존성 주입 및 객체 저장하는 부분)
엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 '즉시 로딩'이라고 한다.
Hibernate:
select
productdet0_.id as id1_1_0_,
productdet0_.created_at as created_2_1_0_,
productdet0_.updated_at as updated_3_1_0_,
productdet0_.description as descript4_1_0_,
productdet0_.product_number as product_5_1_0_,
product1_.number as number1_0_1_,
product1_.created_at as created_2_0_1_,
product1_.updated_at as updated_3_0_1_,
product1_.name as name4_0_1_,
product1_.price as price5_0_1_,
product1_.stock as stock6_0_1_
from
product_detail productdet0_
left outer join
product product1_
on productdet0_.product_number=product1_.number
where
productdet0_.id=?
출력된 쿼리를 보면 left outer join이 수행된다는 것을 확인할 수 있다.
@OneToOne 어노테이션 때문인데 @OneToOne 어노테이션을 확인하면 기본 fetch 전략으로 EAGER, optional() 메서드가 기본값으로 true로 설정되어 있다.(true이면 nullable, false이면 null 허용하지 않는다.) optional() 속성을 false로 설정하면 테이블 생성 쿼리에서 not null이 설정되고, left outer join이 아닌 inner join으로 바뀌어 실행된다.
이처럼 객체에 대한 설정에 따라 JPA는 최적의 쿼리를 생성해서 실행한다.
★ JPA Fetch 전략 (참고 : https://rutgo-letsgo.tistory.com/199)
즉시 로딩(EAGER)
- 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
- 즉시로딩을 사용하면 실제 엔티티를 불러온다.
- 즉시 로딩을 최적화하기 위해 가능하면 조인쿼리를 사용한다.
- 기본 전략이 즉시 로딩인 어노테이션 : @ManyToOne, @OneToOne
지연 로딩(LAZY)
- 연관된 엔티티를 실제 사용할 때 조회한다.
- 이때 실제 객체가 아닌 프록시 객체를 넣어놓는다.
- 기본 전략이 지연 로딩인 어노테이션 : @OneToMany, @ManyToMany
9.3.2 일대일 양방향 매핑
객체에서 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다.
앞서 ProductDetail에서 product를 매핑했으니 Product에서 ProductDetail을 매핑하면 된다.
@OneToOne
private ProductDetail productDetail;
양방향 매핑 후 아까 활용 예제를 실행하면 쿼리가 바뀐 것을 확인할 수 있다.
Hibernate:
select
productdet0_.id as id1_1_0_,
productdet0_.created_at as created_2_1_0_,
productdet0_.updated_at as updated_3_1_0_,
productdet0_.description as descript4_1_0_,
productdet0_.product_number as product_5_1_0_,
product1_.number as number1_0_1_,
product1_.created_at as created_2_0_1_,
product1_.updated_at as updated_3_0_1_,
product1_.name as name4_0_1_,
product1_.price as price5_0_1_,
product1_.product_detail_id as product_7_0_1_,
product1_.stock as stock6_0_1_,
productdet2_.id as id1_1_2_,
productdet2_.created_at as created_2_1_2_,
productdet2_.updated_at as updated_3_1_2_,
productdet2_.description as descript4_1_2_,
productdet2_.product_number as product_5_1_2_
from
product_detail productdet0_
left outer join
product product1_
on productdet0_.product_number=product1_.number
left outer join
product_detail productdet2_
on product1_.product_detail_id=productdet2_.id
where
productdet0_.id=?
여러 테이블끼리 연관관계가 설정돼 있어 여러 left outer join이 설정되는 것은 괜찮으나 양쪽에서 외래키를 가지고 left outer join이 두 번이나 실행되는 것은 효율성이 떨어진다. 따라서 실제 데이터베이스처럼 한쪽 테이블에서만 외래키를 바꿀 수 있도록 하는 것이 좋다.
이때 사용되는 속성 값이 mappedBy이다. mappedBy는 어떤 객체가 주인인지 표시하는 속성이라고 할 수 있다.
다음과 같이 mappedBy 속성을 사용한다.
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이다.
이렇게 할 경우 ProductDetail 엔티티가 Product 엔티티의 주인이 된다.
Product 테이블에 외래키가 사라진 것을 확인할 수 있다.
양방향으로 설정되면 ToString을 사용할 때 순환참조가 발생하기 때문에 필요한 경우가 아니라면 대체로 단방향으로 연관관계를 설정하거나 양방향 설정이 필요한 경우에는 순환참조 제거를 위해 exclude를 사용해 ToString에서 제외 설정을 하는 것이 좋다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
9.4 다대일, 일대다 매핑
9.4.1 다대일 단방향 매핑
상품 엔티티와 공급업체 엔티티 사이에 다대일 매핑을 작성하려면 다음과 같이 작성해 준다.
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
공급업체(Provider) 엔티티에 대한 다대일 연관관계를 설정한 것으로 일반적으로 외래키를 갖는 쪽이 주인의 역할을 수행하기 때문에 이 경우 상품(Product) 엔티티가 공급업체 엔티티의 주인이다.
Hibernate:
insert
into
product
(created_at, updated_at, name, price, provider_id, stock)
values
(?, ?, ?, ?, ?, ?)
Product 객체를 저장할 때 쿼리를 보면 provider_id가 들어가는 것을 확인할 수 있다. @JoinColumn에 설정한 이름을 기반으로 자동으로 값을 선정해서 추가한다.
void relationshipTest1() {
Provider provider = new Provider();
provider.setName("ㅇㅇ물산");
providerRepository.save(provider);
Product product = new Product();
product.setName("가위");
product.setPrice(5000);
product.setStock(500);
product.setProvider(provider);
productRepository.save(product);
System.out.println(
"product : " + productRepository.findById(1L).orElseThrow(RuntimeException::new));
System.out.println("provider : " + productRepository.findById(1L).orElseThrow(RuntimeException::new).getProvider());
}
Product 엔티티에서 단방향으로 Provider 엔티티 연관관계를 맺고 있기 때문에 ProductRepository만으로도 Provider 객체 조회가 가능하다.
9.4.2 다대일 양방향 매핑
앞서 적었던 것처럼 양쪽에서 단방향으로 매핑하는 것이 양방향 매핑 방식이다.
다대일 단방향 매핑에서 공급업체를 통해 등록된 상품을 조회하기 위한 일대다 연관관계를 추가로 설정하면 양방향 매핑이 된다.
@OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(Collection, List, Map) 형식으로 필드를 생성한다. @OneToMany가 붙은 쪽에서 @JoinColumn 어노테이션을 사용하면 상대 엔티티에 외래키가 설정된다. fetch 속성은 위에서 즉시로딩과 지연로딩의 설명에서 본 것처럼 @OneToMany는 지연로딩을 기본값으로 하기 때문에 즉시로딩으로 조정한 것이다.
양쪽에서 연관관계를 설정하고 있기 때문에 mappedBy를 통해 한쪽으로 외래키 관리를 위임한 것이다.
활용 예
@Test
void relationshipTest() {
// 테스트 데이터 생성
Provider provider = new Provider();
provider.setName("ㅇㅇ상사");
providerRepository.save(provider);
Product product1 = new Product();
product1.setName("펜");
product1.setPrice(2000);
product1.setStock(100);
product1.setProvider(provider);
Product product2 = new Product();
product2.setName("가방");
product2.setPrice(20000);
product2.setStock(200);
product2.setProvider(provider);
Product product3 = new Product();
product3.setName("노트");
product3.setPrice(3000);
product3.setStock(1000);
product3.setProvider(provider);
productRepository.save(product1);
productRepository.save(product2);
productRepository.save(product3);
System.out.println("check 1");
List<Product> products = providerRepository.findById(provider.getId()).get()
.getProductList();
for (Product product : products) {
System.out.println(product);
}
}
Provider 엔티티 클래스는 Product 엔티티와의 연관관계에서 주인이 아니기 때문에 외래키를 관리할 수 없다. 그래서 Provider를 등록한 후 Product에 객체를 설정하는 작업을 통해 데이터베이스에 저장한다.
9.4.3 일대다 단방향 매핑
상품 테이블과 상품 분류 테이블을 가지고 일대다 단방향 매핑을 구현한다.
상품 분류 엔티티 클래스에 다음과 같이 생성한다.
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "category_id")
private List<Product> products = new ArrayList<>();
이렇게 하면 상품 엔티티에 별도의 설정을 하지 않아도 일대다 단방향 연관관계가 매핑되고 상품 테이블에 외래키가 추가된다.
일대다 단방향 관계의 단점은 매핑의 주체가 아닌 반대 테이블에 외래키가 추가된다는 것이다.(N이 외래키를 가지고 있음) 다대일 구조와 다르게 외래키를 설정하기 위해 다른 테이블에 대한 update 쿼리를 발생시킨다. 이러한 문제를 해결하기 위해서는 다대일 연관관계를 사용하는 것이 좋다.
★ 일대다 양방향 매핑 ( 참고 : https://ict-nroo.tistory.com/125)
- 이런 매핑은 공식적으로는 존재하지 않는다.
- @JoinColumn(name = "team_id", insertable = false, updatable = false) 사용.
- @ManyToOne과 @JoinColumn을 사용해서 연관관계를 매핑하면, 다대일 단방향 매핑이 되어버린다. 근데 반대쪽 Team에서 이미 일대다 단방향 매핑이 설정되어있다. 이런 상황에서는 두 엔티티에서 모두 테이블의 FK 키를 관리 하게 되는 상황이 벌어진다.
- 그걸 막기 위해서 insertable, updatable 설정을 FALSE로 설정하고 읽기 전용 필드로 사용해서 양방향 매핑처럼 사용하는 방법이다.
- 읽기 전용으로 사용할 경우도 있을 수 있으니, 알아두자.
- 그러나, 결론은 다대일 양방향을 사용하자.
9.5 다대다 매핑
다대다 연관관계는 실무에서 거의 사용되지 않는 구성이다. 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다. 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해소한다.
9.5.1 다대다 단방향 매핑
상품과 생산업체 엔티티를 통해 다대다 단방향 매핑을 구현한다.
생산업체 엔티티에 다음과 같은 코드를 작성한다.
@ManyToMany
@ToString.Exclude
private List<Product> products = new ArrayList<>();
public void addProduct(Product product){
products.add(product);
}
리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @JoinColumn은 설정하지 않아도 된다.
이렇게 생성된 생산업체 테이블에는 외래키가 추가되지 않는다.
그리고 DB에 중간 테이블이 생성된다.
테이블 이름을 관리하고 싶다면 @ManyToMany 어노테이션 아래에 @JoinTable(name = "이름")을 추가한다.
중간 테이블의 경우 상품 테이블과 생산업체 테이블에서 id 값을 가져와 두 개의 외래키가 설정되어 있다.
활용 예
@Test
@Transactional
void relationshipTest() {
Product product1 = saveProduct("동글펜", 500, 1000);
Product product2 = saveProduct("네모 공책", 100, 2000);
Product product3 = saveProduct("지우개", 152, 1234);
Producer producer1 = saveProducer("flature");
Producer producer2 = saveProducer("wikibooks");
producer1.addProduct(product1);
producer1.addProduct(product2);
producer2.addProduct(product2);
producer2.addProduct(product3);
producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
System.out.println(producerRepository.findById(1L).get().getProducts());
}
출력 결과
[Product(super=BaseEntity(createdAt=2023-09-24T18:00:59.444523700, updatedAt=2023-09-24T18:00:59.444523700), number=1, name=동글펜, price=500, stock=1000), Product(super=BaseEntity(createdAt=2023-09-24T18:00:59.493706600, updatedAt=2023-09-24T18:00:59.493706600), number=2, name=네모 공책, price=100, stock=2000)]
9.5.2 다대다 양방향 매핑
상품 엔티티에서 다음 코드를 작성한다.
@ManyToMany
@ToString.Exclude
private List<Producer> producers = new ArrayList<>();
public void addProducer(Producer producer){
this.producers.add(producer);
}
필요에 따라 mappedBy 속성을 사용해 두 엔티티 간 연관관계의 주인을 설정할 수도 있다.
활용 예
@Test
@Transactional
void relationshipTest2() {
Product product1 = saveProduct("동글펜", 500, 1000);
Product product2 = saveProduct("네모 공책", 100, 2000);
Product product3 = saveProduct("지우개", 152, 1234);
Producer producer1 = saveProducer("flature");
Producer producer2 = saveProducer("wikibooks");
producer1.addProduct(product1);
producer1.addProduct(product2);
producer2.addProduct(product2);
producer2.addProduct(product3);
product1.addProducer(producer1);
product2.addProducer(producer1);
product2.addProducer(producer2);
product3.addProducer(producer2);
producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
productRepository.saveAll(Lists.newArrayList(product1, product2, product3));
System.out.println("products : " + producerRepository.findById(1L).get().getProducts());
System.out.println("producers : " + productRepository.findById(2L).get().getProducers());
}
양방향 연관관계 설정을 위해 product1,2,3에 addProducer가 추가되었다. 이를 출력하면 다음과 같이 나온다.
products : [Product(super=BaseEntity(createdAt=2023-09-24T18:11:16.515055800, updatedAt=2023-09-24T18:11:16.515055800), number=1, name=동글펜, price=500, stock=1000), Product(super=BaseEntity(createdAt=2023-09-24T18:11:16.570041400, updatedAt=2023-09-24T18:11:16.570041400), number=2, name=네모 공책, price=100, stock=2000)]
producers : [Producer(super=BaseEntity(createdAt=2023-09-24T18:11:16.577248600, updatedAt=2023-09-24T18:11:16.577248600), id=1, code=null, name=flature), Producer(super=BaseEntity(createdAt=2023-09-24T18:11:16.583768900, updatedAt=2023-09-24T18:11:16.583768900), id=2, code=null, name=wikibooks)]
이렇게 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있다. 다만 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있기에 중간테이블을 생성하는 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.
9.6 영속성 전이
영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다.
cascade() 요소와 함께 사용하는 영속성 전이 타입
- ALL : 모든 영속 상태 변경에 대해 영속성 전이를 적용
- PERSIST : 엔티티가 영속화할 때 연관된 엔티티도 함께 영속화
- MERGE : 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
- REMOVE : 엔티티를 제거할 때 연관된 엔티티도 제거
- REFRESH : 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
- DETACH : 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외
9.6.1 영속성 전이 적용
상품 엔티티와 공급업체 엔티티를 통해 영속성 전이의 예를 구현한다.
// 공급업체 엔티티에 영속성 전이 설정
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
활용 예
@Test
void cascadeTest() {
Provider provider = savedProvider("새로운 공급업체");
Product product1 = savedProduct("상품1", 1000, 1000);
Product product2 = savedProduct("상품2", 500, 1500);
Product product3 = savedProduct("상품3", 750, 500);
// 연관관계 설정
product1.setProvider(provider);
product2.setProvider(provider);
product3.setProvider(provider);
provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));
providerRepository.save(provider);
}
private Provider savedProvider(String name) {
Provider provider = new Provider();
provider.setName(name);
return provider;
}
private Product savedProduct(String name, Integer price, Integer stock) {
Product product = new Product();
product.setName(name);
product.setPrice(price);
product.setStock(stock);
return product;
}
부모 엔티티가 되는 Provider 엔티티만 저장하면 코드에 작성돼 있는 Cascade.PERSIST 에 맞춰 상품 엔티티도 별도의 저장하는 코드 없이 함께 저장할 수 있다.
특정 상황에 맞춰 영속성 전이 타입을 설정하면 영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행할 수 있어 개발의 생산성이 높아지나, 잘못 사용하면 연관된 엔티티에도 영향을 줄 수 있기 때문에 잘 고려하여 사용해야 한다.
9.6.2 고아 객체
고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다. JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
고아 객체를 제거하는 기능은 다음과 같이 설정한다.
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
'orphanRemoval = true' 속성이 고아 객체를 제거하는 기능이다. 연관관계가 제거되면 하이버네이트에서 상태 감지를 통해 삭제하는 쿼리가 수행된다.
별도로 검색하여 내용에 추가한 부분은 ★로 표시
'개발 지식 기록 > 북스터디' 카테고리의 다른 글
[스프링 부트 핵심 가이드] 11. 액추에이터 활용하기 (0) | 2023.10.08 |
---|---|
[스프링 부트 핵심 가이드] 10. 유효성 검사와 예외 처리 (0) | 2023.10.01 |
[스프링 부트 핵심 가이드] 08. Spring Data JPA 활용 (0) | 2023.09.17 |
[스프링 부트 핵심 가이드] 06. 데이터 베이스 연동 (0) | 2023.09.10 |
[스프링 부트 핵심 가이드] 05. API를 작성하는 다양한 방법 (0) | 2023.09.03 |