Post

JPA 관한 기록

Eraser 를 이용해 만든 직접 자료입니다.
공부하면 할수록 어려운 JPA 를 정리합니다 😭

영속성 컨텍스트

  • 영속성 컨텍스트@Id 필드를 기준으로 엔티티를 식별
  • persist() 시, 1차 캐시 에 저장
    • 해당 엔티티의 상태는 Managed 가 됨
    • EntityManagercommit() 을 호출하면, 쓰기 지연 저장소에 캐싱된 SQL 이 DB 에 반영
      • commit() 메서드 호출 시, flush() 일어남
      • SQL 문은 flush() 과정을 통해 DB 에 반영
        • 단, flush() 는 1차 캐시를 비우는 clear() 를 발생시키지 않음
        • 트랜잭션이 종료되는 commit 이후 1차 캐시가 사라진다.
        • 혹은 JPQL 의 default 설정으로 JPQL 실행과 동시에 flush() 가 일어난다.
    • clear() 실행 시
      • 영속성 컨텍스트에서 엔티티 제거
      • 1차 캐시 초기화
      • 엔티티를 다시 로딩
      • 이전 엔티티들은 준영속 상태로 변경
        • 변경 사항 존재해도 DB 에 반영 X
        • 한 트랙잭션 내에서 이전에 변경한 내용이 있다면 모두 롤백된다.
  • close() 실행 시
    • 영속성 컨텍스트의 완전 종료

image

1차 캐시

image

영속성 컨텍스트 안에 포함되어 관리되는 엔티티는 1차 캐시에 관리된다.

  • 엔티티에 부여한 @Id 값, 엔티티의 이름의 쌍으로 저장된다.
  • .flush() 시점 이전에 1차 캐시에 있는 값을 조회하면
    • DB 에 있는 데이터와 동기화가 안되어 오류가 날 수 있다.
    • 동기화 오류가 발생하면,
      • EntityManager.flush() 로 직접 호출하는 방법이 있다.

1차 캐시가 초기화 되는 시점

  • EntityManager 를 통해 직접적으로 flush() 를 실행한다.
  • JPQL 의 default 설정으로 JPQL 쿼리 실행 시 flush() 를 실행한다.
  • 트랜잭션이 성공적으로 종료되어 commit 이 발생하면 flush() 가 실행된다.

2차 캐시

image

어플리케이션이 살아있는 동안 공유되는 세션이 존재한다. 2차 캐시는 어플리케이션 전역에 걸쳐 공유되는 세션에 존재한다.

  • 2차 캐시는 SessionFactory-scoped 이다.
  • 직접적으로 @Id 를 조회하는 경우, 혹은 연관 관계에 있는 엔티티를 조회하는 경우 다음 과정을 거친다.
    • 첫째로 1차 캐시를 조회하고, 존재한다면 1차 캐시에 있는 값을 가지고 리턴하며 세션이 종료된다.
    • 1차 캐시에 존재하지 않는 경우, 2차 캐시를 조회한다.
      • 2차 캐시에 엔티티가 존재하면 해당 데이터를 리턴하고 세션이 종료된다.
      • 2차 캐시 는 동시성을 보장하기 위해서
        • 원본 객체가 아닌, 복사본을 반환한다.
          • 단, 구현체인 CacheConcurrencyStrategy 속성으로 READ_ONLY 부여 시 원본 객체를 반환한다.
    • 만약 2차 캐시에도 없다면, 데이터베이스에서 데이터를 가져온다.
      • 비용이 비싸다!

따라서, 2차 캐시를 활용한다면 상대적으로 비용이 비싼 데이터베이스와의 연결 횟수를 줄일 수 있다.

2차 캐시 적용

스프링에서는 JPA 의 구현체로 Hibernate 를 채택했다. Hibernate 는 2차 캐시를 어떤 구현체를 선택해도 사용할 수 있도록 추상화 설계를 했고 Ehcache 가 많이 사용된다고 한다.

  • Hibernate 와 2차 캐시 구현체를 연결하는 RegionalFactory 만 설정한다면 어떤 구현체를 사용해도 상관없다.
1
2
3
4
5
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-ehcache</artifactId>
    <version>5.6.15.Final</version>
