상세 컨텐츠

본문 제목

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

24-25/Spring 3

by Igumi 2025. 1. 10. 10:00

본문

728x90

 

9. 연관관계 매핑

RDBMS에서는 여러 테이블 간의 관계를 정의하고 활용해야 하며, JPA는 객체 모델에서도 이를 매핑하여 사용할 수 있습니다. 연관관계는 객체와 테이블의 성질이 다르므로 이를 조정하며 사용하는 방법을 이해해야 합니다.

 

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

두 엔티티 간의 연관관계를 매핑할 수 있는 종류는 다음과 같습니다:

  • One To One: 일대일 (1:1)
  • One To Many: 일대다 (1:N)
  • Many To One: 다대일 (N:1)
  • Many To Many: 다대다 (N:M)

 

예시: 상품 테이블과 공급업체 테이블의 관계

  • 예제에서 가게는 상품과 공급업체 정보를 관리하며, 가게 입장에서는 공급업체가 여러 개의 상품을 가질 수 있으므로 다대일 관계를 표현합니다.

상품 테이블과 공급업체 테이블의 관계

 

데이터베이스에서는 외래키를 통해 두 테이블 간의 관계를 연결하며, 객체 지향 모델에서는 단방향 혹은 양방향으로 매핑이 가능합니다.

  • 단방향: 두 엔티티 중 하나만 참조
  • 양방향: 양쪽 엔티티가 서로 참조

JPA에서 외래키는 관계의 주인(Owner) 테이블이 설정합니다. 이 테이블만 외래키를 관리합니다.

 

9.2 프로젝트 생성

새로운 프로젝트 설정:

  • groupId: com.springboot
  • artifactId: relationship
  • name: relationship
  • Dependencies: Spring Web, Spring Data JPA, MariaDB Driver, Lombok

프로젝트 생성 및 의존성 추가

 

9.3 일대일 매핑

하나의 상품(Product)에 하나의 상품정보(ProductDetail)가 매핑되는 구조로 일대일 관계를 구현합니다. 이를 위해 엔터티와 매핑 테이블을 설계합니다.

 

상품 테이블과 상품 정보 테이블의 일대일 관계

 

9.3.1 일대일 단방향 매핑

상품(Product) 엔터티와 상품정보(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: 다른 엔티티와 일대일 관계를 정의.
  • @JoinColumn: 외래키를 명시하며 기본 이름 설정도 가능.
  • 외래키 옵션(name, referencedColumnName, foreignKey) 설정 가능.

 

Hibernate 설정

  • hibernate.ddl-auto=create를 적용하면 하이버네이트가 자동으로 테이블 생성.

자동 생성된 product_detail 테이블

 

데이터 저장 및 조회

리포지토리를 활용해 데이터 생성 및 조회 테스트를 진행합니다.

  • ProductDetailRepository
public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {
}
  • Test 코드 작성
@SpringBootTest
class ProductDetailRepositoryTest {

    @Autowired
    ProductDetailRepository productDetailRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    public void saveAndReadTest() {
        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());
    }
}

테스트 결과는 연관된 엔티티를 left join으로 즉시 로딩하며 조회 결과를 반환합니다.

  • Hibernate 쿼리 출력
select productdet0_.id as id1_1_,
       productdet0_.description as descript3_1_,
       productdet0_.product_number as product_5_1_
from product_detail productdet0_
left outer join product product1_
on productdet0_.product_number=product1_.number
where productdet0_.id=?

 

 

@OneToOne 어노테이션

  • fetch(): 기본값은 FetchType.EAGER로, 즉시 로딩 전략을 사용합니다.
  • optional(): 기본값은 true로, 매핑되는 값이 nullable임을 의미합니다. 값이 반드시 존재해야 하면 optional=false로 설정합니다.
  • @OneToOne 어노테이션은 JPA에서 일대일 관계를 매핑할 때 사용되며, 주요 요소는 아래와 같습니다.
public @interface OneToOne {
    Class targetEntity() default void.class;
    CascadeType[] cascade() default {};
    FetchType fetch() default EAGER;
    boolean optional() default true;
    String mappedBy() default "";
    boolean orphanRemoval() default false;
}

 

 

값이 반드시 필요할 때 설정 (optional=false)

  • optional=false로 설정하면 매핑된 값이 null일 수 없게 됩니다.
