programming study/B-JPA

JPA 영속성 컨텍스트(Persistence Context)의 5가지 특징

gu9gu 2022. 12. 20. 01:29

JPA 영속성 컨텍스트(Persistence Context)의 5가지 특징 — devoong2 (tistory.com)

 

 

 

 

영속성 컨텍스트 특징

1차 캐시
변경 감지 (Dirty Checking)
동일성 보장
지연 로딩(Lazy Loading)
쓰기 지연

1차 캐시


  • 영속성 컨텍스트 내부에서 엔티티를 캐시로 저장하는 것
  • 일반적으로 @Transactional 어노테이션과 라이프사이클이 동일함
  • OSIV(Open Session In View) 가 true 라면 ServiceLayer 에서
    @Transactional 이 종료되어도 PresentationLayer 까지도 1차 캐시는 유지됨
  • Jpa 는 데이터 조회시 캐시를 우선적으로 조회하고 캐시에 데이터가 없으면 DB를 조회함
@Transactional
public Team findTeam(Long id) {
    return teamRepository.findById(id)
        .orElseThrow(RuntimeException::new);
}

아래는 위 코드 호출시 발생한 log 입니다.
예상대로 select query 가 찍힌것을 확인할 수 있습니다.

@Transactional
public Team cashTest(TeamDto teamDto) {
    Team team = new Team(teamDto.getName());
    team = teamRepository.save(team);   // (1)

    Team team1 = teamRepository.findById(team.getId())
            .orElseThrow(RuntimeException::new);    // (2)

    return team;
}

아래 로그 이미지를 보시면 query는 insert 쿼리 한번 외에는 아무 query 도 찍히지 않았습니다.
(1) 의 save 메소드가 호출되며 Team Entity 에 바로 1차캐시가 저장되었기 때문입니다.
(2) 의 findById 를 호출해도 select 쿼리가 찍히지 않은것은 Team Entity 에 저장된 1차 캐시를 읽어왔기 때문입니다.

@Transactional
public Team cashTest(Long id) {
    Team team = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (1)

    Team team2 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (2)

    return team;
}

위의 코드도 동일하게 select query 가 한번만 호출되었습니다.
(1) 에서 findById 를 통해 Entity 를 가져와서 1차 캐시에 저장해놓았기 때문입니다.
그래서 (2) 에서 동일한 동작을 하더라도 DB가 아닌 1차 캐시에서 데이터를 가져오게 됩니다.

주의

@Transactional
public Team cashTest3(Long id) {
    Team team1 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (1)

    Team team2 = teamRepository.findByName("test"); // (2)

    Team team3 = teamRepository.findByName("test"); // (2)

    return team1;
}

위 코드를 호출하면 select query 가 몇번 호출될거라 예상하시나요 ?
정답은 3번입니다.
(1) 에서 Team 이 호출되었으니 1차캐시에 저장되어 (2) , (3) 에선 1차 캐시를 조회하기 때문에
한번만 호출될거라고 생각하셨을수 있습니다.

지만 1차 캐시를 사용하는데에는 식별자(id) 로 조회하는 경우에만 해당된다는 특징이 있습니다.
왜냐하면 영속성 컨텍스트는 내부적으로 식별자로 Entity를 관리하기 때문에 영속상태인 객체는 식별자가 반드시 있어야합니다.

위의 (2) , (3) 처럼 query methods 방식을 사용하게되면 영속성 컨텍스트가 관리하는 식별자로 값을 찾는게 아닌 JPQL로 쿼리가 나가기 때문에 1차캐시에서 데이터를 불러오지 못합니다.

@Transactional
public Team cashTest3(Long id) {
    Team team1 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (1)

    Team team2 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (2)

    Team team3 = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);    // (3)

    return team1;
}

위의 코드는 (1) , (2) , (3) 모두 식별자를 이용한 쿼리이기 때문에
실제 select query 는 한번만 호출된걸 확인할 수 있습니다.

변경 감지 (Dirty Checking)


  • 트랜잭션 안에서의 엔티티의 변경을 감지하여 변경이 일어나면
    변경 내용을 자동으로 DB에 반영
@Transactional
public void getTeam(Long id) {
    Team team = teamRepository.findById(id)
            .orElseThrow(RuntimeException::new);

    team.setName("dirty-checking-test");
}
  1. Transaction 이 시작합니다.
  2. Team Entity 를 조회합니다.
  3. Team Entity 의 Name 을 "dirty-checking-test" 로 수정합니다.
  4. Transaction 이 종료됩니다.

로그를 확인해보겠습니다.

분명 update query 를 요청한 로직이 없는데 트랜잭션이 끝남과 동시에 update query 가 찍혀있는 것을 확인할 수 있습니다.

