본문 바로가기
프로젝트/KokoaHairshop

JPA 영속성 컨텍스트 주의점

by 걸어가는 신사 2022. 7. 17.

1. 문제 발생

  • JPQL을 통해서 Designer를 조회할 때 fetch join을 통해서 OneToMany 관계를 맺고 있는 ReservationTime까지 정보를 가져오려고 시도하였다.
  • 이때 Designer 정보는 잘가져오지만 Designer에 속하는 List<ReservationTime> 정보는 계속 size가 0인 문제가 발생하였다.

코드를 통해서 자세히 살펴보자.

Entity

@Entity
@Getter
@Table(name = "designer")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Designer {

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

    @Column(name = "name", nullable = false, columnDefinition = "varchar(20)")
    private String name;

    @Column(name = "profile_img", nullable = false, columnDefinition = "varchar(200)")
    private String image;

    @Column(name = "introduction", nullable = false, columnDefinition = "varchar(300)")
    private String introduction;

    @Enumerated(EnumType.STRING)
    @Column(name = "position", nullable = false, columnDefinition = "varchar(20)")
    private Position position;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hairshop_id", referencedColumnName = "id")
    private Hairshop hairshop;

    @OneToMany(mappedBy = "designer")
    private List<Reservations> reservations = new ArrayList<>();

    @OneToMany(mappedBy = "designer")
    private List<ReservationTime> reservationTimes = new ArrayList<>();

    @Builder(toBuilder = true)
    public Designer(Long id, String name, String image, String introduction,
        Position position, Hairshop hairshop, List<ReservationTime> reservationTimes) {

        this.id = id;
        this.name = name;
        this.image = image;
        this.introduction = introduction;
        this.position = position;
        this.hairshop = hairshop;
        if(reservationTimes != null) {
            this.reservationTimes = reservationTimes;
        }

    }

}

 

@Entity
@Table(name = "reservation_time")
@Getter
@NoArgsConstructor
public class ReservationTime {

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

    @Column(name = "date", nullable = false)
    private LocalDate date;

    @Column(name = "time", nullable = false, columnDefinition = "char(5)")
    private String time;

    @Column(name = "reserved", nullable = false)
    private Boolean reserved;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "designer_id", referencedColumnName = "id")
    Designer designer;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hairshop_id", referencedColumnName = "id")
    Hairshop hairshop;

    @Builder
    public ReservationTime(Long id, LocalDate date, String time, boolean reserved, Designer designer, Hairshop hairshop) {
        this.id = id;
        this.date = date;
        this.time = time;
        this.reserved = reserved;
        this.designer = designer;
        this.hairshop = hairshop;
    }

    public void setDesigner(Designer designer) {
        this.designer = designer;
    }

    public void setHairshop(Hairshop hairshop) {
        this.hairshop = hairshop;
    }

    public void changeReserved() {
        this.reserved = true;
    }

}

Repository

public interface DesignerRepository extends JpaRepository<Designer, Long> {

    @Query("select distinct d from Designer d join fetch d.reservationTimes r "
        + "where d.hairshop.id = :hairshopId and r.date = :date and r.reserved = false")
    List<Designer> findDesignerFetchJoinByHairshopIdAndDate(@Param("hairshopId") Long hairshopId, @Param("date") LocalDate date);
}

Test Code

@Test
@DisplayName("designer와 ReservationTimes 테이블을 fetch join 해서 가져올 수 있다.")
void findDesignerFetchJoinByHairshopIdAndDateTest() {
    // Given
		hairshop = Hairshop.builder()
        .name("데브헤어")
        .phoneNumber("010-1234-5678")
        .startTime("11:00")
        .endTime("20:00")
        .build();
    em.persist(hairshop);

    designer = Designer.builder()
        .name("디자이너1")
        .position(Position.DESIGNER)
        .introduction("안녕하세요")
        .image("이미지 URL")
        .build();
    em.persist(designer);

    reservationTime1 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime1);

    reservationTime2 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime2);

    // When
    List<Designer> designers = designerRepository.findDesignerFetchJoinByHairshopIdAndDate(
        hairshop.getId(), LocalDate.now());

    // Then
    assertThat(designers.get(0).getReservationTimes()).isIn(reservationTime1, reservationTime2);
}
  • Designer를 영속화 한 이후 ReservationTime1, ReservationTime2의 영속화를 진행하였다.
  • 그리고 fetch join 쿼리를 통해서 Designer와 Designer가 가지고 있는 ReservationTime List를 가져온다.
  • Assertions 문을 통해서 리스트 reservationTimes에 ReservationTime1, ReservationTime2이 있는지 검증하였다.

하지만 Test Code는 실패한다.

 

  • 디버깅 결과 designers의 정보는 있지만 reservationTimes의 SIZE가 0인 것을 확인할 수 있다.
  • 그렇다면 reservationTime들은 영속화가 안될 것 일까?

  • designer insert, reservationTime1, reservationTime2 insert 쿼리를 확인할 수 있다.

  • 또한 jpql을 통한 fetch 쿼리 또한 문제없이 작동하고 있다.

영속화도 잘되었고 insert, select 쿼리도 잘 날아갔는데 ReservationTime 리스트가 가져와지지 않는 것 일까? 이 이유를 찾기 위해 오랫동안 고민하였고 그 이유는 영속성 컨텍스트의 1차 캐시 저장 메커니즘에 있다.

 

