ORM JPA6

유요한·2023년 12월 1일
0

JPA

목록 보기
6/10
post-thumbnail

이 공부는 인프런의 김영한 강사님의 수업에서 공부한 것입니다.

김영한 강사님 수업보기

여기서는 Spring Data JPA를 사용하는 것이 아니라 ORM JPA를 사용한 것이다. 추후에 Spring Data JPA를 사용할 예정입니다.


jpa를 사용하면 (?, ?)로 값이 보이는데 불편하다.

이 때,

logging:
  level:
    org.hibernate.sql: debug
    org.hibernate.type: trace

application.yml에 이 코드를 적으면 맨 끝에 나오기는 해도 만족스럽지가 못하다. 그럴 때 오픈소스인 이 코드를 적으면 됩니다.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'


JPA와 DB 동작 확인

테스트에서 JPA와 DB가 제대로 동작하는지 확인을 해보자

@SpringBootTest
@ExtendWith(SpringExtension.class)
@Log4j2
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;

    @Test
    // 테스트 메서드에 @Transactional을 사용하면 트랜잭션으로 감싸지며, 메서드가 종료될 때 자동으로 롤백된다.
    // 이 어노테이션이 테스트에 있으면 수행하고 db를 롤백을 한다.
    // 테스트 말고 다른 곳에 있으면 정상적으로 동작을 한다.
    @Transactional
    @Rollback(false)
    public void testMember() throws Exception {
        // given
        Member member = Member.builder()
                .userName("memberA")
                .build();

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

        // then
        Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
        Assertions.assertThat(findMember.getUserName()).isEqualTo(member.getUserName());
        Assertions.assertThat(findMember).isEqualTo(member);

        // 같은 트랜잭션에서 돌아가기 때문에 영속성 컨텍스트가 같다.
        // 같은 영속성 컨텍스트 안에서는 아이디 값이 같으면 같은 엔티티로 식별한다.
        // 1차 캐쉬에서 기존에 관리하던거에서 꺼내온것이다.
        log.info("findMember == member : " + (findMember == member));
    }
}

여기서 @Rollback(value = false)가 없으면 @Transactional 때문에 성공해도 값이 안담기지만 여기서는 추가했기 때문에 성공을 하고 rollback되지 않고 값이 담긴다.

그러면 의문이 들 수 있다.

@Transactional란 무엇일까?

@Transactional

주석을 보면 알듯이 테스트 환경에서 @Transactional를 추가하면 rollback을 시켜준다. 하지만 일반적으로 @Transactional를 구성하면 메소드를 실행할 때 오류 발생 시 전체 실행 내용을 롤백 시켜준다. 트랜잭션은 Spring AOP를 통해 구현되어있다. 더 정확하게 말하면, 어노테이션 기반 AOP를 통해 구현되어있다. (import문을 보면 알 수 있다)

특징

  • 클래스, 메소드에 @Transactional이 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체 생성

  • 프록시 객체는 @Transactional이 포함된 메서드가 호출될 경우, 트랜잭션을 시작하고 Commit or Rollback을 수행

  • CheckedException or 예외가 없을 때는 Commit

  • UncheckedException이 발생하면 Rollback

주의점

  1. 우선순위
    @Transactional은 우선순위를 가지고 있다. 클래스 메서드에 선언된 트랜잭션의 우선순위가 가장 높고, 인터페이스에 선언된 트랜잭션의 우선순위가 가장 낮다.

    따라서 공통적인 트랜잭션 규칙은 클래스에, 특별한 규칙은 메서드에 선언하는 식으로 구성할 수 있다. 또한, 인터페이스 보다는 클래스에 적용하는 것을 권고한다.
  • 인터페이스나 인터페이스의 메서드에 적용할 수 있다. 하지만, 인터페이스 기반 프록시에서만 유효한 트랜잭션 설정이 된다.
  • 자바 어노테이션은 인터페이스로부터 상속되지 않기 때문에 클래스 기반 프록시 or AspectJ 기반에서 트랜잭션 설정을 인식 할 수 없다.
  1. 트랜잭션의 모드
    @Transactional은 Proxy Mode와 AspectJ Mode가 있는데 Proxy Mode가 Default로 설정되어있다. Proxy Mode는 다음과 같은 경우 동작하지 않는다.
  • 반드시 public 메서드에 적용되어야한다.
    • Protected, Private Method에서는 선언되어도 에러가 발생하지는 않지만, 동작하지도 않는다.
    • Non-Public 메서드에 적용하고 싶으면 AspectJ Mode를 고려해야한다.
  • @Transactional이 적용되지 않은 Public Method에서 @Transactional이 적용된 Public Method를 호출할 경우, 트랜잭션이 동작하지 않는다.

설정


쿼리 파라미터 로그 남기기

외부 라이브러리

스프링 부트를 사용하면 라이브러리만 추가하면 된다.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.6'

참고: 쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하므로, 개발 단계에서는 편하게 사용해도 된다. 하지만 운영시스템에 적용하려면 꼭 성능테스트를 하고 사용하는 것이 좋다.

3.0 이상의 버전에서는 라이브러리 버전을 1.9.0 이상을 사용해야 한다.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'

