[JPA 활용 1편] 1. 프로젝트 설정 및 도메인 설계

HJ·2024년 2월 13일
0

JPA 활용 1편

목록 보기
1/4
post-thumbnail
post-custom-banner

김영한 님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 보고 작성한 내용입니다.


1. JPA 살펴보기

1-1. JPA 라이브러리

  • spring-boot-starter-data-jpa

    • spring-boot-starter-aop

    • spring-boot-starter-jdbc

      • HikariCP 커넥션 풀 ( 스프링부트 2.0 부터 기본 )

      • JDBC Driver

    • hibernate + JPA: 하이버네이트 + JPA

    • spring-data-jpa: 스프링 데이터 JPA


1-2. JPA 와 DB 설정

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop
    username: sa
    password:
    driver-class-name: org.h2.Driver

spring.datasource.driver-class-name 은 Spring 프레임워크에서 데이터베이스와의 연결을 관리하는 데 사용되는 DataSource 의 드라이버 클래스를 지정하는 속성입니다.

driver-class-name 속성을 통해 데이터베이스 연결에 사용할 JDBC 드라이버 클래스를 명시할 수 있으며 database connection 과 관련된 데이터 소스 설정이 완료됩니다.


logging.level:
  org.hibernate.SQL: debug
  org.hibernate.orm.jdbc.bind: trace # springboot 3.x, hibernate6, 파라미터 로그 출력 

bind 속성을 설정하면 아래처럼 sql 의 ? 에 어떤 것이 들어가는지 확인할 수 있습니다.

insert into member (username, id) values (?, ?)
binding parameter (1:VARCHAR) <- [memberA]
binding parameter (2:BIGINT) <- [1]


build.gradleimplementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' 를 추가하면 아래처럼 ? 안에 값이 들어간 형태의 sql 을 확인할 수 있습니다.

insert into member (username,id) values (?,?)
insert into member (username,id) values ('memberA',1);


1-3. Repository 에서 동작 확인

@Repository
public class MemberRepository {
    @PersistenceContext
    private EntityManager em;

    public Long save(Member member) {
        em.persist(member);
        return member.getId();
    }

    public Member find(Long id) {
        return em.find(Member.class, id);
    }
}

@PersistenceContext 가 있으면 SpringBoot 가 EntityManager 를 생성하고 주입해줍니다.

entity 를 저장할 때는 persist() 를, 찾을 때는 찾을 find() 메서드에 entity 클래스와 id 값을 전달합니다.


1-4. Repository Test

@Test
void testMember() {
    Member member = new Member();
    member.setUsername("memberA");

    Long savedId = memberRepository.save(member);
    Member findMember = memberRepository.find(savedId);

    Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
    Assertions.assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
    Assertions.assertThat(findMember).isEqualTo(member);
}

위의 테스트 코드를 실행하면 아래와 같은 에러가 발생합니다.

No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call

왜냐하면 EntityManger 를 통한 데이터 변경은 항상 트랜잭션 안에서 이루어져야 하기 때문입니다.

그래서 위의 테스트를 진행할 때 @Transactional 어노테이션을 붙이면 정상적으로 동작하며 @Transactional 이 test case 에 있으면 테스트가 종료된 후 바로 Rollback 을 합니다.


가장 아래쪽에 테스트 케이스가 성공하는 이유 :

같은 트랜잭션 안에서 저장을 하고 조회하면 영속성 컨텍스트가 동일합니다. 같은 영속성 컨텍스트 안에서는 id 값이 같으면 같은 Entity 로 식별합니다.

1차 캐시라고 불리는 곳에서 이미 영속성 컨텍스트에 entity 가 관리되고, 똑같은 것이 있기 때문에 기존에 관리하던 것이 조회됩니다.

그래서 위의 테스트에서 @Rollback(value = false) 를 작성한 후에 로그를 살펴보면 Insert 이후에 select 쿼리가 실행되지 않은 것을 확인할 수 있습니다.




