ACHO.pk devlog

[Springboot] 연관 관계 매핑 본문

프레임워크/Springboot

[Springboot] 연관 관계 매핑

Acho 2023. 3. 6. 19:36

1. 연관 관계 매핑의 기초

엔티티들은 대부분 다른 엔티티와 연관 관계를 맺고 있다. JPA에서는 엔티티에 연관 관계를 매핑해두고 필요할 때 해당 엔티티와 연관된 엔티티를 사용하여 프로그래밍할 수 있도록 도와준다.

 

가. 연관 관계 매핑의 종류

  • 일대일 매핑 (1:1) : @OneToOne
  • 일대다 매핑 (1:N) : @OneToMany
  • 다대일 매핑 (N:1) : @ManyToOne
  • 다대다 매핑 (N:M) : @ManyToMany

 나. 방향성 고려

테이블에서 관계는 항상 양방향이지만, 객체에서는 단방향과 양방향이 존재한다. 

 

 

1-1. 일대일 단방향 매핑하기

장바구니(Cart) 엔티티를 만들고 회원 엔티티와 연관 관계 매핑을 설정해준다.Cart 에는 cart_id(PK)와 member_id가 존재한다.

entity > Cart.java

package com.shop.shop.entity;


import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;

@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")
    private Member member;

}

@OneToOne : 어노테이션을 이용해 회원 엔티티와 일대일로 매핑을 한다.

@OneToOne(fetch = FetchType.LAZY)

 

▹ @JoinColum : 어노테이션을 이용해 매핑할 외래키를 지정한다. name 속석에는 매핑할 외래키의 이름을 설정한다. name을 명시하지 않으면 JPA가 알아서 ID를 찾지만 컬럼명이 원하는대로 생성되지 않을 수 있기 때문에 직접 지정한다.

@JoinColumn(name="member_id")

 

하지만 여기서 회원 엔티티를 보면 장바구니 엔티티와 관련된 소스가 전혀 없다. 이는 장바구니 엔티티가 일방적으로 회원 엔티티를 참조하고 있는것이다. 장바구니와 회원은 일대일로 매핑되어 있으며, 장바구니 엔티티가 회원 엔티티를 참조하는 일대일 단방향 매핑이다.

애플리케이션을 실행하면 콘솔창에 cart 테이블이 생성되는 쿼리문이 실행되는 것을 볼 수 있다.

 

여기서, cart 테이블은 member_id 컬럼을 외래키로 갖는다. 테이블을 생성하는 쿼리문이 실행된 후 member_id를 foreign key로 지정하는 쿼리문이 실행된다.

 

이렇게 함으로써 장바구니 엔티티와 회원 엔티티의 매핑이 완료된다. 장바구니 엔티티를 조회하면서 회원 엔티티의 정보도 가져올 수 있게 된다.

 

 

1-2. 매핑한 엔티티의 정보를 가져오는 테스트 코드 작성

JpaRepository를 상속받는 CartRepository 인터페이스를 생성한다.

package com.shop.shop.repository;

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

public interface CartRepository extends JpaRepository<Cart, Long> {
}

 

CartTest 클래스를 생성 후에 아래 코드를 작성해서 테스트를 진행한다.

package com.shop.shop.entity;

import com.shop.shop.dto.MemberFormDto;
import com.shop.shop.repository.CartRepository;
import com.shop.shop.repository.MemberRepository;
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.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;

import javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;

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

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

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();
        em.clear();

        Cart savedCart = cartRepository.findById(cart.getId())
                .orElseThrow(EntityNotFoundException::new);
        assertEquals(savedCart.getMember().getId(), member.getId());
    }

▹회원 엔티티를 생성하는 메소드를 만든다.

