kinggora 2022. 5. 10. 23:39

도메인 분석 설계

요구사항 분석

회원 기능

  • 회원 등록
  • 회원 조회

상품 기능

  • 상품 등록
  • 상품 수정
  • 상품 조회

주문 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

기타 요구사항

  • 상품은 재고 관리가 필요하다.
  • 상품의 종류는 도서, 음반, 영화가 있다.
  • 상품을 카테고리로 구분할 수 있다.
  • 상품 주문시 배송 정보를 입력할 수 있다.

도메인 모델과 테이블 설계

 

회원, 주문, 상품의 관계

회원은 여러 상품을 주문할 수 있다. -> 회원과 주문은 일대다 관계

한 번 주문할 때 여러 상품을 선택할 수 있고, 상품도 여러 주문에 포함될 수 있다. -> 주문과 상품은 다대다 관계

다대다 관계는 관계형 데이터베이스는 물론이고 엔티티에서도 거의 사용하지 않는다.

-> 주문상품이라는 엔티티(테이블)를 추가해서 다대다 관계를 일대다, 다대일 관계로 변경 (주문 수량, 주문 가격 등 포함)

 

상품 분류

상품은 도서, 음반, 영화로 구분되는데 상품이라는 공통 속성을 사용하므로 상속 구조로 표현했 다

회원 엔티티 분석

 

기본적으로 모든 Primary Key는 Long 타입으로 설정

  • 회원(Member): 이름과 임베디드 타입인 주소( Address ), 그리고 주문 리스트( orders )를 가진다.
  • 주문(Order): 한 번 주문시 여러 상품을 주문할 수 있으므로 주문과 주문상품( OrderItem )은 일대다 관계이다. 주문은 상품을 주문한 회원과 배송 정보( Delivery ), 주문 날짜, 주문 상태( OrderStatus )를 가지고 있다. 주문 상태는 열거형(enum)을 사용했는데 주문( ORDER ), 취소( CANCEL )을 표현할 수 있다.
  • 주문상품(OrderItem): 주문한 상품 정보와 주문(할 당시의) 금액( orderPrice ), 주문 수량( count ) 정보를 가지고 있다. (보통 OrderLine , LineItem 으로 많이 표현한다.)
  • 상품(Item): 이름, 가격, 재고수량( stockQuantity )을 가지고 있다. 상품을 주문하면 재고수량이 줄어든다. 상품의 종류로는 도서, 음반, 영화가 있는데 각각은 사용하는 속성이 조금씩 다르다.
  • 배송(Delivery): 주문시 하나의 배송 정보를 생성한다. 주문과 배송은 일대일 관계다.
  • 카테고리(Category): 상품과 다대다 관계를 맺는다. parent, child 로 부모, 자식 카테고리를 연결한다.
  • 주소(Address): 값 타입(임베디드 타입). 회원과 배송(Delivery)에서 사용한다.

참고: 회원이 주문을 하기 때문에 회원이 주문리스트를 가지는 것은 얼핏 보면 잘 설계한 것 같지만 객체 세상은 실제 세계와 다르다. 실무에서는 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하다. 여기서는 일대다, 다대일의 양방향 연관관계를 설명하기 위해서 추가했다. 설계 단계에서는 가급적 단방향 연관 관계를 설정하는 것이 좋다.

  • 회원이 주문을 한다 (X)
  • 주문을 생성할 때 회원이 필요하다 (O)
  • 회원이 주문내역을 불러온다 -> 주문을 회원으로 필터링

회원 테이블 분석

 

  • MEMBER: 회원 엔티티의 Address 임베디드 타입 정보가 회원 테이블에 그대로 들어갔다. 이것은 DELIVERY 테이블도 마찬가지다.
  • ITEM: 앨범, 도서, 영화 타입을 통합해서 하나의 테이블로 만들었다. DTYPE 컬럼으로 타입을 구분한다.

참고: 테이블명이 ORDER 가 아니라 ORDERS 인 것은 데이터베이스가 order by 때문에 예약어로 잡고 있 는 경우가 많다. 그래서 관례상 ORDERS 를 많이 사용한다.

 

