1. 문제인식
프로젝트를 진행중 Reservation 엔티티와 Review 엔티티는
예약을 한 사용자만 리뷰를 작성할 수 있도록 일대일 관계로 매핑되어있습니다.
@Entity
@Getter
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
private Long id;
...
@OneToOne(mappedBy = "reservation")
private Review review;
}
@Entity
@Getter
@Table(name = "rental_home_review")
public class Review extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "rental_home_review_id")
private Long id;
...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
private Reservation reservation;
}
이때 다음과 같이 숙소와 예약을 fetch join하여 가져왔고 review 엔티티를 사용하지 않았음에도
다음과같은 N+1 문제가 발생하였습니다.
@Test
public void 숙소_예약_패치조인() {
RentalHome rh = queryFactory.selectFrom(rentalHome)
.join(rentalHome.reservations).fetchJoin()
.where(rentalHome.id.eq(6486L))
.fetchOne();
boolean rentalHomeLoaded = emf.getPersistenceUnitUtil().isLoaded(rh);
assertThat(rentalHomeLoaded).isTrue();
boolean reservationsLoaded2 = emf.getPersistenceUnitUtil().isLoaded(rh.getReservations());
assertThat(reservationsLoaded2).isTrue();
}
해당 숙소의 reservation - review의 개수는 17개였기 때문에 17개의 예상치 못한 추가 쿼리가 발생하였습니다.
리뷰의 개수가 많아질 수록 치명적인 성능 저하 문제를 발생시킬 수 있기 때문에 이에 대해 다뤄보고자 합니다.
먼저 이 문제는 일대일 양방향 참조에서 프록시 객체 생성과 관련된 문제입니다.
2. Hibernate의 프록시 객체
2.1. Proxy란?
먼저 Hibernate의 프록시 객체를 알아보기전 프록시에 대해 먼저 알아 보겠습니다.
Proxy란 '대리자'를 뜻합니다. 이는
마치 차주가 아니지만 차주인냥 차주를 대신해 차를 운전해주는 대리운전에서의 대리와 의미가 같습니다.
이처럼 프록시 객체는 진짜 객체를 대리하는 가짜 객체로서 진짜 객체를 상속받습니다.
프록시 객체는 주로 진짜 객체의 작업에 추가적인 작업을 하고싶거나,
일부의 기능만 변경하고자 할때 생성합니다.
이전에 간단한 DBConnectionPool을 구현해보는 과정에서
실제 Connection 객체의 close() 메서드의 연결을 해제하는 메서드를
ConnectionPool에 반환하는 기능으로 변경하고자 Proxy를 활용한 경험이 있어 이해를 위해 url을 남겨놓았습니다.
2.2 Hibernate의 proxy 객체
위에서 설명했듯 하이버네이트는 실제 객체의 작업에 지연로딩을 추가로 작업하고자 proxy 객체를 사용하는것으로 보입니다.
이 proxy객체를 이용하여 해당 엔티티가 실제로 DB에서 조회되는 시점을 지연시키는 역할을 합니다.
즉, 하이버네이트는 지연 로딩을 사용하는 연관관계 자리에 프록시 객체를 주입하여 실제 객체가 들어있는 것처럼 동작하도록 합니다.
이때 프록시 객체는 데이터베이스로부터 실제 엔티티 데이터를 가져오기 전까지는 최소한의 정보를 가지고 있는데
이에 PK로 사용되는 ID값이 포함되어 엔티티 클래스의 ID값을 가져올때는 추가적인 쿼리가 발생하지 않습니다.
@Entity
@Getter
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
private Long id;
...
@OneToOne(mappedBy = "reservation")
private Review review; //프록시 객체 주입
}
2.2. proxy 객체 초기화
프록시 객체는 기본적으로 엔티티의 ID만을 가지고 있고, 초기화되기 전까지는 나머지 데이터가 비어 있습니다.
따라서 해당 객체를 사용하는 시점에 DB를 조회하여 데이터를 채워 넣습니다. 이를 프록시 초기화라고 합니다.
(참고로 객체의 사용은 toString 메서드도 포함되기 때문에 엔티티에서
lombok의 @toString이나 @Data 어노테이션 사용은 지양해야합니다.)
프록시 객체는 영속성 컨텍스트(EntityManager)가 살아있을 때만 정상적으로 초기화됩니다.
따라서 영속성 컨텍스트의 관리를 받지 못하는 상황, 즉 준영속 상태의 프록시를 초기화 한다거나
일반적으로 트랜잭션 바깥에서 프록시를 초기화 하려 하는 경우 LazyInitializationException
이 발생할 수 있습니다.
3. 일대일 양방향 관계에서 프록시 초기화
일대일 양방향 관계에서 프록시 생성이 어려운 이유는 외래 키(FK) 관리와 프록시 객체 생성 시점에서의 문제때문입니다.
3.1 프록시 생성에 충돌이 발생
일대일 관계에서 각 엔티티는 서로의 엔티티를 참조하게 됩니다.
즉, Reservation은 Review를 참조하고, Review도 Reservation을 참조합니다.
하지만 Hibernate가 지연 로딩을 위해 프록시 객체를 생성할 때, 양쪽 엔티티가 모두 프록시로 감싸져야 하는 상황이 발생합니다.
이때 한쪽 엔티티가 프록시객체로 로딩되면, 다른 쪽 엔티티 역시 프록시로 감싸져야 하지만,
일대일 양방향 관계이기 때문에 관계의 주종을 제대로 관리하는 데 어려움이 생깁니다.
저의 케이스의 경우 Review가 주인이고 Reservation이 종속되는 관계인데(예약에 반드시 리뷰가 존재해야하는것이 아니기 때문에)
하이버네이트 입장에서 어떤 Entity를 프록시 객체로 로딩해야하는지 혼란스럽다고 판단하는 것으로 보입니다.
이러한 혼란은 프록시 객체를 생성하지 않고 즉시 로딩으로 처리하게 만듭니다.
또한 외래키를 가진 엔티티(여기서는 Review)는 해당 관계를 즉시 로딩하는 경향이 있어 Reservation을 조회할 때
Review가 바로 로딩되어 N+1문제가 발생한 것으로 보여집니다.
4. 해결방법
4.1. 양방향 관계를 단방향 관계로
이 문제의 원인은 위에서 설명했듯 양방향 참조로 인해 관계의 주종을 관리하는데의 모호함으로
프록시 객체생성이 어렵다는 점입니다.
reservation에서 review로 바로 접근이 어렵다는 불편함이 있지만
관계를 단방향 관계로 변경하여 해결할 수 있습니다.
@Entity
@Getter
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
private Long id;
...
}
@Entity
@Getter
@Table(name = "rental_home_review")
public class Review extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "rental_home_review_id")
private Long id;
...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
private Reservation reservation;
}
4.2. 일대다 또는 다대일 + 제약조건
관계를 조정하고 제약조건을 추가함으로써 일대일, 다대일 관게에서도 데이터의 무결성을 지킬 수 있습니다.
@UniqueConstraint(columnNames = "reservation_id"): reservation_id에 고유 제약을 걸어
여전히 하나의 Reservation에 하나의 Review만 존재하도록 제한할 수 있습니다.
@Entity
@Getter
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
private Long id;
...
@OneToOne(mappedBy = "reservation")
private Review review;
}
@Entity
@Getter
@Table(name = "rental_home_review", uniqueConstraints = {
@UniqueConstraint(columnNames = "reservation_id")
})
public class Review extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "rental_home_review_id")
private Long id;
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
private Reservation reservation;
}
결론
이러한 N+1 문제는 사실 예상치 못했습니다.
하지만 문제를 해결해나가는 과정에서 JPA를 한번더 깊게 알아보고
하이버네이트가 프록시 객체를 생성하여 지연로딩을 구현해내는 방식등을 공부하면서
기술에 대한 놀라움과 이해한것에 대한 뿌듯함도 느끼게되어 재미있는 시간이었습니다.
'Java > JPA' 카테고리의 다른 글
@DataJpaTest를 활용한 Repository 테스트 오류 (0) | 2024.11.16 |
---|---|
AWS 프리티어 환경에서 데이터 20만건 조회하기 (0) | 2024.09.22 |
JPA 복잡한 쿼리 작성하기 (0) | 2024.09.20 |