2. 도메인 분석 설계

2-1. 도메인 모델

  1. 회원은 여러 상품을 주문할 수 있다 ➜ 1 : N

  2. 한 번 주문할 때 여러 개의 상품을 주문할 수 있다 ➜ M : N ➜ 주문 상품이라는 중간 Entity 를 추가해서 1 : N, N : 1 구조로 변환

  3. 하나의 주문은 하나의 배송 정보를 가진다 ➜ 1 : 1

  4. 상품은 카테고리( 도서, 음반, 영화 )를 가진다 ➜ 하나의 카테고리에 여러 개의 상품이 있을 수 있고, 하나의 상품이 여러 카테고리에 해당될 수 있다 ➜ M : N


2-2. Entity 분석

Member 는 Address 라는 내장타입( embedded type )을 가지고, Order 를 리스트 형태로 가집니다.

Order 와 Item 사이에 OrderItem 이라는 중간 Entity 를 두고 M : N1 : N, N : 1 로 풀어냅니다.
관계를 분리하는 것 외에도 상품을 몇 개를 담았는지 count 정보 같은 것들이 필요하기 때문에 중간 Entity 를 사용합니다.

OrderOrderItem 을 리스트 형태로 가지며, 어떤 회원이 주문했는지 알아야 하기 때문에 Member 를 가집니다. Delivery 라는 배송 정보도 가집니다.

Delivery 는 누가 어떤 주문에 의한 배송인지 알아야 하기 때문에 Order 를 가집니다.

Item 은 상속 관계로 Album, Book, Movie 를 가지고, Category 를 리스트 형태로 가집니다.

Category 는 parent, child 를 가진 계층 구조로 되어 있습니다. child 가 리스트 형태이기 때문에 parent 는 하나이고, child 는 여러 개가 가능합니다. 또한 카테고리가 가진 Item 역시 리스트로 표현됩니다.


실제 업무에서 M : N 관계를 사용하면 안되는데 예시이기 때문에 사용했다고 말씀하셨습니다.

Member 와 Order 는 서로 양방향 관계로 표현되어 있습니다. 하지만 가급적이면 양방향을 사용하지 않고 단방향을 사용해야 합니다.

회원을 통해서 주문이 일어나는 것이 아니라 주문을 생성할 때 회원이 필요한, 즉 Member 와 Order 를 동급으로 보아야 합니다.
Member 의 리스트에서 Order 를 찾는 것이 아니라 Order 에서 Member 에 필터링을 해서 조회하기 때문에 Member 가 가진 order 리스트는 필요하진 않습니다.


2-3. 테이블 연관관계

카테고리와 아이템은 M : N 으로 되어 있어서 객체에서는 카테고리가 아이템을 리스트로 가져도 되고, 아이템이 카테고리를 리스트로 가져도 됩니다.

하지만 관계형 DB 에서 일반적인 설계로는 불가능하기 때문에 중간에 매핑 테이블이 필요합니다. ( M : N 관계를 풀어냅니다 )


규칙 : 1 : N 관계에서는 무조건 N 쪽에 외래키가 존재하며 외래키가 있는 테이블을 연관관계의 주인으로 정하는 것이 좋습니다.

[ 회원과 주문 ]
1 : N, N : 1양방향 관계입니다. 따라서 연관관계의 주인을 정해야 합니다.
외래키가 존재하는 Order.member 가 연관관계의 주인이 되고, ORDERS.MEMBER_ID 외래키에 매핑합니다.
Member.orders 는 연관관계 거울이 되어 단순히 읽기만 가능하고, 연관관계의 주인 쪽에 값을 세팅해야 값이 변경됩니다.


[ 주문과 주문상품 ]
1 : N양방향 관계입니다. 외래키가 주문상품에 있기 때문에 ORDER_ITEM 이 연관관계의 주인이 됩니다.
따라서 ORDER_ITEM.orderORDER_ITEM.ORDER_ID 외래키에 매핑합니다.


