[Spring-plus] JPA 객체 매핑하는법(JPA 관계 설정)
📦

[Spring-plus] JPA 객체 매핑하는법(JPA 관계 설정)

Lecture
Framework
태그
dev
spring
public
완성
Y
생성일
Mar 17, 2024 01:40 PM
LectureName
Spring

0. 매핑

개념
JPA에서는 엔티티에 연관관계를 매핑해두고 필요할 때 해당 엔티티와 연관된 엔티티를 사용하여 좀 더 객체지향적으로 프로그래밍 할 수 있도록 도와줍니다.
 
JPA 매핑 종류
  • 일대일 (OneToOne)
  • 일대다 (OneToMany)
  • 다대일 (ManyToOne)
  • 다대다 (ManyToMany)
 
방향
객체에서는 단방향과 양방향이 존재한다.
  • 단방향
  • 양방향
 
 

1. 일대 일 단방향 매핑

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") private Member member; }
  • 장바구니에서는 회원 엔티티를 일방적으로 참조한다
  • 장바구니와 회원은 일대일로 매핑되어 있다.
😲 한개의 장바구니에 여러개의 회원이 있을 수 없다.
 
참조 형태
notion image
 
CartRepository.java ( 장바구니 저장 )
package soti.shop.repository; import org.springframework.data.jpa.repository.JpaRepository; import soti.shop.entity.Cart; public interface CartRepository extends JpaRepository<Cart, Long> { }
  • 실제로 장바구니를 생성하고 Member ID가 어떻게 참조되는지 확인해보자
 
 
CartTest.java ( 장바구니 매핑 테스트)
@SpringBootTest @Transactional @TestPropertySource(locations = "classpath:application-test.properties") class CartRepositoryTest { @Autowired private CartRepository cartRepository; @Autowired private MemberRepository memberRepository; @Autowired private PasswordEncoder passwordEncoder; @PersistenceContext EntityManager em; //MEMBER id 생성 public Member createMember(){ MemberFormDto memberFromDto = new MemberFormDto(); memberFromDto.setEmail("test@test.com"); memberFromDto.setPassword("1234"); memberFromDto.setName("jalnik"); memberFromDto.setAddress("광명시 하안동"); return Member.createMember(memberFromDto, passwordEncoder); } @Test @DisplayName("장바구니(Cart) 와 회원 member_id 엔티티 매핑 테스트") 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()); } }
  • 결과를 보면 엔티티가 조회되면서 즉시 로딩되는걸 볼 수 있다. 일대일, 다대일 매핑의 경우 즉시 로딩을 기본 Fetch 전략으로 설정한다.
 
💡
즉시로딩
  • 엔티티 조회 시, 해당 엔티티와 매핑된 엔티티도 한번에 조회한다.
  • 매핑할 때 FetchType.EAGER로 설정할 수 있다.
 
 

2. 다대일 단방향 매핑

CartItem.java ( 장바구니에 담길 아이템들 )
@Entity @Getter @Setter @Table(name = "cart_item") public class CartItem { @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; }
  • 하나의 장바구니에는 여러개의 상품이 들어갈 수 있다.
  • 또한 상품을 여러 개 주문할 수도 있다.
  • 해당 관계에서는 여러개의 상품이 한개의 카트 혹은 한개의 아이템을 보고 있음으로 ManyToOne으로 설정해 준다.
 
현재까지의 구상도
notion image
 
 

3. 다대일 / 일대다 매핑

아이템, 멤버, 카트, 카트에 담길 것이 준비가 되었으니 주문 정보를 만들어야 한다.
  1. OrderStatus.java (enum) 설정
public enum OrderStatus { ORDER, CANCLE }
  • 주문 상태는 주문, 주문 취소로 구분한다.
 
  1. Order.java (주문 정보 )
@Entity @Getter @Setter @Table(name = "orders") 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; }
  • 주문 정보에는 멤버의 ID, 주문번호 ID가 들어간다.
  • 주문이 등록된 시간과 바뀐 시간을 검수한다.
 
notion image
 
 
주문에 들어간 ITEM ( OrderItem.java )
@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; }
  • 하나의 상품은 여러개의 주문 목록으로 들어갈 수 있다. 따라서 다대일 매핑을 수행한다.
  • 한번의 주문에 여러개의 상품을 주문할 수 있음으로 주문 상품엔티티와 주문 엔티티를 다대일 방향 매핑을 먼저 설정한다.
 
 

4. 연관관계의 주종관계

💡
여기서 주의사항, 객체를 양방향 매핑할 경우
  • 연간 관계의 주인은 외래키가 있는 곳으로 설정
  • 주인이 외래키를 관리
  • 주인이 아닌 쪽은 매핑 시 mappedBy 속성을 사용
 
