본문 바로가기

Project

[JPA] N+1문제 정의 + 해결 전략

JPA N+1 문제 해결하기: 단계별 가이드

1. N+1 문제가 뭘까요? 

N+1 문제를 이해하기 위해 먼저 실제 상황을 살펴보겠습니다.

예를 들어 병원 근무표 신청 시스템이 있다고 할게요:

  • 근무자가 근무 신청을 합니다 (Request)
  • 각 근무자는 특정 병동에 소속되어 있죠 (WardMember)
  • 근무자의 정보도 있습니다 (Member)
@Transactional
public List<WardRequestResponseDto> readWardRequest(Member member) {
    Ward myWard = member.getWardMember().getWard();
    return requestRepository.findByWardMember_Ward(myWard)
        .stream()
        .map(WardRequestResponseDto::of)
        .toList();
}

이 코드가 실행되면 어떤 일이 일어날까요?

우선 myWard 를 조회하는 과정을 살펴보면 다음과 같습니다.
member(1) : wardMemeber(1)
wardMember(N): ward(1)
ward 를 지목하는 과정에서 항상 단일 객체를 지목하므로 문제가 없습니다.

2. 문제 상황 이해하기 👀

DTO를 변환하는 과정을 보면:

public static WardRequestResponseDto of(Request request) {
    return WardRequestResponseDto.builder()
       .memberId(request.getWardMember().getMember().getMemberId())
       .name(request.getWardMember().getMember().getName())
       .date(request.getRequestDate())
       .shift(String.valueOf(request.getRequestShift()))
       .memo(request.getMemo())
       .status(request.getStatus().getValue())
       .build();
}

실제로 실행되는 쿼리를 보면:

  1. 첫 번째 쿼리: 모든 Request 조회
  2. SELECT * FROM request WHERE ward_member_id IN ( SELECT ward_member_id FROM ward_member WHERE ward_id = ? )
  3. Request가 5개라면?
  • 각 Request마다 WardMember 조회: 5번
  • 각 WardMember마다 Member 조회: 5번
  • 총 11번의 쿼리가 실행됨! (1 + 5 + 5)

이것이 바로 N+1 문제입니다.
실제 쿼리를 보면 다음과 같아요 .

SELECT r.*
FROM request r 
INNER JOIN ward_member wm ON r.ward_member_id = wm.ward_member_id
WHERE wm.ward_id = ?

-- 1번째 Request의 WardMember 조회
SELECT wm.*
FROM ward_member wm
WHERE wm.ward_member_id = ?

-- 2번째 Request의 WardMember 조회
SELECT wm.*
FROM ward_member wm
WHERE wm.ward_member_id = ?
-- 5번 반복
-- 1번째 WardMember의 Member 조회
SELECT m.*
FROM member m
WHERE m.member_id = ?

-- 2번째 WardMember의 Member 조회
SELECT m.*
FROM member m
WHERE m.member_id = ?
---5번 반복

3. 해결 방법: @EntityGraph 사용하기

JPA에서는 이 문제를 해결하기 위한 좋은 도구를 제공합니다.
바로 @EntityGraph 또는 fetch join입니다!
이 포스트에선 fetch join 을 다뤄볼게요.

@Query("SELECT DISTINCT r FROM Request r " +
       "LEFT JOIN FETCH r.wardMember wm " +
       "LEFT JOIN FETCH wm.member m " +
       "WHERE wm.ward = :ward")
List<Request> findByWardMember_Ward(@Param("ward") Ward ward);

left join 을 사용한 이유는 request는 유효한 상황에서 해당 member 가 병동을 탈퇴할 수 있기 때문입니다.

이렇게 하면:

  1. Request, WardMember, Member를 한 번의 쿼리로 가져옵니다
  2. 실행되는 SQL은:
  3. SELECT r.*, wm.*, m.* FROM request r LEFT OUTER JOIN ward_member wm ON r.ward_member_id = wm.ward_member_id LEFT OUTER JOIN member m ON wm.member_id = m.member_id WHERE wm.ward_id = ?

4. 코드 개선하기 💪

서비스 코드도 조금 개선해볼까요?

    @Transactional
    public List<WardRequestResponseDto> readWardRequest(Member member) {
        if (!String.valueOf(member.getRole()).equals("HN"))
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "관리자만 접근할 수 있는 요청입니다.");
        Ward myWard = member.getWardMember().getWard();
        return requestRepository.findAllWardRequests(myWard)
            .stream()
            .map(WardRequestResponseDto::of)
            .toList();
    }

5. 정리 📝

N+1 문제는:

  1. 연관 관계가 있는 엔티티를 조회할 때 발생
  2. 최초 1번의 쿼리 이후 N번의 추가 쿼리가 발생하는 현상
  3. 성능에 매우 안 좋은 영향을 미침

이렇게 하면 N+1 문제를 깔끔하게 해결할 수 있습니다! 🎉

실습해보기 🔥

  1. 여러분의 프로젝트에서 비슷한 상황을 찾아보세요
  2. 실제로 실행되는 쿼리를 확인해보세요 (log로 확인)

이렇게 하나씩 따라하다 보면 어려웠던 N+1 문제도 자연스럽게 해결할 수 있습니다!

💡 Tip

  • 항상 실행되는 쿼리를 먼저 확인하세요
  • 연관 관계가 있는 엔티티를 조회할 때는 N+1 문제를 의심해보세요