상세 컨텐츠

본문 제목

[스프링3] 9장 연관관계 매핑

23-24/Spring 3

by recoday 2023. 12. 1. 10:00

본문

728x90

 

9. 연관관계 매핑

9.1 연관관계 매핑 종류와 방향

9.1.1 연관관계를 맺는 두 엔티티 간에 생성할 수 있는 연관관계의 종류

  • One To One : 일대일
  • One To Many : 일대다
  • Many To One: 다대일
  • Many To Many: 다대다

 

  • 공급 업체 입장에서 보면 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와는 일대다 관계
  • 상품 입장에서 보면 하나의 공급업체에 속하게 되므로 다대일 관계
  • 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라짐

 
 

  • 데이터베이스에서는 두 테이블의 연관관계를 설정하면 "외래키"를 통해 서로 조인해서 참조하는 구조로 생성
  • JPA를 사용하는 객체지향 모델에서는 엔티티 간 참조 방향을 설정
  • 데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만 비즈니스 로직의 관점에서 봤을 때 는 단방향 관계만 설정해도 해결되는 경우가 많음
    • 단방향: 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
    • 양방향: 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
  • 연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키
  • 일반적으로 외래키를 가진 테이블이 그 관계의 주인 (Owner)이 됨
  • 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행

 

9.2 프로젝트 생성

• groupld: com,springboot
• artifactid: relationship
• name: relationship
• Developer Tools: Lombok, Spring Configuration Processor
• Web: Spring Web
• SQL: Spring Data JPA. MariaDB Driver

이전에 사용한 소스코드를 다음과 같이 그대 로 가져와 사용합니다.
(이에 따라 queryDSL의 의존 성과 플러그인 설정을 Don. xml 파일에 그대로 추가해야함)
 

 

9.3 일대일 매핑

  • Product 엔티티를 대상으로 상품 정보 테이블을 일대일 매핑하는 예제
  • 하나의 상품에 하나의 상품정보만 매핑되는 일대일 관계

 

9.3.1 일대일 단방향 매핑

  • entity 패키지 안에 상품 정보 ProductDetail  엔티티를 작성
@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
    @JoinColumn(name = "product_number")
    private Product product;
}

 

  • @OneToOne 어노테이션: 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용
  • @JoinColum 어노테이션: 매핑할 외래키를 설
  • @JoinColum 어노테이션은 기본값이 설정되어 있어 자동으로 이름을 매핑,
    그러나 의도한 이름이 들어가지 않기 때문에 name 속성을 사용해 원하는 칼럼명을 지정하는 것이 좋음
  • 간단하게 @JoinColum 어노테이션에서 사용할 수 있는 속성들
    • name: 매핑할 외래키의 이름을 설정합니다.
    • referencedColumnNane: 외래키가 참조할 상대 테이블의 칼럼명을 지정합니다.
    • foreignkey: 외래키를 생성하면서 지정할 제약조건을 설정합니다(unique. nul lable, insertable. updatable 등).

 

  • 생성된 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스를 생성
// data/repository/ProductDetailRepository.java
public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {
}

 

  • 연관 관계를 활용한 데이터 생성 및 조회 기능 테스트 코드
// test/com.springboot.relationship/data/repository/ProductDetailRepositoryTest.java
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
public 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());
        
    }
}

 

  • 상품과 상품정보에 매핑된 리포지토리에 대해 의존성 주입
  • ProductDeatil 객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 ProductDetailRepository에서 ProductDetail 객체를 조회한 후 연관관계 매핑된 Product 객체를 조회

 

  • 테스트를 실행하면 하이버네이트로 결과 확인
  • select 구문을 보면 ProductDetail 객체와 Product 객체가 함께 조회됨
  • 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 '즉시 로딩'이라 함

 

  • @OneToOne 어노테이션 인터페이스
public @interface OneToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}

 
 

  • @ OneToOne 어노테이션은 기본 fetch 전략으로 EAGER(즉시 로딩) 전략이 채택
  • optional() 메서드는 기본값으로 true가 설정
    • 기본값이 true인 상태는 매핑되는 값이 nullable이라는 것을 의미
  • @OneToOne 어노테이션에 'optional=false' 설정을 하면 product가 null인 값을 허용하지 않음
    • 코드 변경 후 테스트 코드를 실해앟면 left outer join이 inner join으로 바뀌어 실행되는 것을 확인함
  • 객체에 대한 설정에 따라 JPA는 최적의 쿼리를 생성해서 실행할 수 있음

 