2. Error 원인 분석

영속성 컨텍스트 1차 캐시 저장 메커니즘

  • findById() 같은 경우는 영속성 컨텍스트를 먼저 찾고 영속성 컨텍스트에 해당 엔티티가 있으면 그 값을 바로 리턴한다. 이를 1차 캐시라고 한다.

반면 JPQL은 영속성 컨텍스트를 먼저 조회하지 않고 데이터베이스에 query 하여 결과를 가져온다.

  1. JPQL을 호출하면 데이터베이스에 우선적으로 조회한다.
  2. 조회한 값을 영속성 컨텍스트에 저장을 시도한다.
  3. 저장을 시도할 때 해당 데이터가 이미 영속성 컨텍스트에 존재하는 경우 (영속성 컨테스트에서는 식별자로 값으로 식별) 데이터베이스에서 조회한 신규 데이터를 버린다.

테스트가 실패하는 이유

  • 영속성 컨텍스트 저장 메커니즘 중 3번째 동작 때문에 테스트가 실패한다.
    • JPQL Fetch Join을 사용했기 때문에 designer, reservationTime 객체를 한 번에 가져오게 된다.
    • 하지만 designer, reservationTime이 1차 캐시에 이미 존재 하므로 해당 쿼리를 통해서 가져온 데이터를 버리게 된다.

 

3. Error 해결

(1) Designer 객체에 ReservationTime 객체 추가하기

@Test
@DisplayName("designer와 ReservationTimes 테이블을 fetch join 해서 가져올 수 있다.")
void findDesignerFetchJoinByHairshopIdAndDateTest() {
    // Given
		hairshop = Hairshop.builder()
        .name("데브헤어")
        .phoneNumber("010-1234-5678")
        .startTime("11:00")
        .endTime("20:00")
        .build();
    em.persist(hairshop);

    designer = Designer.builder()
        .name("디자이너1")
        .position(Position.DESIGNER)
        .introduction("안녕하세요")
        .image("이미지 URL")
        .build();
    em.persist(designer);

    reservationTime1 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime1);

    reservationTime2 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime2);

    designer.reservationTimes.add(reservationTime1);
    designer.reservationTimes.add(reservationTime2);

    // When
    List<Designer> designers = designerRepository.findDesignerFetchJoinByHairshopIdAndDate(
        hairshop.getId(), LocalDate.now());

    // Then
    assertThat(designers.get(0).getReservationTimes()).isIn(reservationTime1, reservationTime2);
}

 

designer.reservationTimes.add(reservationTime1);
designer.reservationTimes.add(reservationTime2);
  • reservationTime1, reservationTime2 영속화 이후 designer 객체에 영속화된 reservationTime 객체를 추가해 준다.
  • fetch join을 통해서 데이터베이스에 query를 진행하고 해당 데이터가 영속성 컨텍스트에 이미 있으므로 데이터를 버리게 되더라도 이미 reservationTime1, reservationTime2가 존재하는 상태이기 때문에 문제가 되지 않는다.

(2) 양방향 연관 관계 편의 메서드

public void setDesigner(Designer designer) {
    if (this.designer != null) {
        this.designer.getReservationTimes().remove(this);
    }
    this.designer = designer;
    designer.getReservationTimes().add(this);
}
  • 영속화된 reservationTime 객체를 일일이 designer 객체에 추가해주는 것보다 양방향 연관 관계 편의 메서드를 통해 reservationTime 객체에서 designer를 추가해줄 때 양방향에 data를 동시에 추가해준다면 사전에 문제를 방지할 수 있다.

(3) 영속성 컨텍스트 초기화

@Test
@DisplayName("designer와 ReservationTimes 테이블을 fetch join 해서 가져올 수 있다.")
void findDesignerFetchJoinByHairshopIdAndDateTest() {
    // Given
		hairshop = Hairshop.builder()
        .name("데브헤어")
        .phoneNumber("010-1234-5678")
        .startTime("11:00")
        .endTime("20:00")
        .build();
    em.persist(hairshop);

    designer = Designer.builder()
        .name("디자이너1")
        .position(Position.DESIGNER)
        .introduction("안녕하세요")
        .image("이미지 URL")
        .build();
    em.persist(designer);

    reservationTime1 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime1);

    reservationTime2 = ReservationTime.builder()
        .date(LocalDate.now())
        .time("11:00")
        .reserved(false)
        .designer(designer)
        .build();
    em.persist(reservationTime2);
		
   em.flush();
   em.clear();

    // When
    List<Designer> designers = designerRepository.findDesignerFetchJoinByHairshopIdAndDate(
        hairshop.getId(), LocalDate.now());

    // Then
    assertThat(designers.get(0).getReservationTimes()).isIn(reservationTime1, reservationTime2);
}

 

em.flush();
em.clear();
  • em.flush()을 통해서 영속성 컨텍스트의 내용을 데이터베이스에 반영하고 em.clear()을 통해서 영속성 컨텍스트를 모두 초기화한다. (영속성 컨텍스트의 데이터를 모두 제거)
  • 영속성 컨텍스트가 초기화 되었기 때문에 JPQL의 Fetch Join의 결과가 모두 영속성 컨텍스트에 반영된다.

 

Reference

https://cheese10yun.github.io/jpa-persistent-context/

 

반응형

댓글