영속성 전이와 지연로딩 그리고 공통 속성 공통화

gdhi·2023년 12월 8일
post-thumbnail

📖영속성 전이 (CASCADE)

cascade : "폭포, 폭포처럼 흐르다" 라는 사전적 의미를 바탕으로 Entity의 상태를 변경할 때 해당 Entity와 연관된 Entity의 상태 변화를 전파시키는 옵션.
a@email.com 에서 b@email.com로 변경하면 그와 연결된 item 등을 b로 전파해야한다.

  • 부모는 One 에 해당하는 Entity
  • 자식은 Many 에 해당하는 Entity
  • 부모 Entity 가 존재해야 자식 Entity 가 부모를 참조하며 생성될 수 있음

CASCADE 종류

Cascade설명
PERSIST부모가 영속화될 때 자식도 영속화
MERGE부모가 병합될 때 자식도 병합
REMOVE부모가 삭제될 때 자식도 삭제
REFRESH부모가 refresh 되면 자식도 refresh
DETACH부모가 detach 되면 자식도 detach
⚡ALL부모의 상태 변화를 자식에게 모두 전이⚡

📌OrderRepository 인터페이스 생성

package com.shop.repository;

import com.shop.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {

}



📌Order Entity 클래스 수정

...java

   @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) // order_item 테이블의 order 필드에 매핑
    private List<OrderItem> orderItems = new ArrayList<>();
    
    ...



🤦‍♀️Order 테스트 하기

package com.shop.entity;

import com.shop.constant.ItemSellStatus;
import com.shop.repository.ItemRepository;
import com.shop.repository.OrderRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceContext;
import org.aspectj.weaver.ast.Or;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class OrderTest {

    @Autowired
    OrderRepository orderRepository;

    @Autowired
    ItemRepository itemRepository;

    @PersistenceContext
    EntityManager em;

    public Item createItem(){

        Item item = new Item();

        item.setItemNm("테스트 상품");
        item.setPrice(10000);
        item.setItemDetail("상세 설명");
        item.setItemSellStatus(ItemSellStatus.SELL);
        item.setRegTime(LocalDateTime.now());
        item.setUpdateTime(LocalDateTime.now());

        return item;

    }

    // 부모인 Order Entity 가 저장될 때, 자식인 OrderItem 또한 저장되는 영속성 전이
    // Order 객체 저장 시, 참조되는 Order_Item 객체 저장
    @Test
    @DisplayName("영속성 전이 테스트")
    public void cascadeTest(){

        Order order = new Order();

        for (int i = 0; i < 3; i++) {

            Item item = this.createItem();
            itemRepository.save(item);
            OrderItem orderItem = new OrderItem();

            orderItem.setItem(item);
            orderItem.setCount(10);
            orderItem.setOrderPrice(1000);
            orderItem.setOrder(order); // 하나의 order에 3개의 item
            order.getOrderItems().add(orderItem); // List니까 add

        }

        orderRepository.saveAndFlush(order); // 주문 추가 flush()
        em.clear();;

        Order savedOrder = orderRepository.findById(order.getId()).orElseThrow(EntityNotFoundException::new);
        assertEquals(3, savedOrder.getOrderItems().size());
    }

}



📍결과

👉 3개의 Item 생성

👉 orders 생성


👉 order_item을 넣지도 않았는데 알아서 insert ? 👉 영속성 전이. 부하가 부모걸 읽어서 반영.

A라는 상품을 3개 주문 👉 3개의 주문서가 "A라는 상품을 3개 주문" 에 의해 3개의 주문서가 알아서 생성

보통 우리가 주문 할 때 A상품 3개를 주문하면 합쳐져서 주문되지만
지금 한 테스트는 영속성전이를 이해하기 위해 for문으로 같은 객체를 3개 주문해 주문서 3개를 만든 것









📖고아 객체(ORPHAN)