동작 방식

  1. Jpa 는 영속상태의 엔티티가 최초 조회되었을때 최초 조회된 상태를 기준으로 따로 snapshot을 만들어 놓습니다.
  2. 그리고 트랜잭션이 종료되기전 flush()가 일어나는 시점에 현재 Entity의 상태와 snapshot에 저장된 Entity 의 상태를 비교하여 update query 를 생성합니다.
  3. 생성한 update query 를 쓰기지연 SQL 저장소에 보냅니다.
  4. 트랜잭션이 commit() 되는 시점에 DB로 update query 를 전송합니다.

트랜잭션이 종료되기 전 이라 표현한 이유는 트랜잭션이 끝나는 시점에 flush 가 발생하기 떄문입니다.

만약 트랜잭션 내에서 flush 를 실행하게 되는 JPQL 이 실행되거나

강제로 entityManager.flush() 를 호출하게 되어도 동일하게 update query 가 발생합니다.

추가로 flush 가 발생했다고 바로 DB에 반영되는것은 아닙니다.
flush 는 트랜잭션을 커밋하지 않기 때문에 이후에 커밋이 되어야 DB에 반영됩니다.

동일성 보장


  • 같은 Transaction 혹은 같은 EntityManager 에서 가져온 객체는
    항상 동일한 객체를 리턴한다.

아래와 같이 테스트코드를 작성해보았습니다.

@Test
@Transactional
public void 동일성_테스트() {
    Team team1 = teamRepository.findById(1L).get();
    Team team2 = teamRepository.findById(1L).get();

    boolean val = team1 == team2;
    System.out.println("### 동일성 테스트 결과 : " + val);
    assertThat(val).isEqualTo(true);
}

동일성을 보장하다는 테스트코드 결과를 확인할 수 있습니다.
그렇다면 같은 트랜잭션이 아닌 케이스도 한번 확인해보겠습니다.

@Test
public void 동일성_테스트_v2() {
    Team team1 = teamRepository.findById(1L).get();
    Team team2 = teamRepository.findById(1L).get();

    boolean val = team1 == team2;
    System.out.println("### 동일성 테스트 결과 : " + val);
    assertThat(val).isEqualTo(true);
}

메소드에 @Transactional 어노테이션을 제거하여 테스트 해보았습니다.
예상한대로 동일성 보장이 되지 않았고 테스트코드도 실패한걸 확인할 수 있습니다.

지연 로딩(Lazy Loading)


  • 연관관계에 있는 엔티티를 조회시 한번에 가져오지 않고
    필요시에 가져오는 것.
  • 연관관계에 있는 객체는 프록시 상태로 초기화되지 않은 상태로 존재함.
  • 필요시에 가져오기 때문에 불필요한 쿼리를 실행하지 않을 수 있음

각 연관관계의 기본 로딩 방식


  • OneToMany : Lazy
  • ManyToMany : Lazy
  • ManyToOne : Eager
  • OneToOne : Eager

Team.java

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Team {

    public Team(String name) {
        this.name = name;
    }

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

    private String name;

    @OneToMany(fetch = FetchType.LAZY , cascade = CascadeType.ALL)
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();

    public void addMembers(Member member) {
        this.members.add(member);
    }
}

Member.java

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Member {

    public Member(String name) {
        this.name = name;
    }

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

    @Column(name = "team_id")
    private Long teamId;

    private String name;
}

예제에서 사용할 TeamEntity 입니다.
Team(1) : Member(N) - 1:N 단방향으로 설계를 해봤습니다.

지연 로딩으로 객체를 가져오게 되면 객체는 사용되기 전까지는 프록시객체로
초기화되지 않은 상태로 있게됩니다.
이 점을 이용하여 테스트코드를 작성해보겠습니다.

@SpringBootTest
class 지연_로딩_테스트_클래스 {

        @Autowired
        private TeamRepository teamRepository;

        @Autowired
        private MemberRepository memberRepository;

        @Autowired
        private EntityManager entityManager;

        @BeforeEach
        void init() {
            Team team = new Team("test");
            Member member = new Member("member1");
            Member member2 = new Member( "member2");
            Member member3 = new Member("member3");

            team.addMembers(member);
            team.addMembers(member2);
            team.addMembers(member3);
            teamRepository.save(team);

            entityManager.flush();
            entityManager.clear();
            entityManager.close();
        }

        @Test
        @Transactional
        public void 연관관계_객체가_프록시객체라면_성공() {
                Team team = teamRepository.findById(1L)
                                .orElseThrow(RuntimeException::new); // (1)

                boolean isLoad = entityManager.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(team.getMembers()); // (2)
                assertThat(isLoad).isEqualTo(false); // (3)
        }

        @Test
        @Transactional
        public void 연관관계_객체가_초기화_되었다면_성공() {
                Team team = teamRepository.findById(1L)
                                .orElseThrow(RuntimeException::new); // (1)

                System.out.println(team.getMembers().size()); // (2)

                boolean isLoad = entityManager.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(team.getMembers()); // (3)
                assertThat(isLoad).isEqualTo(true); // (4)
        }
}