도메인 분석 설계

이 파트에서 공부할 것은 shop을 만드는 것입니다.

회원 엔티티 분석

참고: 실제 코드에서는 DB에 소문자 + _(언더스코어) 스타일을 사용하겠다.

데이터베이스 테이블명, 컬럼명에 대한 관례는 회사마다 다르다. 보통은 대문자 + _(언더스코어)나 소문자 + _(언더스코어) 방식 중에 하나를 지정해서 일관성 있게 사용한다.


엔티티 클래스 개발

  • 예제에서는 설명을 쉽게하기 위해 엔티티 클래스에 Getter, Setter를 모두 열고, 최대한 단순하게 설계

  • 실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천

참고: 이론적으로 Getter, Setter 모두 제공하지 않고, 꼭 필요한 별도의 메서드를 제공하는게 가장 이상적이다. 하지만 실무에서 엔티티의 데이터는 조회할 일이 너무 많으므로, Getter의 경우 모두 열어두는 것이 편리하다. Getter는 아무리 호출해도 호출 하는 것 만으로 어떤 일이 발생하지는 않는다.

하지만 Setter는 문제가 다르다. Setter를 호출하면 데이터가 변한다. Setter를 막 열어두면 가까운 미래에 엔티티에가 도대체 왜 변경되는지 추적하기 점점 힘들어진다. 그래서 엔티티를 변경할 때는 Setter 대신에 변경 지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 한다.

그러면 어떻게 값을 넣어야 할까?
이럴 때 @Builder를 사용하면 된다!

package com.example.jpabook.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

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

    @Embedded
    private Address address;

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

    @Builder
    public Member(Long id, String userName, Address address, List<Order> orders) {
        this.id = id;
        this.userName = userName;
        this.address = address;
        this.orders = orders;
    }
}

여기서 보면 List<Order>로 배열이 Order이 List형식으로 들어와 있는데 이것은 양방향 관계를 맺기 위해서이다. 하나의 유저는 여러개의 주문을 할 수 있으므로 @OneToMany를 해주고 (mappedBy = "member")이것을 붙여주는데 이것은 이 List는 읽기전용이라고 나타내는 것이다. 이렇게 하지 않으면 JPA는 Member에서 List가 수정되는 것과 Order가 수정되는 것 중에 뭐가 진짜인지 모른다. 그렇기 때문에 읽기전용으로 바꾸고 Order을 수정했을 때 바뀌게 한다.

package com.example.jpabook.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

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

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

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

}

반대로 주문의 입장에서는 여러개의 주문을 하나의 유저가 할 수 있으므로 @ManyToOne이다. 그리고 @JoinColumn(name = "member_id")으로 어디에 넣을 것인지 나타내준다.

package com.example.jpabook.entity;

import lombok.Getter;

import javax.persistence.Embeddable;

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

카테고리

상품을 처리할 때 어떻게 카테고리를 처리할지 정합니다.

package com.example.jpabook.entity.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor
@ToString
@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;

    @Builder
    public Item(Long id, String name, int price, int stockQuantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }
}
package com.example.jpabook.entity.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@NoArgsConstructor
@ToString
@Getter
@DiscriminatorValue("Album")
public class Album extends Item{
    private String artist;
    private String etc;

    @Builder
    public Album(String artist, String etc) {
        this.artist = artist;
        this.etc = etc;
    }
}
package com.example.jpabook.entity.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@NoArgsConstructor
@ToString
@Getter
@DiscriminatorValue("Book")
public class Book extends Item{
    private String author;
    private String isbn;

    @Builder
    public Book(String author, String isbn) {
        this.author = author;
        this.isbn = isbn;
    }
}
package com.example.jpabook.entity.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@Getter
@ToString
@NoArgsConstructor
@DiscriminatorValue("Movie")
public class Movie extends Item{
    private String director;
    private String isbn;

    @Builder
    public Movie(String director, String isbn) {
        this.director = director;
        this.isbn = isbn;
    }
}

@Inheritance(strategy = InheritanceType.SINGLE_TABLE) 및 @DiscriminatorColumn(name = "dtype")은 JPA에서 상속 관계를 매핑하기 위한 어노테이션입니다. 이 설정은 여러 가지 상속 전략 중 하나인 "Single Table" 상속 전략을 나타냅니다.

Single Table 상속 전략은 모든 하위 엔티티 클래스가 하나의 테이블에 저장되며, 그 중 어떤 하위 엔티티 클래스의 인스턴스인지를 구분하기 위해 dtype (Discriminator Type) 컬럼을 사용합니다. 이 컬럼은 각 엔티티의 타입을 나타내는 문자열 값을 저장합니다.

구조는 다음과 같습니다.

  1. Item 엔티티 클래스는 추상 클래스로서 공통적인 속성을 정의합니다.

  2. Album과 Book 엔티티 클래스는 Item을 상속받아서 고유한 속성을 추가합니다.

  3. @Inheritance(strategy = InheritanceType.SINGLE_TABLE) 어노테이션은 이러한 상속 관계를 "하나의 테이블에 저장"하는 전략을 사용한다고 지정합니다.

  4. @DiscriminatorColumn(name = "dtype") 어노테이션은 dtype라는 이름의 컬럼을 사용하여 각 엔티티의 타입을 구분하도록 설정합니다.

