도메인 분석 설계 - 간단 쇼핑몰 예제

2022. 5. 23. 13:26Java/JPA

[목차]

요구사항 분석

도메인 모델과 테이블 설계

엔티티 클래스 개발

엔티티 설계 시 주의점

요구사항 분석

기능 목록

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

도메인 모델과 테이블 설계

도메인 모델

 

각 테이블의 id가 PK

임베디드 타입(값 타입) Address, 재활용가능

 

❗  연관관계 주인
양방향 연관관계에서는 연관관계 주인을 정해야 한다,
일대다 관계에서 多에 외래키 존재, 이를 연관관계 주인으로 한다. → 정석적인 방법임!
  주인(多) 쪽에서 값 세팅 → 그래야 값 변경 가능
  반대쪽(一)은 mapped by로 단순 조회용

* 코드 적용
1. Order엔티티
...
@ManyToOne
@JoinColumn(name = "member_id")       // PK이름이 member_id
private Member member;                         // * 이 member필드

2. Memeber 엔티티
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
...
@OneToMany(mappedBy = "member")    // 나는 주인이 아닙니다. 읽기 전용
                                               // 여기서 member는 Order테이블에 있는 member필드*에 의해서 매핑된 것이다 의미
private List<Order> orders = new ArrayList<>();

3. JPA가 둘 중 어디를 보고 업데이트 쳐야할까?
둘 중 하나를 선택해야하므로 둘 중에 하나를 연관관계 주인으로 잡는 것이다. 
관리하기 좋게

* 외래 키가 있는 곳을 연관관계의 주인으로 정해라.
연관관계 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스상 우위에 있다고 주인으로 정하면 안된다.
예를 들어서 자동차와 바퀴가 있으면, 일다대 관계에서 항상 다쪽에 외래 키가 있으므로 외래 키가 있는 바퀴를
연관관계의 주인으로 정하면 된다.  물론 자동차를 연관관계의 주인으로 정하는 것이 불가능 한 것은 아니지만 자동차를 연관관계 주인으로 정하면 자동차가 관리하지 않는 바퀴 테이블의 외래 키 값이 업데이트 되므로 유지보수 및 관리가 어렵고, 추가적으로 별도의 업데이트 쿼리가 발생하는 성능의 문제도 있다. 

엔티티 클래스 개발 

실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천

참고:
실무에서 엔티티의 데이터는 조회할 일이 너무 많으므로, Getter의 경우 모두 열어두는 것이 편리하다. Getter는 아무리 호출해도 호출하는 것 만으로 어떤 일이 발생하지는 않는다. 하지만 Setter는 문제가 다르다. Setter를 호출하면 데이터가 변한다. Setter를 막 열어두면 가까운 미래에 엔티티가 도대체 어디서 변경되는지 추적하기 점점 힘들어진다. 그래서 엔티티를 변경할 때는 Setter 대신에 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 한다.
그래야 유지보수성도 올라간다.

 

엔티티의 식별자는 id를 사용하고, PK 컬럼명은 member_id를 사용했다.
이 부분 ➡@Column(name = "테이블명_id")
엔티티는 타입(여기서는 Member )이 있으므로 id 필드만으로 쉽게 구분할 수 있다.
테이블은 타입이 없으므로 구분이 어렵다. 찾기가 어렵고, 조인도 불편, 명확성을 위해, FK랑 이름을 맞춘다는 여러 이유가 있다. 그리고 테이블은 관례상 '테이블명 + id'를 많이 사용한다. 참고로 객체에서 id 대신에 memberId를 사용해도 된다. 중요한 것은 일관성이다.

 

값 타입

값 타입은 변경 가능하면 안 된다. @Setter 제공 안 함

생성할 때만 값이 세팅.

값 타입은 변경 불가능하게 설계해야 한다. 
@Setter를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자.
JPA 스펙상 엔티티나 임베디드 타입(@Embeddable )은 자바 기본 생성자(default constructor)를 public 또는 protected로 설정해야 한다. public으로 두는 것보다는 protected로 설정하는 것이 그나마 더 안전하다.
package jpabook.jpashop.domain;

import lombok.Getter;

import javax.persistence.Embeddable;

@Embeddable
@Getter
public class Address {

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

