개발/Project(Spring-쇼핑몰)

Project - (4) 연관관계 매핑

잇(IT) 2023. 8. 7. 13:14
728x90
- ERD

- 구성할 ERD는 위와 같다.


- 연관 관계 매핑 종류

1. 일대일 : @OneToOne

2. 일대다 : @OneToMany

3. 다대일 : @ManyToOne

4. 다대다 : @ManyToMany

 

- 일대일 단방향 매핑

- Cart.java

@Entity
@Table(name = "cart")
@Getter
@Setter
@ToString
public class Cart{

    @Id
    @Column(name = "cart_id")
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @OneToOne
    //회원 엔티티와 일대일로 매핑한다.
    @JoinColumn(name = "member_id")
    //매핑할 외래키를 지정한다. name 속성에는 매핑할 외래키의 이름을 설정한다.
    private Member member;
}

- @OneToOne으로 매핑을 해주고 @JoinColumn을 사용하면 자동으로 외래키를 지정하여 테이블을 수정하는 것을 확인 할 수 있다.

 

- CartTest.java

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

    @Autowired
    CartRepository cartRepository;
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    PasswordEncoder passwordEncoder;

    @PersistenceContext
    EntityManager em;

    public Member createMember() {
        MemberFormDto memberFormDto = new MemberFormDto();
        memberFormDto.setEmail("test@email.com");
        memberFormDto.setName("백인수");
        memberFormDto.setAddress("서울시 동작구 상도동");
        memberFormDto.setPassword("1234");
        return Member.createMember(memberFormDto, passwordEncoder);
    }
    //회원 엔티티를 생성하는 메소드

    @Test
    @DisplayName("장바구니 회원 엔티티 매핑 조회 테스트")
    public void findCartAndMemberTest() {
        Member member = createMember();
        memberRepository.save(member);

        Cart cart = new Cart();
        cart.setMember(member);
        cartRepository.save(cart);

        em.flush();
        // flush를 영속성 컨텍스트에 저장된 데이터들을 DB에 반영한다.
        em.clear();
        // 영속성 컨텍스트를 비워준다.
        // 영속성 컨텍스트가 비워져있는 상태에서 조회하면 DB에서 값을 가져온다.

        Cart savedCart = cartRepository.findById(cart.getId())
                .orElseThrow(EntityNotFoundException::new);
        // 저장된 장바구니 엔티티를 조회한다.
        Assertions.assertEquals(savedCart.getMember().getId(), member.getId());
    }
}

- 매핑을 하게 되면 xToOne은 fetch가 기본적으로 EAGER(즉시 로딩)이기 때문에 연관된 모든 엔티티를 한 번에 조회하게 된다.

- 연관된 엔티티가 몇개 없을 경우 한 번에 모든 것을 조회하게 되면 편리해 보이지만 실제로는 연관된 엔티티가 엄청나게 많기 때문에 즉시 로딩을 사용하는 것은 권장하지 않는다.


- 다대일 단방향 매핑

 

 

- CartItem.java

@Entity
@Table(name = "cart_item")
@Getter
@Setter
public class CartItem extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "cart_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "cart_id")
    private Cart cart;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    private int count;
}

- 마찬가지로 다대일 연관관계도 중간에 Cart와 Item을 위한 CartItem 엔티티를 생성하여 매핑해 줄 수 있다.

- 매핑을 하고 애플리케이션을 실행하게되면 외래키가 들어간 테이블을 생성하는 것을 확인 할 수 있다.


- 다대일/일대다 양방향 매핑하기

- 기본적으로 양방향 매핑이란 단방향 매핑이 2개 있다고 생각하면 된다.

 

- Order.java

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order extends BaseEntity{

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    private LocalDateTime regTime;
    private LocalDateTime updateTime;
}
   @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

- order와 member는 다대일이기 때문에 @ManyToOne으로 매핑해주고 order가 연관관계 주인이기 때문에 @JoinColumn을 통해 외래키를 설정해준다.

 

- OrderItem.java

@Entity
@Getter @Setter
public class OrderItem extends BaseEntity{

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    private int orderPrice;

    private int count;
}

- OrderItem은 Order와 Item과 둘 다 다대일 @ManyToOne 관계에 있다.

- 연관관계에서는 주인을 설정하는 것이 중요하다. 대체적으로  Many쪽에 연관관계의 주인을 설정하는 것이 좋다.

1. 연관 관계의 주인은 외래키가 있는 곳으로 설정

2. 연관 관계의 주인이 외래키를 관리(등록, 수정, 삭제)