따라서 이 구조를 사용하면 Item 테이블 하나에 Album과 Book 엔티티의 데이터가 함께 저장되며, dtype 컬럼을 통해 어떤 종류의 아이템인지 식별할 수 있습니다. 이러한 방식은 간단하고 효율적인 방법으로 상속 관계를 매핑할 수 있으며, 상품 카테고리와 같은 경우에 적합한 방법일 수 있습니다.


엔티티 설계시 주의점

  • 엔티티에는 가급적 setter를 사용하면 안된다!

    변경 포인트가 너무 많아서 유지 보수가 어려움

  • 모든 연관관계는 지연로딩으로 설정!

    • 즉시로딩(EAGER)은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
    • 실무에서 모든 연관관계는 지연로딩(LAZY)으로 설정해야 한다.
    • 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
    • @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
  • 컬렉션은 필드에서 초기화 하자.

    • null 문제에서 안전
    • 하이버네이트는 엔티티를 영속화 할 때,컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders() 처럼 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 따라서 필드레벨에서 생성하는 것이 가장 안전하고 코드도 간결하다.

Cascade란?

특정 엔티티를 영속 상태로 만들 경우, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 영속성 전이를 사용합니다. JPA에서는 영속성 전이를 Cascade옵션을 통해서 설정하고 관리할 수 있습니다. 쉽게 말해서 부모 엔티티를 다룰 경우, 자식 엔티티까지 다룰 수 있다는 뜻이죠.

Cascade는 6가지의 옵션을 가지고 있습니다.

  • ALL
  • PERSIST
  • MERGE
  • REMOVE
  • REFRESH
  • DETACH

여기서 주로 사용하는 것은 ALL과 PERSIST와 REMOVE입니다.

CascadeType.PERSIST

PERSIST는 부모와 자식엔티티를 한 번에 영속화할 수 있습니다.

CascadeType.REMOVE

PERSIST로 함께 저장했던 부모와 자식의 엔티티를 모두 제거할 경우에 CascadeType.REMOVE를 사용합니다.

Circle circle = EntityManger.find(Circle.class,1L);
UserCircle userCircle1 = EntityManager.find(UserCircle.class,1L);
UserCircle userCircle2 = EntityManager.find(UserCircle.class,2L);

EntityManager.remove(userCircle1);
EntityManager.remove(userCircle2);
EntityManager.remove(circle);

원래대로라면 위와 같이 userCircle1, userCircle2를 먼저 제거해 준 뒤, 그 다음 부모객체인 circle을 제거해줘야 합니다. 하지만 CascadeType.REMOVE옵션을 사용했을 경우에는 아래와 같이 부모객체를 삭제하면 연관되어 있는 자식 객체들이 줄줄이 사라지게 되죠.

CascadeType.ALL

CascadeType.PERSIST 와 CascadeType.REMOVE의 기능을 모두 수행 해주는 옵션입니다.

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor
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;
}
@Getter
@Setter
@Entity
@Table(name = "order_item")
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에서 Order을 @JoinColumn을 해서 FK로 받아오니 JPA상으로 여기가 주인이다. OrderItem여기에서 cascade = CascadeType.ALL와 같은 연관 관계 설정을 하더라도 이 엔티티를 저장, 수정, 삭제할 때만 해당 cascade 옵션이 적용됩니다.

Order 엔티티에서 orderItems 필드에 대한 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 설정은 Order 엔티티를 저장, 수정, 삭제할 때 orderItems 컬렉션에 속한 OrderItem 엔티티들에게도 해당 연산이 전파되는 것을 의미합니다.

주인 엔티티에서 cascade 설정을 하면 그 엔티티의 상태 변화(추가, 수정, 삭제)가 연관된 엔티티에 모두 적용됩니다. 따라서 주인 엔티티를 저장할 때, 연관된 엔티티도 함께 저장되며, 주인 엔티티를 수정할 때, 연관된 엔티티도 함께 수정되며, 주인 엔티티를 삭제할 때 연관된 엔티티도 함께 삭제됩니다.

반면에 주인이 아닌 엔티티에서 cascade 설정을 하더라도 해당 엔티티의 상태 변화는 그 엔티티에만 적용되며, 주인 엔티티의 상태 변화와는 별개로 처리됩니다. 주인이 아닌 엔티티에서 cascade 설정을 사용하면, 주인 엔티티의 상태 변화와 관계 없이 해당 엔티티에 대한 cascade 작업이 실행됩니다.

따라서, 주인 엔티티를 저장, 수정, 삭제할 때 관련된 엔티티에도 동일한 작업을 적용하려면 주인 엔티티에서 cascade 설정을 하면 됩니다. 주인 엔티티가 변경되면 주인이 아닌 엔티티도 변경되지 않습니다.