부모 엔티티와 연관 관계가 끊어진 자식 엔티티 👉 자식 엔티티는 남아 있으니까 우리가 일일히 확인하며 삭제 할 수 없기 때문에 알아서 제거 된다. 단, 자식의 엔티티를 다른 엔티티가 참조하고 있다면 제거 하지 않는다.
orphanRemoval = true/false 사용

 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL
    , orphanRemoval = true)

CASCADE.REMOVE vs 고아객체 제거

CASCADE.REMOVE - 부모 Entity 가 삭제될 때 같이 삭제되는 것
고아객체 제거 - 부모 Entity 와의 연관관계가 끊어질 때 삭제되는 것


🤦‍♀️OrderTest 수정

...

    @Autowired
    MemberRepository memberRepository;

...

	// 부모 Entity 인 Order 객체 생성
    public Order createOrder(){

        Order order = new Order();

        for (int i = 0; i < 3; i++) {

            Item item = this.createItem();
            itemRepository.save(item);
            OrderItem orderItem = new OrderItem();

            orderItem.setItem(item);
            orderItem.setCount(10);
            orderItem.setOrderPrice(1000);
            orderItem.setOrder(order); // 하나의 order에 3개의 item
            order.getOrderItems().add(orderItem); // List니까 add

        }

        Member member = new Member();
        memberRepository.save(member);
        order.setMember(member);
        orderRepository.save(order);

        return order;

    }
    
    @Test
    @DisplayName("고아객체 제거 테스트")
    /*
	Order 객체가 관리하는 OrderItem 리스트에서 0번째 요소의 id를 추출
	0번째 요소를 제거한 뒤, OrderItem 조회 (id 이용)
	조회 결과 Optional.empty() 라면 제대로 제거된 것이므로 테스트 통과
    */
    public void orphanRemovalTest(){
        Order order = this.createOrder();
        order.getOrderItems().remove(0); // 연관 관계 끊기
        em.flush();
    }

}



📍결과

Hibernate: 
    delete 
    from
        order_item 
    where
        order_item_id=?

👉 고아 객체 제거









📖즉시로딩

엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 Fetch EAGER 타입. 기본 default 값이다


📌OrderItemRepository 생성

package com.shop.repository;

import com.shop.entity.OrderItem;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {


}

🤦‍♀️OrderTest로 테스트 하기

...

    @Autowired
    OrderItemRepository orderItemRepository;

...

    @Test
    @DisplayName("즉시 로딩 테스트")
    public void lazyLoadingTest(){

        Order order = this.createOrder();
        Long orderItemId = order.getOrderItems().get(0).getId();
        em.flush();
        em.clear();

        OrderItem orderItem = orderItemRepository.findById(orderItemId)
                .orElseThrow(EntityNotFoundException::new);

        System.out.println("Order class : " + orderItem.getOrder().getClass());
       System.out.println("============================");
        orderItem.getOrder().getOrderDate();
        System.out.println(orderItem.getOrder().getOrderDate());
        System.out.println("============================");

    }
    

📍결과

Hibernate: 
    select
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        i1_0.item_detail,
        i1_0.item_nm,
        i1_0.item_sell_status,
        i1_0.price,
        i1_0.reg_time,
        i1_0.stock_number,
        i1_0.update_time,
        o1_0.order_id,
        m1_0.member_id,
        m1_0.address,
        m1_0.email,
        m1_0.name,
        m1_0.password,
        m1_0.role,
        m1_0.tel_number,
        o1_0.order_date,
        o1_0.order_status,
        o1_0.reg_time,
        o1_0.update_time,
        oi1_0.order_price,
        oi1_0.reg_time,
        oi1_0.update_time 
    from
        order_item oi1_0 
    left join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id 
    left join
        orders o1_0 
            on o1_0.order_id=oi1_0.order_id 
    left join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    where
        oi1_0.order_item_id=?
Order class : class com.shop.entity.Order
============================
null
============================

👉 즉시로딩은 로딩을 바로바로 하니까 join을 3개나 불렀다. 이러니까 현업에선 사용하지 않는다









📖⚡지연 로딩⚡