3. 주인이 아닌 쪽은 연관 관계 매핑 시 mappedBy 속성의 값으로 연관 관계의 주인을 설정

4. 주인이 아닌 쪽은 읽기만 가능

 

- Order.java

...

@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();

...

 

- 연관관계의 주인은 OrderItem이다. Order는 주인이 아니므로 읽기만 가능하기 때문에 mappedBy를 사용한다.
- order를 작성한 이유는 OrderItem에 있는 Order에 의해 관리된다는 의미다.

- 일대다의 관계이기 때문에 OrderItem의 객체가 여러개 있을 수 있기 때문에 List로 받는다.


- 다대다 매핑하기

- 다대다의 연관 관계의 경우 다대다로 매핑하게 되면 추가 필드를 생성할 수 없기 때문에 중간에 연결 테이블을 생성하여 일대다 다대일 관계로 풀어내야 한다.


- 영속성 전이

- 영속성 전이(cascade) : 엔티티의 상태를 변경할 때 해당 엔티티와 연관된 엔티티의 상태 변화를 전파시키는 옵션이다.

- 이때 부모는 One에 해당하고, 자식은 Many에 해당한다.

   - 예를 들어 Order 엔티티가 삭제되었을 때 해당 엔티티와 연관되어 있는 OrderItem 엔티티가 함께 삭제 되거나, Order 엔티티를 저장 할 때 Order 엔티티에 담겨있던 OrderItem 엔티티를 한꺼번에 저장할 수 있다.

CASCADE 종류 설명
PERSIST 부모 엔티티가 영속화될 때 자식 엔티티도 영속화
MERGE 부모 엔티티가 병합될 때 자식 엔티티도 병합
REMOVE 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 삭제
REFRESH 부모 엔티티가 refresh되면 연관된 자식 엔티티도 refresh
DETACH 부모 엔티티가 detach 되면 연관된 자식 엔티티도 detach 상태로 변경
ALL 부모 엔티티의 영속성 상태 변화를 자식 엔티티에 모두 전이

- 영속성 전이 옵션은 단일 엔티티에 완전히 종속적이고 부모 엔티티와 자식 엔티티의 라이프 사이클이 유사할 때 cascade 옵션을 활용하는 것이 좋다.

 

- OrderRepository.java

public interface OrderReporsitory extends JpaRepository<Order, Long> {

}

 

- Order.java

  ... 
   
   @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

...

 

- OrderTest.java

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

    @Autowired
    OrderReporsitory orderReporsitory;

    @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.setStockNumber(100);
        item.setRegTime(LocalDateTime.now());
        item.setUpdateTime(LocalDateTime.now());
        return 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(10000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
//영속성 컨텍스트에 저장되지 않은 orderItem 엔티티를 order 엔티티에 담아준다.

        }

        orderReporsitory.saveAndFlush(order);
//order 엔티티를 저장하면서 강제로 flush를 호출하여 영속성 컨텍스트에 있는 객체들을 데이터베이스에 반영한다.

        em.clear();

        Order savedOrder = orderReporsitory.findById(order.getId())
                .orElseThrow(EntityNotFoundException::new);
//영속성 컨텍스트를 초기화했기 때문에 데이터베이스에서 주문 엔티티를 조회한다.
        Assertions.assertEquals(3, savedOrder.getOrderItems().size());
    }

- 총 3번의 쿼리가 나가는 것을 확인 할 수 있다.

 

- 고아 객체 제거하기

- 부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 고아 객체라고 한다. 영속성 전이 기능과 같이 사용하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.

- 고아 객체 제거 기능은 참조하는 곳이 하나일 때만 사용해야 한다. 다른 곳에서도 참조하고 있는 엔티티인데 삭제를 하게 되면 문제가 생길 수 있다.

- @OneToOne, @OneToMany 어노테이션에서 옵션으로 사용하면 된다.

 

- Order.java

...
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
    orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
...

 

 

- OrderTest.java