@Entity(name = "board")
@Table
@ToString
@Getter
@NoArgsConstructor
public class BoardEntity extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "board_id")
    private Long boardId;

    @Column(length = 300, nullable = false)
    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private MemberEntity member;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "board")
    private List<BoardImgEntity> boardImgDTOList = new ArrayList<>();

    @Builder
    public BoardEntity(Long boardId,
                       String title,
                       String content,
                       MemberEntity member,
                       List<BoardImgEntity> boardImgDTOList) {
        this.boardId = boardId;
        this.title = title;
        this.content = content;
        this.member = member;
        this.boardImgDTOList = boardImgDTOList;
    }
}
@Entity(name = "board_img")
@Table
@Getter
@NoArgsConstructor
@ToString
public class BoardImgEntity extends BaseEntity {
    @Id
    @GeneratedValue
    @Column(name = "board_img_id")
    private Long boardImgId;
    private String uploadImgPath;
    private String uploadImgName;               // 이미지 파일명
    private String oriImgName;                  // 원본 이미지 파일명
    private String uploadImgUrl;                // 이미지 조회 경로
    private String repImgYn;                    // 대표 이미지 여부 Y면 대표이미지를 보여줌


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private BoardEntity board;

    @Builder
    public BoardImgEntity(Long boardImgId,
                          String uploadImgPath,
                          String uploadImgName,
                          String oriImgName,
                          String uploadImgUrl,
                          String repImgYn,
                          BoardEntity board) {
        this.boardImgId = boardImgId;
        this.uploadImgPath = uploadImgPath;
        this.uploadImgName = uploadImgName;
        this.oriImgName = oriImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.repImgYn = repImgYn;
        this.board = board;
    }
}
@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private BoardEntity board;

여기에 cascade = CascadeType.ALL추가하면 된다.

주인 엔티티인 BoardImgEntity에서 cascade = CascadeType.ALL 설정을 하면, BoardImgEntity를 저장, 수정, 삭제할 때 관련된 BoardEntity도 해당 작업이 전파됩니다. 따라서 BoardImgEntity를 저장하거나 수정할 때 BoardEntity도 자동으로 저장 또는 수정됩니다.

만약 BoardEntity를 수정하고 이에 따라 BoardImgEntity도 수정하려면, BoardEntity에서의 설정은 따로 필요하지 않습니다. 연관 관계에서 주인 엔티티에서 설정한 cascade 옵션이 작동하면 연관된 엔티티에 대한 작업이 전파되기 때문입니다.

따라서 cascade = CascadeType.ALL 설정은 BoardImgEntity에서만 하면 충분합니다. BoardEntity에서는 설정하지 않아도 됩니다. 이렇게 하면 BoardEntity를 저장, 수정, 삭제할 때 해당 BoardEntity와 연관된 BoardImgEntity도 같이 처리됩니다.

    // 게시글 작성
    public ResponseEntity<?> createBoard(BoardDTO boardDTO, List<MultipartFile> boardImages, String userEmail) throws IOException {
        MemberEntity findUser = memberRepository.findByUserEmail(userEmail);

        if (findUser != null) {
            // 게시글을 등록
            // 작성자랑 시간은 Auditing 기능으로 자동으로 DB에 들어감
            BoardEntity boardEntity = BoardEntity.builder()
                    .title(boardDTO.getTitle())
                    .content(boardDTO.getContent())
                    .member(findUser)
                    .build();

            // S3에 이미지 넣기
            List<BoardImgDTO> boardImg = s3BoardImgUploaderService.upload("boardImg", boardImages);
            // List형식으로 이미지를 담는 이유는 Board에 List형식으로
            // 이미지와 양방향을 맺고 있기 때문에 List로 넣어줘야 한다.
            List<BoardImgEntity> boardImgEntities = new ArrayList<>();
            List<BoardDTO> savedImg = new ArrayList<>();

            for (int i = 0; i < boardImg.size(); i++) {
                BoardImgDTO uploadImg = boardImg.get(i);

                if(i == 0) {
                    BoardImgEntity boardImgEntity = BoardImgEntity.builder()
                            .board(boardEntity)
                            .oriImgName(uploadImg.getOriImgName())
                            .uploadImgName(uploadImg.getUploadImgName())
                            .uploadImgUrl(uploadImg.getUploadImgUrl())
                            .uploadImgPath(uploadImg.getUploadImgPath())
                            .repImgYn("Y")
                            .build();
                    // List에 넣어준다.
                    boardImgEntities.add(boardImgEntity);
                    // 이미지를 DB에 저장
                    boardImgRepository.save(boardImgEntity);
                } else {
                    BoardImgEntity boardImgEntity = BoardImgEntity.builder()
                            .board(boardEntity)
                            .oriImgName(uploadImg.getOriImgName())
                            .uploadImgName(uploadImg.getUploadImgName())
                            .uploadImgUrl(uploadImg.getUploadImgUrl())
                            .uploadImgPath(uploadImg.getUploadImgPath())
                            .repImgYn("Y")
                            .build();
                    // List에 넣어준다.
                    boardImgEntities.add(boardImgEntity);
                    // 이미지를 DB에 저장
                    boardImgRepository.save(boardImgEntity);
                }
                boardEntity = BoardEntity.builder()
                        .title(boardDTO.getTitle())
                        .content(boardDTO.getContent())
                        .member(findUser)
                        .boardImgDTOList(boardImgEntities)
                        .build();

                // 게시글 DB에 저장
                BoardEntity save = boardRepository.save(boardEntity);
                savedImg.add(BoardDTO.toBoardDTO(save));
        }
            return ResponseEntity.ok().body(savedImg);
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("회원이 없습니다.");
        }
    }

