1. 문제 상황 파악
- 쿠폰 보유 회원 목록 조회 시 CouponMember 테이블에서 특정 쿠폰 id에 해당하는 객체를 가져오는 메서드 호출함
// CouponAdminService.java 의 findCouponMembers() 메서드 내부
Page<CouponMember> couponMembers = couponMemberRepository.findByCouponId(pageRequest, couponId);
// CouponMemberRepository.java
Page<CouponMember> findByCouponId(Pageable pageable, Long couponId);
- 의도한 바는 쿠폰id를 비교해서 가져오는 쿼리 한 번 발생하는 것임
⇒ 그러나 의도한 쿼리 이외에 쿼리가 3개 더 생성되었음!
- 추가적으로 발생한 3개의 쿼리는 쿠폰을 보유한 회원을 조회하는 쿼리임을 확인
2. 문제 발생 조건 파악
// CouponAdminService.java 의 findCouponMembers() 메서드 내부
Page<CouponMember> couponMembers = couponMemberRepository.findByCouponId(pageRequest, couponId);
// CouponMemberRepository.java
Page<CouponMember> findByCouponId(Pageable pageable, Long couponId);
- 위의 코드까지는 쿼리가 의도한대로 (1번) 실행됨을 확인함
// CouponMember.java
@ManyToOne(fetch = FetchType.LAZY) // 단방향 다대일 연관관계
@JoinColumn(name = "member_id")
private Member member;
⇒ CouponMember 엔티티에서 Member 객체에 대해서는 지연로딩이 설정되어 있기 때문에 프록시 객체를 조회함
(DB로 쿼리는 전송하지 않지만 객체가 조회됨)
em.getReference()
- 데이터베이스 (실제) 조회를 미루는 가짜 엔티티 객체를 조회함
⇒ 껍데기만 갖고 있음
- 프록시 객체 : 실제 객체의 참조 (target) 보관함
- 프록시 객체를 호출하게 되면 프록시 객체가 실제 객체의 메소드를 호출하는 것
- 그렇다면 왜 DB로 쿼리를 추가적으로 날리는 것인가?
- 위의 코드가 문제가 아닌 추가적인 코드에서 문제가 발생한 것
- 지연로딩 설정으로 프록시 객체로 조회했지만 실제 사용 시점에는 DB에 조회하는 쿼리를 날리기 때문!
- 실제 사용 시점 관련 코드
⇒ Member를 프록시 객체로 조회하고 couponMember.getMember() 를 통해서 실제 객체에 접근해야 할 때 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());
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 결과 쿼리 로그 확인
⇒ 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);
- 결과 확인
'Trouble Shooting' 카테고리의 다른 글
[Trouble Shooting/성능 개선] Redis로 캐싱 도입해 채팅 전송 및 조회 성능 개선 (1) | 2025.01.03 |
---|---|
[Trouble Shooting/성능 개선] QueryDSL 로 no-offset 페이지네이션 구현하여 조회 성능 개선 (0) | 2024.12.31 |
[Trouble Shooting] 단위 테스트 작성 시 발생한 JpaAuditingHandler 빈 생성 오류 해결 (1) | 2024.12.30 |