public Member createMember(){

 

▹JPA는 영속성 컨텍스트에 데이터를 저장 후 트랜잭션이 끝날 때 flush()를 호출하여 데이터베이스에 반영한다. 회원 엔티티와 장바구니 엔티티를 영속성 컨텍스트에 저장 후 엔티티 매니저로부터 강제로 flush()를 호출하여 데이터베이스에 반영한다.

이 코드를 실행하면 장바구니와 회원 데이터를 insert하는 쿼리문이 콘솔창에 출력되는 것을 볼 수 있다. SQL 저장소에 저장된 쿼리문이 데이터베이스에 반영된다.

em.flush();

 

▹JPA는 영속성 컨텍스트로부터 엔티티를 조회 후 영속성 컨텍스트에 엔티티가 없을 경우 데이터베이스를 조회한다. 실제 데이터베이스에서 장바구니 엔티티를 가지고 올 때 회원 엔티티도 같이 가지고 오는지 보기 위해서 영속성 컨텍스트를 비워준다.

em.clear();

 

▹저장된 장바구니 엔티티를 조회한다.

이 코드를 실행하면 cart 테이블과 member 테이블을 조인해서 가져오는 쿼리문이 실행된다. cart 엔티티를 조회하면서 member 엔티티도 동시에 가져오게 된다.

Cart savedCart = cartRepository.findById(cart.getId())

 

▹처음에 저장한 member 엔티티의 id와 savedCart에 매핑된 member 엔티티의 id를 비교한다.

assertEquals(savedCart.getMember().getId(), member.getId());

 

📚 영속성 컨텍스트란?

 

 

 

엔티티를 조회할 때 해당 엔티티와 매핑된 엔티티도 한 번에 조회하는 것을 '즉시 로딩'이라고 한다. 일대일, 다대일로 매핑할 경우 즉시 로딩을 기본 Fetch 전략으로 설정한다.

@OneToOne(fetch=FetchType.EAGER)
@JoinColumn(name="member_id")
private Member member;

📚 기본 Fetch 전략이란?

 

 

1-3. 다대일 단방향 매핑하기

장바구니(cart)에는 여러 가지의 상품들이 들어갈 수 있으며, 같은 상품을 여러 개 주문할 수도 있으니, 이러한 관계를 매핑해줘야한다.

entity > CartItem.java

package com.shop.shop.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cart_id")
    private Cart cart;

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

    private int count;
}

▹하나의 장바구니에는 여러 개의 상품을 담을 수 있으므로 @ManyToOne 어노테이션을 이용하여 다대일 관계로 매핑한다.

private Cart cart;

 

▹장바구니에 담을 상품의 정보를 알아야하므로 상품 엔티티를 매핑해준다. 하나의 상품은 여러 장바구니의 장바구니 상품으로 담길 수 있으므로 @ManyToOne 어노테이션을 이용하여 다대일 관계로 매핑한다.

private Item item;

 

▹같은 상품을 장바구니에 몇 개 담을지 저장한다.

private int count;

 

장바구니 상품 도메인 설계를 완료하면 애플리케이션을 재실행해서 콘솔창에 출력되는 쿼리문을 확인해보자.

cart_item 테이블에 @JoinColumn 어노테이션에 name으로 설정한 값이 컬럼 id로 추가된다.

 

엔티티와 매핑되는 테이블에 @JoinColumn 어노테이션의 name으로 설정한 값이 foreign key로 추가되는 것도 볼 수 있다. @JoinColumn 어노테이션을 사용하는 엔티티에 컬럼이 추가된다고 보면 된다.

 

 

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

양방향 매핑이란 단방향 매핑이 2개 있는 것이다.

장바구니 상품 엔티티가 장바구니를 참조하는 단방향 매핑이었다면, 장바구니 엔티티에 장바구니 상품 엔티티를 일대다 관계로 매핑을 해주는 것이 양방향 매핑이다.

constant > OrderStatus.java

package com.shop.shop.constant;

public enum OrderStatus {
    ORDER, CANCEL
}

 

주문 엔티티를 만들어보자. 엔티티를 먼저 설계 후 주문과 주문 상품 엔티티의 매핑 관계를 정의한다.

entity > Order.java

package com.shop.shop.entity;

import com.shop.shop.constant.OrderStatus;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;

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

    @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;
}

▹정렬할 때 사용하는 "order" 키워드가 있기 때문에 Order 엔티티에 매핑되는 테이블로 "orders"를 지정한다.

@Table(name = "orders")

 

▹한 명의 회원은 여러 번 주문을 할 수 있으므로 주문 엔티티 기준으로 다대일 단방향 매핑을 한다.

private Member member;

 

이제, 주문 상품 엔티티와 주문 엔티티의 단방향 매핑을 먼저 설정해주자.

entity > OrderItem.java

package com.shop.shop.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

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

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

    private int orderPrice; //주문가격

    private int count; //수량

    private LocalDateTime regTime;

    private LocalDateTime updateTime;

}

▹하나의 상품은 여러 주문 상품으로 들어갈 수 있으므로 주문 상품 기준으로 다대일 단방향 매핑을 설정한다.