cascade = CascadeType.ALL 을 사용하지 않으면 board와 boardImg 엔티티에 각각 넣어줘야 하는데 cascade = CascadeType.ALL을 사용하면 boardImg 엔티티 만들고 값넣고 저장하고 board 엔티티 만들고 값넣고 저장하면 양방향으로 묶어둔 양쪽에 적용됩니다.

양방향 연관 관계의 설정과 데이터베이스 저장 시점

  1. BoardEntity와 BoardImgEntity는 양방향 관계입니다. BoardEntity 쪽에서 boardImgDTOList 필드로 BoardImgEntity들과의 연관 관계를 설정하고 있습니다.

  2. BoardImgEntity의 board 필드로는 BoardEntity와 연관 관계를 설정하고 있습니다.

  3. 코드에서 보면 BoardEntity를 먼저 생성하고 BoardImgEntity를 생성한 후 BoardEntity에 BoardImgEntity를 설정하고, 마지막으로 BoardEntity를 저장하고 있습니다.

  4. 이렇게 연관 관계를 설정하고 저장할 때, JPA는 양방향 관계를 인식하고, BoardEntity를 저장하면서 연관된 BoardImgEntity도 저장합니다. 마찬가지로 BoardImgEntity를 저장할 때에도 연관된 BoardEntity를 저장합니다.

즉, 양방향 관계 설정이 제대로 되어있고, cascade 설정이 올바르게 설정되어 있다면 한쪽 엔티티를 저장할 때 다른 쪽 엔티티도 자동으로 저장됩니다. 이렇게 양방향 관계를 설정하면 양쪽 엔티티에 적용됩니다.

따라서 양방향 관계 설정이 올바르게 이루어져 있고, cascade 설정도 적절히 되어 있다면 각각의 엔티티를 개별적으로 저장하는 대신 어느 한 엔티티를 저장할 때 연관된 다른 엔티티도 함께 저장될 것입니다.

코드상 차이점은 boardImgRepository.save(boardImgEntity); BoardEntity save = boardRepository.save(boardEntity); 이렇게 2개로 저장하던게 boardRepository.save(boardEntity); 이거 하나만 저장하면 연관돤게 다저장되는거 차이 하나이다.


회원 도메인 개발

테스트

import com.example.jpabook.entity.member.Member;
import com.example.jpabook.repository.MemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Transactional;

import static org.aspectj.bridge.MessageUtil.fail;

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
public class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired
    MemberRepository memberRepository;

    @Test
    public void 회원가입() throws Exception {
        // given
        Member member = Member.builder()
                .userName("kim")
                .build();

        // when
        Long join = memberService.join(member);

        // then
        Assertions.assertThat(member).isEqualTo(memberRepository.findOne(join));
    }

    @Test
    public void 중복회원예외() throws Exception {
        // given
        Member member = Member.builder()
                .userName("kim")
                .build();

        Member member2 = Member.builder()
                .userName("kim")
                .build();

        // when
        memberService.join(member);

        // 예외 검사를 할 때 이 문법으로 처리해야 한다.
       Assertions.assertThatThrownBy(() -> memberService.join(member2))
                       .isInstanceOf(IllegalStateException.class);

        // then
        fail("예외가 발생해야 한다.");
    }
}

이렇게 처리하면 기존의 DB를 사용해서 문제도 되고 테스트만 해보고 싶을 때는 DB를 다운받아야 해서 불편하다.

가상 DB(메모리 DB)

test폴더에 resources를 만들고 거기에 yml을 만들어준다.

H2 인메모리

spring:
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: true

  # swagger
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:test
    username: root
    password: 1234

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    open-in-view: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 500

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace

이렇게 하면 테스트를 돌렸을 때 test안에 있는 yml을 실행해준다.
그러나 스프링 부트는 이거를 적어줄 필요가 없이 지원해준다. 기본적으로 create-drop으로 실행해준다. 이렇게 테스트와 실제 돌아가는 것을 따로 나눠줘야 한다.


상품 엔티티

package com.example.jpabook.entity.item;