@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)
    @JoinColumn(name = "product_number")
    private Product product;
}

 

 

Hibernate 쿼리 (테이블 생성):

optional=false로 설정하면 데이터베이스 테이블 생성 시 NOT NULL 제약 조건이 추가됩니다.

create table product_detail (
    id bigint not null auto_increment,
    created_at datetime(6),
    updated_at datetime(6),
    description varchar(255),
    product_number bigint not null,
    primary key (id)
) engine=InnoDB

 

Hibernate 쿼리 (데이터 조회):

  • optional=true (기본값):
select
    productdet1_.id as id1_1_0_,
    productdet1_.product_number as product_5_1_0_
from 
    product_detail productdet1_
left outer join
    product product1_
on 
    productdet1_.product_number=product1_.number
where 
    productdet1_.id=?;
  • optional=false:
select
    productdet1_.id as id1_1_0_,
    productdet1_.product_number as product_5_1_0_
from 
    product_detail productdet1_
inner join
    product product1_
on 
    productdet1_.product_number=product1_.number
where 
    productdet1_.id=?;

 

9.3.2 일대일 양방향 매핑

  • 양방향 매핑은 양쪽 엔티티가 서로를 참조하는 구조입니다.

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
    private ProductDetail productDetail;
}

자동 생성된 product 테이블

 

문제점

  • 양방향 매핑 시 두 엔티티가 서로를 참조하여 쿼리에서 비효율적인 LEFT OUTER JOIN이 중복될 수 있습니다.
  • Hibernate 쿼리 (비효율적 쿼리):
select 
    productdet1_.id as id1_1_0_,
    productdet1_.product_number as product_5_1_0_,
    product1_.product_detail_id as product_7_0_1_
from 
    product_detail productdet1_
left outer join
    product product1_
on 
    productdet1_.product_number=product1_.number
left outer join
    product_detail productdet2_
on 
    product1_.product_detail_id=productdet2_.id
where 
    productdet1_.id=?;

 

mappedBy 속성

  • mappedBy 속성을 통해 연관관계의 주인을 설정하여 한쪽 테이블만 외래키를 가지도록 합니다.
  • Product 엔티티에서 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;
}
  • 설정 후, 외래키가 ProductDetail 엔티티에만 존재합니다.

mappedBy 속성을 사용한 product 테이블

 

순환 참조 문제

  • 양방향 매핑으로 인해 @ToString 호출 시 순환 참조로 StackOverflowError 발생 가능.
  • 이를 해결하기 위해 @ToString.Exclude를 사용하여 순환 참조를 제외합니다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;

 

Hibernate 쿼리 (효율적인 쿼리):

select 
    productdet1_.id as id1_1_0_,
    productdet1_.product_number as product_5_1_0_
from 
    product_detail productdet1_
inner join
    product product1_
on 
    productdet1_.product_number=product1_.number
where 
    productdet1_.id=?;

 

9.4 다대일, 일대다 매핑

  • 상품 테이블과 공급업체 테이블은 상품의 입장에서 다대일, 공급업체 입장에서 일대다 관계로 구성됩니다.
  • 이를 구현하기 위해 다대일 및 일대다 매핑을 설정하고 테스트합니다.

상품 테이블과 공급업체 테이블의 관계

 

9.4.1 다대일 단방향 매핑

1. 공급업체 엔티티 클래스

@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;
}
  • Provider는 공급업체 정보를 담는 엔티티.
  • 필드: id (공급업체 ID), name (공급업체 이름).
  • BaseEntity를 상속받아 생성일자와 수정일자 포함.

2. 상품 엔티티에 공급업체 필드 추가

@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;
}
  • 상품(Product)은 공급업체와 다대일 관계.
  • @ManyToOne 어노테이션으로 공급업체와 연관 설정.
  • @JoinColumn(name = "provider_id")로 외래키(provider_id) 명시.
  • @ToString.Exclude로 순환 참조 방지.

3. Product 테이블과 Provider 테이블 생성

공급업체와 연관 설정된 상품 테이블
자동 생성된 공급업체 테이블

 

ProviderRepository 인터페이스 생성

public interface ProviderRepository extends JpaRepository<Provider, Long> {
}
  • 공급업체 정보를 관리하기 위한 리포지토리입니다.

 

다대일 단방향 매핑 테스트

  • 테스트 코드
