도메인 모델과 테이블 설계
참고: 테이블명이 `ORDER` 가 아니라 `ORDERS` 인 것은 데이터베이스가 `order by` 때문에 예약어로 잡고 있는 경우가 많다. 그래서 관례상 `ORDERS` 를 많이 사용한다.
참고: 실제 코드에서는 DB에 소문자 + _(언더스코어) 스타일을 사용
데이터베이스 테이블명, 컬럼명에 대한 관례는 회사마다 다르다.
보통은 대문자 + _(언더스코어)나 소문자 + _(언 더스코어) 방식 중에 하나를 지정해서 일관성 있게 사용한다.
연관관계 매핑 분석
참고: 외래 키가 있는 곳을 연관관계의 주인으로 정해라.
연관관계의 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스상 우위에 있다고 주인으로 정하면 안 된다.
예를 들어서 자동차와 바퀴가 있으면, 일대다 관계에서 항상 다쪽에 외래 키가 있으므로 외래 키가 있는 바퀴를 연관관계의 주인으로 정하면 된다. 물론 자동차를 연관관계의 주인으로 정하는 것이 불가능한 것은 아니지만, 자동차를 연관관계의 주인으로 정하면 자동차가 관리하지 않는 바퀴 테이블의 외래 키 값이 업데이트되므로 관리와 유지보수가 어렵고, 추가적으로 별도의 업데이트 쿼리가 발생하는 성능 문제도 있다.
엔티티 클래스 개발
실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천
참고: 이론적으로 Getter, Setter 모두 제공하지 않고, 꼭 필요한 별도의 메서드를 제공하는 게 가장 이상적이다.
하지만 실무에서 엔티티의 데이터는 조회할 일이 너무 많으므로, Getter의 경우 모두 열어두는 것이 편리하다.
Getter는 아무리 호출해도 호출하는 것 만으로 어떤 일이 발생하지는 않는다.
하지만 Setter는 문제가 다르다. Setter를 호출하면 데이터가 변한다.
Setter를 막 열어두면 가까운 미래에 엔티티가 도대체 왜 변경되는지 추적하기 점점 힘들어진다.
그래서 엔티티를 변경할 때는 Setter 대신에 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 한다.
회원 엔티티
@Entity
@Getter
@Setter
public class Member {
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
}
참고: 엔티티의 식별자는 id를 사용하고 PK 컬럼명은 member_id를 사용했다.
엔티티는 타입(여기서는 Member)이 있으므로 id 필드만으로 쉽게 구분할 수 있다.
예를 들어 member.id 이런 식으로 어디 소속인지 명확하다.
테이블은 타입이 없으므로 구분이 어렵다. 그래서 테이블은 단순하게 id를 하면 찾기가 쉽지 않다.
그리고 테이블은 관례상 테이블명 + id를 많이 사용한다.
참고로 객체에서 id 대신에 memberId를 사용해도 된다. 중요한 것은 일관성이다.
order과 delivery의 일대일 관계
@Entity
@Getter
@Setter
public class Delivery {
@Id
@Column(name = "delivery_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY, mappedBy = "delivery")
private Order order;
...
}
@Entity
@Getter
@Setter
@Table(name = "orders")
public class Order {
@Id
@Column(name = "order_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
...
}
1:1 관계에서는 FK를 아무 데나 둬도 상관없다. 어디에 두냐에 따라 장단점이 있다.
선호하는 스타일은 접근을 많이 하는 곳에 둔다. 예를 들어 delivery를 가지고 order을 조회하는 일은 거의 없고 order을 가지고 delivery를 조회하는 일이 많을 경우 order에 FK를 둔다. 그래서 FK를 가지고 있는 order을 연관관계의 주인으로 설정하면 된다.
category와 item 다대다 관계
@Entity
@Getter
@Setter
public class Category {
@Id
@Column(name = "catrgory_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@ManyToMany
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
}
@Entity
@Getter
@Setter
@DiscriminatorColumn(name = "dtype")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class Item {
@Id
@Column(name = "item_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
@JoinTable에 쓰인 속성값들은 위 이미지에서 CATEGORY_ITEM 테이블로 보면 된다.
참고: 실무에서는 @ManyToMany를 사용하지 말자
@ManyToMany는 편리한 것 같지만, 중간 테이블(CATEGORY_ITEM)에 컬럼을 추가할 수 없고, 세밀하게 쿼리를 실행하기 어렵기 때문에 실무에서 사용하기에는 한계가 있다. 중간 엔티티(CategoryItem)를 만들고 @ManyToOne, @OneToMany로 매핑해서 사용하자. 정리하면 다대다 매핑을 일대다, 다대일 매핑으로 풀어내서 사용하자.
category 계층 구조
@Entity
@Getter
@Setter
public class Category {
@Id
@Column(name = "catrgory_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
}
parent는 category의 부모고 child는 category의 자식이다.
위 상태는 셀프로 양방향 연관관계를 설정한 상태다.
FK를 반드시 걸어야 하는가?
시스템마다 다르다.
실시간 트래픽이 엄청 중요하고 정합성보다는 잘 서비스되는 유연한 게 중요하면 FK를 빼고 인덱스만 잘 잡아주면 된다. 근데 돈과 관련된 것처럼 매우 중요하고 데이터가 항상 맞아야 되면 FK를 거는 게 좋을 수 있다.
주소 값 타입
@Embeddable
@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를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자.
JPA 스펙상 엔티티나 임베디드 타입(@Embeddable)은 자바 기본 생성자(default constructor)를 public 또는 protected로 설정해야 한다. public으로 두는 것보다는 protected로 설정하는 것이 그나마 더 안전하다.
JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.
엔티티 설계 시 주의점
엔티티에는 가급적 Setter를 사용하지 말자
- Setter가 모두 열려있으면 변경 포인트가 너무 많아서, 유지보수가 어렵다.
- 나중에 리펙토링으로 Setter 제거
모든 연관관계는 지연로딩으로 설정!
- 즉시로딩( `EAGER` )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다.
- 최악의 경우 연관된 엔티티들을 다 조회하는 select 문이 발생한다.
- 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
- 실무에서 모든 연관관계는 지연로딩( `LAZY` )으로 설정해야 한다.
- 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
- @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
컬렉션은 필드에서 초기화하자.
컬렉션은 필드에서 바로 초기화하는 것이 안전하다.
- `null` 문제에서 안전하다.
- 하이버네이트는 엔티티를 영속화(persist) 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.
- 감싸는 이유는 하이버네이트가 컬렉션이 변경된걸 추적해야 되기 때문에 하이버네이트가 추적할 수 있는 본인의 내장 컬렉션으로 변경한다.
- 문제는 getOrders()처럼 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생 할 수 있다.
- 따라서 필드레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다.
- 필드 레벨에서 생성한 컬렉션을 바꾸지 말고 사용하는게 안전하다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(member);
System.out.println(member.getOrders().getClass());
//출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag
테이블, 컬럼명 생성 전략
스프링 부트에서 하이버네이트 기본 매핑 전략을 변경해서 실제 테이블 필드명은 다름
- https://docs.spring.io/spring-boot/docs/2.1.3.RELEASE/reference/htmlsingle/#howto-configure-hibernate-naming-strategy
- http://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#naming
하이버네이트 기존 구현: 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용했다.
(SpringPhysicalNamingStrategy)
스프링 부트 신규 설정은 아래의 전략을 사용한다.(엔티티(필드) -> 테이블(컬럼))
- 카멜 케이스 -> 언더스코어(예시 : memberPoint -> member_point)
- .(점) -> _(언더스코어)
- 대문자 -> 소문자
적용 2 단계
- 논리명 생성: 명시적으로 컬럼, 테이블명을 직접 적지 않으면 ImplicitNamingStrategy 사용spring.jpa.hibernate.naming.implicit-strategy: 테이블이나, 컬럼명을 명시하지 않을 때 논리명 적용
- 물리명 적용:
spring.jpa.hibernate.naming.physical-strategy: 모든 논리명에 적용됨, 실제 테이블에 적용 (username usernm 등으로 회사 룰로 바꿀 수 있음)
스프링 부트 기본 설정
spring.jpa.hibernate.naming.implicit-strategy:
org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy spring.jpa.hibernate.naming.physical-strategy:
org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrateg
연관관계 편의 메서드
@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(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@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 객체를 생성하고 필드에 값을 넣을 때 당연히 member에도 값을 넣어야 한다.
이때 만약에 Order와 Member가 양방향 관계라면 Order에 member 값을 넣을 시 Member에 있는 orders에도 값을 넣어야 한다.
Member member = new Member();
Order order = new Order();
member.getOrders().add(order);
order.setMember(member);
따라서 비즈니스 로직상에서 위와 같이 코드를 작성하게 된다.
문제는 member.getOrders().add(order) 코드 부분을 깜박할 수 있고 order에 대한 데이터 값을 넣고 있는데 중간에 Member에 있는 orders 필드에 값을 넣어 맥락이 흐려져 코드가 지저분해 보인다. 그래서 member와 orders에 값을 넣는 부분을 원자적으로 딱 묶는 메서드를 만드는 건데 이 메서드를 연관관계 편의 메서드라고 부른다.
연관 관계 편의 메서드의 위치는 둘 중에 좀 더 주가 되는 부분에 작성한다.
'JPA > JPA 활용 1' 카테고리의 다른 글
7. 웹 계층 개발 (0) | 2024.08.22 |
---|---|
6. 주문 도메인 개발 (0) | 2024.08.21 |
5. 상품 도메인 개발 (0) | 2024.08.20 |
4. 회원 도메인 개발 (0) | 2024.08.20 |
1. 프로젝트 환경설정 (0) | 2024.08.20 |