백엔드/JPA

복합키 JPA 사용 시 연관관계 및 1차 캐시 주의 사항 - 2

꾸준함의 미더덕 2024. 1. 13. 22:22

안녕하세요 ~ 린내입니다!
저번 게시글에선 복합키를 사용한 엔티티의 연관관계 조회 이슈까지 알아보았습니다.

부모엔티티와 자식 엔티티를 각각 insert 하고나서, 그래프 탐색을 통해 조회를 하려고 시도했는데,
select문이 나가지 않거나 심지어 select문이 나갔는데도 조회결과가 없는 상황이 발생하였습니다.


왜 그런 것일까요?

저는 이 사항을 해결하기 위해 하이버네이트 영속성 컨텍스트와 1차 캐시에 대해 좀 더 이해할 필요가 있었습니다.

 

 

영속성 컨텍스트 및 1차 캐시

 

1차 캐시

  • 1차 캐시는 영속성 컨텍스트에 위치한 메모리 공간

 

엔티티 저장 방식

  • 영속성 컨텍스트에 엔티티 인스턴스가 보관되기 위해선 반드시 식별자 값이 필요합니다..
    • 아이덴티티, 시퀀스 PK전략에 따라, insert가 플러시 되는 시점이 다른 이유가 여기에 있습니다.
    • save() 시 (persist() 시) pk가 존재하면 1차 캐시에 보관하였다가, flush가 발생하면 insertDB로 전달됩니다.
    • save() (persist)시점에 pk값이 없으면 즉시 insert 문이 DB로 전달됩니다.
  • 엔티티 인스턴스가 영속성 컨텍스트 내부의 Map 형태로 1차 캐시에 보관됨
    • Map의 키는 @id로 매핑한 식별자값, 값은 엔티티 인스턴스!!

 

 

조회 시

 

 

 
  • 엔티티매니저의 find() 메소드가 호출되면 하이버네이트는 해당 엔티티를 먼저 1차 캐시에서 찾고, 없으면 DB를 조회한다.
    • 1차 캐시에 없는 것을 확인하고 DB 조회 후, 엔티티를 생성하여 1차 캐시에 저장한다..
  • 1차 캐시는 영속성 컨텍스트 내부에 위치함
  • 일반 트랜잭션 하나당 연속성 컨텍스트는 하나 존재
  • 따라서 트랜잭션 하나당 일차 캐시도 하나 존재하게 된다
  • 아래의 예시 코드 같은 경우, 1차 캐시가 존재하지 않는 것이 아니라, 각각의 트랜잭션 마다 각각의 독립된 영속성 컨텍스트 및 1차 캐시가 존재한 것으로 이해할 수 있다.
    • cf) 지연조회의 경우, 영속성 컨텍스트 내에서 프록시 접근 가능하므로 example2() 같이 트랜잭션으로 묶이지 않았다면 요청 결과에 대한 연관관계 지연조회는 불가능하다! → 조회해오자마자 영속성 컨텍스트 및 1차 캐시가 클리어 되는 것이므로…
  • @Transactional
    public void example1(){
        Person person1 = personJpaRepository.findById(3L).get(); 
        // select 쿼리 생성 및 fetch
        
        Person person2 = personJpaRepository.findById(3L).get();
        // select 문 생성 X  -> DB에서 가져온 것이 아니라, 1차 캐시에서 가져옴 
        // -> PK를 키로 인식하여 1차캐시에서 조회해온다.
    }
    
    //@Transactional 트랜잭션 어노테이션을 지정하지 않으면 모든 db요청 당 하나의 트랜잭션이 생기는 것!
    public void example2(){
        Person person1 = personJpaRepository.findById(3L).get(); 
        // select 쿼리 생성 및 fetch -> 트랜잭션 1
        
        Person person2 = personJpaRepository.findById(3L).get();
        // select 쿼리 생성 및 fetch -> 트랜잭션 2
        // 하나의 트랜잭션 안에서 작동되지 않고 있으므로 1차 캐시가 각각 생성되어 쿼리생성과 fetch가 각각 일어난다!
    }
  • 따라서 1차 캐시의 경우 각각의 트랜잭션 내에서 PK를 통한 동등 비교가 이루어짐을 알수가 있다!

 

 

저장 시

 

 

 
  • 영속성 컨텍스트는 1차 캐시에 엔티티를 저장하면서 동시에 등록 쿼리를 만들어 쓰기지연저장소에 보관함.
  • 트랜잭션을 커밋하면 엔티티 매니저는 우선 영속성 컨텍스트를 플러시한다
    • JPQL 쿼리를 호출해도 flush가 일어납니다..!
  • 플러시는 영속성 컨텍스트의 변경 내용을 DB에 동기화 하는 작업인데 이때 엔티티 정보들을 DB에 반영하는 것으로 이해할 수 있다..

이렇게 영속성 컨텍스트와 1차 캐시에 대한 이론을 정리하고 기존 코드로 되돌아가 봅니다..