private Item item;

 

▹한 번의 주문에 여러 개의 상품을 주문할 수 있으므로 주문 상품 엔티티와 주문 엔티티를 다대일 단방향 매핑을 먼저 설정한다.

private Order order

 

다대일과 일대다. 이 둘은 반대 관계로 생각하면 편하다. 주문 상품 엔티티 기준에서 다대일 매핑이었으므로 주문 엔티티 기준에서는 일대다 매핑이 되는 것이다. 양방향 매핑에서는 "연관 관계 주인"을 설정해야 한다는 점이 중요하다.

📚 연관 관계 주인이란 ?

 

ORDERS 와 ORDERS_ITEM 테이블을 ORDER_ID를 외래키로 조인하면 주문에 속한 상품이 어떤 상품들이 있는지 알 수 있고, 주문 상품은 어떤 주문에 속하는지를 알 수 있다. 즉, 테이블외래키 하나로 양방향 조회가 가능하다.

하지만

엔티티는 테이블과 다르게 엔티티를 양방향 연관 관계로 설정하면 객체의 참조는 둘인데 외래키는 하나이므로 누가 외래키를 관리할지를 정해야한다.

  • 연관 관계의 주인은 외래키가 있는 곳으로 설정
  • 연관 관계의 주인이 외래키를 관리(등록, 수정, 삭제)
  • 주인이 아닌 쪽은 연관 관계 매핑 시 mappedBy 속성의 값으로 연관 관계의 주인을 설정
  • 주인이 아닌 쪽은 읽기만 가능

연관 관계 주인 설정을 자세히 보자. Order 엔티티에 OrderItem과 연관 관계 매핑을 추가해준다. OrderItem 엔티티에서 이미 다대일 단방향 매핑을 했으므로 양방향 매핑이 된다.

entity > Order.java

package com.shop.shop.entity;

import com.shop.shop.constant.OrderStatus;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

    @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; //주문상태

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

    private LocalDateTime regTime;

    private LocalDateTime updateTime;
}

▹주문 상품 엔티티와 일대다 매핑을 한다. 외래키(order_id)가 order_item 테이블에 있으므로 연관 관계의 주인은 OrderItem 엔티티이다. Order 엔티티가 주인이 아니므로 "mappedBy" 속성으로 연관 관계의 주인을 설정한다. 속성의 값으로 "order"를 적어준 이유는 OrderItem에 있는 Order에 의해 관리된다는 의미로 해석하면 된다. 즉, 연관 관계 주인의 필드인 order를 mappedBy의 값으로 세팅하면 된다.

@OneToMany(mappedBy = "order")

 

▹하나의 주문이 여러 개의 주문 상품을 갖으므로 List 자료형을 사용해서 매핑한다.

private List<OrderItem> orderItems = new ArrayList<>();

무조건 양방향으로 연관 관계를 매핑하면 해당 엔티티는 엄청나게 많은 테이블과 연관 관계를 맺게 되고, 엔티티 클래스 자체가 복잡해지기 때문에 연관 관계 단방향 매핑으로 설계 후 나중에 필요할 경우 양방향 매핑을 하는 것이 좋다.

 

 

1-5. 다대다 매핑하기

관계형 데이터 베이스는 정규화된 테이블 2개로 다대다를 표현할 수 없다. 따라서 연결 테이블을 생성해서 다대다 관계를 일대다, 다대일 관계로 풀어내야 한다.

 

객체는 테이블과 다르게 컬렉션을 사용해서 다대다 관계를 표현할 수 있다.  member 엔티티는 item을 리스트 형태로 가질 수 있으며, item 엔티티도 member를 리스트로 가질 수 있다.

 📚 컬렉션이란?

 

@ManyToMany 어노테이션을 사용해서 다대다 매핑이 가능한데, 아래 코드를 참고하면 좋을 것 같다.

다대다 매핑은 실무에서 사용하지 않는 이유는 연결 테이블에는 컬럼을 추가할 수 없기 때문이다. 또한 엔티티를 조회할 때 member 엔티티에서 item을 조회하면 중간 테이블이 있기 대문에 어떤 쿼리문이 실행될지 예측하기 어렵다.

public class Item {
	@ManyToMany
    @JoinTable(
    	name = "member_item",
        joinColums = @joinColumn(name="member_id")
        inverseJoinColumns = @JoinColumn(name="item_id")
    )
	private List<Member> member;
}

 

 