[ 주문상품과 상품 ]
ORDER_ITEM 에는 ITEM 으로 가는 참조값이 있지만 ITEM 에는 ORDER_ITEM 으로 가는 참조값이 없습니다. 따라서 N : 1단방향 관계입니다.
OrderItem.itemORDER_ITEM.ITEM_ID 외래키와 매핑합니다.


[ 주문과 배송 ]
1 : 1양방향 관계입니다. 1 : 1 관계에서는 주문과 배송 둘 중 아무곳에나 외래키를 둘 수 있습니다.
테이블을 설계할 때 ORDER 에 외래키를 두었기 때문에 연관관계의 주인이 되어 Order.deliveryORDERS.DELIVERY_ID 외래키와 매핑합니다.


[ 카테고리와 상품 ]
@ManyToMany 를 사용해서 매핑합니다. ( 실무에서는 사용하지 않고 예제이기 때문에 포함되었다고 하셨습니다 )




3. Entity 개발

3-1. Address, Member, Order

[ Address ]

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

@Embeddable 은 어딘가에 내장될 수 있다는 어노테이션입니다.

값 타입은 변경 불가능하게 설계해야 합니다. 때문에 @Setter 도 선언하지 않았습니다.

[ Order ]

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

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

Order 의 입장에서는 Member 와 Order 의 관계에서 자신이 N 이기 때문에 @ManyToOne 을 사용합니다.

@JoinColumn 으로 외래키 이름을 지정할 수 있습니다. 위에서는 ORDER 테이블에 member_id 라는 컬럼이 외래키가 됩니다.

[ Member ]

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

@Embedded내장 타입을 포함한다는 어노테이션입니다.

Member 의 입장에서는 Member 와 Order 의 관계에서 자신이 1 이기 때문에 @OneToMany 를 사용합니다.

Member 와 Order 가 서로를 가지고 있는 양방향 관계이기 때문에 연관관계의 주인을 정해주어야 한다고 했으며 외래키가 있는 Order 를 연관관계의 주인으로 한다고 했습니다.
이때 양방향 관계에서의 연관관계의 주인을 지정하기 위해 mappedBy 라는 속성을 사용합니다.

mappedBy="member" 는 Order 테이블에 있는 member 라는 필드에 의해서 매핑된 것이라는 의미로, 본인이 매핑을 하는 주체가 아니고 매핑된 거울이라는 표현이며 읽기 전용이 됩니다.


3-2. Order, OrderItem, Delivery

[ OrderItem ]

@Entity
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 은 단방향 관계이고, Item 을 참조합니다.

OrderItem 과 Order 는 서로 양방향 관계인데 OrderItem 에 ORDER_ID 라는 외래키가 있기 때문에 연관관계의 주인이 됩니다.

[ Delivery ]

@Entity
public class Delivery {
    @Id
    @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @OneToOne(mappedBy = "delivery")
    private Order order;

    @Embedded
    private Address address;

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

Delivery 는 Order 와 1 : 1 관계이기 때문에 @OneToOne 을 사용하고, 양방향 관계인데 외래키를 Order 에 설정했기 때문에 mappedBy 를 사용합니다.

Enum 을 사용할 때는 @Enumerated 어노테이션을 사용해서 EnumType 을 지정해야 합니다.
EnumType 에는 ORDINALSTRING 을 넣을 수 있습니다.

ORDINAL 은 값이 숫자로 들어가는데 예를 들어, READY 와 COMP 가 있을 때 중간에 XXX 라는 속성이 추가되면 COMP 로 저장된 속성들이 모두 XXX 로 표현됩니다. 따라서 꼭 STRING 을 사용해야 합니다.

[ Order ]

@Entity
public class Order {
    ...
    private LocalDateTime orderDate;

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

