이번에는 세미나의 신청인원을 확인하고, 결제내역을 확인하는 기능을 만들어보게 되었습니다.
이떄 발생하는 성능적인 문제로, JPA의 N+1 쿼리 문제를 마주하여 해결과정을 정리해보았습니다.
먼저 원인에 대해 알아보겠습니다.
JPA N+1이 발생하는 이유는 연관관계를 가진 엔티티를 조회할떄 발생할 수 있는 성능 이슈입니다. 1번의 쿼리에 추가적인 쿼리가 N번 발생하는 현상입니다. 사실상 1+N 이라고 생각해도 됩니다. 다양한 연관관계에서 발생하는데, @OneToMany, @ManyToOne, @OneToOne 에서 발생합니다. (사실상 모든 연관관계입니다.)
이 문제가 발생하는 이유는 지연로딩(Lazy Loading)에 있습니다. JPA에서 연관관계를 지연로딩으로 설정한경우, 엔티티를 조회할떄 연관된 엔티티들은 초기에 가져오지 않고 실제로 사용될때 비로소 추가적인 쿼리를 통해 가져오게 됩니다.
이러한 기본적인 원리는 JPA의 영속성 컨텍스트와 연관되어있습니다. 영속성 컨텍스트는 엔티티의 생명주기를 관리합니다. 엔티티가 영속상태로 전환되면, 영속성 컨텍스트에 의해 관리됩니다. 이 상태에서 데이터베이스와의 동기화가 이루어집니다. 호출하는 엔티티의 연관엔티티에 지연로딩이 적용되어있다면, 연관된 엔티티를 나타내는 프록시(Proxy)객체가 사용됩니다. 이 프록시(Proxy)객체는 실제 엔티티와 같은 인터페이스를 제공하며, 실제 사용시에만 데이터베이스에서 연관된 엔티티를 로딩합니다. 즉, 실제 만약 이미 영속화 한 엔티티에서 연관 엔티티를 호출 할시에 영속성 컨텍스트에 올라와있는 해당 Entity Proxy 가 영속화 되며 쿼리를 발생시키는 것입니다.
예를들어, 하나의 부모 엔티티와 여러개의 자식 엔티티가 있는경우, 부모 엔티티를 조회할때는 먼저 부모엔티티만 가져오고, 실제로 자식 엔티티를 사용할때 각각의 자식 엔티티에 대해 추가적인 쿼리가 발생하는것이다..
먼저, 오류가 발생하는 상황을 정리해보았고,
이후에는 JPA N+1 해결방안으로
1. fetchjoin()으로 해결하는 방안
- 우선 fetchjoin을 사용하는 이유는 엔티티의 연관관계는 일반적으로 지연로딩인데, 지연로딩이 아니라 특정상황에서 는 즉시로딩처럼 동작시키게 하기 위하여 사용합니다.
2. dto로 Join 시켜서 해결하는 방안
으로 해결방안을 정리해보았습니다.
1. Entity 입니다.
1-1 Seminar Entity 입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seminar_no;
@Column(length = 100, nullable = false, unique = true)
private String seminar_name;
@Column(length = 500)
private String seminar_explanation;
@Column
private Long seminar_price;
@Column
private Long seminar_maxParticipants;
@Column(nullable=true)
private LocalDateTime del_dt;
@OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
private List<Member_Seminar> member_seminar_list;
}
1-2 Member_Seminar Entity 입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member", "seminar", "payment"})
public class Member_Seminar extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long member_seminar_no;
@ManyToOne(targetEntity = Member.class, fetch = FetchType.LAZY)
@JoinColumn(name = "member_no")
private Member member;
@ManyToOne(targetEntity = Seminar.class, fetch = FetchType.LAZY)
@JoinColumn(name = "seminar_no")
private Seminar seminar;
@OneToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "payment_no", referencedColumnName = "payment_no")
private Payment payment;
@Column(nullable=true)
private LocalDateTime del_dt;
public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
public void setPayment(Payment payment){
this.payment = payment;
}
}
1-3. Payment Entity 입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
//@ToString(exclude = {"member_seminar"})
public class Payment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long payment_no;
@OneToOne(mappedBy = "payment", optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "member_seminar_no")
private Member_Seminar member_seminar;
@Column()
private Long amount;
@Column(nullable=true)
private LocalDateTime del_dt;
public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
public void setMember_seminar(Member_Seminar member_seminar){
this.member_seminar = member_seminar;
}
}
JPA N+1이 발생하는 상황
우선 Seminar List를 1개만 가져오는 상황에서 쿼리를 살펴보겠습니다.
1. SeminarQuerydslRepository.java에 세미나의 정보를 불러올 Repository 함수를 만듭니다.
public List<Seminar> getSeminar(long seminar_no){
QSeminar seminar = QSeminar.seminar;
List<Seminar> seminarEntity = queryFactory
.select(seminar)
.from(seminar)
.where(seminar.seminar_no.eq(seminar_no))
.fetch();
return seminarEntity;
}
위와 같이 특정 seminar_no를 호출해서 가져오는 함수를 선언합니다.
2. 테스트를 진행해보겠습니다.
@Transactional //Proxy 유지
@DisplayName("test getListForMember_SeminarAndPayment")
@Test
public void testgetListForMember_SeminarAndPayment() throws InterruptedException {
Long member_no = 1L;
Long seminar_no = 2000028L;
List<Seminar> seminar = seminarQuerydslRepository.getSeminar(seminar_no);
System.out.println(seminar.get(0).toString());
System.out.println(seminar.get(0).getMember_seminar_list());
}
3. SQL 로그를 확인해보겠습니다.
우리가 원하는 상황은 1개의 쿼리로 모든 정보를 불러와서 효율적인 쿼리를 원합니다.
시간은 437ms가 소요됩니다.
코드를 확인해보면 Seminar 1개를 호출할시에
Seminar 1번, Member_Seminar 1번, Payment N번을 호출하고있습니다.
현재로써는 페이징 처리도 안한 상태에서 하나의 Seminar에 대해서만 작업을 진행하고있기에 괜찮지만,
만약 여러개의 데이터를 가져올 경우에는 Member_Seminar와 Payment가 추가로 SQL을 호출하기에 너무나 많은 서버부하가 일어납니다.
여기서 member_seminar는 왜 딱 1번만 추가로 호출되는 이유는 Seminar 와 Member_Seminar는 @OneToMany 관계로 List형태로 연결되어있기에 단 한번의 호출로도 Seminar와 연관된 모든 값을 가져옵니다.
여기서 Payment가 N번 호출되는 이유는 member_seminar와 Payment가 @OneToOne 관계로 1:1 관계이기에 그렇습니다.
Join Fetch를 활용하여 JPA 1+N 번 문제를 해결
1. SeminarQuerydslRepository.java에 세미나의 정보를 불러올 Repository 함수를 만듭니다.
fetchjoin을 활용합니다.
public List<Seminar> getSeminarFetchJoin(long seminar_no){
QSeminar seminar = QSeminar.seminar;
QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
QPayment payment = QPayment.payment;
List<Seminar> seminarEntity = queryFactory
.select(seminar)
.from(seminar)
.leftJoin(seminar.member_seminar_list, member_seminar)
.fetchJoin()
.leftJoin(member_seminar.payment, payment)
.fetchJoin()
.where(seminar.seminar_no.eq(seminar_no))
.fetch();
return seminarEntity;
}
2. 테스트를 진행합니다.
@Transactional //Proxy 유지
@DisplayName("test getListForMember_SeminarAndPaymentFetchJoin")
@Test
public void testgetListForMember_SeminarAndPaymentWithFetchJoin() throws InterruptedException {
Long member_no = 1L;
Long seminar_no = 2000028L;
List<Seminar> seminar = seminarQuerydslRepository.getSeminarFetchJoin(seminar_no);
System.out.println(seminar.get(0).toString());
for(int i=0;i<seminar.get(0).getMember_seminar_list().size();i++){
System.out.println("----------------------------------------");
System.out.println(seminar.get(0).getMember_seminar_list().get(i).toString());
System.out.println(seminar.get(0).getMember_seminar_list().get(i).getPayment().toString());
}
}
3. SQL 로그를 확인해보겠습니다.
우리가 원하는 상황은 1개의 쿼리로 모든 정보를 불러와서 효율적인 쿼리를 원합니다.
400ms 가 소요되었습니다.
단 1가지의 쿼리로 모든 내용을 출력하고 우리가 원하는 대로 되었습니다.
DTO 호출을 통해 JPA 1+N 번 문제를 해결
DTO로 호출하는 방안은 Service Layer에서 이후의 논리적인 로직을 처리하지 못하기에, 단순 조회성 데이터를 마주칠떄 사용합니다. 또한, 원하는 데이터만 querydsl의 Projections.field 를 활용하여 가져올 수 있어 불필요 데이터는 가져오지 않아 단순조회에는 효율적일 수 있습니다.
1. 가져올 데이터를 보여줄 DTO 인 Seminar_Member_Seminar_PaymentResponseDTO.class 를 만듭니다.
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Seminar_Member_Seminar_PaymentResponseDTO implements Serializable {
@Schema(description = "seminar_no")
private Long seminar_no;
@Schema(description = "seminar_name")
private String seminar_name;
@Schema(description = "seminar_explanation")
private String seminar_explanation;
@Schema(description = "seminar_explanation")
private Long seminar_price;
@Schema(description = "inst_dt")
private LocalDateTime seminar_inst_dt;
@Schema(description = "updt_dt")
private LocalDateTime seminar_updt_dt;
@Schema(description = "del_dt")
private LocalDateTime seminar_del_dt;
@Schema(description = "member_seminar_no")
private Long member_seminar_no;
@Schema(description = "member_no")
private Long member_no;
@Schema(description = "member_inst_dt")
private LocalDateTime member_inst_dt;
@Schema(description = "member_updt_dt")
private LocalDateTime member_updt_dt;
@Schema(description = "member_del_dt")
private LocalDateTime member_del_dt;
@Schema(description = "payment_no")
private Long payment_no;
@Schema(description = "amount")
private Long amount;
@Schema(description = "payment_inst_dt")
private LocalDateTime payment_inst_dt;
@Schema(description = "payment_updt_dt")
private LocalDateTime payment_updt_dt;
@Schema(description = "payment_del_dt")
private LocalDateTime payment_del_dt;
}
2. SeminarQuerydslRepository.java에 세미나의 정보를 불러올 Repository 함수를 만듭니다. querydsl의 Projections.field를 활용합니다.
public List<Seminar_Member_Seminar_PaymentResponseDTO> getListForMember_SeminarAndPayment(long seminar_no){
QSeminar seminar = QSeminar.seminar;
QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
QPayment payment = QPayment.payment;
List<Seminar_Member_Seminar_PaymentResponseDTO> list = queryFactory
.select(Projections.fields(Seminar_Member_Seminar_PaymentResponseDTO.class,
seminar.seminar_no.as("seminar_no"),
seminar.seminar_name.as("seminar_name"),
seminar.seminar_explanation.as("seminar_explanation"),
seminar.seminar_price.as("seminar_price"),
member_seminar.member_seminar_no.as("member_seminar_no"),
member_seminar.member.member_no.as("member_no"),
payment.payment_no,
payment.amount))
.from(seminar)
.leftJoin(seminar.member_seminar_list, member_seminar)
.leftJoin(member_seminar.payment, payment)
.where(seminar.seminar_no.eq(seminar_no))
.fetch();
return list;
}
3. 테스트코드입니다.
@Transactional //Proxy 유지
@DisplayName("test getListForMember_SeminarAndPayment With DTO")
@Test
public void testgetListSeminarWithFetchJoinWIthDTO() throws InterruptedException {
Long seminar_no = 2000028L;
List<Seminar_Member_Seminar_PaymentResponseDTO> seminar = seminarQuerydslRepository.getListForMember_SeminarAndPayment(seminar_no);
System.out.println("CNT:"+seminar.size());
for(int i=0;i<seminar.size();i++){
System.out.println("---------------------------------------");
System.out.println("Seminar:"+seminar.get(i).toString());
seminar.get(i).getMember_seminar_no();
seminar.get(i).getMember_no();
seminar.get(i).getSeminar_explanation();
seminar.get(i).getSeminar_price();
seminar.get(i).getSeminar_explanation();
seminar.get(i).getMember_seminar_no();
seminar.get(i).getMember_no();
seminar.get(i).getPayment_no();
seminar.get(i).getAmount();
}
}
417ms 가 걸렸습니다.
SQL 로그를 확인해보겠습니다.
left join을 진행하여 DTO에 한번에 Projections하고 있습니다.
이로써 테스트를 마치겠습니다.
테스트 환경은 ( Seminar )1개 - 1 : N - ( Member_Seminar ) 40개 - 1 : 1 - ( Payment ) 40개 입니다.
각 기능별 성능을 비교해보면,
1. N+1 발생하는 문제일시에는 437ms
2. fetchjoin 활용할시에는 400ms
3. DTO를 활용할시에는 417ms 가 걸렸습니다.
현재 테스트 데이터는 작은 수의 데이터를 기준으로 진행하여 속도차이가 작지만, 데이터가 더 많아질수록 SQL호출 횟수의 차이는 성능적으로 유의미한 결과를 낼 것 입니다.
이로써 특정 세미나의 참여회원정보와 결제정보를 조회하는 기능의 성능을 개선해보았습니다.