2. 영속성 전이

영속성 전이엔티티의 상태를 변경할 때 해당 엔티티와 괸련된 엔티티의 상태 변화를 전파시키는 옵션이다. 이때 부모는 One에 해당하고 자식은 Many에 해당한다.

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

 

주문 엔티티를 저장하기 위해서 JpaRepository를 상속받는 OrderRepository 인터페이스를 생성한다.

repository > OrderRepository.java

package com.shop.shop.repository;

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

public interface OrderRepository extends JpaRepository<Order, Long> {
}

 

@OneToMany 어노테이션에 cascade 옵션을 설정한다.

entity > Order.java

package com.shop.shop.entity;

import com.shop.shop.constant.OrderStatus;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

    @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; //주문상태

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

    private LocalDateTime regTime;

    private LocalDateTime updateTime;
}

▹ 부모 엔티티의 영속성 상태 변화를 자식 엔티티에 모두 전이하는 CascadeTypeAll 옵션을 설정한다.

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

 

2-1. OrderTest 테스트 코드 작성하기

entity > OrderTest.java

package com.shop.shop.entity;

import com.shop.shop.constant.ItemSellStatus;
import com.shop.shop.repository.ItemRepository;
import com.shop.shop.repository.MemberRepository;
import com.shop.shop.repository.OrderRepository;
import org.junit.jupiter.api.AfterEach;
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 javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(locations="classpath:application-test.properties")
@Transactional
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.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(1000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }

        orderRepository.saveAndFlush(order);
        em.clear();

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

}

▹아직 영속성 컨텍스트에 저장되지 않은 orderitem 엔티티를 order 엔티티에 담아준다. 

order.getOrderItems().add(orderItem);

 

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

이 코드를 실행하면 flush를 호출하면서 콘솔창에 insert 쿼리문이 출력되는 것을 확인할 수 있다. 데이터베이스에 반영이 되는 것이다.

orderRepository.saveAndFlush(order);

 

그 후 영속성이 전이되면서 order에 담아두었던 orderItem이 insert 되는 것을 확인할 수 있다.

 

 

▹영속성 컨텍스트의 상태를 초기화한다.

em.clear();

 

▹영속성 컨텍스트를 초기화했기 때문에 데이터베이스에서 주문 엔티티를 조회한다. select 쿼리문이 실행되는 것을 콘솔창에서 확인할 수 있다.

Order savedOrder = orderRepository.findById(order.getId())

 

2-2. 고아 객체 제거하기

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

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

 

고아 객체 제거를 사용하기 위해서 @OneToMany 어노테이션에 "orphanRemoval = true" 옵션을 추가해야 한다.

entity > Order.java

package com.shop.shop.entity;

import com.shop.shop.constant.OrderStatus;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

    @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; //주문상태

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

    private LocalDateTime regTime;

    private LocalDateTime updateTime;
}

▹orphanRemoval = true 는 모두 부모 엔티티 삭제시 고아가 된 자식 객체도 함께 삭제된다.

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

 

주문 엔티티에서 주문 상품을 삭제했을 때, orderItem 엔티티가 삭제되는지 테스트 코드를 작성해보자.

entity > OrderTest.java

package com.shop.shop.entity;

import com.shop.shop.constant.ItemSellStatus;
import com.shop.shop.repository.ItemRepository;
import com.shop.shop.repository.MemberRepository;
import com.shop.shop.repository.OrderRepository;
import org.junit.jupiter.api.AfterEach;
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 javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(locations="classpath:application-test.properties")
@Transactional
class OrderTest {

    @Autowired
    OrderRepository orderRepository;

    @Autowired
    ItemRepository itemRepository;

    @PersistenceContext
    EntityManager em;

    @Autowired
    MemberRepository memberRepository;

    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(1000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }

        orderRepository.saveAndFlush(order);
        em.clear();

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


    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(1000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }
        Member member = new Member();
        memberRepository.save(member);
        order.setMember(member);
        orderRepository.save(order);
        return order;
    }

    @Test
    @DisplayName("고아객체 제거 테스트")
    public void orphanRemovalTest(){
        Order order = this.createOrder();
        order.getOrderItems().remove(0);
        em.flush();
    }

}

▹주문 데이터를 생성해서 저장하는 메소드를 만든다.

