JPA 관한 기록
Eraser 를 이용해 만든 직접 자료입니다.
공부하면 할수록 어려운 JPA 를 정리합니다 😭
영속성 컨텍스트
- 영속성 컨텍스트는
@Id
필드를 기준으로 엔티티를 식별 persist()
시,1차 캐시
에 저장- 해당 엔티티의 상태는
Managed
가 됨 EntityManager
가commit()
을 호출하면, 쓰기 지연 저장소에 캐싱된 SQL 이 DB 에 반영됨commit()
메서드 호출 시,flush()
일어남- SQL 문은
flush()
과정을 통해 DB 에 반영- 단,
flush()
는 1차 캐시를 비우는clear()
를 발생시키지 않음 - 트랜잭션이 종료되는
commit
이후 1차 캐시가 사라진다. - 혹은
JPQL
의 default 설정으로JPQL
실행과 동시에flush()
가 일어난다.
- 단,
clear()
실행 시- 영속성 컨텍스트에서 엔티티 제거
- 1차 캐시 초기화
- 엔티티를 다시 로딩
- 이전 엔티티들은 준영속 상태로 변경
- 변경 사항 존재해도 DB 에 반영 X
- 한 트랙잭션 내에서 이전에 변경한 내용이 있다면 모두 롤백된다.
- 해당 엔티티의 상태는
close()
실행 시- 영속성 컨텍스트의 완전 종료
1차 캐시
영속성 컨텍스트
안에 포함되어 관리되는 엔티티는 1차 캐시
에 관리된다.
- 엔티티에 부여한
@Id
값, 엔티티의 이름의 쌍으로 저장된다. .flush()
시점 이전에 1차 캐시에 있는 값을 조회하면- DB 에 있는 데이터와 동기화가 안되어 오류가 날 수 있다.
- 동기화 오류가 발생하면,
EntityManager.flush()
로 직접 호출하는 방법이 있다.
1차 캐시가 초기화 되는 시점
EntityManager
를 통해 직접적으로flush()
를 실행한다.JPQL
의 default 설정으로JPQL
쿼리 실행 시flush()
를 실행한다.- 트랜잭션이 성공적으로 종료되어
commit
이 발생하면flush()
가 실행된다.
2차 캐시
어플리케이션이 살아있는 동안 공유되는 세션이 존재한다. 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
쓰기 지연 저장소
위 그림은 캐시된 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
의 수가 굉장히 많다. 데이터베이스와의 연결 비용을 아끼지만, JVM
의 Heap 메모리
를 많이 사용하게 되는 Trade-Off 가 있음을 기억해두자.
BatchSize
에 따른 메모리 분석
Batch fetch size | Heap memory usage(MB) | EntityLoader instances | EntityLoader memory usage(MB) | String memory usage(MB) | char[] memory usage(MB) |
---|---|---|---|---|---|
1 | 363 | 13864 | 1.3 | 13 | 81.5 |
2 | 404 | 27724 | 2.6 | 14.5 | 109 |
3 | 446 | 41584 | 4 | 16.1 | 137 |
4 | 493 | 55444 | 5.3 | 17.7 | 166 |
5 | 532 | 69304 | 6.6 | 19.2 | 194 |
7 | 621 | 97024 | 9.3 | 22.4 | 253 |
10 | 753 | 138604 | 13.3 | 27.1 | 342 |
15 | 797 | 152464 | 14.6 | 28.6 | 373 |
20 | 796 | 152464 | 14.6 | 28.6 | 374 |
30 | 836 | 166324 | 15.9 | 30.2 | 408 |
50 | 889 | 180184 | 17.3 | 31.7 | 446 |
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;
}
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());
}
}
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
관계인 경우
Article
이User
에 관해 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);
BatchSize
1
2
3
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();
위와 동일한 쿼리를 날리면,
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();
과격하지만 어떤 상황에서는 간단명료할 수 있는 어노테이션이다. 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();
실행은 되지만, 근본적으로 페이징 처리 관련한 문제는 해결하지 못한다. 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);
결론적으로 @BatchSize
지정한 경우,
Article
값을 구하려고하면Article
만 따로 Batch 쿼리를 발생시켜 가져온다.
Connection Management
Hibernate
는Connection Pool
구현체로Hikari
를 사용한다.
@Transactional
은 AOP
를 사용해 Proxy 객체를 생성한다는 점을 기억하자. Hibernate
는 CP
의 구현체로 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 가 붙어서 발생한다. 따라서 경우에 따라 불필요한 자원이 소모될 수 있고, 이는 @Query
에 countQuery
를 직접 작성하는 걸로 최적화를 할 수 있다.
1
2
@Query(countQuery = "select count(u) from User u where u.major = :major")
Page<User> findByMajor(String major, Pageable pageable);
출처
- https://www.setgetweb.com/p/wxs70/com.ibm.websphere.extremescale.over.doc/cxscchbeh.html
- https://www.baeldung.com/spring-transactions-read-only
- https://everydayyy.tistory.com/157
- https://siyoon210.tistory.com/138
- https://gmlwjd9405.github.io/2019/08/06/persistence-context.html
- https://jongminlee0.github.io/2020/02/11/jpa5/
- https://victorydntmd.tistory.com/207
- [JPA] commit, flush, Entity Manager의 clear()와 close()에서 궁금한 부분들 탐구 + 데이터 삭제 및 수정 시 1차 캐시에서 발생하는 현상 + 준영속과 비영속의 차이점
- https://cla9.tistory.com/100
- https://yjksw.github.io/jpa-default-batch-fetch-size-not-working/
- https://granger.tistory.com/67
- https://prasanthmathialagan.wordpress.com/2017/04/20/beware-of-hibernate-batch-fetching/
- https://jojoldu.tistory.com/414
- https://multifrontgarden.tistory.com/280
- https://ilovepotato.tistory.com/36
- https://resilient-923.tistory.com/417
- [JPA] Fetch 전략 공부하기 - @OneToOne 양방향 매핑에서 Lazy가 동작하지 않는 이유
- https://www.baeldung.com/hibernate-second-level-cache
- [JPA] Second-Level Cache
- 하이버네이트와 EHCACHE 적용
- [Spring boot] JPA Delete is not Working, 영속성와 연관 관계를 고려했는가.
- https://huisam.tistory.com/entry/routingDataSource
- https://techblog.woowahan.com/2664/
- Spring Transaction and Connection Management
- https://escapefromcoding.tistory.com/712
- JPA 모든 N+1 발생 케이스과 해결책
- JPA : Pageable 객체를 이용한 페이징