연관된 엔티티는 사용할 때 조회하는 Fetch LAZY 타입

  • 실제 비즈니스 구현 시 매핑되는 엔티티의 개수 ↑
  • 사용하지 않는 데이터도 한꺼번에 조회하므로 성능 ↓

📌OrderItem 클래스 수정


...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id") // 외래키
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id") // 외래키
...



🤦‍♀️OrderTest 테스트 하기

    @Test
    @DisplayName("지연 로딩 테스트")
    public void lazyLoadingTest(){

        Order order = this.createOrder();
        Long orderItemId = order.getOrderItems().get(0).getId();
        em.flush();
        em.clear();

        OrderItem orderItem = orderItemRepository.findById(orderItemId)
                .orElseThrow(EntityNotFoundException::new);

        System.out.println("Order class : " + orderItem.getOrder().getClass());
        System.out.println("============================");
        orderItem.getOrder().getOrderDate();  
        System.out.println("============================");

    }

👉 이름만 변경



📍결과

Hibernate: 
    select
        oi1_0.order_item_id,
        oi1_0.count,
        oi1_0.item_id,
        oi1_0.order_id,
        oi1_0.order_price,
        oi1_0.reg_time,
        oi1_0.update_time 
    from
        order_item oi1_0 
    where
        oi1_0.order_item_id=?
Order class : class com.shop.entity.Order$HibernateProxy$PAJtIzFU

👉 많이 짧아 졌다. 그 이유는?

Order class : class com.shop.entity.Order$HibernateProxy$0COhfF1VHibernateProxy 가 나왔다

엔티티 대신 위 프록시에 객체를 넣어뒀다가(지연) 프록시 객체는 실제 사용 시점에 HibernateProxy 에서 받아와 조회 쿼리문이 실행한다.


============================
Hibernate: 
    select
        o1_0.order_id,
        m1_0.member_id,
        m1_0.address,
        m1_0.email,
        m1_0.name,
        m1_0.password,
        m1_0.role,
        m1_0.tel_number,
        o1_0.order_date,
        o1_0.order_status,
        o1_0.reg_time,
        o1_0.update_time 
    from
        orders o1_0 
    left join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    where
        o1_0.order_id=?
============================

👉 rderItem.getOrder().getOrderDate() 수행 결과

👉실제 사용 시점에 select 쿼리문이 수행



📌나머지 엔티티 외래키들 전부 지연로딩으로 변경 할 것

👉 Cart

👉 CartItem

👉 item

👉 Order









📖Auditing 공통 속성 공통화

엔티티에 공통으로 들어가는 멤버변수들 (등록시간, 수정시간, 등록자, 수정자 등)추상클래스로 만들고, 해당 추상 클래스를 상속받아 엔티티에 공통적인 기능을 수행하도록 하며 엔티티의 생성과 수정을 감시(Audit)하는 기법. 👉 코드를 깔끔하게 만드는 리팩토링.

Spirng JPA Audition 기능 제공 👉 엔티티가 저장 또는 수정될 때 자동으로 동작한다.

Auditing의 필요성

  • 여러 엔티티에 공통된 멤버변수가 존재할 때 하나의 추상클래스로 통합하여 구현할 수 있음
  • 등록시간, 수정시간, 등록자, 수정자 등의 엔티티 상태 변경에 대한 정보를 기록할 수 있음
  • 기록을 바탕으로 버그 문의, 업데이트 변경 대상 조회 등등 여러 상황에서 사용됨

📌AuditorAwareImpl 클래스 생성

package com.shop.config;

import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

// 로그인한 사용자를 등록자 및 수정자로 지정하기 위해 AuditorAware 인터페이스를 구현
public class AuditorAwareImpl implements AuditorAware<String > {

    // Optional 클래스는 아래와 같은 value 값을 저장하기 때문에
    // 값이 "null이더라도 바로 NullPointError"가 발생하지 않으며, 클래스이기 때문에 각종 메소드를 제공
    @Override
    public Optional<String> getCurrentAuditor(){ // getCurrentAuditor 👉 현재 로그인된 상태

        // 현재 로그인한 사용자의 정보를 추출
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        String userId = "";

        if(authentication != null){
            //현재 로그인 한 사용자의 정보를 조회하여 사용자의 이름을 등록자와 수정자로 지정
            userId = authentication.getName();
        }

        return Optional.of(userId);

    }
}



