JPA에 대한 개념 설명(이전 강의)을 듣지 않고 이번 강의를 먼저 들으면서 JPA 활용부터 공부하고 있다. 따라서 이해가 되지 않는 부분이 엄청 많기 때문에 설명이나 코드는 이해한 부분만 적고 넘어가려고 한다. 나중에 JPA 개념 강의를 듣고 다시 복습하면서 설명을 채워 넣을 예정이다.
1. 도메인 모델과 테이블 설계
요구사항 분석
간단한 쇼핑몰을 만들어 볼 예정이다. 핵심 기능은 아래와 같다.
- 회원 기능
- 회원 등록 (POST)
- 회원 목록 조회 (GET)
- 상품 기능
- 상품 등록 (POST)
- 상품 수정 (PATCH || PUT)
- 상품 목록 조회 (GET)
- 주문 기능
- 상품 주문 (POST)
- 주문 내역 조회 (GET)
- 주문 취소 (DELETE)
- 기타 요구사항
- 상품은 재고 관리가 필요하다.
- 상품의 종류는 도서, 음반, 영화가 있다.
- 상품을 카테고리로 구분할 수 있다.
- 상품 주문 시 배송 정보를 입력할 수 있다.
도메인 모델
위의 핵심 기능을 바탕으로 도메인 모델부터 설계해 본다. 핵심 기능을 보고 개념적으로 필요한 엔티티를 생성하고 관계차수 등을 표시한다. 개념적인 엔티티 설계기 때문에 실제로 구현한다기보단 구현의 틀을 잡아둔다고 보면 된다.
회원, 주문, 상품의 관계
설명 | 관계 | 관계차수 |
회원은 여러 상품을 주문할 수 있음 | 회원:상품 | 1:N |
한 번 주문할 때 여러 상품을 선택할 수 있음 | 주문:상품 | N:M |
- 다대다 관계는 RDB는 물론이고 엔티티에서도 거의 사용하지 않는다. 따라서 다대다 관계일 경우 중간에 엔티티를 추가해 N:M 관계를 1:N 관계와 N:1 관계로 풀어내야 한다.
상품 분류
- 위에서 상품은 도서, 음반, 영화로 구분된다고 했다. 상품이라는 공통된 속성을 사용하므로 상속 구조로 나타낸다.
회원 엔티티 분석
개념적 엔티티 설계가 끝났다면 논리적인 실제 설계상 엔티티를 만들어야 한다. 이때 정규화를 진행하기도 한다. 아래 그림은 JPA에서 표현할 수 있는 모든 관계(관계차수, 상속관계 등)가 표현돼 있다.
회원(Member) | 이름과 임베디드 타입인 주소(Address), 주문리스트(orders)를 가진다. |
주문(Order) | 한 번에 여러 상품을 주문할 수 있으므로 주문과 주문상품(OrderItem)은 일대다 관계다. 주문은 상품을 주문한 회원과 배송 정보, 주문 날짜, 주문 상태(status)를 갖고 있다. 주문 상태는 열거형을 사용했고 ORDER와 CANCEL을 나타낼 수 있다. |
주문상품(OrderItem) | 주문한 상품 정보와 주문금액(orderPrice), 주문수량(count) 정보를 갖고 있다. 보통 OrderItem보단 OrderLine이나 LineItem으로 많이 표현한다. |
상품(Item) | 이름, 가격, 재고수량(stockQuantity)을 갖고 있다. 상품을 주문하면 재고수량이 감소한다. 상품의 종류로는 도서, 음반, 영화가 있는데 각각 사용하는 속성이 조금씩 다르다. |
배송(Delivery) | 주문 시 하나의 배송 정보를 생성한다. 주문과 배송은 일대일 관계다. |
카테고리(Category) | 상품과 다대다 관계를 맺는다. parent와 child로 부모, 자식 카테고리를 연결한다. |
주소(Address) | 값 타입(임베디드 타입)이다. 회원(Member)과 배송(Delivery)에서 사용한다. |
참고
회원 엔티티 분석 그림에서 Order와 Delivery가 단방향 관계로 잘못 그려져 있다. 양방향 관계가 맞는 설계다.
참고
회원이 주문을 하기 때문에, 회원이 주문리스트를 가지는 건 얼핏 보면 잘 설계한 것 같지만 객체 세상은 실제 세계와는 다르다. 실무에서는 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하다.
참고
되도록이면 양방향 관계 대신 단방향 관계로 설계하는 게 낫지만 간단한 예제기 때문에 일대다, 다대일의 양방향 연관관계를 설명하기 위해 추가했다고 보면 된다.
회원 테이블 분석
논리적 엔티티 설계가 끝났다면 물리적으로 설계해 테이블로 나타낸다. 논리적 엔티티 설계를 실제 테이블로 구성하기 위해 컬럼을 추가하고 제약조건도 설정한다. 이때 인덱스를 만들기도 한다. 점선은 비식별관계를 나타내고, 실선은 식별관계를 나타낸다.
- 식별관계 = 자식 테이블이 부모 테이블의 PK를 자신의 PK 속성에 넣는 경우
- 비식별관계 = 자식 테이블이 부모 테이블의 PK를 자신의 기본 속성에 넣는 경우
MEMBER, DELIVERY
- 회원 엔티티의 Address 임베디드 타입 정보가 회원 테이블에 그대로 들어갔다.
CATEGORY_ITEM
- 객체에서는 Item이 Category리스트를 갖든, Category가 Item리스트를 갖든 상관없지만 RDS의 경우 다대다 관계를 허용하지 않기 때문에 중간에 매핑 테이블을 둬야 한다.
ITEM
- 상속 관계를 갖던 상품 정보를 통합해서 하나의 테이블로 만들었다.
- 싱글 테이블 전략을 사용했기 때문에 한 테이블에 모든 컬럼을 넣고 DTYPE 컬럼으로 타입을 구분한다.
참고
테이블 명이 ORDER가 아니라 ORDERS인 것은 DB가 order by(정렬)를 예약어로 걸고 있는 경우가 많기 때문이다.
참고
DB 테이블 명, 컬럼 명에 대한 관례는 회사(Oracle, MySQL, PostgreSQL 등)마다 다르다. 보통은 아래 2개 중 하나를 지정해서 일관성 있게 사용한다. 강의에서 설명할 때는 객체와의 차이를 나타내기 위해 DB 테이블 명, 컬럼 명에 대문자를 사용했지만, 실제 코드로 구현할 땐 소문자 + _(underscore) 스타일을 사용할 것이다.
대문자 + _(underscore) || 소문자 + _(underscore)
💫연관관계 매핑 분석
테이블끼리 연관관계를 갖게 된다. 예를 들어 회원과 주문 엔티티를 확인해 보면 Member에 있는 orders를 통해 Order로 이동할 수 있고, Order에 있는 member로 Member로 이동할 수 있다. 이런 걸 양방향 연관관계를 맺었다고 한다. 한 테이블에서 다른 테이블로 이동할 수 없다면 단방향 연관관계라고 보면 된다.
- 일대다 연관관계에서 외래 키는 '다'인 쪽이 갖게 된다.
- 일대일 연관관계라면 외래 키를 둘 중 하나의 테이블에 넣으면 된다.
연관관계의 주인은 단순히 외래 키를 누가 관리할지 정하는 문제이기 때문에 비즈니스상 우위에 있다고 연관관계의 주인으로 정하면 안 된다. (💫기본편 보고 추가 정리 필요)
회원(Member) & 주문(Orders) | 일대다, 다대일의 양방향 관계다. 따라서 연관관계의 주인을 정해야 하는데, 외래 키가 있는 주문(Order)를 연관관계의 주인으로 정하는 것이 좋다. 그러므로 Order.member를 ORDERS.MEMBER_ID 외래 키와 매핑한다. |
주문상품(OrderItem) & 주문(Orders) | 다대일 양방향 관계다. 외래 키가 주문상품에 있으므로 주문상품이 연관관계의 주인이다. 그러므로 OrderItem.order를 ORDER_ITEM.ORDER_ID 외래 키와 매핑한다. |
주문상품(OrderItem) & 상품(Item) | 다대일 단방향 관계다. OrderItem.item을 ORDER_ITEM.ITEM_ID 외래 키와 매핑한다. |
주문(Order) & 배송(Delivery) | 일대일 양방향 관계다. Order.delivery를 ORDERS.DELIVERY_ID 외래 키와 매핑한다. |
카테고리(Category) & 상품(Item) | @ManyToMany를 사용해서 매핑한다. |
참고
실무에서 @ManyToMany는 사용하지 말자. 여기서는 다대다 관계를 예제로 보여주기 위해 추가한 것이다.
2. 엔티티 클래스 개발
지금 예제에서는 쉽게 설명하기 위해 엔티티 class에 Getter, Setter를 모두 열고 최대한 단순하게 설계한다. 그러나 실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천한다.
참고
이론적으로는 Getter와 Setter 모두 제공하지 않고 꼭 필요한 경우 별도의 메서드를 제공하는 게 이상적이다.
- Setter를 필요한 경우에만 사용하는 이유: 보통 데이터를 변경해야 할 때 Setter를 호출하기 때문에 열어두면 엔티티가 왜 변경되는지 추척하기 힘들어지기도 한다. 따라서 엔티티를 변경할 땐 Setter 대신 별도의 비즈니스 메서드를 만들어 제공하는 게 좋다.
엔티티 설계
테이블 설계 시 @Column(name = "")를 통해 테이블에 저장될 이름을 지정할 수 있다. 아래 코드를 보면 엔티티의 식별자로 id를 사용하고, PK 컬럼명은 member_id를 사용한다. 엔티티는 타입(ex. Member)이 있기 때문에 id 필드만으로 쉽게 구분할 수 있지만, 테이블은 타입이 없으므로 구분하기 어렵다.
- 테이블은 관례상 테이블명 + id를 많이 사용한다.
- 참고로 객체에서 id 대신에 memberId를 사용해도 된다. 중요한 것은 일관성이다.
- member_id로 지정하는 게 DBA에서 선호하는 방식이기도 하다.
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
연관관계 매핑 설정
a. 일대다, 다대일 양방향 연관관계
🫠💫 논리적 엔티티 설계를 보면 회원과 주문이 일대다, 다대일 양방향 연관관계라는 것을 확인할 수 있다. 한쪽에서 다른 쪽의 값을 참조해 이동할 수 있다는 뜻이다. 이때 JPA는 한쪽의 값이 바뀐 경우 어떤 쪽의 외래 키 값을 변경해야 하는지 혼란이 오기 때문에 연관관계 주인을 설정함으로써 회원이나 주문의 값이 바뀐 경우 연관관계의 주인 쪽의 값을 변경하게 된다. 물리적 테이블 설계를 보면 주문 테이블에 외래 키가 있기 때문에 연관관계의 주인은 주문 테이블에 있는 member로 설정하면 된다.
- 연관관계 주인을 설정해두지 않으면 회원 테이블만 변경된 경우에도 주문 테이블까지 변경되게 된다. 또는 성능 이슈도 생길 수 있다.
b. 일대일 양방향 연관관계
일대일 양방향 연관관계를 갖는 테이블(주문과 배송)의 경우 연관관계의 주인을 두 테이블 중 하나로 설정하면 된다. 물론 어디에 두냐에 따라 장단점이 있다. 주로 접근을 많이 하는 테이블에 두는 게 좋다.
c. 다대다 양방향 연관관계
상품과 카테고리는 다대다 매핑을 해야 한다. @ManyToMany를 사용해 나타낼 수 있다. 객체는 각자 List를 갖게 하면 되기 때문에 다대다 관계를 나타낼 수 있지만, RDB는 양쪽에 리스트를 갖게 할 수 없기 때문에 중간에 매핑 테이블을 만들어야 한다. @JoinTable 애노테이션을 사용해 매핑 테이블의 이름과 양쪽 테이블에서 조인할 컬럼들의 이름을 적어서 만들 수 있다.
참고
@ManyToMany는 편리한 것 같지만, 중간 테이블(CATEGORY_ITEM)에 컬럼을 추가할 수 없고, 세밀하게 쿼리를 실행하기 어렵기 때문에 실무에서 사용하기엔 한계가 있다. 중간 엔티티(CategoryItem)를 만들고 @ManyToOne과 @OneToMany로 매핑해서 사용하자.
다대다 매핑은 일대다와 다대일 매핑으로 쪼개자.
내부에서 계층 구조를 갖는 테이블
카테고리는 부모와 자식처럼 계층 구조를 갖고 있다. (ex. 수입 - 월급, 용돈, 부업, ...) 일대다, 다대일 테이블을 매핑하는 것과 비슷하게 필드에 @ManyToOne과 @OneToMany를 붙이면 된다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
상속 관계를 갖는 테이블
상속 관계를 갖는 테이블 있을 경우 @Inheritance 애노테이션을 사용해 나타낸다. 이때 strategy 속성에 SINGLE_TABLE, TABLE_PER_CLASS, JOINED 등을 넣어 어떻게 상속할 것인지 나타낼 수 있다.
- SINGLE_TABLE: 하나의 테이블로 나타냄
- TABLE_PER_CLASS: CLASS마다 각각의 테이블로 나타냄
- JOINED: 테이블을 가장 정규화된 스타일로 나타냄
열거형 타입
열거형(enum) 타입 사용 시 필드 위에 @Enumerated 애노테이션을 붙인다. 이때 EnumType에 ORDINAL과 STRING을 넣을 수 있다.
- 기본값은 ORDINAL로, 이렇게 설정하면 1, 2, 3, ...처럼 숫자 형식으로 들어간다. 이때 1과 2 사이에 새로운 값을 넣게 되면 당연히 뒤에 있는 모든 값이 꼬이게 되기 때문에, 되도록이면 EnumType은 STRING을 사용해야 한다.
값 타입
회원과 배송 테이블에서 값 타입을 사용한다는 표시로 @Embedded 애노테이션을 사용한다. 임베디드 타입 class인 주소엔 @Embeddable 애노테이션을 붙인다. 이때 둘 중 하나만 표시해도 값 타입을 사용했음을 알릴 수 있지만, 더 확실하게 표현하기 위해 둘 다 사용해도 된다.
참고
값 타입은 변경할 수 없게 설계해야 한다.
@Setter를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 class를 만들자. JPA 스펙상 엔티티나 임베디드 타입(@Embeddable)은 자바 기본 생성자를 public이나 protected로 설정해야 한다. (public보단 protect로 설정하는 게 그나마 더 안전하다.)
JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플렉션 같은 기술을 사용할 수 있기 때문이다.
3. 엔티티 설계 시 주의점
Setter 사용 지양 & 연관관계 편의 메서드
엔티티에는 가급적 Setter를 사용하지 말자.
지금 예제는 Setter가 모두 열려있지만 이렇게 설계하면 변경 포인트가 너무 많아서 데이터가 어디서 언제 변경되는지 예측할 수 없게 된다. 따라서 유지 보수가 어려워지기 때문에 나중에 리팩토링으로 Setter를 제거할 것이다. Setter를 사용하기보단 아래 코드처럼 비즈니스 메서드를 따로 등록하는 것이 좋다.
양방향 연관관계를 갖는 두 엔티티에서 한쪽에 값을 넣을 땐, 양쪽에 모두 추가해야 한다. 이때 연관관계 편의 메서드를 만들어 한 번에, 원자적으로 처리하는 게 좋다. 비즈니스 로직을 컨트롤하는 엔티티에 메서드를 추가해 놓는 게 좋다.
// 회원과 주문 연관관계 메서드
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);
}
💫연관관계 지연로딩
모든 연관관계는 지연로딩으로 설정하자.
즉시로딩(FetchType.EAGER)은 예측하기 어렵고, 어떤 SQL이 실행될지 추적하기도 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다. 실무에서는 모든 연관관계는 지연로딩(FetchType.LAZY)으로 설정해야 한다.
연관된 엔티티를 함께 DB에서 조회해야 하면, 내가 선택한 연관된 엔티티만 같이 가져올 수 있는 fetch join 또는 엔티티 그래프 기능을 사용한다.
@OneToOne와 @ManyToOne 관계는 기본값이 즉시로딩(FetchType.EAGER)이므로 직접 지연로딩(FetchType.LAZY)으로 설정해야 한다.
참고: @OneToMany와 @ManyToMany 관계는 기본값이 지연로딩(FetchType.LAZY)이다.
즉시로딩이란?
- 예를 들면, 회원 조회를 할 때 연관된 주문까지 모두 조회해 로딩해 두는 것이다. 최악의 경우엔 연관된 주문에 또 연관된 것들을 DB에서 모두 로딩하게 된다. 따라서 예측이나 최적화 관점에서 문제가 생긴다.
N+1 문제란?
- 예를 들면, 주문에서 JPQL로 SELECT o FROM order o;라는 쿼리를 1건 실행한 결과가 N개의 행을 갖는다고 하면, 즉시로딩으로 설정한 관계에 있는 필드(ex. 회원)에 대해 쿼리가 N건 발생하게 된다. 쿼리를 1건만 실행했지만 총결과는 N+1건이 된다고 해서 N+1 문제라고 불린다.
@ManyToOne(fetch = FetchType.LAZY) // 다대일 -> 지연로딩 설정
@JoinColumn(name = "member_id") // '다'인 쪽이 연관관계의 주인, 외래 키로 설정
private Member member; // 주문 회원
컬렉션 필드 초기화
컬렉션은 필드에서 바로 초기화하는 것이 null 문제에서 안전하다.
하이버네이트는 엔티티를 영속화할 때, 컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders()처럼 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 따라서 필드 레벨에서 생성하는 것이 가장 안전하고 코드도 간결하다. 하이버네이트가 원하는 메커니즘으로 동작하게 하려면 객체 생성할 때 생성해 두고 컬렉션 자체를 바꾸지 않는 게 좋다.
Member member = new Member();
System.out.println(member..getOrders().getClass());
// 출력 결과 -> class java.util.ArrayList
em.persist(member);
System.out.println(member.getOrders().getClass());
// 출력 결과 -> class org.hibernate.collection.internal.PersistentBag
CascadeType.ALL이란?
- 아래 코드를 보면 orderItems가 order와 연관돼 있다. 모든 엔티티는 기본적으론 하나의 엔티티를 persist 하고 싶으면 연관된 엔티티도 각각 persist 해줘야 한다. 그러나 CascadeType.ALL로 설정했을 때 order를 persist 하게 되면 연관된 orderItems도 전부 persist 된다. 물론 order가 delete 돼 동일하게 작동한다.
- 두 엔티티가 서로만 참조하고, 라이프 사이클에 대해 동일하게 관리하는 경우엔 CascadeType.ALL을 적용하는 것이 좋다.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>(); // 컬렉션은 필드에서 초기화
테이블, 컬럼 명 생성 전략
스프링 부트에서 하이버네이트 기본 매핑 전략을 변경했기 때문에 실제 테이블 필드명은 다를 수 있다.
- 하이버네이트 기존 구현 = 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용 (SpringPhysicalNamingStrategy)
스프링 부트 신규 설정 (논리적: 엔티티(필드) -> 물리적: 테이블(컬럼))
1. 카멜 케이스 → 언더스코어 (memberPoint -> member_point)
2. .(점) → _(언더스코어)
3. 대문자 → 소문자
적용 2단계
- 논리명 생성: 명시적으로 테이블, 컬럼 명을 직접 적지 않으면 ImplicitNamingStrategy 사용
- spring.jpa.hibernate.naming.implicit-strategy = 테이블이나 컬럼 명을 명시하지 않을 때 논리명 적용
- 물리명 적용
- spring.jpa.hibernate.naming.physical-strategy = 모든 논리명에 적용되며, 실제 테이블에 적용 (name → XXname 등으로 회사 룰에 맞게 바꿀 수 있음)
스프링 부트 기본 설정
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.SpringPhysicalNamingStrategy
4. 애플리케이션 구현 준비
구현 요구사항
핵심 기능은 위에서 설명했었다. 화면(웹 계층)보단 핵심 비즈니스 메서드를 구현하는 걸 우선한다.
예제를 단순화하기 위해 아래 기능은 구현하지 않거나 단순화한다.
- 로그인과 권한 관리
- 파라미터 검증과 예외 처리
- 상품은 도서만 사용
- 카테고리
- 배송 정보
애플리케이션 아키텍처
구조는 아래와 같다.
계층형 구조 사용
controller, web | 웹 계층, 리포지토리에 직접 접근 가능 |
service | 비즈니스 로직, 트랜잭션 처리 |
repository | JPA를 직접 사용하는 계층, 엔티티 매니저 사용 |
domain | 엔티티가 모여 있는 계층, 모든 계층에서 사용 |
패키지 구조
jpabook.jpashop
|
---- domain
|
---- exception
|
---- repository
|
---- service
|
---- web
개발 순서
1. 핵심 비즈니스 계층 (domain, repository, service)
2. 테스트 케이스 검증 (test)
3. 웹 계층(web)