import com.example.jpabook.exception.NotEnoughStockException;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor
@ToString
@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;

    @ManyToMany(mappedBy = "items")
    private List<Catagory> catagories = new ArrayList<>();

    @Builder
    public Item(Long id, String name, int price, int stockQuantity, List<Catagory> catagories) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.catagories = catagories;
    }

    // 비즈니스 로직
    // stock 증가
    public void addStock(int stockQuantity) {
        this.stockQuantity += stockQuantity;
    }

    // stcck 감소
    public void removeStock(int stockQuantity) {
        int restStock = this.stockQuantity - stockQuantity;
        if(restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

상품에 대한 기능을 상품안에 구현해서 숫자을 늘리거나 줄이거나 하는 기능을 상품 안에 구현해준다. 이렇게 상품에 관련된 기능은 상품 엔티티에 넣어주는것이 좋다.

여기서 void로 처리하는 이유는 return해줄 필요가 없이 DB 수정용이기 때문에 void로 처리해주는 것이다. 예를들어, 주문시 주문을 구매자가 구매가 성공적으로 처리되면 상품의 재고수량이 감소될 것이다.

이 때, 주문서비스에서 주문이 성공적이면 상품 정보를 가져와서 어떤 상품을 몇개 구매했는지 알 수 있고 기존의 상품의 재고수량을 가져와서 빼줘서 DB에 넣어줘야 하는데 그거를 상품 엔티티에서 구현한 메소드를 가지고 와서 사용하는 것이다.


주문 로직

package com.example.jpabook.entity.order;

import com.example.jpabook.entity.member.Member;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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;

    @Builder
    public Order(Long id,
                 Member member,
                 List<OrderItem> orderItems,
                 Delivery delivery,
                 LocalDateTime orderDate,
                 OrderStatus status) {
        this.id = id;
        this.member = member;
        this.orderItems = orderItems;
        this.delivery = delivery;
        this.orderDate = orderDate;
        this.status = status;

        if(member != null) {
            member.getOrders().add(this);
        }
    }

    public void addOrderItem(OrderItem orderItem) {
        this.orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    // 생성 메서드
    public static Order createOrder(Member member,
                                    Delivery delivery,
                                    OrderItem... orderItems2) {
        ArrayList<OrderItem> arrayList = new ArrayList<>();
        for(OrderItem orderItem : orderItems2) {
            arrayList.add(orderItem);
        }
        return Order.builder()
                .member(member)
                .delivery(delivery)
                .orderItems(arrayList)
                .status(OrderStatus.ORDER)
                .orderDate(LocalDateTime.now())
                .build();
    }

    // 비즈니스 로직
    // 주문 취소
    public void cancel() {
        if(delivery.getStatus() == DeliveryStatus.COMP) {
           throw  new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.status = OrderStatus.CANCEL;
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

    // 조회로직
    // 전체 주문 가격 조회
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}
package com.example.jpabook.entity.order;

import com.example.jpabook.entity.item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Getter
@Setter
@Entity
@Table(name = "order_item")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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;          // 주문 수량
    
       // 생성 메소드
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        // 주문한 만큼 상품 재고를 까줘야 한다.
        item.removeStock(count);
        return orderItem;
    }


    // 비즈니스 로직
    public void cancel() {
        getItem().addStock(count);
    }

    // 주문할 때 주문 가격과 수량을 곱해야하기 때문에
    // 여기서 가져온다.
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}
   // 주문
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        // 배송정보 생성
        Delivery delivery = Delivery.builder()
                .address(member.getAddress())
                .build();

        // 주문 상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        // 주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        // 주문 저장
        orderRepository.save(order);
        return order.getId();
    }

이렇게 엔티티에 기능을 넣어서 서비스에서 호출하는 기능을 도메인 모델 패턴이라고 한다.

도메인 모델 패턴

비즈니스 로직 대부분이 엔티티에 있고 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.

엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.

둘중에 정답이 있는것은 아니지만 유지보수를 신경써서 사용해야한다. JPA를 사용할 때는 주로 도메인 모델 패턴을 사용한다.

OrderRepository

package com.example.jpabook.repository;

import com.example.jpabook.entity.order.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.*;
import java.util.ArrayList;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    private final EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

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

    public List<Order> findAll(OrderSearch orderSearch) {
        return em.createQuery("select o from Order o join o.member m" +
                        " where o.status = :status " +
                " and m.userName like :name"
                , Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setMaxResults(1000) // 최대 1000건
                .getResultList();
    }

    public List<Order> findAllByString(OrderSearch orderSearch) {

        String jpql = "select o from Order o join o.member m";
        boolean isFirstCondition = true;

        //주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " o.status = :status";
        }

        //회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " m.name like :name";
        }

        TypedQuery<Order> query = em.createQuery(jpql, Order.class)
                .setMaxResults(1000);

        if (orderSearch.getOrderStatus() != null) {
            query = query.setParameter("status", orderSearch.getOrderStatus());
        }
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            query = query.setParameter("name", orderSearch.getMemberName());
        }

        return query.getResultList();
    }
    /**
     * JPA Criteria
     */
    public List<javax.persistence.criteria.Order> findAllByCriteria(OrderSearch orderSearch) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<javax.persistence.criteria.Order> cq = cb.createQuery(javax.persistence.criteria.Order.class);
        Root<javax.persistence.criteria.Order> o = cq.from(javax.persistence.criteria.Order.class);
        Join<Object, Object> m = o.join("member", JoinType.INNER);

        List<Predicate> criteria = new ArrayList<>();

        //주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            criteria.add(status);
        }
        //회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Predicate name =
                    cb.like(m.<String>get("name"), "%" + orderSearch.getMemberName() + "%");
            criteria.add(name);
        }

        cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
        TypedQuery<javax.persistence.criteria.Order> query = em.createQuery(cq).setMaxResults(1000);
        return query.getResultList();
    }
}

