글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.
왜 연관관계 매핑이 필요할까에 대해서 책에서 말하는 내용을 먼저 요약하자면
복잡한 애플리케이션에서 RDBMS를 사용할 때 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 사실상 불가능하다.
설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서
조인(Join)등의 기능을 활용해야 한다.
JPA를 사용하는 애플리케이션도 테이블의 연관관계를 엔티티(entity) 간의 연관관계로 표현할 수 있다.
하지만 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다.
JPA에서 이러한 제약을 보완하면서 연관관계를 매핑하고 사용하는 방법에 대해 알아본다.
연관관계 매핑 종류와 방향
- One To One : 일대일(1 : 1)
- One To Many : 일대다(1 : N)
- Many To One : 다대일(N :1)
- Many To Many : 다대다(N : M)
- 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
- 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
- 연관관계가 설정 시 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서 주인(Owner)이라는 개념이 사용 일반적으로 외래키를 가진 테이블이 관계의 주인 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행 가능하다.
일대일 매핑(1 : 1)
예시) 하나의 상품에 하나의 상품정보만 매핑이 되는구조(1 : 1)
// 상품 엔티티
package com.springboot.relationship.data.entity;
import java.util.ArrayList;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToMany;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer price;
@Column(nullable = false)
private Integer stock;
@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
@ManyToMany
@ToString.Exclude
private List<Producer> producers = new ArrayList<>();
public void addProducer(Producer producer){
this.producers.add(producer);
}
}
// 상품정보 엔티티
package com.springboot.relationship.data.entity;
import lombok.*;
import javax.persistence.*;
@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ProductDetail extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
/*
@OneToOne 어노테이션에 'optional=false' 속성 설정 시 product가 null인 값을 허용하지 않음
*/
@OneToOne //(optional=false)
@JoinColumn(name = "product_number")
private Product product;
}
- @JoinColumn : 어노테이션을 사용해 매핑할 외래키 설정
- name : 매핑할 외래키의 이름을 설정
- referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정
- foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정(unique, nullable, updatable 등)
// 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스 생성
package com.springboot.relationship.data.repository;
import com.springboot.relationship.data.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
// ProductRepository와 ProductDetailRepository에 대한 테스트 코드
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());
}
}
일대일 양방향 매핑
- 앞에서 생성한 일대일 단방향 설정을 양방향으로 설정 변경
- 사실 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의
- 일대일 양방향을 위한 Product 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer price;
@Column(nullable = false)
private Integer stock;
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
}
- JPA에서도 실제 데이터 베이스의 연관관계를 반영해서 한쪽 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다.
- 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성 값이 mappedBy, mappedBy는 어떤 객체가 주인인지 표시하는 속성, 여기서는 Product 객체에 붙음
- 양방향으로 연관관계 설정 후 toString 사용할 때 순환참조 발생
- 대체로 단방향으로 연관관계 설정 및 양방향 설정이 필요할 경우 순환참조 제거를 위해 exclude를 사용해 ToString에서 제외 설정하는 것이 좋다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
다대일(N : 1), 일대다(1 : N) 매핑
- 상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우에는 다대일
- 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다.
다대일 단방향 매핑
- 공급업체 엔티티 클래스(Provider)
- BaseEntity를 통해 생성일자와 변경일자를 상속받는다.
- 일반적으로 외래키를 갖는 쪽이 주인 역할을 수행하기 때문에 상품 엔티티가 공급업체 엔티티의 주인
// 공급업체 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 상품 엔티티와 공급업체 엔티티의 다대일 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer price;
@Column(nullable = false)
private Integer stock;
@OneToOne(mappedBy = "product")
@ToString.Exclude // 예제 9.8
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
// ProviderRepository 인터페이스
public interface ProviderRepository extends JpaRepository<Provider, Long> {
}
// Product, Provider 엔티티 연관관계 테스트
@SpringBootTest
class ProviderRepositoryTest {
@Autowired
ProductRepository productRepository;
@Autowired
ProviderRepository providerRepository;
@Test
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());
}
}
다대일 양방향 매핑
- 공급업체 엔티티와 상품 엔티티의 일대다 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
// 일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(Collection, List, Map)형식으로 필드를 생성
private List<Product> productList = new ArrayList<>();
}
// Provider 엔티티 기반의 테스트 코드
@SpringBootTest
class ProviderRepositoryTest {
@Autowired
ProductRepository productRepository;
@Autowired
ProviderRepository providerRepository;
@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.getProductList().add(product1); // 무시
provider.getProductList().add(product2); // 무시
provider.getProductList().add(product3); // 무시
일대다 단방향 매핑
- OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없다.
// 상품분류 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
@EqualsAndHashCode
@Table(name = "category")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String code;
private String name;
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "category_id")
private List<Product> products = new ArrayList<>();
}
// CategoryRepository 인터페이스
public interface CategoryRepository extends JpaRepository<Category, Long> {
}
// CategoryRepository를 활용한 테스트
@SpringBootTest
class CategoryRepositoryTest {
@Autowired
ProductRepository productRepository;
@Autowired
CategoryRepository categoryRepository;
@Test
void relationshipTest(){
// 테스트 데이터 생성
Product product = new Product();
product.setName("펜");
product.setPrice(2000);
product.setStock(100);
productRepository.save(product);
Category category = new Category();
category.setCode("S1");
category.setName("도서");
category.getProducts().add(product);
categoryRepository.save(category);
// 테스트 코드
List<Product> products = categoryRepository.findById(1L).get().getProducts();
for(Product foundProduct : products){
System.out.println(product);
}
}
}
다대다 매핑
- 다대다(M : N) 연관관계는 실무에서 거의 사용되지 않는 구성
- ex) 상품과 생산업체 → 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있다.
- 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.
- 이런 경우 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해결
다대다 단방향 매핑
// 생산업체 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "producer")
public class Producer extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String code;
private String name;
@ManyToMany
@ToString.Exclude
private List<Product> products = new ArrayList<>();
public void addProduct(Product product){
products.add(product);
}
}
// ProducerRepository 인터페이스
public interface ProducerRepository extends JpaRepository<Producer, Long> {
}
// 생산업체 연관관계 테스트
@SpringBootTest
class ProducerRepositoryTest {
@Autowired
ProducerRepository producerRepository;
@Autowired
ProductRepository productRepository;
@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());
}
private Product saveProduct(String name, Integer price, Integer stock) {
Product product = new Product();
product.setName(name);
product.setPrice(price);
product.setStock(stock);
return productRepository.save(product);
}
private Producer saveProducer(String name) {
Producer producer = new Producer();
producer.setName(name);
return producerRepository.save(producer);
}
}
다대다 양방향 매핑
// 상품 엔티티에서 생산업체 엔티티 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer price;
@Column(nullable = false)
private Integer stock;
@OneToOne(mappedBy = "product")
@ToString.Exclude // 예제 9.8
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
@ManyToMany
@ToString.Exclude
private List<Producer> producers = new ArrayList<>();
public void addProducer(Producer producer){
this.producers.add(producer);
}
}
// 다대다 양방향 연관관계 테스트
@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());
}
- 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있다.
- 다만 다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있음
- 즉 관리하기 힘든 포인트가 발생한다는 문제가 있다.
- 다대다 연관관계의 한계를 극복하기 위해 중간 테이블 생성 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.
영속성 전이
- 영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미
영속성 전이 적용
- 여기서 사용할 엔티티는 상품 엔티티와 공급업체 엔티티
- 예)한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용이 되는 상
// 공급업체 엔티티에 영속성 전이 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@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;
}
고아 객체
- JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미
- JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
// 공급업체 엔티티에 고아 객체를 제거하는 기능을 추가
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
// 고아 객체의 제거 기능 테스트
@Test
@Transactional
void orphanRemovalTest() {
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.saveAndFlush(provider);
System.out.println("## Before Removal ##");
System.out.println("## provider list ##");
providerRepository.findAll().forEach(System.out::println);
System.out.println("## product list ##");
productRepository.findAll().forEach(System.out::println);
// 연관관계 제거
Provider foundProvider = providerRepository.findById(1L).get();
foundProvider.getProductList().remove(0);
System.out.println("## After Removal ##");
System.out.println("## provider list ##");
providerRepository.findAll().forEach(System.out::println);
System.out.println("## product list ##");
productRepository.findAll().forEach(System.out::println);
}
정리
- 이번 장에서는 연관관계를 설정하는 방법과 영속성 전이라는 개념을 알아봄
- JPA 사용 시 영속이라는 개념은 매우 중요
- 코드를 통해 편리하게 DB에 접근하기 위해서는 중간에서 엔티티의 상태를 조율하는 영속성 컨텍스트가 어떻게 동작하는지 이해해야 한다.
'북스터디 > 스프링 부트 핵심 가이드' 카테고리의 다른 글
Chapter 10. 유효성 검사와 예외처리 (0) | 2023.06.28 |
---|---|
Chapter 08. Spring Data JPA 활용 (0) | 2023.06.11 |
Chapter 06. 데이터베이스 연동 (3) | 2023.06.11 |
Chapter 05. API를 작성하는 다양한 방법 (0) | 2023.05.27 |
Chapter 04. 스프링 부트 애플리케이션 개발하기 (0) | 2023.05.27 |