참고: 실제 코드에서는 DB에 '소문자 + _(언더스코어)' 스타일을 사용할 것이다.

데이터베이스 테이블명, 컬럼명에 대한 관례는 회사마다 다르다. 보통은 대문자 + _(언더스코어)나 소문자 + _(언더스코어) 방식 중에 하나를 지정해서 일관성 있게 사용한다. 

연관관계 매핑 분석

회원과 주문

  • 일대다 양방향 관계
  • 연관관계의 주인은 외래 키가 있는 주문으로 정하는 것이 좋다. (일대다에서 다)
  • Order.member 를 ORDERS.MEMBER_ID 외래 키와 매핑

주문상품과 주문

  • 다대일 양방향 관계
  • 연관관계의 주인은 외래 키가 있는 주문상품
  • OrderItem.order 를 ORDER_ITEM.ORDER_ID 외래 키와 매핑

주문상품과 상품

  • 다대일 단방향 관계
  • OrderItem.item 을 ORDER_ITEM.ITEM_ID 외래 키와 매핑

주문과 배송

  • 일대일 양방향 관계
  • Order.delivery 를 ORDERS.DELIVERY_ID 외래 키와 매핑

카테고리와 상품

  • 다대다 양방향 관계
  • 실무에서 @ManyToMany는 사용하지 말자. 예제를 위해 임의로 설정했다.

참고: 외래 키가 있는 곳을 연관관계의 주인으로 정해라. (1:多 에서 多)

연관관계의 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스상 우위에 있다고 주인으로 정하면 안된다.

1에 불필요한 다수의 정보가 업데이트 되므로 관리와 유지보수가 어렵고, 별도의 업데이트 쿼리가 발생하는 성능 문제도 있다.

  • 연관관계의 주인: write&read
  • 연관관계의 거울: only read

엔티티 클래스 개발

예제에서는 설명을 쉽게하기 위해 엔티티 클래스에 Getter, Setter를 모두 열고, 최대한 단순하게 설계

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

 

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

Getter는 아무리 호출해도 호출하는 것 만으로 어떤 일이 발생하지는 않는다. Setter를 막 열어두면 엔티티가 왜 변경되는지 추적하기 어려워진다. 그래서 엔티티 변경을 위해 Setter 대신, 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 한다.

 

@Id : Primary Key 설정

@GeneratedValue : 생성 전략 (기본값: AUTO)

@Column(name = "member_id") : 매핑 컬럼 이름

private Long id;

 

@Embedded : 임베디드 타입(값 타입) 사용

private Address address;

 

@Entity

@Table(name = "orders") : 매핑 테이블 이름

public class Order

 

@ManyToOne(fetch = FetchType.LAZY) : 다대일 관계(주인). 지연 로딩

@JoinColumn(name = "member_id") : 조인 컬럼(외래 키 컬럼)

private Member member; 

 

@OneToMany(mappedBy = "order", : 일대다 관계(거울). 주인 필드 지정

    cascade = CascadeType.ALL) : 엔티티 상태 변화 전파 옵션. 모든 변경에 대하여 cascade

private List orderItems = new ArrayList<>();

 

연관관계 매핑

객체의 참조와 테이블의 외래키를 매핑하는 것

JPA에서는 JDBC를 사용했을 때와 달리 연관 관계에 있는 상대 테이블의 PK(Long:member_id)를 멤버 변수로 갖지 않고, 엔티티 객체 자체(Member:member)를 통째로 참조한다.

참고: 일대일 관계에서는 외래 키를 주로 많이 액세스가 일어나는 클래스에 둔다. (연관관계의 주인)

 

cascade 옵션

@OneToMany 나 @ManyToOne에 옵션으로 줄 수 있는 값. Entity의 상태 변화를 전파시키는 옵션이다. 만약 Entity의 상태 변화가 있으면 연관된 Entity에 상태 변화를 전이시킨다. (원래 모든 엔티티의 상태는 직접 각각 변화시켜줘야 한다.)

 

