연관 관계 매핑1 - 연관 관계 매핑 종류

진크·2022년 2월 26일
0
post-thumbnail

엔티티들은 대부분 다른 엔티티와 연관 관계를 맺고 있습니다. JPA에서는 엔티티에 연관 관계를 매핑해두고 필요할 때 해당 엔티티와 연관된 엔티티를 사용하여 좀 더 객체지향적으로 프로그래밍할 수 있습니다.

🐢
연관 관계 매핑을 할 때는 2가지만 기억하세요. 북치기 박치기

1. 연관 관계 매핑의 종류: 일대일(1:1), 일대다(1:N), 다대일(N:1), 다대일(N:M)
2. 엔티티 매핑 방향성: 단방향, 양방형

일대일 단방향 매핑하기

장바구니(Cart) 엔티티를 만들고 기존 회원 엔티티와 연관 관계 매핑을 설정하겠습니다.

package me.jincrates.gobook.domain.carts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.jincrates.gobook.domain.members.Member;

import javax.persistence.*;

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

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

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

    @Builder
    public Cart(Member member) {
        this.member = member;
    }
}
  • @JoinColumn: 매핑할 외래키를 지정합니다. @JoinColumnname을 명시하지 않으면 JPA가 알아서 찾지만 컬럼명이 원하는 대로 생성되지 않을 수 있기 때문에 직접 지정하겠습니다.

애플리케이션을 실행하면 콘솔창에 cart 테이블이 생성되는 쿼리문과 member_id 컬럼을 외래키로 갖는 쿼리문을 볼 수 있습니다. 이렇게 매핑을 맺어주면 장바구니 엔티티를 조회하면서 회원 엔티티의 정보도 동시에 가져올 수 있는 장점이 있습니다.

실제로 장바구니 Cart 엔티티를 조회하면 연관된 Member 엔티티를 가지고 오는지 테스트 코드를 작성해보겠습니다. 먼저 JpaRepository를 상속받는 CartRepository 인터페이스를 생성합니다.

package me.jincrates.gobook.domain.carts;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CartRepository extends JpaRepository<Cart, Long> {
}
package me.jincrates.gobook.domain.carts;

import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.domain.members.MemberRepository;
import me.jincrates.gobook.web.dto.MemberFormDto;
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 org.springframework.transaction.annotation.Transactional;

import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.PersistenceContext;

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

@SpringBootTest
@Transactional
@TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"})
public class CartTest {

    @Autowired
    CartRepository cartRepository;

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    PasswordEncoder passwordEncoder;

    @PersistenceContext
    EntityManager em;

    public Member createMember() {
        MemberFormDto memberFormDto = MemberFormDto.builder()
                .email("test@email.com")
                .name("테스트")
                .address("서울시 강서구")
                .password("1111")
                .build();

        return Member.createMember(memberFormDto, passwordEncoder);
    }

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

        Cart cart = Cart.builder()
                .member(member)
                .build();
        cartRepository.save(cart);

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

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

}
  • em.flush() : JPA는 영속성 컨텍스트에 데이터를 저장 후 트랜잭션이 끝날 때 flush()를 호출하여 데이터베이스에 반영합니다. 위에서는 회원 엔티티와 장바구니 엔티티를 영속성 컨텍스트에 저장 후 엔티티 매니저(em)로부터 강제로 flush()를 호출하여 데이터베이스에 반영합니다.
  • em.clear() : JPA는 영속성 컨텍스트로부터 엔티티를 조회 후 영속성 컨텍스트에 엔티티가 없을 경우 데이터베이스를 조회합니다. 실제 데이터베이스에서 장바구니 엔티티를 가지고 올 때 회원 엔티티도 같이 가지고 오는지를 확인하기 위해 영속성 컨텍스트를 비워주겠습니다.

위에서 cartRepository.findById(cart.getId()) 코드를 실행할 때는 cart 테이블과 member 테이블을 조인해서 가져오는 쿼리문이 실행됩니다. cart 엔티티를 조회하면서 member 엔티티도 동시에 가져오는 것이죠.

이처럼 엔티티를 조회할 때 해당 엔티티와 매핑된 엔티티도 한 번에 조회하는 것을 ‘즉시 로딩’이라고 합니다. 일대일(@OneToOne), 다대일(@ManyToOne)로 매핑할 경우 ‘즉시 로딩’을 기본 Fetch 전략으로 설정합니다.

다대일 단방향 매핑하기