OrderService

package com.example.jpabook.service;

import com.example.jpabook.entity.item.Item;
import com.example.jpabook.entity.member.Member;
import com.example.jpabook.entity.order.Delivery;
import com.example.jpabook.entity.order.Order;
import com.example.jpabook.entity.order.OrderItem;
import com.example.jpabook.repository.ItemRepository;
import com.example.jpabook.repository.MemberRepository;
import com.example.jpabook.repository.OrderRepository;
import com.example.jpabook.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    // 주문
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        // 배송정보 생성
        Delivery delivery = Delivery.builder()
                .address(member.getAddress())
                .build();

        // 주문 상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        // 주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        // 주문 저장
        orderRepository.save(order);
        return order.getId();
    }


    // 취소
    @Transactional
    public void cancelOrder(Long orderId) {
        // 주문 회원 엔티티 조회
        Order order = orderRepository.findOne(orderId);
        // 주문취소
        order.cancel();
    }

    // 검색
    public List<Order> findOrders(OrderSearch orderSearch) {
        return orderRepository.findAll(orderSearch);
    }
}

★지연 로딩과 조회성능 최적화

지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자!
V의 숫자가 늘어날 수록 업그레이드!

xToOne(ManyToOne, OneToOne) 최적화

엔티티 반환

V1

/*
*   xToOne(ManyToOne, OneToOne)
*   Order
*   Order → Member
*   Order → Delivery
*   위에꺼의 최적화
* */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    // 여기서 보면 List안에 엔티티가 있는데 이러면 안된다.
    // 왜 안되는지를 보여주기 위해서 사용해 본다.
    @GetMapping("/api/v1/simple-orderds")
    public List<Order> orderV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}

이렇게 하면 무한으로 돌면서 에러가 발생하는데 그 이유는 Order에 가면 Member가 있고 Member에 가면 Order가 있다. 이렇게 반복적으로 돌면서 무한히 도는 것이다.

이문제를 해결하기 위해서는 양방향으로 되어 있는 곳에서 한 곳에서 @JsonIgnore을 추가해주면 된다.

하지만 또 에러가 발생한다.

Order엔티티에서

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

여기서 보면 LAZY로 되어 있는데 지연로딩은 바로 가져오지 않고 Order에 대한 정보만 가져오는 것이다. 이 때 하이브네이트에서는 프록시 라이브러리를 사용해서 프록시 멤버를 넣어준다. 그것이 bytebuddy다.

우리 눈에는 안보이지만

이런식으로 들어가 있는 것이다. 여기서 문제가 발생하는 이유는 json 라이브러리가 루프를 돌리는 와중에 member가 객체가 아니고 bytebuddy라서 문제가 생기는 것이다.

여기서 member을 뿌리지 말라고 하는 방법이 있는데

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

gradle에 추가해준다.

그리고 다음과 같이 넣어준다.

@SpringBootApplication
public class JpabookApplication {
    public static void main(String[] args) {
        SpringApplication.run(JpabookApplication.class, args);
    }
    @Bean
    Hibernate5Module hibernate5Module() {
        return  new Hibernate5Module();
    }
}

이제 에러 없이 제대로 나온거를 볼 수 있는데 사실상 이렇게 사용할일이 없다. 엔티티를 반환하지 않기 때문이다.

DTO 반환

  @GetMapping("/api/v2/simple-orderds")
    public List<SimpleOrderDTO> orderV2() {
     // ORDER 2개
        // N +1 문제 발생 → 회원 N + 배송 N
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDTO> collect = all.stream()
                .map(SimpleOrderDTO::new)
                .collect(Collectors.toList());
        return collect;
    }
    @Data
    static class SimpleOrderDTO {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDTO(Order order) {
            orderId = order.getId();
            name = order.getMember().getUserName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
        }
    }

여기서도 문제가 있는데 쿼리가 총 1 + N + N번 실행된다. 흔히말하는 N +1 문제이다.

  • order 조회시 1번
  • order → member 지연 로딩 조회 N번
  • order → delivery 지연 로딩 조회 N번

이럴때 fetch join을 사용한다.

   public List<Order> findAllWithMemberDelivery() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member" +
                        " join fetch o.delivery", Order.class
        ).getResultList();
    }