   // public말고
    protected Address() {
    }
    
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

 

 

추상 클래스 사용시 상속관계 매핑

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")                  // DB입장에서 구분을 위해 사용
public abstract class Item {
...
}


@Entity
@DiscriminatorValue("B")
public class Book extends Item {
...
}

 

일대일 관계일 경우

➡ FK를 어디에 둬도 상관없지만, access를 많이 하는 쪽에 두는 방법도 있다. 

➡ FK가 있는 쪽을 주인으로 한다. 

예) Order - Delivery

 

FK를 꼭 걸어야할까? 시스템 마다 다르다. 

1. 실시간 트래픽이 중요하고, 정합성 보다 유연하게 서비스가 바로 되는 게 중요하면 FK빼고 인덱스만 잘 잡아주면 된다.

2. 돈, 데이터 같이 항상 맞아야 하면 FK잡아주는 게 좋을 수도 있다.

 

+) 쿼리 파라미터 로그 남기기

스프링 부트를 사용하면 라이브러리 추가만 하면 된다.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.6'
참고: 쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하므로,
개발 단계에서는 편하게 사용해도 된다. 하지만 운영시스템에 적용하려면 꼭 성능 테스트를 하고 사용하는 것이 좋다.

 

엔티티 설계 시 주의점

[중요] 모든 연관관계는 지연 로딩으로 무조건 설정해야 한다! 즉시 로딩 절대 쓰지 말 것.

즉시로딩 : 로딩하는 시점에 연관 테이블까지 전부 로딩, DB에서 다 끌고 옴

- 즉시 로딩( EAGER )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다.
  특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
- 실무에서 모든 연관관계는 지연 로딩( LAZY )으로 설정해야 한다.
  예) @ManyToOne(fetch = FetchType.EAGER), @ManyToOne(fetch = FetchType.LAZY)
- 만약 연관된 엔티티를 함께 DB에서 조회해야 하면 그때 fetch join 또는 엔티티 그래프 기능을 사용하는 식으로 최적화가 가능  하다.

@XToOne(OneToOne, ManyToOne) 관계는 기본값이 즉시 로딩이므로 직접 지연 로딩으로 설정해야 한다.
@XToMany 기본값은 LAZY입니다.

 

컬렉션은 필드에서 초기화 하자

컬렉션은 필드에서 바로 초기화하는 것이 안전하다. (밑에서 작성한 코드)
null 문제에서 안전하다.

하이버네이트는 엔티티를 영속화할 때, 컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.
임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있으므로
컬렉션은 필드에서 초기화, 가급적 바꾸지 말기 = 이 orders를 그대로 가져다 쓰기, 변경하지 말 것.
@Entity
@Getter @Setter
public class Member {
    ...
    @OneToMany(mappedBy = "member") 
    private List<Order> orders = new ArrayList<>();
}

 

테이블, 칼럼명 생성 전략

따로 지정해주지 않으면 이렇게 자동 변환된다. (기본 전략)

1. 카멜 케이스 → 언더스코어(memberPoint member_point)

2. .(점)     _(언더스코어)

3. 대문자    소문자

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
   ...
}

 

 

cascade

@OneToMany 나 @ManyToOne에 옵션으로 줄 수 있는 값으로 Entity의 상태 변화를 전파시키는 옵션이다.

만약 Entity의 상태 변화가 있으면 연관되어 있는(ex. @OneToMany, @ManyToOne...) Entity에도 상태 변화를 전이시킨다. 

예를들어, Order에 00을 저장하면, OrderItem에도 저장(persist)됨.

기본적으로는 아무것도 전이시키지 않는다. = 각자 persist하는 것이다.

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
   	...
   
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    private List<OrderItem> orderItems = new ArrayList<>();
}

주로 사용하는 옵션은 아래와 같다.

cascade = CascadeType.ALL

 

연관관계 (편의) 메서드

양방향 연관관계 편의 메서드의 위치는 핵심적으로 컨트롤하는 쪽에 위치한다.

 

원래는 이렇게 작성해야하는데, 놓칠 수 있기에 원자적으로 이 둘을 묶는 메서드를 만드는 것이다. 

public static void main(String[] args) {
	Member member = new Member();
    Order order = new Order();
    member.getOrders().add(order);
    order.setMember(member);
}

양방향 연관관계 편의 메서드 적용

 

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
    ... 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id") // member_id가 FK, Member와 Order는 양방향연관관계 - 주인 정하기
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // 일대일관계는 FK는 어디다 둬도 된다, 그럼 access잦은 쪽에 둔다, 연관관계주인 Order가 됨
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    ...
    
    //== 양방향 연관관계 편의메서드 ==//
    public void setMember(Member member) {
        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);
    }
}