Spring/JPA

JPA 활용 (1)

챛채 2024. 6. 4. 13:50

1. 요구 사항 분석

  • 회원 기능
    • 등록, 조회
  • 상품 기능
    • 등록, 수정, 조회
  • 주문 기능
    • 주문, 조회, 취소
  • 기타 요구사항
    • 상품은 재고 관리가 필요하다.
    • 상품의 종류는 도서, 음반, 영화가 있다.
    • 상품을 카테고리로 구분할 수 있다.
    • 상품 주문시 배송 정보를 입력할 수 있다.

도메인 모델과 테이블 설계

 

  • 회원, 주문, 상품의 관계 : 회원은 여러 상품을 주문할 수 있고 한 번 주문할 때 여러 상품을 선택할 수 있어서 주문과 상품은 다대다 관계이다. 하지만 다대다 관계는 거의 사용하지 않기 때문에 주문상품이라는 엔티티를 추가해서 다대다 관계를 일대다, 다대일로 풀어냈다.
  • 상품 분류 : 상품은 도서, 음반, 영화로 구분되는데 상품이라는 공통 속성을 사용하므로 상속 구조로 표현했다.

회원 엔티티 분석

 

회원 (Member) : 이름, 임베디드 타입인 주소(Address), 주문(orders)리스트를 가진다.

 

주문(Order) : 한 번 주문시 여러 상품을 주문할 수 있으므로 주문 상품(OrderItem)과는 일대다 관계이다. 주문은 상품을 주문한 회원과 배송 정보, 주문 날짜, 주문 상태(status)를 가지고 있다. 주문 상태는 열거형을 사용했는데 주문(ORDER), 취소(CANCEL)을 표현할 수 있다.

 

주문상품(OrderItem) : 주문한 상품 정보와 주문 금액, 수량 정보를 갖고 있다.

 

상품(Item) : 이름, 가격, 재고수량을 갖고 있는데 상품을 주문하면 재고 수량이 줄어든다.

 

배송 (Delivery) : 주문시 하나의 배송 정보를 생성하고 주문과 배송은 일대일 관계이다.

 

카테고리(Category) : 상품과 다대다 관계를 맺는데 parent, child로 부모, 자식 카테고리를 연결한다.

 

회원 테이블 분석

MEMBER : 회원 엔티티의 Address 임베디드 타입 정보가 회원 테이블에 들어가있다.

ITEM : 앨범, 도서, 영화 타입을 통합해 하나의 테이블로 만들었다. DTYPE 컬럼으로 타입을 구분한다.

-테이블 명이 ORDER가 아닌 ORDERS인 것은 DB가 order by 때문에 예약어로 잡고 있는 경우가 많기 때문

-실제 코드에서는 DB에 소문자 + _(언더스코어)스타일 사용 

 

연관관계 매핑 분석

회원과 주문 : 일대다, 다대일의 양방향 관계이다. 외래키가 있는 주문을 연관 관계 주인으로 정하는 것이 좋다.

Order.member를 ORDERS.MEMBER_ID 외래키와 매핑한다.

 

주문 상품과 주문 : 다대일 양방향 관계이다. 외래 키가 주문상품에 있으므로 주문상품이 연관관계의 주인이다.

그러므로 OrderItem.order를 ORDER_ITEM.ORDER_ID 외래 키와 매핑한다.

 

주문상품과 상품 : 다대일 단방향 관계로 OrderItem.item을 ORDER_ITEM.ITEM_ID외래 키와 매핑한다.

 

주문과 배송 : 일대일 양방향 관계로 Order.delivery를 ORDERS.DELIVERY_ID 외래 키와 매핑한다.

 

카테고리와 상품 : @ManyToMany를 사용해서 매핑한다. (실무에서는 사용 X)

 

2. 엔티티 클래스 개발

이론적으로 Getter, Setter 모두 제공하지 않고 꼭 필요한 별도의 메서드를 제공하는 것이 가장 이상적이지만 실무에서 엔티티의 데이터는 조회할 일이 너무 많으므로, Getter의 경우는 모두 열어두는 것이 편리하다. 

Getter는 아무리 호출해도 데이터가 변하지 않지만, Setter의 경우는 호출하면 데이터가 변하기 때문에 꼭 필요한 경우에만 사용하고 엔티티를 변경할 때에는 Setter 대신 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공하는 것이 좋다.

 

[회원 엔티티]

@Entity
@Getter @Setter
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id") //name 설정 안 하면 id로 지정됨
    private Long id;
    /*
    왜 id를 Wrapper 클래스로?
    -> new Member()처럼 Member 객체를 생성하는 시점에는 id 값이 없어야한다. 그래서 없다는 표현을 null하는 것이 좋은데
    long을 사용하면 null을 입력할 수 없고, 0이란 값을 넣어두어야하기 때문에 Long 사용
     */
    @NotEmpty
    private String name; //회원명
    @Embedded
    private Address address;
    @OneToMany(mappedBy = "member") //하나의 회원이 여러개를 주문하기 때문에 일대다
    private List<Order> orders = new ArrayList<>(); //베스트 예시 ->초기화 고민 x (NullPointException날 일 없음)
    //컬렉션은 필드에서 바로 초기화는 것이 좋다.
    /*
    * private List<Order> orders;
    * public Member() {
    *       orders = new ArrayList<>();
    * }
    * */
}

 