@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

    @Test
    void relationshipTest() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("OO물산");
        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(() -> new RuntimeException("Product not found")));
        System.out.println("provider: " + productRepository.findById(1L)
                .orElseThrow(() -> new RuntimeException("Product not found"))
                .getProvider());
    }
}

 

Hibernate 실행 쿼리

  • Provider 객체 저장
insert into provider (created_at, updated_at, name) values (?, ?, ?);
  • Product 객체 저장
insert into product (created_at, updated_at, name, price, provider_id, stock) values (?, ?, ?, ?, ?, ?);
  • Product 객체 조회
select
    product0_.number as number1_0_0_,
    product0_.created_at as created2_0_0_,
    product0_.updated_at as updated3_0_0_,
    product0_.name as name4_0_0_,
    product0_.price as price5_0_0_,
    product0_.provider_id as provider7_0_0_,
    product0_.stock as stock6_0_0_,
    provider1_.id as id1_2_1_,
    provider1_.created_at as created2_2_1_,
    provider1_.updated_at as updated3_2_1_,
    provider1_.name as name4_2_1_
from
    product product0_
left outer join
    provider provider1_
on
    product0_.provider_id = provider1_.id
where
    product0_.number = ?;
  • 테스트 결과
product: Product(super=BaseEntity(...), number=1, name=가위, price=5000, stock=500, provider=Provider(super=BaseEntity(...), id=1, name=OO물산))
provider: Provider(super=BaseEntity(...), id=1, name=OO물산)

 

9.4.2 다대일 양방향 매핑

  • 공급업체 엔티티에 일대다 연관관계 설정
@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", fetch = FetchType.EAGER)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}
  • @OneToMany(mappedBy = "provider")로 상품과 일대다 관계 설정.
  • fetch = FetchType.EAGER: 즉시 로딩 전략 설정.
  • @ToString.Exclude: 순환 참조 방지.

 

테스트 코드

@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

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

        Product product1 = new Product();
        product1.setName("가위");
        product1.setPrice(2000);
        product1.setStock(100);
        product1.setProvider(provider);
        productRepository.save(product1);

        Product product2 = new Product();
        product2.setName("가방");
        product2.setPrice(20000);
        product2.setStock(200);
        product2.setProvider(provider);
        productRepository.save(product2);

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

 

Hibernate 실행 쿼리

  • Provider 조회
select
    provider0_.id as id1_2_0_,
    provider0_.created_at as created2_2_0_,
    provider0_.updated_at as updated3_2_0_,
    provider0_.name as name4_2_0_,
    productlis1_.number as number1_0_1_,
    productlis1_.created_at as created2_0_1_,
    productlis1_.updated_at as updated3_0_1_,
    productlis1_.name as name4_0_1_,
    productlis1_.price as price5_0_1_,
    productlis1_.provider_id as provider7_0_1_,
    productlis1_.stock as stock6_0_1_
from
    provider provider0_
left outer join
    product productlis1_
on
    provider0_.id = productlis1_.provider_id
where
    provider0_.id = ?;
  • 테스트 결과
Product(super=BaseEntity(...), number=1, name=가위, price=2000, stock=100, ...)
Product(super=BaseEntity(...), number=2, name=가방, price=20000, stock=200, ...)

 

9.4.3 일대다 단방향 매핑

  • 일대다 단방향 매핑은 한 엔티티가 다른 엔티티 리스트를 포함하는 관계를 설정합니다.
  • 하지만 연관관계의 주인을 설정하지 못해 외래키가 반대 테이블에 추가되며, 추가적인 업데이트 쿼리 발생이라는 단점이 있습니다.
  • 실습에서는 상품 분류(Category) 테이블과 상품(Product) 테이블 간의 관계를 설정합니다.

일대다 관계인 상품 분류 테이블과 상품 테이블

 

상품 분류(Category) 엔티티

@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(fetch = FetchType.EAGER): 즉시 로딩으로 상품 리스트를 조회.
  • @JoinColumn(name = "category_id"): 외래키 이름을 category_id로 지정.
  • products: 상품(Product) 리스트를 포함하는 필드.

 

테이블 생성

생성된 상품 분류 테이블
외래키가 추가된 상품 테이블

 

리포지토리 생성

  • CategoryRepository
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

 

테스트 코드

  • CategoryRepositoryTest