    @OneToOne
    private Delivery delivery;

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문 상태 : ORDER, CANCEL
}

예전에는 Date 를 사용하면 날짜 관련된 어노테이션을 사용했어야 하는데 현재는 LocalDateTime 을 사용하면 Hibernate 가 자동으로 지원해줍니다.

Order 는 OrderItem 과의 관계에서 연관관계 거울이기 때문에 mappedBy 로 OrderItem 내부의 참조 필드를 지정해줍니다.

Order 와 Delivery 는 1 : 1 인데 Order 에 외래키를 두었기 때문에 연관관계의 주인이 됩니다.


3-3. OrderItem, Item

[ Order ]

@Entity
public class OrderItem {
    ...
    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;
}

OrderItem 과 Item 은 OrderItem 이 N 측이기 때문에 @ManyToOne 을 사용하고, 외래키 이름을 지정합니다.

[ Item ]

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

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

Item 은 추상클래스로 만들고 Book, Album, Movie 가 Item 을 상속 받도록 합니다.

상속관계에서는 상속관계 전략을 지정해주어야 하는데 @Inheritance 를 사용하며 이는 부모 클래스에서 지정합니다.

상속관계 전략에는 SINGLE_TABLE, TABLE_PER_CLASS, JOINED 가 있습니다.
JOINED 는 가장 정교화된 스타일이며, SINGLE_TABLE 은 하나의 테이블에 전부 넣는 방식이고, TABLE_PER_CLASS 는 각 엔터티 클래스마다 별도의 테이블을 생성하는 방식입니다.

위에서는 싱글 테이블 전략을 사용하는데 하나의 테이블이기 때문에 저장해둘 때 구분할 수 있는 것이 필요합니다.
@DiscriminatorColumn 을 사용하면 이 테이블에서 어떤 클래스의 데이터인지를 구별할 수 있는 컬럼을 지정할 수 있습니다.

[ Book ]

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    private String author;
    private String isbn;
}

@DiscriminatorValue@DiscriminatorColumn 과 함께 사용되며, 각각의 서브 클래스에게 실제 저장될 때 사용될 값을 명시해줍니다. 값을 지정하지 않으면 클래스명으로 저장됩니다.


실제로 Book 을 저장한 후 DB 를 확인해보면 위와 같이 DTYPE 이라는 필드명에 B 라는 값이 추가된 것을 확인할 수 있습니다.

참고로 SINGLE_TABLE 전략을 사용했기 때문에 Album 과 Movie 에 해당하는 필드에는 NULL 이 들어간 것을 확인할 수 있습니다.


3-4. Category, Item

[ Category ]

@Entity
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 와 Item 은 M : N 관계로 생성하기 때문에 @ManyToMany 를 사용합니다.
관계형 DB 에서 객체처럼 List 표현이 불가능하기 때문에 중간 테이블을 사용해서 매핑하는 것이 필요하며 @JoinTable 을 사용하여 중간 테이블을 지정합니다.

joinColumn 속성은 중간 테이블에서 Category 와 연결되는 컬럼을 지정합니다. inverseJoinColumns 은 중간 테이블에서 Item 테이블과 연결되는 컬럼을 지정합니다.
CATEGORY_ITEM 은 별도로 클래스를 생성하지 않아도 자동으로 테이블이 생성됩니다.

parent 와 child 를 보면, parent 하나에 여러 개의 category 가 올 수 있기 때문에 현재 클래스 기준으로 parent 는 @ManyToOne 이 됩니다.
또 현재 클래스 기준으로 자식 category 가 여러 개 올 수 있기 때문에 @OneToMany 을 사용하며, mappedBy 속성을 사용하여 parent 필드에 의해 매핑되는 것을 표현합니다.

[ Item ]

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
    ...
    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

Item 과 Category 는 M : N 관계로 생성하기 때문에 @ManyToMany 를 사용합니다.

이때 mappedBy 속성을 사용하여 연관관계의 거울임을 명시합니다.


3-5. 연관관계 정리

정리하자면 1 : N, N : 1 관계를 표현할 때 @OneToMany, @ManyToOne 과 같은 어노테이션을 사용합니다.