entityManager.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded()

  • 해당 객체가 프록시 객체가 초기화되었는지 유무를 나타냄.
  • 프록시 객체가 초기화되어 로드되었다면 true , 초기화되지 않았다면 false.

첫번째 메소드 우선 확인해보겠습니다.
(1) - TeamEntity 를 조회합니다. 연관관계는 Lazy 입니다.
(2) - team.getMembers() 는 List 객체를 리턴합니다.
하지만 해당 객체는 한번도 사용되지 않았으니 초기화되지 않았으니 false 를 리턴할 것으로 예상됩니다.
(3) - isLoad 는 예상대로 false 를 반환합니다.

두번째 메소드 를 한번 확인해보겠습니다.

(1) - TeamEntity 를 조회합니다. 연관관계는 Lazy 입니다.
(2) - team.getMembers().size() 를 호출했습니다.
즉, 프록시객체를 사용한 것입니다. 이렇게 되면 해당 객체는 초기화됩니다.
(3) - team.getMembers() 객체는 위에서 사용되었으니 true를 리턴할 것으로 예상됩니다.
(4) - isLoad 는 예상대로 true를 반환합니다.

  • 추가적으로 객체가 초기화되며 select query 발생한것을 이미지에서 학인할 수 있습니다.

쓰기 지연


  • 한 트랜잭션 내에서 발생하는 insert query 혹은 영속상태인 객체에서 일어나는
    update , delete query 는 쓰기지연 SQL 저장소에 저장되었다가 트랜잭션이 종료될때 한번에 날아감.

테스트코드로 udpate 먼저 확인해보겠습니다.

@Nested
class 쓰기_지연_테스트_클래스 {

    @Test
    @Transactional
    @Rollback(value = false)
    public void 쓰기지연_update_test() {
            Long id = init().getId();

            Team team = teamRepository.findById(id).get();

            System.out.println("### UPDATE BEGIN");
            team.setName("CHANGE");
            System.out.println("### UPDATE END");
    }
}

정상적으로 query 가 찍히는것을 확인하기 위해 Rollback 옵션을 false 로 주었습니다.
Test 에서의 @Transactional 은 자동적으로 Rollback 이 되기 때문에 jpa 에서 query 가 찍히지 않는 경우가 있어 확인을 위해 넣었습니다.

변경감지 기능을 이용하여 update 해보겠습니다.
과연 update query 가 어느 위치에 찍힐까요?

update query 가 가장 마지막에 찍힌것을 확인하실 수 있습니다.
트랜잭션이 종료되며 쓰기지연 저장소에 있던 query 가 실행된것입니다.

마찬가지로 delete 도 확인해보겠습니다.

@Test
@Transactional
@Rollback(value = false)
public void 쓰기지연_delete_test() {
        Long id = init().getId();

        Team team = teamRepository.findById(id).get();

        System.out.println("### DELETE BEGIN");
        teamRepository.delete(team);
        System.out.println("### DELETE END");
}

delete query 역시 update query와 동일합니다.

이번엔 마지막으로 insert query 에 대해 알아보겠습니다.

@Test
@Transactional
@Rollback(value = false)
public void 쓰기지연_insert_test() {
        System.out.println("### INSERT BEGIN");
        Team team = new Team("test");
        Team team2 = new Team("test2");
        teamRepository.save(team);
        teamRepository.save(team2);
        System.out.println("### INSERT END");
}

Team.java
public class Team {
    public Team(String name) {
            this.name = name;
    }

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

테스트를 실행하기전에 GenerationType을 SEQUENCE 로 변경하였습니다. AUTO 도 상관없습니다.
코드를 실행해보겠습니다.

시퀀스를 저장과 동시에 가져오고 이후에 트랜잭션이 종료되는 시점에
정상적으로 insert query 가 마지막에 찍힌것을 볼 수 있습니다.

이번엔 GenerationType을 IDENTITY으로 변경해서 다시 실행해보겠습니다.

public class Team {
    public Team(String name) {
            this.name = name;
    }

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

뭔가 이상합니다. GenerationType을 IDENTITY 으로 변경했을 뿐인데 예상과 다르게
트랜잭션이 종료되기전인 저장하자마자 바로 query가 실행이 되었습니다.

이를 이해하기 위해서는 GenerationType.IDENTITY 의 특징에 대해 찾아볼 필요가 있습니다.

GenerationType.IDENTITY

  • 기본키 생성을 DB에 위임
  • DB에서 자동으로 auto_increment 로 저장됨

즉, DB에 insert 가 되어야만 id 값을 알 수가 있습니다.
기본키 매핑전략중 GenerationType.IDENTITY 가 유일합니다.
그렇기 때문에 IDENTITY 전략은 insert 시 쓰기지연의 혜택을 받을 수 없습니다.