@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(foundProduct);
        }
    }
}

 

Hibernate 실행 쿼리

  • Product 저장
insert into product (created_at, updated_at, name, price, provider_id, stock)
values (?, ?, ?, ?, ?, ?);
  • Category 저장
insert into category (code, name) values (?, ?);
  • Product 업데이트 (Category와 연관 설정)
update product set category_id=? where number=?;
  • Category 조회
select
    category0_.id as id1_0_0_,
    category0_.code as code2_0_0_,
    category0_.name as name3_0_0_,
    products1_.category_id as category8_1_1_,
    products1_.number as number1_1_1_,
    products1_.created_at as created2_1_1_,
    products1_.updated_at as updated3_1_1_,
    products1_.name as name4_1_1_,
    products1_.price as price5_1_1_,
    products1_.provider_id as provider7_1_1_,
    products1_.stock as stock6_1_1_
from
    category category0_
left outer join
    product products1_
on
    category0_.id = products1_.category_id
where
    category0_.id = ?;

 

결과

  • 테스트 데이터를 생성하면, product 테이블의 category_id 필드에 추가적인 업데이트 쿼리가 발생합니다.
  • 생성된 데이터는 category와 product를 조인하여 조회할 수 있습니다.

 

9.5 다대다 매핑

  • 다대다 연관관계는 두 엔티티가 서로 다수의 연관 데이터를 가질 때 사용됩니다.
  • 실무에서는 교차 엔티티(중간 테이블)를 생성해 다대다 관계를 일대다 및 다대일로 분리하는 것이 일반적입니다.
  • 여기서는 다대다 단방향 매핑을 다룰 예정입니다.

다대다 관계인 상품 테이블과 생산업체 테이블

 

생산업체(Producer) 엔티티

@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);
    }
}
  • @ManyToMany: 다대다 연관관계를 설정.
  • @ToString.Exclude: 순환 참조 방지.
  • addProduct(Product product): 연관 데이터를 추가하는 메서드.

 

테이블 생성

자동 생성된 생산업체 테이블

 

상품 테이블과 생산업체 테이블의 중간 테이블

 

리포지토리 생성

  • ProducerRepository
public interface ProducerRepository extends JpaRepository<Producer, Long> {
}

 

테스트 코드

  • ProducerRepositoryTest
@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("네이처");
        Producer producer2 = saveProducer("북스");

        producer1.addProduct(product1);
        producer1.addProduct(product2);

        producer2.addProduct(product2);
        producer2.addProduct(product3);

        producerRepository.saveAll(List.of(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);
    }
}

 

Hibernate 실행 쿼리

  • Product 저장
insert into product (created_at, updated_at, name, price, provider_id, stock)
values (?, ?, ?, ?, ?, ?);
  • Producer 저장
insert into producer (code, name) values (?, ?);
  • 중간 테이블(연관 관계) 저장
insert into producer_products (producer_id, products_number)
values (?, ?);

 

결과

  • producer_products라는 중간 테이블에 연관 데이터가 저장됩니다.
  • 테스트를 통해 생산업체와 상품 간 연관관계가 정상적으로 동작하는 것을 확인할 수 있습니다.

상품 테이블과 생산업체 테이블의 중간 테이블 레코드

 

9.5.2 다대다 양방향 매핑

  • 다대다 단방향 매핑을 확장하여 양방향 매핑을 설정.
  • 두 엔티티가 서로를 참조하며, 관계를 설정하고 데이터 조작이 가능합니다.
  • JPA에서는 중간 테이블을 생성해 다대다 연관관계를 관리하며, 양방향 매핑에서는 관계 설정이 양쪽에서 가능합니다.

상품(Product) 엔티티 - 생산업체(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
    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);
    }
}
  • @ManyToMany로 생산업체(Producer)와 다대다 관계 설정.
  • addProducer 메서드로 연관 관계를 편리하게 추가.
  • 순환 참조 방지를 위해 @ToString.Exclude를 추가.

 

테스트 코드

@SpringBootTest
class ProducerRepositoryTest {

