김영한 님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 보고 작성한 내용입니다.
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
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.gradle
에 implementation '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);
@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 값을 전달합니다.
@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 쿼리가 실행되지 않은 것을 확인할 수 있습니다.
회원은 여러 상품을 주문할 수 있다 ➜ 1 : N
한 번 주문할 때 여러 개의 상품을 주문할 수 있다 ➜ M : N
➜ 주문 상품이라는 중간 Entity 를 추가해서 1 : N
, N : 1
구조로 변환
하나의 주문은 하나의 배송 정보를 가진다 ➜ 1 : 1
상품은 카테고리( 도서, 음반, 영화 )를 가진다 ➜ 하나의 카테고리에 여러 개의 상품이 있을 수 있고, 하나의 상품이 여러 카테고리에 해당될 수 있다 ➜ M : N
Member 는 Address 라는 내장타입( embedded type )을 가지고, Order 를 리스트 형태로 가집니다.
Order 와 Item 사이에 OrderItem 이라는 중간 Entity 를 두고 M : N
을 1 : N
, N : 1
로 풀어냅니다.
관계를 분리하는 것 외에도 상품을 몇 개를 담았는지 count 정보 같은 것들이 필요하기 때문에 중간 Entity 를 사용합니다.
Order 는 OrderItem 을 리스트 형태로 가지며, 어떤 회원이 주문했는지 알아야 하기 때문에 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 리스트는 필요하진 않습니다.
카테고리와 아이템은 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.order
를 ORDER_ITEM.ORDER_ID
외래키에 매핑합니다.
[ 주문상품과 상품 ]
ORDER_ITEM 에는 ITEM 으로 가는 참조값이 있지만 ITEM 에는 ORDER_ITEM 으로 가는 참조값이 없습니다. 따라서 N : 1
의 단방향 관계입니다.
OrderItem.item
을 ORDER_ITEM.ITEM_ID
외래키와 매핑합니다.
[ 주문과 배송 ]
1 : 1
의 양방향 관계입니다. 1 : 1
관계에서는 주문과 배송 둘 중 아무곳에나 외래키를 둘 수 있습니다.
테이블을 설계할 때 ORDER 에 외래키를 두었기 때문에 연관관계의 주인이 되어 Order.delivery
를 ORDERS.DELIVERY_ID
외래키와 매핑합니다.
[ 카테고리와 상품 ]
@ManyToMany
를 사용해서 매핑합니다. ( 실무에서는 사용하지 않고 예제이기 때문에 포함되었다고 하셨습니다 )
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
}
@Embeddable
은 어딘가에 내장될 수 있다는 어노테이션입니다.
값 타입은 변경 불가능하게 설계해야 합니다. 때문에 @Setter
도 선언하지 않았습니다.
@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 라는 컬럼이 외래키가 됩니다.
@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 라는 필드에 의해서 매핑된 것이라는 의미로, 본인이 매핑을 하는 주체가 아니고 매핑된 거울이라는 표현이며 읽기 전용이 됩니다.
@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 라는 외래키가 있기 때문에 연관관계의 주인이 됩니다.
@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 에는 ORDINAL 과 STRING 을 넣을 수 있습니다.
ORDINAL 은 값이 숫자로 들어가는데 예를 들어, READY 와 COMP 가 있을 때 중간에 XXX 라는 속성이 추가되면 COMP 로 저장된 속성들이 모두 XXX 로 표현됩니다. 따라서 꼭 STRING 을 사용해야 합니다.
@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 에 외래키를 두었기 때문에 연관관계의 주인이 됩니다.
@Entity
public class OrderItem {
...
@ManyToOne
@JoinColumn(name = "item_id")
private Item item;
}
OrderItem 과 Item 은 OrderItem 이 N 측이기 때문에 @ManyToOne
을 사용하고, 외래키 이름을 지정합니다.
@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
을 사용하면 이 테이블에서 어떤 클래스의 데이터인지를 구별할 수 있는 컬럼을 지정할 수 있습니다.
@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 이 들어간 것을 확인할 수 있습니다.
@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 필드에 의해 매핑되는 것을 표현합니다.
@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
속성을 사용하여 연관관계의 거울임을 명시합니다.
정리하자면 1 : N
, N : 1
관계를 표현할 때 @OneToMany
, @ManyToOne
과 같은 어노테이션을 사용합니다.
단방향 관계일 때는 어느 한 쪽에만 다른 Entity 를 참조하는 값( 객체 )을 가집니다.
양방향 관계일 때는 두 Entity 모두가 다른 Entity 를 참조하는 값( 객체 )을 가집니다.
또 양방향 관계일 때는 연관관계의 주인을 정해야 하는데 연관관계의 거울에 해당하는 Entity 의 관계 어노테이션에 mappedBy 속성을 사용하여 본인을 참조하는 필드명을 명시합니다.
M : N
관계는 실제로 사용하지는 않지만, 중간 테이블을 하나 두고, @JoinTable
을 이용해서 속성들을 지정합니다.
즉시로딩( 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 를 사용합니다.
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 // 내장 컬렉션으로 변경됨
이렇게 변경하는 이유는 하이버네이트가 변경된 것을 추적해야 하기 때문에 본인이 추적할 수 있는 것으로 변경하는 것입니다.
하이버네이트가 변경을 했는데 컬렉션을 다시 변경해버리면 하이버네이트 내부 메커니즘에 문제가 발생 할 수 있습니다.
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 도 저장됩니다.
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);
}
}