📌AuditConfig 설정 클래스

package com.shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing // Auditing 기능 활성화
public class AuditConfig {

    @Bean
    public AuditorAware<String> auditorProvider(){

        return new AuditorAwareImpl(); // AuditorAware<String> 구현체를 Bean 객체에 등록
    }
}



📌BaseTimeEntity Auditing 기능 수행 Entity 추상 클래스 생성

package com.shop.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

// 등록자 및 수정자 제외한 시간관련 Auditing 기능 수행 Entity
@EntityListeners(value = {AuditingEntityListener.class}) // Auditing을 하기 위해 엔티티 리스너 추가
@MappedSuperclass // 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공
@Getter
@Setter
public abstract class BaseTimeEntity {

    @CreatedDate // 생성 시 자동 저장
    @Column(updatable = false) // 생성시에만 업데이트 한다
    private LocalDateTime regTime;

    @LastModifiedDate // 변경 시 자동 저장
    private LocalDateTime updateTime;
}



📌BaseTimeEntity 상속 BaseEntity 추상 클래스 생성

package com.shop.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.hibernate.sql.Update;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.jdbc.object.UpdatableSqlQuery;

// BaseTimeEntity 상속를 상속 받아
// 등록자 및 수정자, 등록 및 수정 시간 4가지 모두 갖는 Entity
@EntityListeners(value = {AuditingEntityListener.class}) // Auditing을 하기 위해 엔티티 리스너 추가
@MappedSuperclass // 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공
@Getter
public abstract class BaseEntity extends BaseTimeEntity{
    
    @CreatedBy // 등록자
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy // 수정자
    private String modifiedBy;
}



📌Member Entity 클래스에 BaseEntity 상속

@Entity
@Table (name = "member")
@Getter
@Setter
@ToString
public class Member extends BaseEntity{
    @Id
    
    ...
    



🤦‍♀️MeberTest 테스트 해보기

package com.shop.entity;

import com.shop.repository.MemberRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class MemberTest {

    @Autowired
    MemberRepository memberRepository;

    @PersistenceContext
    EntityManager em;

    @Test
    @DisplayName("Auditing 테스트")
    @WithMockUser(username = "gildong", roles = "USER")
    public void auditingTest(){

        Member newMember = new Member();
        memberRepository.save(newMember);

        em.flush();
        em.clear();

        Member member = memberRepository.findById(newMember.getId())
                .orElseThrow(EntityNotFoundException::new);

        System.out.println("register time : " + member.getRegTime());
        System.out.println("update time : " + member.getUpdateTime());
        System.out.println("create member : " + member.getCreatedBy());
        System.out.println("modify member : " + member.getModifiedBy());

    }
}



📍결과

👉 잘 감시하고 있다



❓어떤 구조로 동작?

@EntityListeners 기능을 이용해서 Entity 등록 및 수정 시에 Auditing 기능을 수행하는 구조이다.
@MappedSuperclass - 해당 클래스를 상속 받는 Entity 들이 공통되는 필드를 사용할 수 있도록 부모 클래스에 지정

1. AuditorAwareImpl

AuditorAware 인터페이스를 구현한 클래스 생성

2. AuditConfig

@Configuration, @EnableJpaAuditing 을 통해서 JPA Auditing 기능 활성화
동시에 AuditorAware 인터페이스 구현체 @Bean 등록

3. BaseEntity

Auditing 활성화 상태에서는 AuditorAware 구현체 @Bean 을 자동으로 찾아서 AuditingEntityListener 와 매핑

4. Member

이제 원하는 엔티티에 @EntityListeners, @MappedSuperclass 지정

5. 엔티티 등록 및 수정 시에 관련된 해당 필드 insertupdate



📌다른 Entity 변경하기

👉 미리 상속받아 사용하자

0개의 댓글