@Autowired
    MemberRepository memberRepository;

    public Order createOrder() {
        Order order = new Order();

        for (int i = 0; i < 3; i++) {
            Item item = createItem();
            itemRepository.save(item);
            OrderItem orderItem = new OrderItem();
            orderItem.setItem(item);
            orderItem.setCount(10);
            orderItem.setOrderPrice(10000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }

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

        order.setMember(member);
        orderReporsitory.save(order);
        return order;
    }

    @Test
    @DisplayName("고아객체 제거 테스트")
    public void orphanRemovalTest() {
        Order order = this.createOrder();
        order.getOrderItems().remove(0);
        //order 엔티티에서 관리하고 있는 orderItems 리스트의 0번째 인덱스 요소를 제거한다.
        em.flush();
    }
order.getOrderItems().remove(0);

- flush()를 호출하면 콘솔창에 orderItem을 삭제하는 쿼리문이 출력된다. 즉, 부모 엔티티와 연관 관계가 끊겼기 떄문에 고아 객체를 삭제하는 쿼리문이 실행된 것이다.


- 지연 로딩

- EAGER 즉시 로딩 외에도 LAZY 지연 로딩이 있다.

- LAZY 지연 로딩은 관련 엔티티를 바로 조회하는 것이 아니라 해당 엔티티의 필드값이 필요한 경우 쿼리를 날려 데이터를 가져온다.

 

- OrderItemRepository.java

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
}

 

- OrderTest.java

...

@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);
        //orElseThrow를 작성해주지 않으면 findById의 결과로 null이 나올 수 있기 때문에 반환 타입이 Optional로 바꿔줘야 한다.
        // clear로 영속성 컨텍스트를 비웠기 때문에 DB에서 다시 데이터를 조회하게 된다.
        System.out.println("orderItem.getOrder().getClass() = " + orderItem.getOrder().getClass());
Hibernate: 
    select
        orderitem0_.order_item_id as order_it1_4_0_,
        orderitem0_.reg_time as reg_time2_4_0_,
        orderitem0_.update_time as update_t3_4_0_,
        orderitem0_.created_by as created_4_4_0_,
        orderitem0_.modified_by as modified5_4_0_,
        orderitem0_.count as count6_4_0_,
        orderitem0_.item_id as item_id8_4_0_,
        orderitem0_.order_id as order_id9_4_0_,
        orderitem0_.order_price as order_pr7_4_0_,
        item1_.item_id as item_id1_2_1_,
        item1_.reg_time as reg_time2_2_1_,
        item1_.update_time as update_t3_2_1_,
        item1_.created_by as created_4_2_1_,
        item1_.modified_by as modified5_2_1_,
        item1_.item_detail as item_det6_2_1_,
        item1_.item_nm as item_nm7_2_1_,
        item1_.item_sell_status as item_sel8_2_1_,
        item1_.price as price9_2_1_,
        item1_.stock_number as stock_n10_2_1_,
        order2_.order_id as order_id1_5_2_,
        order2_.reg_time as reg_time2_5_2_,
        order2_.update_time as update_t3_5_2_,
        order2_.created_by as created_4_5_2_,
        order2_.modified_by as modified5_5_2_,
        order2_.member_id as member_i8_5_2_,
        order2_.order_date as order_da6_5_2_,
        order2_.order_status as order_st7_5_2_,
        member3_.member_id as member_i1_3_3_,
        member3_.reg_time as reg_time2_3_3_,
        member3_.update_time as update_t3_3_3_,
        member3_.created_by as created_4_3_3_,
        member3_.modified_by as modified5_3_3_,
        member3_.address as address6_3_3_,
        member3_.email as email7_3_3_,
        member3_.name as name8_3_3_,
        member3_.password as password9_3_3_,
        member3_.role as role10_3_3_ 
    from
        order_item orderitem0_ 
    left outer join
        item item1_ 
            on orderitem0_.item_id=item1_.item_id 
    left outer join
        orders order2_ 
            on orderitem0_.order_id=order2_.order_id 
    left outer join
        member member3_ 
            on order2_.member_id=member3_.member_id 
    where
        orderitem0_.order_item_id=?

- EAGER로 설정되어 있을 경우 모든 엔티티가 한번에 조회되는 것을 확인 할 수 있다. 물론 한번에 조회되면 좋아보이지만 연관 엔티티의 양이 많다면 성능에 문제가 발생할 수 있다.

- 또 사용하지 않는 데이터도 한꺼번에 들고 오기 때문에 불필요한 지연이 발생할 수 있다.

 

- OrderItem.java

@Entity
@Getter @Setter
public class OrderItem extends BaseEntity{

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

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

    private int orderPrice;

    private int count;
}

 

- OrderTest.java

...

@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);
        //orElseThrow를 작성해주지 않으면 findById의 결과로 null이 나올 수 있기 때문에 반환 타입이 Optional로 바꿔줘야 한다.
        // clear로 영속성 컨텍스트를 비웠기 때문에 DB에서 다시 데이터를 조회하게 된다.
        System.out.println("orderItem.getOrder().getClass() = " + orderItem.getOrder().getClass());
        System.out.println("======================================");
        orderItem.getOrder().getOrderDate();
        System.out.println("======================================");
    }
}

