본문 바로가기
Dev/JPA

[JPA] JPA 활용 I - 도메인 분석 설계

by dev_jsk 2021. 9. 15.
728x90
반응형

쇼핑몰을 주제로하여 간단한 웹 애플리케이션을 개발해보려 한다.

요구사항 분석

기능목록

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

도메인 모델과 테이블 설계

관계도

관계도

  • 회원, 주문, 상품의 관계는 회원은 여러 주문을 할 수 있고, 그 주문에는 여러 상품이 담겨있다. 이때 주문과 상품은 다대다 관계이기 때문에 중간 엔티티 주문상품을 추가하여 일대다, 다대일 관계로 풀어낸다.
  • 상품은 도서, 음반, 영화로 구성되며 상속 관계를 가진다.

엔티티 및 테이블 설계

엔티티 설계 (좌측) / 테이블 설계 (우측)

  • 회원이 주문을 하기 때문에 회원이 주문리스트를 가지는 것이 잘 설계한 것 같지만, 실무에서는 회원이 주문을 참조하는 것이 아닌 주문이 회원을 참조하기 때문에 주문 테이블에 회원을 가진다.
  • 주소 값타입 엔티티는 회원과 배송 테이블에 각각의 컬럼으로 생성된다.
  • 상품 테이블은 싱글 테이블 전략을 사용하여 상속관계에 있는 필드 전부 컬럼으로 생성하여 관리한다.
  • 상품 테이블 내 DTYPE컬럼을 통해 도서, 음반, 영화의 구분값을 저장한다.
  • 주문 테이블의 이름이 ORDER가 아니라 ORDERS인 이유는 데이터베이스의 예약어 ORDER BY가 있기 때문에 관례상 ORDERS로 많이 사용한다.

연관관계 매핑 분석

  • 양방향 관계에서는 연관관계의 주인이 매우 중요한데 이때 외래키가 있는 쪽을 연관관계의 주인으로 정하는 것이 설계도 깔끔하고 성능도 좋고 관리, 조작하기 쉽다.
  • 연관관계의 주인은 단순히 외래키를 누가 관리하나의 문제이지 비즈니스상 우위에 있다고 주인으로 정하면 안된다.

엔티티 클래스 개발

주의사항

  • 엔티티, 테이블 설계에 맞춰서 개발
  • 연관관계를 주의하여 구성하고 연관관계 주인 선정이 중요하다.
  • 실무에서는 가급적 Getter만 열어두고, Setter는 꼭 필요할 경우에만 사용해야 한다. 왜냐하면 Getter의 경우 아무리 호출해도 어떠한 일이 발생하지 않지만, Setter의 경우 미래에 엔티티가 어떻게 변경되었는지 추적하기 힘들어지기 때문에 엔티티 변경시엔 Setter대신에 별도의 메소드를 구성하여 변경해야 한다.
  • 해당 예제에서는 쉽게 구성하기 위해 Getter, Setter모두 열어두고 사용한다.

엔티티 구현

회원 (Member)

package jpabook.jpashop.domain;

@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<>();

}

주문 (Order)

package jpabook.jpashop.domain;

@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
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)  // ENUM 클래스 값 형식 설정
    private OrderStatus status;  // ORDER, CANCEL

}

주문 상태 (OrderStatus)

package jpabook.jpashop.domain;

public enum OrderStatus {
    ORDER, CANCEL
}

주문 상품 (OrderItem)

package jpabook.jpashop.domain;

@Entity
@Table(name = "order_item")
@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;  // 주문 수량
}

상품 (Item)

package jpabook.jpashop.domain.item;

@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<>();
}

음반 (Album)

package jpabook.jpashop.domain.item;

@Entity
@DiscriminatorValue(value = "A") // 구분 컬럼 값 지정
@Getter @Setter
public class Album extends Item {
    
    private String artist;
    private String etc;
}

도서 (Book)

package jpabook.jpashop.domain.item;

@Entity
@DiscriminatorValue(value = "B")  // 구분 컬럼 값 지정
@Getter @Setter
public class Book extends Item {
    
