Today
-
Yesterday
-
Total
-
  • 스프링 부트 핵심 가이드 5주차: 9장
    책 공부/스프링 부트 핵심 가이드 2023. 12. 6. 00:01

    📖 책 정보

    모바일 가이드

    연관관계 매핑

    1. 연관관계 매핑 종류와 방향
    2. 1:1 매핑
    3. N:1 , 1:N 매핑
    4. N:M 매핑
    5. 영속성 전이



    9장

    1. 연관관계 매핑 종류와 방향
    2. 1:1 매핑
    3. N:1, 1:N 매핑
    4. N:N 매핑
    5. 영속성 전이

    RDBMS를 사용할 때는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 불가능하다.

    대체로 설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서 조인 Join 등의 기능을 활용한다.

    JPA를 사용하는 애플리케이션에서도 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있다.




    1. 연관관계 매핑 종류와 방향

    연관관계를 맺는 두 엔티티 간에

    생성할 수 있는 연관관계의 종류는 다음과 같다.

    • @OneToOne    1 : 1
    • @OneToMany   1 : N
    • @ManyToOne   N : 1
    • @ManyToMany  N : M

    데이터베이스에서는

    두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성되지만,

    JPA를 사용하는 객체지향 모델링에서는 엔티티참조 방향설정할 수 있다.


    데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만,

    비즈니스 로직의 관점에서 봤을 때 단방향 관계만 설정해도 해결되는 경우가 많다.


    • 단방향 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식

    • 양방향 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식


    연관관계가 설정되면

    한 테이블에서 다른 테이블의 기본 값을 외래키로 갖게 된다.

    이런 관계에서는 주인 Owner 라는 개념이 사용된다.


    일반적으로 외래키를 가진 테이블이 그 관계의 주인이 되며,

    주인은 외래키를 사용하여 상대 엔티티에 대한 조작을 할 수 있으나,

    상대 엔티티는 읽는 작업만 수행할 수 있다.


        🐢🎈


    2. 일대일 매핑

    A 엔티티에 B 엔티티 정보가 하나만 매핑되는 구조

    - 단방향 매핑

    새로운 ProductDetail 엔티티를 생성하고, 이 엔티티를 Owner로 하여 Product 엔티티와 1:1 관계를 설정한다.

    ProductDetail.java

    @Entity
    @Table(name = "product_datail")
    @Getter @Setter
    @SuperBuilder
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString(callSuper = true)
    @EqualsAndHashCode(callSuper = true)
    public class ProductDetail extends BaseEntity{
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String decription;
    
        @OneToOne
        @JoinColumn(name = "product_number")
        private Product product;
    
    }

    1:1 연관관계 설정 부분

    @OneToOne
    @JoinColumn(name = "product_number") // 매핑할 외래키 설정
    private Product product;

    @OneToOne 어노테이션은 다른 엔티티 객체를 필드로 정의했을 때 1:1 연관관계로 매핑하기 위해 사용한다.


    @JoinColumn 어노테이션을 이용해 매핑할 외래키를 설정한다.

    @JoinColum 어노테이션은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만, 의도한 이름이 들어가지 않기 때문에, name 속성을 사용해 원하는 컬럼명을 지정하는 것이 좋다.

    만약  @JoinColumn을 선언하지 않으면  엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않다.

    @JoinColumn 어노테이션에서 사용할 수 있는 속성

    • name : 매핑할 외래키 이름 설정
    • referencedColumnName : 외래키가 참조할 상대 테이블의 식별자 컬럼 이름 지정
    • foreignKey : 외래키를 생성하면서 지정할 제약조건 설정

    test 코드 작성

    @Test
    void crTest() {
        Product product = Product
                .builder()
                .name("스프링 부트 JPA")
                .price(5000)
                .stock(500)
                .build();
        
        productRepository.save(product);
    
        ProductDetail productDetail = ProductDetail
                .builder()
                .product(product)
                .decription("스프링 부트와 JPA를 함께 볼 수 있는 책")
                .build();
    
        productDetailRepository.save(productDetail);
    
    	// 조회 1
        productDetailRepository
                .findById(productDetail.getId())
                .get()
                .getProduct();
    
    	// 조회 2
        productDetailRepository
                .findById(productDetail.getId())
                .get();
    }

    위의 코드에서 가져온 아래의 코드를 보면

    productDetailRepository
            .findById(productDetail.getId())
            .get()
            .getProduct();

    productDetailRepository에서 productDetail 엔티티 데이터를 조회한 후 Product 엔티티 값을 읽어오는데,

    이것은 ProductDetail 엔티티에다가 Product 엔티티에 대해 1:1 단방향 연관관계 매핑을 설정 해놓았기 때문에 가능하다.

    위 문장을 jpa가 해석한 쿼리를 보면 다음과 같다.

    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_.decription as decripti4_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_datail productdet0_ 
        left outer join
            product product1_ 
                on productdet0_.product_number=product1_.number 
        where
            productdet0_.id=?

    @OneToOne 연관관계를 걸어놓았기 때문에 ProductDetail 객체와 Product 객체가 함께 조회되고 있다.

    이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 즉시 로딩이라고 한다.

    그리고 두 객체를 조회할 때 left outer join 을 수행하였는데, 이것은 @OneToOne 어노테이션의 기본 설정으로 인해 일어난 결과이다.


    @OneToOne 어노테이션의 설정에 대해 몇 가지만 살펴본다면

    • fetch() 기본 값은 EAGER (즉시 로딩 전략) 이다.

    • optional()

      • 기본 값은 true 이다. 매핑되는 값이 nullable 이라는 의미이다.

      • 매핑되는 값에 반드시 값이 존재해야 한다면, 해당 옵션을 false로 설정해야 한다.

      • optional 설정이 false 인 경우 inner join을 수행하고,

      optional 설정이 true 인 경우 left outer join을 수행한다.


    - 양방향 매핑

    객체에서의 양방향 개념은

    양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다.

    Product 엔티티에 ProductDetail에 대한 1:1 연관관계 추가!

    ...
    @Entity
    @Table(name = "product")
    public class Product extends BaseEntity {
    
    	...
    
        @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_.decription as decripti4_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_.decription as decripti4_1_2_,
            productdet2_.product_number as product_5_1_2_ 
        from
            product_datail productdet0_ 
        left outer join
            product product1_ 
                on productdet0_.product_number=product1_.number 
        left outer join
            product_datail productdet2_ 
                on product1_.product_detail_id=productdet2_.id 
        where
            productdet0_.id=?

    엔티티 두 개에 각각 연관관계 설정이 되어 있어

    left outer join이 두번 작성되었다.


    이렇게 되면 에러가 발생하지는 않지만, 효율성이 떨어진다.


    실제 데이터베이스에서도 테이블 간 연관관계를 맺으면

    한쪽 테이블이 외래키를 가지는 구조로 이뤄진다.


    jpa에서도 실제 데이터베이스의 연관관계를 반영해서

    한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다.


    이 경우

    엔티티는 양방향으로 매핑하되,

    한쪽에게만 외래키를 줘야 하는데

    이때 사용되는 속성이 mappedBy 이다.


    mappedBy는 어떤 객체가 주인 Owner 인지 표시하는 속성이라고 볼 수 있다.

    Product 엔티티 객체에 mappedBy 속성 추가

    ...
    @Entity
    @Table(name = "product")
    public class Product extends BaseEntity {
    	...
        @OneToOne(mappedBy = "product")
        private ProductDetail productDetail;
    }

    mappedBy에 들어가는 값은

    연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이다.

    이 설정을 하면

    ProductDetail 엔티티가 Product 엔티티의 주인이 된다.

    Product 엔티티에서는 이 설정으로 FK가 생성되지 않는다.


    양방향 관계를 설정하여 mappedBy 사용시 주의할 점은

    toString을 사용할 때 순환참조가 일어나 StackOverFlow 에러가 발생한다.


    이것을 방지하기 위해서는

    양방향 관계가 꼭 필요한 경우가 아니라면, 단방향 관계로 설정하거나,

    양방향 관계 설정시 mappedBy를 지정해준 필드에 @ToString.exclude를 함께 지정해주어 ToString에서 제외 설정을 해주어야 한다.


    Product 엔티티에 ToString.exclude 설정

    @OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;



        🐢🎈


    3. 다대일, 일대다 매핑

    상품 테이블과 공급 업체 테이블의 경우

    상품 테이블 입장에선 (상품) N : 1 (공급업체) 의 관계가,

    공급 업체 테이블 입장에선 (공급 업체) 1 : N (상품) 의 관계가 있다고 볼 수 있다.


    이러한 관계에 대해 구현해본다.


    - N:1 단방향 매핑

    먼저 1에 해당하는 공급 업체 테이블에 매핑되는 엔티티 클래스 작성

    Provider 엔티티

    @Entity
    @Table(name = "provider")
    @Getter @Setter
    @SuperBuilder
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString(callSuper = true)
    public class Provider extends BaseEntity{
        
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        
        private String name;
    }

    그 다음으로 N에 해당하는 상품 테이블에 매핑되는 Product 엔티티 클래스에 Provider 관계 추가

    Product 엔티티

    ...
    @Entity
    @Table(name = "product")
    public class Product extends BaseEntity {
    	...
        
        @ManyToOne
        @JoinColumn(name = "provider_id")
        private Provider provider;
    }

    일반적으로 외래키를 갖는 쪽이 주인의 역할을 수행하기 때문에

    이 경우 상품 엔티티가 공급 업체 엔티티의 주인이 된다.


    test 코드 확인

    @Test
    void relationshipTest1() {
        Provider provider = Provider
                .builder()
                .name("떙땡 물산")
                .build();
    
        providerRepository.save(provider);
    
        Product product = Product
                .builder()
                .name("키보드")
                .price(50000)
                .stock(500)
                .provider(provider)
                .build();
        productRepository.save(product);
    
        // test
        productRepository
                .findById(1L)
                .orElseThrow(RuntimeException::new);
    
        productRepository
                .findById(1L)
                .orElseThrow(RuntimeException::new)
                .getProvider();
    }

    test 실행 결과 log

    Product(super=BaseEntity(createdAt=2023-06-18T20:09:16.924405, updatedAt=2023-06-18T20:09:16.924405), number=1, name=키보드, price=50000, stock=500, provider=Provider(super=BaseEntity(createdAt=2023-06-18T20:09:16.796204, updatedAt=2023-06-18T20:09:16.796204), id=1, name=떙땡 물산))
    
    Provider(super=BaseEntity(createdAt=2023-06-18T20:09:16.796204, updatedAt=2023-06-18T20:09:16.796204), id=1, name=떙땡 물산)

    - N:1 양방향 매핑

    공급업체를 통해

    등록된 상품을 조회하기 위해

    1:N 연관관계를 설정!

    Product 엔티티에서 ManyToOne 을 가졌으니, Product의 상대 엔티티가 되는 공급업체 엔티티는 OneToMany가 된다.


    이번엔 공급업체 엔티티만 작성한다.

    Provider 엔티티

    ...
    public class Provider extends BaseEntity{
    	...
    
        @OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
        @ToString.Exclude
        List<Product> productList = new ArrayList<>();
    }

    1:N 연관관계의 경우

    여러 상품 엔티티가 포함될 수 있어서 컬렉션 형식으로 필드를 생성한다.

    그리고 OneToMany 의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩 EAGER로 조정했다.

    JPA에서 지연 로딩과 즉시로딩은 중요한 개념이다.

    ✏️ JPA 지연 lazy 로딩과 즉시 eager 로딩
    
    엔티티라는 객체의 개념으로 데이터베이스를 구현했기 때문에
    연관관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체들이 필드에 존재하게 된다.
    
    연관관계와 상관없이 
    
    즉각 해당 엔티티의 값만 조회하고 싶거나 
    
    연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등
    
    여러 조건들을 만족하기 위해 등장한 개념이다.
    

    test

    @Test
    void relationshipTest() {
    	// provider 등록
        Provider provider = Provider.builder()
                .name("땡땡 상사")
                .build();
        providerRepository.save(provider);
    
    	// product에 provider 설정 1
        productRepository.save(
                Product.builder()
                        .name("키보드")
                        .price(200000)
                        .stock(200)
                        .provider(provider)
                        .build()
        );
        
    	// product에 provider 설정 2
        productRepository.save(
                Product.builder()
                        .name("모니터")
                        .price(100000)
                        .stock(200)
                        .provider(provider)
                        .build()
        );
        
    	// product에 provider 설정 3
        productRepository.save(
                Product.builder()
                        .name("마우스")
                        .price(20000)
                        .stock(200)
                        .provider(provider)
                        .build()
        );
    
    	// provider에서 product list 조회
        providerRepository
                .findById(provider.getId())
                .get()
                .getProductList()
                .forEach(product -> System.out.println(
                        "[ product ]___________🚩  " +
                                product
                        )
                );
    }

    Provider 엔티티 클래스는

    Product 엔티티와의 연관관계에서

    주인이 아니기 때문에

    외래키를 관리할 수 없다.

    그렇기 때문에,

    Provider 를 등록한 후

    Product에 객체를 설정하는 작업을 해주어야 한다.

    test 실행 로그

    Hibernate: 
        select
            provider0_.id as id1_2_0_,
            provider0_.created_at as created_2_2_0_,
            provider0_.updated_at as updated_3_2_0_,
            provider0_.name as name4_2_0_,
            productlis1_.provider_id as provider7_0_1_,
            productlis1_.number as number1_0_1_,
            productlis1_.number as number1_0_2_,
            productlis1_.created_at as created_2_0_2_,
            productlis1_.updated_at as updated_3_0_2_,
            productlis1_.name as name4_0_2_,
            productlis1_.price as price5_0_2_,
            productlis1_.provider_id as provider7_0_2_,
            productlis1_.stock as stock6_0_2_,
            productdet2_.id as id1_1_3_,
            productdet2_.created_at as created_2_1_3_,
            productdet2_.updated_at as updated_3_1_3_,
            productdet2_.decription as decripti4_1_3_,
            productdet2_.product_number as product_5_1_3_ 
        from
            provider provider0_ 
        left outer join
            product productlis1_ 
                on provider0_.id=productlis1_.provider_id 
        left outer join
            product_datail productdet2_ 
                on productlis1_.number=productdet2_.product_number 
        where
            provider0_.id=?
    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T20:35:50.152794, updatedAt=2023-06-18T20:35:50.152794), number=1, name=키보드, price=200000, stock=200, provider=Provider(super=BaseEntity(createdAt=2023-06-18T20:35:49.996508, updatedAt=2023-06-18T20:35:49.996508), id=1, name=땡땡 상사))
    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T20:35:50.178179, updatedAt=2023-06-18T20:35:50.178179), number=2, name=모니터, price=100000, stock=200, provider=Provider(super=BaseEntity(createdAt=2023-06-18T20:35:49.996508, updatedAt=2023-06-18T20:35:49.996508), id=1, name=땡땡 상사))
    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T20:35:50.186561, updatedAt=2023-06-18T20:35:50.186561), number=3, name=마우스, price=20000, stock=200, provider=Provider(super=BaseEntity(createdAt=2023-06-18T20:35:49.996508, updatedAt=2023-06-18T20:35:49.996508), id=1, name=땡땡 상사))
    

    - 1:N 단방향 매핑

    @OneToMany 를 사용하는 입장에서는

    어느 엔티티 클래스도 연관관계의 주인이 될 수 없기 때문에

    1:N 양방향 매핑은 다루지 않는다.


    1:N 매핑의 실습을 위해

    1:N 관계를 갖는 상품 엔티티와 상품 분류 엔티티를 작성한다.


    먼저 1에 해당하는 상품 분류 엔티티를 작성!

    Category 상품분류 엔티티

    @Entity
    @Table(name = "category")
    @Getter @Setter
    @SuperBuilder
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString(callSuper = true)
    public class Category extends BaseEntity{
    
        @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")
        @Builder.Default
        private List<Product> products = new ArrayList<>();
    }

    상품 분류 엔티티 클래스를 생성하고 애플리케이션을 실행하면

    상품 분류 테이블이 생성되고,

    상품 테이블에 외래키가 추가된 것을 확인할 수 있다.

    일대다 단방향 관계의 단점은

    매핑의 주체가 아닌  반대 테이블에 외래키가 추가된다는 점 이다.

    이 방식은 다대일 구조와 다르게

    외래키를 설정하기 위해 다른 테이블에 대한 update 쿼리를 발생시킨다.


    테스트를 위해 CategoryRepository 생성 후 테스트 파일을 생성한다.

    test 코드

    @Test
    void relationshipTest() {
        Product product = Product.builder()
                .name("고량주")
                .price(2000)
                .stock(100)
                .build();
        productRepository.save(product);
    
        Category category = Category
                .builder()
                .code("S1")
                .name("주류")
                .build();
        category.getProducts().add(product);
        categoryRepository.save(category);
    
        // test
        categoryRepository
                .findById(1L)
                .get()
                .getProducts()
                .forEach(p -> System.out.println(
                        "[ p ]___________🚩  " +
                                p
                ));
    }

    test 코드 실행 후 로그를 보면

    Hibernate: 
        update
            product 
        set
            category_id=? 
        where
            number=?

    이렇게 연관관계 설정을 위한 update 쿼리가 발생한다. 이것은 일대다 연관관계이기 때문에 발생하는 것으로

    이같은 문제를 해결하기 위해서는 일대다 양방향 연관관계를 사용하기 보다는

    다대일 연관관계를 사용하는 것이 좋다.



        🐢🎈



    4. 다대다 매핑

    다대다 연관관계는

    실무에서 거의 사용되지 않는 구성이다.


    다대다 연관관계를 상품과 생산업체의 예로 들자면

    한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고,

    생산업체 한 곳이 여러 상품을 생산할 수도 있다.


    다대다 연관관계에서는

    각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.


    이런 경우에는 교차 엔티티라고 부르는

    중간 테이블을 생성해서

    다대다 관계를 일대다 또는 다대일 관계로 해소한다.


    - 단방향 매핑

    N:M 관계를 갖는 상품 엔티티와 생산업체 엔티티를 작성!

    Producer 생산업체 엔티티 작성

    @Entity
    @Table(name = "producer")
    @Getter @Setter
    @SuperBuilder
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString(callSuper = true)
    public class Producer extends BaseEntity{
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String code;
    
        private String name;
    
        @ManyToMany(fetch = FetchType.EAGER)
        @Builder.Default
        @ToString.Exclude
        private List<Product> products = new ArrayList<>();
    
        public void addProduct(Product product) {
            products.add(product);
        }
    }

    리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에

    별도의 @JoinColumn은 설정하지 않아도 된다.

    이렇게 작성하고 어플리케이션을 실행해 보면!

    producer_products 라는 테이블이 새로 생성되어 있는 것을 확인할 수 있다.

    @ManyToMany 연관관계시 자동 생성되는 테이블로, 이 테이블의 이름을 지정하고 싶다면

    @ManyToMany 어노테이션 아래에 @JoinTable(name = "이름")의 형태로 정의하면 된다.

    @ManyToMany 연관관계가 설정된 테이블에는 외래키가 생성되지 않고, 별도로 생성 된 테이블에서 두 테이블의 외래키를 관리한다.


    ManyToMany 연관관계 테스트!

    @Test
    @Transactional
    void relationshipTest() {
    
        Product product1 = saveProduct("두꺼비", 500, 1000);
        Product product2 = saveProduct("원숭이", 100, 2000);
        Product product3 = saveProduct("살빼지마 두꺼비ㅠ", 150, 3000);
    
        Producer producer1 = saveProducer("jinro");
        Producer producer2 = saveProducer("眞露");
    
        producer1.addProduct(product1);
        producer1.addProduct(product2);
    
        producer2.addProduct(product2);
        producer2.addProduct(product3);
    
        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
        producerRepository
                .findById(1L)
                .get()
                .getProducts()
                .forEach(product -> System.out.println(
                        "[ product ]___________🚩  " +
                                product
                ));
    }
    
    private Producer saveProducer(String name) {
        return producerRepository.save(Producer.builder().name(name).build());
    }
    
    private Product saveProduct(String name, int price, int stock) {
        return productRepository.save(
                Product.builder()
                        .name(name)
                        .price(price)
                        .stock(stock)
                        .build()
        );
    }

    이 코드에서는 가독성을 위해 리포지토리를 통해 테스트 데이터를 생성하는 부분을 별도의 메서드로 구현했다.

    이 경우 리포지토리를 사용하게 되면 매번 트랜잭션이 끊어져 생산업체 엔티티에서 상품 리스트를 가져오는 작업이 불가능해진다.

    이런 문제를 해소하기 위해 테스트 메서드에 @Transactional 어노테이션을 지정해 트랜잭션이 유지되도록 구성해서 테스트를 진행한다.


    relationshipTest() 메서드의 마지막 sout 코드 실행 결과를 보면 아래의 내용이 출력된다.

    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T22:14:01.010368700, updatedAt=2023-06-18T22:14:01.010368700), number=1, name=두꺼비, price=500, stock=1000, provider=null)
    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T22:14:01.108208, updatedAt=2023-06-18T22:14:01.108208), number=2, name=원숭이, price=100, stock=2000, provider=null)
    

    각각 producer 아이디가 1인 product 데이터가 출력된 것이다.

    연관관계 설정이 되어있기 때문에 정상적으로 생산업체 엔티티에서 상품 리스트를 가져온 것이다.

    이것들의 연관관계 설정 값은 중간 생성 테이블인 producer_product 테이블에 저장되며 관리되어진다.


    - 양방향 매핑

    다대다 양방향 매핑!

    다대다 단방향 매핑을 이해했다면

    이것은 간단하게 보여질 수 있다.

    Product 엔티티에 필드 추가

    ...
    @Entity
    @Table(name = "product")
    public class Product extends BaseEntity {
    	...
        
        @ManyToMany(fetch = FetchType.EAGER)
        @Builder.Default
        @ToString.Exclude
        private List<Producer> producers = new ArrayList<>();
    
        public void addProducer(Producer producer) {
            producers.add(producer);
        }
    }

    필요에 따라

    mappedBy 속성을 사용해

    두 엔티티 간 연관관계의 주인을 설정할 수도 있다.


    이렇게 작성한 후 애플리케이션을 실행하면

    데이터베이스 테이블의 구조는 변경되지 않는다.

    중간 테이블이 연관관계를 설정하고 있기 때문이다.


    test !

    @Test
    @Transactional
    void relationshipTest2() {
    
        Product product1 = saveProduct("두꺼비", 500, 1000);
        Product product2 = saveProduct("원숭이", 100, 2000);
        Product product3 = saveProduct("살빼지마 두꺼비ㅠ", 150, 3000);
    
        Producer producer1 = saveProducer("jinro");
        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(Lists.newArrayList(producer1, producer2));
        productRepository.saveAll(Lists.newArrayList(product1, product2));
    
        producerRepository
                .findById(1L)
                .get()
                .getProducts()
                .forEach(product -> System.out.println(
                        "[ product ]___________🚩  " +
                                product
                ));
        productRepository
                .findById(2L)
                .get()
                .getProducers()
                .forEach(producer -> System.out.println(
                        "[ producer ]___________🚩  " +
                                producer
                ));
    }
    
    private Producer saveProducer(String name) {
        return producerRepository.save(Producer.builder().name(name).build());
    }
    
    private Product saveProduct(String name, int price, int stock) {
        return productRepository.save(
                Product.builder()
                        .name(name)
                        .price(price)
                        .stock(stock)
                        .build()
        );
    }

    test 실행 결과

    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T22:35:24.527745400, updatedAt=2023-06-18T22:35:24.527745400), number=1, name=두꺼비, price=500, stock=1000, provider=null)
    [ product ]___________🚩  Product(super=BaseEntity(createdAt=2023-06-18T22:35:24.626131300, updatedAt=2023-06-18T22:35:24.626131300), number=2, name=원숭이, price=100, stock=2000, provider=null)
    [ producer ]___________🚩  Producer(super=BaseEntity(createdAt=2023-06-18T22:35:24.634122900, updatedAt=2023-06-18T22:35:24.634122900), id=1, code=null, name=jinro)
    [ producer ]___________🚩  Producer(super=BaseEntity(createdAt=2023-06-18T22:35:24.646049, updatedAt=2023-06-18T22:35:24.646049), id=2, code=null, name=眞露)
    

    이렇게 다대다 연관관계를 설정하면

    중간 테이블을 통해

    연관된 엔티티의 값을 가져올 수 있다.


    다만,

    다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있다.


    즉, 관리하기 힘든 포인트가 발생할 수 있는 문제가 있다.


    그렇기 때문에

    이러한 다대다 연관관계의 한계를 극복하기 위해서는

    중간 테이블을 생성하는 대신

    일대다 + 다대일로 연관관계를 맺을 수 있는

    중간 엔티티로 승격시켜

    JPA에서 관리할 수 있게 생성하는 것이 좋다.



        🐢🎈


    5. 영속성 전이

    영속성 전이 cascade 란

    특정 엔티티의 영속성 상태를 변경할 때

    그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐

    영속성 상태를 변경하는 것을 의미한다.


    영속성 전이는

    각 연관관계 어노테이션의 설정값 중에서 cascade 항목으로 입력할 수 있다.

    cascade 타입의 종류로는

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

    이렇게 존재한다.

    위의 표에서 알 수 있듯이

    영속성 전이에 사용되는 타입은

    엔티티 생명주기와 연관이 있다.


    한 엔티티가 cascade 요소의 값으로 주어진 영속 상태의 변경이 일어나면

    매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것이다.

    - 적용

    cascade를 적용해볼 엔티티는 상품 엔티티와 공급업체 엔티티이다.

    예를 들어,

    한 가게가 새로운 공급업체와 계약하며

    몇 가지 새 상품을 입고시키는 상황에서

    어떻게 영속성 전이가 적용되는지 살펴본다!


    우선 엔티티를 데이터베이스에 추가하는 경우로,

    영속성 전이 타입을 PERSIST로 지정하는 것을

    공급업체 엔티티에 정의한다.

    Provider 엔티티 작성

    기존에 작성되어있던 productList 필드의 연관관계 어노테이션에 cascade 속성을 추가해준다.

    ...
    public class Provider extends BaseEntity{
    	...
        
        @OneToMany(
                mappedBy = "provider",
                fetch = FetchType.EAGER,
                cascade = CascadeType.PERSIST // 추가!
        )
        @Builder.Default
        @ToString.Exclude
        List<Product> productList = new ArrayList<>();
    }

    test 코드 작성

    @Test
    void cascadeTest() {
        Provider provider = savedProvider("새로운 공급업체");
    
        Product product1 = savedProduct("상품1", 1000, 1100);
        Product product2 = savedProduct("상품2", 2000, 2200);
        Product product3 = savedProduct("상품3", 3000, 3300);
        
        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);
        
        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));
        
        // 여기서 영속성 전이가 수행 됨
        providerRepository.save(provider);
    }
    
    private Product savedProduct(String name, int price, int stock) {
        return Product.builder()
                .name(name)
                .price(price)
                .stock(stock)
                .build();
    }
    
    private Provider savedProvider(String name) {
        return Provider.builder().name(name).build();
    }

    영속성 전이를 사용하면

    부모 엔티티가 되는 Provider 엔티티만 저장하면

    코드에 작성돼 있는 Cascade.PERSIST에 맞춰

    상품 엔티티도 함께 저장할 수 있다.

    실행된 sql 쿼리

    Hibernate: 
        insert 
        into
            provider
            (created_at, updated_at, name) 
        values
            (?, ?, ?)
    Hibernate: 
        insert 
        into
            product
            (created_at, updated_at, name, price, provider_id, stock) 
        values
            (?, ?, ?, ?, ?, ?)
    Hibernate: 
        insert 
        into
            product
            (created_at, updated_at, name, price, provider_id, stock) 
        values
            (?, ?, ?, ?, ?, ?)
    Hibernate: 
        insert 
        into
            product
            (created_at, updated_at, name, price, provider_id, stock) 
        values
            (?, ?, ?, ?, ?, ?)

    특정 상황에 맞추어 영속성 전이 타입을 설정하면

    영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행할 수 있어

    개발의 생산성이 높아진다.


    다만,

    자동 설정으로 동작하는 코드들이 정확히 어떤 영향을 미치는지 파악할 필요가 있다.

    예를 들어,

    REMOVE 와 REMOVE를 포함하는 ALL 같은 타입을

    무분별하게 사용하면

    연관된 데이터가

    의도치 않게 모두 삭제될 수 있기 때문에

    다른 타입보다 더욱 사이드 이펙트고려해서 사용해야 한다.


    - 고아 객체

    JPA에서 고아 Orphan 란,

    부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다.


    JPA 에서는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.

    물론,

    자식 엔티티가

    다른 엔티티와 연관관계를 가지고 있다면

    이 기능은 사용하지 않는 것이 좋다.


    현재 예제에서 사용되는 상품 엔티티는

    다른 엔티티와 연관관계가 많이 설정되어 있기 때문에

    그 부분은 예외로 두고 테스트를 진행!


    고아 객체를 제거하는 기능을 사용하기 위해

    공급업체 엔티티의 연관관계 어노테이션에 다음과 같이 속성 값을 추가해준다.

    Provider 엔티티 작성

    public class Provider extends BaseEntity{
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String name;
    
        @OneToMany(
                mappedBy = "provider",
                fetch = FetchType.EAGER,
                cascade = CascadeType.PERSIST,
                orphanRemoval = true // 이 부분 추가
        )
        @Builder.Default
        @ToString.Exclude
        List<Product> productList = new ArrayList<>();
    }

    orphanRemoval = true 이 속성은 고아 객체를 제거하는 기능이다.

    test 코드 작성

    @Test
    @Transactional
    void orphanRemovalTest() {
        Provider provider = savedProvider("새로운 공급업체");
    
        Product product1 = savedProduct("상품1", 1000, 1100);
        Product product2 = savedProduct("상품2", 2000, 2200);
        Product product3 = savedProduct("상품3", 3000, 3300);
    
        // 연관관계 설정
        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);
    
        // 고아 객체 생성
        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);
    
        // 연관관계가 끊긴 상품 제거 확인
        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
    }
    
    private Product savedProduct(String name, int price, int stock) {
        return Product.builder()
                .name(name)
                .price(price)
                .stock(stock)
                .build();
    }
    
    private Provider savedProvider(String name) {
        return Provider.builder().name(name).build();
    }

    test log 확인

    고아 객체 생성 전

    Hibernate: 
        select
            provider0_.id as id1_6_,
            provider0_.created_at as created_2_6_,
            provider0_.updated_at as updated_3_6_,
            provider0_.name as name4_6_ 
        from
            provider provider0_
    Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체)
    Hibernate: 
        select
            product0_.number as number1_3_,
            product0_.created_at as created_2_3_,
            product0_.updated_at as updated_3_3_,
            product0_.name as name4_3_,
            product0_.price as price5_3_,
            product0_.provider_id as provider7_3_,
            product0_.stock as stock6_3_ 
        from
            product product0_
    Product(super=BaseEntity(createdAt=2023-06-18T23:24:55.309850400, updatedAt=2023-06-18T23:24:55.309850400), number=1, name=상품1, price=1000, stock=1100, provider=Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체))
    Product(super=BaseEntity(createdAt=2023-06-18T23:24:55.327731700, updatedAt=2023-06-18T23:24:55.327731700), number=2, name=상품2, price=2000, stock=2200, provider=Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체))
    Product(super=BaseEntity(createdAt=2023-06-18T23:24:55.329696, updatedAt=2023-06-18T23:24:55.329696), number=3, name=상품3, price=3000, stock=3300, provider=Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체))

    고아 객체 생성 후

    Hibernate: 
        select
            provider0_.id as id1_6_,
            provider0_.created_at as created_2_6_,
            provider0_.updated_at as updated_3_6_,
            provider0_.name as name4_6_ 
        from
            provider provider0_
    Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체)
    Hibernate: 
        delete 
        from
            product 
        where
            number=?
    Hibernate: 
        select
            product0_.number as number1_3_,
            product0_.created_at as created_2_3_,
            product0_.updated_at as updated_3_3_,
            product0_.name as name4_3_,
            product0_.price as price5_3_,
            product0_.provider_id as provider7_3_,
            product0_.stock as stock6_3_ 
        from
            product product0_
    Product(super=BaseEntity(createdAt=2023-06-18T23:24:55.327731700, updatedAt=2023-06-18T23:24:55.327731700), number=2, name=상품2, price=2000, stock=2200, provider=Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체))
    Product(super=BaseEntity(createdAt=2023-06-18T23:24:55.329696, updatedAt=2023-06-18T23:24:55.329696), number=3, name=상품3, price=3000, stock=3300, provider=Provider(super=BaseEntity(createdAt=2023-06-18T23:24:55.217474900, updatedAt=2023-06-18T23:24:55.217474900), id=1, name=새로운 공급업체))



        🐢🎈

Designed by Tistory / Custom by 얼거스