9.3.2 일대일 양방향 매핑

  • 이번에는 앞에서 생성한 일대일 단방향 설정을 양방향 설정으로 변경
  • 객체에서의 양 방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미

 

  • Product 엔티티에 일대일 양방향 매핑을 위해서는 다음과 같이 추가
@Entity
// 생략..
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
    private ProductDetail productDetail;

}

 

  • 코드를 실행하면 product 테이블에도 칼럼이 생성됨

 

  • 테스트 코드를 실행하면 양쪽에서 외래키를 가지고 left outer join이 두 번이나 수행되어 효율성이 떨어지는 것을 확인할 수 있음
  • 실제 데이터베이스에서도 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조
  • 앞에서 언급한 '주인' 개념을 사용함

 

  • JPA에서도 데이터베이스의 연관관계를 반영해서 한 쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋음
  • 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줌
  •  mappedBy : 어떤 객체가 주인인지 표시하는 속성
@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;

}

 

  • mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름
  • ProductDetail 엔티티가 Product 엔티티의 주인이 됨

 

  • 테스트 코드를 실행하면 toString 실행 시점에서 StackOverflowError 발생
  • 양방향으로 연관관계가 설정되면 ToString을 사용할 때 순환 참조가 발생하기 때문
  • 필요한 경우가 아니라면 대체로 단방향으로 연관관계를 설정하거나 양방향 설정이 필요할 경우에는 순환참조 제거를 위해 exclude를 사용해 ToString에서 제외 설정해야함
// data/entity/Product.java
    @OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;

 
 

9.4 다대일, 일대다 매핑

  • 상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우에는 다대일 관계
  • 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계

 

9.4.1 다대일 단방향 매핑

  • 공급업체 테이블에 매핑되는 Provider 엔티티 클래스 생성
@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;
}
  • BaseEntity를 통해 생성일자와 변경일자를 상속받음

 
 

  • 상품 엔티티에 공급 업체 번호를 받기 위한 엔티티 필드 구성 추가
package com.springboot.relationship.data.entity;
// 생략..

public class Product extends BaseEntity  {

    // 생략..
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

 

  • 일반적으로 외래키를 갖는 쪽이 주인의 역할을 수행
  • 이 경우 상품 엔티티가 공급업체 엔티티의 주인입니다. 

  •  ProviderRepository를 생성
public interface ProviderRepository extends JpaRepository<Provider, Long> {
}

 
 

  • ProductRepository를 활용한 테스트
package com.springboot.relationship.data.repository;

import com.springboot.relationship.data.entity.Product;
import com.springboot.relationship.data.entity.Provider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class ProviderRepositoryTest {
    // 의존성 주입
    @Autowired
    ProviderRepository providerRepository;
    
    @Autowired
    ProductRepository productRepository;
    
    @Test
    void relationshipTest1(){
        // 테스트 데이터 생산
        Provider provider = new Provider();
        provider.setName("Corner");
        
        providerRepository.save(provider);
        
        Product product = new Product();
        product.setName("가위");
        product.setPrice(5000);
        product.setStock(500);
        productRepository.save(product);

        // 테스트
        System.out.println("product: " + productRepository.findById(1L)
                .orElseThrow(RuntimeException::new));

        System.out.println("provider: " + productRepository.findById(1L)
                .orElseThrow(RuntimeException::new).getProvider());

    }
}
  • 테스트 코드를 실행하면 쿼리로 데이터를 저장할 때 provider_id 값만 들어감
  • product 테이블에 @JoinColum에 설정한 이름을 기반으로 자동으로 값을 선정해서 추가
  • Product 엔티티에서 단방향으로 Provider 엔티티 연관관계를 맺고 있기 때문에 ProductRepository만으로 Provider 객체도 조회가 가능

 
 

9.4.2 다대일 양방향 매핑

  • 공급업체를 통해 등록된 상품을 조회하기 위한 일대다 연관관계를 설정
  • 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;

    // @OneToMny의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정
    @OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

 