단방향 관계일 때는 어느 한 쪽에만 다른 Entity 를 참조하는 값( 객체 )을 가집니다.

양방향 관계일 때는 두 Entity 모두가 다른 Entity 를 참조하는 값( 객체 )을 가집니다.

또 양방향 관계일 때는 연관관계의 주인을 정해야 하는데 연관관계의 거울에 해당하는 Entity 의 관계 어노테이션에 mappedBy 속성을 사용하여 본인을 참조하는 필드명을 명시합니다.

M : N 관계는 실제로 사용하지는 않지만, 중간 테이블을 하나 두고, @JoinTable 을 이용해서 속성들을 지정합니다.




4. Entity 설계 시 주의점

4-1. 연관관계 지연로딩

즉시로딩( EAGER )이란 하나의 Entity 를 로딩할 때 관련된 Entity 들을 함께 로드하는 것입니다. 즉시로딩은 예측이 어렵고, 어떤 SQL 이 실행될 지 추적하기 어렵습니다.

그래서 모든 연관관계는 지연로딩으로 설정해야 하는데 @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 합니다.

또 JPQL 을 사용할 때 N + 1 문제가 자주 발생하는데, 예를 들어, Order 가 100 개가 있을 때 select 0 from Order o 라는 쿼리를 사용하면 Order 만을 가져오게 됩니다.
이때 Member 가 즉시로딩으로 설정되어 있으면 Member 를 가져오기 위해 100 개의 쿼리가 실행되게 되는데 이를 N + 1 문제라고 합니다.

연관된 Entity 를 함께 조회해야 하는 경우에는 fetch join 이나 EntityGraph 를 사용합니다.


4-2. 컬렉션 초기화

Entity 내부에서 컬렉션을 사용하는 경우가 있는데 이때 컬렉션은 필드에서 바로 초기화 하는 것이 안전합니다.

왜냐하면 임의의 메서드에서 따로 생성했을 때 발생할 수 있는 NullPointerException 에서 안전합니다.

또 하이버네이트는 엔티티를 영속화 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경합니다.

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   // 내장 컬렉션으로 변경됨

이렇게 변경하는 이유는 하이버네이트가 변경된 것을 추적해야 하기 때문에 본인이 추적할 수 있는 것으로 변경하는 것입니다.

하이버네이트가 변경을 했는데 컬렉션을 다시 변경해버리면 하이버네이트 내부 메커니즘에 문제가 발생 할 수 있습니다.


4-3. cascade

public class Order {
    ...
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
}

Order 를 살펴보면 cascade = CascadeType.ALL 로 지정되어 있습니다. 해당 속성은 엔터티 간의 연관 관계에서 부모 엔터티의 변경이나 삭제와 같은 특정 작업이 자식 엔터티에 전파되도록 지정하는 것을 의미합니다.

즉, OrderItems 에 데이터를 넣어두고, Order 를 저장하면 OrderItems 도 함께 저장되는 것입니다.

// cascade 설정 전
em.persist(orderItemA);
em.persist(orderItemB);
em.persist(orderItemC);
em.persist(order);

// 설정 후
em.persist(order);

orderItem 을 각각 persist 해주고, order 를 persist 해야 합니다. cascade 속성을 사용하게 되면 order 만 persist 해도 이를 전파하기 때문에 orderItem 도 저장됩니다.


4-4. 연관관계 편의 메서드

Member 와 Order 와 같이 양방향 관계에서 Member 가 주문을 하면 아래처럼 Member 와 Order 모두에 값을 저장해야 합니다.

Member member = new Member();
Order order = new Order();

member.getOrders().add(order);
order.setMember(member);

위의 과정을 단순화할 수 있도록 Order 에서 아래처럼 코드를 작성할 수 있습니다.

public class Order {
    // 연관관계 편의 메서드
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }
}
post-custom-banner

0개의 댓글