Entity의 상태는 크게 4가지 종류가 있다.

  1. Transient: 객체를 생성하고, 값을 주어도 JPA나 hibernate가 그 객체에 관해 아무것도 모르는 상태. 즉, 데이터베이스와 매핑된 것이 아무것도 없다. (영속화 전)
  2. Persistent: 저장을 하고나서, JPA가 아는 상태(관리하는 상태)가 된다. 그러나 save()를 했다고 해서, 이 순간 바로 DB에 이 객체에 대한 데이터가 들어가는 것은 아니다. JPA가 persistent 상태로 관리하고 있다가, 후에 데이터를 저장한다. (1차 캐시, Dirty Checking(변경사항 감지), Write Behind(최대한 늦게, 필요한 시점에 DB에 적용) 등의 기능을 제공한다)
  3. Detached: JPA가 더이상 관리하지 않는 상태. JPA가 제공해주는 기능들을 사용하고 싶다면, 다시 persistent 상태로 돌아가야한다.
  4. Removed: JPA가 관리하는 상태이긴 하지만, 실제 commit이 일어날 때, 삭제가 일어난다.

@Enumerated(EnumType.STRING) : Enum 타입. 문자열로 저장

private OrderStatus status;

 

Enum 타입 매핑

@Enumerated의 EnumType

@Enumerated

EnumType은 반드시 ORDINAL(1,2,3...)이 아닌 STRING으로 설정할 것

READY(1), COMP(2) ->  READY(1), XXX(2), COMP(3)  

중간에 XXX 타입을 추가하면 COMP였던 2가 전부 XXX로 매핑된다.

 

public enum OrderStatus : 엔티티로 지정X

{ ORDER, CANCEL }

 

@Entity

@Inheritance(strategy = InheritanceType.SINGLE_TABLE) : 상속 매핑 전략

@DiscriminatorColumn(name = "dtype") : 하위 클래스 구분 컬럼. 부모 클래스에 명시

public abstract class Item {} : 실제로 객체를 생성할 필요가 없으므로 추상 메서드로 설정

 

상속관계 매핑

@InheritanceType의 starategy

  •  SINGLE_TABLE: 1개의 테이블에 상속 클래스의 모든 필드를 포함한 테이블 생성
  •  TABLE_PER_CLASS: 상속 클래스당 테이블 생성
  •  JOINED: 정규화

@ManyToMany(mappedBy = "items") : 다대다 관계(거울)

private List categories = new ArrayList();

 

@Entity

@DiscriminatorValue("B") : 구분 컬럼(DiscriminatorColumn)에 입력할 값

public class Book extends Item {}

 

@ManyToMany : 다대다 관계(주인)

@JoinTable(name = "category_item", : 생성할 조인 테이블 이름

  joinColumns = @JoinColumn(name = "category_id"), : 조인 컬럼(pk)

  inverseJoinColumns = @JoinColumn(name = "item_id")) : 조인 컬럼(fk)

private List items = new ArrayList<>();

 

참고: @ManyToMany 는 편리한 것 같지만, 중간 테이블( CATEGORY_ITEM )에 컬럼을 추가할 수 없고, 세밀하게 쿼리를 실행하기 어렵기 때문에 실무에서 사용하기에는 한계가 있다.

중간 엔티티( CategoryItem )를 만들고 @ManyToOne , @OneToMany 로 매핑해서 사용하자. 

 

@Embeddable : 임베디드 타입(값 타입) 정의. 엔티티로 지정X

public class Address {

  ...

  protected Address() { }

  public Address(...) { ... } : setter 없이 생성자에서 모든 필드 초기화

}

 

임베디드 타입

값 타입은 변경 불가능하게 설계해야 한다.

@Setter 를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자.

JPA 스펙상 엔티티나 임베디드 타입( @Embeddable )은 자바 기본 생성자(default constructor)를 public 또는 protected 로 설정해야 한다. public 보다 protected 가 그나마 더 안전하다.

JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.

 

연관관계 편의 메서드