public Order createOrder(){

 

▹order 엔티티에서 관리하고 있는 orderItem 리스트의 0번째 인덱스 요소를 제거한다.

order.getOrderItems().remove(0);

 

flush()를 호출하면 콘솔창에 orderItem을 삭제하는 쿼리문이 출력되는 것을 확인할 수 있다. 즉, 부모 엔티티와 연관 관계가 끊어졌기 때문에 고아 객체를 삭제하는 쿼리문이 실행되는 것이다.

 

고아 객체 delete 쿼리문

Cascade 옵션 중 REMOVE 옵션과 헷갈릴 수 있다. Cascade의 REMOVE 옵션은 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 함께 삭제된다. order를 삭제하면 order에 매핑되어 있던 orderItem이 함께 삭제되는 것이다.

 

 

3. 지연 로딩

지연 로딩을 알아보기 전에 즉시 로딩에 대해 알아보자.

OrderItem을 조회하기 위해서 JpaRepository를 상속 받는 OrderItemRepository 인터페이스를 생성한다.

package com.shop.shop.repository;

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

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

 

주문 데이터를 먼저 데이터베이스에 저장하고, 저장한 주문 상품 데이터를 조회해보자.

entity > OrderTest.java

package com.shop.shop.entity;

import com.shop.shop.constant.ItemSellStatus;
import com.shop.shop.repository.ItemRepository;
import com.shop.shop.repository.MemberRepository;
import com.shop.shop.repository.OrderItemRepository;
import com.shop.shop.repository.OrderRepository;
import org.junit.jupiter.api.AfterEach;
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 javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(locations="classpath:application-test.properties")
@Transactional
class OrderTest {

    @Autowired
    OrderRepository orderRepository;

    @Autowired
    ItemRepository itemRepository;

    @PersistenceContext
    EntityManager em;

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    OrderItemRepository orderItemRepository;

    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(1000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }

        orderRepository.saveAndFlush(order);
        em.clear();

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


    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(1000);
            orderItem.setOrder(order);
            order.getOrderItems().add(orderItem);
        }
        Member member = new Member();
        memberRepository.save(member);
        order.setMember(member);
        orderRepository.save(order);
        return order;
    }

    @Test
    @DisplayName("고아객체 제거 테스트")
    public void orphanRemovalTest(){
        Order order = this.createOrder();
        order.getOrderItems().remove(0);
        em.flush();
    }

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

▹기존에 만들었던 주문 생성 메소드를 이용하여 주문 데이터를 저장한다.

Order order = this.createOrder();

 

▹영속성 컨텍스트의 상태 초기화 후 order 엔티티에 저장했던 주문 상품 아이디를 이용하여 orderItem을 데이터베이스에서 다시 조회한다.

이 코드를 실행하면 orderItem 데이터를 조회하면 콘솔창에서 엄청나게 긴 쿼리문을 볼 수 있다. orderItem 엔티티 하나를 조회했을 뿐인데 order_item 테이블과 item, orders, member 테이블을 조인해서 한꺼번에 가지고 오고 있다.

OrderItem orderItem = orderItemRepository.findById(orderItemId)

 

▹orderItem 엔티티에 있는 order 객체의 클래스를 출력한다. Order 클래스가 출력되는 것을 확인할 수 있다. 

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

 

즉시 로딩 대신 지연 로딩 방식을 사용해보자.

FetchType.LAZY 방식으로 설정한다.

entity > OrderItem.java

package com.shop.shop.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

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

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

    private int orderPrice; //주문가격

    private int count; //수량

    private LocalDateTime regTime;

    private LocalDateTime updateTime;

}

 

지연 로딩으로 변경 후 테스트 코드를 수정 후 실행해보자.

@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 엔티티만 조회하는 쿼리문이 실행되는 것을 볼 수 있다.

이 코드를 실행하면 Order 클래스 조회 결과가 HibernateProxy라고 출력되는 것을 볼 수 있다. 지연 로딩으로 설정하면 실제 엔티티 대신에 프록시 객체를 넣어둔다.

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

조회 쿼리
Order 클래스 출력 결과

 📚 프록시 객체란 ?

 

 

▹Order의 주문일을 조회할 때 select 쿼리문이 실행되는 것을 확인할 수 있다. 

orderItem.getOrder().getOrderDate();

조회 쿼리

 

연관 관계 매핑 어노테이션에 Fetch 전략을 LAZY로 직접 설정해야한다.

entity > Cart.java