  • 일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(collection, List, Map) 형식으로 필드를 생성
  • @OneToMany가 붙은 쪽에서 @JoinColum 어노테이션을 사용하면 상대 엔티티에 외래키가 설정
  • 롬복의 ToString에 의해 순환참조가 발생할 수 있어 Tostring에서 제외 처리를 하는 것이 좋음

 

  • 공급업체 엔티티로 연관된 엔티팅릐 값을 가져올 수 있는지 테스트 코들르 작성해 테스트
@SpringBootTest
public class ProviderRepositoryTest {
    @Autowired
    ProviderRepository providerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    void relationshipTest(){
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("Corner");

        providerRepository.save(provider);

        Product product1 = new Product();
        product1.setName("펜");
        product1.setPrice(1000);
        product1.setStock(1000);
        product1.setProvider(provider);

        Product product2 = new Product();
        product2.setName("가방");
        product2.setPrice(5000);
        product2.setStock(300);
        product2.setProvider(provider);

        Product product3 = new Product();
        product3.setName("모자");
        product3.setPrice(500);
        product3.setStock(50);
        product3.setProvider(provider);

        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        List<Product> productList = providerRepository.findById(provider.getId()).get().getProductList();
    
        for(Product product: productList){
            System.out.println(product);
        }
    }
}

 

9.4.3 일대다 단방향 매핑

 

  • 상품 분류 테이블 생성
  • 상품 분류의 도메인 이름은 Category로 설정

 
먼저 상품 분류 엔티티와 레포지토리를 생성합니다.

// data/entity/Category.java
@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<>();
}

 

  • 코드를 실행하면 상품 분류 테이블이 생성되고 상품 테이블 외래키가 추가되는 것을 확인
  • @OneToMany와 @JoinColum을 사용하면 상품 엔티티에서 별도의 설정 없이 일대다 단방향 연관관계가 매핑
  • @JoinColum 어노테이션은 필수 사항은 아님
    이 어노테이션을 사용하지 않으면 중간 테이블로 Join 테이블이 생성되는 전략이 채택

 

  • 지금 같은 일대다 단방향 관계의 단점은 매핑의 주체가 아닌 반대 테이블에 외래키가 추가된다는 점
  • 이 방식은 다대일 구조와 다르게 외래키를 설정하기 위해 다른 테이블에 대한 update 쿼리를 발생
  • 이를 확인하기 위해 저장할 레포지토리와 테스트 코드 작성
// data/repository/CategoryRepository.java
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

 
 

// test/com.springboot.relationship/data/repository/CategoryRepositoryTest.java

@SpringBootTest
public 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> productList = categoryRepository.findById(1L).get().getProducts();

        for(Product foundProduct:productList){
            System.out.println(product);
        }
    }
    
}

 

  • 테스트 코드를 실행시키면 일대다 연관관계에서는 연관관계 설정을 위한 update 쿼리가 발생
  • 같은 문제를 해결하기 위해는 다대일 연관관계를 사용하는 것이 좋음
  • 일대다 연관관계에서는 category와 product의 조인이 발생해서 상품 데이터를 정상적으로 가져옴

 

9.5 다대다 매핑

  • 다대다(M:N) 연관관계는 실무에서 거의 사용되지 않는 구성
  • 다대다 연관관계를 상품과 생산업체의 예로 들자면 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있음
  • 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조
  • 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해소

 

 

@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> productList = new ArrayList<>();
    
    public void addProduct(Product product){
        productList.add(product);
    }
}

 

  • 다대다 연관관계는 @ManyToMany 어노테이션으로 설정
  • 리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @Joincolum은 설정하지 않아도 됨
  • 엔티티를 생성하고 애플리케이션을 실행하면 생산업체 테이블이 생성되고 별도의 외래키가 추가되지 않음
  • 중간 테이블을 별도의 이름으로 설정하고 싶다면 @ManyToMany 어노테이션 아래 @JoinTable(name = "이름")으로 어노테이션을 정의하면 됨

 

  • 리포지토리를 통해 생산업체에 대한 기본적인 데이터베이스 조작이 가능함
public interface ProducerRepository extends JpaRepository<Producer, Long> {
}
public class ProducerRepositoryTest {
    
    @Autowired
    ProducerRepository producerRepository;
    
    @Autowired
    ProductRepository productRepository;
    
