⇒ CouponMember 엔티티에서 Member 객체에 대해서는 지연로딩이 설정되어 있기 때문에 프록시 객체를 조회함 (DB로 쿼리는 전송하지 않지만 객체가 조회됨)
em.getReference()
- 데이터베이스 (실제) 조회를 미루는 가짜 엔티티 객체를 조회함 ⇒ 껍데기만 갖고 있음
- 프록시 객체 : 실제 객체의 참조 (target) 보관함 - 프록시 객체를 호출하게 되면 프록시 객체가 실제 객체의 메소드를 호출하는 것
그렇다면 왜 DB로 쿼리를 추가적으로 날리는 것인가?
위의 코드가 문제가 아닌 추가적인 코드에서 문제가 발생한 것
지연로딩 설정으로 프록시 객체로 조회했지만 실제 사용 시점에는 DB에 조회하는 쿼리를 날리기 때문!
실제 사용 시점 관련 코드
// 위에서 작성한 코드
Page<CouponMember> couponMembers = couponMemberRepository.findByCouponId(pageRequest, couponId);
// 위의 코드 실행 후 아래 코드가 실행됨 => 실제 사용 시점!
// CouponMember를 CouponMemberResponseDto로 변환
List<CouponMemberResponseDto> dtos = couponMembers.stream()
.map(couponMember -> CouponMapper.INSTANCE.toCouponMemberResponseDto(couponMember.getMember()))
.collect(Collectors.toList());
⇒ Member를 프록시 객체로 조회하고 couponMember.getMember() 를 통해서 실제 객체에 접근해야 할 때 DB에 조회하는 쿼리가 날라간 것임
N+1 문제로 인한 발생 가능 문제점
지금은 쿠폰을 보유한 회원이 3명뿐이여서 쿼리가 3개(n개) 추가적으로 날라갔지만 만약 쿠폰 보유 회원이 백만명이라면?
⇒ DB에 날리는 쿼리 수가 많아지면 많아질 수록 성능 저하가 심해질 것!
3. 해결 방법 및 결과 검증
3-1. 즉시 로딩 사용
“한 번에 가져오면 되지 않을까?” 라는 생각으로 즉시 로딩을 적용함
// CouponMember.java
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 적용
@JoinColumn(name = "member_id")
private Member member;
전송된 쿼리 결과 확인 ⇒ 해결 x
여전히 N+1 문제가 발생하는 이유는?
1. 조건에 맞는 CouponMember 객체를 조회함 2. Member 객체에 대해 fetch = FetchType.EAGER 임을 확인하고 JPA가 조회한 모든 CouponMember에 대해 Member를 조회함 ⇒ 이 때 의도하지 않은 3개 (n개) 의 쿼리가 발생하는 것!
3-2. fetch join 사용
3-2-1. fetch join 이란?
3-2-2. fetch join 사용하여 문제 해결
서비스 단의 메서드(위의 코드)에서 Member 객체를 바로 사용하기 때문에 (fetch) join 을 통해 한 번의 쿼리로 모두 가져오도록 해결해야 함
기존 레파지토리 코드
// CouponMemberRepository.java
Page<CouponMember> findByCouponId(Pageable pageable, Long couponId);
기존 메서드가 생성하는 쿼리 ( ? : 넘어오는 파라미터)
SELECT * FROM coupon_member cm
WHERE cm.coupon_id = ?
LIMIT ? OFFSET ?
fetch join 사용한 jpql 쿼리 적용
@Query("SELECT cm " +
"FROM CouponMember cm " +
"JOIN FETCH cm.member " +
"WHERE cm.coupon.id = :couponId")
Page<CouponMember> findByCouponId(@Param("couponId") Long couponId, Pageable pageable);
fetch join 결과 쿼리 로그 확인
join 문을 포함한 쿼리 1회 발생!
⇒ CouponMember와 Member를 join 한 쿼리가 단 한번만 나감을 확인함!
3-2-3. 실제 사용 하는 코드 ( couponMember.getMember() ) 는 변함이 없는데 왜 쿼리는 한 번만 나갈까?
조인 쿼리를 통해서 CouponMember와 관련된 Member 정보도 같이 db에서 조회함
db에서 쿼리 결과를 받으면 JPA가 결과를 엔티티 객체에 매핑함
각 행은 CouponMember 엔티티와 관련된 Member 엔티티로 매핑됨
JPA는 각 필드를 적절한 엔티티 필드에 바인딩함
매핑된 엔티티는 영속성 컨텍스트에 저장됨
여기서는 CouponMember 객체, Member객체가 모두 영속성 컨텍스트에 저장됨
이후에 실제사용 (참조) 할 때는 쿼리가 발생하지 않는 것!
3-2-4. 영속성 컨텍스트에 CouponMember 객체, Member 객체가 모두 저장되었는지 확인
// 기존 코드
Page<CouponMember> couponMembers = couponMemberRepository.findByCouponId(couponId, pageRequest);
// 영속성 컨텍스트에서 객체를 가져와 확인하는 코드
for (CouponMember couponMember : couponMembers.getContent()) {
// CouponMember가 영속성 컨텍스트에 있는지 확인
if (entityManager.contains(couponMember)) {
System.out.println("CouponMember ID " + couponMember.getId() + " is managed.");
} else {
System.out.println("CouponMember ID " + couponMember.getId() + " is NOT managed.");
}
// Member 객체가 영속성 컨텍스트에 있는지 확인
Member member = couponMember.getMember();
if (entityManager.contains(member)) {
System.out.println("Member ID " + member.getId() + " is managed.");
} else {
System.out.println("Member ID " + member.getId() + " is NOT managed.");
}
}
실행 결과
⇒ 모두 영속성 컨텍스트에 저장되어 있음을 확인!
4. 해당 도메인에서 발생한 유사한 문제 해결
동일한 N+1 문제에 대해서도 같은 방법으로 해결함
4-1. 1+n+n 회의 쿼리 발생 문제 해결
4-1-1. CouponService 와 CouponMemberRepository의 기존 코드와 문제점
// CouponService.java의 findAllCoupons() 메서드 중
Page<CouponMember> couponMembers;
if(includeInActiveCoupons) { // 비활성화 쿠폰 포함
couponMembers = couponMemberRepository.findByMemberId(pageRequest, loginMemberId);
} else { // 활성화 쿠폰만 조회
couponMembers = couponMemberRepository.findByMemberIdAndCouponActiveIsAndUsed(pageRequest, loginMemberId, true, false);
}
List<CouponResponseDto> dtos = new ArrayList<>();
// coupon -> List<CouponResponseDto>
for (CouponMember couponMember : couponMembers) {
CouponResponseDto couponResponseDto = CouponMapper.INSTANCE.toCouponResponseDto(couponMember.getCoupon());
couponResponseDto.setUsed(couponMember.isUsed()); // 사용 여부 설정
if(couponMember.isUsed()) couponResponseDto.setActive(false);
dtos.add(couponResponseDto);
}
// CouponMemberRepository.java 중
Page<CouponMember> findByMemberId(Pageable pageable, Long memberId);
Page<CouponMember> findByMemberIdAndCouponActiveIsAndUsed(Pageable pageable, Long memberId, boolean active, boolean used);
문제점
조건에 따라 CouponMember를 조회함 (쿼리 1회)
조회한 CouponMember 를 DTO로 변환하는 과정에서 Mapper 클래스로 쿠폰을 넘김 ⇒ 실제 사용! (couponMember.getCoupon() ⇒ 쿼리 n회)
Mapper 클래스 (Impl) 에서 카테고리 조회 ⇒ 실제 사용! (getCategory() ⇒ 쿼리 n회)
⇒ 총 1+n+n 회의 쿼리가 발생!
4-1-2. fetch join 사용한 JPQL 작성하여 문제 해결
// CouponService.java의 findAllCoupons() 메서드 중
Page<CouponMember> couponMembers;
if(includeInActiveCoupons) { // 비활성화 쿠폰 포함
couponMembers = couponMemberRepository.findByMemberIdWithCouponsAndCategories(loginMemberId, pageRequest);
} else { // 활성화 쿠폰만 조회
couponMembers = couponMemberRepository.findByMemberIdAndCouponActiveAndUsed(loginMemberId, true, false, pageRequest);
}
List<CouponResponseDto> dtos = new ArrayList<>();
// coupon -> List<CouponResponseDto>
for (CouponMember couponMember : couponMembers) {
CouponResponseDto couponResponseDto = CouponMapper.INSTANCE.toCouponResponseDto(couponMember.getCoupon());
couponResponseDto.setUsed(couponMember.isUsed()); // 사용 여부 설정
if(couponMember.isUsed()) couponResponseDto.setActive(false);
dtos.add(couponResponseDto);
}
// CouponMemberRepository.java 중
@Query("SELECT cm FROM CouponMember cm " +
"JOIN FETCH cm.coupon c " +
"LEFT JOIN FETCH c.category " +
"WHERE cm.member.id = :memberId " +
"ORDER BY c.expiredAt ASC")
Page<CouponMember> findByMemberIdWithCouponsAndCategories(@Param("memberId") Long memberId, Pageable pageable);
@Query("SELECT cm FROM CouponMember cm " +
"JOIN FETCH cm.coupon c " +
"LEFT JOIN FETCH c.category " +
"WHERE cm.member.id = :memberId AND c.active = :active AND cm.used = :used " +
"ORDER BY c.expiredAt ASC")
Page<CouponMember> findByMemberIdAndCouponActiveAndUsed(@Param("memberId") Long memberId, @Param("active") boolean active, @Param("used") boolean used, Pageable pageable);
결과 확인
활성화 쿠폰만 조회시 ⇒ n+1 문제 해결비활성화 쿠폰도 모두 조회 시 ⇒ n+1 문제 해결