| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- mockito
- convertAndSendToUser
- AWS
- 프로젝트 이름 변경
- injellij
- oauth2.0
- JPA
- redis
- naturalid
- websocket
- awspring
- 정적 팩터리 메서드
- 컨트리뷰터
- N + 1
- fetch join
- @RequestMapping
- @Transaction(readOnly=true)
- spring-cloud-starter-aws
- MySQLTransactionRollbackException
- Cannotacquirelockexception
- 성능테스트
- intellij
- 이펙티브 자바
- batch insert
- assert
- @controller
- OIDC
- spring
- Git
- ngrinder
- Today
- Total
정리정리
@NaturalId와 캐싱 및 동작 방식 본문
@NaturalId
@NaturalId는 NaturalId(자연 키) 임을 명시해 주고, 캐싱과 같은 추가적인 기능을 제공하는 하이버네이트의 어노테이션입니다.
자연 키로 사용하고 싶은 필드 위에 붙여주는 방식으로 사용할 수 있습니다.
이 어노테이션은 JPA의 어노테이션이 아니기 때문에 캐싱을 사용하려면 하이버네이트의 API를 사용해야 합니다.
import org.hibernate.annotations.NaturalId;
@Entity
@Getter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NaturalId
private String email;
public Member(String email) {
this.email = email;
}
}
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager entityManager;
private final MemberSpringJpaRepository memberJpaRepository;
public Member save(Member member) {
return memberJpaRepository.save(member);
}
public Optional<Member> findById(Long id) {
return memberJpaRepository.findById(id);
}
public Optional<Member> findByEmail(String email) {
return memberJpaRepository.findByEmail(email);
}
/**
* NaturalId 전용 API를 이용한 조회 메서드
*/
public Optional<Member> findByEmailWithNatural(String email) {
return entityManager.unwrap(Session.class)
.bySimpleNaturalId(Member.class)
.loadOptional(email);
}
}
Hibernate의 API를 사용하기 위해 findByEmailWithNatural 메서드에 있는 것처럼 SessionImpl을 가져와서 메서드를 이용해야 합니다.
기존의 JPA의 쿼리와 다른 점을 확인해보기 위해 두 메서드를 만들었습니다.
- findByEmail: Spring Data Jpa에서 제공하는 이메일 조회 쿼리
- findByEmailWithNatural: 하이버네이트의 Natural Id 캐싱을 이용한 이메일 조회 쿼리
@SpringBootTest
@Transactional
record MemberCacheTest(
MemberRepository memberRepository,
EntityManager em
) {
@Test
void 쿼리가_한_번_발생해야_함1() throws Exception {
// given
Member member = memberRepository.save(new Member("email@email.com"));
em.flush();
em.clear();
// when then
memberRepository.findById(member.getId()).orElseThrow();
memberRepository.findById(member.getId()).orElseThrow();
}
@Test
void 단순_조회하는_경우_쿼리가_두_번_발생해야_함() throws Exception {
// given
String email = "email@email.com";
memberRepository.save(new Member(email));
em.flush();
em.clear();
// when then
memberRepository.findByEmail(email).orElseThrow();
memberRepository.findByEmail(email).orElseThrow();
}
@Test
void naturalId용_api로_조회하는_경우_쿼리가_한_번_발생해야_함() throws Exception {
// given
String email = "email@email.com";
Member member = memberRepository.save(new Member(email));
em.flush();
em.clear();
// when then
memberRepository.findById(member.getId()).orElseThrow();
memberRepository.findByEmailWithNatural(email).orElseThrow();
}
}
캐싱을 테스트해보기 위해 위와 같은 테스트 코드를 작성하였습니다.
하나씩 실행해보면서 결과를 확인해 보겠습니다.
@Test
void 쿼리가_한_번_발생해야_함1() throws Exception {
// given
Member member = memberRepository.save(new Member("email@email.com"));
em.flush();
em.clear();
// when then
memberRepository.findById(member.getId()).orElseThrow();
memberRepository.findById(member.getId()).orElseThrow();
}