    @Test
    @Transactional
    void relationshipTest(){
        Product product1 = saveProduct("동글펜", 400, 1000);
        Product product2 = saveProduct("네모 공책", 200, 200);
        Product product3 = saveProduct("지우개", 300, 1400);
        
        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().getProductList());
    }
    
    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  productRepository.save(producer);
    }
}

 

  • 가독성을 위해 리포지토리를 통해 테스트 데이터를 생성하는 부분을 별도 메서드로 구현
  • 리포지토리를 사용하게 되면 매번 트랜잭션이 끊어져 생산업체 엔티티에서 상품 리스트를 가져오는 작업이 불가능
  • 이 문제를 해소하기 위해 테스트 메서드에 @Transactional 어노테이션을 지정해 트랜잭션이 유지되도록 구성해서 테스트를 진행

 

  • 연관관계를 설정했기 때문에 정상적으로 생산업체 엔티티에서 상품 리스트를 가져옴
  • 앞의 테스트를 통해 테스트 데이터를 생성하면 product 테이블과 producer 테이블에 레코드가 추가되지만 보여지는 내용만으로는 연관관계 설정 여부를 확인하기 어려움
    • 그 이유는 다대다 연관관계 설정 을 통해 생성된 중간 테이블에 연관관계 매핑이 돼 있기 때문
    • 중간 테이블을 확인하면 앞서 설정한 연관관계에 맞춰 양 테이블의 기본키를 매핑한 레코드가 생성된 것을 볼 수 있음

  

9.5.1 다대다 양방향 매핑

@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);
    }
}
  • 필요에 따라 mappedBy 속성을 사용해 두 엔티티 간 연관관계의 주인을 설정
  • 중간 테이블이 연관관계를 설정하고 있기 때문에 데이터베이스의 테이블 구조는 변경되지 않음

 

  • 테스트 코드 작성
Public class ProducerRepositoryTest {

    // 생략..
    @Test
    @Transactional
    void relationshipTest2(){
        Product product1 = saveProduct("동글펜", 400, 1000);
        Product product2 = saveProduct("네모 공책", 200, 200);
        Product product3 = saveProduct("지우개", 300, 1400);
        // 생략..

        // 양방향 연관관계 설정
        product1.addProducer(producer1);
        product2.addProducer(producer2);
        product2.addProducer(producer1);
        product3.addProducer(producer2);

        producerRepository.saveAll(Lists.newArrayList(producer1,producer2));
        productRepository.saveAll(Lists.newArrayList(product1, product2, product3));

        System.out.println(producerRepository.findById(1L).get().getProductList());
        System.out.println(productRepository.findById(2L).get().getProducers());

    }
    // 생략..
}

 

  • 양방향 연관관계 설정을 위해 연관관계 설정 코드를 추가
  • 연관관계를 설정하고 각 엔티티에 연관된 엔티티를 출력하면 정상적으로 출력됨

 

  • 이렇게 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있음
  • 다만 다대다 연관관계에서는 관리하기 힘든 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있음
  • 이러한 다대다 연관관계의 한계를 극복하기 위해서는 중간 테이블을 생성하는 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋음

 
 


9.6 영속성 전이

  • 영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것
  • 연관관계와 관련된 어노테이션을 보면 위와 같이 cascade()이라는 은 영속성 전이를 설정하는 데 활용되는 요소 확인

 

  • cascade() 요소와 함께 사용하는 영속성 전이 타입
종류 설명
ALL 모든 영속 상태 변경에 대해 영속성 전이를 적용
PERSIST 엔티티가 영속화할 때 연관된 엔티티도 함께 영속화
MERGE 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
REMOVE 엔티티를 제거할 때 연관된 엔티티도 제거
REFRESH 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
DETACH 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

 

  • 영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관이 있음
  • 한 엔티티가 cascade 요소의 값으로 주어진 영속 상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생
  • cascade() 요소의 리턴 타입은 배열 형식
    • 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용

 

9.6.1 영속성 전이 적용

  • 상품 엔티티와 공급업체 엔티티를 사용해 영속성 전이를 적용
  • 한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용되는지 살펴보기

 

  • 엔티티를 데이터베이스에 추가하는 경우로 영속성 전이 타입으로 PERSIST를 지정
@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 어노테이션
    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

 
 

  • 테스트 코드 작성