</dependency>
1
implementation 'org.hibernate:hibernate-ehcache:5.6.15.Final'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
  jpa:
    properties:
      hibernate:
        # 성능에 영향을 주므로 개발환경에서만 사용
        generate_statistics: true
        format_sql: true

        cache:
          # 2차 캐시 활성화
          use_second_level_cache: true
          region:
            # 2차 캐시를 처리 할 클래스 지정
            factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory

      javax:
        persistence:
          sharedCache:
            mode: ENABLE_SELECTIVE

src/main/resources/ehcache.xml 에 캐시 정책을 정의할 수 있다. 공식 문서 에서 세부 사항을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
<ehcache>
    <defaultCache
        maxElementsInMemory="10000"
        eternal="false"
        timeToIdleSeconds="1200"
        timeToLiveSeconds="1200"
        diskExpiryThreadIntervalSeconds="1200"
        memoryStoreEvictionPolicy="LRU"/>
</ehcache>

Hibernate

  • 엔티티 단위 (JPA 표준)
  • 컬렉션 단위
  • 쿼리 단위

로 2차 캐시를 지원한다.

Hibernate 에서 지원하는 어노테이션을 사용하는 방법은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class Parent {

  @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
  @OneToMany(mappedBy = "parent")
  private List<Child> children;

}

따라서, 여러 엔티티와 결합되어 있고 (거의) 불변의 데이터가 존재한다면 2차 캐시를 적극적으로 활용할 수 있다. 👍

Dirty Checking

image

쓰기 지연 저장소

image

위 그림은 캐시된 SQL 이 어느정도 모였다가 한번에 DB 로 flush 되는 과정을 나타낸다. 어느정도라는 말은 수치를 개발자가 정할 수 있다는 것이다.

BatchSize

복잡한 조회쿼리 작성 시, 지연로딩으로 발생하는 쿼리는 IN 절로 한번에 묶어서 보낼 수 있다. 지연로딩 시 호출될 엔티티를 프록시 객체로 저장해두었다가, 실제 사용하는 시점에 DB 에서 가져온다고 이해하자.

hibernate.default.batch_fetch_size 옵션을 전역적으로 부여할 수도 있고, 혹은 어노테이션으로 개별 옵션 지정도 가능하다.

1
2
@OneToMany
@BatchSize(size = 3)

여러 DB 에서 IN 절의최대 개수 값이 1000 인 것을 고려하면, 기본적으로 Batch Size 를 1000 이하로 설정해둬야 DB 와 연결이 갑자기 끊기는 불상사를 막을 수 있다.

관련된 재밌는 글이 있어 기록한다. 😁

출처: BatchSize 에 따른 Heap 메모리 분석