@Repository
public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
    @Query("select b from board b" +
            " join fetch b.member " +
            "where b.boardId = :boardId")
    Optional<BoardEntity> findByBoardId(@Param("boardId") Long boardId);

    void deleteByBoardId(Long boardId);

    @Query("select b from board b" +
            " join fetch b.member " +
            "where b.title like %:searchKeyword%")
    Page<BoardEntity> findByTitleContaining(Pageable pageable, @Param("searchKeyword")String searchKeyword);

    @Query("select b from board b" +
            " join fetch b.member ")
    Page<BoardEntity> findAll(Pageable pageable);

    @Query("select b from board b " +
            "join fetch b.member " +
            "where b.member.email = :email")
    Page<BoardEntity> findAllByMemberEmail(@Param("email")String email, Pageable pageable);

    @Query("select  b from board  b " +
            " join  fetch b.member " +
            "where b.member.email = :email and b.title like %:searchKeyword%")
    Page<BoardEntity> findByMemberEmailAndTitleContaining(@Param("email")String email,
                                                          Pageable pageable,
                                                          @Param("searchKeyword")String searchKeyword);

    @Query("select b from board b " +
    " join fetch b.member " +
    "where b.member.nickName = :nickName")
    Page<BoardEntity> findAllByMemberNickName(@Param("nickName") String nickName,
                                              Pageable pageable);

    @Query("select  b from board  b " +
            " join fetch b.member " +
            "where b.member.nickName = :nickName and b.title like %:searchKeyword%")
    Page<BoardEntity> findByMemberNickNameAndTitleContaining(@Param("nickName")String nickName,
                                                             Pageable pageable,
                                                             @Param("searchKeyword")String searchKeyword);

    @Query("select b from board b " +
    " join fetch b.member " +
    "where b.item.itemId = :itemId")
    Page<BoardEntity> findAllByItemItemId(@Param("itemId")Long itemId, Pageable pageable);

    @Query("select b from board b " +
            " join fetch b.member " +
            "where b.item.itemId = :itemId and b.title like %:searchKeyword%")
    Page<BoardEntity> findByItemItemIdContaining(@Param("itemId")Long itemId,
                                                 Pageable pageable,
                                                 @Param("searchKeyword")String searchKeyword);
}

DTO에 반환할 때 다른 엔티티가 필요없다면 fetch join을 사용하지 않아도 되지만 DTO를 프론트에 반환할 때 다른 엔티티의 정보가 필요하다면 fetch join을 사용하는 것이 좋다.

레포지토리에서 DTO로 반환해주는 방법도 있는데 레포지토리 재사용성이 떨어지고 API 스펙에 맞춘 코드가 레포지토리에 들어가는 단점이 있다.

유지보수는 엔티티를 DTO를 바꿔줘서 리턴해주는 방법이 유지보수에 좋다.

컬렉션 조회 최적화

컬렉션 조회는 일대다조회이다.

주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자.
Order 기준으로 컬렉션인 OrderItemItem이 필요하다.

이번에는 컬렉션인 OneToMany를 조회하고 최적화하는 방법을 알아보고자 한다.

    public List<Order> findAllWithItem() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d" +
                        " join fetch  o.orderItems oi" +
                        " join fetch oi.item i", Order.class
        ).getResultList();
    }

이렇게하면 값이 orderItems에 있는 갯수만큼 뻥튀기가 된다.
이 뻥튀기를 안되게 막으려면 distinct를 해주면 된다.

이게 있으면 Order를 가지고 올 때 이 Order가 같은 ID이면 중복된 것을 버린다. 즉, 중복된 엔티티를 제거해준다.

    public List<Order> findAllWithItem() {
        return em.createQuery(
                "select distinct o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d" +
                        " join fetch o.orderItems oi" +
                        " join fetch oi.item i", Order.class
        ).getResultList();
    }
  • 페치 조인으로 SQL이 1번만 실행됨
  • distinct를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 중복처리를 조회되는 것을 막아준다.
  • 페이지 불가능

컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다.


페이징 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.

    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
    • Order를 기준으로 페이징하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

한계 돌파

그렇다면 페이징과 컬렉션 엔티티를 함께 조회하라면 어떻게 해야할까?

  • 먼저, ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인한다. ..ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

  • 컬렉션은 지연 로딩으로 조회한다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.

    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화

    이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데 100~1000 사이를 선택하는 것을 권장합니다. 이 전략을 SQL IN 절을 사용하는데 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 한번에 1000개를 DB에서 애플리케이션으로 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하면 성능상 가장 좋지만 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 좋다.


엔티티 조회하는 이유

엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에 단순한 코드를 유지하면서 성능을 최적화 할 수 있다. 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사합니다.


OSIV와 성능 최적화

Open EntityManager In View : JPA
(관례상 OSIV라고 합니다.)

OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다. 그래서 지금까지 View Template이나 API 컨트롤러에서 지연로딩이 가능했던 것이다.

지연로딩은 영속성 컨텍스트가 살아있어야 가능하고 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이것 자체가 큰 장점이자 단점이다.

이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다. 이것은 장애로 이어진다.

OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다. OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연로딩 코드를 트랜잭션으로 넣어야 한다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.

커맨드와 쿼리 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.

보통 비즈니스 로직은 특정 엔티티 몇개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지는 않는다. 그런데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것이 아니다. 그래서 크고 복잡한 애플리케이션을 개발한다면 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미있다. 다음과 같이 분리한다.

  • OrderService : 핵심 비즈니스 로직
  • OrderQueryService : 화면이나 API에 맞춘 서비스(주로 읽기 전용 트랜잭션 사용)

트래픽이 크다면 false로 하고 보통 배포는 admin은 따로 배포하기 때문에 admin같이 트래픽이 많지 않다면 true로 합니다. 성능 위주면 true로 해줍니다.

profile
발전하기 위한 공부

0개의 댓글