엔티티 식별자는 id를 사용하고 PK 컬럼명은 member_id를 사용했다. 엔티티 타입(여기서는 Member)이 있으므로 id 필드만으로 쉽게 구분할 수 있다. 테이블은 타입이 없으므로 구분이 어렵다. 그리고 테이블은 관례상 테이블명 + id를 많이 사용한다. 

 

[주문 엔티티]

@Entity
@Table(name = "orders") //안 적으면 관례로 Order이 돼버림
@Getter @Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id") //테이블명_id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    /*
    * orders(Member)와 member(Order)중 연관 관계 주인은 누구?
    * -> Order에 있는 member가 FK에 더 가까우니까 연관 관계의 주인은 member로 한다.(JoinColumn)
    * */

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) //mappedBY -> order에 의해 맵핑이 됨
    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) //EnumType.ORDINAL 사용 X
    private OrderStatus status; //주문 상태 [ORDER, CANCEL]

    //===연관 관계 메서드===//
    //연관관계 메서드는 핵심적으로 컨트롤하는 쪽에 위치
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }//ManyToOne, 컬렉션(Member가 가지고 있는 Orders 목록)에 현재 객체 추가하는 것

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }//OneToMany,OneToOne, 하나의 객체에만 값 설정하는 것

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    /* ==기존==
    public static void main(String[] args) {
        Member member = new Member();
        Order order = new Order();

        member.getOrders().add(order);
        order.setMember(member);
    }
     */

 

[주문 상태]

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 {
    /* Item을 왜 추상 클래스로?
    * Item 자체로 사용하기보다는 상속하여 공통 기능과 함께 구현한 Book, Album, Movie를 직접 사용할 에정인데
    * 추상 클래스는 직접 인스턴스를 생성해서 사용하는 것이 불가능하기 때문에, 코드적으로 다른 사람에게 Item은 직접 사용하는 게
    * 아니라고 알려줄 수 있음
    * 즉, 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
public class Delivery {
    @Id
    @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @OneToOne(mappedBy = "delivery",fetch = FetchType.LAZY) //Order에 있는 delivery에 의해 맵핑됨
    private Order order;

    @Embedded
    private Address address;
    @Enumerated(EnumType.STRING) //EnumType.ORDINAL은 사용 X
    private DeliveryStatus status; //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"), //중간 테이블에 있는 category_id
            inverseJoinColumns = @JoinColumn(name = "item_id"))
    private List<Item> items = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent; /*parent(현재 객체의 부모)*/
    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();
    /* 계층형 카테고리 (ex. 전체 - 상의 - 아우터, 티셔츠, 맨투맨)
    * 현재객체 this(상의), parent(전체), child(아우터,티셔츠,맨투맨)
    * ManyToOne -> Many(현재 카테고리 객체), One(해당 에노테이션 달아준 private Category parent)
    * 즉, 현재 객체에 비해 부모는 하나, 부모에 비했을 떄 자식 카테고리는 여러 개가 될 수 있으므로 Many
    * 또 현재 객체 기준으로 자식 객체는 여러 개가 될 수 있고 자식 객체에 대해 부모는 본 객체 하나만 존재
    * 그래서 OneToMan에서 One(현재 객체 this), Many(자식 객체)
    * ** 현재 객체 기준으로 부모와 자식 객체 각각 존재 **
    * */

    //===연관 관계 메서드===//
    public void addChildCategory(Category child) {
        this.child.add(child);
        child.setParent(this);
    }
}

 

[주소 값 타입]

@Embeddable //Member에는 Embedded
@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;
    }
}

참고로 값 타입은 변경 불가능하게 설계해야 한다. @Setter를 제거하고 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만드는 게 좋다.

 

3. 엔티티 설계시 주의점

  • 엔티티에는 가급적 Setter 사용 금지! 
    • 변경 포인트가 너무 많아서 유지보수가 어렵다 나중에 리팩토링으로 Setter를 제거해라
  • 모든 연관 관계는 지연로딩으로 설정
    • 즉시로딩(EAGER)은 예측이 어렵고 어떤 SQL이 실행될지 추적하기가 어렵다. 특히 JPQL 실행시 N+1 문제가 자주 발생한다.
    • 실무에서는 모든 연관 관계는 지연로딩(LAZY)로 설정해야 한다.
    • XToOne(OneToOne, ManyToOne)은 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
  • 컬렉션은 필드에서 초기화 하기
    • null문제에서 안전하다.