    @Autowired
    ProducerRepository producerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    @Transactional
    void relationshipTest2() {
        Product product1 = saveProduct("동글펜", 500, 1000);
        Product product2 = saveProduct("네모 공책", 100, 2000);
        Product product3 = saveProduct("지우개", 152, 1234);

        Producer producer1 = saveProducer("네이처");
        Producer producer2 = saveProducer("북스");

        // 연관 관계 설정
        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(List.of(producer1, producer2));
        productRepository.saveAll(List.of(product1, product2, product3));

        // 테스트 출력
        System.out.println("products: " + producerRepository.findById(1L).get().getProducts());
        System.out.println("producers: " + productRepository.findById(2L).get().getProducers());
    }

    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);
    }
}
  • addProductaddProducer 메서드를 활용해 연관관계를 설정.
  • 양방향 설정을 통해 각 엔티티에서 상대 엔티티를 참조 가능.
  • @Transactional을 활용해 테스트 메서드 내 트랜잭션 유지.

 

Hibernate 실행 쿼리

  • Product 저장
insert into product (created_at, updated_at, name, price, provider_id, stock)
values (?, ?, ?, ?, ?, ?);
  • Producer 저장
insert into producer (created_at, updated_at, name, code)
values (?, ?, ?, ?);
  • 중간 테이블 연관 데이터 저장
insert into producer_products (producer_id, products_number)
values (?, ?);
  • 연관된 데이터 조회
select
    producer0_.id as id1_6_0_,
    producer0_.created_at as created2_6_0_,
    producer0_.updated_at as updated3_6_0_,
    producer0_.name as name4_6_0_,
    producer0_.code as code5_6_0_,
    products1_.producer_id as producer1_7_1_,
    products1_.products_number as products2_7_1_,
    product2_.number as number1_3_2_,
    product2_.created_at as created2_3_2_,
    product2_.updated_at as updated3_3_2_,
    product2_.name as name4_3_2_,
    product2_.price as price5_3_2_,
    product2_.provider_id as provider7_3_2_,
    product2_.stock as stock6_3_2_
from
    producer producer0_
left outer join
    producer_products products1_
on
    producer0_.id = products1_.producer_id
left outer join
    product product2_
on
    products1_.products_number = product2_.number
where
    producer0_.id = ?;

 

테스트 결과

  • 연관관계 데이터 출력:
products: [Product(super=BaseEntity(...), number=1, name=동글펜, ...), Product(super=BaseEntity(...), number=2, name=네모 공책, ...)]
producers: [Producer(super=BaseEntity(...), id=1, name=네이처, ...), Producer(super=BaseEntity(...), id=2, name=북스, ...)]

 

한계 및 권장 사항

  1. 복잡성 증가:
    • 다대다 관계에서는 중간 테이블로 인해 복잡한 쿼리가 발생할 수 있음.
    • 예기치 않은 연산 비용이 추가될 가능성이 존재.
  2. 관리 어려움:
    • 중간 테이블 데이터를 직접적으로 관리하기 어려움.
    • 연관 관계의 명확성을 떨어뜨릴 수 있음.
  3. 권장 사항:
    • 중간 테이블을 엔티티로 승격하여 명시적으로 관리.
    • 다대다 관계를 일대다 + 다대일로 분리하여 처리.

 

9.6 영속성 전이

  • 영속성 전이(cascade)란 특정 엔티티의 상태 변경(저장, 삭제 등)이 연관된 엔티티에도 자동으로 적용되는 것을 의미합니다.
  • @OneToMany 등 관계 설정 어노테이션에서 cascade 요소를 활용해 설정 가능.
  • 사용 가능한 영속성 전이 타입:
    • PERSIST: 부모 엔티티가 저장될 때 연관된 자식 엔티티도 함께 저장.
    • MERGE: 부모 엔티티 병합 시 자식 엔티티도 병합.
    • REMOVE: 부모 엔티티 삭제 시 자식 엔티티도 삭제.
    • REFRESH, DETACH, ALL: 기타 상태 변경 관련.

@OneToMany 어노테이션에 있는 cascade 요소
영속성 전이 타입의 종류

 

9.6.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;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}
  • @OneToManycascade = CascadeType.PERSIST를 설정하여 부모(Provider) 저장 시 자식(Product)도 함께 저장.
  • 연관관계의 주인은 자식 엔티티(Product).

 

테스트 코드

