1. 도메인 모델과 테이블 설계 및 도식화
본 실습에서는 다양한 객체간 관계(1:1, 1:N, N:N)에 대해 실습하기 위해 실무에서의 설계와는 상이한 가상의 모델과 테이블을 설계한다. 예를 들어 회원이 주문을 하기 때문에 Member와 Order의 관계가 1:N으로 표현한 것이 잘 설계된 것처럼 보이지만, 실무에서는 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하다.
객체 테이블 분석
ITEM 테이블은 하나의 테이블에 Album, Book, Movie 객체를 다 넣은 후, DTYPE으로 구분하는 싱글 테이블 전략을 사용한다.
CATEGORY와 ITEM이 서로 N:N 관계를 가지기 때문에, CATEGORY도 ITEM 객체를 List로 가져도 되고, ITEM도 CATEGORY 객체를 List로 가져도 된다. 그러나 객체는 List를 이용하여 N:N 관계를 나타낼 수 있지만, 관계형 데이터베이스는 일반적인 설계로는 N:N 관계를 정의하는 것이 불가하다. 따라서 CATEGORY_ITEM 테이블을 따로 두어 N:N 관계를 1:N, N:1 관계로 풀어내는 과정을 거쳐야한다.
연관 관계 매핑 분석
① Member와 Orders
Member와 Order 은 일대다, 다대일의 양방향 관계이기 때문에 연관 관계의 주인을 정해야 하는데, 1:N 관계에서는 무조건 N에 외래 키(Foreign Key)가 존재하게 되며, 외래 키가 있는 Order를 연관 관계의 주인으로 정하는 것이 좋다.
그러므로 Order.member 를 ORDERS.MEMBER_ID 외래 키와 매핑한다.
② OrderItem과 Orders
OrderItem과 Order은 다대일 양방향 관계다. 외래 키가 OrderItem에 있으므로 OrderItem이 연관관계의 주인이다.
③ OrderItem과 Item
OrderItem과 Item은 다대일 단방향 관계다. OrderItem.item 을 ORDER_ITEM.ITEM_ID 외래 키와 매핑한다.
④ Delivery와 Orders
일대일 양방향 관계다. Order.delivery 를 ORDERS.DELIVERY_ID 외래 키와 매핑한다. 일대일 관계에서는 양쪽 모두 외래 키를 둘 수 있으며, 마찬가지로 외래 키를 가지고 있다면 연관 관계의 주인이 된다.
연관 관계의 주인은 단순히 Foreign Key를 누가 관리하냐의 문제이지 비즈니스상 우위에 있다고 주인으로 정하는 것이 아니다!
2. Entity 클래스 개발
본 실습에서는 @Getter @Setter 모두 열고 Entity를 개발한다. 실무에서는 가급적 @Getter는 열어두고 @Setter는 꼭 필요한 경우에만 사용하도록 한다. 주의해서 봐야할 점은 연관 관계의 매핑과 상속 관계의 매핑이다.
회원 Entity
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
# @Column(name = "member_id")
@Column을 설정하지 않으면 id로 column 명이 정해진다. 요구사항에 맞게 member_id로 변형하기 위해서는 @Column을 설정해줘야한다.
# @Embedded
내장 되었음을 나타내는 의미의 어노테이션이다.
# @OneToMany
@OneToMany 어노테이션과 mappedBy 옵션은 연관 관계 매핑과 관련된 것으로 아래 Order Entity에서 자세히 설명한다.
Address 값 타입
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
}
값 타입은 변경 불가능하게 설계해야 한다. @Setter를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들어야 한다. 이때 JPA 스펙상 Entity나 임베디드 타입(@Embeddable)은 자바 기본 생성자(default constructor)를 public 또는 protected로 설정해야 한다.
public 으로 두면 외부에서 호출할 수 있기 때문에 protected로 설정하는 것이 더 안전하다.
JPA는 왜 이런 제약을 두었을까?
JPA 구현 라이브러리가 객체를 생성할 때 리플렉션, 프록시 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.
# @Embeddable
내장 될 수 있다는 의미의 어노테이션이다. @Embedded 중 하나만 사용해도 동작하지만, 실제 실무에서는 둘 다 마킹한다.
Order Entity와 연관 관계의 주인 매핑
@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;
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER, CANCEL]
}
# @ManyToOne
Member와 Orders의 관계가 1:N이므로 Member Entity의 List<Orders>에 @OneToMany 어노테이션을, Order Entity의 Member 에 @ManyToOne 어노테이션을 붙인다.
# @JoinColumn(name = "member_id")
Foreign Key의 이름이 "member_id"가 된다.
지금 Member와 Order Entity는 양방향 관계이므로 연관 관계의 주인을 정해줘야 한다. 예를 들어 Order의 Member를 바꾸는 경우 Order Entity에 정의한 Member를 바꿀 수도 있고, Member Entity 자체를 바꿀 수도 있기 때문에 JPA 입장에서는 혼동이 생길 수 있다.
지금 데이터베이스의 Foreign Key는 Order의 member_id 이다. 연관 관계의 주인은 이 Foreign Key와 가까운 곳에 있는 쪽을 선택하는 것이 성능 및 관리에 용이하다. 그렇다면 앞서 1:N 관계에서 Foreign Key는 N에 존재한다고 했으니 연관 관계의 주인은 N이 되겠다.
연관 관계의 주인을 선정하기 위해서는 주인이 아닌 다른 Entity의 @OneToMany 어노테이션에 "나는 매핑된 거울일 뿐이다"라는 의미의 mappedBy 옵션을 준다. 옵션에 대한 값은 주인에서 설정한 프로퍼티 이름이 된다.
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
OrderItem Entity와 Order 간의 연관 관계 매핑
@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; // 주문 수량
}
Order과 OrderItem 간 연관 관계가 1:N이기 때문에 OrderItem이 연관 관계의 주인이 되며 Foreign Key(@JoinColumn)를 설정해줘야 한다. Order Entity의 List<OrderItem>에는 @OneToMany 어노테이션과 함께 mappedBy = "order" 옵션을 주어 매핑되었음을 표시한다.
Item Entity와 상속 관계 매핑
// Item Entity
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@Getter @Setter
public abstract class Item {
@Id @GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
// Album
@Entity
@DiscriminatorValue("A")
@Getter @Setter
public class Album extends Item {
private String artist;
private String etc;
}
// Book
@Entity
@DiscriminatorValue("B")
@Getter @Setter
public class Book extends Item {
private String author;
private String isbn;
}
// Movie
@Entity
@DiscriminatorValue("M")
@Getter @Setter
public class Movie extends Item {
private String director;
private String actor;
}
상속 관계 매핑에서는 상속 관계 전략을 지정해야 하는데, 이 전략을 부모 클래스에 잡아줘야 한다. 요구사항을 반영하여 하나의 테이블에 다 넣고 DTYPE으로 구분하는 싱글 테이블 전략을 사용한다. 상속 관계 전략으로는 크게 JOINED, TABLE_PER_CLASS, SINGLE_TABLE이 있다.
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
이제 이러한 상속 클래스들을 구분하기 위하여 부모 클래스에서는 @DiscriminatorColumn을, 상속 클래스에는 @DiscriminatorValue를 추가한다. 만약 @DiscriminatorValue에 아무런 값을 넣지 않는다면 Class 이름이 기본값으로 포함된다.
// Item
@DiscriminatorColumn(name = "dtype")
// Book, Movie, Album
@DiscriminatorValue("B")
@DiscriminatorValue("M")
@DiscriminatorValue("A")
Delivery Entity와 1:1 매핑 및 EnumeratedType 지정
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@OneToOne(mappedBy = "delivery")
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; // READY, COMP
}
# @Enumerated(EnumType.STRING)
Enum을 사용할 때는 Enum Type을 지정해줘야 하는데 Default 값으로는 ORDINAL(숫자)이다. 하지만 이때 새로운 Type이 중간에 들어가면 꼬이기 때문에 꼭 EnumType을 STRING으로 넣어야 한다.
# 1:1 관계 매핑
JPA에서는 1:1 관계에서 Foreign Key를 양쪽 어디에든 넣어도 되지만, 주로 자주 Access하는 곳에 추가하는 것을 추천한다.
그러므로 연관관계의 주인을 Order의 Delivery로 선정한다.
// Order Entity
@OneToOne
@JoinColumn(name = "delivery_id")
private Delivery delivery;
// Delivery Entity
@OneToOne(mappedBy = "delivery")
private Order order;
Category-Item N:N 매핑(@JoinTable) + self Entity 내에서 양방향 연관 관계
@Entity
@Getter @Setter
public class Category {
@Id @GeneratedValue
@Column(name = "category_id")
private Long id;
private String name;
@ManyToMany
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
// 계층 구조 - self로 양방향 연관 관계를 걸 수 있다.
// 부모는 하나
@ManyToOne
@JoinColumn(name = "parent_id")
private Category parent;
// 자식은 여러개 가질 수 있다.
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
}
@ManyToMany는 편리해 보이지만 중간 테이블에 컬럼을 추가할 수 없고, 세밀하게 쿼리를 실행하기 어렵기 때문에 실무에서 사용하기에는 한계가 있다.
# self Entity 내에서 양방향 연관 관계 설정
부모는 하나이므로 @ManyToOne이고, 자식은 여러 개일 수 있기 때문에 @OneToMany이다. ForeignKey는 연관 관계의 주인인 parent이므로 @JoinColumn을 주고, 자식은 @OneToMany에서 parent로 mappedBy 옵션을 준다.
// 부모는 하나
@ManyToOne
@JoinColumn(name = "parent_id")
private Category parent;
// 자식은 여러개 가질 수 있다.
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
# @JoinTable
N:N 관계에서는 @JoinTable이 필요하다. 앞서 설명하였듯 객체는 다 collection이 있어서 N:N 관계가 가능한데, 관계형 DB는 이게 불가하다. 따라서 1:N과 N:1의 관계를 표현하기 위한 또 하나의 테이블이 필요한데, 이때 @JoinTable이 사용된다.
@JoinTable의 인자에는 해당 테이블의 이름과 양 방향을 가리키는 Column 이름이 들어간다.
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
3. 주의 사항
모든 연관관계는 지연로딩으로 설정
즉시 로딩(EAGER)은 한마디로 Member를 조회할 때 연관된 Order를 조회하는 것이다.
하지만 즉시 로딩은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어려우며, JPQL을 실행할 때 N+1 문제가 자주 발생한다.
실무에서는 모든 연관관계를 지연 로딩(LAZY)로 설정해야 한다.
만약 연관된 Entity를 함께 DB에서 조회해야 하면, fetch join 또는 Entity Graph 기능을 사용한다.
@OneToOne, @ManyToOne의 경우 Default 값이 즉시 로딩(EAGER)이기 때문에 반드시 지연 로딩(LAZY)로 설정해야 한다.
(cmd+shift+f로 찾고, fetch = FetchType.LAZY 설정)
Collection은 필드에서 초기화하기
생성자 메서드로 초기화할 수도 있지만, 필드에서 초기화 하는 방식을 더 권장한다. 왜 그럴까?
// 방법 1. 생성자로 초기화
@OneToMany(mappedBy = "member")
private List<Order> orders;
public Member() {
orders = new ArrayList<>();
}
// 방법 2. 필드에서 초기화
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
장점 1. null 문제에서 안전
장점 2. Hibernate가 Entity를 Persistence 하는 순간 해당 컬렉션을 감싸서 Hibernate가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders() 처럼 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 즉 이 컬렉션을 처음 객체를 생성한 그대로 냅둬야 하며, 바꾸거나 무엇인가 처리를 하지 않아야 한다.
따라서 필드 레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(member);
System.out.println(member.getOrders().getClass());
//출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag
Cascade 옵션
Entity의 상태 변화를 전파시키는 옵션이다. OneToMany와 ManyToOne으로 양방향 관계를 맺는 Entity의 상태 변화를 전이시킬 때 사용한다.
즉, 부모 Entity가 PERSIST 될 때 자식 Entity도 같이 PERSIST 되고, 부모 Entity가 REMOVE 될 때 자식 Entity도 같이 REMOVE 되는 등 특정 Entity를 영속 상태로 만들 때 연관된 Entity도 함께 영속 상태로 전이되는 것을 말한다.
예를 들어 다음과 같이 orderItems와 delivery에 cascade를 지정해줬다. 만약 cascade를 지정하지 않았다면 entity마다 각자 persist 하고, Order Entity도 persist 해야 한다. 하지만 cascade를 지정하면 Order만 persist 하여도 자식 entity인 orderItems, delivery 모두 영속 상태로 전이된다.
public class Order {
...
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
}
연관관계 메서드
양방향 연관관계를 맺는 경우, 양쪽 모두 관계를 맺어주어야 한다.
JPA 입장에서만 본다면 Foreign Key를 가진 연관 관계의 주인 쪽에서만 관계를 맺는다면 정상적으로 양 쪽 모두에서 조회는 가능하다. 하지만 객체까지 고려한다면 양쪽 다 관계를 맺어야 한다.
하나의 메서드에서 양측의 관계를 설정하게 해주는 것이 안전하므로, 한쪽에서 양방향 관계를 설정(set)하는 메서드를 정의하는데, 이때 핵심적으로 사용하는 쪽에 정의하는 것이 좋다.
@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;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER, CANCEL]
// == 연관관계 메서드== //
public void setMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
public void setOrderItems(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
}
테이블, 컬럼명 생성 전략
SpringPhysicalNamingStrategy에 따라 다음과 같이 처리된다.
private LocalDateTime orderDate;
// Table Column 명
order_date
출처
'🌱 Spring > JPA ①' 카테고리의 다른 글
[Spring] 주문 Entity, Repository, Service 개발 (0) | 2023.08.09 |
---|---|
[Spring] 상품 Entity, Repository, Service 개발 (0) | 2023.08.05 |
[Spring] 회원 Service Unit Test 작성하기 (0) | 2023.08.05 |
[Spring] 회원 Service & Repository 개발 (0) | 2023.08.04 |
[Spring] 프로젝트 기본 구성 (0) | 2023.08.02 |