@Transactional
public void postEmployee(EmployeeDto employeeDto){
    // 0. 엔티티 저장 
    Employee saved = employeeRepository.save(new Employee(employeeDto.getNickName(), employeeDto.getName()));
    employeeDetailRepository.save(new EmployeeDetail(saved.getNickName(), saved.getName())); // 1차 캐시 저장됨

    // 논리적으로는 연관관계가 설정된 것이 맞지 않을까? JPA가 무엇인가를 해주지 않았을까..?
    
    // 1. 저장한 엔티티로 연관관계 조회 
    int size = saved.getEmployeeDetailList().size();// select문 안나감 ! 
    System.out.println(size);  //-> PK 비교 후 1차 캐시에 있다고 판단 -> 위의 엔티티의 list 필드는 size() 0이 맞다..

    // 2. JPA findById()로 엔티티 조회 후, 연관관계 조회
    Employee found = employeeRepository.findById(new EmployeeId(saved.getNickName(), saved.getName())).orElseThrow();
    size = found.getEmployeeDetailList().size(); // 위의 find()메소드로도 select문 안나감 !
    System.out.println(size);  // 먼저 PK 값으로 1차캐시를 조회하는데, 같은 PK 값이 존재하므로 select가 flush되지 않음!!

    // 3. querydsl로 엔티티 조회 후, 연관관계 조회
    Employee foundByQuerydsl = employeeQueryRepository.findById(saved.getNickName(), saved.getName()).orElseThrow();
    size = foundByQuerydsl.getEmployeeDetailList().size(); // 그렇다면 이 경우는 select가 flush 되었는데 왜???
    System.out.println(size);
}
  • 1번 사항과 2번 사항은 지난 게시물에서 알아보았습니다.
  • 엔티티가 1차캐시에 저장되는 시점에 해당 인스턴스의 참조 주소값의 존재하는 연관관계 리스트는 길이가 0이 맞습니다.
    • insert문이 DB에 전달되어도, 혹은 쓰기지연 저장소에만 있어도 결과는 같습니다.
    • 1차 캐시에 저장되는 순간 엔티티 인스턴스의 리스트 길이는 0이 맞기 때문입니다!
  • 그런데 3번 사항에서는 ..? select 문이 전달되었는데요…??

 

 

 

  • 마지막 경우에 대해 설명하려면 querydsl과 이의 기반 기술인 JPQL에 대한 이해가 필요합니다.
  • QueryDsl은 기본적으로 "JPQL을 java코드로 쓸 수 있게 해주는" JPQL 빌더입니다.
  • JPQL을 호출하게 되면 flush가 즉시 일어나게 됩니다.
    • 쓰기지연저장소에 저장해둔 insert 및 update 쿼리가 flush 됩니다.
  • 또한 JPQL을 이용해 select문을 실행하면 1차 캐시를 건너뛰고 바로 DB에게 select문을 전달합니다.
    • 그러나 조회해온 결과를 가지고 1차 캐시와 비교를 합니다!
    • 조회해온 결과가 1차 캐시에 없다면 1차 캐시에 결과 엔티티를 저장합니다.
    • 조회해온 결과가 1차 캐시에 있다면 DB 조회 결과를 버립니다..! (충격..)

 

  • 정리해보자면
    • querydsl의 기반기술은 jpql입니다.
    • 따라서 querydsl로 select를 할 경우 먼저 1차 캐시를 건너뛰고 select문을 DB로 전달합니다.
    • 하지만, DB 결과값은 1차 캐시에 영향을 받습니다!
    • 위의 경우, 이미 1차 캐시에 동일한 PK에 해당하는 엔티티 인스턴스가 있으므로 querydsl의 DB 결과 조회값을 버립니다..!!!
  • 따라서 외의 예시코드의 모든 조회 결과, 연관관계 리스트의 길이는 0이 맞습니다..

 

 

 

  • 그렇다면 연관관계 조회, 어떻게 하는 것일까요?
  • 제가 위에서 뻘 짓을 많이 했지만….
  • 정답은 쉽습니다. 책이든 인강에서 많이 보았던 연관관계 편의 set메소드를 호출해주면 됩니다.
    • 식별관계 복합키를 사용하면, 키만으로도 논리적인 연관관계를 표현할 수가 있어서 연관관계 설정이 되었다고 착각하기 쉽습니다..
    • 하지만 1차 캐시의 경우 엔티티 인스턴스 그 자체로만 연관관계를 알수가 있으므로 꼭 인스턴스 안에서의 연관관계 편의 메소드 호출해주어야 합니다!
    • (애초에 식별관계 복합키를 사용하지 않으면 애초에 set()메소드 없이 연관관계를 설정할 수 없습니다..)
      • 식별관계 복합키는 외래키를 직접 제어하므로 set()메소드 사용하지 않더라도, 엔티티가 1차캐시에 저장되어 있지 않은 경우에 대해 연관관계 조회 가능 (다른 트랜잭션으로 조회한다면)
// 연관관계 편의 메소드
public void setTeam(Team team) {
    if (this.team != null) { 
        this.team.getMembers().remove(this); 
    }
    this.team = team;
    team.getMembers().add(this);
}

 

 

 

참고자료)
https://insanelysimple.tistory.com/406
https://www.youtube.com/watch?v=wBK2THyXmE4&list=LL&index=13
https://velog.io/@cws0718/Spring-JPA-QueryDsl%EC%9D%B4%EB%a%80

김영한, 자바 orm 표준 jpa 프로그래밍