    private String author;
    private String isbn;
}

영화 (Movie)

package jpabook.jpashop.domain.item;

@Entity
@DiscriminatorValue(value = "M") // 구분 컬럼 값 지정
@Getter @Setter
public class Movie extends Item {
    
    private String director;
    private String actor;
}

배송 (Delivery)

package jpabook.jpashop.domain;

@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
}

배송 상태 (DeliveryStatus)

package jpabook.jpashop.domain;

public enum DeliveryStatus {
    READY, COMP
}

카테고리 (Category)

package jpabook.jpashop.domain;

@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<>();

    // 현재 엔티티 기준 부모니까 나의 부모는 1개
    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Category parent;

    // 현재 엔티티 기준 자식이니까 나의 자식은 여러개
    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();
}
  • 실무에서는 @ManyToMany를 사용하지 않는것을 추천한다. 다대다 관계가 편리한 것 같지만 중간 테이블에 추가적인 컬럼을 구성할 수 없고 세밀하게 쿼리를 실행할 수 없기 때문에 한계가 있다. 중간 엔티티를 구성하여 일대다, 다대일 매핑으로 구성하여 사용하는 것을 추천한다.

주소 (Address)

package jpabook.jpashop.domain;

@Embeddable
@Getter
public class Address {

    private String city;
    private String street;
    private String zipcode;

    // setter를 제공 안하고 생성자를 통해 값 설정하는 것을 권장
    protected Address() {}

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}
  • 값 타입은 변경 불가능하게 설계해야 한다.
  • Getter만 열고 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스로 구현한다.
  • JPA 스펙상 엔티티나 임베디드 타입은 자바 기본 생성자를 public 또는 protected로 설정해야 하는데 public보다는 protected가 안전하기 때문에 protected로 설정한다.
  • JPA가 기본생성자 권한 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.

구현 결과

테이블 생성 구문 (좌측) / 데이터베이스 구조 (우측)

엔티티 설계 시 주의사항

  • 엔티티에는 가급적 Setter를 사용하지 말아야 한다.
    - Setter가 열려있다면 변경 포인트가 너무 많아 유지보수가 어렵다.
  • 모든 연관관계는 지연로딩으로 설정해야 한다.
    - 즉시로딩은 예측이 어렵고 어떤 SQL이 실행될 지 추적하기 어렵다.
    - 특히 JPQL을 실행할 때 N+1문제가 자주 발생한다.
    - 연관된 엔티티를 함께 조회해야 한다면 FETCH JOIN 또는 엔티티 그래프 기능을 사용한다.
    - @OneToOne, @ManyToOne관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다. (@OneToMany, @ManyToMany는 기본이 지연로딩)
  • 컬렉션은 필드에서 초기화 하여 사용해야 한다.
    - 컬렉션을 필드에서 바로 초기화 하여 사용할 경우 NULL관련 문제에서 안전하다.
    - 하이버네이트는 엔티티를 영속할 때 컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경하게 되는데 만약 임의의 메소드에서 컬렉션을 잘못 생성하거나 수정하면 하이버네이트 내부 매커니즘에 문제가 발생할 수 있다. 따라서 컬렉션을 필드에서 바로 초기화 하여 사용해야 하며, 절대로 컬렉션을 교체하지 말고 생성 시점의 객체 그대로 사용해야 한다.
  • 테이블 컬럼명 생성 전략
    - 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용한다.
    - 스프링 부트에서는 카멜 표기법을 팟홀 표기법으로 구성하고 .(점)_(언더바), 대문자를 소문자로 변경하여 자동으로 구성한다.
  • Cascade
    - 특정 엔티티를 영속성 상태로 만들때 연관된 엔티티도 함께 영속성 상태로 변경하는것으로 ALL 설정 시 영속상태 변경이나 삭제가 함께 진행된다.
  • 연관관계 편의 메소드
    - 연관관계를 편하게 연결하기 위한 메소드로 핵심적으로 조작하는 곳에 구현하는 것이 좋다.
    - 양방향 관계일 경우 사용하면 특히 좋다.
728x90
반응형

댓글