Heap Dump Report

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
num     #instances         #bytes  class name
----------------------------------------------
   1:        727727      235191336  [C
   2:       1949160       48144472  [Ljava.lang.String;
   3:        171172       21921824  
   4:        171172       21581600  
   5:        727337       17456088  java.lang.String
   6:         13770       16400304  
   7:        331148       10596736  java.util.HashMap$Entry
   8:         13770       10483088  
   9:         96859        9298464  org.hibernate.loader.entity.EntityLoader
  10:        125190        8366608  [[Ljava.lang.String;
  11:        140946        6765408  java.util.HashMap
  12:          8927        6644608  
  13:        145303        5812120  java.util.LinkedHashMap$Entry
  14:         52938        5595736  [Ljava.util.HashMap$Entry;
  15:         47148        4732600  [B
  16:        110339        4413560  org.hibernate.loader.DefaultEntityAliases
  17:         41846        3347680  java.lang.reflect.Method
  18:         44019        3169368  java.lang.reflect.Field
  19:         97394        3116608  org.hibernate.LockOptions
  20:         99327        2412328  [Lorg.hibernate.type.EntityType;
  21:         99327        2412328  [Lorg.hibernate.LockMode;
  22:         99326        2412304  [Lorg.hibernate.persister.entity.Loadable;
  23:         99326        2412304  [Lorg.hibernate.loader.EntityAliases;
  24:         41633        2331448  java.util.LinkedHashMap
  .
  .
  .
  .
  60:          9685         309920  org.hibernate.loader.entity.BatchingEntityLoader

위 Heap Dump 분석을 보면 String, EntityLoader 의 수가 굉장히 많다. 데이터베이스와의 연결 비용을 아끼지만, JVMHeap 메모리를 많이 사용하게 되는 Trade-Off 가 있음을 기억해두자.

  • BatchSize 에 따른 메모리 분석
Batch fetch sizeHeap memory usage(MB)EntityLoader instancesEntityLoader memory usage(MB)String memory usage(MB)char[] memory usage(MB)
1363138641.31381.5
2404277242.614.5109
344641584416.1137
4493554445.317.7166
5532693046.619.2194
7621970249.322.4253
1075313860413.327.1342
1579715246414.628.6373
2079615246414.628.6374
3083616632415.930.2408
5088918018417.331.7446

LazyLoading

엔티티를 프록시 객체로 영속성 컨텍스트에 보관하고, 필요한 시점에 쿼리문을 날린다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
public class Store {

  @OneToMany(mappedBy = "store")
  private List<Employee> employees = new ArrayList<>();

}

@Entity
public class Employee {

  @ManyToOne
  @JoinColumn(name="store_id")
  private Store store;

}

image

Store 를 조회하면, 각 Store연관관계를 맺고 있는 Employee 들도 모두 조회된다. 물론 이렇게 사용해야 하는 경우도 있겠지만, 불필요한 쿼리를 날리는 상황이 더 많을 것이다. 따라서, @ManyToOne 에 옵션을 주어 기본 설정을 바꿔야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Store {

  @Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @OneToMany(mappedBy = "store")
  private List<Employee> employees = new ArrayList<>();

}

@Entity
public class Employee {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY) 
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name="store_id")
  private Store store;

}

이처럼 LazyLoading 을 사용하는 경우, Store 를 조회하면 Store 만을 조회한다. 그럼 Employee 객체는 어떻게 되는건가? 연관관계를 맺고 있기 때문에, JPA 는 이를 Proxy 객체로 생성해 1차 캐시에 저장해둔다.

위에서 1차 캐시에는 @Id 기준으로 엔티티를 식별하는 것을 알았다. 따라서 Employee@Id 기준으로 1차 캐시에 저장해둔다.

  • @Id 값을 이미 가지고 있기 때문에 Store 조회 시, Employee.getId() 해도 N+1 문제가 발생하지 않는다.
  • 다른 필드에 접근하면, 그 시점에 쿼리문이 발생한다.
  • 영속성 컨텍스트에 없다면 Store 와 연관관계에 있는 모든 Employee 를 조회해야 한다는 사명감을 가지고 SELECT 문이 Employee 개수 만큼 발생한다. Store 1개만 조회하려고 했는데, Employee 를 N번 추가적으로 조회하게 된다.

결론적으로 해결책은 다음과 같다.

  • Fetch Join 사용
  • BatchSize 지정
  • EntityGraph 사용

Fetch Join

자세히 설명한 블로그 글 소개

Store 엔티티와 연관관계에 묶인 Employee 객체도 함께 SELECT 를 하는 한방 쿼리가 나간다. 따라서 DB Connection Pool 에 여러 번 접근하는 과정을 생략하고 딱 한번에 전부 가져온다. 그럼 이제 더이상 N+1 걱정은 하지 않아도 되는건가? 🤔

@EntityGraph

JPQL 을 사용하는 경우와 @EntityGraph 를 사용하면 뭐가 어떻게 다른지 살펴보자.

1
2
3
4
5
6
7
8
// 1. FETCH JOIN
@Query("select distinct u from User u left join fetch u.articles")
List<User> findAllJPQLFetch();

// 2. @EntityGraph
@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph();

JPA 2.2 부터 표준 스펙으로 채용된, fetch join 을 좀 더 편하게 쓰기 위해 도입된 기능이라고 한다.

1
@EntityGraph(attributePaths = {"FETCH JOIN 할 엔티티의 필드명1", "필드명2", "필드명3", ...})

추가 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@Getter
@Table(name = "member")
class Member {

    @Id @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface MemberRepository extends JpaRepository<Member, Long> {

    //공통 메서드 오버라이드
    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();

    //JPQL + 엔티티 그래프
    @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();

    //메서드 이름으로 쿼리에서 특히 편리하다.
    @EntityGraph(attributePaths = {"team"})
    List<Member> findByUsername(String username);

}

복잡한 쿼리문이 필요없는 심플한 API 설계 시 사용하면 좋을 것 같다. 하지만, 여러 테이블을 JOIN 하는 경우 매우 복잡해질 것이 예상된다.

FETCH JOIN 주의사항

Paging

문제가 되는 상황

  • User : Article@OneToMany 인 상황
    • User 에서 Article 과 left join
1
2
3
@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
Page<User> findAllPage(Pageable pageable);
1
2
3
4
5
6
7
8
9
10
11
@Test
@DisplayName("fetch join을 paging처리에서 사용해도 N+1문제가 발생한다.")
void pagingFetchJoinTest() {
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 2);
    Page<User> users = userRepository.findAllPage(pageRequest);
    System.out.println("== find all ==");
    for (User user : users) {
        System.out.println(user.articles().size());
    }
}

image

1
2021-11-18 22:25:56.284  WARN 79170 --- [    Test worker] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Paging 요청을 했지만, 모든 결과를 메모리에 저장해버린다. Out of Memory Exception 에러가 발생하기 딱 좋다. 이러면 페이징을 하는 의미가 없다.

문제가 안되는 상황

@ManyToOne 관계인 경우

  • ArticleUser 에 관해 ManyToOne 관계일 때,
1
2
3
@EntityGraph(attributePaths = {"user"}, type = EntityGraphType.FETCH)
@Query("select a from Article a left join a.user")
Page<Article> findAllPage(Pageable pageable);

image

BatchSize

1
2
3
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();

위와 동일한 쿼리를 날리면,

image

BatchSize 의 핵심은 IN 절을 사용해 N+1 문제에서 N 번 발생되는 쿼리를 1번만 발생되게 하는 아래 WHERE .. IN 쿼리문이다.

1
2
3
4
where
  article0_.user_in i (
    ?, ?
  )

하지만, 일반적으로 사용되는 100~1000 사이 값에서 모든 상황에 최적화된 값을 찾기가 어렵다. BatchSize 를 너무 크게 가져가는 메모리에 큰 부담이 될 수 있다.

@Fetch(FetchMode.SUBSELECT)

1
2
3
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();

image

과격하지만 어떤 상황에서는 간단명료할 수 있는 어노테이션이다. IN 절에 지정한 사이즈로 끊어서 데이터를 가져오는 것이 아니라, SELECT 문으로 모든 데이터를 조회한다. 마치 @BatchSize(size = INFINITE) 처럼 설정하는 것과 같다.

(일반적인 클라이언트 - 서버 요청 상황에서는 사용할 일이 없을 것 같다. 다만, 실시간 모니터링 툴 등에서 몇 분 간격으로 쿼리문을 날려 SELECT ALL 해서 전체 데이터를 보여주어야 하는 프로그램 등이 use-case 지 않을까 생각을 해본다)

MultipleBagFetchException

FETCH JOIN 에서 @OneToMany 인 경우, 두 개 이상의 COLLECTION JOIN 이 발생하면 너무 많은 값이 한번에 요청되어 인메모리가 부족해지는 상황을 우려해 MultipleBagFetchException 이 발생된다. 그러니, 1 : N 관계에서 N 을 2개 이상 FETCH JOIN 해서 데이터를 가져오는 쿼리문은 실행이 안된다.

아래 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 10, nullable = false)
    private String name;
    
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Article> articles = new ArrayList<>();
    
    @OneToMany(mappedBy = "question", fetch = FetchType.LAZY)
    private List<Question> questions = new ArrayList<>();
}
1
2
3
@EntityGraph(attributePaths = {"articles", "questions"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph2();
1
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.domain.User.articles, com.example.jpa.domain.User.questions]; nested exception is java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.domain.User.articles, com.example.jpa.domain.User.questions]

중복된 데이터가 계속해서 발생하는 것이 문제기 때문에, 중복을 막는 HashSet 등을 사용해볼 수 있겠다. 순서가 중요하다면, LinkedHashSet 을 사용한다.

List 에서 Set 으로 변경한 뒤, 동일한 요청을 하면

1
2
3
4
5
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Question> questions = emptySet(); 

image

실행은 되지만, 근본적으로 페이징 처리 관련한 문제는 해결하지 못한다. Pagination 은 근본적으로 인메모리에서 가져오기 때문에 자료구조를 변경하는 것과는 아무런 연관이 없다.

다시 BatchSize

BatchSize 를 걸고 쿼리를 날려보자

1
2
3
4
5
6
7
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Question> questions = new ArrayList<>();
1
2
@Query("select distinct u from User u left join u.articles left join u.questions")
Page<User> findAllPage2(Pageable pageable);

image

image

결론적으로 @BatchSize 지정한 경우,

  • Article 값을 구하려고하면 Article 만 따로 Batch 쿼리를 발생시켜 가져온다.

Connection Management

HibernateConnection Pool 구현체로 Hikari 를 사용한다.

image

@TransactionalAOP 를 사용해 Proxy 객체를 생성한다는 점을 기억하자. HibernateCP 의 구현체로 Hikari 를 채택했으며, 몇 가지 설정만으로 쉽게 커스텀해서 사용할 수 있다.

관련 블로그 에 설정하는 법이 정리되어 있다.

  • .yaml 파일 설정
  • Bean 으로 등록

설정법 예시 의 예시를 살펴보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/example
    username: 
    password: 
    driver-class-name: com.mysql.jdbc.Driver
    hikari:
      # 로그 관련 설정
      data-source-properties:
        dataSource.logWriter: # 로그 작성 구현체 지정
        dataSource.logUnclosedConnections: true  # 사용하지 않은 커넥션의 로깅 여부 지정

      # 모니터링 관련 설정
      metrics:
        enabled: true  # HikariCP 메트릭스 활성화
        export:
          reporter:
            - prometheus  # 사용할 메트릭스 리포터 설정
          prometheus:
            enabled: true  # Prometheus 메트릭스 리포터 활성화 여부
            step: 60s  # 측정 간격
            
logging:
  level:
    com.zaxxer.hikari: DEBUG

아무런 설정도 하지 않아도 default 값이 지정되어 있기 때문에 어플리케이션 실행에는 문제가 없다.

  • auto-commit 여부
  • connection time out
  • idle time out
  • keep alive time
  • maximum pool size

fine-tuning 을 위해서는 공식 문서 를 참고하자.

Thread 를 꽉 채워서 사용하는 엔드 포인트 유저가 다수 존재하는 환경에서는 pool 개수, connection time 등에 의해서 디버깅이 굉장히 어려운 문제가 발생할 수 있다고 한다. (관련 이슈를 다룬 우아한 블로그)

Hikari 에서 Dead-Lock 을 피하기 위해 제시하는 최소한의 CP 사이즈를 구하는 공식은 다음과 같다.

connection pool size = (Thread Number) * (Connection Number - 1)

  • 여기서 Connection Number
    • 단일 Thread 에 동시에 생성되는 connection 의 개수이다.

Hikari CP 가 제시하는 해결책 을 살펴보면, CPU cores 수 * 2 이상의 CP 가 필요한 일이 없을거라고 얘기한다.

(참고) 하지만, 실질적으로 성능 테스트를 해보고 우아한 기술팀이 내린 결론은 다음과 같다.

connection pool size = (Thread Number) * (Connection Number - 1) + 1 (Thread Number / 2)

Cascade

연관관계를 부여하면, 제약조건이 생성된다. 제약조건이 걸린 엔티티들 간의 변화에 어떻게 대응할지 전략을 선택해야 한다.

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

PERSIST

부모, 자식을 한 번에 영속화 시킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
@Table(name = "parent")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class Parent extends BaseTimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    @Builder.Default
    private List<Child> children = new ArrayList<>();

}

Parent 와 연관관계를 맺는 Child 도 함께 영속화된다.

REMOVE

PERSIST 로 저장했던 부모, 자식의 엔티티를 모두 제거하는 경우 사용한다.

1
2
3
Parent parent = EntityManger.find(Parent.class,1L);

EntityManager.remove(parent);

ALL

CascadeType.PERSIST, CascadeType.REMOVE 를 합한 옵션이다.

orphanRemoval 과 차이

Parent 와 Child 간의 연관관계가 끊어진 상황을 생각해본다. JPA 사용 시 연관관계에 관한 문제상황, 해결책을 정리한 블로그 에서 확인할 수 있다.

CascadeType.REMOVE 옵션은 논리적으로 참조를 변경시킨다. 참조값을 바꿔서 무결성(Referential Integrity) 오류 를 피해간다. 하지만, 부모와 자식의 연관 관계가 끊어지면 데이터는 남는다. 따라서, 부모가 사라진 자식은 고아 객체가 된다.

자식만 덩그러니 남은 상황에서 이 데이터를 지우기 위한 옵션이 orphanRemoval=true 이다.

CascadeType.ALL 주의점

편리하지만, 경우에 따라 위험한 상황을 초래한다.

자식 엔티티를 삭제하면 절대 안되는 경우인데, 어느 한쪽에서 부모 엔티티를 삭제하면 연관된 자식 엔티티가 같이 사라진다. PERSIST 옵션을 포함하고 있어, 지연로딩 시 1차 캐시에 들어가기 때문에 삭제가 안되는 경우가 발생한다.

따라서, PERSIST 옵션을 사용하지 않고 REMOVE 옵션만 줘야 하는 상황이 발생함을 기억해두자.

Pageable

Pageable 인터페이스의 구현체는 PageRequest

DB 부담을 줄이기 위한 페이지 설정은 거의 필수적인데, 추상 클래스로 페이징에 필요한 변수를 만들어 모든 엔티티, DTO 에 상속시켜서 사용할 수 있다. Spring Data JPA 에서 제공하는 Pageable 이라는 인터페이스를 사용할 수 있다.

repository 에서 다음과 같이 사용할 수 있다.

1
Page<User> findByMajor(String major, Pageable pageable);

페이징 처리를 하기 위해 Pageable 인터페이스의 구현체인 PageRequest 를 생성해 인자로 넘겨주면 된다.

PageRequst 가 받는 파라미터는 다음과 같다.

  • pageNumber : 페이지 번호 (0 부터 시작)
  • pageSize : 한 페이지에서 나타낼 데이터의 개수
  • direction : ASC, DESC
  • Sort : 정렬하는 기준
1
2
3
4
5
6
7
8
9
10
11
12
13
protected PageRequest(int pageNumber, int pageSize, Sort sort) {

  super(pageNumber, pageSize);

  Assert.notNull(sort, "Sort must not be null");

  this.sort = sort;
}


public static PageRequest of(int pageNumber, int pageSize, Direction direction, String... properties) {
  return of(pageNumber, pageSize, Sort.by(direction, properties));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Sort 의 by 메서드 - 1
public static Sort by(Direction direction, String... properties) {

  Assert.notNull(direction, "Direction must not be null");
  Assert.notNull(properties, "Properties must not be null");
  Assert.isTrue(properties.length > 0, "At least one property must be given");

  return Sort.by(Arrays.stream(properties)//
      .map(it -> new Order(direction, it))//
      .collect(Collectors.toList()));
}
// Sort 의 by 메서드 - 2
public static Sort by(String... properties) {

  Assert.notNull(properties, "Properties must not be null");

  return properties.length == 0 //
      ? Sort.unsorted() //
      : new Sort(DEFAULT_DIRECTION, Arrays.asList(properties));
}
// Sort 의 by 메서드 - 3
public static Sort by(List<Order> orders) {

  Assert.notNull(orders, "Orders must not be null");

  return orders.isEmpty() ? Sort.unsorted() : new Sort(orders);
}

// Order 클래스의 생성자
public Order(@Nullable Direction direction, String property) {
  this(direction, property, DEFAULT_IGNORE_CASE, DEFAULT_NULL_HANDLING);
}
// Order 클래스의 기본 정렬값 -> 오름차순
public static final Direction DEFAULT_DIRECTION = Direction.ASC;
1
2
3
4
5
6
// 1. PageRequest 는 여러 생성자를 가진다.
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
PageRequest pageRequest = PageRequest.of(0, 3, Sort.Direction.DESC, "username", "age"));
// 2. JpaRepository 에 의해, Pageable 인터페이스의 리턴 타입으로 Page 혹은 Slice 2가지가 존재한다.
Page<User> page = userRepository.findByMajor(pageRequest);
Slice<User> page = userRepository.findByMajor(pageRequest);

Page 혹은 Slice 타입으로 받을 수 있는데,

  • Page : total count 을 확인하는 쿼리가 나간다.
    • total count 를 구하는 쿼리는 join 상황에 따라 굉장히 복잡하고 무거운 쿼리가 나갈 수 있는 만큼
    • 최적화가 필요하다.
  • Slice : total count 쿼리가 발생하지 않는다. 내부적으로 limit + 1 만큼의 페이징을 한다.
    • 다음 페이지의 존재 여부를 표현해 줄 수 있다.

total count 쿼리 최적화

default 로 total count 는 모든 join 관계를 고려한 쿼리문에 select count 가 붙어서 발생한다. 따라서 경우에 따라 불필요한 자원이 소모될 수 있고, 이는 @QuerycountQuery 를 직접 작성하는 걸로 최적화를 할 수 있다.

1
2
@Query(countQuery = "select count(u) from User u where u.major = :major")
Page<User> findByMajor(String major, Pageable pageable);

출처

This post is licensed under CC BY 4.0 by the author.