하나의 장바구니에는 여러 개의 상품들이 들어갈 수 있습니다. 또한 같은 상품을 여러 개 주문할 수 있으므로 몇 개 담아 줄 것인지도 설정해줘야 합니다. 장바구니 아이템 CartItem 엔티티를 만들겠습니다.

package me.jincrates.gobook.domain.carts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.jincrates.gobook.domain.items.Item;

import javax.persistence.*;

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

    @Id
    @GeneratedValue
    @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;
    
    @Builder
    public CartItem(Cart cart, Item item, int count) {
        this.cart = cart;
        this.item = item;
        this.count = count;
    }
}

엔티티와 매핑되는 테이블에 @JoinColumn 어노테이션의 name으로 설정한 값이 외래키(FK)로 추가된 것을 볼 수 있습니다. 어떤 테이블에 컬럼이 추가되는지 헷갈릴 수 있는데 @JoinColumn 어노테이션을 사용하는 엔티티에 컬럼이 추가된다고 생각하시면 됩니다.


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

양항뱡 매핑이란 단방향 매핑이 2개 있다고 생각하시면 됩니다. 주문과 주문 상품의 매핑을 양방향으로 설정해보겠습니다. 먼저 주문 엔티티와 주문 상태를 나타내는 enum을 설계합니다.

package me.jincrates.gobook.domain.orders;

public enum OrderStatus {
    ORDER, CANCEL
}
package me.jincrates.gobook.domain.orders;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import me.jincrates.gobook.domain.members.Member;

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

@Getter @ToString
@NoArgsConstructor
@Table(name = "orders")
@Entity
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;    //주문상태
}
  • @Table(name = "orders") : 정렬할 때 사용하는 “order” 키워드가 있기 때문에 Order 엔티티에 매핑되는 테이블로 “orders”를 지정합니다.
  • 한 명의 회원은 여러 번 주문을 할 수 있으므로 주문 엔티티 기준에서 다대일 단방향 매핑을 합니다.

주문 상품 엔티티는 장바구니 상품 엔티티와 거의 비슷합니다. 주문 상품 엔티티와 주문 엔티티의 단방향 매핑을 먼저 설정하겠습니다.

package me.jincrates.gobook.domain.orders;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import me.jincrates.gobook.domain.items.Item;

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

@Getter @ToString
@NoArgsConstructor
@Table(name = "order_item")
@Entity
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;  //수량

    @Builder
    public OrderItem(Item item, Order order, int orderPrice, int count) {
        this.item = item;
        this.order = order;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

테이블은 외래키 하나로 양방향 조회가 가능하지만, 엔티티는 테이블과 다릅니다. 엔티티를 양방향 연관 관계로 설정하면 객체의 참조는 둘인데 외래키는 하나이기 때문에 둘 중 누가 외래키를 관리할지를 정해야 합니다.

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

Order 엔티티에 OrderItem과 연관 관계 매핑을 추가하겠습니다. 연관 관계의 주인 설정을 자세히 보시길 바랍니다.

package me.jincrates.gobook.domain.orders;

///...impoert 생략
import java.util.ArrayList;
import java.util.List;

@Getter @ToString
@NoArgsConstructor
@Table(name = "orders")
@Entity
public class Order {
    //...코드 생략
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();

    //...코드 생략
}
  • @OneToMany(mappedBy = "order") : 주문 상품 엔티티와 일대다 매핑을 합니다. 외래키(order_id)가 order_item 테이블에 있으므로 연관 관계의 주인은 OrderItem 엔티티입니다. Order 엔티티가 주인이 아니므로 “mappedBy” 속성으로 연관 관계의 주인 설정을 합니다.

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

다대다 매핑하기 - 사용X

여러 JPA 책에서도 언급되지만, 다대다 매핑은 실무에서는 사용하지 않는 매핑 관계입니다. 다대다 매핑을 사용하지 않는 이유는 연결 테이블에는 컬럼을 추가할 수 없기 때문입니다. 연결 테이블에는 조인 컬럼 뿐 아니라 추가 컬럼들이 필요한 경우가 많습니다. 또한 엔티티를 조회할 때 어떤 쿼리문이 실행될지 예측하기도 쉽지 않습니다. 따라서 다대다 매핑이 아닌 일대다, 다대일 매핑으로 설정하시면 됩니다.

profile
철학있는 개발자 - 내가 무지하다는 것을 인정할 때 비로소 배움이 시작된다.

0개의 댓글