@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(List.of(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;
}
  • cascade = CascadeType.PERSIST 설정으로 Provider 저장 시 연관된 Product도 자동 저장.
  • 직접 Product를 저장하지 않고, 연관 관계만 설정한 뒤 Provider를 저장.

 

Hibernate 실행 쿼리

  • Provider 저장
insert into provider (created_at, updated_at, name)
values (?, ?, ?);
  • Product 저장
insert into product (created_at, updated_at, name, price, provider_id, stock)
values (?, ?, ?, ?, ?, ?);

 

9.6.2 고아 객체

  • 고아 객체: 부모-자식 관계에서 부모와의 연관이 끊어진 자식 엔티티.
  • JPA는 orphanRemoval = true를 설정해 고아 객체를 자동으로 삭제 가능.
  • 단, 자식 엔티티가 다른 연관 관계를 가지고 있다면 신중하게 사용해야 함.

공급업체(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;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}
  • orphanRemoval = true 설정으로 부모 엔티티에서 연관 관계가 제거된 자식 엔티티 자동 삭제.

 

테스트 코드

@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(List.of(product1, product2, product3));

    providerRepository.saveAndFlush(provider);

    // 고아 객체 생성
    Provider foundProvider = providerRepository.findById(1L).get();
    foundProvider.getProductList().remove(0); // 첫 번째 Product 제거

    providerRepository.findAll().forEach(System.out::println);
    productRepository.findAll().forEach(System.out::println);
}
  • product1 제거 후, 고아 객체로 처리되어 자동 삭제.
  • orphanRemoval 설정 없이 자식 엔티티는 삭제되지 않음.

 

 

Hibernate 실행 쿼리

  • 고아 객체 제거
delete from product where number = ?;
  • 전체 조회
select * from provider;
select * from product;

Blank Quiz

  1. JPA에서 연관관계 주인은 외래 키를 소유하는 쪽입니다. 이를 설정하기 위해 사용되는 속성 이름은 (mappedBy)입니다.
  2. JPA에서 (즉시) 로딩은 연관된 데이터를 즉시 가져오고, (지연) 로딩은 필요할 때 데이터를 가져오는 방식입니다.
  3. 영속성 전이는 부모 엔티티의 상태 변화가 자식 엔티티에도 전이되도록 설정하는 기능입니다. 이를 설정하기 위해 사용하는 속성 이름은 (cascade)입니다.
  4. JPA에서 다대다 관계는 중간 테이블을 통해 관리됩니다. 중간 테이블의 이름을 지정하려면 (@JoinTable) 어노테이션을 사용합니다.
  5. 부모-자식 관계에서 부모와 연관이 끊어진 자식 엔티티를 자동으로 삭제하려면 (orphanRemoval = true)를 설정합니다.
  6. (@OneToMany)의 기본 fetch 전략은 (Lazy)입니다.
  7. (mappedBy)는 연관관계를 맺는 상대 엔티티의 (필드) 이름을 지정합니다.

 

Programming Quiz

1. 양방향 연관관계에서 mappedBy 설정

  • 아래 코드에서 @OneToOne 어노테이션에 올바른 mappedBy 값을 작성하세요.
@Entity
public class ProductDetail {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "product_id")
    private Product product;
}

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(mappedBy = "__________")
    private ProductDetail productDetail;
}

 

2. ManyToMany 중간 테이블 이름 설정

  • 아래 @ManyToMany 관계에서 중간 테이블의 이름을 "producer_product_mapping"으로 설정하세요.
@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany
    private List<Producer> producers = new ArrayList<>();
}

@Entity
public class Producer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany
    private List<Product> products = new ArrayList<>();
}

더보기

Blank Quiz

 

1. mappedBy

2. 즉시, 지연 

3. cascade

4. @JoinTable

5. orphanRemoval = true

6. @OneToMany, Lazy

7. mappedBy, 필드

 

Programming Quiz

1. 양방향 연관관계에서 mappedBy 설정

@Entity
public class ProductDetail {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "product_id")
    private Product product;
}

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(mappedBy = "product")
    private ProductDetail productDetail;
}

 

2. ManyToMany 중간 테이블 이름 설정

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany
    @JoinTable(name = "producer_product_mapping")
    private List<Producer> producers = new ArrayList<>();
}

@Entity
public class Producer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany(mappedBy = "producers")
    private List<Product> products = new ArrayList<>();
}

[출처] 장정우, 「스프링부트 핵심 가이드」 9장

 

Corner Spring 3

ⓒ Hetbahn

728x90

관련글 더보기