@SpringBootTest
public class ProviderRepositoryTest {
    @Autowired
    ProviderRepository providerRepository;

    @Autowired
    ProducerRepositoryTest productRepository;

    @Test
    void cascadeTest(){
        Provider provider = saveProvider("new provider");

        Product product1 = saveProduct("동글펜", 400, 1000);
        Product product2 = saveProduct("네모 공책", 200, 200);
        Product product3 = saveProduct("지우개", 300, 1400);
        
        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);
        
        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));
        
        // 영속성 전이 수행
        providerRepository.save(provider);
    }

    private Product saveProduct(String name, Integer price, Integer stock){

        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);

        return  product;
    }

    private Provider saveProvider(String name){
        Provider provider = new Provider();
        provider.setName(name);

        return  provider;
    }
}

 

  • 영속성 전이를 테스트하기 위해 객체에는 영속화 작업을 수행하지 않고, 연관 관계만 설정
  • 영속성 전이가 수행되는 부분은 주석으로 설정

 

providerRepository.save(provider);

 

  • 지금까지는 엔티티를 데이터베이스에 저장하기 위해 각 엔티티를 저장하는 코드를 작성해야 함
  • 영속성 전이를 사용하면 부모 엔티티가 되는 Provider 엔티티만 저장하면 코드에 작성돼 있는 Cascade.PERSIST에 맞춰 상품 엔티티도 함께 저장
  • 특정 상황에 맞춰 영속성 전이 타입을 설정하면 영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행
  • 다만 자동 설정으로 동작하는 코드들이 정확히 어떤 영향을 미치는지 파악할 필요가 있음

 

9.6.2 고아 객체

  • 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;

    // 'orphanRemoval = true'은 고아 객체를 제거하는 기능
    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

 
 

  • 테스트 코드를 작성
@SpringBootTest
public class ProviderRepositoryTest {

    // 생략...
    
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.saveAndFlush(provider);
        
        // 엔티티 출력
        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
        
    // 생략...
}

 

  • 먼저 테스트 데이터를 저장한 후, 연관관계 매핑을 수행
  • 연관관계가 매핑된 각 엔티티들을 저장한 후 각 엔티티를 출력하면 공급업체 엔티티 1개, 상품 엔티티 3개가 출력
  • 고아 객체를 생성하기 위해 공급업체 엔티티를 가져온 후 첫 번째로 매핑돼 있는 상품 엔티티의 연관관계를 제거
  • 전체 조회 코드를 수행하면 다음과 같이 연관관계가 끊긴 상품의 엔티티가 제거된 것을 확인

 


QUIZ

  1. 연관관계를 맺는 두 엔티티 간에 생성할 수 있는 연관관계의 종류는 일대일, ( 일대다 ) , 다대일, 다대다 총 4종류이다
  2. 데이터베이스에서는 두 테이블의 연관관계를 설정하면 ( 외래키 )를 통해 서로 조인해서 참조하는 구조로 생성된다
  3. ( @JoinColum ) 어노테이션은 매핑할 외래키를 설정할 때 사용되며 기본값이 설정되어 있어 자동으로 이름을 매핑해준다
  4. 양방향으로 연관관계가 설정되면 ToString을 사용할 때 순환 참조( StackOverflowError)가 발생 하기 때문에 순환참조 제거를 위해 ( @ToString.Exclude )를 사용한다
  5. 일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 ( 컬렉션 ) (List, Map) 형식으로 필드를 생성
  6. 일대다 단방향 관계의 단점은 매핑의 주체가 아닌 반대 테이블에 외래키가 추가되어 외래키를 설정하기 위해 다른 테이블에 대한 ( update ) 쿼리를 발생한다.
  7. ( 영속성 전이 )란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것이다.
  8. 영속성 전이를 위한 코드를 작성하시오.
@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;

    // 영속성 전이 코드 작성
}

9. 상품 엔티티에 공급 업체 번호를 받기 위한 엔티티 필드를 추가하는 코드를 작성하시오.

@Entity
// 생략..
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;

    // 코드 추가
}

 
8. 

@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();

9.

@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;

[출처] 장정우, 스프링 부트 핵심 가이드』, 위키북스(2022), p.159-207.
 

ⓒ 다라

 

 

728x90

관련글 더보기