추가된 order 코드
@Table(name = "orders") public class Order { .. 이전의 코드 @OneToMany(mappedBy = "order") private List<OrderItem> orderItems = new ArrayList<>(); //이후의 코드는 생략 }
  • 외래키를 가지고 있는 Order이 주가 된다.
  • Order은 주문한 ITEM을 여러개 가지고 있어야 함으로 List 형태로 OrderItem을 저장한다.
  • 여기서 주는 order임으로 mappedBy를 사용한다
  • Order에서만 외래키를 관리하고, 수정 및 삭제등을 할 수 있다.
  • OrderItem에서는 읽기 권한만 존재한다고 보면 된다.
 
 
변경 시각화
notion image
notion image
 
 
 

5. 영속성 전이

영속성 전이
영속 전이는 엔티티의 상태를 변경할 때 해당 엔티티와 연관된 엔티티들에게 상태 변화를 전달하는 옵션이다. 이때 부모는 매핑에서 One에 해당하고 자식은 Many에 해당한다. 즉 Order 엔티티가 삭제되었을 때 해당 엔티티와 연관된 OrderItem 엔티티가 함께 삭제되거나 OrderItem 엔티티를 한번에 저장할 수 있다.
 
JPA 에서의 CASCADE 종류
JPA에서 지원하는 CASCADE 종류는 아래와 같습니다.
종류
설명
ALL
모든 변경에 대해 적용
PERSIST
영속 상태가 되었을 때 적용
MERGE
병합할 때 적용
REMOVE
삭제할 때 적용
REFRESH
엔티티를 새로 고칠 때 적용
DETACH
영속성 컨텍스트에서 분리할 때 적용
 
 
OrderRepository 를 추가하고 Order에 대하여 CASCADE.ALL 적용
  1. OrderRepository 추가
public interface OrderRepository extends JpaRepository<Order, Long> { }
  • 주문을 사용할 수 있다.
 
.. //cascade 추가 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) private List<OrderItem> orderItems = new ArrayList<>();
  • CascadeType.ALL을 통하여 영속성 전이를 할 수 있다.
 
 
영속성 전이 test ( OrderTest.java )
import static org.junit.jupiter.api.Assertions.assertEquals; import soti.shop.repository.MemberRepository; @SpringBootTest @TestPropertySource(locations="classpath:application-test.properties") @Transactional public class OrderTest { @Autowired OrderRepository orderRepository; @Autowired ItemRepository itemRepository; @PersistenceContext EntityManager em; @Autowired MemberRepository memberRepository; public Item createItem() { Item item = new Item(); item.setItemName("테스트 상품"); 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); //DB 반영 em.clear(); //영속성 컨텍스트 초기화 Order savedOrder = orderRepository.findById(order.getId()) .orElseThrow(EntityNotFoundException::new); assertEquals(3, savedOrder.getOrderItems().size()); } }
  • 아이템을 생성하고 주문을 생성, 주문 아이템을 등록한다.
  • FLUSH를 통해 DB에 반영하고, 영속성 컨텍스트를 초기화 한다.
  • 등록한 주문 정보와 DB 주문 정보를 비교해서 테스트를 진행한다.
 
 
 

6. 고아 객체

고아 객체?
부모 엔티티와 관계가 끊어진 자식 엔티티를 고아 객체라고 한다. 영속성 전의 기능과 같이 사용하여 자식 엔티티의 생명 주기에 관여할 수 있다. 다만 주의사항으로 해당 엔티티를 참조하는 엔티티가 두개 이상일 경우 이 기능을 사용하면 안된다.
 
Order.java
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> orderItems = new ArrayList<>();
 
 
OrderTest.java - OrphanRemove Test
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(); }
  • 이렇게 되면 주문 엔티티에서 주문 상품에 대한 엔티티를 삭제했을 때 엔티티가 삭제되는것을 볼 수 있습니다.
 
 

7. 지연 로딩

지연로딩?
지연 로딩은 실제 엔티티 대신에 프록시 객체를 넣어둔다. 프록시 객체는 실제로 사용하기 이전에 로딩을 하지 않고, 실제 사용 시점에 조회 쿼리문이 실행된다.
 
 
지연로딩 테스트
  1. orderItem 조회를 위한 OrderItemRepository 생성
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> { }
 
  1. 테스트 코드 작성 (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("==========================="); }
  • 해당 테스트를 실행하게 되면 한개의 동작을 수행했을 뿐인데 등장하는 엄청난 트랜잭션을 볼 수 있습니다.
 
 
  1. orderItem.java 지연로딩 설정
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "item_id") private Item item; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private Order order;
  • FetchType.LAZY 를 설정해서 지연로딩을 설정한다.
 
 
로그를 보면 order item을 조회하는 쿼리만 실행되는 것을 볼 수 있다.
=========================== Hibernate: select order0_.order_id as order_id1_5_0_, order0_.member_id as member_i6_5_0_, order0_.order_date as order_da2_5_0_, order0_.order_status as order_st3_5_0_, order0_.reg_time as reg_time4_5_0_, order0_.update_time as update_t5_5_0_, member1_.member_id as member_i1_3_1_, member1_.address as address2_3_1_, member1_.email as email3_3_1_, member1_.name as name4_3_1_, member1_.password as password5_3_1_, member1_.role as role6_3_1_ from orders order0_ left outer join member member1_ on order0_.member_id=member1_.member_id where order0_.order_id=? 2023-07-16 21:58:40.633 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [5] 2023-07-16 21:58:40.635 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([member_i1_3_1_] : [BIGINT]) - [4] 2023-07-16 21:58:40.635 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([member_i6_5_0_] : [BIGINT]) - [4] 2023-07-16 21:58:40.635 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([order_da2_5_0_] : [TIMESTAMP]) - [null] 2023-07-16 21:58:40.635 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([order_st3_5_0_] : [VARCHAR]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([reg_time4_5_0_] : [TIMESTAMP]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([update_t5_5_0_] : [TIMESTAMP]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([address2_3_1_] : [VARCHAR]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([email3_3_1_] : [VARCHAR]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name4_3_1_] : [VARCHAR]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([password5_3_1_] : [VARCHAR]) - [null] 2023-07-16 21:58:40.636 TRACE 18288 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([role6_3_1_] : [VARCHAR]) - [null] ===========================
 
 
나머지 엔티티도 지연로딩 설정
//Cart.java @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; //CartItem.java @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cart_id") private Cart cart; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "item_id") private Item item; //Order.java @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<>();