메인 화면
- 메인 뷰는 다음과 같다. 이어서 요구사항에 대해 분석해보자.
애플리케이션 구현 준비
- 로그인, 권한 관리 X
- 파라미터 검증 및 예외 처리 X
- 상품은 도서만 사용
- 카테고리는 사용 X
- 배송 정보는 사용 X
아키텍처
- 계층형 구조
- controller, web : 웹 계층
- service : 비즈니스 로직, 트랜잭션 처리
- repository : JPA를 직접 사용하는 계층, 엔티티 매니저 사용
- domain : 엔티티가 모여있는 계층, 모든 계층에서 사용
- 개발 순서 : 서비스 , 리포지토리 계층 개발, 테스트 케이스 작성 및 검증, 마지막 웹 계층 적용
요구사항 분석
기능은 크게 회원, 상품, 주문, 기타 요구사항으로 나누어 볼 수 있다. 각 기능별 목록을 보자.
기능 목록
- 회원 기능
- 회원 등록
- 회원 조회
- 상품 기능
- 상품 등록
- 상품 수정
- 상품 조회
- 주문 기능
- 상품 주문
- 주문 내역 조회
- 주문 취소
- 기타 요구사항
- 상품은 재고 관리가 필요하다.
- 상품의 종류는 도서, 음반, 영화가 있다.
- 상품을 카테고리로 구분할 수 있다.
- 상품 주문시 배송 정보를 입력할 수 있다.
도메인 모델과 테이블 설계
- 회원, 주문, 상품의 관계 : 회원은 여러 상품을 주문할 수 있다. 그리고 한 번 주문할 때 여러 상품을 선택할 수 있으므로 주문과 상품은 다대다 관계다. 하지만 이런 다대다 관계는 관계형 데이터베이스는 물론이고 엔티티에서도 거의 사용하지 않는다. 따라서 그림처럼 주문상품이라는 엔티티를 추가해서 다대다 관계를 일대 다, 다대일 관계로 풀어냈다. 다만, 카테고리와 상품은 경험을 위해 다대다로 두었다.
- 상품 분류 : 상품은 도서, 음반, 영화로 구분되는데 상품이라는 공통 속성을 사용하므로 상속 구조로 표현
회원 엔티티 분석
- Member(회원) : 이름과 임베디드 타입인 주소, 그리고 주문 리스트를 가진다.
- Order(주문) : 주문은 상품을 주문한 회원과 배송 정보, 주문 날짜, 주문 상태를 가지고 있다. 주문 상태는 열거형을 사용했는데, ORDER, CANCEL 등을 표현한다.
- OrderItem(주문 상품) : 주문한 상품 정보와 주문 금액, 주문 수량 정보를 가지고 있다.
- Item(상품) : 이름, 가격, 재고수량을 가지고 있다. 상품을 주문하면 재고수량이 줄어든다. 상품의 종류로는 도서, 음반, 영화가 있는데 각각은 사용하는 속성이 조금씩 다르다.
- Delivery(배송) : 주문 시 하나의 배송 정보를 생성한다. 주문과 배송은 일대일 관계다.
- Category(카테고리) : 상품과 다대다 관계를 맺는다.
parent
,child
로 부모, 자식 카테고리를 연결한다. - Address(주소) : 값 타입(임베디드)으로 회원과 배송에서 사용한다.
주문을 하기 때문에, 회원이 주문리스트를 가지는 것은 잘 설계한 것 같지만, 객체지향 관점에서 봤을 때 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하다. 일대다, 다대일의 양방향 연관관계를 이해하기 위해 사용했다고 생각하자.
회원 테이블 분석
- MEMBER : 회원 엔티티의
Address
임베디드 타입 정보가 회원 테이블과 배송 테이블에 들어갔다. - ITEM : 앨범, 도서, 영화 타입을 통합해서 하나의 테이블로
DTYPE
컬럼을 통해 구분하도록 만들었다. (싱글 테이블 전략)
주문 테이블이 ORDERS 인 이유는
order by
가 예약어로 잡고 있는 경우가 많기 때문에 관례상 변경한 것이다.데이터베이스 네이밍 방식은 회사마다 다르지만 보통은
대문자 + _
나소문자 + _
로 지정해서 일관성 있게 사용한다.실제 코드에서는
소문자 + _
방식을 사용해보자.
연관관계 매핑 분석
- 회원과 주문 : 일대다, 다대일의 양방향 관계
- 외래 키가 있는 주문을 연관관계의 주인으로 설정
- 주문상품과 주문 : 다대일 양방향 관계 - 주문상품이 연관관계의 주인
- 주문상품과 상품 : 다대일 단방향 관계
- 주문과 배송 : 일대일 양방향 관계
외래 키가 있는 곳을 연관관계의 주인으로 정하자. 연관관계의 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스 상 우위에 있다고 주인으로 정하면 안된다. 그렇지 않으면 관리와 유지보수가 어렵고, 업데이트 쿼리가 발생하는 성능 문제도 있을 수 있다.
엔티티 클래스 개발
- 예제에서는 간단하게 Getter, Setter를 모두 열지만 실무에서는 Getter를 열고 Setter는 필요한 경우만 사용하는 것을 추천
- 복잡한 구조에서 Setter는 값이 변하기 때문에 추적하기가 힘들어진다. (예제에서는 간단한 예제이므로 그냥 설정)
Member 와 Order 연관관계 (다대일)
Order
와Member
의 관계는 다대일이다. 다에 해당하는Order
가 연관관계의 주인으로 채택되며 양방향 연관관계(일에 해당하는 Member도 Order의 정보를 컬렉션으로 갖고 있다.)로 설계되어있다.- 연관관계의 하인 (
mappedBy
지정 한 필드)은 수정이 발생해도 값이 변경되지 않는다.
Order 와 Delivery 연관관계(일대일)
- 하나의 주문은 하나의 배송을 한다. 즉, 일대일 연관관계(OneToOne)로 판단할 수 있다.
- 이 때 연관관계의 주인은 access가 많은 즉, 조회할 일이 많은 곳을 선택하여 주인으로 선택한다. 해당 예제에서는 주문에 관련된 정보가 많을 것이라고 판단되었기 때문에 Order의 Delivery가 연관관계의 주인이 된다.
public enum DeliveryStatus {
READY, COMP
}
- Delivery 엔티티는
DeliveryStatus
가 ENUM 클래스로 되어있다. 해당 내용은 다음과 같으며,EnumType.STRING
을 선택해주어야 순서 보장이 된다.
Address (값 타입)
import lombok.Getter;
import javax.persistence.Embeddable;
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
// JPA의 기본 전략인 기본 생성자가 있어야 reflector 나 프록시를 사용할 수 있다. (puliic / protected 가능)
protected Address(){}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
- 값 타입을 사용하기 위해
@Embeddable
을 지정하였고, Delivery 에서 Address 컬럼에@Embedded
를 선언하였다. - 값 타입 사용 시 불변의 설계가 올바르기 때문에 생성자를 통해 생성하고, 수정을 막을 수 있도록한다. 이 때 JPA 기본 전략 때문에 기본 생성자는 추가로 만들어줘야 한다.
값 타입 (Embedded) 를 사용하는 이유는 주소라는 값 타입 아래 하위 데이터들을 명확하게 표현하기 위해서이다.
배송 엔티티 (주문정보, 도시, 도로명, 우편번호, 배송상태) -> 배송 엔티티 (주문정보, 주소, 배송상태)
Order, OrderItem, Item 연관관계 (다대다 분리)
Order
와Item
의 관계를 살펴보자. 하나의 주문은 여러 상품을 가질 수 있고, 하나의 상품은 여러 주문을 가질 수 있다. 즉, 다대다 관계이다. 다대다 관계를 풀기 위해OrderItem
을 두어 다대일, 일대다로 나누어 처리한다.- OrderItem의 입장에서 Order와 Item의 연관관계 주인이 되어있다. Order 와 OrderItem은 양방향의 연관관계가 되어있지만, OrderItem과 Item은 단방향 연관관계이기 때문에 Item에 별도의 OrderItem 컬렉션이 없는 것을 볼 수 있다.
Category, Item 연관관계 (다대다)
- Category 와 Item의 관계는 다대다이다. 별도의 CategoryItem을 만들어서 다대다를 풀어서 사용하는 것이 올바른 설계지만, 해당 경우도 예제에서 다루기 때문에 적용해보자.
- RDBMS에선 컬렉션처럼 다대다관계를 사용할 수 없기 때문에
@JoinTable()
와 같이 중간테이블을 만들어준다.joinColumns
: 현재 PK로 사용할 컬럼 지정inverseJoinColumns
: 반대편에서 PK로 사용할 컬럼 지정
- Category 구조가 계층 구조기 때문에 자기의 부모 (@ManyToOne), 자기의 자식 (@OneToMany) 즉, 자기 자신을 양방향 연관관계로 설정했다.
- Item 엔티티를 살펴보면 하위 타입들을 지정하여 싱글테이블 전략을 사용한 것을 볼 수 있다.
@Inheritance
: 상속 전략 (디폴트가 싱글테이블, JOINED, TABLE_PER_CLASS 등 존재 )@DiscriminatorColumn
: 하위 클래스를 구분하기 위한 컬럼명 지정 (부모에서 설정)- 하위 엔티티들은 Item을 상속 받고
@DiscriminatorValue()
를 지정하여 사용될 데이터를 지정
현재까지의 연관관계 설정은 엔티티 튜닝 및 성능 최적화를 사용할 수 없다. 그 이유는 모든 연관관계는 지연로딩으로 설정(즉시 로딩은 관련된 모든 데이터를 가져오기 때문)해야하기 때문이다.
@XToOne 은 기본이 즉시로딩이므로 직접 지연로딩으로 설정하자. (@ManyToOne(fetch = FetchType.LAZY))
엔티티 설계시 주의점
- 엔티티는 가급적 Setter를 사용하지 말자
- 모든 연관 관계는 지연 로딩으로 설정 - 매우 중요
- 즉시로딩(Eager)은 예측이 어렵고, 어떤 SQL이 실행될지 추적이 어렵다. JPQL에서 N+1 문제 자주 발생
- 지연로딩(LAZY)으로 꼭 설정하자.
- 연관된 엔티티를 함께 DB에서 조회해야하면, fetch join 또는 엔티티 그래프 기능 사용
- @XToOne 관계는 기본이 즉시로딩이므로 직접 지연로딩 설정을 해야 한다.
- 컬렉션은 필드에서 초기화 하자.
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>(); // 선택 Null 문제에서 안전, 하이버네이트 기능 문제 해결
/*
public Member() {
orders = new ArrayList<>();
}
*/
- 컬렉션을 초기화 하는 방법은 필드, 생성자 두 가지가 존재한다. 아래의 이유로 필드에서 초기화하는 것이 best practice라고 볼 수 있다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(team); //영속성 컨텍스트에서 관리
System.out.println(member.getOrders().getClass());
/*
- 출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag
*/
- 하이버네이트는 컬렉션이 영속성 컨텍스트에 들어오면 추적을 위해 컬렉션을 감싸 내장컬렉션으로 변경한다.
테이블, 컬럼명 생성 전략
- 스프링 부트 설정 (
엔티티-필드
->테이블-컬럼
)카멜 케이스
->언더스코어
(예 :memberPoint
->member_point
).
->_
대문자
->소문자
논리명 생성
- 논리명 생성 : 명시적으로 컬럼, 테이블명을 직접 적지않으면
ImplicitNamingStrategy
사용 spring.jpa.hibernate.naming.implicit-strategy
: 테이블이나, 컬럼명을 명시하지 않을 때 논리명 적용
물리명 적용
spring.jpa.hibernate.naming.physical-strategy
: 모든 논리명에 적용됨, 실제 테이블에 적용
CascadeType 설정
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter @Setter
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)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER , CANCEL]
}
orderItems
와delivery
필드에CascadeType.ALL
을 설정해주자.- 해당 기능은
Order
를 persist() 할 때 연관된 컬렉션들을 같이 영속화 하며, 삭제할 때 같이 삭제해주는 기능을 한다.
연관관계 편의 메서드
/*
Member member = new Member();
Order order = new Order();
member.getOrders().add(order); // 연관관계의 하인인 Member엔티티의 Orders 컬렉션에 order를 넣어주는 기능
order.setMember(member); // 연관관계의 주인인 Order 엔티티의 member 컬렉션에 order를 넣어주는 기능
*/
public void setMember(Member member) { // Order 엔티티 기준
this.member = member; // 현재의 member 컬렉션에 넣어준다.
member.getOrders().add(this); // member에서 Member 엔티티의 Orders를 탐색하여 member를 넣어준다.
}
- 양방향 연관관계를 구현했을 때 한 곳에 데이터를 생성하면 반대편에도 값을 넣어주어야한다. 해당 부분을 빠뜨리기 쉬운 부분이므로 메서드 안에서 양쪽 모두 넣어주는 기능을 구현하는 것이 바람직하다.
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter @Setter
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)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER , CANCEL]
// 연관관계 편의 메서드
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);
}
}
- Order 엔티티에서 다음과 같이 양방향 연관관계를 처리를 위해 연관관계 편의 메서드를 작성하자.
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@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);
}
}
- Category는 자기 자신을 양방향 연관관계로 지정하였고 (계층 구조) 연관관계 편의 메서드는 다음과 같다.
본 포스팅은 '인프런 - 김영한님 강의(실전! 스프링 부트와 JPA 활용1)'를 토대로 정리한 내용입니다.