본문 바로가기
BackEnd/JPA

[JPA] 도메인 분석 설계(3)

by 개발 Blog 2024. 10. 1.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

엔티티 클래스 개발 2

 

1. Category 엔티티 클래스 설계

Category 엔티티는 상품(Item)과 다대다(Many-to-Many) 관계를 맺고 있으며, 또한 부모-자식 구조를 형성할 수 있다. 이러한 계층 구조를 설계하기 위해 자기 참조 관계다대다 관계를 모두 고려한 설계를 한다.

package jpabook.jpashop.domain;

import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

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
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();

}

위 코드를 통해 알 수 있듯이, Category는 다대다 관계를 설정하기 위해 중간 테이블인 category_item을 사용한다. 또한, 부모-자식 구조를 통해 계층적인 구조를 표현하며, @ManyToOne과 @OneToMany 관계를 설정하여 각각 부모와 자식 카테고리를 관리할 수 있도록 설계한다.

 

2. Address 값 타입 설계

Address 클래스는 엔티티가 아닌 값 타입(Embeddable)으로 설계된다. JPA에서 값 타입은 불변하게 설계하는 것이 원칙이며, 값 타입은 엔티티가 아닌 단순한 객체로써, 별도의 식별자가 없고 그 자체로 값의 의미를 가진다.

 

수정 전

package jpabook.jpashop.domain;

import jakarta.persistence.Embeddable;
import lombok.Getter;

@Embeddable
@Getter
public class Address {

    private String city;
    private String street;
    private String zipCode;
}

수정 후 

값 타입은 불변으로 설계되어야 한다. 즉, 생성 시점 이후에는 변경이 불가능하게 해야 한다. 이를 위해 @Setter를 제거하고, 모든 필드는 생성자에서 초기화하도록 수정한다. 또한, JPA 스펙 상 기본 생성자가 필수이므로, 해당 생성자는 protected로 설정하는 것이 안전하다.

package jpabook.jpashop.domain;

import jakarta.persistence.Embeddable;
import lombok.Getter;

@Embeddable
@Getter
public class Address {
    private String city;
    private String street;
    private String zipcode;

    // 기본 생성자는 JPA 스펙을 위해 protected로 설정
    protected Address() {
    }

    // 모든 필드를 초기화하는 생성자
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

 

Address 클래스 설계 포인트

  • 값 타입의 불변성: 값 타입은 그 값이 변경되지 않도록 설계되어야 한다. 이를 위해 @Setter를 제거하고, 필요한 값은 모두 생성자에서 초기화한다.
  • 기본 생성자 설정: JPA 스펙 상 값 타입은 기본 생성자가 필수이다. 이 생성자는 JPA 내부에서 객체를 생성할 때 사용되며, 리플렉션을 사용해 객체를 생성하므로 protected로 설정하는 것이 안전하다.

3. 성능 최적화 및 연관관계 설정

ManyToOne, OneToOne 관계는 모두 LAZY로 설정하여 지연 로딩을 기본으로 한다. 이는 실제로 해당 데이터가 필요할 때만 불러오도록 하여 성능을 최적화하는 전략이다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;

@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Category> child = new ArrayList<>();

이처럼 모든 연관 관계는 기본적으로 LAZY로 설정하여 불필요한 쿼리 실행을 방지하고, 성능 최적화를 도모할 수 있다.

 

4. H2 실행화면

JPA를 사용하여 도메인 모델을 설계하고, 이를 기반으로 데이터베이스에 매핑된 테이블을 생성할 수 있다. 실행 후 데이터베이스에 생성된 테이블을 H2 데이터베이스 콘솔에서 확인할 수 있으며, 이 과정에서 JPA가 자동으로 만들어 준 테이블 구조를 살펴보는 것이 중요하다.

JPA가 자동으로 생성한 테이블은 기본적으로 도메인 모델의 설계에 맞게 생성되지만, 실제 운영 환경에서는 테이블을 바로 사용하는 것이 아니라 꼼꼼히 검토한 후 필요한 수정 작업을 진행하는 것이 중요하다. 예를 들어, 테이블 구조나 데이터 타입, 관계 설정 등을 비즈니스 요구 사항에 맞게 수정할 수 있다.

JPA가 자동으로 생성한 테이블을 기반으로 시스템을 구현할 때는 성능, 확장성, 관리 용이성 등을 고려하여 필요한 부분을 손봐야 한다.

 

엔티티 설계 시 주의점

엔티티를 설계할 때, 코드 유지보수성, 성능 최적화, 데이터 무결성을 고려하여 신중하게 설계해야 한다. 다음은 엔티티 설계 시 주의해야 할 몇 가지 사항들이다.

 

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

엔티티 클래스에 Setter 메서드를 무분별하게 사용하면, 객체의 상태가 어디서든지 쉽게 변경될 수 있다. 이는 코드 유지보수성에 큰 문제를 일으킬 수 있으며, 특히 리펙토링 시 발생하는 오류를 추적하기 어렵게 만든다. 따라서 엔티티 설계 시 불필요한 Setter 메서드는 제거하고, 필요한 경우 생성자나 비즈니스 메서드를 통해 객체의 상태를 변경하는 것이 바람직하다.

 

2. 모든 연관관계는 지연로딩(LAZY)으로 설정

연관관계를 설정할 때, 즉시로딩(EAGER)을 사용하는 것은 예측 불가능한 SQL 쿼리 실행으로 이어지며, 특히 N+1 문제가 발생할 가능성이 크다. 실무에서는 모든 연관관계에 대해 지연로딩(LAZY)을 기본 설정으로 하여, 필요할 때만 데이터를 로딩하도록 설계해야 한다.

 

연관관계 설정 예시

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;

필요한 경우에는 페치 조인(fetch join)이나 엔티티 그래프(EntityGraph) 기능을 활용하여 연관된 엔티티를 함께 조회할 수 있다.

 

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

컬렉션 필드(List, Set 등)는 엔티티 내에서 필드 레벨에서 바로 초기화하는 것이 안전하다. Hibernate는 엔티티를 영속화할 때, 컬렉션을 감싸서 제공하는 내장 컬렉션으로 변경하기 때문에 null 문제를 방지하기 위해서라도 컬렉션을 바로 초기화하는 것이 좋다.

private List<OrderItem> orderItems = new ArrayList<>();

위와 같이 필드에서 바로 초기화하면, 컬렉션 관련 문제를 피할 수 있으며 코드도 간결해진다. 만약 메서드 내에서 임의로 컬렉션을 생성하면, Hibernate의 내부 메커니즘에 문제가 발생할 수 있다.

 

4. 테이블, 컬럼명 생성 전략

스프링 부트는 기본적으로 하이버네이트가 제공하는 네이밍 전략을 사용하여 테이블 및 컬럼명을 자동으로 생성한다. 하이버네이트는 엔티티의 필드명을 그대로 테이블의 컬럼명으로 매핑하지만, 스프링 부트에서는 카멜 케이스를 언더스코어로 변환하는 등의 규칙을 따른다. 이를 통해 실제 테이블 필드명은 다르게 생성될 수 있다.

 

기본 네이밍 전략