양방향 관계의 경우 양쪽 모두에 관계를 맺어주어야한다.
사실 JPA의 입장에서는 외래키 관리자(연관관계의 주인) 쪽에만 관계를 맺어준다면 정상적으로 양쪽 모두에서 조회가 가능하다.

한 쪽에서만 관계를 맺고 다른 쪽이 누락되는 것을 막기 위해 두 코드를 하나의 메서드로 묶어서 관리하는 것이 효율적이다.

엔티티 설계시 주의점

엔티티에는 가급적 Setter를 사용하지 말자

Setter가 모두 열려있다. 변경 포인트가 너무 많아서, 유지보수가 어렵다. 나중에 리펙토링으로 Setter 제거

 

모든 연관관계는 지연로딩으로 설정

  • 즉시 로딩: 특정 엔티티를 로딩할 때 연관된 모든 엔티티도 함께 로딩
  • 지연 로딩: 연관된 엔티티를 조회하는 시점에 해당 엔티티만 로딩

즉시로딩( EAGER )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.

실무에서 모든 연관관계는 지연로딩( LAZY )으로 설정해야 한다.

연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.

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

 

참고: 트랜젝션 밖에서 LAZY 로딩이 작동하지 않는 이슈

미리 트랙젝션을 가져오거나, 오픈세션리뷰 방식 사용. 왠만하면 fetch join으로 해결 가능

 

참고: OneToOne 양방향 연관관계의 주인이 아닌 엔티티에서 LAZY 로딩이 작동하지 않는 이슈 

FetchType을 Lazy로 설정해도 Eager로 동작한다.

연관관계 정보(fk)를 주인만 가지고 있기 때문에 거울에는 주인을 참조하는 컬럼이 존재하지 않는다.

(mappedBy가 지정되지 않으면 연관관계 엔티티를 참조하기 위한 컬럼이 만들어진다.)

따라서 엔티티를 조회할 때 테이블을 봐도 연관관계 엔티티가 존재하는지 알 수 없다.

JPA도 조회 시점에 @OneToOne 필드에 프록시를 초기화할지, null(연관관계 엔티티 없음)을 초기화해야 할지 모른다.

프록시는 null을 감쌀 수 없기 때문에 프록시가 초기화 된다는 것은 연관된 엔티티가 있음을 의미한다.

(OneToMany에서 List는 값이 없어도 empty로 표현할 수 있기 때문에 프록시로 감쌀 수 있다.)

따라서 연관관계 주인을 실제 조회해볼 수 밖에 없어 Lazy 로딩 전략이 무시된다.

-> fetch join 또는 엔티티 그래프 기능으로 해결 가능

 

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

컬렉션은 필드에서 바로 초기화 하는 것이 안전하다.

private List<Order> orders = new ArrayList<>();

  • null 문제에서 안전하다.
  • 하이버네이트는 엔티티를 영속화(persist) 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. (-> 컬렉션 변경을 추적해야 하기 때문에) 만약 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 필드 레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다.

테이블, 컬럼명 생성 전략

스프링 부트에서 하이버네이트 기본 매핑 전략을 변경해서 실제 테이블 필드명은 다르다.

하이버네이트 기존 구현: 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용 ( SpringPhysicalNamingStrategy )

 

스프링 부트 신규 설정 (엔티티(필드) -> 테이블(컬럼))

  1. 카멜 케이스 -> 언더스코어(memberPoint -> member_point)
  2. .(점) -> _(언더스코어)
  3. 대문자 -> 소문자

적용 2 단계

1. 논리명 생성: 명시적으로 컬럼명(@Column), 테이블명(@Table)을 직접 적지 않으면 ImplicitNamingStrategy 사용

  spring.jpa.hibernate.naming.implicit-strategy : 테이블이나, 컬럼명을 명시하지 않을 때 논리명 적용

2. 물리명 적용: spring.jpa.hibernate.naming.physical-strategy : 모든 논리명에 적용됨, 실제 테이블에 적용

  (username usernm 등으로 회사 룰로 바꿀 수 있음)

 

*이전 코드 파일로 이동: ctrl+alt+left

*해당 코드 포함 여부 검색: ctrl+shift+F