package com.shop.shop.entity;


import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;

@Entity
@Table(name = "cart")
@Getter
@Setter
@ToString
public class Cart {
    @Id
    @Column(name = "cart_id")
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="member_id")
    private Member member;

}

 

entity > CartItem.java

package com.shop.shop.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cart_id")
    private Cart cart;

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

    private int count;
}

 

entity > Order.java

package com.shop.shop.entity;

import com.shop.shop.constant.OrderStatus;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    private LocalDateTime orderDate; //주문일

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus; //주문상태

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

    private LocalDateTime regTime;

    private LocalDateTime updateTime;
}

 

 

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

엔티티의 생성과 수정을 감시하고 있는 것으로 이런 공통 멤버 변수들을 추상 클래스로 만들고, 해당 추상 클래스를 상속받는 형태로 엔티티를 리팩토링해보자. 현재 로그인한 사용자의 정보를 등록자와 수정자로 지정하기 위해서 AuditorAware 인터페이스를 구현한 클래스를 생성한다.

config > AuditorAwareImpl.java

package com.shop.shop.config;

import org.apache.tomcat.util.net.openssl.ciphers.Authentication;
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

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();

 

Auditing 기능을 사용하기 위해서 config 파일을 생성한다.

config > AuditConfig.java

package com.shop.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
public class AuditConfig {
    @Bean
    public AuditorAware<String> auditorProvider() {
        return new AuditorAwareImpl();
    }
}

▹JPA의 Auditing 기능을 활성화한다.

@EnableJpaAuditing

 

▹등록자와 수정자를 처리해주는 AuditorAware을 빈으로 등록한다.

public AuditorAware<String> auditorProvider() {

 

 

BaseTimeEntity만 상속받을 수 있도록 BaseTimeEntity 클래스를 생성한다.

entity > BaseTimeEntity.java

package com.shop.shop.entity;

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 javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@EntityListeners(value = {AuditingEntityListener.class})
@MappedSuperclass
@Getter
@Setter
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime regTime;

    @LastModifiedDate
    private LocalDateTime updateTime;
}

▹Auditing을 적용하기 위해서 @EntityListeners 어노테이션을 추가한다.

@EntityListeners(value = {AuditingEntityListener.class})

 

▹공통 매핑 정보가 필요할 때 사용하는 어노테이션으로 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공한다.

@MappedSuperclass

 

▹엔티티가 생성되어 저장될 때 시간을 자동으로 저장한다.

@CreatedDate

 

▹엔티티의 값을 변경할 때 시간을 자동으로 저장한다.

@LastModifiedDate

 

BaseEntity는 위에서 만든 BaseTimeEntity를 상속받고 있다. 등록일, 수정일, 등록자, 수정자를 모두 갖는 엔티티는 BaseEntity를 상속받으면 된다.

entity > BaseEntity.java

package com.shop.shop.entity;

import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;

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

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

    @LastModifiedBy
    private String modifiedBy;
}

 

Member 엔티티에 Auditing 기능을 적용하기 위해서 BaseEntity 클래스를 상속받도록 하자.

entity > Member.java

package com.shop.shop.entity;

import com.shop.shop.constant.Role;
import com.shop.shop.dto.MemberFormDto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.persistence.*;

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

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

    private String name;

    @Column(unique = true)
    private String email;

    private String password;

    private String address;

    @Enumerated(EnumType.STRING)
    private Role role;

    public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder){
        Member member = new Member();
        member.setName(memberFormDto.getName());
        member.setEmail(memberFormDto.getEmail());
        member.setAddress(memberFormDto.getAddress());
        String password = passwordEncoder.encode(memberFormDto.getPassword());
        member.setPassword(password);
        member.setRole(Role.USER);
        return member;
    }
}

 

회원 엔티티 저장 시 자동으로 저장이 되는지 테스트 코드를 작성하자.

entity > MemberTest.java

package com.shop.shop.entity;

import com.shop.shop.repository.MemberRepository;
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 javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;
import javax.transaction.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());
    }

}

▹스프링 시큐리티에서 제공하는 어노테이션으로 @WithMockUser에 지정한 사용자가 로그인한 상태라고 가정하고 테스트를 진행할 수 있다.

@WithMockUser(username = "gildong", roles = "USER")

 

나머지 엔티티도 BaseEntity를 상속받도록 수정해주면 된다 !!!!

 

 

 

 

Comments