반응형
실제 동작하는 화면은 다음과 같다.
기능 목록
- 회원 - 등록, 조회
- 상품 - 등록, 수정, 조회
- 주문 - 주문, 주문 내역 조회, 주문 취소
- 기타 요구사항
- 상품은 재고 관리가 필요하다.
- 상품의 종류는 도서, 음반, 영화가 있다.
- 상품을 카테고리로 구분할 수 있다.
- 상품 주문시 배송 정보를 입력할 수 있다.
도메인 분석 설계
도메인 모델과 테이블 설계
도메인 모델
- 회원, 주문, 상품의 관계
: 회원은 여러 상품을 주문할 수 있다. 그리고 한 번 주문할 때 여러 상품을 선택할 수 있으므로 주문과 상품은 다대다 관계다. 따라서 그림처럼 주문상품이라는 엔티티를 추가해서 다대다 관계를 일대 다, 다대일 관계로 풀어냈다. 다만, 카테고리와 상품은 경험을 위해 다대다로 두었다. - 상품 분류 : 도서, 음반, 영화는 상품이라는 공통 속성을 사용하므로 상속 구조로 표현한다.
테이블 설계
테이블 분석
- MEMBER : 회원 엔티티의
Address
임베디드 타입 정보가 회원 테이블과 배송 테이블에 들어갔다. - ITEM : 앨범, 도서, 영화 타입을 통합해서 하나의 테이블로
DTYPE
컬럼을 통해 구분하도록 만들었다. (싱글 테이블 전략)
주문 테이블이 ORDERS 인 이유는 order by가 예약어로 잡고 있는 경우가 많기 때문에 관례상 변경한 것이다.
데이터베이스 네이밍 방식은 회사마다 다르지만 보통은
대문자 + _
나소문자 + _
로 지정해서 일관성 있게 사용한다.실제 코드에서는
소문자 + _
방식을 사용해보자.
연관관계 분석
- 회원과 주문 : 일대다, 다대일의 양방향 관계
- 외래 키가 있는 주문을 연관관계의 주인으로 설정
- 주문상품과 주문 : 다대일 양방향 관계 - 주문상품이 연관관계의 주인
- 주문상품과 상품 : 다대일 단방향 관계
- 주문과 배송 : 일대일 양방향 관계
외래 키가 있는 곳을 연관관계의 주인으로 정하자.
연관관계의 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스 상 우위에 있다고 주인으로 정하면 안된다. 그렇지 않으면 관리와 유지보수가 어렵고, 업데이트 쿼리가 발생하는 성능 문제도 있을 수 있다.
엔티티 클래스 개발
- 예제에서는 설명을 쉽게하기 위해 엔티티 클래스에 Getter, Setter를 모두 열고, 최대한 단순하게 설계했다.
- 실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천한다.
: Setter를 막 열어두면 엔티티에 변경 되는 원인을 추적하기 힘들다. 그래서 Setter 대신에 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 하는 것이 좋다.
회원 엔티티
@Getter @Setter
@Entity
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<>();
}
주문 엔티티
@Entity
@Getter @Setter
@Table(name = "orders")
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)
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; //주문상태 [ORDER, CANCEL]
//연관관계 편의 메서드
public void setMember(Member member) {
//기존 관계 제거
if (this.member != null) {
this.member.getOrders().remove(this);
}
this.member = member;
member.getOrders().add(this);
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
}
주문 상태
public enum OrderStatus {
ORDER, CANCEL
}
주문 상품 엔티티
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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; //주문 수량
}
상품 엔티티
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@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<>();
}
도서, 음반, 영화 엔티티
@Entity
@Getter @Setter
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
}
@Entity
@Getter @Setter
@DiscriminatorValue("A")
public class Album extends Item {
private String artist;
}
@Entity
@Getter @Setter
@DiscriminatorValue("M")
public class Movie extends Item {
private String director;
private String actor;
}
배송 엔티티
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; //ENUM [READY, COMP]
}
배송 상태
public enum DeliveryStatus {
READY, COMP
}
카테고리 엔티티
@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<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
//==연관관계 메서드==//
public void addChildCategory(Category child) {
this.child.add(child);
child.setParent(this);
}
}
주소 값 타입
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
protected Address(){
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
값 타입은 변경 불가능하게 설계해야 한다. JPA 스펙상 엔티티나 임베디드 타입은 자바 기본 생성자를 public 또는 protected로 설정해야한다. public으로 두는 것 보다는 Protected로 설정하는 것이 그나마 더 안전하다. JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야하기 때문이다.
엔티티 설계시 주의점
- Setter 사용하지 말자.
- 모든 연관관계는 지연로딩으로!
- 즉시 로딩은 어떤 SQL이 실행될 지 추적하기 어렵다. 특히 JPQL을 사용할 때 N + 1 문제가 자주 발생한다ㅏ.
- 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용
- @XToOne 관계는 기본이 즉시 로딩이므로 지연 로딩으로 바꿔주자.
- 컬렉션은 필드에서 초기화 하자.
- null 문제에서도 안전하고, 코드도 간결해진다.
이 글은 김영한 님의 "실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발"을 듣고 정리한 내용입니다.
반응형