Jpa는 기본적으로 같은 세션 안에서 데이터를 조회할 경우 1차 캐시에 엔티티를 저장하기 때문에 조회를 통해 캐시에 저장된 데이터를 Id 값을 통해 다시 조회를 하게 되면 쿼리가 발생하지 않습니다.
따라서 위와 같은 케이스에서는 첫 번째 조회 시 쿼리가 한 번 발생하고 이후 조회에서는 캐시에 있는 데이터를 사용합니다.
@Test
void 단순_조회하는_경우_쿼리가_두_번_발생해야_함() throws Exception {
// given
String email = "email@email.com";
Member member = memberRepository.save(new Member(email));
em.flush();
em.clear();
// when then
memberRepository.findById(member.getId()).orElseThrow();
memberRepository.findByEmail(email).orElseThrow();
}

단순 Spring Data를 이용하여 @NaturalId를 조회하는 경우에는 일반적으로 쿼리가 두 번 발생합니다.
@Test
void naturalId용_api로_조회하는_경우_쿼리가_한_번_발생해야_함() throws Exception {
// given
String email = "email@email.com";
memberRepository.save(new Member(email));
em.flush();
em.clear();
// when then
memberRepository.findById(member.getId()).orElseThrow();
memberRepository.findByEmailWithNatural(email).orElseThrow();
}

hibernate의 naturalId 조회용 api를 사용하는 경우 1차 캐시에 저장된 데이터를 먼저 조회하기 때문에 쿼리가 한 번만 발생한 것을 볼 수 있습니다.
동작 방식
public Optional<Member> findByEmailWithNatural(String email) {
return entityManager.unwrap(Session.class)
.bySimpleNaturalId(Member.class)
.loadOptional(email);
}
동작 방식은 위의 메서드를 따라가 보면 금방 확인할 수 있었습니다.


우선 bySimpleNaturalId(Member.class) 메서드를 따라가면 BaseNaturalIdLoadAccessImpl를 상속받은 SimpleNaturalIdLoadAccessImpl객체를 만드는 것을 볼 수 있습니다.

이제 loadOptional(email) 부분을 계속 따라가면 위에서 확인했던 BaseNaturalIdLoadAccessImpl에서 핵심 구현 메서드인 doLoad를 찾을 수 있습니다.

여기서 핵심이 되는 부분은 157줄과 166줄입니다.
157줄에서 파라미터로 넘긴 naturalId 값을 통해 캐시에 데이터가 있는지 확인하고 있으면 해당 엔티티의 Id 값을, 없으면 null 값을 리턴합니다.
해당 값을 가지고 166줄에서 Id 값이 있으면 캐시에서, 없으면 기존 파라미터로 넘어온 값을 통해 쿼리를 만들어 db에서 데이터를 가져오는 작업을 하게 됩니다.
@Test
void naturalId용_api로_조회하는_경우_쿼리가_한_번_발생해야_함2() throws Exception {
// given
String email = "email@email.com";
Member member = memberRepository.save(new Member(email));
em.flush();
em.clear();
// when then
memberRepository.findByEmailWithNatural(email).orElseThrow();
memberRepository.findByEmailWithNatural(email).orElseThrow();
}
캐시에 데이터가 있을 때와 없을 때의 차이를 확인하기 위해 위의 테스트로 디버깅을 해봤습니다.
처음 데이터를 조회할 때는 1차 캐시에 엔티티가 없기 때문에 쿼리가 발생하고, 두 번째 조회에는 캐시에서 데이터를 가져오기 때문에 쿼리가 발생하지 않아야 합니다.

처음 조회를 하는 경우 캐시에 데이터가 없기 때문에 cachedResolution값이 null이고, db에서 데이터를 가져오는 방식을 선택하기 때문에 쿼리가 발생하게 됩니다.

두 번째 조회하는 경우 캐시에 엔티티가 있기 때문에 cachedResolution이 Id 값인 1로 리턴되고, 해당 값을 가지고getIdentifierLoadAccess().load(cachedResolution)를 통해 캐시에서 데이터를 가져오기 때문에 쿼리가 추가로 발생하지 않는 것을 볼 수 있습니다.
참고
https://techblog.woowahan.com/17221/
https://thorben-janssen.com/naturalid-good-way-persist-natural-ids-hibernate/
'JPA' 카테고리의 다른 글
| @Transactional(readOnly = true) 성능 향상되는 이유 (0) | 2023.05.01 |
|---|---|
| JPA N + 1 문제 (0) | 2023.04.11 |
| [JPA] CascadeType.DELETE, orphanRemoval = true 차이 (0) | 2023.03.27 |
| Spring JPA 데이터베이스 초기화 (0) | 2023.03.27 |
| JPA, Hibernate, Spring Data JPA, JDBC (0) | 2023.03.27 |