- LAZY 설정을 한 뒤 Test를 실행해보면 orderitem 엔티티만 조회하고 =====================가 실행된 뒤 Order의 필드의 데이터가 필요하기 때문에 별도로 Order 엔티티의 조회가 이뤄진 것을 확인할 수 있다.

orderItem.getOrder().getClass() = class com.shop.entity.Order$HibernateProxy$MFWTYjfX

- 또한 클래스를 확인해보면 Order객체 자체가 아닌 LAZY 지연 로딩을 위한 프록시 객체가 사용된 것을 확인 할 수 있다.\

 

- 나머지 엔티티들 도 xToOne 연관 관계 전부 LAZY로 변환해준다.


- Auditing을 이용한 엔티티 공통 속성 공통화

- Spring Data Jpa에서는 Auditing 기능을 제공하여 엔티티가 저장 또는 수정될 때 자동으로 등록일, 수정일, 등록자, 수정자를 입력해준다. 

- 즉, 엔티티의 생성과 수정을 감시하고 있는 것이다. 이런 공통 멤버 변수들을 추상 클래스로 만들고, 해당 추상 클래스를 상속받는 형태로 엔티티를 리펙토링 한다.

 

- AuditorAwareImpl.java

public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = "";
        if (authentication != null) {
            userId = authentication.getName();
        }
        return Optional.of(userId);
    }
}
userId = authentication.getName();

- 현재 로그인 한 사용자의 정보를 조회하여 사용자의 이름을 등록자와 수정자로 지정한다.

 

- AuditConfig.java

@Configuration
@EnableJpaAuditing
//JPA의 Auditing 기능을 활성화한다.
public class AuditConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {
        //등록자와 수정자를 처리해주는 AuditorAware을 빈으로 등록한다.
        return new AuditorAwareImpl();
    }
}

 

- BaseTimeEntity.java

@EntityListeners(value = {AuditingEntityListener.class})
//Auditing을 적용하기 위해서 @EntityListeners 어노테이션을 추가한다.
@MappedSuperclass
//공통 매핑 정보가 필요할 때 사용하는 어노테이션으로 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공한다.
@Getter
@Setter
public abstract class BaseTimeEntity {

    @CreatedDate
    //엔티티가 생성되어 저장될 때 시간을 자동으로 지정한다.
    @Column(updatable = false)
    private LocalDateTime regTime;

    @LastModifiedDate
    //엔티티의 값을 변경할 때 시간을 자동으로 저장한다.
    private LocalDateTime updateTime;
}

- 기본적으로 등록일 수정일은 대부분 엔티티에 들어가지만 등록자, 수정자의 경우 일부 엔티티에만 들어가는 경우가 많기 때문에 BaseTimeEntity에 등록자, 수정자를 생성하고 해당 등록자 수정자가 필요한 경우 상속 받도록 한다.

 

- BaseEntity.java

@EntityListeners(value = {AuditingEntityListener.class})
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity{

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String modifiedBy;
}

 

- Member.java

@Entity
@Table(name="member")
@Getter @Setter
@ToString
public class Member extends BaseEntity{
    //회원 정보를 저장하는 Member 엔티티 생성
    //BaseEntity를 상속 받는다.
    
    ...

- Member 엔티티의 경우 등록자, 수정사, 등록일, 수정일이 필요하기 때문에 모든 정보를 가지고 있는 BaseEntity를 상속 받는다.

 

- 엔티티마다 필요에 따라 BaseEntity 혹은 BaseTimeEntity를 상속 받아서 사용한다.

 

- MemberTest.java

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

    @Autowired
    MemberRepository memberRepository;

    @PersistenceContext
    EntityManager em;

    @Test
    @DisplayName("Auditing 테스트")
    @WithMockUser(username = "insoo", roles = "USER")
    // 스프링 시큐리티에서 제공하는 어노테이션으로 @WithMockUser에 지정한 사용자가
    //로그인한 상태라고 가정하고 테스트를 진행한다.
    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("member.getRegTime() = " + member.getRegTime());
        System.out.println("member.getUpdateTime() = " + member.getUpdateTime());
        System.out.println("member.getCreatedBy() = " + member.getCreatedBy());
        System.out.println("member.getModifiedBy() = " + member.getModifiedBy());

    }
}

- 위와 같이 Member 엔티티에 별도의 필드를 작성해주지 않아도 상속을 받아 생성된 것을 확인 할 수 있다.

728x90