  • 카멜 케이스: memberPoint -> member_point
  • 대문자 -> 소문자 변환
  • 논리명 생성: 명시적으로 테이블이나 컬럼명을 설정하지 않으면 ImplicitNamingStrategy를 사용하여 논리명을 생성
  • 물리명 적용: PhysicalNamingStrategy를 사용하여 논리명에 물리적인 네이밍 규칙을 적용
yml
spring:
  jpa:
    hibernate:
      naming:
        implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
        physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy

 

5. 엔티티 수정 예시

엔티티 설계 시, 기본 설정을 그대로 사용하는 것보다는 실무에서 발생할 수 있는 성능 문제나 유지보수 이슈를 고려해 수정하는 것이 중요하다. 특히, 연관관계 설정과 컬렉션 초기화는 효율적인 데이터베이스 접근과 객체 관리에 큰 영향을 미친다. 여기서는 Delivery, Item, Order, OrderItem 엔티티의 수정된 부분을 살펴본다.

 

1) Delivery 엔티티

수정 전

@OneToOne(mappedBy = "delivery")

기본적으로 OneToOne 관계는 즉시로딩(EAGER)으로 설정되어 있어, 관련된 엔티티를 즉시 조회하게 된다. 이는 불필요한 쿼리를 발생시키고, 성능 문제를 일으킬 수 있다.

 

수정 후

@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)

연관관계를 지연로딩(LAZY)으로 설정하여, 실제로 필요할 때만 Delivery 엔티티를 조회하도록 한다. 이를 통해 N+1 문제를 방지하고, 성능을 최적화할 수 있다.

 

2) Item 엔티티 수정

수정 전

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter
@Setter
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;
}

Item 엔티티는 상속 구조를 통해 다양한 유형의 상품을 관리하고 있으며, 상속 전략으로 SINGLE_TABLE을 사용하고 있다. 그러나 카테고리와의 다대다 관계가 누락되어 있어 카테고리 정보를 연결할 수 없다.

 

수정 후 

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter
@Setter
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

수정 후에는 Item 엔티티에 카테고리와의 다대다 관계를 추가하여, 각 아이템이 여러 카테고리에 속할 수 있게 한다. 컬렉션(categories)은 필드에서 바로 초기화하여 null 문제를 방지하고, 코드가 간결해졌다.

 

3) Order 엔티티 수정

수정 전

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

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

기존의 Order 엔티티는 연관관계 설정 시 즉시로딩(EAGER)을 사용하고 있었으며, Cascade 옵션이 설정되지 않아 연관된 엔티티들을 개별적으로 관리해야 했다.

 

수정 후

@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;

    // 연관관계 편의 메서드
    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);
    }
}

수정 후에는 지연로딩(LAZY) 설정과 CascadeType.ALL 옵션을 추가하여, 관련된 엔티티가 함께 저장/삭제되도록 변경했다. 또한, 연관관계를 보다 명확하게 관리하기 위해 연관관계 편의 메서드를 추가하여, 객체 간의 관계 설정을 한 번에 처리할 수 있도록 했다.

4) OrderItem 엔티티 수정

수정 전

@Entity
@Getter
@Setter
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;

    private int count;
}
 

OrderItem 엔티티는 Item과 Order에 대해 즉시로딩(EAGER)이 기본 설정되어 있어, 불필요한 데이터를 미리 로딩하는 문제가 발생했다.

 

수정 후

@Entity
@Getter
@Setter
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;

    private int count;
}

OrderItem 엔티티의 연관관계를 지연로딩(LAZY)으로 변경하여, 필요한 시점에만 Item과 Order 데이터를 로딩하도록 최적화했다. 이를 통해 불필요한 쿼리 실행을 방지하고 성능을 개선할 수 있다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 상품 도메인 개발  (0) 2024.10.03
[JPA] 회원 도메인 개발  (0) 2024.10.02
[JPA] 도메인 분석 설계(2)  (0) 2024.09.26
[JPA] 도메인 분석 설계(1)  (2) 2024.09.23
[JPA] 객체지향 쿼리 언어2 - 중급 문법(2)  (0) 2024.07.21